diff --git a/crates/rds-sync/tests/session_v2.rs b/crates/rds-sync/tests/session_v2.rs index ee17db5..9327f24 100644 --- a/crates/rds-sync/tests/session_v2.rs +++ b/crates/rds-sync/tests/session_v2.rs @@ -2,7 +2,6 @@ //! `SyncTransferV2` route — Hello/HelloAck limit exchange, transfer-ID //! bound frames, typed Cancel propagation, and version/tag refusal. -use std::path::PathBuf; use std::sync::mpsc; use std::time::Duration; @@ -10,20 +9,12 @@ use rds_core::{HelloAck, StreamHello}; use rds_net::{Endpoint, EndpointAddr, EndpointConfig, bind_endpoint, read_frame, write_frame}; use rds_sync::engine::{Access, Transfer}; -/// Temp dir per test — unique per process + name. -fn scratch(name: &str) -> PathBuf { - static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - let dir = std::env::temp_dir().join(format!( - "rds-v2-{name}-{}-{}-{}", - std::process::id(), - NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::create_dir(&dir).unwrap(); - dir +#[path = "support/scratch.rs"] +mod scratch; +use scratch::Scratch; + +fn scratch(name: &str) -> Scratch { + Scratch::new(&format!("rds-v2-{name}")) } fn random_bytes(len: usize, seed: u64) -> Vec { @@ -42,7 +33,7 @@ fn random_bytes(len: usize, seed: u64) -> Vec { /// reports each serve outcome back to the test. fn spawn_server_v2( ep: Endpoint, - dir: PathBuf, + dir: Scratch, outcomes: mpsc::Sender, ) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { @@ -71,13 +62,14 @@ fn spawn_server_v2( .serve( conn, (send, recv), - dir, + dir.to_path_buf(), Access::READ_WRITE, Duration::from_secs(60), ) .await } .await; + drop(dir); let _ = outcomes.send(match outcome { Ok(()) => "ok".into(), Err(e) => format!("err:{e:#}"), @@ -92,7 +84,7 @@ fn spawn_server_v2( async fn pair() -> ( Endpoint, EndpointAddr, - PathBuf, + Scratch, mpsc::Receiver, tokio::task::JoinHandle<()>, ) { diff --git a/crates/rds-sync/tests/support/scratch.rs b/crates/rds-sync/tests/support/scratch.rs new file mode 100644 index 0000000..04078a8 --- /dev/null +++ b/crates/rds-sync/tests/support/scratch.rs @@ -0,0 +1,60 @@ +//! Synthetic roots stay alive until their last asynchronous fixture owner exits. +use std::{ + ops::Deref, + path::{Path, PathBuf}, + sync::Arc, +}; + +#[derive(Clone)] +pub struct Scratch(Arc); + +struct Root(PathBuf); + +impl Scratch { + pub fn new(prefix: &str) -> Self { + assert!(!prefix.contains(['/', '\\'])); + let path = std::env::temp_dir().join(format!("{prefix}-{:032x}", rand::random::())); + std::fs::create_dir(&path).unwrap(); + Self(Arc::new(Root(path))) + } +} + +impl Deref for Scratch { + type Target = Path; + fn deref(&self) -> &Path { + &self.0.0 + } +} + +impl AsRef for Scratch { + fn as_ref(&self) -> &Path { + self + } +} + +impl Drop for Root { + fn drop(&mut self) { + // remove_dir_all does not follow fixture-created symlinks. Cleanup is + // best-effort and must not panic again while unwinding a failed test. + let _ = std::fs::remove_dir_all(&self.0); + } +} + +#[test] +fn the_last_owner_removes_the_root_without_following_aliases() { + let outside = Scratch::new("rds-scratch-outside"); + std::fs::write(outside.join("retained"), b"outside this root").unwrap(); + let root = Scratch::new("rds-scratch-owner"); + let path = root.to_path_buf(); + #[cfg(unix)] + std::os::unix::fs::symlink(&outside, root.join("alias")).unwrap(); + let worker = root.clone(); + drop(root); + assert!(path.is_dir(), "a fixture worker still owns this root"); + drop(worker); + assert!(!path.exists(), "the finished fixture leaked its root"); + assert_eq!( + std::fs::read(outside.join("retained")).unwrap(), + b"outside this root" + ); +} diff --git a/crates/rds-sync/tests/sync_e2e.rs b/crates/rds-sync/tests/sync_e2e.rs index 02c9dd0..fe707c4 100644 --- a/crates/rds-sync/tests/sync_e2e.rs +++ b/crates/rds-sync/tests/sync_e2e.rs @@ -3,7 +3,7 @@ //! journals, corrupt parts, traversal rejection, and an impaired-link //! lane that proves the resume overhead stays small. -use std::path::{Path, PathBuf}; +use std::path::Path; use std::sync::Arc; use std::time::Duration; @@ -12,20 +12,12 @@ use rds_sync::engine::{recv_file, send_file, serve}; use rds_sync::proto::{check_manifest, check_rel_path}; use rds_sync::{Manifest, manifest_of}; -/// Temp dir per test — unique per process + name. -fn scratch(name: &str) -> PathBuf { - static NEXT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); - let dir = std::env::temp_dir().join(format!( - "rds-sync-{name}-{}-{}-{}", - std::process::id(), - NEXT.fetch_add(1, std::sync::atomic::Ordering::Relaxed), - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::create_dir(&dir).unwrap(); - dir +#[path = "support/scratch.rs"] +mod scratch; +use scratch::Scratch; + +fn scratch(name: &str) -> Scratch { + Scratch::new(&format!("rds-sync-{name}")) } /// Deterministic pseudo-random content — reproducible per size/seed. @@ -43,7 +35,7 @@ fn random_bytes(len: usize, seed: u64) -> Vec { /// Server half: accept one conn, run `serve` on each accepted bi /// stream — mirrors the agent's per-stream dispatch. -fn spawn_server(ep: Endpoint, dir: PathBuf) -> tokio::task::JoinHandle<()> { +fn spawn_server(ep: Endpoint, dir: Scratch) -> tokio::task::JoinHandle<()> { tokio::spawn(async move { while let Some(incoming) = ep.accept().await { let conn = match incoming.await { @@ -56,7 +48,8 @@ fn spawn_server(ep: Endpoint, dir: PathBuf) -> tokio::task::JoinHandle<()> { let conn = conn.clone(); let dir = dir.clone(); tokio::spawn(async move { - let _ = serve(conn, send, recv, dir).await; + let _ = serve(conn, send, recv, dir.to_path_buf()).await; + drop(dir); }); } }); @@ -69,7 +62,7 @@ async fn pair() -> ( Endpoint, EndpointAddr, tokio::task::JoinHandle<()>, - PathBuf, + Scratch, ) { let config = EndpointConfig { discovery: false, diff --git a/docs/reports/rds-sync-test-scratch-20261007.md b/docs/reports/rds-sync-test-scratch-20261007.md new file mode 100644 index 0000000..d60112e --- /dev/null +++ b/docs/reports/rds-sync-test-scratch-20261007.md @@ -0,0 +1,25 @@ +# Owned sync test scratch — 2026-10-07 + +The v1 and negotiated-session integration fixtures created unique temporary +roots and returned only PathBuf. Those roots survived test completion and +accumulated synthetic transfer files across repeated local qualification. + +A shared test-only Scratch owner now removes each exclusive root when its last +Arc owner drops. Serving tasks retain a clone while awaiting asynchronous engine +work; the root survives cancellation/worker ownership and is released when the +fixture runtime shuts down. Paths joined under the root remain ordinary paths; +production sync, journal durability, metadata and conflict semantics are unchanged. +No directory is selected for cleanup by matching a pre-existing name. + +The lifetime regression checks retained worker ownership, last-owner removal and +symlink confinement: removing an owned fixture does not remove its outside alias +target. Both complete integration suites passed locally on macOS (24 tests total), +including cancellation, corrupt parts, torn journals, resume, repeated connection +kills, path substitution and impaired transfer. Strict Clippy and supported CI +remain required before merge. Historical scratch directories and failed incident +evidence are not silently deleted by this change. + +The relevant [Arc ownership semantics](https://doc.rust-lang.org/1.98.1/std/sync/struct.Arc.html) +and [remove_dir_all symlink behavior](https://doc.rust-lang.org/1.98.1/std/fs/fn.remove_dir_all.html) +are established standard-library behavior. This uses no new dependency or +background cleanup daemon and makes no process-kill/power-loss cleanup guarantee.