diff --git a/src/services/storage/providers/rclone/helpers.rs b/src/services/storage/providers/rclone/helpers.rs index ae106e8..7466399 100644 --- a/src/services/storage/providers/rclone/helpers.rs +++ b/src/services/storage/providers/rclone/helpers.rs @@ -133,7 +133,17 @@ pub async fn rcat(config_path: &Path, target: &str, mut stream: RcloneStream) -> while let Some(chunk) = stream.next().await { // A stream error is ours, not rclone's — report it directly. - let chunk = chunk.context("backup stream failed")?; + let chunk = match chunk { + Ok(c) => c, + Err(e) => { + // Returning here would drop stdin and hand rclone an EOF, which it + // treats as a complete stream — finalizing a truncated object that + // looks like a good backup. Kill it instead. + let _ = child.start_kill(); + let _ = child.wait().await; + return Err(e).context("backup stream failed"); + } + }; // A write error means rclone already exited. Stop pumping and let the // exit status below produce the real reason; surfacing the broken-pipe diff --git a/src/tests/storage/rclone.rs b/src/tests/storage/rclone.rs index 1c28dd6..29fbe06 100644 --- a/src/tests/storage/rclone.rs +++ b/src/tests/storage/rclone.rs @@ -251,5 +251,11 @@ async fn rcat_reports_rclone_stderr_when_the_remote_is_unreachable() { msg.contains("rclone rcat failed"), "the broken stdin pipe must not mask rclone's own error: {msg}" ); - assert!(!msg.trim().ends_with("failed"), "rclone stderr must be included: {msg}"); + let (_, stderr_part) = msg + .rsplit_once(": ") + .expect("bail message must carry rclone stderr after the exit status"); + assert!( + !stderr_part.trim().is_empty(), + "rclone stderr must be included: {msg}" + ); }