Skip to main content

harn_vm/testbench/
mod.rs

1//! Testbench: hermetic-execution composition primitive.
2//!
3//! Wires the four pluggable axes Harn already had — virtual time, mocked
4//! LLM, filesystem overlay, recorded subprocess — behind a single
5//! [`Testbench`] handle. Production wires real impls; tests/demos pick a
6//! config and get an audit trail of everything that crossed the host
7//! boundary.
8//!
9//! # Axes
10//!
11//! - **Clock** ([`crate::clock_mock`]). Pinned wall-clock + monotonic time
12//!   honored by stdlib `now_ms`/`sleep`/`monotonic_ms`, the trigger
13//!   dispatcher, and the cron scheduler. Tests advance with
14//!   [`crate::clock_mock::advance`] or the script-side `advance_time(...)`.
15//!
16//! - **LLM** ([`crate::llm`]). The CLI replay/record path
17//!   (`install_cli_llm_mocks` / `enable_cli_llm_mock_recording`) is the
18//!   workhorse; [`crate::llm::FakeLlmProvider`] adds streaming/error
19//!   fidelity for tests that care about per-token order.
20//!
21//! - **Filesystem** ([`overlay_fs`]). Copy-on-write overlay rooted at a
22//!   real worktree: reads pass through, writes land in an in-memory
23//!   layer, and [`overlay_fs::OverlayFs::diff`] surfaces a unified-style
24//!   diff that can be applied back or discarded.
25//!
26//! - **Subprocess** ([`process_tape`]). Records `(program, args, cwd) →
27//!   (stdout, stderr, exit, virtual Δt)` tuples in record mode and
28//!   replays them deterministically in replay mode. Env-var matching
29//!   is documented as future work — the JSON tape carries an `env`
30//!   field reserved for it.
31//!
32//! # Network
33//!
34//! Network egress is deny-by-default in testbench mode — outbound HTTP
35//! and connector requests fail fast unless an explicit allowlist names
36//! the destination. The deny pass routes through [`crate::egress`], the
37//! same policy engine production uses.
38
39pub mod annotations;
40pub mod fidelity;
41pub mod mcp_mock;
42pub mod overlay_fs;
43pub mod process_tape;
44pub mod tape;
45#[cfg(feature = "testbench-wasi")]
46pub mod wasi_process;
47
48use std::path::PathBuf;
49use std::sync::Arc;
50
51use crate::clock_mock::leak_audit::{self, ClockLeak};
52use crate::clock_mock::{install_override, ClockOverrideGuard, MockClock};
53use crate::egress::reset_egress_policy_for_host;
54
55use overlay_fs::{install_overlay, OverlayFs, OverlayFsGuard};
56use process_tape::{install_process_tape, ProcessTape, ProcessTapeGuard, ProcessTapeMode};
57use tape::{install_recorder, TapeHeader, TapeRecorder, TapeRecorderGuard};
58
59/// Declarative configuration for [`Testbench::activate`]. Every axis is
60/// optional so callers can compose only the surfaces they need.
61#[derive(Debug, Default, Clone)]
62pub struct Testbench {
63    pub clock: ClockConfig,
64    pub llm: LlmConfig,
65    pub filesystem: FilesystemConfig,
66    pub subprocess: SubprocessConfig,
67    pub network: NetworkConfig,
68    pub tape: TapeConfig,
69}
70
71/// Configures the unified mock clock. Defaults to the runtime's real
72/// clock so the testbench stays opt-in.
73#[derive(Debug, Default, Clone)]
74pub enum ClockConfig {
75    /// Leave the clock alone. Real wall-clock + monotonic time.
76    #[default]
77    Real,
78    /// Pin time to the given UNIX-epoch milliseconds. Honored by stdlib
79    /// `now_ms`/`sleep`, the trigger dispatcher, and cron.
80    Paused { starting_at_ms: i64 },
81}
82
83/// LLM provider configuration. Mirrors `harn run --llm-mock` /
84/// `--llm-mock-record` so the testbench is a strict superset of that
85/// flag pair. The testbench *does not* install LLM mocks itself — it
86/// stays declarative so [`crate::llm::install_cli_llm_mocks`] (or its
87/// `harn-cli` wrapper) remains the single mutator of LLM state.
88#[derive(Debug, Default, Clone)]
89pub enum LlmConfig {
90    /// No LLM substitution. Calls go through the configured provider.
91    #[default]
92    Real,
93    /// Replay scripted responses from a JSONL fixture.
94    Replay { fixture: PathBuf },
95    /// Capture executed responses into a JSONL fixture.
96    Record { fixture: PathBuf },
97}
98
99/// Filesystem overlay configuration.
100#[derive(Debug, Default, Clone)]
101pub enum FilesystemConfig {
102    /// No overlay. Reads and writes hit the real filesystem.
103    #[default]
104    Real,
105    /// Read-through, copy-on-write overlay rooted at `worktree`. Writes
106    /// stay in memory until the run ends, at which point the configured
107    /// emitter (CLI flag, in-process API) can read the diff.
108    Overlay { worktree: PathBuf },
109}
110
111/// Subprocess record/replay configuration.
112#[derive(Debug, Default, Clone)]
113pub enum SubprocessConfig {
114    /// No interception. Subprocesses spawn against the host OS.
115    #[default]
116    Real,
117    /// Record `(program, args, cwd)` tuples and their outputs into
118    /// `tape` so a follow-up run can replay them.
119    Record { tape: PathBuf },
120    /// Look every spawn up in `tape` and emit the recorded result. Errors
121    /// loudly when a tuple is not in the tape.
122    Replay { tape: PathBuf },
123    /// Resolve subprocess invocations against a directory of WASI
124    /// (`wasm32-wasi`) modules. Each `program` resolves to
125    /// `<dir>/<program>.wasm`; the module runs under wasmtime with the
126    /// testbench's mock clock virtualized into `clock_time_get` and
127    /// `poll_oneoff`. Calls whose program has no matching `.wasm` fall
128    /// through to the native spawn path. Requires the `testbench-wasi`
129    /// Cargo feature.
130    WasiToolchain { dir: PathBuf },
131}
132
133/// Network policy. Defaults to the production egress policy (no
134/// override). Testbench callers usually pick `DenyByDefault`.
135#[derive(Debug, Default, Clone)]
136pub enum NetworkConfig {
137    /// Use whatever egress policy the host has already installed.
138    #[default]
139    Real,
140    /// Deny outbound requests unless `allow` matches. Routes through
141    /// [`crate::egress`] using the same env-var format that
142    /// `HARN_EGRESS_*` accepts.
143    DenyByDefault {
144        /// Comma-separated allow rules (e.g. `"github.com,*.openai.com"`).
145        /// Empty means deny everything.
146        allow: Vec<String>,
147    },
148}
149
150/// Unified-tape configuration. Recording is opt-in: `Off` (the default)
151/// installs nothing and pays nothing in production; `Emit { path }`
152/// installs a [`tape::TapeRecorder`] consulted by every host-capability
153/// axis, then persists the result to `path` (plus `path.cas/` for large
154/// payloads) when [`TestbenchSession::finalize`] runs.
155#[derive(Debug, Default, Clone)]
156pub enum TapeConfig {
157    #[default]
158    Off,
159    Emit {
160        path: PathBuf,
161        /// Argv forwarded to the script after `--`. Captured in the tape
162        /// header so two tapes that differ only in argv are
163        /// distinguishable.
164        argv: Vec<String>,
165        /// Path to the `.harn` script. Informational only; used to
166        /// populate the tape header so consumers can attribute records.
167        script_path: Option<String>,
168    },
169}
170
171impl Testbench {
172    /// Convenience: construct a builder.
173    pub fn builder() -> TestbenchBuilder {
174        TestbenchBuilder::default()
175    }
176
177    /// Activate every configured axis and return an RAII handle. Drop
178    /// the handle to restore the prior state.
179    pub fn activate(self) -> Result<TestbenchSession, TestbenchError> {
180        TestbenchSession::install(self)
181    }
182}
183
184/// Fluent constructor for [`Testbench`].
185#[derive(Debug, Default, Clone)]
186pub struct TestbenchBuilder {
187    bench: Testbench,
188}
189
190impl TestbenchBuilder {
191    pub fn paused_clock_at_ms(mut self, starting_at_ms: i64) -> Self {
192        self.bench.clock = ClockConfig::Paused { starting_at_ms };
193        self
194    }
195
196    pub fn replay_llm(mut self, fixture: impl Into<PathBuf>) -> Self {
197        self.bench.llm = LlmConfig::Replay {
198            fixture: fixture.into(),
199        };
200        self
201    }
202
203    pub fn record_llm(mut self, fixture: impl Into<PathBuf>) -> Self {
204        self.bench.llm = LlmConfig::Record {
205            fixture: fixture.into(),
206        };
207        self
208    }
209
210    pub fn fs_overlay(mut self, worktree: impl Into<PathBuf>) -> Self {
211        self.bench.filesystem = FilesystemConfig::Overlay {
212            worktree: worktree.into(),
213        };
214        self
215    }
216
217    pub fn record_subprocesses(mut self, tape: impl Into<PathBuf>) -> Self {
218        self.bench.subprocess = SubprocessConfig::Record { tape: tape.into() };
219        self
220    }
221
222    pub fn replay_subprocesses(mut self, tape: impl Into<PathBuf>) -> Self {
223        self.bench.subprocess = SubprocessConfig::Replay { tape: tape.into() };
224        self
225    }
226
227    /// Use a directory of WASI modules as the subprocess source. See
228    /// [`SubprocessConfig::WasiToolchain`].
229    pub fn wasi_toolchain(mut self, dir: impl Into<PathBuf>) -> Self {
230        self.bench.subprocess = SubprocessConfig::WasiToolchain { dir: dir.into() };
231        self
232    }
233
234    pub fn deny_network(mut self) -> Self {
235        self.bench.network = NetworkConfig::DenyByDefault { allow: Vec::new() };
236        self
237    }
238
239    pub fn allow_network(mut self, allow: impl IntoIterator<Item = String>) -> Self {
240        self.bench.network = NetworkConfig::DenyByDefault {
241            allow: allow.into_iter().collect(),
242        };
243        self
244    }
245
246    pub fn emit_tape(mut self, path: impl Into<PathBuf>) -> Self {
247        self.bench.tape = TapeConfig::Emit {
248            path: path.into(),
249            argv: Vec::new(),
250            script_path: None,
251        };
252        self
253    }
254
255    pub fn emit_tape_for(
256        mut self,
257        path: impl Into<PathBuf>,
258        script_path: Option<String>,
259        argv: Vec<String>,
260    ) -> Self {
261        self.bench.tape = TapeConfig::Emit {
262            path: path.into(),
263            argv,
264            script_path,
265        };
266        self
267    }
268
269    pub fn build(self) -> Testbench {
270        self.bench
271    }
272}
273
274/// RAII handle returned by [`Testbench::activate`]. Holds every guard
275/// for the active axes; dropping it tears them all down in order.
276#[must_use = "the testbench tears down on drop; bind the handle to a `_session` local"]
277pub struct TestbenchSession {
278    _clock_leak_scope: Option<leak_audit::ClockLeakScopeGuard>,
279    _clock: Option<ClockOverrideGuard>,
280    _process: Option<ProcessTapeGuard>,
281    _overlay: Option<OverlayFsGuard>,
282    _recorder: Option<TapeRecorderGuard>,
283    process_tape: Option<Arc<ProcessTape>>,
284    overlay: Option<Arc<OverlayFs>>,
285    recorder: Option<Arc<TapeRecorder>>,
286    tape_path: Option<PathBuf>,
287    tape_started_at_unix_ms: Option<i64>,
288    tape_script_path: Option<String>,
289    tape_argv: Vec<String>,
290    subprocess_mode: ProcessTapeMode,
291    subprocess_tape_path: Option<PathBuf>,
292    #[cfg(feature = "testbench-wasi")]
293    _wasi_toolchain: Option<wasi_process::WasiToolchainGuard>,
294    /// Saved env state (`HARN_EGRESS_DEFAULT`, `_ALLOW`, `_DENY`) for
295    /// restoration on drop. `None` means the testbench did not override
296    /// network policy this run.
297    saved_egress_env: Option<SavedEgressEnv>,
298}
299
300#[derive(Debug, Clone)]
301struct SavedEgressEnv {
302    default: Option<String>,
303    allow: Option<String>,
304    deny: Option<String>,
305}
306
307impl TestbenchSession {
308    fn install(bench: Testbench) -> Result<Self, TestbenchError> {
309        let (clock_leak_scope, clock_guard, started_at_unix_ms) = match bench.clock {
310            ClockConfig::Real => (None, None, None),
311            ClockConfig::Paused { starting_at_ms } => (
312                Some(leak_audit::install_scope()),
313                Some(install_override(MockClock::at_wall_ms(starting_at_ms))),
314                Some(starting_at_ms),
315            ),
316        };
317
318        // LLM state is *not* installed here — the caller owns the
319        // CliLlmMockMode channel. Reading bench.llm just keeps the
320        // declarative config visible to test inspection.
321        #[allow(clippy::no_effect_underscore_binding)]
322        let _llm_config = bench.llm;
323
324        #[cfg(feature = "testbench-wasi")]
325        let mut wasi_guard: Option<wasi_process::WasiToolchainGuard> = None;
326
327        let (process_tape, process_guard, subprocess_mode, subprocess_tape_path) =
328            match bench.subprocess {
329                SubprocessConfig::Real => (None, None, ProcessTapeMode::Replay, None),
330                SubprocessConfig::Record { tape } => {
331                    let active = Arc::new(ProcessTape::recording());
332                    let guard = install_process_tape(Arc::clone(&active));
333                    (
334                        Some(Arc::clone(&active)),
335                        Some(guard),
336                        ProcessTapeMode::Record,
337                        Some(tape),
338                    )
339                }
340                SubprocessConfig::Replay { tape } => {
341                    let loaded = ProcessTape::load(&tape).map_err(TestbenchError::Subprocess)?;
342                    let active = Arc::new(loaded);
343                    let guard = install_process_tape(Arc::clone(&active));
344                    (
345                        Some(Arc::clone(&active)),
346                        Some(guard),
347                        ProcessTapeMode::Replay,
348                        Some(tape),
349                    )
350                }
351                #[cfg(feature = "testbench-wasi")]
352                SubprocessConfig::WasiToolchain { dir } => {
353                    if !dir.exists() {
354                        return Err(TestbenchError::Subprocess(format!(
355                            "wasi toolchain directory does not exist: {}",
356                            dir.display()
357                        )));
358                    }
359                    wasi_guard = Some(wasi_process::install_wasi_toolchain(dir));
360                    (None, None, ProcessTapeMode::Replay, None)
361                }
362                #[cfg(not(feature = "testbench-wasi"))]
363                SubprocessConfig::WasiToolchain { .. } => {
364                    return Err(TestbenchError::Subprocess(
365                        "WasiToolchain requires the `testbench-wasi` Cargo feature".to_string(),
366                    ));
367                }
368            };
369
370        let (overlay, overlay_guard) = match bench.filesystem {
371            FilesystemConfig::Real => (None, None),
372            FilesystemConfig::Overlay { worktree } => {
373                let overlay = Arc::new(OverlayFs::rooted_at(worktree));
374                let guard = install_overlay(Arc::clone(&overlay));
375                (Some(overlay), Some(guard))
376            }
377        };
378
379        let saved_egress_env = match bench.network {
380            NetworkConfig::Real => None,
381            NetworkConfig::DenyByDefault { allow } => {
382                let saved = SavedEgressEnv {
383                    default: std::env::var("HARN_EGRESS_DEFAULT").ok(),
384                    allow: std::env::var("HARN_EGRESS_ALLOW").ok(),
385                    deny: std::env::var("HARN_EGRESS_DENY").ok(),
386                };
387                // Reset any prior policy so install_policy doesn't trip the
388                // "policy already configured" guard, then install via env-var
389                // so the host_policy and stdlib paths see the same view.
390                reset_egress_policy_for_host();
391                std::env::set_var("HARN_EGRESS_DEFAULT", "deny");
392                if allow.is_empty() {
393                    std::env::remove_var("HARN_EGRESS_ALLOW");
394                } else {
395                    std::env::set_var("HARN_EGRESS_ALLOW", allow.join(","));
396                }
397                std::env::remove_var("HARN_EGRESS_DENY");
398                Some(saved)
399            }
400        };
401
402        let (recorder, recorder_guard, tape_path, tape_argv, tape_script_path) = match bench.tape {
403            TapeConfig::Off => (None, None, None, Vec::new(), None),
404            TapeConfig::Emit {
405                path,
406                argv,
407                script_path,
408            } => {
409                let recorder = Arc::new(TapeRecorder::new());
410                let guard = install_recorder(Arc::clone(&recorder));
411                (
412                    Some(Arc::clone(&recorder)),
413                    Some(guard),
414                    Some(path),
415                    argv,
416                    script_path,
417                )
418            }
419        };
420
421        Ok(Self {
422            _clock_leak_scope: clock_leak_scope,
423            _clock: clock_guard,
424            _process: process_guard,
425            _overlay: overlay_guard,
426            _recorder: recorder_guard,
427            process_tape,
428            overlay,
429            recorder,
430            tape_path,
431            tape_started_at_unix_ms: started_at_unix_ms,
432            tape_script_path,
433            tape_argv,
434            subprocess_mode,
435            subprocess_tape_path,
436            #[cfg(feature = "testbench-wasi")]
437            _wasi_toolchain: wasi_guard,
438            saved_egress_env,
439        })
440    }
441
442    /// Whether subprocess interception is recording new entries.
443    pub fn subprocess_mode(&self) -> ProcessTapeMode {
444        self.subprocess_mode
445    }
446
447    /// Path that recorded subprocess tape entries should land in, or
448    /// where replay loaded them from.
449    pub fn subprocess_tape_path(&self) -> Option<&std::path::Path> {
450        self.subprocess_tape_path.as_deref()
451    }
452
453    /// Reference to the active filesystem overlay (if any).
454    pub fn overlay(&self) -> Option<&Arc<OverlayFs>> {
455        self.overlay.as_ref()
456    }
457
458    /// Reference to the active process tape (if any).
459    pub fn process_tape(&self) -> Option<&Arc<ProcessTape>> {
460        self.process_tape.as_ref()
461    }
462
463    /// Reference to the active tape recorder (if any).
464    pub fn tape_recorder(&self) -> Option<&Arc<TapeRecorder>> {
465        self.recorder.as_ref()
466    }
467
468    /// Persist the recorded subprocess tape (if recording) and return
469    /// the filesystem diff (if an overlay is active). Tearing down the
470    /// session via [`Drop`] will not persist; call this explicitly to
471    /// flush.
472    pub fn finalize(self) -> Result<TestbenchFinalize, TestbenchError> {
473        let diff = self
474            .overlay
475            .as_ref()
476            .map(|overlay| overlay.diff())
477            .unwrap_or_default();
478        let recorded = if matches!(self.subprocess_mode, ProcessTapeMode::Record) {
479            if let (Some(tape), Some(path)) = (
480                self.process_tape.as_ref(),
481                self.subprocess_tape_path.as_ref(),
482            ) {
483                tape.persist(path).map_err(TestbenchError::Subprocess)?;
484            }
485            self.process_tape
486                .as_ref()
487                .map(|tape| tape.recorded())
488                .unwrap_or_default()
489        } else {
490            Vec::new()
491        };
492        let mut emitted_tape = None;
493        if let (Some(recorder), Some(path)) = (self.recorder.as_ref(), self.tape_path.as_ref()) {
494            let header = TapeHeader::current(
495                self.tape_started_at_unix_ms,
496                self.tape_script_path.clone(),
497                self.tape_argv.clone(),
498            );
499            let tape = recorder.snapshot(header);
500            tape.persist(path).map_err(TestbenchError::Tape)?;
501            emitted_tape = Some(EmittedTape {
502                path: path.clone(),
503                records: tape.records.len(),
504            });
505        }
506        // Drain the leak audit last so anything emitted while we
507        // serialized other artifacts (e.g. tape persistence reading the
508        // wall clock for timestamps it shouldn't be reading) is still
509        // captured in this session's report.
510        let clock_leaks = leak_audit::drain();
511        // The Drop impl undoes mocks regardless of finalize success.
512        Ok(TestbenchFinalize {
513            fs_diff: diff,
514            recorded_subprocesses: recorded,
515            tape: emitted_tape,
516            clock_leaks,
517        })
518    }
519}
520
521impl Drop for TestbenchSession {
522    fn drop(&mut self) {
523        if let Some(saved) = self.saved_egress_env.take() {
524            restore_env("HARN_EGRESS_DEFAULT", saved.default);
525            restore_env("HARN_EGRESS_ALLOW", saved.allow);
526            restore_env("HARN_EGRESS_DENY", saved.deny);
527            reset_egress_policy_for_host();
528        }
529        // The remaining `_clock`/`_overlay`/`_process` guards drop in
530        // field-declared order, restoring the prior thread-local state.
531    }
532}
533
534fn restore_env(key: &str, prior: Option<String>) {
535    match prior {
536        Some(value) => std::env::set_var(key, value),
537        None => std::env::remove_var(key),
538    }
539}
540
541/// Outcome of a finalized testbench session — the artifacts the operator
542/// inspects after a hermetic run.
543#[derive(Debug, Default, Clone)]
544pub struct TestbenchFinalize {
545    pub fs_diff: Vec<overlay_fs::DiffEntry>,
546    pub recorded_subprocesses: Vec<process_tape::TapeEntry>,
547    pub tape: Option<EmittedTape>,
548    /// Capabilities that observed real wall-clock or monotonic time
549    /// during the session. Empty under a hermetic run; non-empty entries
550    /// are fidelity hazards the operator should investigate or migrate
551    /// off of direct host-clock reads.
552    pub clock_leaks: Vec<ClockLeak>,
553}
554
555/// Summary metadata for a unified tape that was emitted at finalize-time.
556#[derive(Debug, Clone)]
557pub struct EmittedTape {
558    pub path: PathBuf,
559    pub records: usize,
560}
561
562/// Errors surfaced when activating or finalizing a testbench session.
563#[derive(Debug)]
564pub enum TestbenchError {
565    Subprocess(String),
566    Tape(String),
567}
568
569impl std::fmt::Display for TestbenchError {
570    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
571        match self {
572            Self::Subprocess(msg) => write!(f, "testbench subprocess: {msg}"),
573            Self::Tape(msg) => write!(f, "testbench tape: {msg}"),
574        }
575    }
576}
577
578impl std::error::Error for TestbenchError {}
579
580#[cfg(test)]
581mod tests {
582    use super::*;
583    use std::sync::Mutex;
584
585    static ENV_TEST_LOCK: Mutex<()> = Mutex::new(());
586
587    /// Some tests in this module mutate process-global env vars. Keep
588    /// those serialized without coupling them to the clock-leak audit,
589    /// which is session-scoped.
590    fn serial<F: FnOnce()>(body: F) {
591        let _guard = ENV_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner());
592        body();
593    }
594
595    #[test]
596    fn paused_clock_pins_now_ms_for_session_lifetime() {
597        serial(|| {
598            let bench = Testbench::builder()
599                .paused_clock_at_ms(1_700_000_000_000)
600                .build();
601            let session = bench.activate().expect("activate");
602            assert_eq!(crate::clock_mock::now_ms(), 1_700_000_000_000);
603            crate::clock_mock::advance(std::time::Duration::from_mins(1));
604            assert_eq!(crate::clock_mock::now_ms(), 1_700_000_060_000);
605            drop(session);
606            // After drop the override is gone; no assertion on real time.
607            assert!(!crate::clock_mock::is_mocked());
608        });
609    }
610
611    #[test]
612    fn deny_by_default_blocks_egress_until_drop() {
613        serial(|| {
614            let bench = Testbench::builder().deny_network().build();
615            let session = bench.activate().expect("activate");
616            assert_eq!(std::env::var("HARN_EGRESS_DEFAULT").as_deref(), Ok("deny"));
617            drop(session);
618            assert!(std::env::var("HARN_EGRESS_DEFAULT").is_err());
619        });
620    }
621
622    #[test]
623    fn finalize_surfaces_clock_leaks_for_contrived_capability() {
624        serial(|| {
625            let bench = Testbench::builder()
626                .paused_clock_at_ms(1_700_000_000_000)
627                .build();
628            let session = bench.activate().expect("activate");
629
630            // Contrived "leaky" capability: routes through the audit shim
631            // while a paused mock is installed. Production callers (e.g.
632            // `stdlib/date_iso`) follow the exact same pattern.
633            let _ = leak_audit::wall_now("test/contrived_leak");
634            let _ = leak_audit::instant_now("test/contrived_instant");
635            let _ = leak_audit::wall_now("test/contrived_leak");
636
637            let finalize = session.finalize().expect("finalize");
638            let by_id: std::collections::BTreeMap<&str, &ClockLeak> = finalize
639                .clock_leaks
640                .iter()
641                .map(|leak| (leak.capability_id.as_str(), leak))
642                .collect();
643            let wall = by_id
644                .get("test/contrived_leak")
645                .expect("wall leak surfaced");
646            assert_eq!(wall.count, 2);
647            let inst = by_id
648                .get("test/contrived_instant")
649                .expect("instant leak surfaced");
650            assert_eq!(inst.count, 1);
651
652            // Drain semantics: a fresh session sees no carry-over.
653            let next_session = Testbench::builder()
654                .paused_clock_at_ms(1_700_000_000_000)
655                .build()
656                .activate()
657                .expect("activate next");
658            let next = next_session.finalize().expect("finalize next");
659            assert!(next.clock_leaks.is_empty());
660        });
661    }
662
663    #[test]
664    fn audit_quiet_when_no_mock_is_active() {
665        serial(|| {
666            leak_audit::reset();
667            // No `Testbench` activated → no mock clock → no leak entries
668            // even when the helpers are called.
669            let _ = leak_audit::wall_now("test/no_mock");
670            let _ = leak_audit::instant_now("test/no_mock");
671            assert!(leak_audit::snapshot().is_empty());
672        });
673    }
674}