Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 10 additions & 18 deletions crates/rds-sync/tests/session_v2.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,19 @@
//! `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;

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<u8> {
Expand All @@ -42,7 +33,7 @@ fn random_bytes(len: usize, seed: u64) -> Vec<u8> {
/// reports each serve outcome back to the test.
fn spawn_server_v2(
ep: Endpoint,
dir: PathBuf,
dir: Scratch,
outcomes: mpsc::Sender<String>,
) -> tokio::task::JoinHandle<()> {
tokio::spawn(async move {
Expand Down Expand Up @@ -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:#}"),
Expand All @@ -92,7 +84,7 @@ fn spawn_server_v2(
async fn pair() -> (
Endpoint,
EndpointAddr,
PathBuf,
Scratch,
mpsc::Receiver<String>,
tokio::task::JoinHandle<()>,
) {
Expand Down
60 changes: 60 additions & 0 deletions crates/rds-sync/tests/support/scratch.rs
Original file line number Diff line number Diff line change
@@ -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<Root>);

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::<u128>()));
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<Path> 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"
);
}
29 changes: 11 additions & 18 deletions crates/rds-sync/tests/sync_e2e.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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.
Expand All @@ -43,7 +35,7 @@ fn random_bytes(len: usize, seed: u64) -> Vec<u8> {

/// 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 {
Expand All @@ -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);
});
}
});
Expand All @@ -69,7 +62,7 @@ async fn pair() -> (
Endpoint,
EndpointAddr,
tokio::task::JoinHandle<()>,
PathBuf,
Scratch,
) {
let config = EndpointConfig {
discovery: false,
Expand Down
25 changes: 25 additions & 0 deletions docs/reports/rds-sync-test-scratch-20261007.md
Original file line number Diff line number Diff line change
@@ -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.
Loading