fix(peer): reset cancelled outbound file streams

Cancelled outbound transfers previously returned from the streaming loop without
terminating the QUIC send half. A whole-file receiver relies on the stream
ending to distinguish EOF from an in-progress body, so cancellation could leave
it waiting on a truncated transfer until its own timeout fired.

Reset the send stream on every cancellation branch, including cancellation
while waiting for the final close acknowledgement. A reset is deliberately used
instead of a graceful close so truncated whole-file transfers cannot be
misinterpreted as a valid EOF.

Test Plan:
- just test
- just clippy
- git diff --check

Refs: Claude review finding #1
This commit is contained in:
2026-05-30 15:57:14 +02:00
parent 738095235f
commit f89ff9ceea
+15
View File
@@ -4,6 +4,7 @@ use bytes::Bytes;
use lanspread_db::db::GameFileDescription; use lanspread_db::db::GameFileDescription;
use lanspread_utils::maybe_addr; use lanspread_utils::maybe_addr;
use s2n_quic::{ use s2n_quic::{
application,
connection, connection,
stream::{Error as StreamError, SendStream}, stream::{Error as StreamError, SendStream},
}; };
@@ -14,6 +15,16 @@ use tokio::{
use crate::{config::FILE_TRANSFER_BUFFER_SIZE, path_validation::validate_game_file_path}; use crate::{config::FILE_TRANSFER_BUFFER_SIZE, path_validation::validate_game_file_path};
fn cancel_send_stream(tx: &mut SendStream, remote_addr: impl std::fmt::Display, path: &Path) {
// Reset instead of finishing so truncated whole-file transfers cannot look like EOF.
if let Err(err) = tx.reset(application::Error::UNKNOWN) {
log::debug!(
"{remote_addr} failed to reset cancelled transfer for {}: {err}",
path.display()
);
}
}
#[allow(clippy::too_many_lines)] #[allow(clippy::too_many_lines)]
async fn stream_file_bytes( async fn stream_file_bytes(
tx: &mut SendStream, tx: &mut SendStream,
@@ -52,6 +63,7 @@ async fn stream_file_bytes(
"{remote_addr} transfer cancelled for {}", "{remote_addr} transfer cancelled for {}",
validated_path.display() validated_path.display()
); );
cancel_send_stream(tx, remote_addr, &validated_path);
return Err(eyre::eyre!("File transfer cancelled by user")); return Err(eyre::eyre!("File transfer cancelled by user"));
} }
@@ -67,6 +79,7 @@ async fn stream_file_bytes(
"{remote_addr} transfer cancelled for {}", "{remote_addr} transfer cancelled for {}",
validated_path.display() validated_path.display()
); );
cancel_send_stream(tx, remote_addr, &validated_path);
return Err(eyre::eyre!("File transfer cancelled by user")); return Err(eyre::eyre!("File transfer cancelled by user"));
} }
res = file.read(&mut buf[..read_len]) => { res = file.read(&mut buf[..read_len]) => {
@@ -86,6 +99,7 @@ async fn stream_file_bytes(
"{remote_addr} transfer cancelled for {}", "{remote_addr} transfer cancelled for {}",
validated_path.display() validated_path.display()
); );
cancel_send_stream(tx, remote_addr, &validated_path);
return Err(eyre::eyre!("File transfer cancelled by user")); return Err(eyre::eyre!("File transfer cancelled by user"));
} }
res = tx.send(Bytes::copy_from_slice(&buf[..bytes_read])) => { res = tx.send(Bytes::copy_from_slice(&buf[..bytes_read])) => {
@@ -132,6 +146,7 @@ async fn stream_file_bytes(
tokio::select! { tokio::select! {
() = cancel_token.cancelled() => { () = cancel_token.cancelled() => {
log::info!("{remote_addr} transfer cancelled while closing stream"); log::info!("{remote_addr} transfer cancelled while closing stream");
cancel_send_stream(tx, remote_addr, &validated_path);
return Err(eyre::eyre!("File transfer cancelled by user")); return Err(eyre::eyre!("File transfer cancelled by user"));
} }
res = tx.close() => { res = tx.close() => {