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
+19 -3
View File
@@ -6,6 +6,7 @@ use super::{
thread, trace, warn,
};
use crate::message::server::MessageFactory;
use crate::peer::DownloadError;
const CONNECT_SWEEP_INTERVAL: Duration = Duration::from_secs(1);
@@ -148,6 +149,7 @@ impl Client {
download.status,
DownloadStatus::InProgress { .. }
| DownloadStatus::Completed
| DownloadStatus::Cancelled
) {
debug!(
"[client] transfer token {} already claimed; \
@@ -210,6 +212,7 @@ impl Client {
download.size, filename
);
}
Err(DownloadError::Cancelled) => {}
Err(e) => {
let reason = Some(e.to_string());
let _ = download.sender.send(
@@ -363,7 +366,14 @@ impl Client {
}
});
if let Some((old_token, download)) = download_to_update
let cancelled = download_to_update
.as_ref()
.is_some_and(|(_, d)| {
matches!(d.status, DownloadStatus::Cancelled)
});
if !cancelled
&& let Some((old_token, download)) =
download_to_update
{
trace!(
"[client] UpdateDownloadTokens found {old_token}, transfer: {:?}",
@@ -386,10 +396,16 @@ impl Client {
let registry = context.peer_registry.clone();
drop(context);
if let Some(registry) = registry {
let response =
let response = if cancelled {
MessageFactory::build_transfer_denial_message(
transfer.token,
"Cancelled",
)
} else {
MessageFactory::build_transfer_response_message(
transfer,
);
)
};
let _ = registry.send_to_peer(
&username,
PeerMessage::SendMessage(response),