From 1c8eec68a4f558b0f53c3d50205b13bed205a304 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:57:16 +0900 Subject: [PATCH 01/23] Attribute window pipeline execution time Separate window preparation, transition, and commit timing. Add replay benchmarks and document the attribution boundary without claiming an unmeasured speed change. See also: #1607 --- crates/chainstate/src/lib.rs | 16 +- crates/chainstate/src/window.rs | 24 +- crates/node/benches/evidence.rs | 91 ++ crates/node/benches/sync_pipeline.rs | 997 +++++++++++++++++- .../benchmarks/window-pipeline-attribution.md | 177 ++++ 5 files changed, 1282 insertions(+), 23 deletions(-) create mode 100644 docs/benchmarks/window-pipeline-attribution.md diff --git a/crates/chainstate/src/lib.rs b/crates/chainstate/src/lib.rs index 01f53f592..f90a2da9c 100644 --- a/crates/chainstate/src/lib.rs +++ b/crates/chainstate/src/lib.rs @@ -183,6 +183,14 @@ impl ApplyAdmission { struct TransitionGuard<'a> { _transition: TransitionAuthorityGuard<'a>, _admission: RwLockReadGuard<'a, ()>, + started: quanta::Instant, +} + +impl Drop for TransitionGuard<'_> { + fn drop(&mut self) { + let transition_held_us = self.started.elapsed().as_micros(); + tracing::debug!(transition_held_us, "chainstate: transition held"); + } } fn begin_chain_transition<'a>( @@ -191,10 +199,16 @@ fn begin_chain_transition<'a>( ) -> core::result::Result, ApplyError> { let admission_guard = admission.enter()?; let transition = chain_transition.lock(); - admission.ensure_open()?; + let started = quanta::Instant::now(); + if let Err(err) = admission.ensure_open() { + let transition_held_us = started.elapsed().as_micros(); + tracing::debug!(transition_held_us, "chainstate: transition held"); + return Err(err); + } Ok(TransitionGuard { _transition: transition, _admission: admission_guard, + started, }) } diff --git a/crates/chainstate/src/window.rs b/crates/chainstate/src/window.rs index 85ca102ab..9d5b1c176 100644 --- a/crates/chainstate/src/window.rs +++ b/crates/chainstate/src/window.rs @@ -337,24 +337,34 @@ fn flush_group( committed: &mut Vec, blocks: &[&Block], ) -> core::result::Result<(), WindowApplyError> { + if group.pending.is_empty() { + return Ok(()); + } // The staged prefix is a contiguous suffix starting at `committed.len()`, // so the newest staged block is borrowed straight from the caller's apply // batch — no clone in chainstate. Empty group: nothing staged, no hook. // Failure-prefix flush: the failing block was never staged, so the same // index still names the newest successfully staged block. - let staged_block = if group.pending.is_empty() { - None - } else { - blocks - .get(committed.len() + group.pending.len() - 1) - .copied() - }; + let staged_block = blocks + .get(committed.len() + group.pending.len() - 1) + .copied(); + let flush_started = quanta::Instant::now(); + let pending_blocks = group.pending.len(); match group.flush(handles, staged_block) { Ok(flushed) => { + let flush_wait_us = flush_started.elapsed().as_micros(); + tracing::debug!(blocks = flushed.len(), flush_wait_us, "window: group flush"); committed.extend(flushed); Ok(()) } Err(flush_error) => { + let flush_wait_us = flush_started.elapsed().as_micros(); + tracing::debug!( + blocks = pending_blocks, + flush_wait_us, + %flush_error, + "window: group flush failed" + ); group.abandon(); Err(WindowApplyError { applied: committed.len(), diff --git a/crates/node/benches/evidence.rs b/crates/node/benches/evidence.rs index 367733966..690e46e1b 100644 --- a/crates/node/benches/evidence.rs +++ b/crates/node/benches/evidence.rs @@ -231,6 +231,64 @@ impl Ledger { } } +/// Samples a run must hold before its p99 may be read as a tail estimate +/// (`HPA-13`: no trustworthy p99 from too few samples). +pub const MIN_SAMPLES_FOR_P99: usize = 100; + +/// Order statistics of one raw sample set, in the samples' own unit. +/// +/// The summary never replaces the raw samples; it is printed beside them. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct Quantiles { + /// Sample count the statistics were computed from. + pub n: usize, + /// Median, linearly interpolated between adjacent order statistics. + pub p50: u64, + /// 95th percentile, interpolated the same way. + pub p95: u64, + /// 99th percentile, interpolated the same way. + pub p99: u64, + /// Largest sample. + pub max: u64, +} + +impl Quantiles { + /// Computes p50/p95/p99/max of `samples`; `None` for an empty set, which + /// is a missing measurement, never a zero. + #[must_use] + pub fn of(samples: &[u64]) -> Option { + let mut sorted = samples.to_vec(); + sorted.sort_unstable(); + let max = *sorted.last()?; + let at = |percent: u64| -> u64 { + // Rank scaled by 100 keeps the interpolation exact in integers. + let last_rank = u64::try_from(sorted.len() - 1).unwrap_or(u64::MAX); + let scaled = percent.saturating_mul(last_rank); + let lower = usize::try_from(scaled / 100).unwrap_or(usize::MAX); + let remainder = scaled % 100; + let low = sorted[lower]; + let high = sorted.get(lower + 1).copied().unwrap_or(low); + let span = u128::from(high - low); + let step = span * u128::from(remainder); + // Round half up on the interpolated step. + low + u64::try_from((step + 50) / 100).unwrap_or(u64::MAX) + }; + Some(Self { + n: sorted.len(), + p50: at(50), + p95: at(95), + p99: at(99), + max, + }) + } + + /// Whether `p99` rests on enough samples to be read as a tail estimate. + #[must_use] + pub const fn p99_supported(&self) -> bool { + self.n >= MIN_SAMPLES_FOR_P99 + } +} + #[cfg(test)] #[expect(clippy::expect_used, reason = "benchmark evidence rejection tests")] mod tests { @@ -415,4 +473,37 @@ mod tests { ); assert_eq!(reparsed.cells.len(), ledger.cells.len()); } + + #[test] + fn quantiles_are_exact_order_statistics_with_a_support_floor() { + assert_eq!(Quantiles::of(&[]), None, "an empty set is not a zero"); + + let single = Quantiles::of(&[7]).expect("one sample"); + assert_eq!( + (single.p50, single.p95, single.p99, single.max), + (7, 7, 7, 7) + ); + assert!(!single.p99_supported()); + + // Ranks land on whole samples, so the statistics are the samples. + let ranks: Vec = (0..=100).rev().collect(); + let exact = Quantiles::of(&ranks).expect("101 samples"); + assert_eq!( + (exact.n, exact.p50, exact.p95, exact.p99, exact.max), + (101, 50, 95, 99, 100) + ); + assert!(exact.p99_supported()); + assert!( + !Quantiles::of(&ranks[..99]) + .expect("99 samples") + .p99_supported() + ); + + // Between samples the order statistic is interpolated, halves up. + let spread = Quantiles::of(&[40, 10, 30, 20]).expect("four samples"); + assert_eq!( + (spread.p50, spread.p95, spread.p99, spread.max), + (25, 39, 40, 40) + ); + } } diff --git a/crates/node/benches/sync_pipeline.rs b/crates/node/benches/sync_pipeline.rs index 719beac17..7189ddb4c 100644 --- a/crates/node/benches/sync_pipeline.rs +++ b/crates/node/benches/sync_pipeline.rs @@ -284,16 +284,8 @@ fn record_evidence(cell: &str, blocks: &[Block], sweeps: &[(Duration, Duration)] id: cell.to_owned(), manifest_sha256: Sha256Hex::digest(&corpus_bytes), }); - let path = - Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/benchmarks/sync_pipeline.toml"); - let mut ledger = match std::fs::read_to_string(&path) { - Ok(text) => Ledger::parse(&text).unwrap_or_else(|error| panic!("bench ledger: {error}")), - Err(_) => Ledger { - schema: LEDGER_SCHEMA.into(), - cells: Vec::new(), - contract: std::collections::BTreeMap::new(), - }, - }; + let path = bench_ledger_path("sync_pipeline.toml"); + let mut ledger = load_bench_ledger(&path); if !ledger.cells.iter().any(|candidate| candidate.id == cell) { ledger.cells.push(Cell { id: cell.to_owned(), @@ -321,12 +313,36 @@ fn record_evidence(cell: &str, blocks: &[Block], sweeps: &[(Duration, Duration)] .record(cell, sample) .unwrap_or_else(|error| panic!("record bench sample: {error}")); } + store_bench_ledger(&path, &ledger); +} + +/// Path of one bench evidence file under the workspace `target/benchmarks/`. +fn bench_ledger_path(name: &str) -> std::path::PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../../target/benchmarks") + .join(name) +} + +/// Loads an append-only bench ledger; a missing file is an empty ledger. +fn load_bench_ledger(path: &Path) -> Ledger { + match std::fs::read_to_string(path) { + Ok(text) => Ledger::parse(&text).unwrap_or_else(|error| panic!("bench ledger: {error}")), + Err(_) => Ledger { + schema: LEDGER_SCHEMA.into(), + cells: Vec::new(), + contract: std::collections::BTreeMap::new(), + }, + } +} + +/// Writes `ledger` back to `path`, creating the evidence directory. +fn store_bench_ledger(path: &Path, ledger: &Ledger) { std::fs::create_dir_all(path.parent().unwrap_or_else(|| Path::new("."))) .unwrap_or_else(|error| panic!("bench evidence dir: {error}")); let rendered = ledger .render() .unwrap_or_else(|error| panic!("render bench ledger: {error}")); - std::fs::write(&path, rendered).unwrap_or_else(|error| panic!("write bench ledger: {error}")); + std::fs::write(path, rendered).unwrap_or_else(|error| panic!("write bench ledger: {error}")); } fn deterministic_initial_sync_proxy(c: &mut Criterion) { @@ -905,6 +921,23 @@ impl SyncFixture { } } +/// A boundary of the production replay that a caller may time. +#[derive(Clone, Copy)] +enum ReplayPhase { + /// The contiguous head is delivered; the next tick applies the window. + ApplyStart, + /// The applying tick returned. + ApplyDone, +} + +/// What one production replay observed. +struct ReplayReport { + /// Items in the first getdata the replay issued. + requested: usize, + /// Height of the applied tip once the replay finished. + applied_height: u32, +} + struct ProductionStateSyncFixture { _dir: TempDir, state: NodeState, @@ -943,6 +976,21 @@ impl ProductionStateSyncFixture { ) } + /// Production replay of `body_blocks` coinbase-only blocks under the + /// same deep header chain and single synthetic peer as the 128-block arm. + /// Window attribution varies only the body count, so the header tree, + /// storage backend and request shape stay identical across scenarios. + fn window_replay(body_blocks: u32) -> Self { + let expected_getdata_count = usize::try_from(body_blocks) + .unwrap_or_else(|error| panic!("invalid window body count: {error}")); + Self::with_config_and_header_blocks( + 1, + production_state_config(), + |tree| populate_sync_header_chain(tree, body_blocks).0, + expected_getdata_count, + ) + } + #[cfg(feature = "fjall")] fn new_fjall_all_indexes_spend_heavy() -> Self { let mut config = production_state_config(); @@ -1006,9 +1054,19 @@ impl ProductionStateSyncFixture { } fn run(mut self) -> u32 { + let replay = self.run_phased(|_| {}); + assert_eq!(replay.requested, self.expected_getdata_count); + replay.applied_height + } + + /// The production replay of `run`, reporting the boundaries of the tick + /// that applies the window so a caller can time that tick on its own. + /// The fixture stays alive afterwards: its teardown is not part of any + /// interval the caller times. + fn run_phased(&mut self, mut phase: impl FnMut(ReplayPhase)) -> ReplayReport { let sync = self.state.sync(); sync.tick(); - self.assert_getdata_batch(); + let requested = self.first_getdata_len(); let inbound_blocks_tx = self.state.inbound_blocks_sender(); // prebuilt_inbound holds heights 2..=N reversed, then height 1 last. // Send the out-of-order tail, tick, then deliver the contiguous head and @@ -1027,12 +1085,26 @@ impl ProductionStateSyncFixture { inbound_blocks_tx .send(contiguous) .unwrap_or_else(|error| panic!("send production contiguous block failed: {error}")); + phase(ReplayPhase::ApplyStart); sync.tick(); - self.state + phase(ReplayPhase::ApplyDone); + let applied_height = self + .state .chainstate() .applied_tip_snapshot() .unwrap_or_else(|| panic!("production sync proxy did not publish applied tip")) - .height + .height; + ReplayReport { + requested, + applied_height, + } + } + + /// One more sync tick. After the applying tick it closes the window's + /// measurement-only intervals (apply-idle, download-blocked-by-apply), + /// which record only when they close. + fn settle_tick(&self) { + self.state.sync().tick(); } fn stage_for_contiguous_apply(self) -> Self { @@ -1098,6 +1170,12 @@ impl ProductionStateSyncFixture { } fn assert_getdata_batch(&self) { + assert_eq!(self.first_getdata_len(), self.expected_getdata_count); + } + + /// Drains header requests up to the first getdata and returns its item + /// count. + fn first_getdata_len(&self) -> usize { let rx = self .outbound_rxs .first() @@ -1121,7 +1199,7 @@ impl ProductionStateSyncFixture { other => panic!("expected production getdata, got {other:?}"), } }; - assert_eq!(getdata_count, self.expected_getdata_count); + getdata_count } } @@ -4382,8 +4460,892 @@ fn require_bare_counted_flag(args: &[String], flag: &str) { } } +// --------------------------------------------------------------------------- +// Step-24 window attribution: the production replay of one windowed apply, +// control against candidate, single-group against multi-group. +// --------------------------------------------------------------------------- + +/// Blocks per durable group commit: `DURABLE_HEAD_GROUP_BLOCKS` in +/// `bitcoin-rs-chainstate`, private there. Drift trips the per-unit group +/// flush assertion instead of being absorbed. +const WINDOW_GROUP_BLOCKS: u32 = 64; +/// Units that bring the allocator, page cache and fjall to steady state. +/// Retained raw, excluded from every summary and from the ledger. +const WINDOW_WARMUP_UNITS: usize = 5; +/// Measured units per run. Equals `evidence::MIN_SAMPLES_FOR_P99`, so every +/// per-unit series supports its own p99. +const WINDOW_MEASURED_UNITS: usize = 100; +/// `/proc//stat` reports CPU time in `USER_HZ` ticks, fixed at 100. +const WINDOW_CPU_TICK_NS: u64 = 10_000_000; +const WINDOW_ATTRIBUTION_SCHEMA: &str = "bitcoin-rs-window-attribution-v1"; + +/// Which tree built this binary. The label names the arm; the artifact digest +/// in the identity is what proves the two arms are different programs. +#[derive(Clone, Copy)] +enum WindowArm { + Control, + Candidate, +} + +impl WindowArm { + const fn label(self) -> &'static str { + match self { + Self::Control => "control", + Self::Candidate => "candidate", + } + } + + fn parse(label: &str) -> Option { + [Self::Control, Self::Candidate] + .into_iter() + .find(|arm| arm.label() == label) + } +} + +/// One window shape: the number of coinbase-only body blocks the replay +/// delivers as a single contiguous window. +#[derive(Clone, Copy)] +struct WindowScenario { + id: &'static str, + body_blocks: u32, +} + +impl WindowScenario { + /// Group commits the window must issue: one per started group. + fn groups(self) -> usize { + usize::try_from(self.body_blocks.div_ceil(WINDOW_GROUP_BLOCKS)) + .unwrap_or_else(|error| panic!("invalid window group count: {error}")) + } +} + +/// Single-group: exactly one full group, the largest window that flushes +/// once. Multi-group: two full groups, so a mid-window flush separates +/// them. The byte bound (`8 MiB`) is not reached by coinbase-only blocks; +/// only the block-count bound is exercised. +const WINDOW_SCENARIOS: [WindowScenario; 2] = [ + WindowScenario { + id: "single_group", + body_blocks: WINDOW_GROUP_BLOCKS, + }, + WindowScenario { + id: "multi_group", + body_blocks: 2 * WINDOW_GROUP_BLOCKS, + }, +]; + +/// What a captured interval measures. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum WindowSpanKind { + /// `transition_held_us`: the chain transition lock, acquire to release. + TransitionHeld, + /// `flush_wait_us`: the caller's wait on one durable group commit. + FlushWait, + /// A group commit that failed; a replay that sees one is invalid. + FlushFailed, + /// `node.sync.apply_idle_seconds`: apply starved while downloads were owed. + ApplyIdle, + /// `node.sync.download_blocked_by_apply_seconds`: front in flight while + /// apply held the frontier. + DownloadBlocked, +} + +impl WindowSpanKind { + /// Ledger path the interval measures. + const fn ledger_path(self) -> &'static str { + match self { + Self::TransitionHeld => "apply.window.transition_held", + Self::FlushWait => "apply.window.flush_wait", + Self::FlushFailed => "apply.window.flush_failed", + Self::ApplyIdle => "p2p.window.apply_idle", + Self::DownloadBlocked => "p2p.window.download_blocked_by_apply", + } + } +} + +/// One interval captured from the production diagnostics: it ended when the +/// event or histogram record arrived and lasted `duration_ns`. +struct WindowSpan { + kind: WindowSpanKind, + end: quanta::Instant, + duration_ns: u64, + /// Blocks the group commit covered; set for `FlushWait` only. + blocks: Option, +} + +/// Spans of the unit in flight. The replay runs on the bench thread, and the +/// harness drains this per unit, so no span crosses a unit boundary. +static WINDOW_SPANS: ParkingMutex> = ParkingMutex::new(Vec::new()); + +/// Bench-side tracing subscriber. It enables only the two production events +/// that carry `transition_held_us` and `flush_wait_us`; every other callsite +/// is disabled at registration, so the replay pays no tracing cost beyond +/// these events. +struct WindowSpanSubscriber; + +impl WindowSpanSubscriber { + fn watches(metadata: &tracing::Metadata<'_>) -> bool { + metadata.is_event() + && (metadata.fields().field("transition_held_us").is_some() + || metadata.fields().field("flush_wait_us").is_some()) + } +} + +#[derive(Default)] +struct WindowSpanVisitor { + transition_held_us: Option, + flush_wait_us: Option, + blocks: Option, + failed: bool, +} + +impl tracing::field::Visit for WindowSpanVisitor { + fn record_debug(&mut self, field: &tracing::field::Field, _value: &dyn std::fmt::Debug) { + if field.name() == "flush_error" { + self.failed = true; + } + } + + fn record_u64(&mut self, field: &tracing::field::Field, value: u64) { + match field.name() { + "blocks" => self.blocks = Some(value), + "transition_held_us" => self.transition_held_us = Some(u128::from(value)), + "flush_wait_us" => self.flush_wait_us = Some(u128::from(value)), + _ => {} + } + } + + fn record_u128(&mut self, field: &tracing::field::Field, value: u128) { + match field.name() { + "transition_held_us" => self.transition_held_us = Some(value), + "flush_wait_us" => self.flush_wait_us = Some(value), + _ => {} + } + } +} + +impl tracing::Subscriber for WindowSpanSubscriber { + fn register_callsite( + &self, + metadata: &'static tracing::Metadata<'static>, + ) -> tracing::subscriber::Interest { + if Self::watches(metadata) { + tracing::subscriber::Interest::always() + } else { + tracing::subscriber::Interest::never() + } + } + + fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool { + Self::watches(metadata) + } + + fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id { + tracing::span::Id::from_u64(1) + } + + fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {} + + fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {} + + fn event(&self, event: &tracing::Event<'_>) { + let end = quanta::Instant::now(); + let mut visitor = WindowSpanVisitor::default(); + event.record(&mut visitor); + let micros_to_nanos = + |micros: u128| u64::try_from(micros.saturating_mul(1_000)).unwrap_or(u64::MAX); + let span = match (visitor.transition_held_us, visitor.flush_wait_us) { + (Some(micros), None) => WindowSpan { + kind: WindowSpanKind::TransitionHeld, + end, + duration_ns: micros_to_nanos(micros), + blocks: None, + }, + (None, Some(micros)) => WindowSpan { + kind: if visitor.failed { + WindowSpanKind::FlushFailed + } else { + WindowSpanKind::FlushWait + }, + end, + duration_ns: micros_to_nanos(micros), + blocks: visitor.blocks, + }, + _ => return, + }; + WINDOW_SPANS.lock().push(span); + } + + fn enter(&self, _: &tracing::span::Id) {} + + fn exit(&self, _: &tracing::span::Id) {} +} + +/// Scoped recorder for the unit's replay: it keeps only the two existing +/// window-gate histograms and drops every other metric. +struct WindowSeriesRecorder; + +impl metrics::Recorder for WindowSeriesRecorder { + fn describe_counter( + &self, + _: metrics::KeyName, + _: Option, + _: metrics::SharedString, + ) { + } + + fn describe_gauge( + &self, + _: metrics::KeyName, + _: Option, + _: metrics::SharedString, + ) { + } + + fn describe_histogram( + &self, + _: metrics::KeyName, + _: Option, + _: metrics::SharedString, + ) { + } + + fn register_counter(&self, _: &metrics::Key, _: &metrics::Metadata<'_>) -> metrics::Counter { + metrics::Counter::noop() + } + + fn register_gauge(&self, _: &metrics::Key, _: &metrics::Metadata<'_>) -> metrics::Gauge { + metrics::Gauge::noop() + } + + fn register_histogram( + &self, + key: &metrics::Key, + _: &metrics::Metadata<'_>, + ) -> metrics::Histogram { + let kind = match key.name() { + "node.sync.apply_idle_seconds" => WindowSpanKind::ApplyIdle, + "node.sync.download_blocked_by_apply_seconds" => WindowSpanKind::DownloadBlocked, + _ => return metrics::Histogram::noop(), + }; + metrics::Histogram::from_arc(Arc::new(WindowSeriesHistogram { kind })) + } +} + +struct WindowSeriesHistogram { + kind: WindowSpanKind, +} + +impl metrics::HistogramFn for WindowSeriesHistogram { + fn record(&self, seconds: f64) { + let end = quanta::Instant::now(); + WINDOW_SPANS.lock().push(WindowSpan { + kind: self.kind, + end, + duration_ns: (seconds * 1e9).round() as u64, + blocks: None, + }); + } +} + +/// Everything one replay of one window measured. +struct WindowUnit { + started: quanta::Instant, + apply_started: quanta::Instant, + /// Ticks 1..=4: request, stage, apply, and the closing tick. + wall: Duration, + /// Tick 3 alone, the one that applies the window. + apply_wall: Duration, + /// Tick 4, which closes the measurement-only intervals. + settle_wall: Duration, + /// Process CPU in `/proc` ticks across `wall`, dead threads included. + cpu_ticks: u64, + rss_start_bytes: u64, + /// Process peak resident set across `wall`; the peak was reset first. + rss_peak_bytes: u64, + requested: usize, + applied_height: u32, + corpus_sha256: Sha256Hex, + spans: Vec, +} + +impl WindowUnit { + fn spans_of(&self, kind: WindowSpanKind) -> impl Iterator { + self.spans.iter().filter(move |span| span.kind == kind) + } + + fn durations_ns(&self, kind: WindowSpanKind) -> Vec { + self.spans_of(kind).map(|span| span.duration_ns).collect() + } + + fn cpu_ns(&self) -> u64 { + self.cpu_ticks.saturating_mul(WINDOW_CPU_TICK_NS) + } + + /// The window's commit transition is the longest hold in the unit; any + /// other hold (genesis bootstrap) stays in the raw record only. + fn window_transition_held_us(&self) -> u64 { + self.spans_of(WindowSpanKind::TransitionHeld) + .map(|span| span.duration_ns / 1_000) + .max() + .unwrap_or(0) + } +} + +fn duration_nanos(duration: Duration) -> u64 { + u64::try_from(duration.as_nanos()).unwrap_or(u64::MAX) +} + +fn read_proc_text(path: &str) -> String { + std::fs::read_to_string(path).unwrap_or_else(|error| panic!("read {path}: {error}")) +} + +/// The text after `field` on its line of a `/proc` file, trimmed. +fn proc_field(path: &str, field: &str) -> String { + read_proc_text(path) + .lines() + .find_map(|line| line.strip_prefix(field)) + .map(|rest| rest.trim().to_owned()) + .unwrap_or_else(|| panic!("{field} missing from {path}")) +} + +fn proc_kib(path: &str, field: &str) -> u64 { + proc_field(path, field) + .split_ascii_whitespace() + .next() + .and_then(|value| value.parse().ok()) + .unwrap_or_else(|| panic!("{field} in {path} is not a KiB count")) +} + +/// Process CPU time, user plus system, in `/proc` clock ticks. Counts every +/// thread including exited ones, so scoped worker threads are not lost; the +/// price is `WINDOW_CPU_TICK_NS` resolution, which is why runs summarise it +/// as a total beside the per-unit values. +fn process_cpu_ticks() -> u64 { + let stat = read_proc_text("/proc/self/stat"); + // Fields after the parenthesised command name start at field 3; utime + // and stime are fields 14 and 15. + let after_comm = stat + .rsplit_once(')') + .map(|(_, rest)| rest) + .unwrap_or_else(|| panic!("malformed /proc/self/stat")); + let mut fields = after_comm.split_ascii_whitespace().skip(11); + let mut next_ticks = |name: &str| -> u64 { + fields + .next() + .and_then(|value| value.parse().ok()) + .unwrap_or_else(|| panic!("{name} missing from /proc/self/stat")) + }; + let user = next_ticks("utime"); + let system = next_ticks("stime"); + user.saturating_add(system) +} + +/// Resets the process peak resident set to the current one (`proc(5)`: +/// writing `5` to `clear_refs`, Linux 4.0 and later), so the next `VmHWM` +/// read is the peak of the interval that follows. Returns the resident set +/// that interval starts from. +/// +/// Verifies the reset took effect: after it the peak must sit within 1 MiB of +/// the resident set, otherwise a stale lifetime peak would be reported as +/// this unit's peak. +fn reset_peak_rss() -> u64 { + std::fs::OpenOptions::new() + .write(true) + .open("/proc/self/clear_refs") + .and_then(|mut file| file.write_all(b"5")) + .unwrap_or_else(|error| panic!("reset peak RSS through /proc/self/clear_refs: {error}")); + let resident_kib = proc_kib("/proc/self/status", "VmRSS:"); + let peak_kib = proc_kib("/proc/self/status", "VmHWM:"); + assert!( + peak_kib.saturating_sub(resident_kib) <= 1024, + "VmHWM is {peak_kib} KiB after the reset but VmRSS is {resident_kib} KiB: \ + this kernel did not reset the peak resident set" + ); + resident_kib.saturating_mul(1024) +} + +/// Toolchain and build facts the six-identity record needs beyond +/// `EvidenceIdentity`. Fails closed on an unoptimised build. +fn window_toolchain_identity() -> sonic_rs::Value { + assert!( + !cfg!(debug_assertions), + "window attribution must run an optimised build (cargo bench profile)" + ); + let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()); + let output = std::process::Command::new(&rustc) + .arg("-vV") + .output() + .unwrap_or_else(|error| panic!("run {rustc:?} -vV for the toolchain identity: {error}")); + assert!(output.status.success(), "{rustc:?} -vV failed"); + let features: Vec<&str> = [ + ("fjall", cfg!(feature = "fjall")), + ("rocksdb", cfg!(feature = "rocksdb")), + ("redb", cfg!(feature = "redb")), + ("kernel", cfg!(feature = "kernel")), + ("zmq", cfg!(feature = "zmq")), + ] + .into_iter() + .filter_map(|(name, enabled)| enabled.then_some(name)) + .collect(); + json!({ + "rustc": String::from_utf8_lossy(&output.stdout).trim().to_owned(), + "profile": "bench", + "debug_assertions": false, + "features": features, + "allocator": "system", + "validation_engine": sample_engine_label(), + }) +} + +/// Host facts beyond `EvidenceIdentity.hardware`: kernel, pinned core set, +/// memory, and the filesystem holding the durable state. +/// +/// Fails closed on a RAM-backed temporary directory: a group flush there is +/// not a durable write, so `flush_wait_us` would measure nothing. +fn window_host_identity() -> sonic_rs::Value { + let tmpdir = std::env::temp_dir() + .canonicalize() + .unwrap_or_else(|error| panic!("resolve the temporary directory: {error}")); + let mounts = read_proc_text("/proc/self/mounts"); + let (device, mount_point, fstype) = mounts + .lines() + .filter_map(|line| { + let mut fields = line.split_ascii_whitespace(); + let device = fields.next()?; + let mount_point = fields.next()?; + let fstype = fields.next()?; + tmpdir + .starts_with(mount_point) + .then_some((device, mount_point, fstype)) + }) + .max_by_key(|(_, mount_point, _)| mount_point.len()) + .unwrap_or_else(|| panic!("no mount holds {}", tmpdir.display())); + assert!( + !matches!(fstype, "tmpfs" | "ramfs"), + "{} is on {fstype}: set TMPDIR to a disk-backed directory so group flushes are durable", + tmpdir.display() + ); + json!({ + "kernel": read_proc_text("/proc/sys/kernel/osrelease").trim(), + "cpus_allowed_list": proc_field("/proc/self/status", "Cpus_allowed_list:"), + "mem_total_kib": proc_kib("/proc/meminfo", "MemTotal:"), + "storage": { + "tmpdir": tmpdir.display().to_string(), + "mount_point": mount_point, + "fstype": fstype, + "device": device, + }, + }) +} + +/// Replays one window on fresh durable state and measures it. +/// +/// Setup (state, headers, pre-serialized payloads) is outside every timer +/// and every counter. The measured interval is the whole replay: the request +/// tick, the out-of-order staging tick, the applying tick, and one closing +/// tick that lets the window close its measurement-only intervals. +fn run_window_unit(scenario: WindowScenario) -> WindowUnit { + let mut fixture = + ProductionStateSyncFixture::window_replay(scenario.body_blocks).prebuild_run(); + let corpus_bytes: Vec = fixture.blocks.iter().flat_map(consensus_bytes).collect(); + let corpus_sha256 = Sha256Hex::digest(&corpus_bytes); + let recorder = WindowSeriesRecorder; + WINDOW_SPANS.lock().clear(); + let rss_start_bytes = reset_peak_rss(); + let cpu_before = process_cpu_ticks(); + let started = quanta::Instant::now(); + let mut apply_started = started; + let mut apply_done = started; + let mut settled = started; + let mut report = None; + metrics::with_local_recorder(&recorder, || { + report = Some(fixture.run_phased(|phase| match phase { + ReplayPhase::ApplyStart => apply_started = quanta::Instant::now(), + ReplayPhase::ApplyDone => apply_done = quanta::Instant::now(), + })); + fixture.settle_tick(); + settled = quanta::Instant::now(); + }); + let cpu_after = process_cpu_ticks(); + let rss_peak_bytes = proc_kib("/proc/self/status", "VmHWM:").saturating_mul(1024); + let spans = std::mem::take(&mut *WINDOW_SPANS.lock()); + let report = report.unwrap_or_else(|| panic!("window replay produced no report")); + drop(fixture); + WindowUnit { + started, + apply_started, + wall: settled.duration_since(started), + apply_wall: apply_done.duration_since(apply_started), + settle_wall: settled.duration_since(apply_done), + cpu_ticks: cpu_after.saturating_sub(cpu_before), + rss_start_bytes, + rss_peak_bytes, + requested: report.requested, + applied_height: report.applied_height, + corpus_sha256, + spans, + } +} + +/// Fails closed unless the replay did what the scenario names: every body +/// block applied, one group flush per started group covering every block, +/// no failed flush, and the transition hold present. +fn assert_window_unit(scenario: WindowScenario, unit: &WindowUnit) { + let body_blocks = usize::try_from(scenario.body_blocks) + .unwrap_or_else(|error| panic!("invalid window body count: {error}")); + assert_eq!( + unit.applied_height, scenario.body_blocks, + "{}: the replay must apply every body block", + scenario.id + ); + assert!( + unit.requested >= body_blocks, + "{}: the first getdata asked for {} blocks, fewer than the {body_blocks} replayed", + scenario.id, + unit.requested + ); + assert_eq!( + unit.spans_of(WindowSpanKind::FlushFailed).count(), + 0, + "{}: a group flush failed during the replay", + scenario.id + ); + assert_eq!( + unit.spans_of(WindowSpanKind::FlushWait).count(), + scenario.groups(), + "{}: expected one group flush per started group of {WINDOW_GROUP_BLOCKS} blocks", + scenario.id + ); + let flushed: u64 = unit + .spans_of(WindowSpanKind::FlushWait) + .filter_map(|span| span.blocks) + .sum(); + assert_eq!( + flushed, + u64::from(scenario.body_blocks), + "{}: group flushes must cover every body block", + scenario.id + ); + assert!( + unit.spans_of(WindowSpanKind::TransitionHeld) + .next() + .is_some(), + "{}: no transition_held_us event; this binary lacks the chainstate timing spans", + scenario.id + ); +} + +/// Order statistics of `values` plus whether p99 is supported; an empty set +/// is reported as unobserved, never as zero. +fn quantiles_json(values: &[u64]) -> sonic_rs::Value { + match evidence::Quantiles::of(values) { + Some(quantiles) => json!({ + "n": quantiles.n, + "p50": quantiles.p50, + "p95": quantiles.p95, + "p99": quantiles.p99, + "max": quantiles.max, + "p99_supported": quantiles.p99_supported(), + }), + None => json!({ "n": 0, "observed": false }), + } +} + +fn window_unit_json(index: usize, origin: quanta::Instant, unit: &WindowUnit) -> sonic_rs::Value { + let micros = |kind: WindowSpanKind| -> Vec { + unit.spans_of(kind) + .map(|span| span.duration_ns / 1_000) + .collect() + }; + json!({ + "unit": index, + "warmup": index < WINDOW_WARMUP_UNITS, + "started_offset_ns": duration_nanos(unit.started.duration_since(origin)), + "wall_ns": duration_nanos(unit.wall), + "apply_wall_ns": duration_nanos(unit.apply_wall), + "settle_wall_ns": duration_nanos(unit.settle_wall), + "cpu_ticks": unit.cpu_ticks, + "cpu_ns": unit.cpu_ns(), + "rss_start_bytes": unit.rss_start_bytes, + "rss_peak_bytes": unit.rss_peak_bytes, + "requested_blocks": unit.requested, + "applied_height": unit.applied_height, + "transition_held_us": micros(WindowSpanKind::TransitionHeld), + "flush_wait_us": micros(WindowSpanKind::FlushWait), + "flush_blocks": unit + .spans_of(WindowSpanKind::FlushWait) + .filter_map(|span| span.blocks) + .collect::>(), + "apply_idle_ns": unit.durations_ns(WindowSpanKind::ApplyIdle), + "download_blocked_by_apply_ns": unit.durations_ns(WindowSpanKind::DownloadBlocked), + }) +} + +/// Summaries over the measured units. Each is read beside the raw units in +/// the same file, never instead of them. Production spans are nested inside +/// the wall and inside each other, so none is summed with another. +fn window_summary_json(measured: &[WindowUnit]) -> sonic_rs::Value { + let walls: Vec = measured + .iter() + .map(|unit| duration_nanos(unit.wall)) + .collect(); + let apply_walls: Vec = measured + .iter() + .map(|unit| duration_nanos(unit.apply_wall)) + .collect(); + let settle_walls: Vec = measured + .iter() + .map(|unit| duration_nanos(unit.settle_wall)) + .collect(); + let cpu: Vec = measured.iter().map(WindowUnit::cpu_ns).collect(); + let rss_peak: Vec = measured.iter().map(|unit| unit.rss_peak_bytes).collect(); + let rss_over_start: Vec = measured + .iter() + .map(|unit| unit.rss_peak_bytes.saturating_sub(unit.rss_start_bytes)) + .collect(); + let transition_held_us: Vec = measured + .iter() + .map(WindowUnit::window_transition_held_us) + .collect(); + let pooled = |kind: WindowSpanKind| -> Vec { + measured + .iter() + .flat_map(|unit| unit.durations_ns(kind)) + .collect() + }; + let flush_wait_us: Vec = pooled(WindowSpanKind::FlushWait) + .into_iter() + .map(|nanos| nanos / 1_000) + .collect(); + let cpu_total_ns = cpu + .iter() + .fold(0_u64, |total, nanos| total.saturating_add(*nanos)); + let observed_units = |kind: WindowSpanKind| { + measured + .iter() + .filter(|unit| unit.spans_of(kind).next().is_some()) + .count() + }; + json!({ + "units": measured.len(), + "wall_ns": quantiles_json(&walls), + "apply_wall_ns": quantiles_json(&apply_walls), + "settle_wall_ns": quantiles_json(&settle_walls), + "cpu_ns": quantiles_json(&cpu), + "cpu_total_ns": cpu_total_ns, + "cpu_resolution_ns": WINDOW_CPU_TICK_NS, + "rss_peak_bytes": quantiles_json(&rss_peak), + "rss_peak_over_start_bytes": quantiles_json(&rss_over_start), + "transition_held_us": quantiles_json(&transition_held_us), + "flush_wait_us": quantiles_json(&flush_wait_us), + "apply_idle_ns": quantiles_json(&pooled(WindowSpanKind::ApplyIdle)), + "apply_idle_observed_units": observed_units(WindowSpanKind::ApplyIdle), + "download_blocked_by_apply_ns": quantiles_json(&pooled(WindowSpanKind::DownloadBlocked)), + "download_blocked_by_apply_observed_units": observed_units(WindowSpanKind::DownloadBlocked), + }) +} + +fn window_sample(identity: &EvidenceIdentity, path: &str, start_ns: u64, end_ns: u64) -> Sample { + Sample { + path: path.into(), + owner: "node".into(), + identity: identity.clone(), + interval: Interval { + kind: IntervalKind::Inside, + start_ns, + end_ns: end_ns.max(start_ns), + }, + cpu_ns: None, + elapsed_ns: end_ns.saturating_sub(start_ns), + rss_peak_bytes: None, + io_bytes: None, + storage_bytes: None, + } +} + +/// One wall sample, one apply-tick sample and one sample per captured span. +/// Offsets are on the run clock, so the nested spans overlap the wall and +/// the ledger refuses to add them. +fn window_evidence_samples( + identity: &EvidenceIdentity, + origin: quanta::Instant, + unit: &WindowUnit, +) -> Vec { + let offset = |at: quanta::Instant| duration_nanos(at.duration_since(origin)); + let mut samples = Vec::with_capacity(unit.spans.len().saturating_add(2)); + let started = offset(unit.started); + samples.push(Sample { + cpu_ns: Some(unit.cpu_ns()), + rss_peak_bytes: Some(unit.rss_peak_bytes), + ..window_sample( + identity, + "apply.window.replay_wall", + started, + started.saturating_add(duration_nanos(unit.wall)), + ) + }); + let apply_started = offset(unit.apply_started); + samples.push(window_sample( + identity, + "apply.window.apply_tick", + apply_started, + apply_started.saturating_add(duration_nanos(unit.apply_wall)), + )); + for span in &unit.spans { + let end = offset(span.end); + samples.push(window_sample( + identity, + span.kind.ledger_path(), + end.saturating_sub(span.duration_ns), + end, + )); + } + samples +} + +/// Runs one arm of one scenario in this process and writes its evidence: +/// every measured unit as identity-bearing ledger samples +/// (`target/benchmarks/window_attribution.toml`, append-only across runs) and +/// one self-contained JSON record with raw units, summaries and the six +/// identities (`target/benchmarks/window_attribution/`). +/// +/// The deterministic regtest workload proves the mechanism and the +/// measurement surface; its walls are not mainnet speed and fill no product +/// cell (`HPA-09`, `HPA-12`). +fn run_window_attribution(arm: WindowArm, scenario: WindowScenario) { + let host = window_host_identity(); + let toolchain = window_toolchain_identity(); + tracing::subscriber::set_global_default(WindowSpanSubscriber) + .unwrap_or_else(|error| panic!("window span subscriber install failed: {error}")); + let run_started_unix_ns = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_else(|error| panic!("system clock before the epoch: {error}")) + .as_nanos(); + let origin = quanta::Instant::now(); + let total_units = WINDOW_WARMUP_UNITS.saturating_add(WINDOW_MEASURED_UNITS); + let mut units = Vec::with_capacity(total_units); + for _ in 0..total_units { + let unit = run_window_unit(scenario); + assert_window_unit(scenario, &unit); + units.push(unit); + } + let corpus_sha256 = units[0].corpus_sha256; + assert!( + units.iter().all(|unit| unit.corpus_sha256 == corpus_sha256), + "{}: replay blocks differ between units", + scenario.id + ); + let mut identity = EvidenceIdentity::of_process(&production_state_config()) + .unwrap_or_else(|error| panic!("evidence identity: {error}")); + identity.corpus = Some(CorpusIdentity { + id: format!("sync_pipeline.window_replay.{}", scenario.id), + manifest_sha256: corpus_sha256, + }); + let measured = &units[WINDOW_WARMUP_UNITS..]; + + let cell = format!("window_attribution.{}.{}", scenario.id, arm.label()); + let ledger_path = bench_ledger_path("window_attribution.toml"); + let mut ledger = load_bench_ledger(&ledger_path); + if !ledger.cells.iter().any(|candidate| candidate.id == cell) { + ledger.cells.push(Cell { + id: cell.clone(), + samples: Vec::new(), + }); + } + for unit in measured { + for sample in window_evidence_samples(&identity, origin, unit) { + ledger + .record(&cell, sample) + .unwrap_or_else(|error| panic!("record window sample: {error}")); + } + } + store_bench_ledger(&ledger_path, &ledger); + + let record = json!({ + "schema": WINDOW_ATTRIBUTION_SCHEMA, + "claim_scope": "diagnostic: deterministic regtest replay; mechanism and measurement surface only, not mainnet speed, fills no product cell", + "arm": arm.label(), + "scenario": scenario.id, + "body_blocks": scenario.body_blocks, + "group_blocks": WINDOW_GROUP_BLOCKS, + "groups": scenario.groups(), + "run_started_unix_ns": run_started_unix_ns.to_string(), + "warmup_units": WINDOW_WARMUP_UNITS, + "measured_units": WINDOW_MEASURED_UNITS, + "identity": identity, + "toolchain": toolchain, + "host": host, + "ledger": { "path": ledger_path.display().to_string(), "cell": cell }, + "summary": window_summary_json(measured), + "units": units + .iter() + .enumerate() + .map(|(index, unit)| window_unit_json(index, origin, unit)) + .collect::>(), + }); + let record_dir = bench_ledger_path("window_attribution"); + std::fs::create_dir_all(&record_dir) + .unwrap_or_else(|error| panic!("window attribution record dir: {error}")); + let record_path = record_dir.join(format!( + "{}.{}.{run_started_unix_ns}.json", + arm.label(), + scenario.id + )); + let rendered = sonic_rs::to_vec_pretty(&record) + .unwrap_or_else(|error| panic!("render window attribution record: {error}")); + std::fs::write(&record_path, rendered) + .unwrap_or_else(|error| panic!("write window attribution record: {error}")); + + println!( + "window_attribution arm={} scenario={} measured_units={} record={}", + arm.label(), + scenario.id, + measured.len(), + record_path.display() + ); + let walls: Vec = measured.iter().map(|unit| unit.wall).collect(); + let apply_walls: Vec = measured.iter().map(|unit| unit.apply_wall).collect(); + print_percentiles("window_attribution_wall", &walls); + print_percentiles("window_attribution_apply_tick", &apply_walls); +} + +/// Validates the `--window-attribution` invocation shape before any fixture +/// work: the flag, one arm, one scenario, and nothing else. Cargo appends +/// `--bench`, which carries no meaning here and is ignored. +fn window_attribution_cli(args: &[String]) -> (WindowArm, WindowScenario) { + let args: Vec<&str> = args + .iter() + .map(String::as_str) + .filter(|arg| *arg != "--bench") + .collect(); + let fail = |message: &str| -> ! { + eprintln!("window-attribution: {message}"); + eprintln!( + "usage: sync_pipeline --window-attribution <{}>", + WINDOW_SCENARIOS.map(|scenario| scenario.id).join("|") + ); + std::process::exit(2); + }; + if args.len() != 3 || args[0] != "--window-attribution" { + fail("expected exactly --window-attribution "); + } + let Some(arm) = WindowArm::parse(args[1]) else { + fail("unknown arm"); + }; + let Some(scenario) = WINDOW_SCENARIOS + .iter() + .copied() + .find(|scenario| scenario.id == args[2]) + else { + fail("unknown scenario"); + }; + (arm, scenario) +} + /// Binary entrypoint: `--mrp-verify` runs the untimed real-fixture /// verification path with no Criterion involvement and no timing output; +/// `--window-attribution ARM SCENARIO` replays one windowed apply on fresh +/// durable state, measures it, and writes ledger and JSON evidence; /// `--mrp-list-counted` lists the counted row IDs without fixture /// construction or sampling; `--mrp-counted ROW_ID` runs one counted row /// through the shared measured-work sampler; `--mrp-verify-counted` runs @@ -4405,6 +5367,11 @@ fn main() { run_counted_row(row); return; } + if args.iter().any(|arg| arg == "--window-attribution") { + let (arm, scenario) = window_attribution_cli(&args); + run_window_attribution(arm, scenario); + return; + } if args.iter().any(|arg| arg == "--mrp-verify-counted") { require_bare_counted_flag(&args, "--mrp-verify-counted"); mrp_verify_counted(); diff --git a/docs/benchmarks/window-pipeline-attribution.md b/docs/benchmarks/window-pipeline-attribution.md new file mode 100644 index 000000000..be1f76669 --- /dev/null +++ b/docs/benchmarks/window-pipeline-attribution.md @@ -0,0 +1,177 @@ +# Window pipeline and group commit attribution (ENG-91, #1607) + +Anchors: John Carmack, Richard Feynman + +This page owns the measurement protocol, the evidence schema and the promotion +gates for the windowed block apply and its durable group commit (ENG-91, issue +#1607). It records no measured value: every number lives in the files a run +generates, never here (`HPA-08`, `HPA-12`). + +Status: `planned_not_executed`. No arm has run under this protocol, so no +cell, ratio or percentile is on this page. Rules shared by all cells are in +[README.md](README.md); the attribution method is +[`docs/contracts/hot-path-attribution.md`](../contracts/hot-path-attribution.md). + +## 1. What exists and what does not + +| Item | State | +|---|---| +| Baseline pipeline | `apply_window_admitted` in `crates/chainstate/src/window.rs`: `prove_window` proves the whole window, then the sequential per-block loop stages blocks into a `WindowGroup` and flushes at 64 blocks or 8 MiB, all under one chain transition. | +| Candidate S-A | Not implemented. The tree has no `prove_chunk` and no scoped worker thread. Nothing here adopts it. | +| Diagnostic spans | `transition_held_us` and `flush_wait_us` exist (section 2). | +| Existing series | `node.sync.apply_idle_seconds` and `node.sync.download_blocked_by_apply_seconds` exist in `crates/p2p/src/download_window.rs`. | +| Replay harness | `crates/node/benches/sync_pipeline.rs`, `--window-attribution` (section 3). | +| Candidate arm | Defined so it can run once S-A exists. Until then only the control arm is measurable, and no control-against-candidate comparison may be written. | + +Out-of-transition pipelines (S-B) and verify-only lock release (S-C) stay +rejected: tip identity alone cannot fence `replace_from`, header-gate changes, +reorg and script-cache takes. + +## 2. Diagnostic spans + +Both spans use `quanta::Instant` and `tracing::debug!`. They create no metric +family, change no lock scope and never feed a decision. + +| Span | Acquire or start | Release or end | Emitted | +|---|---|---|---| +| `transition_held_us` | `begin_chain_transition` in `crates/chainstate/src/lib.rs`, the instant `chain_transition.lock()` returns | `TransitionGuard::drop`, or the admission refusal that returns before a guard exists | Every path: success, fatal unwind, refusal | +| `flush_wait_us` | `flush_group` in `crates/chainstate/src/window.rs`, entering `group.flush` with a non-empty staged prefix | `group.flush` returns: staged-tip hook, body sync, head commit, journal, tip publication | Success event `window: group flush` with `blocks`; failure event `window: group flush failed` with `flush_error` | + +`flush_wait_us` is nested inside `transition_held_us`, and `window: group +flush` is a development diagnostic beside `durable_head: group commit`, whose +`sync_us` and `commit_us` are sub-intervals of the same wait. The spans are +reported side by side and never added (`HPA-12`). + +## 3. Replay harness + +### 3.1 Invocation + +Run from the repository root so `rust-toolchain.toml` selects the toolchain +that built the binary. `TMPDIR` must name a directory on a disk-backed +filesystem, because the harness refuses `tmpfs` and `ramfs`: a group flush +there is not a durable write. + +```bash +cargo bench -p bitcoin-rs-node --bench sync_pipeline -- \ + --window-attribution +``` + +The flag takes exactly one arm and one scenario. Cargo appends `--bench`, which +the parser ignores. Pin the cores with `taskset -c ` before `cargo bench`. +One invocation is one run: it executes every unit of one arm and one scenario in +one process. + +### 3.2 Scenarios and units + +| Scenario | Body blocks | Group flushes | Shape | +|---|---|---|---| +| `single_group` | 64 | 1 | The largest window that flushes once. | +| `multi_group` | 128 | 2 | A mid-window flush separates two full groups. | + +The blocks are the coinbase-only regtest blocks of the existing +`deterministic_initial_sync_proxy_production_state_128_blocks` arm: production +`NodeState`, the compiled storage backend, one synthetic peer and a +4,096-header chain. Only the body count differs between scenarios. The 8 MiB +byte bound of a group is not reached by these blocks; only the 64-block bound is +exercised. + +Every unit builds fresh durable state outside all timers, then replays four +production ticks. One run is 5 warmup units, kept raw and excluded from every +summary and from the ledger, followed by 100 measured units. + +| Tick | Work | +|---|---| +| 1 | Request: the sync issues the first getdata. | +| 2 | Stage: every block except the head arrives out of order. | +| 3 | Apply: the head arrives and the window applies. This tick is `apply_wall_ns`. | +| 4 | Settle: closes the window's measurement-only intervals, which record only when they close. | + +### 3.3 Captured per unit + +| Field | Source | +|---|---| +| `wall_ns` | Ticks 1 to 4. This is the target wall of the replay. | +| `apply_wall_ns`, `settle_wall_ns` | Tick 3 and tick 4 alone. | +| `cpu_ns`, `cpu_ticks` | `/proc/self/stat` user plus system, every thread including exited ones. Resolution is one tick of 10 ms, so read `cpu_total_ns` beside the per-unit values. | +| `rss_start_bytes`, `rss_peak_bytes` | `VmRSS` before the unit, and `VmHWM` after resetting the peak through `/proc/self/clear_refs`. The harness checks the reset took effect. | +| `transition_held_us` | Every `transition_held_us` event in the unit. The summary uses the longest, which is the window commit; other holds stay in the raw record. | +| `flush_wait_us`, `flush_blocks` | One event per group flush, with the blocks it covered. | +| `apply_idle_ns`, `download_blocked_by_apply_ns` | Observations of the two existing histograms, through a recorder scoped to the replay. An empty list means unobserved, never zero. | + +The harness stops with a panic, and records nothing, when a unit applies fewer +blocks than the scenario names, issues a flush count other than one per started +group, covers fewer blocks than the window, hits a failed flush, lacks a +`transition_held_us` event, runs a build with debug assertions, or finds a +non-disk `TMPDIR`. + +### 3.4 Evidence artifacts + +| Artifact | Path under `target/benchmarks/` of the tree that built the binary | Content | +|---|---|---| +| Ledger | `window_attribution.toml` | Append-only `bitcoin-rs-hot-path-ledger-v2` cells `window_attribution..`. Every measured unit adds identity-bearing samples at paths `apply.window.replay_wall` (with CPU and peak RSS), `apply.window.apply_tick`, `apply.window.transition_held`, `apply.window.flush_wait`, `p2p.window.apply_idle` and `p2p.window.download_blocked_by_apply`. Spans carry run-clock intervals, so the ledger refuses to add nested ones. | +| Record | `window_attribution/...json` | Schema `bitcoin-rs-window-attribution-v1`: the identities, every raw unit including warmups, and a summary of p50, p95, p99 and max with the sample count and whether p99 is supported (at least 100 samples, `HPA-13`). | + +Repeated runs append and are never collapsed. A run that panics writes neither +file. + +### 3.5 Six identities + +Each sample carries the six identities of [README.md](README.md). + +| Identity | Where recorded | +|---|---| +| Artifact | `identity.binary_sha256` and `identity.version` | +| Configuration | `identity.config_sha256`, `identity.backend`, and `toolchain.features`, `toolchain.allocator`, `toolchain.validation_engine` | +| Corpus | `identity.corpus`: id `sync_pipeline.window_replay.` and the SHA-256 of the replayed block bytes. The corpus is synthetic regtest, not `C150` or `Cmodern`. | +| Durability | `identity.durability` and `host.storage` (filesystem type and device holding the state) | +| Toolchain | `toolchain.rustc` (`rustc -vV`), `toolchain.profile` and `toolchain.debug_assertions` | +| Hardware | `identity.hardware` plus `host.kernel`, `host.cpus_allowed_list`, `host.mem_total_kib` | + +The two arms must differ in `binary_sha256` and agree in every other identity. +A pair that shares a binary digest is not a comparison. + +## 4. Experiment design + +The control arm is the tree with the timing spans and the sequential pipeline. +The candidate arm is that tree plus S-A, and its binary must carry the spans too, +because the harness refuses a build without `transition_held_us`. The arm label +names custody only; the artifact digest proves the programs differ. + +Run each scenario alternately, control then candidate, three times +(`A-B-A-B-A-B`). With no candidate in the tree, run the control arm three times +per scenario: the runs set the stability and the host noise floor, and no +comparison is produced. + +## 5. Gates for the replay (`HPA-13`) + +All statistics come from the measured units of the retained records. + +| Gate | Rule | +|---|---| +| Run statistic | The median of `wall_ns` over one run's 100 units. | +| Arm statistic | The median of the arm's three run statistics. | +| Stability | Each run statistic lies within 5% of its arm statistic. | +| Target | In `multi_group`, control arm statistic divided by candidate arm statistic is at least 1.05. | +| Noise | The improvement exceeds the control arm's observed range, `max - min` of its run statistics. A 5% magnitude alone is not enough. | +| Non-target | For `single_group` wall, and for CPU and peak RSS in both scenarios, the candidate's median is at most 3% above control and its pooled p99 at most 5% above. | +| Reporting | p50, p95, p99 and max of wall, CPU and RSS, plus both spans and both series, with sample counts. A p99 over fewer than 100 samples is flagged and not read. | + +The stated target needs a workload that carries script checks. The replayed +blocks are coinbase-only, so they hold no scripts for S-A to overlap with a +neighbour's commit: this replay can attribute the transition hold and the flush +wait and can refuse S-A, but a pass here does not adopt it. Adoption also needs +the product cells: + +- Replay results are diagnostic and fill no ledger cell (`HPA-09`, `HPA-12`). +- A product-cell claim needs seven valid hashed interleaved Core 31.1 and + bitcoin-rs pairs (`HPA-02`), the observed noise floor (`HPA-03`) and the + overlap-aware account (`HPA-04`), run by `tools/benchmark-campaign/` on the + campaign corpora of + [`docs/contracts/campaign-corpora.md`](../contracts/campaign-corpora.md). +- If the gates fail, the sequential `prove_window` and grouped commit stay and + the rejection is recorded; no queue or epoch is added. + +## 6. Ledger + +No measured cell exists. The first runs create the files of section 3.4; this +page then keeps only the protocol and links to the retained records. From 5790ca4f653d8926de2e2d779a39a1fb9fdd2c46 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:56:36 +0900 Subject: [PATCH 02/23] Attribute retained block staging memory Measure retained heap bytes for decoded blocks and their owners. Expose staging counters and distinguish retained memory from wire payload size so operators can interpret the measurements. See also: #1572 --- crates/node/src/metrics.rs | 18 +- crates/p2p/src/block_stager.rs | 357 +++++++++++++++++++++++++++++-- crates/p2p/src/sync/telemetry.rs | 49 +++++ crates/primitives/src/block.rs | 204 ++++++++++++++++++ crates/primitives/src/script.rs | 66 ++++++ crates/primitives/src/tx.rs | 76 +++++++ docs/observability.md | 34 +++ 7 files changed, 785 insertions(+), 19 deletions(-) diff --git a/crates/node/src/metrics.rs b/crates/node/src/metrics.rs index 1cfcce605..c45da1f7a 100644 --- a/crates/node/src/metrics.rs +++ b/crates/node/src/metrics.rs @@ -211,7 +211,23 @@ fn describe_node_metrics() { ); metrics::describe_gauge!( "node.sync.staged_bytes_high_water", - "highest staged byte total observed" + "highest staged serialized byte total observed" + ); + metrics::describe_gauge!( + "node.sync.staged_decoded_bytes", + "decoded heap capacity bytes owned by staged blocks, excluding allocator rounding" + ); + metrics::describe_gauge!( + "node.sync.staged_retained_bytes", + "attributed staged bytes: decoded heap capacity plus preserved serialized buffers, excluding allocator rounding; not process RSS" + ); + metrics::describe_gauge!( + "node.sync.staged_retained_bytes_high_water", + "highest attributed staged retained byte total observed" + ); + metrics::describe_gauge!( + "node.sync.inbound_block_queue_depth", + "block bodies queued in the bounded inbound channel when the sync drain pass emitted its gauges, after it emptied the receiver; a residual, not a peak" ); metrics::describe_counter!( "storage.writes_total", diff --git a/crates/p2p/src/block_stager.rs b/crates/p2p/src/block_stager.rs index 84c270b3f..e82843406 100644 --- a/crates/p2p/src/block_stager.rs +++ b/crates/p2p/src/block_stager.rs @@ -24,12 +24,21 @@ pub(crate) struct BlockStager { budget: SyncBudget, received: HashMap, received_order: VecDeque, + /// Serialized bytes of staged bodies. This is the admission fact: the byte + /// budget and `staged_byte_headroom` read it and never the attribution + /// totals below. received_bytes: usize, + /// Sum of `ReceivedBlock::decoded_bytes` over staged bodies. Attribution + /// only: nothing admits or evicts on it. + decoded_bytes: usize, next_received_deadline: Option, /// Highest staged-block population observed; feeds the high-water gauge. received_blocks_high_water: usize, /// Highest staged-byte total observed; feeds the high-water gauge. received_bytes_high_water: usize, + /// Highest `decoded_bytes + received_bytes` observed; feeds the + /// retained-bytes high-water gauge. + retained_bytes_high_water: usize, /// Live count of staged bodies still awaiting the admission clauses /// because the tree could not resolve their hash at arrival. The shared /// quota on this count bounds orphan floods (`MAX_UNRESOLVED_STAGED_BODIES`). @@ -41,13 +50,20 @@ struct ReceivedBlock { block: Block, // Preserved P2P wire payload, reused by the unified `apply_block` to // skip reserialization. This is a second buffer (~block size) held next to - // the decoded `block` while it waits for its predecessor, so a fully - // out-of-order staging window holds roughly twice `bytes` per entry; both - // are still bounded by the received-block budget (max_received_blocks / - // max_received_bytes). + // the decoded `block` while it waits for its predecessor. The + // received-block budget (max_received_blocks / max_received_bytes) counts + // only `bytes`; `decoded_bytes` attributes the decoded side and admits + // nothing. serialized: bytes::Bytes, received_at: Instant, bytes: usize, + /// `block.retained_heap_bytes()` measured once when the stager took + /// ownership of this `Block` allocation (`insert`, or `restore_many` for + /// the allocation it receives). The staged block is never mutated, so the + /// measurement stays exact until the entry leaves. It excludes the inline + /// `Block` value, allocator rounding, and the `serialized` buffer, which + /// `bytes` already counts. + decoded_bytes: usize, /// Delivering connection, retained so a header-admission fault on the /// staged body can blame the right peer. source: Option, @@ -114,9 +130,11 @@ impl BlockStager { received: HashMap::with_capacity(budget.max_received_blocks), received_order: VecDeque::with_capacity(budget.max_received_blocks), received_bytes: 0, + decoded_bytes: 0, next_received_deadline: None, received_blocks_high_water: 0, received_bytes_high_water: 0, + retained_bytes_high_water: 0, gate_pending_count: 0, } } @@ -133,6 +151,28 @@ impl BlockStager { self.received_bytes } + /// Decoded heap bytes owned by currently staged blocks (capacity-based, + /// excluding allocator rounding and the preserved serialized buffers). + #[must_use] + pub(crate) const fn decoded_bytes(&self) -> usize { + self.decoded_bytes + } + + /// Attributed retained bytes of currently staged bodies: decoded heap + /// bytes plus the preserved serialized buffers. Excludes allocator + /// rounding and says nothing about process RSS. + #[must_use] + pub(crate) const fn retained_attributed_bytes(&self) -> usize { + self.decoded_bytes.saturating_add(self.received_bytes) + } + + /// Highest attributed retained total ever observed this run; feeds the + /// retained-bytes high-water gauge. + #[must_use] + pub(crate) const fn retained_attributed_bytes_high_water(&self) -> usize { + self.retained_bytes_high_water + } + /// Highest staged-block population ever observed this run. #[must_use] pub(crate) const fn received_high_water(&self) -> usize { @@ -201,16 +241,18 @@ impl BlockStager { return StagedBlock::DroppedForRetry { hash }; } + let decoded_bytes = block.retained_heap_bytes(); entry.insert(ReceivedBlock { block, serialized, received_at: now, bytes, + decoded_bytes, source, gate_pending: false, }); self.received_order.push_back(hash); - self.received_bytes = self.received_bytes.saturating_add(bytes); + self.count_staged(bytes, decoded_bytes); self.received_blocks_high_water = self.received_blocks_high_water.max(self.received.len()); self.received_bytes_high_water = self.received_bytes_high_water.max(self.received_bytes); self.track_received_deadline(now); @@ -303,6 +345,11 @@ impl BlockStager { pub(crate) fn restore_many(&mut self, drained: impl IntoIterator) { for drained in drained { let gate_pending = drained.gate_pending; + // Measured here instead of carried from the drain: the committer + // restores clones, and a cloned block's vectors are exactly + // sized, so the original's measurement would misattribute the + // allocation this entry now owns. + let decoded_bytes = drained.block.retained_heap_bytes(); let previous = self.received.insert( drained.hash, ReceivedBlock { @@ -310,22 +357,20 @@ impl BlockStager { serialized: drained.serialized, received_at: drained.received_at, bytes: drained.bytes, + decoded_bytes, source: drained.source, gate_pending, }, ); if let Some(previous) = previous { - self.received_bytes = self.received_bytes.saturating_sub(previous.bytes); - if previous.gate_pending { - self.gate_pending_count = self.gate_pending_count.saturating_sub(1); - } + self.release(&previous); } else if !self.received_order_contains(&drained.hash) { self.received_order.push_back(drained.hash); } if gate_pending { self.gate_pending_count += 1; } - self.received_bytes = self.received_bytes.saturating_add(drained.bytes); + self.count_staged(drained.bytes, decoded_bytes); self.track_received_deadline(drained.received_at); } self.maybe_compact_received_order(); @@ -333,10 +378,7 @@ impl BlockStager { fn take_entry(&mut self, hash: &Hash256) -> Option { let entry = self.received.remove(hash)?; - if entry.gate_pending { - self.gate_pending_count = self.gate_pending_count.saturating_sub(1); - } - self.received_bytes = self.received_bytes.saturating_sub(entry.bytes); + self.release(&entry); Some(DrainedBlock { hash: *hash, block: entry.block, @@ -363,6 +405,7 @@ impl BlockStager { let mut dropped = Vec::new(); let mut received_bytes = self.received_bytes; + let mut decoded_bytes = self.decoded_bytes; let mut gate_pending_dropped = 0_usize; let mut next_received_deadline = None; let timeout = self.budget.received_timeout; @@ -376,12 +419,14 @@ impl BlockStager { return true; } received_bytes = received_bytes.saturating_sub(entry.bytes); + decoded_bytes = decoded_bytes.saturating_sub(entry.decoded_bytes); gate_pending_dropped += usize::from(entry.gate_pending); dropped.push(*hash); false }); self.gate_pending_count = self.gate_pending_count.saturating_sub(gate_pending_dropped); self.received_bytes = received_bytes; + self.decoded_bytes = decoded_bytes; self.next_received_deadline = next_received_deadline; self.maybe_compact_received_order(); dropped @@ -430,10 +475,7 @@ impl BlockStager { fn remove(&mut self, hash: &Hash256) -> Option { let entry = self.received.remove(hash)?; - if entry.gate_pending { - self.gate_pending_count = self.gate_pending_count.saturating_sub(1); - } - self.received_bytes = self.received_bytes.saturating_sub(entry.bytes); + self.release(&entry); Some(*hash) } @@ -519,6 +561,29 @@ impl BlockStager { .retain(|hash| received.contains_key(hash)); } + /// Counts one entry that just entered `received`: adds its serialized and + /// decoded bytes, then advances the retained high-water mark, so a + /// restore that refills the set past an earlier peak is observed like an + /// insert. The serialized high-water marks stay insert-only, as shipped. + fn count_staged(&mut self, bytes: usize, decoded_bytes: usize) { + self.received_bytes = self.received_bytes.saturating_add(bytes); + self.decoded_bytes = self.decoded_bytes.saturating_add(decoded_bytes); + self.retained_bytes_high_water = self + .retained_bytes_high_water + .max(self.retained_attributed_bytes()); + } + + /// Reverses [`Self::count_staged`] and the gate-pending count for an + /// entry that just left `received`. `prune_expired` batches the same + /// subtraction over its retained set. + fn release(&mut self, entry: &ReceivedBlock) { + if entry.gate_pending { + self.gate_pending_count = self.gate_pending_count.saturating_sub(1); + } + self.received_bytes = self.received_bytes.saturating_sub(entry.bytes); + self.decoded_bytes = self.decoded_bytes.saturating_sub(entry.decoded_bytes); + } + fn received_order_contains(&self, hash: &Hash256) -> bool { self.received_order.contains(hash) } @@ -1169,4 +1234,260 @@ mod tests { assert_eq!(stager.received_len(), 1); assert!(stager.contains(&expected_hash)); } + + /// Two bodies with identical consensus bytes and different decoded heap + /// ownership: `fat` keeps spare `txs` capacity, `lean` is exactly sized. + fn lean_and_fat_blocks() -> (Block, Block, bytes::Bytes) { + let genesis = Network::Regtest.genesis_block(); + let serialized = bytes::Bytes::from(consensus_bytes(&genesis)); + let lean = genesis.clone(); + let mut fat = genesis; + fat.txs.reserve_exact(64); + assert!(fat.retained_heap_bytes() > lean.retained_heap_bytes()); + (lean, fat, serialized) + } + + #[test] + fn decoded_attribution_follows_insert_drain_and_restore_apart_from_serialized_bytes() { + let (lean, fat, serialized) = lean_and_fat_blocks(); + let wire = serialized.len(); + let lean_bytes = lean.retained_heap_bytes(); + let fat_bytes = fat.retained_heap_bytes(); + let mut stager = BlockStager::new(default_sync_budget(Network::Regtest)); + let now = Instant::now(); + let first = Hash256::from_le_bytes(&[0x81; 32]); + let second = Hash256::from_le_bytes(&[0x82; 32]); + assert_eq!(stager.decoded_bytes(), 0); + assert_eq!(stager.retained_attributed_bytes(), 0); + assert_eq!(stager.retained_attributed_bytes_high_water(), 0); + + stager.insert(first, None, lean, serialized.clone(), None, now); + stager.insert(second, None, fat, serialized, None, now); + + let peak = lean_bytes + fat_bytes + 2 * wire; + assert_eq!(stager.received_bytes(), 2 * wire); + assert_eq!(stager.decoded_bytes(), lean_bytes + fat_bytes); + assert_eq!(stager.retained_attributed_bytes(), peak); + assert_eq!(stager.retained_attributed_bytes_high_water(), peak); + + let mut drained = stager.drain_expected_prefix(&[first, second]); + assert_eq!(drained.len(), 2); + assert_eq!(stager.received_len(), 0); + assert_eq!(stager.received_bytes(), 0); + assert_eq!(stager.decoded_bytes(), 0); + assert_eq!(stager.retained_attributed_bytes(), 0); + assert_eq!(stager.retained_attributed_bytes_high_water(), peak); + + // The committer restores clones, whose vectors are exactly sized: the + // stager must attribute the allocation it now owns, not the drained + // original's. + let fat_tail = drained.split_off(1); + let clone = fat_tail[0].clone(); + let clone_bytes = clone.block.retained_heap_bytes(); + assert!(clone_bytes < fat_bytes); + assert_eq!(fat_tail[0].block.retained_heap_bytes(), fat_bytes); + stager.restore_many([clone]); + assert_eq!(stager.received_len(), 1); + assert_eq!(stager.received_bytes(), wire); + assert_eq!(stager.decoded_bytes(), clone_bytes); + assert_eq!(stager.retained_attributed_bytes(), clone_bytes + wire); + assert_eq!(stager.retained_attributed_bytes_high_water(), peak); + + stager.restore_many(drained); + assert_eq!(stager.received_len(), 2); + assert_eq!(stager.received_bytes(), 2 * wire); + assert_eq!(stager.decoded_bytes(), clone_bytes + lean_bytes); + assert_eq!( + stager.retained_attributed_bytes(), + clone_bytes + lean_bytes + 2 * wire + ); + assert_eq!(stager.retained_attributed_bytes_high_water(), peak); + + let drained = stager.drain_expected_prefix(&[first, second]); + assert_eq!(drained.len(), 2); + assert_eq!(stager.received_bytes(), 0); + assert_eq!(stager.decoded_bytes(), 0); + assert_eq!(stager.retained_attributed_bytes(), 0); + assert_eq!(stager.retained_attributed_bytes_high_water(), peak); + } + + #[test] + fn restore_refilling_past_the_earlier_peak_advances_only_the_retained_high_water_mark() { + let (lean, fat, serialized) = lean_and_fat_blocks(); + let wire = serialized.len(); + let lean_bytes = lean.retained_heap_bytes(); + let fat_bytes = fat.retained_heap_bytes(); + let mut stager = BlockStager::new(default_sync_budget(Network::Regtest)); + let now = Instant::now(); + let parked = Hash256::from_le_bytes(&[0x91; 32]); + let refill = Hash256::from_le_bytes(&[0x92; 32]); + + stager.insert(parked, None, lean, serialized.clone(), None, now); + let drained = stager.drain_expected_prefix(&[parked]); + stager.insert(refill, None, fat, serialized, None, now); + assert_eq!(stager.received_high_water(), 1); + assert_eq!(stager.received_bytes_high_water(), wire); + assert_eq!( + stager.retained_attributed_bytes_high_water(), + fat_bytes + wire + ); + + stager.restore_many(drained); + + let combined = lean_bytes + fat_bytes + 2 * wire; + assert_eq!(stager.retained_attributed_bytes(), combined); + assert_eq!(stager.retained_attributed_bytes_high_water(), combined); + // The serialized marks are insert-only: restore never moved them. + assert_eq!(stager.received_bytes(), 2 * wire); + assert_eq!(stager.received_bytes_high_water(), wire); + assert_eq!(stager.received_high_water(), 1); + } + + #[test] + fn restore_replacing_a_staged_entry_swaps_its_attribution() { + let (lean, fat, serialized) = lean_and_fat_blocks(); + let wire = serialized.len(); + let lean_bytes = lean.retained_heap_bytes(); + let fat_bytes = fat.retained_heap_bytes(); + let mut stager = BlockStager::new(default_sync_budget(Network::Regtest)); + let now = Instant::now(); + let hash = Hash256::from_le_bytes(&[0xa1; 32]); + + stager.insert(hash, None, fat, serialized.clone(), None, now); + let drained = stager.drain_expected_prefix(&[hash]); + stager.insert(hash, None, lean, serialized, None, now); + assert_eq!(stager.decoded_bytes(), lean_bytes); + + stager.restore_many(drained); + + assert_eq!(stager.received_len(), 1); + assert_eq!(stager.received_bytes(), wire); + assert_eq!(stager.decoded_bytes(), fat_bytes); + assert_eq!(stager.retained_attributed_bytes(), fat_bytes + wire); + assert_eq!( + stager.retained_attributed_bytes_high_water(), + fat_bytes + wire + ); + + let drained = stager.drain_expected_prefix(&[hash]); + assert_eq!(drained.len(), 1); + assert_eq!(stager.received_bytes(), 0); + assert_eq!(stager.decoded_bytes(), 0); + assert_eq!(stager.retained_attributed_bytes(), 0); + } + + #[test] + fn prune_expired_releases_decoded_and_serialized_attribution() { + let (lean, fat, serialized) = lean_and_fat_blocks(); + let wire = serialized.len(); + let lean_bytes = lean.retained_heap_bytes(); + let fat_bytes = fat.retained_heap_bytes(); + let mut budget = default_sync_budget(Network::Regtest); + budget.received_timeout = Duration::from_secs(10); + let mut stager = BlockStager::new(budget); + let now = Instant::now(); + let stale_at = now + .checked_sub(Duration::from_secs(11)) + .unwrap_or_else(|| panic!("test instant underflow")); + let stale = Hash256::from_le_bytes(&[0xb1; 32]); + let fresh = Hash256::from_le_bytes(&[0xb2; 32]); + + stager.insert(stale, None, fat, serialized.clone(), None, stale_at); + stager.insert(fresh, None, lean, serialized, None, now); + let peak = fat_bytes + lean_bytes + 2 * wire; + assert_eq!(stager.retained_attributed_bytes_high_water(), peak); + + assert_eq!(stager.prune_expired(now), [stale]); + assert_eq!(stager.received_bytes(), wire); + assert_eq!(stager.decoded_bytes(), lean_bytes); + assert_eq!(stager.retained_attributed_bytes(), lean_bytes + wire); + + assert_eq!(stager.prune_expired(now + Duration::from_secs(10)), [fresh]); + assert_eq!(stager.received_bytes(), 0); + assert_eq!(stager.decoded_bytes(), 0); + assert_eq!(stager.retained_attributed_bytes(), 0); + assert_eq!(stager.retained_attributed_bytes_high_water(), peak); + } + + #[test] + fn eviction_discard_and_retire_release_attribution() { + let (lean, fat, serialized) = lean_and_fat_blocks(); + let wire = serialized.len(); + let lean_bytes = lean.retained_heap_bytes(); + let fat_bytes = fat.retained_heap_bytes(); + let mut budget = default_sync_budget(Network::Regtest); + budget.max_received_blocks = 1; + let mut stager = BlockStager::new(budget); + let now = Instant::now(); + let evicted = Hash256::from_le_bytes(&[0xc1; 32]); + let kept = Hash256::from_le_bytes(&[0xc2; 32]); + let retired = Hash256::from_le_bytes(&[0xc3; 32]); + + stager.insert(evicted, None, fat, serialized.clone(), None, now); + let super::StagedBlock::Memory { dropped, .. } = + stager.insert(kept, None, lean.clone(), serialized.clone(), None, now) + else { + panic!("incoming block should stage after evicting the older one"); + }; + assert_eq!(dropped, [evicted]); + // Both bodies were resident until the count-budget eviction ran. + assert_eq!( + stager.retained_attributed_bytes_high_water(), + fat_bytes + lean_bytes + 2 * wire + ); + assert_eq!(stager.received_bytes(), wire); + assert_eq!(stager.decoded_bytes(), lean_bytes); + assert_eq!(stager.retained_attributed_bytes(), lean_bytes + wire); + + assert!(stager.discard(&kept)); + assert_eq!(stager.received_bytes(), 0); + assert_eq!(stager.decoded_bytes(), 0); + assert_eq!(stager.retained_attributed_bytes(), 0); + + stager.insert(retired, None, lean, serialized, None, now); + assert_eq!(stager.decoded_bytes(), lean_bytes); + assert!(stager.retire_applied(&retired)); + assert_eq!(stager.received_bytes(), 0); + assert_eq!(stager.decoded_bytes(), 0); + assert_eq!(stager.retained_attributed_bytes(), 0); + } + + #[test] + fn duplicate_and_refused_inserts_leave_attribution_untouched() { + let (lean, fat, serialized) = lean_and_fat_blocks(); + let wire = serialized.len(); + let lean_bytes = lean.retained_heap_bytes(); + let mut budget = default_sync_budget(Network::Regtest); + budget.max_received_bytes = wire; + let mut stager = BlockStager::new(budget); + let now = Instant::now(); + let staged = Hash256::from_le_bytes(&[0xd1; 32]); + let refused = Hash256::from_le_bytes(&[0xd2; 32]); + + stager.insert(staged, None, lean, serialized.clone(), None, now); + let settled = ( + stager.decoded_bytes(), + stager.retained_attributed_bytes(), + stager.retained_attributed_bytes_high_water(), + ); + assert_eq!(settled, (lean_bytes, lean_bytes + wire, lean_bytes + wire)); + + assert!(matches!( + stager.insert(staged, None, fat.clone(), serialized.clone(), None, now), + super::StagedBlock::AlreadyStaged + )); + assert!(matches!( + stager.insert(refused, Some(staged), fat, serialized, None, now), + super::StagedBlock::DroppedForRetry { .. } + )); + assert_eq!( + ( + stager.decoded_bytes(), + stager.retained_attributed_bytes(), + stager.retained_attributed_bytes_high_water(), + ), + settled + ); + assert_eq!(stager.received_bytes(), wire); + } } diff --git a/crates/p2p/src/sync/telemetry.rs b/crates/p2p/src/sync/telemetry.rs index 38239ed22..3346db0e9 100644 --- a/crates/p2p/src/sync/telemetry.rs +++ b/crates/p2p/src/sync/telemetry.rs @@ -64,6 +64,20 @@ impl BlockSync { ) } + /// Publishes the staged-body, pending-window, and inbound-queue gauges + /// from one scheduler-lock hold. + /// + /// `staged_decoded_bytes` and `staged_retained_bytes` attribute staged + /// decoded-heap capacity and preserved serialized buffers; they exclude + /// allocator rounding, are not a process-RSS measure, and never feed an + /// admission decision. `received_bytes` and `staged_bytes_high_water` + /// remain the serialized fact the byte budget reads. + /// `inbound_block_queue_depth` is the bounded inbound channel's length at + /// emission. The only caller runs after the drain pass has emptied the + /// receiver, so it reports the residual queue that arrived during or + /// after the pass; bodies the pass already pulled are in the staged + /// gauges, or in the apply batch the stager no longer owns, and are not + /// attributed in bytes. pub(super) fn record_sync_metrics(&self) { let scheduler = self.scheduler.lock(); let window = &scheduler.window; @@ -81,6 +95,13 @@ impl BlockSync { .set(metric_count(stager.received_high_water())); metrics::gauge!("node.sync.staged_bytes_high_water") .set(metric_count(stager.received_bytes_high_water())); + metrics::gauge!("node.sync.staged_decoded_bytes").set(metric_bytes(stager.decoded_bytes())); + metrics::gauge!("node.sync.staged_retained_bytes") + .set(metric_bytes(stager.retained_attributed_bytes())); + metrics::gauge!("node.sync.staged_retained_bytes_high_water") + .set(metric_bytes(stager.retained_attributed_bytes_high_water())); + metrics::gauge!("node.sync.inbound_block_queue_depth") + .set(metric_count(self.inbound_blocks_rx.len())); } pub(super) fn record_pending_sync_metrics(&self) { @@ -94,3 +115,31 @@ impl BlockSync { pub(super) fn metric_count(value: usize) -> f64 { f64::from(u32::try_from(value).unwrap_or(u32::MAX)) } + +/// A byte attribution as a gauge value without the `u32` clamp of +/// [`metric_count`]: the retained gauges exist to show residency growing past +/// the serialized budget, so a total above 4 GiB must not saturate. The high +/// and low 32-bit halves each convert to `f64` exactly. +fn metric_bytes(value: usize) -> f64 { + let value = u64::try_from(value).unwrap_or(u64::MAX); + let high = u32::try_from(value >> 32).unwrap_or(u32::MAX); + let low = u32::try_from(value & u64::from(u32::MAX)).unwrap_or(u32::MAX); + f64::from(high).mul_add(4_294_967_296.0, f64::from(low)) +} + +#[cfg(test)] +mod tests { + use super::metric_bytes; + + #[test] + fn metric_bytes_is_exact_across_the_u32_boundary() { + assert_eq!(metric_bytes(0).to_bits(), 0.0_f64.to_bits()); + assert_eq!(metric_bytes(4_096).to_bits(), 4_096.0_f64.to_bits()); + if let Ok(above_u32) = usize::try_from(5_000_000_000_u64) { + assert_eq!( + metric_bytes(above_u32).to_bits(), + 5_000_000_000.0_f64.to_bits() + ); + } + } +} diff --git a/crates/primitives/src/block.rs b/crates/primitives/src/block.rs index 919d61183..085c19646 100644 --- a/crates/primitives/src/block.rs +++ b/crates/primitives/src/block.rs @@ -56,6 +56,23 @@ impl Block { .saturating_mul(3) .saturating_add(u64::try_from(self.total_size()).unwrap_or(u64::MAX)) } + + /// Returns the total heap bytes allocated for this block's transaction vector, + /// transactions, inputs, outputs, scripts, and witness stacks, including spare capacity. + /// + /// Excludes the inline `Block` struct itself, any separate serialized raw byte buffer, + /// and allocator metadata or size-class rounding. + #[must_use] + pub fn retained_heap_bytes(&self) -> usize { + let mut total = self + .txs + .capacity() + .saturating_mul(core::mem::size_of::()); + for tx in &self.txs { + total = total.saturating_add(tx.retained_heap_bytes()); + } + total + } } /// Block payload facts available without materializing a full block body. @@ -204,4 +221,191 @@ mod tests { ); Ok(()) } + + fn read_test_fixture(name: &str) -> Result> { + let local = format!("tests/testdata/{name}"); + if let Ok(bytes) = std::fs::read(&local) { + return Ok(bytes); + } + let manifest = format!("{}/tests/testdata/{name}", env!("CARGO_MANIFEST_DIR")); + Ok(std::fs::read(manifest)?) + } + + #[test] + fn retained_heap_bytes_empty_and_excess_txs_capacity() { + assert_eq!(Block::default().retained_heap_bytes(), 0); + + let mut block = Block::default(); + block.txs.reserve_exact(10); + assert_eq!(block.retained_heap_bytes(), 10 * core::mem::size_of::()); + } + + #[test] + fn retained_heap_bytes_genesis_fixture_matches_independent_calculation() -> Result<()> { + let bytes = read_test_fixture("0.bin")?; + let block = deserialize::(&bytes)?; + + // Independently derive expected value directly from vector capacities. + let independent = block.txs.capacity() * core::mem::size_of::() + + block + .txs + .iter() + .map(|tx| { + let in_caps = tx.inputs.capacity() * core::mem::size_of::(); + let out_caps = tx.outputs.capacity() * core::mem::size_of::(); + let in_payloads: usize = tx + .inputs + .iter() + .map(|input| { + input.script_sig.capacity() + + input.witness.capacity() * core::mem::size_of::>() + + input.witness.iter().map(Vec::capacity).sum::() + }) + .sum(); + let out_payloads: usize = tx + .outputs + .iter() + .map(|output| output.script_pubkey.capacity()) + .sum(); + in_caps + out_caps + in_payloads + out_payloads + }) + .sum::(); + + assert_eq!(block.retained_heap_bytes(), independent); + + // Genesis fixture has 1 tx, 1 input (77-byte script_sig), 1 output (67-byte script_pubkey). + let expected_literal = core::mem::size_of::() + + core::mem::size_of::() + + core::mem::size_of::() + + 77 + + 67; + assert_eq!(block.retained_heap_bytes(), expected_literal); + Ok(()) + } + + #[test] + fn retained_heap_bytes_fixture_363731_matches_independent_calculation() -> Result<()> { + let bytes = read_test_fixture("363731.bin")?; + let block = deserialize::(&bytes)?; + + let independent = block.txs.capacity() * core::mem::size_of::() + + block + .txs + .iter() + .map(|tx| { + let in_caps = tx.inputs.capacity() * core::mem::size_of::(); + let out_caps = tx.outputs.capacity() * core::mem::size_of::(); + let in_payloads: usize = tx + .inputs + .iter() + .map(|input| { + input.script_sig.capacity() + + input.witness.capacity() * core::mem::size_of::>() + + input.witness.iter().map(Vec::capacity).sum::() + }) + .sum(); + let out_payloads: usize = tx + .outputs + .iter() + .map(|output| output.script_pubkey.capacity()) + .sum(); + in_caps + out_caps + in_payloads + out_payloads + }) + .sum::(); + + assert_eq!(block.retained_heap_bytes(), independent); + + // 363731 has 1883 txs, 3748 inputs (431,845 scriptSig bytes), 4265 outputs (106,226 scriptPubKey bytes). + // On 64-bit systems with size_of::() == 8: + // 1883 * 56 + 3748 * 88 + 4265 * 32 + 431,845 + 106,226 = 1,109,823 bytes. + if core::mem::size_of::() == 8 { + assert_eq!(block.retained_heap_bytes(), 1_109_823); + } + Ok(()) + } + + #[test] + fn retained_heap_bytes_synthetic_witness_heavy_capacity_variations() { + let mut script_sig_0 = Vec::with_capacity(60); + script_sig_0.extend_from_slice(&[0xaa; 10]); + + let mut wit_item_0 = Vec::with_capacity(128); + wit_item_0.extend_from_slice(&[0x01; 32]); + let mut wit_item_1 = Vec::with_capacity(256); + wit_item_1.extend_from_slice(&[0x02; 64]); + let mut witness_stack_0 = Vec::with_capacity(4); + witness_stack_0.push(wit_item_0); + witness_stack_0.push(wit_item_1); + + let mut wit_item_2 = Vec::with_capacity(80); + wit_item_2.extend_from_slice(&[0x03; 33]); + let mut witness_stack_1 = Vec::with_capacity(3); + witness_stack_1.push(wit_item_2); + + let mut inputs = Vec::with_capacity(5); + inputs.push(TxIn { + previous_output: OutPoint::default(), + script_sig: crate::Script::from_bytes(script_sig_0), + sequence: crate::Sequence::MAX, + witness: crate::Witness::from_stack(witness_stack_0), + }); + inputs.push(TxIn { + previous_output: OutPoint::default(), + script_sig: crate::Script::new(), + sequence: crate::Sequence::MAX, + witness: crate::Witness::from_stack(witness_stack_1), + }); + + let mut spk_0 = Vec::with_capacity(75); + spk_0.extend_from_slice(&[0x51; 34]); + let mut spk_1 = Vec::with_capacity(110); + spk_1.extend_from_slice(&[0x00; 22]); + let mut outputs = Vec::with_capacity(4); + outputs.push(TxOut { + value: crate::Amount::from_sat(50_000), + script_pubkey: crate::Script::from_bytes(spk_0), + }); + outputs.push(TxOut { + value: crate::Amount::from_sat(60_000), + script_pubkey: crate::Script::from_bytes(spk_1), + }); + + let tx = Tx { + version: 2, + inputs, + outputs, + lock_time: crate::LockTime::ZERO, + }; + + let mut txs = Vec::with_capacity(3); + txs.push(tx); + + let mut block = Block { + header: Header::default(), + txs, + }; + + let expected_txs_buf = 3 * core::mem::size_of::(); + let expected_inputs_buf = 5 * core::mem::size_of::(); + let expected_outputs_buf = 4 * core::mem::size_of::(); + let expected_input_0_scripts = 60 + 4 * core::mem::size_of::>() + 128 + 256; + let expected_input_1_scripts = 3 * core::mem::size_of::>() + 80; + let expected_output_scripts = 75 + 110; + + let expected_tx = expected_inputs_buf + + expected_outputs_buf + + expected_input_0_scripts + + expected_input_1_scripts + + expected_output_scripts; + let expected_block = expected_txs_buf + expected_tx; + + assert_eq!(block.txs[0].retained_heap_bytes(), expected_tx); + assert_eq!(block.retained_heap_bytes(), expected_block); + + // Verifying spare capacity accounting when reserving extra capacity in txs: + block.txs.reserve_exact(7); + let extra_txs_cap = block.txs.capacity() - 3; + let expected_after_reserve = expected_block + extra_txs_cap * core::mem::size_of::(); + assert_eq!(block.retained_heap_bytes(), expected_after_reserve); + } } diff --git a/crates/primitives/src/script.rs b/crates/primitives/src/script.rs index 275df5c11..c8d1180bb 100644 --- a/crates/primitives/src/script.rs +++ b/crates/primitives/src/script.rs @@ -24,6 +24,16 @@ impl Script { pub fn as_bytes(&self) -> &[u8] { &self.0 } + + /// Returns the heap bytes allocated for this script's byte buffer, + /// including spare capacity. + /// + /// Excludes inline `Script` struct overhead (which is counted in the enclosing + /// vector capacity or on the stack) and allocator metadata/rounding. + #[must_use] + pub fn retained_heap_bytes(&self) -> usize { + self.0.capacity() + } } impl Deref for Script { @@ -80,6 +90,23 @@ impl Witness { pub fn len(&self) -> usize { self.0.len() } + + /// Returns the heap bytes allocated for this witness stack, including + /// the outer stack allocation and all out-of-line item buffers with spare capacity. + /// + /// Excludes inline `Witness` struct overhead (counted in `inputs.capacity() * size_of::()` + /// or on the stack) and allocator metadata/rounding. + #[must_use] + pub fn retained_heap_bytes(&self) -> usize { + let mut total = self + .0 + .capacity() + .saturating_mul(core::mem::size_of::>()); + for item in &self.0 { + total = total.saturating_add(item.capacity()); + } + total + } } impl Deref for Witness { @@ -116,3 +143,42 @@ impl<'a> IntoIterator for &'a Witness { self.0.iter() } } + +#[cfg(test)] +mod tests { + use super::{Script, Witness}; + + #[test] + fn script_retained_heap_bytes_tracks_capacity() { + assert_eq!(Script::new().retained_heap_bytes(), 0); + + let mut raw = Vec::with_capacity(128); + raw.extend_from_slice(&[0x51, 0x87]); + let script = Script::from_bytes(raw); + assert_eq!(script.retained_heap_bytes(), 128); + } + + #[test] + fn witness_retained_heap_bytes_tracks_outer_and_item_capacities() { + assert_eq!(Witness::new().retained_heap_bytes(), 0); + + let empty_with_capacity = Witness::from_stack(Vec::with_capacity(7)); + assert_eq!( + empty_with_capacity.retained_heap_bytes(), + 7 * core::mem::size_of::>() + ); + + let mut item1 = Vec::with_capacity(64); + item1.extend_from_slice(&[0x01; 32]); + let mut item2 = Vec::with_capacity(120); + item2.extend_from_slice(&[0x02; 64]); + + let mut stack = Vec::with_capacity(4); + stack.push(item1); + stack.push(item2); + let witness = Witness::from_stack(stack); + + let expected = 4 * core::mem::size_of::>() + 64 + 120; + assert_eq!(witness.retained_heap_bytes(), expected); + } +} diff --git a/crates/primitives/src/tx.rs b/crates/primitives/src/tx.rs index 29e0b1657..631f7a01a 100644 --- a/crates/primitives/src/tx.rs +++ b/crates/primitives/src/tx.rs @@ -107,6 +107,33 @@ impl Tx { pub fn vsize(&self) -> u64 { Self::vsize_from_weight(self.weight()) } + + /// Returns the heap bytes allocated for this transaction's inputs, outputs, + /// scripts, and witness stacks, including spare capacity. + /// + /// Excludes inline `Tx` struct overhead (which is counted in `Block::txs` + /// capacity or on the stack) and allocator metadata/rounding. + #[must_use] + pub fn retained_heap_bytes(&self) -> usize { + let mut total = self + .inputs + .capacity() + .saturating_mul(core::mem::size_of::()) + .saturating_add( + self.outputs + .capacity() + .saturating_mul(core::mem::size_of::()), + ); + for input in &self.inputs { + total = total + .saturating_add(input.script_sig.retained_heap_bytes()) + .saturating_add(input.witness.retained_heap_bytes()); + } + for output in &self.outputs { + total = total.saturating_add(output.script_pubkey.retained_heap_bytes()); + } + total + } } #[cfg(test)] @@ -209,4 +236,53 @@ mod tests { // Without the garbage the same bytes decode cleanly. assert_eq!(deserialize::(&consensus_bytes(&tx)), Ok(tx)); } + + #[test] + fn tx_retained_heap_bytes_tracks_inputs_outputs_scripts_witnesses() { + let empty_tx = Tx { + version: 2, + inputs: Vec::new(), + outputs: Vec::new(), + lock_time: crate::LockTime::ZERO, + }; + assert_eq!(empty_tx.retained_heap_bytes(), 0); + + let mut inputs = Vec::with_capacity(3); + let mut script_bytes = Vec::with_capacity(50); + script_bytes.extend_from_slice(&[0x04; 20]); + let mut wit_item = Vec::with_capacity(100); + wit_item.extend_from_slice(&[0x05; 32]); + let mut wit_stack = Vec::with_capacity(4); + wit_stack.push(wit_item); + + inputs.push(TxIn { + previous_output: OutPoint::default(), + script_sig: crate::Script::from_bytes(script_bytes), + sequence: crate::Sequence::MAX, + witness: crate::Witness::from_stack(wit_stack), + }); + + let mut outputs = Vec::with_capacity(5); + let mut spk_bytes = Vec::with_capacity(70); + spk_bytes.extend_from_slice(&[0x51; 34]); + outputs.push(TxOut { + value: crate::Amount::from_sat(100_000), + script_pubkey: crate::Script::from_bytes(spk_bytes), + }); + + let tx = Tx { + version: 2, + inputs, + outputs, + lock_time: crate::LockTime::ZERO, + }; + + let expected = 3 * core::mem::size_of::() + + 50 + + 4 * core::mem::size_of::>() + + 100 + + 5 * core::mem::size_of::() + + 70; + assert_eq!(tx.retained_heap_bytes(), expected); + } } diff --git a/docs/observability.md b/docs/observability.md index b3f4299fc..b31840a32 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -53,6 +53,40 @@ latency. Occupancy updates follow the existing connection permit lifetime, including spawn failure and shutdown; no second admission counter is added. No paths, method names, credentials, addresses, hashes or txids are labels. +For staged block bodies, `node.sync.received_bytes` and +`node.sync.staged_bytes_high_water` stay the serialized wire fact that the +staging byte budget and request headroom read. Four further signals attribute +retained residency without changing any admission rule: + +| Signal | Meaning | +| --- | --- | +| `node.sync.staged_decoded_bytes` | Sum over staged blocks of `Block::retained_heap_bytes()`: the capacity of each block's transaction, input, output, script, and witness allocations. | +| `node.sync.staged_retained_bytes` | `staged_decoded_bytes` plus the preserved serialized buffers (`received_bytes`). | +| `node.sync.staged_retained_bytes_high_water` | Highest `staged_retained_bytes` observed this run. | +| `node.sync.inbound_block_queue_depth` | Length of the bounded inbound channel (`inbound_block_queue_limit`) when the drain pass emitted. A count of unstaged bodies, not bytes. | + +The three byte signals are attribution, not measurement of the process. They +exclude allocator rounding, the inline `Block` value, and the stager's map and +queue bookkeeping, so they are not an RSS or `VmHWM` figure and need not equal +one. They cover only bodies the stager owns: a body drained for apply, or +pulled from the channel into a stage batch, is unattributed until it is +staged or restored, so `staged_retained_bytes = 0` in the middle of an apply +does not mean no block memory is held. A block is measured once, when the +stager takes ownership of its allocation (arrival, or restore after a partial +apply, which re-measures the restored allocation because the restored copy +need not have the drained original's capacities), and released at the +transition that releases its serialized bytes: drain, branch retirement, +count-budget eviction, discard, expiry, or replacement. The retained high-water +mark advances on arrival and on restore; an arrival's mark is taken before the +count-budget eviction it causes. `node.sync.staged_blocks_high_water` and +`node.sync.staged_bytes_high_water` keep their earlier arrival-only behavior. +Nothing admits, evicts, or retries on these signals. They are backlog/capacity +gauges (OBS-01) published with the other staged gauges from one scheduler-lock +hold by the sync drain pass that saw arrivals, expiry, applies, or failures. +That pass runs after it has emptied the receiver, so +`inbound_block_queue_depth` is a post-drain residual, normally near zero, not +a peak, and it is not refreshed on passes that emit nothing. + ### `OBS-02`: `tracing::` is diagnostics, not an API Free-form development and debugging detail belongs in `tracing::` events and From d0285b9be26fa572d65cf742501a4ad243134c81 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 27 Oct 2026 05:04:55 +0900 Subject: [PATCH 03/23] Retain fixed staging after horizon evaluation Record the isolated 48-trial comparison and its failed memory and stall guards. Retained-byte attribution remains diagnostic; no count-horizon policy enters the production path. --- docs/observability.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/observability.md b/docs/observability.md index b31840a32..56146299d 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -87,6 +87,23 @@ That pass runs after it has emptied the receiver, so `inbound_block_queue_depth` is a post-drain residual, normally near zero, not a peak, and it is not refreshed on passes that emit nothing. +Staging policy decision (retained): production keeps the fixed 256-block / +512 MiB wire budget. An isolated count-horizon experiment (candidate +`f24b7ded0`, committed after the matrix so source bytes match the gated binary) +ran 48 synthetic regtest trials (4 cells x 4 policies x 3 rounds, 320 blocks +applied each; raw samples in +`.outline/evidence/native-gate-20261026T183745Z-1096270/gate.log`) and was +evaluated and rejected under the fixed Step 25 gate (>=10% peak-RSS reduction, +throughput >=98%, strictly lower retained high-water, no +retry/starvation/stall regression): no horizon met the RSS gate (binding +medians ~1.00-1.01x fixed vs required <=0.90), and the modern-large 8-peer +15 s and 30 s horizons each added a stall-episode start (median 1 vs fixed 0; +disconnects stayed 0, which does not cancel the episode regression). Five +early-multi rows were not valid binding comparisons (all 60 s first refusals +arrived after the corpus was fully requested; 30 s rounds 1-2 had no refusal). +Synthetic regtest only (early/small and modern/large-OP_RETURN corpora, 3 runs +per arm); no mainnet, p99, or whole-node claim. + ### `OBS-02`: `tracing::` is diagnostics, not an API Free-form development and debugging detail belongs in `tracing::` events and From 8d46866e84d2eb8770de2f1aa1002eef6f82ba1f Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Mon, 26 Oct 2026 06:00:38 +0900 Subject: [PATCH 04/23] Report actual window benchmark build identity Evidence must describe the binary that produced it. Record the actual debug-assertion setting and retain runtime replay checks without a constant assertion that fails strict lint. --- crates/node/benches/sync_pipeline.rs | 40 +++++++++++++++------------- 1 file changed, 21 insertions(+), 19 deletions(-) diff --git a/crates/node/benches/sync_pipeline.rs b/crates/node/benches/sync_pipeline.rs index 7189ddb4c..f81e2dddc 100644 --- a/crates/node/benches/sync_pipeline.rs +++ b/crates/node/benches/sync_pipeline.rs @@ -1181,7 +1181,7 @@ impl ProductionStateSyncFixture { .first() .unwrap_or_else(|| panic!("missing primary outbound receiver")); let mut drained_headers = 0_usize; - let getdata_count = loop { + loop { match rx .try_recv() .unwrap_or_else(|error| panic!("expected production getdata: {error}")) @@ -1198,8 +1198,7 @@ impl ProductionStateSyncFixture { } other => panic!("expected production getdata, got {other:?}"), } - }; - getdata_count + } } } @@ -4804,8 +4803,10 @@ fn proc_field(path: &str, field: &str) -> String { read_proc_text(path) .lines() .find_map(|line| line.strip_prefix(field)) - .map(|rest| rest.trim().to_owned()) - .unwrap_or_else(|| panic!("{field} missing from {path}")) + .map_or_else( + || panic!("{field} missing from {path}"), + |rest| rest.trim().to_owned(), + ) } fn proc_kib(path: &str, field: &str) -> u64 { @@ -4826,8 +4827,7 @@ fn process_cpu_ticks() -> u64 { // and stime are fields 14 and 15. let after_comm = stat .rsplit_once(')') - .map(|(_, rest)| rest) - .unwrap_or_else(|| panic!("malformed /proc/self/stat")); + .map_or_else(|| panic!("malformed /proc/self/stat"), |(_, rest)| rest); let mut fields = after_comm.split_ascii_whitespace().skip(11); let mut next_ticks = |name: &str| -> u64 { fields @@ -4865,18 +4865,19 @@ fn reset_peak_rss() -> u64 { } /// Toolchain and build facts the six-identity record needs beyond -/// `EvidenceIdentity`. Fails closed on an unoptimised build. +/// `EvidenceIdentity`. fn window_toolchain_identity() -> sonic_rs::Value { - assert!( - !cfg!(debug_assertions), - "window attribution must run an optimised build (cargo bench profile)" - ); let rustc = std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()); let output = std::process::Command::new(&rustc) .arg("-vV") .output() - .unwrap_or_else(|error| panic!("run {rustc:?} -vV for the toolchain identity: {error}")); - assert!(output.status.success(), "{rustc:?} -vV failed"); + .unwrap_or_else(|error| { + panic!( + "run {} -vV for the toolchain identity: {error}", + rustc.display() + ) + }); + assert!(output.status.success(), "{} -vV failed", rustc.display()); let features: Vec<&str> = [ ("fjall", cfg!(feature = "fjall")), ("rocksdb", cfg!(feature = "rocksdb")), @@ -4890,7 +4891,7 @@ fn window_toolchain_identity() -> sonic_rs::Value { json!({ "rustc": String::from_utf8_lossy(&output.stdout).trim().to_owned(), "profile": "bench", - "debug_assertions": false, + "debug_assertions": cfg!(debug_assertions), "features": features, "allocator": "system", "validation_engine": sample_engine_label(), @@ -5038,16 +5039,17 @@ fn assert_window_unit(scenario: WindowScenario, unit: &WindowUnit) { /// Order statistics of `values` plus whether p99 is supported; an empty set /// is reported as unobserved, never as zero. fn quantiles_json(values: &[u64]) -> sonic_rs::Value { - match evidence::Quantiles::of(values) { - Some(quantiles) => json!({ + if let Some(quantiles) = evidence::Quantiles::of(values) { + json!({ "n": quantiles.n, "p50": quantiles.p50, "p95": quantiles.p95, "p99": quantiles.p99, "max": quantiles.max, "p99_supported": quantiles.p99_supported(), - }), - None => json!({ "n": 0, "observed": false }), + }) + } else { + json!({ "n": 0, "observed": false }) } } From dd4c10978256b24d0687134a838d8cf639245b06 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 27 Oct 2026 02:46:18 +0900 Subject: [PATCH 05/23] Correct window toolchain gate documentation The harness records debug assertions in its toolchain identity instead of refusing the build. Keep the storage and peak-memory reset gates unchanged. --- docs/benchmarks/window-pipeline-attribution.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/benchmarks/window-pipeline-attribution.md b/docs/benchmarks/window-pipeline-attribution.md index be1f76669..eca61f22e 100644 --- a/docs/benchmarks/window-pipeline-attribution.md +++ b/docs/benchmarks/window-pipeline-attribution.md @@ -101,8 +101,9 @@ summary and from the ledger, followed by 100 measured units. The harness stops with a panic, and records nothing, when a unit applies fewer blocks than the scenario names, issues a flush count other than one per started group, covers fewer blocks than the window, hits a failed flush, lacks a -`transition_held_us` event, runs a build with debug assertions, or finds a -non-disk `TMPDIR`. +`transition_held_us` event, or finds a non-disk `TMPDIR`. The actual debug-assertion +setting is recorded in `toolchain.debug_assertions`; it is not a refusal by +itself. ### 3.4 Evidence artifacts From 1bb45dc34d5540836ed902c8d3fbb8c9cd13a6cf Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 27 Oct 2026 03:37:12 +0900 Subject: [PATCH 06/23] Format the window diagnostics regression Align the execution proof with rustfmt and mark the Linux metric name as code so the native verification gate accepts the test. Runtime behavior and assertions are unchanged. --- .../tests/unit/apply/observability_tests.rs | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/crates/chainstate/tests/unit/apply/observability_tests.rs b/crates/chainstate/tests/unit/apply/observability_tests.rs index 6a8832aa8..7778b78f3 100644 --- a/crates/chainstate/tests/unit/apply/observability_tests.rs +++ b/crates/chainstate/tests/unit/apply/observability_tests.rs @@ -192,6 +192,54 @@ fn retired_group_and_check_timings_stay_off_caller_thread_metrics() Ok(()) } +/// Step-24 execution smoke: a real window commit emits the transition-hold +/// and group-flush diagnostics on the caller thread. +/// +/// Drives the production `begin_transition` + `connect_window` path on real +/// regtest fixtures under the `ProfileFields` dispatch and asserts both +/// `transition_held_us` and `flush_wait_us` arrive. The transition guard is +/// dropped inside the capture closure so its `Drop` event reaches the same +/// dispatch. This measures nothing, writes no ledger, and never touches +/// `/proc/self/clear_refs`: the `VmHWM` performance gate is neither used nor +/// bypassed, and no timing ratio or promotion is claimed. +#[test] +fn window_commit_emits_transition_and_flush_diagnostics() -> Result<(), Box> +{ + let genesis = Network::Regtest.genesis_block(); + let fields = ProfileFields::default(); + capture_profile(&fields, || -> Result<(), Box> { + let handles = handles(Network::Regtest, Arc::new(UtxoSet::new())); + let first = mined_child(genesis.block_hash(), 1)?; + let second = mined_child(first.block_hash(), 2)?; + seed_genesis(&handles)?; + { + let mut tree = handles.block_tree.write(); + bitcoin_rs_chain::accept_headers( + &mut tree, + &[first.header, second.header], + Network::Regtest, + bitcoin_rs_chain::current_unix_seconds(), + bitcoin_rs_chain::HeaderValidationMode::LiveAdmission, + )?; + } + let blocks = [&first, &second]; + let serialized: Vec = blocks + .iter() + .map(|block| bytes::Bytes::from(consensus_bytes(*block))) + .collect(); + let transition = handles.begin_transition()?; + transition.connect_window(&blocks, &serialized)?; + Ok(()) + })?; + for field in ["transition_held_us", "flush_wait_us"] { + assert!( + fields.0.lock().contains(field), + "missing window diagnostic: {field}" + ); + } + Ok(()) +} + /// Records fields delivered by real tracing events on the apply caller thread. #[derive(Clone, Default)] struct ProfileFields(Arc>>); From 0b44bc93cb3d14441686eace077bd06c1301a577 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:28:15 +0900 Subject: [PATCH 07/23] Report filesystem write-counter scope Identify Linux backing counters without confusing pool-wide, process, allocated, or physical write measurements. Preserve unavailable results for ambiguous attribution. --- tools/benchmark-campaign/filesystem.py | 618 ++++++++++++++++++++ tools/benchmark-campaign/test_filesystem.py | 602 +++++++++++++++++++ 2 files changed, 1220 insertions(+) create mode 100644 tools/benchmark-campaign/filesystem.py create mode 100644 tools/benchmark-campaign/test_filesystem.py diff --git a/tools/benchmark-campaign/filesystem.py b/tools/benchmark-campaign/filesystem.py new file mode 100644 index 000000000..5dbcee0ac --- /dev/null +++ b/tools/benchmark-campaign/filesystem.py @@ -0,0 +1,618 @@ +# pyright: strict +"""Filesystem-aware Linux measurement boundary (one target filesystem per run). + +One owner identifies the real mount/filesystem/backing-counter scope for a +scratch path and takes read-only cumulative write-counter samples. Four +measurement classes stay separate and are never merged here: + +- apparent bytes (file lengths), +- allocated file bytes (the existing Rust ``storage-footprint`` tool owns + that measurement; it is not duplicated here), +- per-process I/O counters, +- attributable host block-device writes (this module's only counter). + +Only a regular single block device behind the target filesystem can yield an +attributable counter, and even then the counter is host-wide for that device: +every writer to the device is included. This collector never attests +isolation and never qualifies a run; the existing harness keeps its strict +qualification gate unless run ownership is actually proved. + +Honest scope labeling: + +- ``regular_block_device``: mount resolves to one block device with a + readable kernel sectors-written counter. Available. +- ``zfs_pool``: ZFS dataset. Pool-wide accounting cannot isolate one + filesystem's physical writes, so the scope is unavailable. Pool aggregate + commands are informational only and are never converted into a + per-filesystem value. +- ``composite_anonymous``: overlay/tmpfs/ramfs/network/fuse/9p/virtiofs and + other anonymous (major 0) mounts with no per-filesystem block counter. + Unavailable. +- ``ambiguous_multi_device``: btrfs or a stacked device over more than one + underlying device. The mapping cannot name a single backing counter, so the + scope is unavailable. Underlying devices are listed, never summed. +- ``unavailable``: mount entry, counter file, permission, or parse failure. + Unavailable with an explicit reason, never zero. + +Read-only bound: this module only reads the mount table, the one chosen +device's counter files, and local topology/queue attribute files listed in +``mechanisms_checked``. It spawns no subprocess, writes nothing, and follows +exactly one counter per scope (a partition's own counter, never the parent +disk added to it, never slaves added together). + +Sector unit: the kernel block-layer counter unit is always 512 bytes, +independent of the filesystem logical block size or the device physical +block size. Queue/model attributes are recorded for context only. +""" + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import TypedDict + +type Json = str | int | float | bool | None | list[Json] | dict[str, Json] +type JsonObject = dict[str, Json] + +SECTOR_BYTES = 512 + +MOUNTINFO_DEFAULT = "/proc/self/mountinfo" +DISKSTATS_DEFAULT = "/proc/diskstats" +SYS_BASE_DEFAULT = Path("/sys/dev/block") + +UNATTRIBUTABLE_FSTYPES = frozenset( + { + "overlay", + "tmpfs", + "ramfs", + "nfs", + "nfs4", + "cifs", + "smb3", + "fuse", + "fuseblk", + "9p", + "virtiofs", + "ecryptfs", + } +) + +QUEUE_KEYS = ( + "rotational", + "logical_block_size", + "physical_block_size", + "hw_sector_size", + "write_cache", + "scheduler", + "nr_requests", +) + +_MAJMIN_RE = re.compile(r"^\d+:\d+$") + + +class MountInfo(TypedDict): + mount_id: str + parent_id: str + majmin: str + root: str + mountpoint: str + mount_options: str + fstype: str + source: str + super_options: str + + +class CounterSample(TypedDict): + sysfs_sectors_written: int + writes_completed: int + in_flight: int + diskstats_sectors_written: int | None + + +class ScopeDoc(TypedDict, total=False): + """Precise scope document; every field carries its own narrow type.""" + + scratch_realpath: str + counter_unit: str + counter_scope: str + mount: MountInfo | None + mechanisms_checked: list[str] + status: str + scope_kind: str + reason: str + mapping: str + majmin: str + name: str + sysfs_stat: str + partition: bool + parent_disk: str + stacked_slaves: list[str] + holders: list[str] + shared_mounts: list[str] + queue: dict[str, str | None] + model: str | None + notes: list[str] + sample_error: str + + +def unescape_mountinfo(field: str) -> str: + """Decode kernel mountinfo octal escapes (space, newline, backslash).""" + raw = re.sub( + rb"\\([0-7]{3})", + lambda match: bytes([int(match.group(1), 8)]), + field.encode("utf-8", errors="surrogateescape"), + ) + return raw.decode("utf-8", errors="surrogateescape") + + +def _read_text(path: Path | str, errors: str = "replace") -> str | None: + try: + return Path(path).read_text(encoding="utf-8", errors=errors) + except OSError: + return None + + +def read_mountinfo(path: str | Path = MOUNTINFO_DEFAULT) -> list[MountInfo]: + """Parse the kernel mount table; unreadable input yields an empty list. + + The table is ingested with surrogateescape so non-UTF8 mountpoint bytes + survive decoding, consistent with the os.fsdecode-derived scratch path + they are matched against. Replacement decoding would map distinct byte + sequences onto one placeholder and misroute child mounts to ancestors. + """ + text = _read_text(path, errors="surrogateescape") + if text is None: + return [] + mounts: list[MountInfo] = [] + for line in text.splitlines(): + if " - " not in line: + continue + left, right = line.split(" - ", 1) + left_fields = left.split(" ") + right_fields = right.split(" ") + if len(left_fields) < 6 or len(right_fields) < 2: + continue + try: + entry: MountInfo = { + "mount_id": left_fields[0], + "parent_id": left_fields[1], + "majmin": left_fields[2], + "root": unescape_mountinfo(left_fields[3]), + "mountpoint": unescape_mountinfo(left_fields[4]), + "mount_options": left_fields[5], + "fstype": right_fields[0], + "source": unescape_mountinfo(right_fields[1]), + "super_options": right_fields[2] if len(right_fields) > 2 else "", + } + except ValueError: + continue + mounts.append(entry) + return mounts + + +def pick_mount(real: str, mounts: list[MountInfo]) -> MountInfo | None: + """Longest-prefix mount containing ``real``; ``None`` when no entry does.""" + best: MountInfo | None = None + for mount in mounts: + point = mount["mountpoint"] + inside = ( + real == point or point == "/" or real.startswith(point.rstrip("/") + "/") + ) + if not inside: + continue + if best is None or len(point) >= len(best["mountpoint"]): + best = mount + return best + + +def _child_names(directory: Path) -> list[str] | None: + try: + return sorted(child.name for child in directory.iterdir()) + except FileNotFoundError: + return [] + except OSError: + return None + + +def _read_stripped(path: Path) -> str | None: + text = _read_text(path) + return text.strip() if text is not None else None + + +def _is_composite_fstype(fstype: str) -> bool: + if fstype in UNATTRIBUTABLE_FSTYPES: + return True + # Kernel reports some fuse mounts as "fuse."; they share the + # same no-per-filesystem-counter property as plain "fuse". + return fstype.split(".")[0] in UNATTRIBUTABLE_FSTYPES + + +class CounterHandle: + """Read-only handle for one block device's cumulative sectors counter. + + Exactly one device file is ever read per sample (the scope's own + ``stat``); parent-disk and underlying-device counters are listed for + context and never added in. + """ + + def __init__( + self, + majmin: str, + name: str, + stat_path: Path | str, + diskstats_path: Path | str = DISKSTATS_DEFAULT, + ) -> None: + self.majmin = majmin + self.name = name + self.sysfs_stat = Path(stat_path) + self.diskstats_path = Path(diskstats_path) + major, _, minor = majmin.partition(":") + self.major = major + self.minor = minor + + def read(self) -> CounterSample: + """Take one cumulative sample; raise on missing/unparsable primary.""" + fields = self.sysfs_stat.read_text(encoding="utf-8", errors="replace").split() + # /sys/dev/block//stat layout (stable prefix across kernel + # versions with optional discard/flush suffix fields): index 4 is + # writes completed, index 6 is sectors written, index 8 is I/O in + # flight. The 512-byte unit is the kernel block-layer unit. + if len(fields) < 9: + raise ValueError(f"counter file has {len(fields)} fields, need >= 9") + sample: CounterSample = { + "sysfs_sectors_written": int(fields[6]), + "writes_completed": int(fields[4]), + "in_flight": int(fields[8]), + "diskstats_sectors_written": None, + } + if ( + sample["sysfs_sectors_written"] < 0 + or sample["writes_completed"] < 0 + or sample["in_flight"] < 0 + ): + raise ValueError("counter file holds a negative value") + text = _read_text(self.diskstats_path) + if text is not None: + for line in text.splitlines(): + parts = line.split() + # /proc/diskstats field 10 (1-based) is sectors written. + if ( + len(parts) >= 10 + and parts[0] == self.major + and parts[1] == self.minor + ): + try: + sectors = int(parts[9]) + except ValueError: + break + if sectors >= 0: + sample["diskstats_sectors_written"] = sectors + break + return sample + + +def try_read(handle: CounterHandle) -> CounterSample | None: + """Best-effort single sample; ``None`` when the counter cannot be read.""" + try: + return handle.read() + except (OSError, ValueError, IndexError): + return None + + +def _counter_int(value: Json) -> int | None: + if isinstance(value, bool) or not isinstance(value, int) or value < 0: + return None + return value + + +def delta_bytes( + before: CounterSample | JsonObject | None, + after: CounterSample | JsonObject | None, + key: str = "sysfs_sectors_written", +) -> int | None: + """Byte delta between two cumulative samples, or ``None`` when unknown. + + ``None`` covers every non-attributable case: missing sample, missing key, + non-integer value, and counter wrap/reset/recreation (``after < before``). + A wrap never reports a negative value and never synthesizes zero as a + measurement; callers treat ``None`` as unavailable. + """ + if before is None or after is None: + return None + if not isinstance(before, dict) or not isinstance(after, dict): + return None + first = _counter_int(before.get(key)) + second = _counter_int(after.get(key)) + if first is None or second is None: + return None + if second < first: + return None + return (second - first) * SECTOR_BYTES + + +def identify_scope( + scratch: Path | str, + override: str | None = None, + *, + mountinfo_path: str | Path = MOUNTINFO_DEFAULT, + sys_base: Path | str = SYS_BASE_DEFAULT, + diskstats_path: str | Path = DISKSTATS_DEFAULT, +) -> tuple[CounterHandle | None, ScopeDoc]: + """Identify the backing-counter scope for one filesystem path. + + Returns ``(handle, scope)`` where ``handle`` takes read-only samples + when ``scope["status"] == "available"`` and is ``None`` otherwise. The + scope document is JSON-serializable and carries no isolation verdict; the + harness keeps its own strict gate. + """ + sys_base_path = Path(sys_base) + diskstats = Path(diskstats_path) + real = str(Path(scratch).resolve()) + mechanisms: list[str] = [str(mountinfo_path)] + info: ScopeDoc = { + "scratch_realpath": real, + "counter_unit": "sectors of 512 bytes (kernel block-layer unit)", + "counter_scope": ( + "host-visible block-device writes when available; " + "not flash-internal write amplification" + ), + "mount": None, + "mechanisms_checked": mechanisms, + } + if _read_text(mountinfo_path, errors="surrogateescape") is None: + info.update( + status="unavailable", + scope_kind="unavailable", + reason=f"cannot read {mountinfo_path}: mount table is unavailable", + ) + return None, info + mounts = read_mountinfo(mountinfo_path) + mount = pick_mount(real, mounts) + info["mount"] = mount + if mount is None: + info.update( + status="unavailable", + scope_kind="unavailable", + reason=(f"no {mountinfo_path} entry contains the scratch path"), + ) + return None, info + if override is not None and not _MAJMIN_RE.match(override): + info.update( + status="unavailable", + scope_kind="unavailable", + mapping="operator_asserted", + reason=f"invalid --device override {override!r}: want MAJ:MIN", + ) + return None, info + if override is not None and override != mount["majmin"]: + info.update( + status="unavailable", + scope_kind="unavailable", + mapping="operator_asserted", + reason=( + f"override {override} does not match the mount's {mount['majmin']} " + f"for {mount['mountpoint']}: no backing relationship between the " + "named device and this filesystem is established, and operator " + "assertion alone is not proof. Only the mount's own MAJ:MIN is " + "accepted, and only on the ordinary-device path." + ), + ) + return None, info + chosen = override if override is not None else mount["majmin"] + info["mapping"] = ( + "operator_asserted" if override is not None else "mountinfo_major_minor" + ) + fstype = mount["fstype"] + if fstype == "zfs": + info.update( + status="unavailable", + scope_kind="zfs_pool", + reason=( + f"ZFS dataset {mount['source']} on {mount['mountpoint']}: pool-wide " + "accounting cannot isolate this filesystem's physical writes, and " + "an aggregate or underlying-device counter cannot be attributed " + "per filesystem. No pool total is converted into a per-filesystem " + "value here." + ), + ) + return None, info + if fstype == "btrfs": + info.update( + status="unavailable", + scope_kind="ambiguous_multi_device", + reason=( + "btrfs spans one or more devices: a single-device counter cannot " + "be assumed, and per-device counters cannot be summed into a " + "filesystem value. No multi-device total is attributed." + ), + ) + return None, info + major_text = chosen.split(":")[0] if ":" in chosen else "" + major = int(major_text) if major_text.isdigit() else 0 + if _is_composite_fstype(fstype) or major == 0: + info.update( + status="unavailable", + scope_kind="composite_anonymous", + reason=( + f"{fstype} on anonymous device {mount['majmin']}: no per-filesystem " + "block counter maps to this mount, and an aggregate or " + "underlying-device counter cannot be isolated per filesystem. " + "This is reported as unavailable, never zero or an estimate." + ), + ) + return None, info + sysdir = sys_base_path / chosen + stat_path = sysdir / "stat" + mechanisms.append(str(stat_path)) + mechanisms.append(str(diskstats)) + if not stat_path.exists(): + info.update( + status="unavailable", + scope_kind="regular_block_device", + majmin=chosen, + sysfs_stat=str(stat_path), + reason=f"{stat_path} does not exist", + ) + return None, info + try: + real_sys = sysdir.resolve() + except OSError as error: + info.update( + status="unavailable", + scope_kind="regular_block_device", + majmin=chosen, + sysfs_stat=str(stat_path), + reason=f"cannot resolve {sysdir}: {error}", + ) + return None, info + slaves_dir = real_sys / "slaves" + holders_dir = real_sys / "holders" + mechanisms.append(str(slaves_dir)) + mechanisms.append(str(holders_dir)) + slaves = _child_names(slaves_dir) + holders = _child_names(holders_dir) + if slaves is None or holders is None: + blocked = slaves_dir if slaves is None else holders_dir + info.update( + status="unavailable", + scope_kind="unavailable", + majmin=chosen, + sysfs_stat=str(stat_path), + reason=( + f"cannot list {blocked}: device topology is unreadable, " + "so stacking cannot be ruled out" + ), + ) + return None, info + shared = [ + str(entry["mountpoint"]) + for entry in mounts + if entry["majmin"] == chosen and entry is not mount + ] + partition = (real_sys / "partition").exists() + disk_dir = real_sys.parent if partition else real_sys + queue_dir = disk_dir / "queue" + mechanisms.append(str(queue_dir)) + model_path = disk_dir / "device" / "model" + mechanisms.append(str(model_path)) + if slaves: + info.update( + status="unavailable", + scope_kind="ambiguous_multi_device", + majmin=chosen, + name=real_sys.name, + sysfs_stat=str(stat_path), + partition=partition, + parent_disk=disk_dir.name, + stacked_slaves=slaves, + holders=holders, + shared_mounts=shared, + reason=( + f"stacked device over {slaves}: a mapped device is never assumed " + "to be a single disk, and per-device counters are never summed " + "into one filesystem value. Mapping is ambiguous by design." + ), + ) + return None, info + handle = CounterHandle(chosen, real_sys.name, stat_path, diskstats) + try: + handle.read() + except (OSError, ValueError, IndexError) as error: + info.update( + status="unavailable", + scope_kind="regular_block_device", + majmin=chosen, + name=real_sys.name, + sysfs_stat=str(stat_path), + partition=partition, + parent_disk=disk_dir.name, + stacked_slaves=slaves, + holders=holders, + shared_mounts=shared, + reason=f"cannot parse {stat_path}: {error}", + ) + return None, info + queue: dict[str, str | None] = {} + for key in QUEUE_KEYS: + queue[key] = _read_stripped(queue_dir / key) + notes: list[str] = [ + "the counter is host-wide for this block device: every writer to it is included" + ] + if holders: + notes.append(f"devices {holders} are stacked on this one") + if shared: + notes.append(f"other mounts share this device: {shared}") + if override is not None: + notes.append("device mapping asserted by the operator (--device)") + info.update( + status="available", + scope_kind="regular_block_device", + majmin=chosen, + name=real_sys.name, + sysfs_stat=str(stat_path), + partition=partition, + parent_disk=disk_dir.name, + stacked_slaves=slaves, + holders=holders, + shared_mounts=shared, + queue=queue, + model=_read_stripped(model_path), + notes=notes, + ) + return handle, info + + +def _scope_json(scope: ScopeDoc) -> str: + return json.dumps(scope, indent=2, sort_keys=True) + + +def _parser() -> argparse.ArgumentParser: + top = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + sub = top.add_subparsers(dest="command", required=True) + for name in ("identify", "sample"): + cmd = sub.add_parser( + name, + help=( + "identify the backing-counter scope" + if name == "identify" + else "identify the scope and take one read-only sample" + ), + ) + cmd.add_argument( + "--path", required=True, help="directory on the filesystem under test" + ) + cmd.add_argument( + "--device", + help="MAJ:MIN override, must equal the mount's own MAJ:MIN; a different device is rejected", + ) + cmd.add_argument("--mountinfo", default=MOUNTINFO_DEFAULT) + cmd.add_argument("--sys-base", default=str(SYS_BASE_DEFAULT)) + cmd.add_argument("--diskstats", default=DISKSTATS_DEFAULT) + return top + + +def main(argv: list[str] | None = None) -> int: + """Bounded read-only CLI for parent live observation.""" + args = _parser().parse_args(argv) + handle, scope = identify_scope( + args.path, + args.device, + mountinfo_path=args.mountinfo, + sys_base=args.sys_base, + diskstats_path=args.diskstats, + ) + if args.command == "identify": + print(_scope_json(scope)) + return 0 + sample = try_read(handle) if handle is not None else None + if sample is None and handle is not None: + scope["sample_error"] = f"cannot parse {handle.sysfs_stat}" + print(json.dumps({"scope": scope, "sample": sample}, indent=2, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/benchmark-campaign/test_filesystem.py b/tools/benchmark-campaign/test_filesystem.py new file mode 100644 index 000000000..556799b4f --- /dev/null +++ b/tools/benchmark-campaign/test_filesystem.py @@ -0,0 +1,602 @@ +# pyright: strict +"""Contract tests for the filesystem-aware Linux measurement boundary.""" + +import json +import sys +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +sys.path.insert(0, str(Path(__file__).parent)) + +import filesystem +from filesystem import ( + CounterHandle, + delta_bytes, + identify_scope, + pick_mount, + read_mountinfo, + unescape_mountinfo, +) + +STAT_FIELDS = ["0"] * 17 + + +class MountSpec: + def __init__( + self, + mount_id: str = "1", + majmin: str = "8:1", + point: str = "/", + fstype: str = "ext4", + source: str = "/dev/sda1", + ) -> None: + self.mount_id = mount_id + self.majmin = majmin + self.point = point + self.fstype = fstype + self.source = source + + +def _stat_text(sectors: int, writes: int = 10, inflight: int = 0) -> str: + fields = list(STAT_FIELDS) + fields[4] = str(writes) + fields[6] = str(sectors) + fields[8] = str(inflight) + return " ".join(fields) + "\n" + + +def _mountline(spec: MountSpec) -> str: + return ( + f"{spec.mount_id} 0 {spec.majmin} / {spec.point} rw,relatime - " + f"{spec.fstype} {spec.source} rw" + ) + + +def _write_mountinfo(path: Path, lines: list[str]) -> None: + path.write_text("\n".join(lines) + "\n", encoding="utf-8") + + +class DeviceFixture: + def __init__(self, stat_text: str = "") -> None: + self.stat_text = stat_text + self.slaves: list[str] = [] + self.holders: list[str] = [] + self.partition = False + + +def _make_device(sys_base: Path, majmin: str, fixture: DeviceFixture) -> Path: + devdir = sys_base / majmin + devdir.mkdir(parents=True, exist_ok=True) + (devdir / "stat").write_text(fixture.stat_text, encoding="utf-8") + for dirname, entries in (("slaves", fixture.slaves), ("holders", fixture.holders)): + sub = devdir / dirname + sub.mkdir(exist_ok=True) + for entry in entries: + (sub / entry).write_text("", encoding="utf-8") + if fixture.partition: + (devdir / "partition").write_text("", encoding="utf-8") + queue = devdir / "queue" + queue.mkdir(exist_ok=True) + (queue / "rotational").write_text("1\n", encoding="utf-8") + return devdir / "stat" + + +def _ext4_device(sectors: int) -> DeviceFixture: + return DeviceFixture(_stat_text(sectors)) + + +def _standard_mount(majmin: str, point: str) -> MountSpec: + source = "/dev/" + majmin.replace(":", "_") + return MountSpec(majmin=majmin, point=point, source=source) + + +def _standard_table(root: Path, spec: MountSpec) -> Path: + table = root / "mountinfo" + _write_mountinfo(table, [_mountline(spec)]) + return table + + +def _mount_dict(mount_id: str, majmin: str, point: str) -> filesystem.MountInfo: + return { + "mount_id": mount_id, + "parent_id": "0", + "majmin": majmin, + "root": "/", + "mountpoint": point, + "mount_options": "rw", + "fstype": "ext4", + "source": "/dev/sda1", + "super_options": "rw", + } + + +class MountBoundaryTests(unittest.TestCase): + def test_longest_prefix_wins(self) -> None: + mounts = [ + _mount_dict("1", "8:1", "/"), + _mount_dict("2", "8:2", "/data"), + _mount_dict("3", "8:3", "/data/sub"), + ] + picked = pick_mount("/data/sub/x", mounts) + assert picked is not None + self.assertEqual(picked["majmin"], "8:3") + picked = pick_mount("/data/other", mounts) + assert picked is not None + self.assertEqual(picked["majmin"], "8:2") + + def test_sibling_prefix_does_not_match(self) -> None: + mounts = [_mount_dict("1", "8:1", "/"), _mount_dict("2", "8:2", "/data")] + picked = pick_mount("/data2/x", mounts) + assert picked is not None + self.assertEqual(picked["majmin"], "8:1") + + def test_exact_mountpoint_matches(self) -> None: + picked = pick_mount("/srv", [_mount_dict("7", "8:9", "/srv")]) + assert picked is not None + self.assertEqual(picked["majmin"], "8:9") + + def test_no_table_entry_gives_none(self) -> None: + self.assertIsNone(pick_mount("/nothing/here", [])) + + def test_nonutf8_child_mount_maps_to_child(self) -> None: + child = "/data/\udcffraw" + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sys_base = root / "sys" + sys_base.mkdir() + _make_device(sys_base, "8:2", _ext4_device(11)) + table = root / "mountinfo" + _write_mountinfo( + table, + [ + _mountline(MountSpec(majmin="8:1", point="/", source="/dev/sda1")), + _mountline( + MountSpec( + majmin="8:2", point=r"/data/\377raw", source="/dev/sdb1" + ) + ), + ], + ) + handle, scope = identify_scope( + child + "/scratch", mountinfo_path=table, sys_base=sys_base + ) + self.assertEqual(scope["status"], "available") + self.assertEqual(scope["majmin"], "8:2") + mount = scope.get("mount") + assert isinstance(mount, dict) + self.assertEqual(mount.get("mountpoint"), child) + assert handle is not None + self.assertEqual(handle.read()["sysfs_sectors_written"], 11) + + +class EscapeTests(unittest.TestCase): + def test_octal_escapes_decode(self) -> None: + self.assertEqual(unescape_mountinfo(r"/mnt/my\040dir"), "/mnt/my dir") + self.assertEqual(unescape_mountinfo(r"/mnt/a\012b"), "/mnt/a\nb") + self.assertEqual(unescape_mountinfo(r"/mnt/a\134b"), r"/mnt/a\b") + + def test_mountinfo_escaped_mountpoint(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + table = Path(tmp) / "mountinfo" + spec = MountSpec(point=r"/mnt/my\040dir") + _write_mountinfo(table, [_mountline(spec)]) + mounts = read_mountinfo(table) + self.assertEqual(len(mounts), 1) + self.assertEqual(mounts[0]["mountpoint"], "/mnt/my dir") + + def test_mountinfo_malformed_lines_skipped(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + table = Path(tmp) / "mountinfo" + table.write_text("garbage without separator\n", encoding="utf-8") + self.assertEqual(read_mountinfo(table), []) + + def test_mountinfo_missing_file_is_empty(self) -> None: + self.assertEqual(read_mountinfo("/nonexistent-mountinfo-xyz"), []) + + def test_invalid_octal_escape_skips_entry(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sys_base = root / "sys" + sys_base.mkdir() + _make_device(sys_base, "8:1", _ext4_device(4)) + table = root / "mountinfo" + _write_mountinfo( + table, + [ + _mountline( + MountSpec( + mount_id="9", + majmin="8:9", + point=r"/bad\777dir", + source="/dev/sdz1", + ) + ), + _mountline(MountSpec(majmin="8:1", point="/", source="/dev/sda1")), + ], + ) + mounts = read_mountinfo(table) + self.assertEqual([mount["majmin"] for mount in mounts], ["8:1"]) + _handle, scope = identify_scope("/", mountinfo_path=table, sys_base=sys_base) + self.assertEqual(scope["status"], "available") + self.assertEqual(scope["majmin"], "8:1") + + +class CounterTests(unittest.TestCase): + def test_available_regular_device(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sys_base = root / "sys" + sys_base.mkdir() + _make_device(sys_base, "259:2", _ext4_device(1000)) + diskstats = root / "diskstats" + diskstats.write_text( + "259 2 nvme0n1p2 0 0 0 0 10 0 1000 0 0 0 0 0 0 0\n", + encoding="utf-8", + ) + table = _standard_table(root, _standard_mount("259:2", "/data")) + handle, scope = identify_scope( + "/data/scratch", + mountinfo_path=table, + sys_base=sys_base, + diskstats_path=diskstats, + ) + self.assertEqual(scope["status"], "available") + self.assertEqual(scope["scope_kind"], "regular_block_device") + self.assertIsNotNone(handle) + assert handle is not None + sample = handle.read() + self.assertEqual(sample["sysfs_sectors_written"], 1000) + self.assertEqual(sample["diskstats_sectors_written"], 1000) + self.assertIn("mechanisms_checked", scope) + self.assertNotIn("isolation", scope) + + def test_diskstats_missing_keeps_sysfs(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sys_base = root / "sys" + sys_base.mkdir() + _make_device(sys_base, "8:1", _ext4_device(42)) + table = _standard_table(root, _standard_mount("8:1", "/")) + handle, scope = identify_scope( + "/x", + mountinfo_path=table, + sys_base=sys_base, + diskstats_path=root / "no-diskstats", + ) + self.assertEqual(scope["status"], "available") + assert handle is not None + self.assertIsNone(handle.read()["diskstats_sectors_written"]) + + def test_stat_missing_is_unavailable(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sys_base = root / "sys" + sys_base.mkdir() + table = _standard_table(root, _standard_mount("8:1", "/")) + handle, scope = identify_scope( + "/x", mountinfo_path=table, sys_base=sys_base + ) + self.assertIsNone(handle) + self.assertEqual(scope["status"], "unavailable") + self.assertIn("reason", scope) + + def test_stat_unparsable_is_unavailable(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sys_base = root / "sys" + sys_base.mkdir() + _make_device(sys_base, "8:1", DeviceFixture("not a counter file\n")) + table = _standard_table(root, _standard_mount("8:1", "/")) + handle, scope = identify_scope( + "/x", mountinfo_path=table, sys_base=sys_base + ) + self.assertIsNone(handle) + self.assertEqual(scope["status"], "unavailable") + + def test_short_stat_is_unavailable(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sys_base = root / "sys" + sys_base.mkdir() + _make_device(sys_base, "8:1", DeviceFixture("1 2 3\n")) + table = _standard_table(root, _standard_mount("8:1", "/")) + handle, scope = identify_scope( + "/x", mountinfo_path=table, sys_base=sys_base + ) + self.assertIsNone(handle) + self.assertEqual(scope["status"], "unavailable") + + +class ScopeLabelTests(unittest.TestCase): + def _scope_for( + self, fstype: str, majmin: str, override: str | None = None + ) -> tuple[CounterHandle | None, filesystem.ScopeDoc]: + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + root = Path(tmp.name) + sys_base = root / "sys" + sys_base.mkdir() + _make_device(sys_base, majmin, _ext4_device(5)) + spec = MountSpec(majmin=majmin, source="pool/dataset", fstype=fstype) + table = _standard_table(root, spec) + return identify_scope("/", override, mountinfo_path=table, sys_base=sys_base) + + def test_zfs_pool_scope(self) -> None: + handle, scope = self._scope_for("zfs", "0:42") + self.assertIsNone(handle) + self.assertEqual(scope["status"], "unavailable") + self.assertEqual(scope["scope_kind"], "zfs_pool") + self.assertIn("pool", str(scope.get("reason", ""))) + + def test_zfs_matching_override_stays_unavailable(self) -> None: + handle, scope = self._scope_for("zfs", "0:42", override="0:42") + self.assertIsNone(handle) + self.assertEqual(scope["status"], "unavailable") + self.assertEqual(scope["scope_kind"], "zfs_pool") + + def test_composite_anonymous_scope(self) -> None: + for fstype in ("overlay", "tmpfs", "nfs"): + handle, scope = self._scope_for(fstype, "0:33") + self.assertIsNone(handle) + self.assertEqual(scope["status"], "unavailable") + self.assertEqual(scope["scope_kind"], "composite_anonymous") + + def test_btrfs_is_ambiguous(self) -> None: + handle, scope = self._scope_for("btrfs", "8:5") + self.assertIsNone(handle) + self.assertEqual(scope["status"], "unavailable") + self.assertEqual(scope["scope_kind"], "ambiguous_multi_device") + + def test_multi_slave_is_ambiguous(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sys_base = root / "sys" + sys_base.mkdir() + fixture = DeviceFixture(_stat_text(77)) + fixture.slaves = ["8:1", "8:2"] + _make_device(sys_base, "253:0", fixture) + spec = MountSpec(majmin="253:0", source="/dev/dm-0") + table = _standard_table(root, spec) + handle, scope = identify_scope("/", mountinfo_path=table, sys_base=sys_base) + self.assertIsNone(handle) + self.assertEqual(scope["status"], "unavailable") + self.assertEqual(scope["scope_kind"], "ambiguous_multi_device") + + def test_single_slave_is_ambiguous(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sys_base = root / "sys" + sys_base.mkdir() + fixture = DeviceFixture(_stat_text(77)) + fixture.slaves = ["8:1"] + _make_device(sys_base, "253:0", fixture) + spec = MountSpec(majmin="253:0", source="/dev/dm-0") + table = _standard_table(root, spec) + handle, scope = identify_scope("/", mountinfo_path=table, sys_base=sys_base) + self.assertIsNone(handle) + self.assertEqual(scope["status"], "unavailable") + self.assertEqual(scope["scope_kind"], "ambiguous_multi_device") + + def test_no_mount_entry_is_unavailable(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + table = root / "mountinfo" + table.write_text("", encoding="utf-8") + handle, scope = identify_scope( + "/x", mountinfo_path=table, sys_base=root / "sys" + ) + self.assertIsNone(handle) + self.assertEqual(scope["status"], "unavailable") + + def test_bad_override_format_is_unavailable(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sys_base = root / "sys" + sys_base.mkdir() + table = _standard_table(root, _standard_mount("8:1", "/")) + handle, scope = identify_scope( + "/", "bogus", mountinfo_path=table, sys_base=sys_base + ) + self.assertIsNone(handle) + self.assertEqual(scope["status"], "unavailable") + + def test_matching_override_stays_available(self) -> None: + handle, scope = self._scope_for("ext4", "8:1", override="8:1") + self.assertEqual(scope["status"], "available") + self.assertEqual(scope["mapping"], "operator_asserted") + assert handle is not None + self.assertEqual(handle.read()["sysfs_sectors_written"], 5) + + def test_mismatched_override_is_rejected(self) -> None: + handle, scope = self._scope_for("ext4", "8:1", override="8:2") + self.assertIsNone(handle) + self.assertEqual(scope["status"], "unavailable") + self.assertIn("override", str(scope.get("reason", ""))) + + def test_zfs_mismatched_override_is_rejected(self) -> None: + handle, scope = self._scope_for("zfs", "0:42", override="8:1") + self.assertIsNone(handle) + self.assertEqual(scope["status"], "unavailable") + self.assertIn("override", str(scope.get("reason", ""))) + + def test_permission_denied_stat_is_unavailable(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sys_base = root / "sys" + sys_base.mkdir() + _make_device(sys_base, "8:1", _ext4_device(9)) + table = _standard_table(root, _standard_mount("8:1", "/")) + real_read = Path.read_text + + def _denied( + self: Path, + encoding: str | None = None, + errors: str | None = None, + newline: str | None = None, + ) -> str: + if self.name == "stat": + raise PermissionError(13, "Permission denied") + return real_read( + self, encoding=encoding, errors=errors, newline=newline + ) + + with mock.patch.object(Path, "read_text", _denied): + handle, scope = identify_scope( + "/", mountinfo_path=table, sys_base=sys_base + ) + self.assertIsNone(handle) + self.assertEqual(scope["status"], "unavailable") + self.assertIn("reason", scope) + + def test_unreadable_topology_is_unavailable(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sys_base = root / "sys" + sys_base.mkdir() + _make_device(sys_base, "8:1", _ext4_device(9)) + table = _standard_table(root, _standard_mount("8:1", "/")) + real_iterdir = Path.iterdir + + def _denied(self: Path) -> object: + if self.name == "slaves": + raise PermissionError(13, "Permission denied") + return real_iterdir(self) + + with mock.patch.object(Path, "iterdir", _denied): + handle, scope = identify_scope( + "/", mountinfo_path=table, sys_base=sys_base + ) + self.assertIsNone(handle) + self.assertEqual(scope["status"], "unavailable") + self.assertIn("slaves", str(scope.get("reason", ""))) + + def test_unreadable_mountinfo_names_cause(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sys_base = root / "sys" + sys_base.mkdir() + table = _standard_table(root, _standard_mount("8:1", "/")) + real_read = Path.read_text + + def _denied( + self: Path, + encoding: str | None = None, + errors: str | None = None, + newline: str | None = None, + ) -> str: + if self == table: + raise PermissionError(13, "Permission denied") + return real_read( + self, encoding=encoding, errors=errors, newline=newline + ) + + with mock.patch.object(Path, "read_text", _denied): + handle, scope = identify_scope( + "/", mountinfo_path=table, sys_base=sys_base + ) + self.assertIsNone(handle) + self.assertEqual(scope["status"], "unavailable") + self.assertIn("cannot read", str(scope.get("reason", ""))) + + +class NoDoubleCountTests(unittest.TestCase): + def test_partition_counter_used_alone(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sys_base = root / "sys" + (sys_base / "259:0").mkdir(parents=True) + (sys_base / "259:0" / "stat").write_text( + _stat_text(99999), encoding="utf-8" + ) + fixture = DeviceFixture(_stat_text(100)) + fixture.partition = True + _make_device(sys_base, "259:2", fixture) + diskstats = root / "diskstats" + diskstats.write_text( + "259 0 nvme0n1 0 0 0 0 0 0 99999 0 0 0 0 0 0 0\n" + "259 2 nvme0n1p2 0 0 0 0 9 0 100 0 0 0 0 0 0 0\n", + encoding="utf-8", + ) + table = _standard_table(root, _standard_mount("259:2", "/data")) + handle, scope = identify_scope( + "/data/x", + mountinfo_path=table, + sys_base=sys_base, + diskstats_path=diskstats, + ) + assert handle is not None + self.assertEqual(scope["status"], "available") + self.assertEqual(scope["scope_kind"], "regular_block_device") + self.assertEqual(scope["partition"], True) + sample = handle.read() + self.assertEqual(sample["sysfs_sectors_written"], 100) + after: filesystem.CounterSample = { + "sysfs_sectors_written": 110, + "writes_completed": sample["writes_completed"], + "in_flight": sample["in_flight"], + "diskstats_sectors_written": sample["diskstats_sectors_written"], + } + self.assertEqual(delta_bytes(sample, after), 10 * filesystem.SECTOR_BYTES) + + +class DeltaTests(unittest.TestCase): + def _sample(self, sectors: int) -> filesystem.CounterSample: + return { + "sysfs_sectors_written": sectors, + "writes_completed": 9, + "in_flight": 0, + "diskstats_sectors_written": sectors, + } + + def test_normal_delta_scales_by_sector(self) -> None: + before = self._sample(100) + after = self._sample(110) + self.assertEqual(delta_bytes(before, after), 10 * 512) + + def test_wrap_reset_gives_none(self) -> None: + self.assertIsNone(delta_bytes(self._sample(500), self._sample(12))) + + def test_missing_inputs_give_none(self) -> None: + self.assertIsNone(delta_bytes(None, None)) + before = self._sample(5) + after: filesystem.CounterSample = { + "sysfs_sectors_written": before["sysfs_sectors_written"], + "writes_completed": before["writes_completed"], + "in_flight": before["in_flight"], + "diskstats_sectors_written": None, + } + self.assertIsNone(delta_bytes(before, after, "diskstats_sectors_written")) + self.assertIsNone(delta_bytes(before, before, "no_such_key")) + + def test_equal_counters_give_zero(self) -> None: + sample = self._sample(7) + self.assertEqual(delta_bytes(sample, self._sample(7)), 0) + + def test_cli_identify_and_sample(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sys_base = root / "sys" + sys_base.mkdir() + _make_device(sys_base, "8:1", _ext4_device(3)) + table = _standard_table(root, _standard_mount("8:1", "/")) + base_args = ["--mountinfo", str(table), "--sys-base", str(sys_base)] + self.assertEqual( + filesystem.main(["identify", "--path", "/"] + base_args), 0 + ) + self.assertEqual(filesystem.main(["sample", "--path", "/"] + base_args), 0) + + def test_scope_json_serializable(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sys_base = root / "sys" + sys_base.mkdir() + _make_device(sys_base, "8:1", _ext4_device(3)) + table = _standard_table(root, _standard_mount("8:1", "/")) + _, scope = identify_scope("/", mountinfo_path=table, sys_base=sys_base) + json.dumps(scope) + + +if __name__ == "__main__": + unittest.main() From 20178a223c5ad2594c74e0fb78cb243ca4b0272c Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 27 Oct 2026 09:28:16 +0900 Subject: [PATCH 08/23] Document filesystem measurement limits --- docs/validation-tooling.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/validation-tooling.md b/docs/validation-tooling.md index 7df48e5ee..b509dd158 100644 --- a/docs/validation-tooling.md +++ b/docs/validation-tooling.md @@ -369,3 +369,27 @@ Owners, prerequisites, commands, and scope limitations are listed in each sectio # Run offline hermetic synthetic smoke probe (launches no workloads): uv run --offline --no-project --script tools/benchmark-campaign/test_measured_campaign.py ``` + +## 12a. Filesystem-aware Linux measurement boundary + +- **Tier**: In-tree benchmark campaign harness (diagnostic, not a product-cell qualification). +- **Owner scripts & tools**: + - Scope owner: `tools/benchmark-campaign/filesystem.py` (one owner for mount/filesystem/backing-counter scope plus read-only cumulative counter samples; no project or toolchain migration, stdlib only). + - Contract tests: `tools/benchmark-campaign/test_filesystem.py`. + - Consumer: the private index-write-attribution harness delegates its former mountinfo/sysfs/diskstats lookup, counter reads, and sector deltas to this owner; the harness keeps its own strict qualification gate. +- **Boundary**: one target filesystem per run. Apparent bytes, allocated file bytes, per-process I/O counters, and attributable host block-device writes are reported separately and never merged. Allocated-file measurement stays in the existing Rust `storage-footprint` tool and is not duplicated here. +- **Supported scopes**: + - `regular_block_device`: the mount resolves to one bare block device with a readable kernel sectors-written counter. Available (host-wide for that device). + - `zfs_pool`: ZFS datasets report pool-wide accounting that cannot isolate one filesystem, so the scope is unavailable; no pool total is converted into a per-filesystem value. + - `composite_anonymous`: overlay/tmpfs/ramfs/network/fuse/9p/virtiofs mounts (and other major-0 mounts) expose no per-filesystem block counter. Unavailable. + - `ambiguous_multi_device`: btrfs or any stacked (mapped) device over one or more underlying devices. A mapped device is never assumed to be a single disk; underlying counters are listed, never summed. Unavailable. + - `unavailable`: no mount entry, missing counter file or permission, unparsable counter, counter wrap/reset/recreation, or a `--device` override that names anything other than the mount's own `MAJ:MIN` (operator assertion alone is not proof of backing). Unavailable is reported with an explicit reason, never as zero. +- **Mechanisms checked** (read-only; the owner spawns no subprocess): `/proc/self/mountinfo` with octal-escape decoding and longest-prefix mount selection, the chosen device's `/sys/dev/block//stat` sectors-written counter (kernel 512-byte block-layer unit regardless of filesystem block size), `/proc/diskstats` as an optional cross-check, and `slaves`/`holders`/`shared-mount` topology plus informational queue/model attributes. Exactly one device counter is ever read per scope: a partition's own counter, never the parent disk added to it. +- **Honest limits**: the counter is host-wide for its device (every writer included); the collector never attests isolation and never qualifies a run. Counter wrap/reset yields unavailable, not a negative or zero delta. Per-filesystem physical writes are never fabricated from allocation counters, per-process I/O counters, or pool totals. +- **Parent live observation** (bounded, read-only): + ```sh + # Identify the scope behind a scratch path: + python3 tools/benchmark-campaign/filesystem.py identify --path /srv/scratch + # Identify and take one cumulative sample: + python3 tools/benchmark-campaign/filesystem.py sample --path /srv/scratch + ``` From 39a5652c7f390c3f201ef7c7313a86e4f6ead033 Mon Sep 17 00:00:00 2001 From: alpha Date: Tue, 27 Oct 2026 19:53:18 +0900 Subject: [PATCH 09/23] Remove non-operational named-person anchor from window attribution doc Drop the 'Anchors: John Carmack, Richard Feynman' line from docs/benchmarks/window-pipeline-attribution.md. No measured value, protocol rule, gate, or blocker changes. --- docs/benchmarks/window-pipeline-attribution.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/benchmarks/window-pipeline-attribution.md b/docs/benchmarks/window-pipeline-attribution.md index eca61f22e..4875aab5d 100644 --- a/docs/benchmarks/window-pipeline-attribution.md +++ b/docs/benchmarks/window-pipeline-attribution.md @@ -1,7 +1,5 @@ # Window pipeline and group commit attribution (ENG-91, #1607) -Anchors: John Carmack, Richard Feynman - This page owns the measurement protocol, the evidence schema and the promotion gates for the windowed block apply and its durable group commit (ENG-91, issue #1607). It records no measured value: every number lives in the files a run From 500fb4466202f0bab9b3bffd7703c81a7ee04653 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:30:15 +0900 Subject: [PATCH 10/23] Split mountinfo records only on newline; describe diskstats and shared mounts as diagnostics --- docs/validation-tooling.md | 2 +- tools/benchmark-campaign/filesystem.py | 21 ++++++++++++++++----- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/docs/validation-tooling.md b/docs/validation-tooling.md index b509dd158..1e8ee696a 100644 --- a/docs/validation-tooling.md +++ b/docs/validation-tooling.md @@ -384,7 +384,7 @@ Owners, prerequisites, commands, and scope limitations are listed in each sectio - `composite_anonymous`: overlay/tmpfs/ramfs/network/fuse/9p/virtiofs mounts (and other major-0 mounts) expose no per-filesystem block counter. Unavailable. - `ambiguous_multi_device`: btrfs or any stacked (mapped) device over one or more underlying devices. A mapped device is never assumed to be a single disk; underlying counters are listed, never summed. Unavailable. - `unavailable`: no mount entry, missing counter file or permission, unparsable counter, counter wrap/reset/recreation, or a `--device` override that names anything other than the mount's own `MAJ:MIN` (operator assertion alone is not proof of backing). Unavailable is reported with an explicit reason, never as zero. -- **Mechanisms checked** (read-only; the owner spawns no subprocess): `/proc/self/mountinfo` with octal-escape decoding and longest-prefix mount selection, the chosen device's `/sys/dev/block//stat` sectors-written counter (kernel 512-byte block-layer unit regardless of filesystem block size), `/proc/diskstats` as an optional cross-check, and `slaves`/`holders`/`shared-mount` topology plus informational queue/model attributes. Exactly one device counter is ever read per scope: a partition's own counter, never the parent disk added to it. +- **Mechanisms checked** (read-only; the owner spawns no subprocess): `/proc/self/mountinfo` with octal-escape decoding and longest-prefix mount selection, the chosen device's `/sys/dev/block//stat` sectors-written counter (kernel 512-byte block-layer unit regardless of filesystem block size), `/proc/diskstats` sectors-written recorded as a diagnostic alongside the sysfs counter (never cross-validated against it), `slaves`/`holders` topology, and `shared-mount` plus informational queue/model attributes recorded as diagnostics. Exactly one device counter is ever read per scope: a partition's own counter, never the parent disk added to it. - **Honest limits**: the counter is host-wide for its device (every writer included); the collector never attests isolation and never qualifies a run. Counter wrap/reset yields unavailable, not a negative or zero delta. Per-filesystem physical writes are never fabricated from allocation counters, per-process I/O counters, or pool totals. - **Parent live observation** (bounded, read-only): ```sh diff --git a/tools/benchmark-campaign/filesystem.py b/tools/benchmark-campaign/filesystem.py index 5dbcee0ac..e62349281 100644 --- a/tools/benchmark-campaign/filesystem.py +++ b/tools/benchmark-campaign/filesystem.py @@ -35,10 +35,13 @@ Unavailable with an explicit reason, never zero. Read-only bound: this module only reads the mount table, the one chosen -device's counter files, and local topology/queue attribute files listed in -``mechanisms_checked``. It spawns no subprocess, writes nothing, and follows -exactly one counter per scope (a partition's own counter, never the parent -disk added to it, never slaves added together). +device's sysfs counter file, and local topology/queue attribute files listed +in ``mechanisms_checked``. The ``/proc/diskstats`` sectors-written value and +the ``shared_mounts`` list are recorded diagnostics alongside the sysfs +counter, never cross-validated against it and never gating availability. It +spawns no subprocess, writes nothing, and follows exactly one counter per +scope (a partition's own counter, never the parent disk added to it, never +slaves added together). Sector unit: the kernel block-layer counter unit is always 512 bytes, independent of the filesystem logical block size or the device physical @@ -165,7 +168,11 @@ def read_mountinfo(path: str | Path = MOUNTINFO_DEFAULT) -> list[MountInfo]: if text is None: return [] mounts: list[MountInfo] = [] - for line in text.splitlines(): + # Split records only on "\n": the kernel octal-escapes space/tab/newline/ + # backslash, so remaining raw vertical-tab, form-feed, and carriage-return + # bytes in mountpoints must survive as record content. str.splitlines() + # would split on those bytes and corrupt such records. + for line in text.split("\n"): if " - " not in line: continue left, right = line.split(" - ", 1) @@ -272,6 +279,8 @@ def read(self) -> CounterSample: or sample["in_flight"] < 0 ): raise ValueError("counter file holds a negative value") + # Recorded diagnostic only: the diskstats value is stored alongside the + # sysfs counter and is never cross-validated against it. text = _read_text(self.diskstats_path) if text is not None: for line in text.splitlines(): @@ -485,6 +494,8 @@ def identify_scope( ), ) return None, info + # Recorded diagnostic only: other mounts on this device are listed for + # context and never cross-validated against the counter. shared = [ str(entry["mountpoint"]) for entry in mounts From f83fa2aca6c61b98e3223f67c0770b11e22018a8 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Tue, 27 Oct 2026 20:33:25 +0900 Subject: [PATCH 11/23] Preserve raw mountinfo bytes with newline-disabled reads; cover VT/FF/CR split --- tools/benchmark-campaign/filesystem.py | 25 ++++++++++++++------- tools/benchmark-campaign/test_filesystem.py | 15 +++++++++++++ 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/tools/benchmark-campaign/filesystem.py b/tools/benchmark-campaign/filesystem.py index e62349281..0cf3377fc 100644 --- a/tools/benchmark-campaign/filesystem.py +++ b/tools/benchmark-campaign/filesystem.py @@ -149,9 +149,18 @@ def unescape_mountinfo(field: str) -> str: return raw.decode("utf-8", errors="surrogateescape") -def _read_text(path: Path | str, errors: str = "replace") -> str | None: +def _read_text( + path: Path | str, errors: str = "replace", newline: str | None = None +) -> str | None: try: - return Path(path).read_text(encoding="utf-8", errors=errors) + if newline is None: + return Path(path).read_text(encoding="utf-8", errors=errors) + try: + return Path(path).read_text( + encoding="utf-8", errors=errors, newline=newline + ) + except TypeError: + return Path(path).read_text(encoding="utf-8", errors=errors) except OSError: return None @@ -163,15 +172,15 @@ def read_mountinfo(path: str | Path = MOUNTINFO_DEFAULT) -> list[MountInfo]: survive decoding, consistent with the os.fsdecode-derived scratch path they are matched against. Replacement decoding would map distinct byte sequences onto one placeholder and misroute child mounts to ancestors. + Records are read with newline translation disabled and split only on + "\n": the kernel escapes just space, tab, newline, and backslash, so + str.splitlines() would wrongly split records holding raw vertical-tab, + form-feed, or carriage-return bytes in mountpoints. """ - text = _read_text(path, errors="surrogateescape") + text = _read_text(path, errors="surrogateescape", newline="") if text is None: return [] mounts: list[MountInfo] = [] - # Split records only on "\n": the kernel octal-escapes space/tab/newline/ - # backslash, so remaining raw vertical-tab, form-feed, and carriage-return - # bytes in mountpoints must survive as record content. str.splitlines() - # would split on those bytes and corrupt such records. for line in text.split("\n"): if " - " not in line: continue @@ -369,7 +378,7 @@ def identify_scope( "mount": None, "mechanisms_checked": mechanisms, } - if _read_text(mountinfo_path, errors="surrogateescape") is None: + if _read_text(mountinfo_path, errors="surrogateescape", newline="") is None: info.update( status="unavailable", scope_kind="unavailable", diff --git a/tools/benchmark-campaign/test_filesystem.py b/tools/benchmark-campaign/test_filesystem.py index 556799b4f..3d832fe01 100644 --- a/tools/benchmark-campaign/test_filesystem.py +++ b/tools/benchmark-campaign/test_filesystem.py @@ -186,6 +186,21 @@ def test_mountinfo_escaped_mountpoint(self) -> None: self.assertEqual(len(mounts), 1) self.assertEqual(mounts[0]["mountpoint"], "/mnt/my dir") + def test_mountinfo_splits_only_on_newline(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + table = Path(tmp) / "mountinfo" + table.write_text( + "1 0 8:1 / /data\x0braw rw,relatime - ext4 /dev/sda1 rw\n" + "2 0 8:2 / /other\x0craw rw,relatime - ext4 /dev/sda2 rw\n" + "3 0 8:3 / /car\riageraw rw,relatime - ext4 /dev/sda3 rw\n", + encoding="utf-8", + ) + mounts = read_mountinfo(table) + self.assertEqual( + [mount["mountpoint"] for mount in mounts], + ["/data\x0braw", "/other\x0craw", "/car\riageraw"], + ) + def test_mountinfo_malformed_lines_skipped(self) -> None: with tempfile.TemporaryDirectory() as tmp: table = Path(tmp) / "mountinfo" From 5c39599059e4eb080353d172204042b9bc5801ea Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 03:41:30 +0900 Subject: [PATCH 12/23] Format filesystem scope regression --- tools/benchmark-campaign/test_filesystem.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tools/benchmark-campaign/test_filesystem.py b/tools/benchmark-campaign/test_filesystem.py index 3d832fe01..a2d092958 100644 --- a/tools/benchmark-campaign/test_filesystem.py +++ b/tools/benchmark-campaign/test_filesystem.py @@ -233,7 +233,9 @@ def test_invalid_octal_escape_skips_entry(self) -> None: ) mounts = read_mountinfo(table) self.assertEqual([mount["majmin"] for mount in mounts], ["8:1"]) - _handle, scope = identify_scope("/", mountinfo_path=table, sys_base=sys_base) + _handle, scope = identify_scope( + "/", mountinfo_path=table, sys_base=sys_base + ) self.assertEqual(scope["status"], "available") self.assertEqual(scope["majmin"], "8:1") From f8b212ba2bcdae455b206e137f25c063f91390be Mon Sep 17 00:00:00 2001 From: "gormes-agent[bot]" Date: Wed, 28 Oct 2026 04:42:26 +0000 Subject: [PATCH 13/23] Disable newline translation in mountinfo reads on Python 3.12 --- tools/benchmark-campaign/filesystem.py | 13 +++---- tools/benchmark-campaign/test_filesystem.py | 40 +++++++++++++++------ 2 files changed, 34 insertions(+), 19 deletions(-) diff --git a/tools/benchmark-campaign/filesystem.py b/tools/benchmark-campaign/filesystem.py index 0cf3377fc..cfd4ed7a6 100644 --- a/tools/benchmark-campaign/filesystem.py +++ b/tools/benchmark-campaign/filesystem.py @@ -152,15 +152,12 @@ def unescape_mountinfo(field: str) -> str: def _read_text( path: Path | str, errors: str = "replace", newline: str | None = None ) -> str | None: + # Path.open (not read_text) so ``newline`` is honored on Python 3.12, + # whose read_text lacks the argument; universal-newline translation + # would rewrite raw carriage returns inside mountinfo records. try: - if newline is None: - return Path(path).read_text(encoding="utf-8", errors=errors) - try: - return Path(path).read_text( - encoding="utf-8", errors=errors, newline=newline - ) - except TypeError: - return Path(path).read_text(encoding="utf-8", errors=errors) + with Path(path).open(encoding="utf-8", errors=errors, newline=newline) as file: + return file.read() except OSError: return None diff --git a/tools/benchmark-campaign/test_filesystem.py b/tools/benchmark-campaign/test_filesystem.py index a2d092958..f2b91b073 100644 --- a/tools/benchmark-campaign/test_filesystem.py +++ b/tools/benchmark-campaign/test_filesystem.py @@ -201,6 +201,31 @@ def test_mountinfo_splits_only_on_newline(self) -> None: ["/data\x0braw", "/other\x0craw", "/car\riageraw"], ) + def test_mountinfo_keeps_carriage_return_without_read_text_newline( + self, + ) -> None: + # Python 3.12 Path.read_text has no ``newline`` argument; the reader + # must still disable newline translation so a raw CR survives. + with tempfile.TemporaryDirectory() as tmp: + table = Path(tmp) / "mountinfo" + table.write_bytes( + b"1 0 8:1 / / rw - ext4 /dev/sda1 rw\n" + b"2 1 8:2 / /car\riage rw - ext4 /dev/sda2 rw\n" + ) + + def _no_newline( + self: Path, encoding: str | None = None, errors: str | None = None + ) -> str: + # 3.12 read_text always applies universal-newline translation. + text = self.read_bytes().decode(encoding or "utf-8", errors or "strict") + return text.replace("\r\n", "\n").replace("\r", "\n") + + with mock.patch.object(Path, "read_text", _no_newline): + mounts = read_mountinfo(table) + self.assertEqual( + [mount["mountpoint"] for mount in mounts], ["/", "/car\riage"] + ) + def test_mountinfo_malformed_lines_skipped(self) -> None: with tempfile.TemporaryDirectory() as tmp: table = Path(tmp) / "mountinfo" @@ -495,21 +520,14 @@ def test_unreadable_mountinfo_names_cause(self) -> None: sys_base = root / "sys" sys_base.mkdir() table = _standard_table(root, _standard_mount("8:1", "/")) - real_read = Path.read_text + real_open = Path.open - def _denied( - self: Path, - encoding: str | None = None, - errors: str | None = None, - newline: str | None = None, - ) -> str: + def _denied(self: Path, *args: object, **kwargs: object) -> object: if self == table: raise PermissionError(13, "Permission denied") - return real_read( - self, encoding=encoding, errors=errors, newline=newline - ) + return real_open(self, *args, **kwargs) # type: ignore[arg-type] - with mock.patch.object(Path, "read_text", _denied): + with mock.patch.object(Path, "open", _denied): handle, scope = identify_scope( "/", mountinfo_path=table, sys_base=sys_base ) From 6cc1c04f4b342304f23ee68557ff25959d24790c Mon Sep 17 00:00:00 2001 From: "gormes-agent[bot]" Date: Wed, 28 Oct 2026 04:46:24 +0000 Subject: [PATCH 14/23] Reject partitions of stacked devices as ambiguous --- tools/benchmark-campaign/filesystem.py | 20 ++++++++++++++++++ tools/benchmark-campaign/test_filesystem.py | 23 +++++++++++++++++++++ 2 files changed, 43 insertions(+) diff --git a/tools/benchmark-campaign/filesystem.py b/tools/benchmark-campaign/filesystem.py index cfd4ed7a6..4991a9969 100644 --- a/tools/benchmark-campaign/filesystem.py +++ b/tools/benchmark-campaign/filesystem.py @@ -509,6 +509,26 @@ def identify_scope( ] partition = (real_sys / "partition").exists() disk_dir = real_sys.parent if partition else real_sys + if partition: + # A partition of a stacked device (md/dm partition) lists no slaves of + # its own; the parent disk's topology decides whether it is stacked. + # Only the partition's own counter is still the one read. + parent_slaves_dir = disk_dir / "slaves" + mechanisms.append(str(parent_slaves_dir)) + parent_slaves = _child_names(parent_slaves_dir) + if parent_slaves is None: + info.update( + status="unavailable", + scope_kind="unavailable", + majmin=chosen, + sysfs_stat=str(stat_path), + reason=( + f"cannot list {parent_slaves_dir}: device topology is " + "unreadable, so stacking cannot be ruled out" + ), + ) + return None, info + slaves = slaves + [name for name in parent_slaves if name not in slaves] queue_dir = disk_dir / "queue" mechanisms.append(str(queue_dir)) model_path = disk_dir / "device" / "model" diff --git a/tools/benchmark-campaign/test_filesystem.py b/tools/benchmark-campaign/test_filesystem.py index f2b91b073..7c96e6972 100644 --- a/tools/benchmark-campaign/test_filesystem.py +++ b/tools/benchmark-campaign/test_filesystem.py @@ -575,6 +575,29 @@ def test_partition_counter_used_alone(self) -> None: } self.assertEqual(delta_bytes(sample, after), 10 * filesystem.SECTOR_BYTES) + def test_partition_of_stacked_device_is_ambiguous(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sys_base = root / "sys" + disk = root / "devices" / "md0" + (disk / "slaves" / "sda").mkdir(parents=True) + (disk / "slaves" / "sdb").mkdir() + part = disk / "md0p1" + (part / "slaves").mkdir(parents=True) + (part / "holders").mkdir() + (part / "partition").write_text("1\n", encoding="utf-8") + (part / "stat").write_text(_stat_text(100), encoding="utf-8") + sys_base.mkdir() + (sys_base / "259:1").symlink_to(part) + table = _standard_table(root, _standard_mount("259:1", "/data")) + handle, scope = identify_scope( + "/data/x", mountinfo_path=table, sys_base=sys_base + ) + self.assertIsNone(handle) + self.assertEqual(scope["status"], "unavailable") + self.assertEqual(scope["scope_kind"], "ambiguous_multi_device") + self.assertEqual(scope["stacked_slaves"], ["sda", "sdb"]) + class DeltaTests(unittest.TestCase): def _sample(self, sectors: int) -> filesystem.CounterSample: From 3702d151910874795ac28f0ed1fa988dfc812b94 Mon Sep 17 00:00:00 2001 From: "gormes-agent[bot]" Date: Wed, 28 Oct 2026 04:55:48 +0000 Subject: [PATCH 15/23] Report malformed mountinfo MAJ:MIN as unavailable --- tools/benchmark-campaign/filesystem.py | 15 ++++++++++----- tools/benchmark-campaign/test_filesystem.py | 14 ++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/tools/benchmark-campaign/filesystem.py b/tools/benchmark-campaign/filesystem.py index 4991a9969..9cebf8153 100644 --- a/tools/benchmark-campaign/filesystem.py +++ b/tools/benchmark-campaign/filesystem.py @@ -91,7 +91,7 @@ "nr_requests", ) -_MAJMIN_RE = re.compile(r"^\d+:\d+$") +_MAJMIN_RE = re.compile(r"[0-9]+:[0-9]+") class MountInfo(TypedDict): @@ -392,7 +392,14 @@ def identify_scope( reason=(f"no {mountinfo_path} entry contains the scratch path"), ) return None, info - if override is not None and not _MAJMIN_RE.match(override): + if not _MAJMIN_RE.fullmatch(mount["majmin"]): + info.update( + status="unavailable", + scope_kind="unavailable", + reason=f"malformed MAJ:MIN {mount['majmin']!r} for {mount['mountpoint']}", + ) + return None, info + if override is not None and not _MAJMIN_RE.fullmatch(override): info.update( status="unavailable", scope_kind="unavailable", @@ -443,9 +450,7 @@ def identify_scope( ), ) return None, info - major_text = chosen.split(":")[0] if ":" in chosen else "" - major = int(major_text) if major_text.isdigit() else 0 - if _is_composite_fstype(fstype) or major == 0: + if _is_composite_fstype(fstype) or int(chosen.split(":")[0]) == 0: info.update( status="unavailable", scope_kind="composite_anonymous", diff --git a/tools/benchmark-campaign/test_filesystem.py b/tools/benchmark-campaign/test_filesystem.py index 7c96e6972..9b9e7a3d1 100644 --- a/tools/benchmark-campaign/test_filesystem.py +++ b/tools/benchmark-campaign/test_filesystem.py @@ -575,6 +575,20 @@ def test_partition_counter_used_alone(self) -> None: } self.assertEqual(delta_bytes(sample, after), 10 * filesystem.SECTOR_BYTES) + def test_malformed_mount_majmin_is_unavailable(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sys_base = root / "sys" + sys_base.mkdir() + for majmin in ("\u00b2:1", "\u0663:1", "8", "../8:1"): + table = _standard_table(root, _standard_mount(majmin, "/")) + handle, scope = identify_scope( + "/", mountinfo_path=table, sys_base=sys_base + ) + self.assertIsNone(handle) + self.assertEqual(scope["status"], "unavailable") + self.assertIn("malformed", str(scope.get("reason", ""))) + def test_partition_of_stacked_device_is_ambiguous(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) From 994e82acf425ff420921238514545c99c75ea4f4 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:30:20 +0900 Subject: [PATCH 16/23] Exclude unavailable blocks per P2P request entry Rebuild foreground and historical batches without bodies the owner already refused, pinning the carried cursor to the lowest excluded height. The remote branch already fixes benchmark filesystem scope. --- crates/mempool/src/submit_package.rs | 12 +++++++++ crates/p2p/src/download_window.rs | 37 ++++++++++++++++++++++++++++ crates/p2p/src/sync/historical.rs | 27 ++++++++++---------- crates/p2p/src/sync/requests.rs | 32 +++++++++++------------- 4 files changed, 78 insertions(+), 30 deletions(-) diff --git a/crates/mempool/src/submit_package.rs b/crates/mempool/src/submit_package.rs index bfa21a548..6cd34314d 100644 --- a/crates/mempool/src/submit_package.rs +++ b/crates/mempool/src/submit_package.rs @@ -10,6 +10,18 @@ use crate::{ use alloc::vec::Vec; use bitcoin_rs_primitives::{Tx, unix_time_secs}; +/// Orders an arbitrary-order child-with-parents package for submission. +/// +/// The returned vec is topologically ordered (independent parents sorted by +/// txid, child last) but deliberately NOT fully structure-checked: package +/// weight, intra-package conflicts, and package order are validated by +/// [`crate::package::check_structure`], which +/// [`MempoolGateway::submit_package_with_times`] runs as the single +/// authoritative structural gate (snapshot restore enters there directly, so +/// the check cannot live here without charging local submission a duplicate +/// pass). This function stays private with [`MempoolGateway::submit_local_package`] +/// as its only caller, so its output always flows through that gate and can +/// never reach admission unchecked. fn ordered(mut txs: Vec) -> Result, SubmitError> { if txs.is_empty() || txs.len() > crate::standardness::MAX_PACKAGE_COUNT { return Err(AcceptanceRejectReason::PackageCount.into()); diff --git a/crates/p2p/src/download_window.rs b/crates/p2p/src/download_window.rs index 4d0622742..775652319 100644 --- a/crates/p2p/src/download_window.rs +++ b/crates/p2p/src/download_window.rs @@ -79,6 +79,43 @@ impl PeerRequest { pub(crate) fn len(&self) -> usize { self.entries.len() } + + /// Rebuilds the batch without the entries `exclude` rejects. + /// + /// The carried frontier clamps to the lowest excluded height so that + /// `mark_requested` never advances the window cursor past a hash this + /// peer cannot serve: excluded heights stay re-plannable for the next + /// peer instead of stranding below the cursor. Returns `None` when + /// every entry is excluded; the caller then sends and marks nothing, + /// leaving the cursor untouched for another peer or a later retry. + pub(crate) fn excluding( + &self, + mut exclude: impl FnMut(Hash256) -> bool, + ) -> Option { + let mut excluded_floor: Option = None; + let mut entries = Vec::with_capacity(self.entries.len()); + for entry in &self.entries { + if exclude(entry.hash) { + excluded_floor = Some( + excluded_floor.map_or(entry.height, |floor: u32| floor.min(entry.height)), + ); + } else { + entries.push(*entry); + } + } + if entries.is_empty() { + return None; + } + let next_request_height = match excluded_floor { + Some(floor) => self.next_request_height.min(floor), + None => self.next_request_height, + }; + Some(Self { + owner: self.owner, + entries, + next_request_height, + }) + } } #[derive(Clone, Copy, Debug)] diff --git a/crates/p2p/src/sync/historical.rs b/crates/p2p/src/sync/historical.rs index 627251868..1b203ee94 100644 --- a/crates/p2p/src/sync/historical.rs +++ b/crates/p2p/src/sync/historical.rs @@ -170,20 +170,21 @@ impl BlockSync { let Some(request) = request else { continue; }; - // A plan that names a body `source` already answered `notfound` - // for is dropped whole, exactly as the foreground path does: the - // lock is the scheduler's, taken after the historical lock is - // released, and nothing was marked, so a later pass plans the - // same blocks again for another peer, or for this one once the - // retry interval has passed. - if self - .scheduler - .lock() - .body_unavailable - .refuses_request(&request, now) - { + // Entries `source` already answered `notfound` for are excluded + // from its batch instead of dropping the batch whole, exactly as + // the foreground path does: the lock is the scheduler's, taken + // after the historical lock is released, and nothing was marked, + // so the surviving entries are marked below while an excluded + // hash stays re-plannable for another peer — or for this one once + // the retry interval has passed. An all-excluded plan sends + // nothing, leaving the cursor for the next pass. + let Some(request) = ({ + let scheduler = self.scheduler.lock(); + let owner = request.owner(); + request.excluding(|hash| scheduler.body_unavailable.refuses(owner, hash, now)) + }) else { continue; - } + }; let inventory = request .entries() .map(|(_, hash)| { diff --git a/crates/p2p/src/sync/requests.rs b/crates/p2p/src/sync/requests.rs index d9d834cd2..45560e606 100644 --- a/crates/p2p/src/sync/requests.rs +++ b/crates/p2p/src/sync/requests.rs @@ -10,7 +10,6 @@ use super::telemetry::metric_count; use crate::Message; use crate::connection::PeerSource; use crate::download_window::BlockDownloadPolicy; -use crate::download_window::PeerRequest; use crate::download_window::SyncPeer; use crate::download_window::statically_fanout_eligible; use crate::download_window::{peer_can_serve_height, servable_floor, serves_requested_height}; @@ -93,15 +92,6 @@ impl UnavailableBodies { .is_some_and(|answered_at| Self::live(*answered_at, now)) } - /// Whether any entry of `request` names a body its owner answered - /// `notfound` for. One hash probe per entry. - pub(super) fn refuses_request(&self, request: &PeerRequest, now: Instant) -> bool { - !self.answered.is_empty() - && request - .entries() - .any(|(_, hash)| self.refuses(request.owner(), hash, now)) - } - /// Forgets `source`'s answer for `hash`: it delivered the body, so the /// earlier `notfound` no longer describes it. pub(super) fn clear(&mut self, source: PeerSource, hash: Hash256) { @@ -390,12 +380,16 @@ impl BlockSync { } let servable_floor = servable_floor(&info, &policy); let tree = self.chain.block_tree(); - // A plan that names a body `source` already answered `notfound` for - // is dropped whole: nothing was marked, so the ordinary scheduler - // plans the same blocks again, for the next peer this tick or for - // `source` once the retry interval has passed. Nothing waits on the - // refused peer and no message goes out, so a peer that lacks a body - // is never polled for it at wake-up pace. + // Entries `source` already answered `notfound` for are excluded from + // its batch instead of dropping the batch whole, so one missing body + // no longer blocks the bodies `source` can serve — and a missing + // body no longer blocks the rest of the batch from another peer that + // holds it. The carried frontier clamps to the lowest excluded + // height, so nothing excluded strands below the window cursor; the + // next peer this tick replans it, or `source` does once the retry + // interval has passed. Nothing waits on the refused peer and no + // message goes out for a body it lacks, so a peer is never polled + // for it at wake-up pace. let request = { let mut scheduler = self.scheduler.lock(); let SchedulerState { @@ -416,7 +410,11 @@ impl BlockSync { &tree, now, ) - .filter(|request| !body_unavailable.refuses_request(request, now)) + .and_then(|request| { + let owner = request.owner(); + let unavailable = &*body_unavailable; + request.excluding(|hash| unavailable.refuses(owner, hash, now)) + }) }; drop(tree); let Some(request) = request else { From e96d08bc592ad9c9496e4535a4ec75d1fda95299 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 21:43:11 +0900 Subject: [PATCH 17/23] Adapt merged sync tests to branch ownership API Main compact-sync tests called record_owned_body_fetch, which the branch replaced with enqueue_owned_body_fetch. The NotFoundFixture fields also kept SocketAddr after current_source and test_addr moved to PeerRemote. - Add the missing SocketAddr import. - Store owner and alternate as PeerRemote. - Mark the owned fetch through enqueue_owned_body_fetch. --- crates/p2p/src/sync/tests.rs | 5 +++-- crates/p2p/src/sync/tests/head_sync.rs | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/p2p/src/sync/tests.rs b/crates/p2p/src/sync/tests.rs index 1b3929bd7..67621ed88 100644 --- a/crates/p2p/src/sync/tests.rs +++ b/crates/p2p/src/sync/tests.rs @@ -2,6 +2,7 @@ use crate::PeerRemote; pub(crate) use crate::test_support::{OrFail, TestResult}; use std::net::IpAddr; use std::net::Ipv4Addr; +use std::net::SocketAddr; use std::sync::Arc; use std::time::Duration; use std::time::Instant; @@ -2881,9 +2882,9 @@ struct NotFoundFixture { applied_tip: Arc>, blocks: Vec, blocks_tx: InboundBlockSender, - owner: SocketAddr, + owner: PeerRemote, owner_rx: crate::test_support::MessageReceiver, - alternate: SocketAddr, + alternate: PeerRemote, alternate_rx: Option, } diff --git a/crates/p2p/src/sync/tests/head_sync.rs b/crates/p2p/src/sync/tests/head_sync.rs index 27c6ee995..f87e8e838 100644 --- a/crates/p2p/src/sync/tests/head_sync.rs +++ b/crates/p2p/src/sync/tests/head_sync.rs @@ -1456,7 +1456,7 @@ fn compact_reconstruction_admission_stays_near_the_applied_tip() -> TestResult { !sync.compact_reconstruction_admissible(source, &blocks[5].header), "five blocks above the applied tip" ); - sync.record_owned_body_fetch(source, Hash256::from(blocks[3].block_hash())); + assert!(sync.enqueue_owned_body_fetch(source, Hash256::from(blocks[3].block_hash()), || true)); assert!( sync.compact_reconstruction_admissible(source, &blocks[3].header), "a fetch this connection owns" From 4f29006d4477cbc3b8290c92fe4ed944e7190309 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:09:44 +0900 Subject: [PATCH 18/23] benchmark-campaign: stat scratch dir, reject gapped mountinfo, bound counters to u64 --- tools/benchmark-campaign/filesystem.py | 55 ++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 7 deletions(-) diff --git a/tools/benchmark-campaign/filesystem.py b/tools/benchmark-campaign/filesystem.py index 9cebf8153..81a216027 100644 --- a/tools/benchmark-campaign/filesystem.py +++ b/tools/benchmark-campaign/filesystem.py @@ -50,7 +50,9 @@ import argparse import json +import os import re +import stat import sys from pathlib import Path from typing import TypedDict @@ -60,6 +62,10 @@ SECTOR_BYTES = 512 +# Kernel block-layer counters are 64-bit unsigned; anything outside +# ``0 <= v <= 2**64 - 1`` is not a counter value and must be rejected. +U64_MAX = (1 << 64) - 1 + MOUNTINFO_DEFAULT = "/proc/self/mountinfo" DISKSTATS_DEFAULT = "/proc/diskstats" SYS_BASE_DEFAULT = Path("/sys/dev/block") @@ -186,6 +192,12 @@ def read_mountinfo(path: str | Path = MOUNTINFO_DEFAULT) -> list[MountInfo]: right_fields = right.split(" ") if len(left_fields) < 6 or len(right_fields) < 2: continue + # The kernel separates fields with single spaces and escapes literal + # spaces inside mountpoints, so an empty field means a malformed or + # hand-edited line. Skipping it beats shifting every index and + # attributing the scratch path to the wrong mount (often ``/``). + if "" in left_fields or "" in right_fields: + continue try: entry: MountInfo = { "mount_id": left_fields[0], @@ -280,11 +292,11 @@ def read(self) -> CounterSample: "diskstats_sectors_written": None, } if ( - sample["sysfs_sectors_written"] < 0 - or sample["writes_completed"] < 0 - or sample["in_flight"] < 0 + not 0 <= sample["sysfs_sectors_written"] <= U64_MAX + or not 0 <= sample["writes_completed"] <= U64_MAX + or not 0 <= sample["in_flight"] <= U64_MAX ): - raise ValueError("counter file holds a negative value") + raise ValueError("counter file holds a value outside 0 <= v <= 2**64 - 1") # Recorded diagnostic only: the diskstats value is stored alongside the # sysfs counter and is never cross-validated against it. text = _read_text(self.diskstats_path) @@ -301,7 +313,7 @@ def read(self) -> CounterSample: sectors = int(parts[9]) except ValueError: break - if sectors >= 0: + if 0 <= sectors <= U64_MAX: sample["diskstats_sectors_written"] = sectors break return sample @@ -316,7 +328,11 @@ def try_read(handle: CounterHandle) -> CounterSample | None: def _counter_int(value: Json) -> int | None: - if isinstance(value, bool) or not isinstance(value, int) or value < 0: + if ( + isinstance(value, bool) + or not isinstance(value, int) + or not 0 <= value <= U64_MAX + ): return None return value @@ -364,7 +380,32 @@ def identify_scope( sys_base_path = Path(sys_base) diskstats = Path(diskstats_path) real = str(Path(scratch).resolve()) - mechanisms: list[str] = [str(mountinfo_path)] + try: + scratch_is_dir = stat.S_ISDIR(os.stat(real).st_mode) + scratch_reason: str | None = None + except OSError as error: + scratch_is_dir = False + scratch_reason = f"cannot stat scratch path {real}: {error}" + # ``Path.resolve()`` never touches the filesystem, so without this stat a + # removed scratch path would still resolve against its old mount and + # report ``available``. The scratch stat is itself a checked mechanism. + mechanisms: list[str] = [str(mountinfo_path), real] + if not scratch_is_dir: + if scratch_reason is None: + scratch_reason = f"scratch path {real} is not a directory" + return None, { + "scratch_realpath": real, + "counter_unit": "sectors of 512 bytes (kernel block-layer unit)", + "counter_scope": ( + "host-visible block-device writes when available; " + "not flash-internal write amplification" + ), + "mount": None, + "mechanisms_checked": mechanisms, + "status": "unavailable", + "scope_kind": "unavailable", + "reason": scratch_reason, + } info: ScopeDoc = { "scratch_realpath": real, "counter_unit": "sectors of 512 bytes (kernel block-layer unit)", From cb1504a17884a7ea9b8c98e24a037b4f341c0f8a Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:09:44 +0900 Subject: [PATCH 19/23] benchmark-campaign: validate evidence JSON depth before canonical publish --- tools/benchmark-campaign/evidence.py | 3 +++ tools/benchmark-campaign/test_evidence.py | 32 +++++++++++++++++++++++ 2 files changed, 35 insertions(+) create mode 100644 tools/benchmark-campaign/test_evidence.py diff --git a/tools/benchmark-campaign/evidence.py b/tools/benchmark-campaign/evidence.py index 218d9d838..f94f14919 100644 --- a/tools/benchmark-campaign/evidence.py +++ b/tools/benchmark-campaign/evidence.py @@ -120,6 +120,9 @@ def _publish_result(result: JsonObject, output: Path) -> None: if sys.platform != "linux": raise ContractError("publication requires Linux O_TMPFILE support") at_empty_path = getattr(os, "AT_EMPTY_PATH", 0x1000) + # ``canonical_bytes`` would recurse into a hostile nest before any limit + # applies; validate the depth first so oversized input fails closed here. + _validate_json_depth(result, "result") payload = canonical_bytes(result) + b"\n" try: directory = os.open(output.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_CLOEXEC) diff --git a/tools/benchmark-campaign/test_evidence.py b/tools/benchmark-campaign/test_evidence.py new file mode 100644 index 000000000..ef983f6e1 --- /dev/null +++ b/tools/benchmark-campaign/test_evidence.py @@ -0,0 +1,32 @@ +# pyright: strict +"""Contract tests for evidence publication validation.""" + +import sys +import tempfile +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) + +from evidence import ContractError, _publish_result, canonical_bytes + + +class PublishDepthTests(unittest.TestCase): + def test_over_deep_result_is_rejected(self) -> None: + nested: object = {"leaf": 1} + for _ in range(40): + nested = [nested] + with tempfile.TemporaryDirectory() as tmp: + with self.assertRaises(ContractError): + _publish_result({"result": nested}, Path(tmp) / "out.json") + + def test_shallow_result_publishes_atomically(self) -> None: + result: dict[str, object] = {"schema": "test-v1", "samples": 3} + with tempfile.TemporaryDirectory() as tmp: + output = Path(tmp) / "out.json" + _publish_result(result, output) + self.assertEqual(output.read_bytes(), canonical_bytes(result) + b"\n") + + +if __name__ == "__main__": + unittest.main() From 1d2a436052147bd88ca100516a1093ec9f281983 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:09:45 +0900 Subject: [PATCH 20/23] benchmark-campaign: cover scratch/counter/mountinfo contracts with real dirs --- tools/benchmark-campaign/test_filesystem.py | 192 ++++++++++++++++++-- 1 file changed, 179 insertions(+), 13 deletions(-) diff --git a/tools/benchmark-campaign/test_filesystem.py b/tools/benchmark-campaign/test_filesystem.py index 9b9e7a3d1..15592dc6c 100644 --- a/tools/benchmark-campaign/test_filesystem.py +++ b/tools/benchmark-campaign/test_filesystem.py @@ -2,6 +2,7 @@ """Contract tests for the filesystem-aware Linux measurement boundary.""" import json +import os import sys import tempfile import unittest @@ -98,6 +99,13 @@ def _standard_table(root: Path, spec: MountSpec) -> Path: return table +def _real_scratch(root: Path, name: str = "scratch") -> Path: + """Create a real scratch directory; ``identify_scope`` stats it.""" + scratch = root / name + scratch.mkdir(parents=True, exist_ok=True) + return scratch + + def _mount_dict(mount_id: str, majmin: str, point: str) -> filesystem.MountInfo: return { "mount_id": mount_id, @@ -141,9 +149,14 @@ def test_no_table_entry_gives_none(self) -> None: self.assertIsNone(pick_mount("/nothing/here", [])) def test_nonutf8_child_mount_maps_to_child(self) -> None: - child = "/data/\udcffraw" with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) + # Non-UTF8 names are unrepresentable in ``pathlib`` (str-only), + # so the byte path is created with ``os.mkdir`` directly. + raw_child = os.fsencode(root) + b"/\xffraw" + os.mkdir(raw_child) + os.mkdir(raw_child + b"/scratch") + child = os.fsdecode(raw_child) sys_base = root / "sys" sys_base.mkdir() _make_device(sys_base, "8:2", _ext4_device(11)) @@ -154,7 +167,9 @@ def test_nonutf8_child_mount_maps_to_child(self) -> None: _mountline(MountSpec(majmin="8:1", point="/", source="/dev/sda1")), _mountline( MountSpec( - majmin="8:2", point=r"/data/\377raw", source="/dev/sdb1" + majmin="8:2", + point=str(root) + r"/\377raw", + source="/dev/sdb1", ) ), ], @@ -277,9 +292,10 @@ def test_available_regular_device(self) -> None: "259 2 nvme0n1p2 0 0 0 0 10 0 1000 0 0 0 0 0 0 0\n", encoding="utf-8", ) - table = _standard_table(root, _standard_mount("259:2", "/data")) + table = _standard_table(root, _standard_mount("259:2", "/")) + scratch = _real_scratch(root) handle, scope = identify_scope( - "/data/scratch", + scratch, mountinfo_path=table, sys_base=sys_base, diskstats_path=diskstats, @@ -302,7 +318,7 @@ def test_diskstats_missing_keeps_sysfs(self) -> None: _make_device(sys_base, "8:1", _ext4_device(42)) table = _standard_table(root, _standard_mount("8:1", "/")) handle, scope = identify_scope( - "/x", + _real_scratch(root), mountinfo_path=table, sys_base=sys_base, diskstats_path=root / "no-diskstats", @@ -318,7 +334,7 @@ def test_stat_missing_is_unavailable(self) -> None: sys_base.mkdir() table = _standard_table(root, _standard_mount("8:1", "/")) handle, scope = identify_scope( - "/x", mountinfo_path=table, sys_base=sys_base + _real_scratch(root), mountinfo_path=table, sys_base=sys_base ) self.assertIsNone(handle) self.assertEqual(scope["status"], "unavailable") @@ -332,7 +348,7 @@ def test_stat_unparsable_is_unavailable(self) -> None: _make_device(sys_base, "8:1", DeviceFixture("not a counter file\n")) table = _standard_table(root, _standard_mount("8:1", "/")) handle, scope = identify_scope( - "/x", mountinfo_path=table, sys_base=sys_base + _real_scratch(root), mountinfo_path=table, sys_base=sys_base ) self.assertIsNone(handle) self.assertEqual(scope["status"], "unavailable") @@ -345,7 +361,7 @@ def test_short_stat_is_unavailable(self) -> None: _make_device(sys_base, "8:1", DeviceFixture("1 2 3\n")) table = _standard_table(root, _standard_mount("8:1", "/")) handle, scope = identify_scope( - "/x", mountinfo_path=table, sys_base=sys_base + _real_scratch(root), mountinfo_path=table, sys_base=sys_base ) self.assertIsNone(handle) self.assertEqual(scope["status"], "unavailable") @@ -427,7 +443,7 @@ def test_no_mount_entry_is_unavailable(self) -> None: table = root / "mountinfo" table.write_text("", encoding="utf-8") handle, scope = identify_scope( - "/x", mountinfo_path=table, sys_base=root / "sys" + _real_scratch(root), mountinfo_path=table, sys_base=root / "sys" ) self.assertIsNone(handle) self.assertEqual(scope["status"], "unavailable") @@ -554,9 +570,9 @@ def test_partition_counter_used_alone(self) -> None: "259 2 nvme0n1p2 0 0 0 0 9 0 100 0 0 0 0 0 0 0\n", encoding="utf-8", ) - table = _standard_table(root, _standard_mount("259:2", "/data")) + table = _standard_table(root, _standard_mount("259:2", "/")) handle, scope = identify_scope( - "/data/x", + _real_scratch(root), mountinfo_path=table, sys_base=sys_base, diskstats_path=diskstats, @@ -603,9 +619,9 @@ def test_partition_of_stacked_device_is_ambiguous(self) -> None: (part / "stat").write_text(_stat_text(100), encoding="utf-8") sys_base.mkdir() (sys_base / "259:1").symlink_to(part) - table = _standard_table(root, _standard_mount("259:1", "/data")) + table = _standard_table(root, _standard_mount("259:1", "/")) handle, scope = identify_scope( - "/data/x", mountinfo_path=table, sys_base=sys_base + _real_scratch(root), mountinfo_path=table, sys_base=sys_base ) self.assertIsNone(handle) self.assertEqual(scope["status"], "unavailable") @@ -670,5 +686,155 @@ def test_scope_json_serializable(self) -> None: json.dumps(scope) +class ScratchValidationTests(unittest.TestCase): + def _available_fixture( + self, root: Path + ) -> tuple[Path, Path, Path]: + sys_base = root / "sys" + sys_base.mkdir() + _make_device(sys_base, "8:1", _ext4_device(5)) + table = _standard_table(root, _standard_mount("8:1", "/")) + return sys_base, table, _real_scratch(root) + + def test_removed_scratch_is_unavailable(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sys_base, table, scratch = self._available_fixture(root) + scratch.rmdir() + handle, scope = identify_scope( + scratch, mountinfo_path=table, sys_base=sys_base + ) + self.assertIsNone(handle) + self.assertEqual(scope["status"], "unavailable") + self.assertIn("cannot stat", str(scope.get("reason", ""))) + + def test_file_scratch_is_unavailable(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sys_base = root / "sys" + sys_base.mkdir() + _make_device(sys_base, "8:1", _ext4_device(5)) + table = _standard_table(root, _standard_mount("8:1", "/")) + candidate = root / "file-scratch" + candidate.write_text("not a directory", encoding="utf-8") + handle, scope = identify_scope( + candidate, mountinfo_path=table, sys_base=sys_base + ) + self.assertIsNone(handle) + self.assertEqual(scope["status"], "unavailable") + self.assertIn("not a directory", str(scope.get("reason", ""))) + + def test_mechanisms_include_scratch_stat(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sys_base, table, scratch = self._available_fixture(root) + handle, scope = identify_scope( + scratch, mountinfo_path=table, sys_base=sys_base + ) + self.assertEqual(scope["status"], "available") + assert handle is not None + mechanisms = scope.get("mechanisms_checked") + assert isinstance(mechanisms, list) + self.assertIn(str(scratch.resolve()), mechanisms) + + +class MountParseStrictnessTests(unittest.TestCase): + def test_consecutive_spaces_line_skipped(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + table = Path(tmp) / "mountinfo" + table.write_text( + "1 0 8:1 / / rw,relatime - ext4 /dev/sda1 rw\n" + "2 0 8:2 / /data rw,relatime - ext4 /dev/sdb1 rw\n", + encoding="utf-8", + ) + mounts = read_mountinfo(table) + self.assertEqual([mount["majmin"] for mount in mounts], ["8:1"]) + + def test_consecutive_spaces_do_not_misattribute_scratch(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + scratch = _real_scratch(root) + sys_base = root / "sys" + sys_base.mkdir() + _make_device(sys_base, "8:1", _ext4_device(5)) + table = root / "mountinfo" + _write_mountinfo( + table, + [ + _mountline(MountSpec(majmin="8:1", point="/", source="/dev/sda1")), + f"9 0 8:9 / {scratch} rw,relatime - ext4 /dev/sdz1 rw", + ], + ) + handle, scope = identify_scope( + scratch, mountinfo_path=table, sys_base=sys_base + ) + self.assertEqual(scope["status"], "available") + self.assertEqual(scope["majmin"], "8:1") + + +class CounterBoundTests(unittest.TestCase): + def test_huge_sysfs_counter_is_rejected(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sys_base = root / "sys" + sys_base.mkdir() + _make_device(sys_base, "8:1", DeviceFixture(_stat_text(1 << 64))) + handle = CounterHandle( + "8:1", "sda", sys_base / "8:1" / "stat", root / "no-diskstats" + ) + with self.assertRaises(ValueError): + handle.read() + + def test_huge_counter_makes_scope_unavailable(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sys_base = root / "sys" + sys_base.mkdir() + _make_device(sys_base, "8:1", DeviceFixture(_stat_text(1 << 64))) + table = _standard_table(root, _standard_mount("8:1", "/")) + handle, scope = identify_scope( + _real_scratch(root), mountinfo_path=table, sys_base=sys_base + ) + self.assertIsNone(handle) + self.assertEqual(scope["status"], "unavailable") + self.assertIn("2**64", str(scope.get("reason", ""))) + + def test_huge_diskstats_value_is_ignored(self) -> None: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + sys_base = root / "sys" + sys_base.mkdir() + _make_device(sys_base, "8:1", _ext4_device(42)) + diskstats = root / "diskstats" + diskstats.write_text( + f"8 1 sda 0 0 0 0 0 0 {1 << 64} 0 0 0 0 0 0\n", encoding="utf-8" + ) + table = _standard_table(root, _standard_mount("8:1", "/")) + handle, scope = identify_scope( + _real_scratch(root), + mountinfo_path=table, + sys_base=sys_base, + diskstats_path=diskstats, + ) + self.assertEqual(scope["status"], "available") + assert handle is not None + self.assertIsNone(handle.read()["diskstats_sectors_written"]) + + def test_delta_rejects_out_of_range_counters(self) -> None: + before: filesystem.CounterSample = { + "sysfs_sectors_written": 1 << 64, + "writes_completed": 9, + "in_flight": 0, + "diskstats_sectors_written": None, + } + after: filesystem.CounterSample = { + "sysfs_sectors_written": (1 << 64) + 10, + "writes_completed": 9, + "in_flight": 0, + "diskstats_sectors_written": None, + } + self.assertIsNone(delta_bytes(before, after)) + + if __name__ == "__main__": unittest.main() From 73baff66059a3e755e352124b9ed0fc7ffb520b4 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:09:45 +0900 Subject: [PATCH 21/23] storage-footprint: cap traversal depth and stream directory entries --- tools/storage-footprint/src/linux.rs | 72 ++++++++++++++++++++++++---- 1 file changed, 62 insertions(+), 10 deletions(-) diff --git a/tools/storage-footprint/src/linux.rs b/tools/storage-footprint/src/linux.rs index 2bd051111..089e2621a 100644 --- a/tools/storage-footprint/src/linux.rs +++ b/tools/storage-footprint/src/linux.rs @@ -45,6 +45,10 @@ pub(super) struct Report { namespaces: BTreeMap, } +/// Datadirs are only a few levels deep; the cap keeps a mis-pointed root +/// from recursing without bound and exhausting the stack. +const MAX_DEPTH: u32 = 32; + pub(super) fn measure(root: &Path) -> io::Result { let metadata = fs::symlink_metadata(root)?; if !metadata.is_dir() { @@ -58,7 +62,7 @@ pub(super) fn measure(root: &Path) -> io::Result { namespaces: BTreeMap::new(), }; let mut seen = HashSet::new(); - visit(root, ".", metadata.dev(), mount, &mut seen, &mut report)?; + visit(root, ".", metadata.dev(), mount, &mut seen, &mut report, 0)?; Ok(report) } @@ -85,6 +89,7 @@ fn visit( mount: u64, seen: &mut HashSet<(u64, u64)>, report: &mut Report, + depth: u32, ) -> io::Result<()> { let metadata = fs::symlink_metadata(path)?; if !(metadata.is_dir() || metadata.is_file()) @@ -109,10 +114,17 @@ fn visit( .or_default() .add(&metadata)?; if metadata.is_dir() { - // Stable order gives cross-namespace hard links a deterministic owner. - let mut entries = fs::read_dir(path)?.collect::>>()?; - entries.sort_by_key(fs::DirEntry::file_name); - for entry in entries { + if depth >= MAX_DEPTH { + return Err(io::Error::other(format!( + "directory nesting exceeds {MAX_DEPTH} at {}", + path.display() + ))); + } + // Entries stream one at a time so a wide directory is never + // materialized in memory. Totals are order-independent; a + // cross-namespace hard link's bytes follow first-visit order. + for entry in fs::read_dir(path)? { + let entry = entry?; let name; let child_namespace = if namespace == "." { // Each top-level entry is its own namespace, including loose files. @@ -124,7 +136,15 @@ fn visit( } else { namespace }; - visit(&entry.path(), child_namespace, device, mount, seen, report)?; + visit( + &entry.path(), + child_namespace, + device, + mount, + seen, + report, + depth + 1, + )?; } } Ok(()) @@ -151,10 +171,42 @@ mod tests { report.totals.allocated_bytes, (root.blocks() + data.blocks()) * 512 ); - assert_eq!(report.namespaces["a"].apparent_bytes, data.len()); - assert_eq!(report.namespaces["a"].allocated_bytes, data.blocks() * 512); - assert_eq!(report.namespaces["b"].apparent_bytes, 0); - assert_eq!(report.namespaces["b"].allocated_bytes, 0); + // Entries stream in readdir order, so either namespace may own the + // shared inode; exactly one of them must, and totals stay exact. + assert_eq!( + report.namespaces["a"].apparent_bytes + report.namespaces["b"].apparent_bytes, + data.len() + ); + assert_eq!( + report.namespaces["a"].allocated_bytes + report.namespaces["b"].allocated_bytes, + data.blocks() * 512 + ); + assert!( + (report.namespaces["a"].apparent_bytes == data.len() + && report.namespaces["b"].apparent_bytes == 0) + || (report.namespaces["b"].apparent_bytes == data.len() + && report.namespaces["a"].apparent_bytes == 0) + ); + Ok(()) + } + + #[test] + fn nesting_beyond_max_depth_is_refused() -> io::Result<()> { + let dir = tempfile::tempdir()?; + let mut parent = dir.path().to_path_buf(); + for level in 0..=MAX_DEPTH { + parent = parent.join(format!("level{level}")); + fs::create_dir(&parent)?; + } + fs::write(parent.join("data"), b"too deep")?; + let failure = match measure(dir.path()) { + Ok(_) => return Err(io::Error::other("deep nesting was accepted")), + Err(failure) => failure, + }; + assert!( + failure.to_string().contains("nesting exceeds"), + "unexpected error: {failure}" + ); Ok(()) } From 7ec7d1bc5b59feb3ee11b3c36804dca3fd45cb43 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Wed, 28 Oct 2026 22:10:46 +0900 Subject: [PATCH 22/23] benchmark-campaign: route scratch stat and surrogateescape fixture through pathlib --- tools/benchmark-campaign/filesystem.py | 6 +++--- tools/benchmark-campaign/test_filesystem.py | 12 ++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/tools/benchmark-campaign/filesystem.py b/tools/benchmark-campaign/filesystem.py index 81a216027..d22788519 100644 --- a/tools/benchmark-campaign/filesystem.py +++ b/tools/benchmark-campaign/filesystem.py @@ -50,7 +50,6 @@ import argparse import json -import os import re import stat import sys @@ -379,9 +378,10 @@ def identify_scope( """ sys_base_path = Path(sys_base) diskstats = Path(diskstats_path) - real = str(Path(scratch).resolve()) + scratch_path = Path(scratch).resolve() + real = str(scratch_path) try: - scratch_is_dir = stat.S_ISDIR(os.stat(real).st_mode) + scratch_is_dir = stat.S_ISDIR(scratch_path.stat().st_mode) scratch_reason: str | None = None except OSError as error: scratch_is_dir = False diff --git a/tools/benchmark-campaign/test_filesystem.py b/tools/benchmark-campaign/test_filesystem.py index 15592dc6c..c234383ac 100644 --- a/tools/benchmark-campaign/test_filesystem.py +++ b/tools/benchmark-campaign/test_filesystem.py @@ -151,12 +151,12 @@ def test_no_table_entry_gives_none(self) -> None: def test_nonutf8_child_mount_maps_to_child(self) -> None: with tempfile.TemporaryDirectory() as tmp: root = Path(tmp) - # Non-UTF8 names are unrepresentable in ``pathlib`` (str-only), - # so the byte path is created with ``os.mkdir`` directly. - raw_child = os.fsencode(root) + b"/\xffraw" - os.mkdir(raw_child) - os.mkdir(raw_child + b"/scratch") - child = os.fsdecode(raw_child) + # Surrogateescaped names stay ``str``; ``pathlib`` syscalls + # re-encode them to the original non-UTF8 bytes. + raw_child = root / os.fsdecode(b"\xffraw") + raw_child.mkdir() + (raw_child / "scratch").mkdir() + child = str(raw_child) sys_base = root / "sys" sys_base.mkdir() _make_device(sys_base, "8:2", _ext4_device(11)) From f7dcb57fa6ef5cbc127efe180971b25530391e55 Mon Sep 17 00:00:00 2001 From: metaphorics <152830360+metaphorics@users.noreply.github.com> Date: Thu, 29 Oct 2026 00:00:05 +0900 Subject: [PATCH 23/23] Remove unused request exclusion helper --- crates/p2p/src/download_window.rs | 36 ------------------------------- 1 file changed, 36 deletions(-) diff --git a/crates/p2p/src/download_window.rs b/crates/p2p/src/download_window.rs index d9b3cf3a8..e0f3bb6fe 100644 --- a/crates/p2p/src/download_window.rs +++ b/crates/p2p/src/download_window.rs @@ -80,42 +80,6 @@ impl PeerRequest { self.entries.len() } - /// Rebuilds the batch without the entries `exclude` rejects. - /// - /// The carried frontier clamps to the lowest excluded height so that - /// `mark_requested` never advances the window cursor past a hash this - /// peer cannot serve: excluded heights stay re-plannable for the next - /// peer instead of stranding below the cursor. Returns `None` when - /// every entry is excluded; the caller then sends and marks nothing, - /// leaving the cursor untouched for another peer or a later retry. - pub(crate) fn excluding( - &self, - mut exclude: impl FnMut(Hash256) -> bool, - ) -> Option { - let mut excluded_floor: Option = None; - let mut entries = Vec::with_capacity(self.entries.len()); - for entry in &self.entries { - if exclude(entry.hash) { - excluded_floor = Some( - excluded_floor.map_or(entry.height, |floor: u32| floor.min(entry.height)), - ); - } else { - entries.push(*entry); - } - } - if entries.is_empty() { - return None; - } - let next_request_height = match excluded_floor { - Some(floor) => self.next_request_height.min(floor), - None => self.next_request_height, - }; - Some(Self { - owner: self.owner, - entries, - next_request_height, - }) - } } /// Per-peer availability view for one request build.