feat: cancel a download from the TUI, the CLI, and the daemon

Closes #54.

A download can now be cancelled whether it is queued, in progress, or
paused. Cancelled is a terminal status: the store refuses to overwrite
it, the status channel relays nothing after it, the partial file is
deleted, and the transfer thread releases its socket at its next
one-second poll instead of the 30s read deadline. A queued download that
is cancelled declines the peer's later offer with TransferResponse
"Cancelled", and an inbound file connection for it is refused. A fresh
download of the same file evicts the dead row so it is not shadowed,
and a cancelled transfer is not re-queued after a restart.

Surfaces: Client::cancel_download, download.cancel over RPC, `transfer
cancel <user> <path>` on the CLI (which also stops an upload by that
name), and x in the transfers pane, where d dismisses the row.
This commit is contained in:
Michel de Graaf
2026-09-06 21:40:45 +02:00
parent 7bc696cab6
commit aa61a1a434
38 changed files with 1139 additions and 102 deletions
+74
View File
@@ -27,6 +27,7 @@ const EXIT_OK: i32 = 0;
const EXIT_USAGE: i32 = 2;
const EXIT_CONNECTION: i32 = 3;
const EXIT_NO_RESULTS: i32 = 4;
const EXIT_TRANSFER: i32 = 6;
const EXIT_SESSION_LOST: i32 = 7;
/// Build a command for the binary, isolated from the developer's environment
@@ -1554,6 +1555,79 @@ fn browse_lists_shares_as_paths_download_understands() {
assert_eq!(fields[1], probe_bytes().len().to_string());
}
#[test]
fn transfer_cancel_without_a_matching_transfer_is_the_no_results_verdict() {
let server = server_or_skip!();
let output = cli(
&server,
"cli_e2e_canceller",
&["transfer", "cancel", "nobody", "nothing.mp3"],
);
assert_eq!(
code(&output),
EXIT_NO_RESULTS,
"stderr: {}",
stderr(&output)
);
assert!(
stderr(&output).contains("nothing.mp3"),
"the verdict names the transfer: {}",
stderr(&output)
);
}
#[test]
fn transfer_cancel_stops_a_download_another_client_is_waiting_on() {
if mode() != Mode::Daemon {
println!("skipped: a one-shot download owns its own session");
return;
}
let server = server_or_skip!();
let mut all = server.args("cli_e2e_cancel_waiter");
all.extend(
[
"download",
"e2e_ghost_peer",
"@@ghost\\never.mp3",
"--size",
"10",
"--timeout",
"60",
]
.iter()
.map(|a| (*a).to_string()),
);
let refs: Vec<&str> = all.iter().map(String::as_str).collect();
let waiting = command(&refs)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("the binary should start");
let deadline = Instant::now() + Duration::from_secs(15);
let mut cancelled = None;
while Instant::now() < deadline {
let output = cli(
&server,
"cli_e2e_cancel_waiter",
&["transfer", "cancel", "e2e_ghost_peer", "@@ghost\\never.mp3"],
);
if code(&output) == EXIT_OK {
cancelled = Some(output);
break;
}
std::thread::sleep(Duration::from_millis(500));
}
assert!(
cancelled.is_some(),
"the queued transfer should be cancellable"
);
let waited = waiting.wait_with_output().expect("wait");
assert_eq!(code(&waited), EXIT_TRANSFER, "stderr: {}", stderr(&waited));
assert!(stderr(&waited).contains("cancelled"), "{}", stderr(&waited));
}
#[test]
fn download_fetches_a_file_and_prints_where_it_landed() {
let server = server_or_skip!();