Skip to content

Commit 0c7ba64

Browse files
Nic-dormanclaude
andcommitted
refactor(data): address review on streaming download (#111)
- TempDownload RAII guard: removes the staging file on every disk-path error AND on a panic unwind out of the block_in_place decrypt loop, replacing three duplicated cleanup arms (#1). drop(file) before rename for Windows. - New Error::Cancelled variant for a dropped receiver; was misclassified as Error::Network (#3). Routed to ApplicationError in classify_error so caller-initiated cancellation is not retried as a transport failure. - Doc the exact channel item type Result<Bytes, Error> on file_download_to_sender (#4). - Drop now-stale #[allow(clippy::unused_async)] on file_download (#7). - Harden e2e test: assert each streamed chunk is non-empty and >=2 segments arrive (multi-batch property), rename to test_file_download_to_sender_multibatch_round_trip (#6). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent aa7c4e4 commit 0c7ba64

4 files changed

Lines changed: 112 additions & 43 deletions

File tree

ant-core/src/data/client/file.rs

Lines changed: 77 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,55 @@ fn spawn_file_encryption(path: PathBuf) -> Result<EncryptionChannels> {
778778
Ok((chunk_rx, datamap_rx, handle))
779779
}
780780

781+
/// RAII guard for the staging temp file used during a disk download.
782+
///
783+
/// Removes the file on drop — including a panic unwind out of the
784+
/// `block_in_place` decrypt loop — unless [`commit`](Self::commit) has
785+
/// promoted it to its final path. Centralizes the cleanup the explicit error
786+
/// arms used to repeat.
787+
struct TempDownload {
788+
/// `Some` while the staging file may need cleanup; `None` once committed.
789+
path: Option<PathBuf>,
790+
}
791+
792+
impl TempDownload {
793+
fn new(path: PathBuf) -> Self {
794+
Self { path: Some(path) }
795+
}
796+
797+
/// Path of the staging file (valid until `commit`).
798+
fn path(&self) -> &Path {
799+
self.path
800+
.as_deref()
801+
.expect("TempDownload::path called after commit")
802+
}
803+
804+
/// Rename the staged file to `dest`. On success the guard is defused so
805+
/// `Drop` is a no-op; on failure the guard stays armed and `Drop` removes
806+
/// the orphaned temp file.
807+
fn commit(mut self, dest: &Path) -> std::io::Result<()> {
808+
std::fs::rename(self.path(), dest)?; // err → guard armed → Drop cleans up
809+
self.path = None; // success → nothing left to clean
810+
Ok(())
811+
}
812+
}
813+
814+
impl Drop for TempDownload {
815+
fn drop(&mut self) {
816+
if let Some(path) = self.path.take() {
817+
if let Err(e) = std::fs::remove_file(&path) {
818+
// Absent file is fine (never created / already gone).
819+
if e.kind() != std::io::ErrorKind::NotFound {
820+
warn!(
821+
"Failed to remove temp download file {}: {e}",
822+
path.display()
823+
);
824+
}
825+
}
826+
}
827+
}
828+
}
829+
781830
impl Client {
782831
/// Upload a file to the network using streaming self-encryption.
783832
///
@@ -2189,7 +2238,6 @@ impl Client {
21892238
///
21902239
/// Returns an error if any chunk cannot be retrieved, decryption fails,
21912240
/// or the file cannot be written.
2192-
#[allow(clippy::unused_async)]
21932241
pub async fn file_download(&self, data_map: &DataMap, output: &Path) -> Result<u64> {
21942242
self.file_download_with_progress(data_map, output, None)
21952243
.await
@@ -2574,7 +2622,8 @@ impl Client {
25742622
///
25752623
/// Same as [`Client::file_download`] but sends [`DownloadEvent`]s for UI
25762624
/// feedback. Streams to a temp file (one decrypt batch resident at a time)
2577-
/// and renames atomically on success.
2625+
/// and renames atomically on success. A [`TempDownload`] guard removes the
2626+
/// staging file on any error path, including a panic.
25782627
pub async fn file_download_with_progress(
25792628
&self,
25802629
data_map: &DataMap,
@@ -2587,47 +2636,28 @@ impl Client {
25872636
let unique: u64 = rand::random();
25882637
let tmp_path = parent.join(format!(".ant_download_{}_{unique}.tmp", std::process::id()));
25892638

2590-
let mut file = std::fs::File::create(&tmp_path)?;
2591-
let write_result = self
2639+
// Guard removes the staging file on any early return OR a panic unwind
2640+
// out of the `block_in_place` decrypt loop; defused only by a
2641+
// successful commit(). Centralizes what used to be three duplicated
2642+
// cleanup arms.
2643+
let tmp = TempDownload::new(tmp_path);
2644+
let mut file = std::fs::File::create(tmp.path())?;
2645+
2646+
let bytes_written = self
25922647
.download_decrypted_chunks(data_map, progress, |bytes| {
25932648
let r = file.write_all(&bytes).map_err(Error::from);
25942649
std::future::ready(r)
25952650
})
2596-
.await
2597-
.and_then(|bytes_written| {
2598-
file.flush()?;
2599-
Ok(bytes_written)
2600-
});
2651+
.await?;
2652+
file.flush()?;
2653+
drop(file); // close the handle before rename (Windows won't rename an open file)
26012654

2602-
match write_result {
2603-
Ok(bytes_written) => match std::fs::rename(&tmp_path, output) {
2604-
Ok(()) => {
2605-
info!(
2606-
"File downloaded: {bytes_written} bytes written to {}",
2607-
output.display()
2608-
);
2609-
Ok(bytes_written)
2610-
}
2611-
Err(rename_err) => {
2612-
if let Err(cleanup_err) = std::fs::remove_file(&tmp_path) {
2613-
warn!(
2614-
"Failed to remove temp download file {}: {cleanup_err}",
2615-
tmp_path.display()
2616-
);
2617-
}
2618-
Err(rename_err.into())
2619-
}
2620-
},
2621-
Err(e) => {
2622-
if let Err(cleanup_err) = std::fs::remove_file(&tmp_path) {
2623-
warn!(
2624-
"Failed to remove temp download file {}: {cleanup_err}",
2625-
tmp_path.display()
2626-
);
2627-
}
2628-
Err(e)
2629-
}
2630-
}
2655+
tmp.commit(output)?;
2656+
info!(
2657+
"File downloaded: {bytes_written} bytes written to {}",
2658+
output.display()
2659+
);
2660+
Ok(bytes_written)
26312661
}
26322662

26332663
/// Download and decrypt a file, streaming the plaintext to `sink` instead
@@ -2637,7 +2667,14 @@ impl Client {
26372667
/// receives bytes progressively as each batch decrypts, suitable for
26382668
/// forwarding to an HTTP chunked body or a gRPC response stream. The
26392669
/// bounded `sink` applies backpressure. If the receiver is dropped (e.g.
2640-
/// the client disconnected) the download stops early and returns an error.
2670+
/// the client disconnected) the download stops early and returns
2671+
/// [`Error::Cancelled`].
2672+
///
2673+
/// The channel item type is `Result<Bytes, Error>`, so the caller sets up:
2674+
///
2675+
/// ```ignore
2676+
/// let (tx, rx) = tokio::sync::mpsc::channel::<Result<Bytes, Error>>(8);
2677+
/// ```
26412678
///
26422679
/// Typically the caller `tokio::spawn`s this and converts the matching
26432680
/// `Receiver` into its response stream. Requires a multi-threaded Tokio
@@ -2653,7 +2690,7 @@ impl Client {
26532690
async move {
26542691
sink.send(Ok(bytes))
26552692
.await
2656-
.map_err(|_| Error::Network("download stream receiver dropped".into()))
2693+
.map_err(|_| Error::Cancelled("download stream receiver dropped".into()))
26572694
}
26582695
})
26592696
.await

ant-core/src/data/client/mod.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,10 @@ use tracing::debug;
4747
/// chunks could not be stored)
4848
/// - `AlreadyStored`, `Encryption`, `Crypto`, `Payment`,
4949
/// `Serialization`, `InvalidData`, `SignatureVerification`,
50-
/// `Config`, `InsufficientDiskSpace`, `CostEstimationInconclusive`
51-
/// -> `ApplicationError` (would happen on a perfectly healthy link)
50+
/// `Config`, `InsufficientDiskSpace`, `CostEstimationInconclusive`,
51+
/// `Cancelled` -> `ApplicationError` (would happen on a perfectly
52+
/// healthy link; `Cancelled` is caller-initiated and must not be retried
53+
/// as a transport failure)
5254
pub(crate) fn classify_error(err: &Error) -> Outcome {
5355
match err {
5456
Error::Timeout(_) => Outcome::Timeout,
@@ -68,6 +70,7 @@ pub(crate) fn classify_error(err: &Error) -> Outcome {
6870
| Error::Config(_)
6971
| Error::InsufficientDiskSpace(_)
7072
| Error::CostEstimationInconclusive(_)
73+
| Error::Cancelled(_)
7174
| Error::BadQuoteBinding { .. } => Outcome::ApplicationError,
7275
}
7376
}
@@ -679,6 +682,7 @@ mod tests {
679682
| Error::AlreadyStored
680683
| Error::InsufficientDiskSpace(_)
681684
| Error::CostEstimationInconclusive(_)
685+
| Error::Cancelled(_)
682686
| Error::PartialUpload { .. }
683687
| Error::BadQuoteBinding { .. } => (),
684688
};

ant-core/src/data/error.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,14 @@ pub enum Error {
6060
#[error("encryption error: {0}")]
6161
Encryption(String),
6262

63+
/// The operation was cancelled by the caller rather than failing.
64+
///
65+
/// Returned, for example, by streaming downloads when the consumer drops
66+
/// its receiver (a client disconnect) — distinct from a transport
67+
/// [`Error::Network`] failure, since nothing went wrong on the wire.
68+
#[error("operation cancelled: {0}")]
69+
Cancelled(String),
70+
6371
/// Data already exists on the network — no payment needed.
6472
#[error("already stored on network")]
6573
AlreadyStored,
@@ -207,6 +215,15 @@ mod tests {
207215
assert_eq!(err.to_string(), "encryption error: decrypt failed");
208216
}
209217

218+
#[test]
219+
fn test_display_cancelled() {
220+
let err = Error::Cancelled("download stream receiver dropped".to_string());
221+
assert_eq!(
222+
err.to_string(),
223+
"operation cancelled: download stream receiver dropped"
224+
);
225+
}
226+
210227
#[test]
211228
fn test_display_insufficient_disk_space() {
212229
let err = Error::InsufficientDiskSpace("need 100 MB but only 10 MB available".to_string());

ant-core/tests/e2e_file.rs

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ async fn test_file_upload_download_round_trip() {
7070
/// batch, then reassembles the stream and asserts equality with the source.
7171
#[tokio::test(flavor = "multi_thread")]
7272
#[serial]
73-
async fn test_file_download_to_sender_streaming() {
73+
async fn test_file_download_to_sender_multibatch_round_trip() {
7474
use tokio::sync::mpsc;
7575

7676
let (client, testnet) = setup().await;
@@ -92,8 +92,13 @@ async fn test_file_download_to_sender_streaming() {
9292
let dl = tokio::spawn(async move { client.file_download_to_sender(&data_map, tx, None).await });
9393

9494
let mut streamed: Vec<u8> = Vec::with_capacity(data.len());
95+
let mut chunk_count = 0usize;
9596
while let Some(item) = rx.recv().await {
9697
let chunk = item.expect("stream chunk should be Ok");
98+
// A buggy "send one empty/sentinel then drop" producer would still
99+
// close the channel; assert each delivered chunk carries real bytes.
100+
assert!(!chunk.is_empty(), "streamed chunk should be non-empty");
101+
chunk_count += 1;
97102
streamed.extend_from_slice(&chunk);
98103
}
99104

@@ -102,6 +107,12 @@ async fn test_file_download_to_sender_streaming() {
102107
.expect("download task should join")
103108
.expect("file_download_to_sender should succeed");
104109

110+
// The whole point of the streaming path: a multi-batch payload must arrive
111+
// as more than one segment, not buffered and emitted in one shot.
112+
assert!(
113+
chunk_count >= 2,
114+
"multi-batch payload should stream as ≥2 segments, got {chunk_count}"
115+
);
105116
assert_eq!(streamed, data, "streamed content should match original");
106117
assert_eq!(
107118
bytes_streamed,

0 commit comments

Comments
 (0)