diff --git a/Cargo.lock b/Cargo.lock index 2ed8faf..c4ec080 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3400,6 +3400,7 @@ name = "rds-bench" version = "0.1.0" dependencies = [ "anyhow", + "blake3", "clap", "ed25519-dalek", "iroh-relay", diff --git a/crates/rds-bench/Cargo.toml b/crates/rds-bench/Cargo.toml index 7c4c279..aaa36e6 100644 --- a/crates/rds-bench/Cargo.toml +++ b/crates/rds-bench/Cargo.toml @@ -14,6 +14,7 @@ transport-noq = ["rds-net/transport-noq", "dep:noq"] [dependencies] rds-observe.workspace = true anyhow.workspace = true +blake3.workspace = true clap.workspace = true ed25519-dalek.workspace = true iroh-relay = { workspace = true, features = ["server"] } diff --git a/crates/rds-bench/src/lib.rs b/crates/rds-bench/src/lib.rs index 9603f46..a4c0ea7 100644 --- a/crates/rds-bench/src/lib.rs +++ b/crates/rds-bench/src/lib.rs @@ -13,4 +13,5 @@ pub mod capacity; pub mod impair; pub mod report; pub mod scenario; +mod transfer; pub mod world; diff --git a/crates/rds-bench/src/scenario.rs b/crates/rds-bench/src/scenario.rs index a57c6e1..a3d7ec5 100644 --- a/crates/rds-bench/src/scenario.rs +++ b/crates/rds-bench/src/scenario.rs @@ -63,7 +63,7 @@ pub enum Scenario { Handshake, /// Established connection, N ping probes, RTT percentiles. Ping, - /// Bulk stream throughput to a TCP discard target. + /// Bulk stream goodput through receiver byte/digest acknowledgement. Transfer, /// Handshake under 5% loss (interop `multiconnect` analogue). Multiconnect, @@ -83,7 +83,7 @@ impl Scenario { match self { Scenario::Handshake => "handshake", Scenario::Ping => "ping", - Scenario::Transfer => "transfer", + Scenario::Transfer => "transfer-receiver-ack-v1", Scenario::Multiconnect => "multiconnect", Scenario::RelayFallback => "relay-fallback", Scenario::Impaired => "impaired", @@ -262,43 +262,56 @@ async fn ping( }) } -/// `transfer_mib` MiB over one forwarded stream to a discard sink. +/// `transfer_mib` MiB over one forwarded stream, ending at a verified receipt. async fn transfer(p: &Params) -> anyhow::Result { - let world = World::spawn(Path::Direct, p.transport_backend()?) - .await - .context("spawn world")?; - let conn = tokio::time::timeout( + let total = p + .transfer_mib + .checked_mul(1024 * 1024) + .context("transfer size overflow")?; + anyhow::ensure!(total > 0, "transfer size must be positive"); + anyhow::ensure!(!p.timeout.is_zero(), "transfer timeout must be positive"); + let world = tokio::time::timeout( p.timeout, - rds_cli::connect(&world.client, world.target.clone()), + World::spawn(Path::Direct, p.transport_backend()?), ) .await - .context("connect timed out")? - .context("connect failed")?; - let (host, port) = world.discard_target(); - let (mut send, _recv) = rds_cli::open_tcp(&conn, &host, port).await?; - let chunk = vec![0xABu8; 256 * 1024]; - let mut written = 0u64; - let total = p.transfer_mib * 1024 * 1024; - let t0 = Instant::now(); - while written < total { - let n = (total - written).min(chunk.len() as u64) as usize; - send.write_all(&chunk[..n]).await?; - written += n as u64; - } - send.finish()?; - // Wait until the receiver has drained: finish() returns when our - // side is done sending; add a grace read timeout on recv to bound it. - let elapsed = t0.elapsed(); - let mib_s = written as f64 / (1024.0 * 1024.0) / elapsed.as_secs_f64(); - let metrics = world.metrics_snapshot(Some(&conn)); - world.close().await; + .context("world startup timed out")? + .context("spawn world")?; + // A single deadline covers connect, OpenTcp, upload, receipt and EOF. + let outcome = tokio::time::timeout(p.timeout, async { + let conn = rds_cli::connect(&world.client, world.target.clone()) + .await + .context("connect failed")?; + let (host, port) = world.transfer_target(); + let (mut send, mut recv) = rds_cli::open_tcp(&conn, &host, port).await?; + let measurement = crate::transfer::send_verified(&mut send, &mut recv, total).await?; + anyhow::Ok((measurement, world.metrics_snapshot(Some(&conn)))) + }) + .await + .context("transfer operation timed out") + .and_then(|result| result); + let cleanup = tokio::time::timeout(Duration::from_secs(5), world.close()).await; + let (measurement, mut metrics) = outcome?; + cleanup.context("world shutdown timed out")?; + let mib_s = measurement.bytes as f64 / (1024.0 * 1024.0) / measurement.elapsed.as_secs_f64(); + metrics.insert("transfer_verified_bytes".into(), measurement.bytes); + metrics.insert( + "transfer_completion_ns".into(), + measurement + .elapsed + .as_nanos() + .try_into() + .unwrap_or(u64::MAX), + ); + let mut notes = proxy_note(&world); + notes.push("receiver-ack-v1: byte count + BLAKE3 digest + EOF; payload generation/hash, upload and receipt are timed; connect/OpenTcp excluded; not comparable to historical sender-finish results".into()); Ok(BenchReport { - meta: meta("transfer", p, world.path_label(), None), + meta: meta(Scenario::Transfer.name(), p, world.path_label(), None), rtt: None, throughput_mib_s: Some(mib_s), attempts: None, metrics, - notes: proxy_note(&world), + notes, }) } @@ -492,3 +505,56 @@ fn impairment_of(path: &Path) -> Option { _ => None, } } + +#[cfg(test)] +mod tests { + use super::*; + + async fn verified_transfer(backend: &str) { + let p = Params { + transfer_mib: 1, + backend: backend.into(), + ..Params::default() + }; + let report = tokio::time::timeout(Duration::from_secs(40), transfer(&p)) + .await + .unwrap() + .unwrap(); + assert_eq!(report.meta.scenario, "transfer-receiver-ack-v1"); + assert_eq!(report.metrics["transfer_verified_bytes"], 1024 * 1024); + assert!(report.metrics["transfer_completion_ns"] > 0); + let rate = report.throughput_mib_s.unwrap(); + assert!(rate.is_finite() && rate > 0.0); + } + + #[tokio::test] + async fn transfer_receipt_crosses_real_iroh_and_forwarded_tcp() { + verified_transfer("iroh").await; + } + + #[cfg(feature = "transport-noq")] + #[tokio::test] + async fn transfer_receipt_crosses_real_noq_and_forwarded_tcp() { + verified_transfer("noq").await; + } + + #[tokio::test] + async fn invalid_transfer_parameters_fail_before_startup() { + for p in [ + Params { + transfer_mib: 0, + ..Params::default() + }, + Params { + transfer_mib: u64::MAX, + ..Params::default() + }, + Params { + timeout: Duration::ZERO, + ..Params::default() + }, + ] { + assert!(transfer(&p).await.is_err()); + } + } +} diff --git a/crates/rds-bench/src/transfer.rs b/crates/rds-bench/src/transfer.rs new file mode 100644 index 0000000..b721bc5 --- /dev/null +++ b/crates/rds-bench/src/transfer.rs @@ -0,0 +1,197 @@ +//! Benchmark-only receipt: big-endian u64 byte count, BLAKE3 digest, EOF. +//! This is the local TCP target's protocol, not an RDS wire extension. + +use std::time::{Duration, Instant}; + +use anyhow::{Context, ensure}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; + +const CHUNK_BYTES: usize = 64 * 1024; + +pub(crate) struct Measurement { + pub bytes: u64, + pub elapsed: Duration, +} + +/// Time payload generation, hashing, upload and receiver completion together. +/// The caller owns the absolute operation deadline and both streams. +pub(crate) async fn send_verified( + send: &mut (impl AsyncWrite + Unpin), + recv: &mut (impl AsyncRead + Unpin), + total: u64, +) -> anyhow::Result { + ensure!(total > 0, "transfer payload must be nonempty"); + let mut generator = blake3::Hasher::new(); + generator.update(b"rds-bench-transfer/v1"); + let mut generator = generator.finalize_xof(); + let mut digest = blake3::Hasher::new(); + let mut chunk = vec![0; CHUNK_BYTES]; + let started = Instant::now(); + let mut written = 0; + while written < total { + let n = (total - written).min(CHUNK_BYTES as u64) as usize; + // Position-dependent data also exposes duplicated/reordered chunks. + generator.fill(&mut chunk[..n]); + digest.update(&chunk[..n]); + send.write_all(&chunk[..n]).await.context("write payload")?; + written += n as u64; + } + send.shutdown().await.context("finish payload")?; + let mut receipt = [0; 40]; + recv.read_exact(&mut receipt) + .await + .context("read receiver receipt")?; + ensure!( + receipt[..8] == total.to_be_bytes(), + "receiver byte count mismatch" + ); + ensure!( + digest.finalize().eq(&receipt[8..]), + "receiver digest mismatch" + ); + ensure!( + recv.read(&mut [0]).await.context("read receipt EOF")? == 0, + "trailing receiver receipt data" + ); + Ok(Measurement { + bytes: total, + elapsed: started.elapsed(), + }) +} + +/// Acknowledge only after all payload bytes and FIN have been consumed. +pub(crate) async fn receive( + socket: &mut (impl AsyncRead + AsyncWrite + Unpin), +) -> anyhow::Result<()> { + let mut buffer = vec![0; CHUNK_BYTES]; + let mut received = 0u64; + let mut digest = blake3::Hasher::new(); + loop { + let n = socket.read(&mut buffer).await?; + if n == 0 { + break; + } + received = received + .checked_add(n as u64) + .context("receiver count overflow")?; + digest.update(&buffer[..n]); + } + socket.write_all(&received.to_be_bytes()).await?; + socket.write_all(digest.finalize().as_bytes()).await?; + socket.shutdown().await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn large_send_window_cannot_complete_before_receiver_ack() { + let total = CHUNK_BYTES as u64 * 2 + 17; + let (sender, mut receiver) = tokio::io::duplex(total as usize + 1); + let (mut read, mut write) = tokio::io::split(sender); + let work = send_verified(&mut write, &mut read, total); + tokio::pin!(work); + // The entire body fits the transport buffer. A sender-only timer + // would already finish, while the receiver has consumed nothing. + tokio::select! { + biased; + result = &mut work => panic!("completed without receipt: {:?}", result.err()), + () = tokio::time::sleep(Duration::from_millis(20)) => {}, + } + let (sent, received) = tokio::time::timeout(Duration::from_secs(2), async { + tokio::join!(work, receive(&mut receiver)) + }) + .await + .unwrap(); + received.unwrap(); + let sent = sent.unwrap(); + assert_eq!(sent.bytes, total); + assert!(sent.elapsed >= Duration::from_millis(20)); + } + + #[tokio::test] + async fn invalid_or_missing_receipts_never_produce_throughput() { + for fault in [ + "count", + "digest", + "truncated", + "trailing", + "missing", + "corrupt_body", + "short_body", + "reordered_body", + ] { + let (sender, mut receiver) = tokio::io::duplex(1024); + let (mut read, mut write) = tokio::io::split(sender); + let (result, ()) = tokio::time::timeout(Duration::from_secs(2), async { + tokio::join!(send_verified(&mut write, &mut read, 257), async { + let mut body = Vec::new(); + receiver.read_to_end(&mut body).await.unwrap(); + assert_eq!(body.len(), 257); + match fault { + "corrupt_body" => body[0] ^= 1, + "short_body" => { + body.pop(); + } + "reordered_body" => body.rotate_left(1), + _ => {} + } + let mut receipt = (body.len() as u64).to_be_bytes().to_vec(); + receipt.extend_from_slice(blake3::hash(&body).as_bytes()); + match fault { + "count" => receipt[7] ^= 1, + "digest" => receipt[8] ^= 1, + "truncated" => { + receipt.pop(); + } + "trailing" => receipt.push(0), + "missing" => receipt.clear(), + "corrupt_body" | "short_body" | "reordered_body" => {} + _ => unreachable!(), + } + receiver.write_all(&receipt).await.unwrap(); + receiver.shutdown().await.unwrap(); + }) + }) + .await + .unwrap(); + assert!(result.is_err(), "accepted {fault} receipt"); + } + } + + #[tokio::test] + async fn missing_receipt_or_eof_remains_subject_to_the_operation_deadline() { + for ack_before_stall in [false, true] { + let (sender, mut receiver) = tokio::io::duplex(1024); + let (mut read, mut write) = tokio::io::split(sender); + let (result, ()) = tokio::time::timeout(Duration::from_secs(2), async { + tokio::join!( + tokio::time::timeout( + Duration::from_millis(20), + send_verified(&mut write, &mut read, 257) + ), + async { + let mut body = Vec::new(); + receiver.read_to_end(&mut body).await.unwrap(); + if ack_before_stall { + receiver + .write_all(&(body.len() as u64).to_be_bytes()) + .await + .unwrap(); + receiver + .write_all(blake3::hash(&body).as_bytes()) + .await + .unwrap(); + } + // Keep the stream alive without EOF until the deadline. + } + ) + }) + .await + .unwrap(); + assert!(result.is_err()); + } + } +} diff --git a/crates/rds-bench/src/world.rs b/crates/rds-bench/src/world.rs index 07393fc..688d460 100644 --- a/crates/rds-bench/src/world.rs +++ b/crates/rds-bench/src/world.rs @@ -8,9 +8,8 @@ use std::sync::Arc; use rds_agent::{Agent, AgentPolicy}; use rds_net::{Endpoint, EndpointAddr, EndpointConfig, TransportAddr, bind_endpoint}; -use tokio::io::AsyncReadExt; use tokio::net::TcpListener; -use tokio::task::JoinHandle; +use tokio::task::JoinSet; use crate::impair::{self, Impairment, Proxy}; @@ -38,22 +37,22 @@ impl Path { } } -/// Everything a scenario needs; dropping `tasks` stops the world. +/// Everything a scenario needs; dropping the owned task group stops the world. pub struct World { pub client: Endpoint, pub target: EndpointAddr, pub agent: Arc, - pub discard_port: u16, + pub transfer_port: u16, pub proxy_stats: Option>, - tasks: Vec>, + _tasks: JoinSet<()>, /// Keeps the in-process relay alive for the world's lifetime. _relay: Option, } impl World { - /// TCP discard service port the agent permits (for `transfer`). - pub fn discard_target(&self) -> (String, u16) { - ("127.0.0.1".into(), self.discard_port) + /// TCP verified-receipt service the agent permits (for `transfer`). + pub fn transfer_target(&self) -> (String, u16) { + ("127.0.0.1".into(), self.transfer_port) } /// Graceful endpoint shutdown; scenarios call this before the @@ -90,28 +89,20 @@ impl World { } } -impl Drop for World { - fn drop(&mut self) { - for t in &self.tasks { - t.abort(); - } - } -} - impl World { /// Spin up relay + agent + client on `backend` and return the world /// plus the target address the client should dial for `path`. /// - /// The owned `noq` backend has no relay transport yet (WS2): relay - /// paths on it fail here with a clear error, and no relay process - /// is spawned. + /// This fixture only wires relay paths for iroh. The owned relay + /// implementation has separate fixtures; requesting it here fails + /// explicitly rather than measuring the wrong carrier. pub async fn spawn(path: Path, backend: rds_net::Backend) -> anyhow::Result { - let mut tasks = Vec::new(); + let mut tasks = JoinSet::new(); let wants_relay = matches!(path, Path::RelayOnly | Path::Mixed); if wants_relay && backend != rds_net::Backend::Iroh { anyhow::bail!( - "path {:?} needs a relay; backend {backend:?} has no relay transport yet (WS2)", + "benchmark world does not wire relay path {:?} for backend {backend:?}", path.label() ); } @@ -146,19 +137,18 @@ impl World { agent_ep.online().await; client_ep.online().await; - // TCP discard sink for throughput scenarios. - let discard_port = spawn_discard(&mut tasks).await; + let transfer_port = spawn_transfer_target(&mut tasks).await?; - let mut policy = AgentPolicy::ssh_only(("127.0.0.1".into(), discard_port)); + let mut policy = AgentPolicy::ssh_only(("127.0.0.1".into(), transfer_port)); policy.allow.insert(client_ep.id()); policy.allow_any_tcp = true; // bench targets are ours let agent = Arc::new(Agent::new(agent_ep, policy)); - tasks.push(tokio::spawn({ + tasks.spawn({ let agent = agent.clone(); async move { let _ = agent.run().await; } - })); + }); // The agent's real UDP address and relay address from its addr(). let advertised = agent.endpoint.addr(); @@ -211,25 +201,61 @@ impl World { addrs, }, agent, - discard_port, + transfer_port, proxy_stats, - tasks, + _tasks: tasks, _relay: relay, }) } } -/// TCP server that reads and discards — the `transfer` scenario's target. -async fn spawn_discard(tasks: &mut Vec>) -> u16 { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let port = listener.local_addr().unwrap().port(); - tasks.push(tokio::spawn(async move { - while let Ok((mut sock, _)) = listener.accept().await { - tokio::spawn(async move { - let mut buf = vec![0u8; 64 * 1024]; - while sock.read(&mut buf).await.unwrap_or(0) > 0 {} - }); +/// Bounded, owned receiver tasks; errors close without a success receipt. +async fn spawn_transfer_target(tasks: &mut JoinSet<()>) -> anyhow::Result { + let listener = TcpListener::bind("127.0.0.1:0").await?; + let port = listener.local_addr()?.port(); + tasks.spawn(async move { + let mut receivers = JoinSet::new(); + loop { + tokio::select! { + _ = receivers.join_next(), if !receivers.is_empty() => {}, + accepted = listener.accept(), if receivers.len() < 8 => { + let Ok((mut socket, _)) = accepted else { break }; + receivers.spawn(async move { + let _ = crate::transfer::receive(&mut socket).await; + }); + } + } } - })); - port + }); + Ok(port) +} + +#[cfg(test)] +mod tests { + use super::*; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + + #[tokio::test] + async fn transfer_target_acknowledges_received_bytes_and_digest_after_fin() { + let mut tasks = JoinSet::new(); + let port = spawn_transfer_target(&mut tasks).await.unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(2), async { + let mut socket = tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .unwrap(); + let body = b"payload with a receiver completion barrier"; + socket.write_all(body).await.unwrap(); + socket.shutdown().await.unwrap(); + let mut receipt = [0; 40]; + socket + .read_exact(&mut receipt) + .await + .expect("receiver must acknowledge before throughput is reported"); + assert_eq!(&receipt[..8], &(body.len() as u64).to_be_bytes()); + assert_eq!(&receipt[8..], blake3::hash(body).as_bytes()); + assert_eq!(socket.read(&mut [0]).await.unwrap(), 0); + }) + .await + .unwrap(); + } } diff --git a/docs/benchmark-transfer.md b/docs/benchmark-transfer.md new file mode 100644 index 0000000..6b9e8db --- /dev/null +++ b/docs/benchmark-transfer.md @@ -0,0 +1,37 @@ +# Verified transfer measurement + +The `rds-bench run --scenario transfer` command reports +`transfer-receiver-ack-v1`. It measures application goodput over an established +QUIC connection and an agent-forwarded loopback TCP stream. It is development +tooling, not a service exposed by the installed agent. + +The receiver streams the body through BLAKE3 using a 64 KiB buffer. Only after +payload EOF does it return a fixed 40-byte receipt: an eight-byte big-endian +byte count followed by the 32-byte digest, then response EOF. The sender checks +all three. Short/corrupt payloads, incorrect/truncated receipts, trailing data, +I/O errors and timeouts produce no successful throughput result. + +The sender generates deterministic position-dependent data with the existing +BLAKE3 XOF, using the fixed `rds-bench-transfer/v1` seed. Its timed interval +includes generation, sender hashing, upload, receiver hashing, the receipt and +response EOF. Connect and OpenTcp setup are excluded from goodput. This is not +raw transport capacity, filesystem durability, or a synchronization benchmark. +The fixture keeps at most eight receiver tasks with bounded per-task buffers; +the world's owned task group cancels them when the scenario ends or fails. + +`--timeout-s` bounds world startup separately, then one combined deadline +covers connect, OpenTcp, upload and receipt. Endpoint cleanup has a separate +five-second deadline. Zero/overflowing payload sizes and zero timeouts fail +before startup. Reports include `transfer_verified_bytes` and +`transfer_completion_ns` as well as MiB/s. + +Historical reports named `transfer` ended at sender finish and did not prove +receiver completion. They are retained as historical evidence, but their rates +must not be used as a receiver-goodput baseline. The versioned scenario name +makes the existing comparator reject a missing matching scenario rather than +compare these incompatible measurement methods. + +W0.2 remains partial: known-rate path calibration, per-phase connection and +authorization timings, full topology coverage and shared-link load measurements +are separate qualification work. Same-host results do not establish WAN capacity +or user-visible desktop latency. diff --git a/docs/implementation-plan.md b/docs/implementation-plan.md index 8769a8e..79d5835 100644 --- a/docs/implementation-plan.md +++ b/docs/implementation-plan.md @@ -135,6 +135,8 @@ Tasks: MB/s), `migrate` (kill socket path mid-transfer — simulated via mux drop, real netem variant later), `loss`/`jitter` via `tc netem` wrapper scripts (documented; run on Linux, skipped on macOS). + Current `transfer` emits the versioned [receiver-verified goodput](benchmark-transfer.md) + scenario; historical sender-finish results are not comparable to it. - **B3 — desktop-latency methodology**: frame carries `{capture_ts, encode_done_ts, send_ts}`; client stamps `{recv_ts, decoded_ts, presented_ts}`; input-to-pixel measured by a diff --git a/docs/remediation-progress.md b/docs/remediation-progress.md index 398c299..451391c 100644 --- a/docs/remediation-progress.md +++ b/docs/remediation-progress.md @@ -16,9 +16,10 @@ requirement; the later [native Rust SSH increment](ssh.md) removes that local requirement while retaining a configured remote SSH server. The repository is still not a completed remote access product. Frame presentation returns unavailable; ScreenCaptureKit, image-copy and PipeWire capture probes remain placeholders. -The throughput scenario stops timing after sender finish without checking a -receiver byte/digest acknowledgment. These implementation gaps remain in -W5, W6/W7 and W0.2, alongside the other open plan tasks. That Linux +The historical throughput scenario stopped timing at sender finish; the later +[verified transfer increment](benchmark-transfer.md) adds a receiver byte/digest +receipt and EOF barrier. Known-rate and phase calibration remain W0.2. The other +implementation gaps remain in W5 and W6/W7 alongside the open plan tasks. That Linux receipt records 431 workspace, 238 expanded and 2 isolated iroh tests passing; those results do not establish missing functionality or native platform/network qualification. The O1 observability foundation recorded 444 workspace and 239 expanded @@ -28,7 +29,8 @@ Neither increment closes these product gaps or any wave. | Task | State | Evidence / remaining scope | |---|---|---| -| W0.1 | Partial | R01/R10 are agent regressions; R02 is now covered by transactional record/delete regressions; R03/R04 are journal regressions, with failures observed before fixing. R05 is covered by planted-link and directory-substitution tests. R06 failed before the name proof fix; R07 is covered by server expiry checks. R08 has failing-before actual-client Drain/drop regressions and passing framing/grace checks. R09 has failing-before direct/relay candidate regressions and passing family/cancellation checks. Desktop body cancellation still needs its owning fixes/tests. | +| W0.1 | Partial | R01/R10 are agent regressions; R02 is now covered by transactional record/delete regressions; R03/R04 are journal regressions, with failures observed before fixing. R05 is covered by planted-link and directory-substitution tests. R06 failed before the name proof fix; R07 is covered by server expiry checks. R08 has failing-before actual-client Drain/drop regressions and passing framing/grace checks. R09 has failing-before direct/relay candidate regressions and passing family/cancellation checks. Desktop byte/cancellation and lifecycle regressions now cover the W6.1/W6.2 increments; broader native/network qualification remains open. | +| W0.2 | Partial; receiver completion barrier | Versioned transfer goodput waits for exact received byte count, BLAKE3 digest and response EOF under one operation deadline. A missing-receipt regression failed before the fix; corrupt/truncated/reordered payloads, invalid receipts, delayed reception and real iroh/noq forwarded-TCP checks cover the boundary. Known-rate calibration, connect/auth/service phase timings and topology/load qualification remain open; see [contract](benchmark-transfer.md). | | W1.1 | Implemented; Linux checks passed | Denylist replacement retains its value without observers; atomic modification preserves concurrent revocations. Subscribe-before-check and initial watchdog snapshot check remove missed-update windows. Durable feed freshness remains W1.4. | | W1.2 | Implemented; Linux checks passed | One authorization state owns admission, replay reservation and watchdog. ACK failure/cancellation closes the connection and releases the grant. Service admission checks live validity/revocation. Connection future teardown runs RAII cleanup. | | W1.3 | Implemented; Linux checks passed | Client trust anchor, per-name domain-separated signatures, exact name/record binding, current validity and volatile anti-rollback. Native directory HTTPS/DNS added; durable revision linkage stays W1.4 and native macOS verification remains open. | @@ -1485,3 +1487,37 @@ See [the scope and validation receipt](reports/rds-managed-sync-20260926.md). This is a reviewed increment, not closure of W2/W8 or product acceptance. Viewer manager APIs, directory synchronization, platform/installed migration, disk-job qualification and the remaining remediation gates stay open. + +## 2026-09-26 — receiver-verified transfer measurement (W0.2 partial) + +The `transfer` scenario no longer stops its timer at sender finish. The local +TCP target now streams the body through BLAKE3 and answers only after payload +EOF with a fixed 40-byte receipt — big-endian byte count plus digest — followed +by response EOF. The sender verifies all three under one operation deadline +that covers connect, OpenTcp, upload and receipt; world startup and endpoint +cleanup carry separate deadlines. Bounded, owned receiver tasks (eight live) +replace the unbounded discard sink, and payload is deterministic +position-dependent BLAKE3 XOF data so duplicated/reordered bytes change the +digest. The report names the scenario `transfer-receiver-ack-v1`, adds +`transfer_verified_bytes` and `transfer_completion_ns`, and keeps historical +sender-finish results incomparable by name. See +[the contract](benchmark-transfer.md). + +Regression evidence: `large_send_window_cannot_complete_before_receiver_ack` +failed before the fix (`UnexpectedEof` — the old target never acknowledged); +invalid/missing/truncated/trailing receipts, corrupt/short/reordered bodies and +a receipt without EOF all fail the measurement. End-to-end runs cross real iroh +and noq connections over forwarded TCP. + +Local validation: `cargo fmt --check`; workspace clippy with default features +and the owned-transport feature set (`-D warnings`); `cargo test -p rds-bench` +(14 tests including both real-transport receipts). Fixed measurement series on +this tree, debug profile, 32 MiB each: iroh 4.4 MiB/s verified +([iroh](reports/bench-transfer-20260926-iroh.md)), noq 3.7 MiB/s verified +([noq](reports/bench-transfer-20260926-noq.md)). The debug-profile series +needed `--timeout-s 120`; the default 15 s deadline expires mid-transfer under +unoptimized crypto — a harness constraint, not a product defect, and the run +fails closed rather than reporting a partial number. Report metadata does not +yet record the build profile; that is W0.6 scope. W0.2 stays partial: +known-rate calibration, per-phase connect/auth/service timings and +topology/load qualification remain open. diff --git a/docs/reports/bench-transfer-20260926-iroh.json b/docs/reports/bench-transfer-20260926-iroh.json new file mode 100644 index 0000000..4a84110 --- /dev/null +++ b/docs/reports/bench-transfer-20260926-iroh.json @@ -0,0 +1,73 @@ +{ + "tool": "rds-bench 0.1.0", + "unix_ts": 1790430042, + "git": "1609b61", + "reports": [ + { + "meta": { + "scenario": "transfer-receiver-ack-v1", + "backend": "iroh", + "path": "direct", + "impairment": null, + "unix_ts": 1790430042, + "git": "1609b61" + }, + "rtt": null, + "throughput_mib_s": 4.350609352049225, + "attempts": null, + "metrics": { + "agent_rds_net_active_connections": 1, + "agent_rds_net_bytes_received_total{via=\"direct\"}": 31709635, + "agent_rds_net_bytes_received_total{via=\"relay\"}": 0, + "agent_rds_net_bytes_sent_total{via=\"direct\"}": 133991, + "agent_rds_net_bytes_sent_total{via=\"relay\"}": 0, + "agent_rds_net_congestion_events_total": 1, + "agent_rds_net_connections_accepted_total": 1, + "agent_rds_net_connections_opened_total": 0, + "agent_rds_net_cwnd_bytes": 5808, + "agent_rds_net_datagrams_lost_total{via=\"direct\"}": 3, + "agent_rds_net_datagrams_lost_total{via=\"relay\"}": 0, + "agent_rds_net_datagrams_sent_total{via=\"direct\"}": 1521, + "agent_rds_net_datagrams_sent_total{via=\"relay\"}": 0, + "agent_rds_net_degraded_path_observers": 0, + "agent_rds_net_live_paths": 1, + "agent_rds_net_path_events_lost_total": 0, + "agent_rds_net_paths_seen_total{via=\"direct\"}": 1, + "agent_rds_net_paths_seen_total{via=\"relay\"}": 0, + "agent_rds_net_policy_observed_connections": 0, + "agent_rds_net_qnt_attempts_total": 0, + "agent_rds_net_qnt_success_total": 0, + "agent_rds_net_rtt_us": 3517, + "agent_rds_net_selected_path_known": 1, + "client_rds_net_active_connections": 1, + "client_rds_net_bytes_received_total{via=\"direct\"}": 139020, + "client_rds_net_bytes_received_total{via=\"relay\"}": 0, + "client_rds_net_bytes_sent_total{via=\"direct\"}": 34372548, + "client_rds_net_bytes_sent_total{via=\"relay\"}": 0, + "client_rds_net_congestion_events_total": 0, + "client_rds_net_connections_accepted_total": 0, + "client_rds_net_connections_opened_total": 1, + "client_rds_net_cwnd_bytes": 131210, + "client_rds_net_datagrams_lost_total{via=\"direct\"}": 0, + "client_rds_net_datagrams_lost_total{via=\"relay\"}": 0, + "client_rds_net_datagrams_sent_total{via=\"direct\"}": 23777, + "client_rds_net_datagrams_sent_total{via=\"relay\"}": 0, + "client_rds_net_degraded_path_observers": 0, + "client_rds_net_live_paths": 1, + "client_rds_net_path_events_lost_total": 0, + "client_rds_net_paths_seen_total{via=\"direct\"}": 1, + "client_rds_net_paths_seen_total{via=\"relay\"}": 0, + "client_rds_net_policy_observed_connections": 0, + "client_rds_net_qnt_attempts_total": 0, + "client_rds_net_qnt_success_total": 0, + "client_rds_net_rtt_us": 3852, + "client_rds_net_selected_path_known": 1, + "transfer_completion_ns": 7355291503, + "transfer_verified_bytes": 33554432 + }, + "notes": [ + "receiver-ack-v1: byte count + BLAKE3 digest + EOF; payload generation/hash, upload and receipt are timed; connect/OpenTcp excluded; not comparable to historical sender-finish results" + ] + } + ] +} \ No newline at end of file diff --git a/docs/reports/bench-transfer-20260926-iroh.md b/docs/reports/bench-transfer-20260926-iroh.md new file mode 100644 index 0000000..da46571 --- /dev/null +++ b/docs/reports/bench-transfer-20260926-iroh.md @@ -0,0 +1,36 @@ +# rds bench suite — 1790430042 + +commit: `1609b61` + +## transfer-receiver-ack-v1 (iroh, direct) + +throughput: **4.4 MiB/s** + +metrics: +- `agent_rds_net_active_connections` = 1 +- `agent_rds_net_bytes_received_total{via="direct"}` = 31709635 +- `agent_rds_net_bytes_sent_total{via="direct"}` = 133991 +- `agent_rds_net_congestion_events_total` = 1 +- `agent_rds_net_connections_accepted_total` = 1 +- `agent_rds_net_cwnd_bytes` = 5808 +- `agent_rds_net_datagrams_lost_total{via="direct"}` = 3 +- `agent_rds_net_datagrams_sent_total{via="direct"}` = 1521 +- `agent_rds_net_live_paths` = 1 +- `agent_rds_net_paths_seen_total{via="direct"}` = 1 +- `agent_rds_net_rtt_us` = 3517 +- `agent_rds_net_selected_path_known` = 1 +- `client_rds_net_active_connections` = 1 +- `client_rds_net_bytes_received_total{via="direct"}` = 139020 +- `client_rds_net_bytes_sent_total{via="direct"}` = 34372548 +- `client_rds_net_connections_opened_total` = 1 +- `client_rds_net_cwnd_bytes` = 131210 +- `client_rds_net_datagrams_sent_total{via="direct"}` = 23777 +- `client_rds_net_live_paths` = 1 +- `client_rds_net_paths_seen_total{via="direct"}` = 1 +- `client_rds_net_rtt_us` = 3852 +- `client_rds_net_selected_path_known` = 1 +- `transfer_completion_ns` = 7355291503 +- `transfer_verified_bytes` = 33554432 + +- receiver-ack-v1: byte count + BLAKE3 digest + EOF; payload generation/hash, upload and receipt are timed; connect/OpenTcp excluded; not comparable to historical sender-finish results + diff --git a/docs/reports/bench-transfer-20260926-noq.json b/docs/reports/bench-transfer-20260926-noq.json new file mode 100644 index 0000000..b53a4f8 --- /dev/null +++ b/docs/reports/bench-transfer-20260926-noq.json @@ -0,0 +1,73 @@ +{ + "tool": "rds-bench 0.1.0", + "unix_ts": 1790430061, + "git": "1609b61", + "reports": [ + { + "meta": { + "scenario": "transfer-receiver-ack-v1", + "backend": "noq", + "path": "direct", + "impairment": null, + "unix_ts": 1790430061, + "git": "1609b61" + }, + "rtt": null, + "throughput_mib_s": 3.697611803093206, + "attempts": null, + "metrics": { + "agent_rds_net_active_connections": 1, + "agent_rds_net_bytes_received_total{via=\"direct\"}": 32993347, + "agent_rds_net_bytes_received_total{via=\"relay\"}": 0, + "agent_rds_net_bytes_sent_total{via=\"direct\"}": 110341, + "agent_rds_net_bytes_sent_total{via=\"relay\"}": 0, + "agent_rds_net_congestion_events_total": 0, + "agent_rds_net_connections_accepted_total": 1, + "agent_rds_net_connections_opened_total": 0, + "agent_rds_net_cwnd_bytes": 5904, + "agent_rds_net_datagrams_lost_total{via=\"direct\"}": 0, + "agent_rds_net_datagrams_lost_total{via=\"relay\"}": 0, + "agent_rds_net_datagrams_sent_total{via=\"direct\"}": 2390, + "agent_rds_net_datagrams_sent_total{via=\"relay\"}": 0, + "agent_rds_net_degraded_path_observers": 0, + "agent_rds_net_live_paths": 2, + "agent_rds_net_path_events_lost_total": 0, + "agent_rds_net_paths_seen_total{via=\"direct\"}": 2, + "agent_rds_net_paths_seen_total{via=\"relay\"}": 0, + "agent_rds_net_policy_observed_connections": 1, + "agent_rds_net_qnt_attempts_total": 0, + "agent_rds_net_qnt_success_total": 0, + "agent_rds_net_rtt_us": 2154, + "agent_rds_net_selected_path_known": 1, + "client_rds_net_active_connections": 1, + "client_rds_net_bytes_received_total{via=\"direct\"}": 123564, + "client_rds_net_bytes_received_total{via=\"relay\"}": 0, + "client_rds_net_bytes_sent_total{via=\"direct\"}": 34333987, + "client_rds_net_bytes_sent_total{via=\"relay\"}": 0, + "client_rds_net_congestion_events_total": 0, + "client_rds_net_connections_accepted_total": 0, + "client_rds_net_connections_opened_total": 1, + "client_rds_net_cwnd_bytes": 5808, + "client_rds_net_datagrams_lost_total{via=\"direct\"}": 0, + "client_rds_net_datagrams_lost_total{via=\"relay\"}": 0, + "client_rds_net_datagrams_sent_total{via=\"direct\"}": 23659, + "client_rds_net_datagrams_sent_total{via=\"relay\"}": 0, + "client_rds_net_degraded_path_observers": 0, + "client_rds_net_live_paths": 2, + "client_rds_net_path_events_lost_total": 0, + "client_rds_net_paths_seen_total{via=\"direct\"}": 2, + "client_rds_net_paths_seen_total{via=\"relay\"}": 0, + "client_rds_net_policy_observed_connections": 1, + "client_rds_net_qnt_attempts_total": 1, + "client_rds_net_qnt_success_total": 1, + "client_rds_net_rtt_us": 1026, + "client_rds_net_selected_path_known": 1, + "transfer_completion_ns": 8654234599, + "transfer_verified_bytes": 33554432 + }, + "notes": [ + "receiver-ack-v1: byte count + BLAKE3 digest + EOF; payload generation/hash, upload and receipt are timed; connect/OpenTcp excluded; not comparable to historical sender-finish results" + ] + } + ] +} \ No newline at end of file diff --git a/docs/reports/bench-transfer-20260926-noq.md b/docs/reports/bench-transfer-20260926-noq.md new file mode 100644 index 0000000..0eef8fa --- /dev/null +++ b/docs/reports/bench-transfer-20260926-noq.md @@ -0,0 +1,38 @@ +# rds bench suite — 1790430061 + +commit: `1609b61` + +## transfer-receiver-ack-v1 (noq, direct) + +throughput: **3.7 MiB/s** + +metrics: +- `agent_rds_net_active_connections` = 1 +- `agent_rds_net_bytes_received_total{via="direct"}` = 32993347 +- `agent_rds_net_bytes_sent_total{via="direct"}` = 110341 +- `agent_rds_net_connections_accepted_total` = 1 +- `agent_rds_net_cwnd_bytes` = 5904 +- `agent_rds_net_datagrams_sent_total{via="direct"}` = 2390 +- `agent_rds_net_live_paths` = 2 +- `agent_rds_net_paths_seen_total{via="direct"}` = 2 +- `agent_rds_net_policy_observed_connections` = 1 +- `agent_rds_net_rtt_us` = 2154 +- `agent_rds_net_selected_path_known` = 1 +- `client_rds_net_active_connections` = 1 +- `client_rds_net_bytes_received_total{via="direct"}` = 123564 +- `client_rds_net_bytes_sent_total{via="direct"}` = 34333987 +- `client_rds_net_connections_opened_total` = 1 +- `client_rds_net_cwnd_bytes` = 5808 +- `client_rds_net_datagrams_sent_total{via="direct"}` = 23659 +- `client_rds_net_live_paths` = 2 +- `client_rds_net_paths_seen_total{via="direct"}` = 2 +- `client_rds_net_policy_observed_connections` = 1 +- `client_rds_net_qnt_attempts_total` = 1 +- `client_rds_net_qnt_success_total` = 1 +- `client_rds_net_rtt_us` = 1026 +- `client_rds_net_selected_path_known` = 1 +- `transfer_completion_ns` = 8654234599 +- `transfer_verified_bytes` = 33554432 + +- receiver-ack-v1: byte count + BLAKE3 digest + EOF; payload generation/hash, upload and receipt are timed; connect/OpenTcp excluded; not comparable to historical sender-finish results +