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: Option<ClockOverrideGuard>,
279    _process: Option<ProcessTapeGuard>,
280    _overlay: Option<OverlayFsGuard>,
281    _recorder: Option<TapeRecorderGuard>,
282    process_tape: Option<Arc<ProcessTape>>,
283    overlay: Option<Arc<OverlayFs>>,
284    recorder: Option<Arc<TapeRecorder>>,
285    tape_path: Option<PathBuf>,
286    tape_started_at_unix_ms: Option<i64>,
287    tape_script_path: Option<String>,
288    tape_argv: Vec<String>,
289    subprocess_mode: ProcessTapeMode,
290    subprocess_tape_path: Option<PathBuf>,
291    #[cfg(feature = "testbench-wasi")]
292    _wasi_toolchain: Option<wasi_process::WasiToolchainGuard>,
293    /// Saved env state (`HARN_EGRESS_DEFAULT`, `_ALLOW`, `_DENY`) for
294    /// restoration on drop. `None` means the testbench did not override
295    /// network policy this run.
296    saved_egress_env: Option<SavedEgressEnv>,
297}
298
299#[derive(Debug, Clone)]
300struct SavedEgressEnv {
301    default: Option<String>,
302    allow: Option<String>,
303    deny: Option<String>,
304}
305
306impl TestbenchSession {
307    fn install(bench: Testbench) -> Result<Self, TestbenchError> {
308        // Clear the leak audit so the session reports leaks it observed
309        // rather than entries left behind by an earlier session that
310        // never called `finalize` (e.g. a panicking test).
311        leak_audit::reset();
312
313        let (clock_guard, started_at_unix_ms) = match bench.clock {
314            ClockConfig::Real => (None, None),
315            ClockConfig::Paused { starting_at_ms } => (
316                Some(install_override(MockClock::at_wall_ms(starting_at_ms))),
317                Some(starting_at_ms),
318            ),
319        };
320
321        // LLM state is *not* installed here — the caller owns the
322        // CliLlmMockMode channel. Reading bench.llm just keeps the
323        // declarative config visible to test inspection.
324        #[allow(clippy::no_effect_underscore_binding)]
325        let _llm_config = bench.llm;
326
327        #[cfg(feature = "testbench-wasi")]
328        let mut wasi_guard: Option<wasi_process::WasiToolchainGuard> = None;
329
330        let (process_tape, process_guard, subprocess_mode, subprocess_tape_path) =
331            match bench.subprocess {
332                SubprocessConfig::Real => (None, None, ProcessTapeMode::Replay, None),
333                SubprocessConfig::Record { tape } => {
334                    let active = Arc::new(ProcessTape::recording());
335                    let guard = install_process_tape(Arc::clone(&active));
336                    (
337                        Some(Arc::clone(&active)),
338                        Some(guard),
339                        ProcessTapeMode::Record,
340                        Some(tape),
341                    )
342                }
343                SubprocessConfig::Replay { tape } => {
344                    let loaded = ProcessTape::load(&tape).map_err(TestbenchError::Subprocess)?;
345                    let active = Arc::new(loaded);
346                    let guard = install_process_tape(Arc::clone(&active));
347                    (
348                        Some(Arc::clone(&active)),
349                        Some(guard),
350                        ProcessTapeMode::Replay,
351                        Some(tape),
352                    )
353                }
354                #[cfg(feature = "testbench-wasi")]
355                SubprocessConfig::WasiToolchain { dir } => {
356                    if !dir.exists() {
357                        return Err(TestbenchError::Subprocess(format!(
358                            "wasi toolchain directory does not exist: {}",
359                            dir.display()
360                        )));
361                    }
362                    wasi_guard = Some(wasi_process::install_wasi_toolchain(dir));
363                    (None, None, ProcessTapeMode::Replay, None)
364                }
365                #[cfg(not(feature = "testbench-wasi"))]
366                SubprocessConfig::WasiToolchain { .. } => {
367                    return Err(TestbenchError::Subprocess(
368                        "WasiToolchain requires the `testbench-wasi` Cargo feature".to_string(),
369                    ));
370                }
371            };
372
373        let (overlay, overlay_guard) = match bench.filesystem {
374            FilesystemConfig::Real => (None, None),
375            FilesystemConfig::Overlay { worktree } => {
376                let overlay = Arc::new(OverlayFs::rooted_at(worktree));
377                let guard = install_overlay(Arc::clone(&overlay));
378                (Some(overlay), Some(guard))
379            }
380        };
381
382        let saved_egress_env = match bench.network {
383            NetworkConfig::Real => None,
384            NetworkConfig::DenyByDefault { allow } => {
385                let saved = SavedEgressEnv {
386                    default: std::env::var("HARN_EGRESS_DEFAULT").ok(),
387                    allow: std::env::var("HARN_EGRESS_ALLOW").ok(),
388                    deny: std::env::var("HARN_EGRESS_DENY").ok(),
389                };
390                // Reset any prior policy so install_policy doesn't trip the
391                // "policy already configured" guard, then install via env-var
392                // so the host_policy and stdlib paths see the same view.
393                reset_egress_policy_for_host();
394                std::env::set_var("HARN_EGRESS_DEFAULT", "deny");
395                if allow.is_empty() {
396                    std::env::remove_var("HARN_EGRESS_ALLOW");
397                } else {
398                    std::env::set_var("HARN_EGRESS_ALLOW", allow.join(","));
399                }
400                std::env::remove_var("HARN_EGRESS_DENY");
401                Some(saved)
402            }
403        };
404
405        let (recorder, recorder_guard, tape_path, tape_argv, tape_script_path) = match bench.tape {
406            TapeConfig::Off => (None, None, None, Vec::new(), None),
407            TapeConfig::Emit {
408                path,
409                argv,
410                script_path,
411            } => {
412                let recorder = Arc::new(TapeRecorder::new());
413                let guard = install_recorder(Arc::clone(&recorder));
414                (
415                    Some(Arc::clone(&recorder)),
416                    Some(guard),
417                    Some(path),
418                    argv,
419                    script_path,
420                )
421            }
422        };
423
424        Ok(Self {
425            _clock: clock_guard,
426            _process: process_guard,
427            _overlay: overlay_guard,
428            _recorder: recorder_guard,
429            process_tape,
430            overlay,
431            recorder,
432            tape_path,
433            tape_started_at_unix_ms: started_at_unix_ms,
434            tape_script_path,
435            tape_argv,
436            subprocess_mode,
437            subprocess_tape_path,
438            #[cfg(feature = "testbench-wasi")]
439            _wasi_toolchain: wasi_guard,
440            saved_egress_env,
441        })
442    }
443
444    /// Whether subprocess interception is recording new entries.
445    pub fn subprocess_mode(&self) -> ProcessTapeMode {
446        self.subprocess_mode
447    }
448
449    /// Path that recorded subprocess tape entries should land in, or
450    /// where replay loaded them from.
451    pub fn subprocess_tape_path(&self) -> Option<&std::path::Path> {
452        self.subprocess_tape_path.as_deref()
453    }
454
455    /// Reference to the active filesystem overlay (if any).
456    pub fn overlay(&self) -> Option<&Arc<OverlayFs>> {
457        self.overlay.as_ref()
458    }
459
460    /// Reference to the active process tape (if any).
461    pub fn process_tape(&self) -> Option<&Arc<ProcessTape>> {
462        self.process_tape.as_ref()
463    }
464
465    /// Reference to the active tape recorder (if any).
466    pub fn tape_recorder(&self) -> Option<&Arc<TapeRecorder>> {
467        self.recorder.as_ref()
468    }
469
470    /// Persist the recorded subprocess tape (if recording) and return
471    /// the filesystem diff (if an overlay is active). Tearing down the
472    /// session via [`Drop`] will not persist; call this explicitly to
473    /// flush.
474    pub fn finalize(self) -> Result<TestbenchFinalize, TestbenchError> {
475        let diff = self
476            .overlay
477            .as_ref()
478            .map(|overlay| overlay.diff())
479            .unwrap_or_default();
480        let recorded = if matches!(self.subprocess_mode, ProcessTapeMode::Record) {
481            if let (Some(tape), Some(path)) = (
482                self.process_tape.as_ref(),
483                self.subprocess_tape_path.as_ref(),
484            ) {
485                tape.persist(path).map_err(TestbenchError::Subprocess)?;
486            }
487            self.process_tape
488                .as_ref()
489                .map(|tape| tape.recorded())
490                .unwrap_or_default()
491        } else {
492            Vec::new()
493        };
494        let mut emitted_tape = None;
495        if let (Some(recorder), Some(path)) = (self.recorder.as_ref(), self.tape_path.as_ref()) {
496            let header = TapeHeader::current(
497                self.tape_started_at_unix_ms,
498                self.tape_script_path.clone(),
499                self.tape_argv.clone(),
500            );
501            let tape = recorder.snapshot(header);
502            tape.persist(path).map_err(TestbenchError::Tape)?;
503            emitted_tape = Some(EmittedTape {
504                path: path.clone(),
505                records: tape.records.len(),
506            });
507        }
508        // Drain the leak audit last so anything emitted while we
509        // serialized other artifacts (e.g. tape persistence reading the
510        // wall clock for timestamps it shouldn't be reading) is still
511        // captured in this session's report.
512        let clock_leaks = leak_audit::drain();
513        // The Drop impl undoes mocks regardless of finalize success.
514        Ok(TestbenchFinalize {
515            fs_diff: diff,
516            recorded_subprocesses: recorded,
517            tape: emitted_tape,
518            clock_leaks,
519        })
520    }
521}
522
523impl Drop for TestbenchSession {
524    fn drop(&mut self) {
525        if let Some(saved) = self.saved_egress_env.take() {
526            restore_env("HARN_EGRESS_DEFAULT", saved.default);
527            restore_env("HARN_EGRESS_ALLOW", saved.allow);
528            restore_env("HARN_EGRESS_DENY", saved.deny);
529            reset_egress_policy_for_host();
530        }
531        // The remaining `_clock`/`_overlay`/`_process` guards drop in
532        // field-declared order, restoring the prior thread-local state.
533    }
534}
535
536fn restore_env(key: &str, prior: Option<String>) {
537    match prior {
538        Some(value) => std::env::set_var(key, value),
539        None => std::env::remove_var(key),
540    }
541}
542
543/// Outcome of a finalized testbench session — the artifacts the operator
544/// inspects after a hermetic run.
545#[derive(Debug, Default, Clone)]
546pub struct TestbenchFinalize {
547    pub fs_diff: Vec<overlay_fs::DiffEntry>,
548    pub recorded_subprocesses: Vec<process_tape::TapeEntry>,
549    pub tape: Option<EmittedTape>,
550    /// Capabilities that observed real wall-clock or monotonic time
551    /// during the session. Empty under a hermetic run; non-empty entries
552    /// are fidelity hazards the operator should investigate or migrate
553    /// off of direct host-clock reads.
554    pub clock_leaks: Vec<ClockLeak>,
555}
556
557/// Summary metadata for a unified tape that was emitted at finalize-time.
558#[derive(Debug, Clone)]
559pub struct EmittedTape {
560    pub path: PathBuf,
561    pub records: usize,
562}
563
564/// Errors surfaced when activating or finalizing a testbench session.
565#[derive(Debug)]
566pub enum TestbenchError {
567    Subprocess(String),
568    Tape(String),
569}
570
571impl std::fmt::Display for TestbenchError {
572    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
573        match self {
574            Self::Subprocess(msg) => write!(f, "testbench subprocess: {msg}"),
575            Self::Tape(msg) => write!(f, "testbench tape: {msg}"),
576        }
577    }
578}
579
580impl std::error::Error for TestbenchError {}
581
582#[cfg(test)]
583mod tests {
584    use super::*;
585
586    /// Tests in this module mutate process-global state (env vars, the
587    /// leak audit registry) and must run one at a time even though
588    /// `cargo test` defaults to parallel execution. We share
589    /// [`leak_audit::TEST_LOCK`] so the audit module's own tests
590    /// serialize with the testbench's tests against the same registry.
591    fn serial<F: FnOnce()>(body: F) {
592        let _guard = leak_audit::TEST_LOCK
593            .lock()
594            .unwrap_or_else(|p| p.into_inner());
595        body();
596    }
597
598    #[test]
599    fn paused_clock_pins_now_ms_for_session_lifetime() {
600        serial(|| {
601            let bench = Testbench::builder()
602                .paused_clock_at_ms(1_700_000_000_000)
603                .build();
604            let session = bench.activate().expect("activate");
605            assert_eq!(crate::clock_mock::now_ms(), 1_700_000_000_000);
606            crate::clock_mock::advance(std::time::Duration::from_mins(1));
607            assert_eq!(crate::clock_mock::now_ms(), 1_700_000_060_000);
608            drop(session);
609            // After drop the override is gone; no assertion on real time.
610            assert!(!crate::clock_mock::is_mocked());
611        });
612    }
613
614    #[test]
615    fn deny_by_default_blocks_egress_until_drop() {
616        serial(|| {
617            let bench = Testbench::builder().deny_network().build();
618            let session = bench.activate().expect("activate");
619            assert_eq!(std::env::var("HARN_EGRESS_DEFAULT").as_deref(), Ok("deny"));
620            drop(session);
621            assert!(std::env::var("HARN_EGRESS_DEFAULT").is_err());
622        });
623    }
624
625    #[test]
626    fn finalize_surfaces_clock_leaks_for_contrived_capability() {
627        serial(|| {
628            let bench = Testbench::builder()
629                .paused_clock_at_ms(1_700_000_000_000)
630                .build();
631            let session = bench.activate().expect("activate");
632
633            // Contrived "leaky" capability: routes through the audit shim
634            // while a paused mock is installed. Production callers (e.g.
635            // `stdlib/date_iso`) follow the exact same pattern.
636            let _ = leak_audit::wall_now("test/contrived_leak");
637            let _ = leak_audit::instant_now("test/contrived_instant");
638            let _ = leak_audit::wall_now("test/contrived_leak");
639
640            let finalize = session.finalize().expect("finalize");
641            let by_id: std::collections::BTreeMap<&str, &ClockLeak> = finalize
642                .clock_leaks
643                .iter()
644                .map(|leak| (leak.capability_id.as_str(), leak))
645                .collect();
646            let wall = by_id
647                .get("test/contrived_leak")
648                .expect("wall leak surfaced");
649            assert_eq!(wall.count, 2);
650            let inst = by_id
651                .get("test/contrived_instant")
652                .expect("instant leak surfaced");
653            assert_eq!(inst.count, 1);
654
655            // Drain semantics: a fresh session sees no carry-over.
656            let next_session = Testbench::builder()
657                .paused_clock_at_ms(1_700_000_000_000)
658                .build()
659                .activate()
660                .expect("activate next");
661            let next = next_session.finalize().expect("finalize next");
662            assert!(next.clock_leaks.is_empty());
663        });
664    }
665
666    #[test]
667    fn audit_quiet_when_no_mock_is_active() {
668        serial(|| {
669            leak_audit::reset();
670            // No `Testbench` activated → no mock clock → no leak entries
671            // even when the helpers are called.
672            let _ = leak_audit::wall_now("test/no_mock");
673            let _ = leak_audit::instant_now("test/no_mock");
674            assert!(leak_audit::snapshot().is_empty());
675        });
676    }
677}