Skip to main content

detcore_model/
config.rs

1/*
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 * All rights reserved.
4 *
5 * This source code is licensed under the BSD-style license found in the
6 * LICENSE file in the root directory of this source tree.
7 */
8
9//! Detcore configuration and widely used types.
10
11use std::collections::BTreeSet;
12use std::ffi::OsString;
13use std::fmt;
14use std::num::NonZeroU64;
15use std::path::PathBuf;
16use std::str::FromStr;
17use std::time::SystemTime;
18
19use chrono::DateTime;
20use chrono::Utc;
21use clap::Parser;
22use serde::Deserialize;
23use serde::Serialize;
24
25use crate::happens_before::HappensBeforeProgram;
26use crate::network_trace::NetworkTraceConfig;
27use crate::pid::DetTid;
28use crate::schedule::SigWrapper;
29use crate::time::NANOS_PER_RCB;
30use crate::time::RcbTimeMultiplier;
31
32const fn default_true() -> bool {
33    true
34}
35
36/// One mount row whose kernel-private root must be replaced before it becomes
37/// guest-visible.
38///
39/// The CLI populates this only after proving, with held file descriptors in the
40/// completed mount namespace, that the mount is one Hermit created.  The raw
41/// mount ID is namespace-local, so these entries are valid only for the one
42/// container run whose configuration carries them.
43#[derive(Debug, Serialize, Deserialize, Clone, Eq, PartialEq)]
44pub struct MountInfoRootRewrite {
45    /// Mount ID read from the held target descriptor's `/proc/self/fdinfo`.
46    pub raw_mount_id: u64,
47    /// Stable guest-visible replacement for the row's root field.
48    pub deterministic_root: Vec<u8>,
49    /// Exact encoded kernel root prefix used for descendant mount rows.
50    ///
51    /// This is present only for a proven private `/tmp`. Mounts installed below
52    /// that directory before it is bound over guest `/tmp` otherwise expose the
53    /// randomly named backing directory in mountinfo field 5.
54    #[serde(default)]
55    pub raw_root_prefix: Option<Vec<u8>>,
56    /// Guest-visible prefix replacing `raw_root_prefix`.
57    #[serde(default)]
58    pub deterministic_root_prefix: Option<Vec<u8>>,
59    /// Exact encoded host path prefix used for descendant mountpoints.
60    #[serde(default)]
61    pub raw_mountpoint_prefix: Option<Vec<u8>>,
62    /// Guest-visible prefix replacing `raw_mountpoint_prefix`.
63    #[serde(default)]
64    pub deterministic_mountpoint_prefix: Option<Vec<u8>>,
65}
66
67/// Configuration options for detcore.
68#[derive(Debug, Serialize, Deserialize, Clone, Parser)]
69pub struct Config {
70    /// Disable virtual/logical time. Note that virtual time is required for virtual metadata.
71    #[clap(long = "no-virtualize-time", action = clap::ArgAction::SetFalse)]
72    pub virtualize_time: bool,
73
74    /// Disable virtual cpuid
75    #[clap(long = "no-virtualize-cpuid", action = clap::ArgAction::SetFalse)]
76    pub virtualize_cpuid: bool,
77
78    /// The execution backend installs a deterministic CPUID policy without instruction faults.
79    #[serde(default)]
80    #[clap(skip)]
81    pub cpuid_virtualized_by_backend: bool,
82
83    /// The execution backend implements guest-visible madvise semantics.
84    #[serde(default = "default_true")]
85    #[clap(skip = true)]
86    pub backend_supports_madvise: bool,
87
88    // AUTONOMOUS-BOT-IMPLEMENTED
89    // TODO-HUMAN-REVIEW(PR-845): Review in-process backend descriptor discovery.
90    /// The execution backend runs Detcore inside the guest and can inspect its live descriptors.
91    #[serde(default)]
92    #[clap(skip)]
93    pub discover_live_file_metadata: bool,
94
95    // AUTONOMOUS-BOT-IMPLEMENTED
96    // TODO-HUMAN-REVIEW(PR-845): Review backend-local guest clock observations.
97    /// Legacy serialized setting retained for record compatibility. Guest-visible wall and
98    /// monotonic clocks always use the coordinator's virtual-time domain.
99    #[serde(default)]
100    #[clap(skip)]
101    pub use_thread_local_clock_reads: bool,
102
103    // AUTONOMOUS-BOT-IMPLEMENTED
104    // TODO-HUMAN-REVIEW(PR-845): Review host-clock futex deadline detection.
105    /// Direct guest clock reads may bypass backend virtualization, so absolute futex deadlines
106    /// must be classified against both the host and logical clocks.
107    #[serde(default)]
108    #[clap(skip)]
109    pub detect_host_clock_futex_timeouts: bool,
110
111    // AUTONOMOUS-BOT-IMPLEMENTED
112    // TODO-HUMAN-REVIEW(PR-845): Review backend-owned syscall-clobber determinism.
113    /// The execution backend already returns deterministic values for registers clobbered by a
114    /// syscall instruction, so Detcore must not write the complete register set back afterward.
115    #[serde(default)]
116    #[clap(skip)]
117    pub syscall_clobbers_virtualized_by_backend: bool,
118
119    // AUTONOMOUS-BOT-IMPLEMENTED
120    // TODO-HUMAN-REVIEW(PR-845): Review backend-local exit-group RPC cancellation.
121    /// Logically killed guest threads need an explicit scheduler response because the backend
122    /// does not rely on ptrace's kernel-driven exit-group teardown.
123    #[serde(default)]
124    #[clap(skip)]
125    pub cancel_killed_thread_rpcs: bool,
126
127    /// The execution backend reports final physical process exits after logical tool cleanup, so
128    /// Detcore can prevent virtual timers from overtaking kernel child-exit publication.
129    #[serde(default)]
130    #[clap(skip)]
131    pub backend_reports_physical_process_exits: bool,
132
133    // TODO-HUMAN-REVIEW(PR-1013): Review backend child process execution ordering.
134    /// The execution backend completes forked process children before returning to the parent.
135    #[serde(default)]
136    #[clap(skip)]
137    pub backend_serializes_fork_children: bool,
138
139    // TODO-HUMAN-REVIEW(PR-1013): Review backend thread callback coverage.
140    /// The execution backend dispatches cloned thread syscalls through this tool.
141    #[serde(default = "default_true")]
142    #[clap(skip = true)]
143    pub backend_dispatches_thread_tools: bool,
144
145    /// The backend reports every process child through Detcore's child-registration protocol.
146    /// When true, an empty scheduler selection is authoritative ECHILD rather than a reason to
147    /// fall back to backend-specific wait filtering.
148    #[serde(default = "default_true")]
149    #[clap(skip = true)]
150    pub backend_tracks_process_children: bool,
151
152    /// The execution backend completes Linux's robust-list cleanup before its task-exit callback
153    /// lets another modeled thread run. Detcore still wakes waiters parked in its precise futex
154    /// model, but it leaves the owner-word transition to Linux so it remains atomic.
155    #[serde(default = "default_true")]
156    #[clap(skip = true)]
157    pub backend_runs_exit_robust_list: bool,
158
159    // AUTONOMOUS-BOT-IMPLEMENTED
160    // TODO-HUMAN-REVIEW(PR-1058): Review process-signal identity translation.
161    /// The backend cannot execute process-directed signal syscalls using Detcore's guest PID and
162    /// therefore requires Detcore to translate an unambiguous process target to a specific thread.
163    #[serde(default)]
164    #[clap(skip)]
165    pub backend_requires_thread_directed_process_signals: bool,
166
167    /// Identifies KVM for its run-installed process alarm control.
168    #[serde(default)]
169    #[clap(skip)]
170    pub backend_is_kvm: bool,
171
172    /// Startup-only real-timer policy using acknowledged shared signal dequeues.
173    #[serde(default)]
174    #[clap(skip)]
175    pub kvm_shared_dequeue_timers: bool,
176
177    /// The backend can wake a scheduler-managed pipe write for a cross-task signal while
178    /// preserving Linux signal-mask, disposition, and syscall-restart behavior.
179    #[serde(default = "default_true")]
180    #[clap(skip = true)]
181    pub backend_supports_parked_write_signal_interruption: bool,
182
183    // AUTONOMOUS-BOT-IMPLEMENTED
184    // TODO-HUMAN-REVIEW(PR-1125): Review backend-owned capability-control prctls.
185    /// The execution backend virtualizes capability bounding-set and ambient-capability state.
186    #[serde(default)]
187    #[clap(skip)]
188    pub backend_virtualizes_capability_prctls: bool,
189
190    // AUTONOMOUS-BOT-IMPLEMENTED
191    // TODO-HUMAN-REVIEW(PR-1152): Review deferred vfork child registration.
192    /// The execution backend does not keep a `CLONE_VFORK` parent blocked inside the injected
193    /// `clone(2)` until the child registers. The ptrace backend relies on the kernel to suspend a
194    /// vfork parent until the child execs or exits, so the child always registers its vfork barrier
195    /// before the parent asks to continue. Out-of-process backends such as KVM service the clone by
196    /// deferring the child spawn, so the child registers only *after* the parent posts its
197    /// continuation. When this is set the scheduler keeps an unfulfilled vfork barrier in place at
198    /// parent continuation (waiting for the late child) instead of treating it as a failed clone.
199    #[serde(default)]
200    #[clap(skip)]
201    pub backend_defers_vfork_child_registration: bool,
202
203    /// Epoch of the logical time.
204    ///
205    /// This is the datetime from which all time and date modtimes begin and
206    /// monotonically increase. It is in RFC3339 format such as `2026-01-01T00:00:00Z`.
207    /// The stable default here is for library callers and wire-format fixtures;
208    /// the `hermit run` and `hermit oci run` commands replace an omitted CLI
209    /// default with one host wall-clock sample taken before backend dispatch.
210    #[clap(
211        long,
212        env = "HERMIT_EPOCH",
213        value_name = "YYYY-MM-DDThh:mm:ssZ",
214        default_value = DEFAULT_EPOCH_STR,
215        hide_default_value = true
216    )]
217    pub epoch: DateTime<Utc>,
218
219    /// Use this number to seed the PRNG randomness for both RNG and scheduler.
220    /// This acts as a global fallback in case either `sched_seed` or `rng-seed`
221    /// are not explicitly specified
222    #[clap(
223        long = "seed",
224        env = "HERMIT_PRNG",
225        default_value = "0",
226        value_name = "uint64"
227    )]
228    pub seed: u64,
229
230    /// Use this number to seed the PRNG that supplies randomness to the guest.
231    /// This supplies guest system calls that expose randomness, as well as
232    /// the `/dev/[u]random` files. It does not affect the `rdrand` instruction,
233    /// which is disabled in the guest.
234    #[clap(long, value_name = "uint64")]
235    pub rng_seed: Option<u64>,
236
237    /// Seeds the PRNG which drives syscall response fuzzing (i.e. chaotically exercising syscall
238    /// nondeterminism).  Like other seeds, this is initialized from the `--seed` if not
239    /// specifically provided.
240    #[clap(long, value_name = "uint64")]
241    pub fuzz_seed: Option<u64>,
242
243    /// Logical clock multiplier. Values above one make time appear to go faster within the sandbox.
244    #[clap(long, value_name = "float")]
245    pub clock_multiplier: Option<f64>,
246
247    /// Disable substitution of virtual (deterministic) file metadata in lieu
248    /// of the real metadata returned by `stat`/`statx`. This also preserves raw
249    /// mountinfo device numbers so those interfaces continue to agree. Raw
250    /// device values are host/filesystem observations and are not promised to
251    /// reproduce across machines. Virtual metadata implies `virtualize_time`.
252    #[clap(long = "no-virtualize-metadata", action = clap::ArgAction::SetFalse)]
253    pub virtualize_metadata: bool,
254
255    /// Proven Hermit-owned mount roots to hide from `/proc/*/mountinfo`.
256    ///
257    /// This is runtime provenance, not a user option.  `serde(default)` keeps
258    /// older serialized configurations compatible and makes backends which do
259    /// not use the common container setup explicitly receive no rewrite claim.
260    #[serde(default)]
261    #[clap(skip)]
262    pub mountinfo_root_rewrites: Vec<MountInfoRootRewrite>,
263
264    /// Backend-proven pairs of (`mountinfo` raw device, `stat`/`statx` raw device).
265    ///
266    /// A backend may synthesize mountinfo independently from its pathname
267    /// metadata implementation.  These pairs state that the two raw numbers
268    /// describe the same filesystem, so Detcore can feed both surfaces through
269    /// one device identity.  The pairs are runtime provenance, not a user
270    /// option; an absent pair must never be inferred from numeric coincidence.
271    #[serde(default)]
272    #[clap(skip)]
273    pub mountinfo_device_rewrites: Vec<(u64, u64)>,
274
275    /// Recording/container namespace mount IDs in canonical row order.
276    ///
277    /// Detcore uses this same mapping for `/proc/*/mountinfo` and
278    /// `/proc/*/fdinfo/*`. It is runtime provenance rather than a user option;
279    /// replay retains recording-time raw IDs because its read events contain
280    /// recording-time kernel bytes.
281    #[serde(default)]
282    #[clap(skip)]
283    pub mountinfo_mount_ids: Vec<u64>,
284
285    /// Whether `mountinfo_mount_ids` is an exact producer-owned snapshot.
286    ///
287    /// The distinction matters for an empty mountinfo file: an absent snapshot
288    /// asks Detcore to observe the completed guest namespace, while a captured
289    /// empty snapshot must remain empty during replay.
290    #[serde(default)]
291    #[clap(skip)]
292    pub mountinfo_mount_ids_captured: bool,
293
294    /// Raw fdinfo mount IDs absent from mountinfo, in first-observation order.
295    ///
296    /// Recording persists this producer-observed order so replay does not
297    /// derive identities from its fresh namespace or launch descriptor shape.
298    #[serde(default)]
299    #[clap(skip)]
300    pub fdinfo_unlisted_mount_ids: Vec<u64>,
301
302    /// Sequentialize thread execution deterministically.
303    #[clap(long)]
304    pub sequentialize_threads: bool,
305
306    /// Choose which side of an ordinary fork/clone runs first after the child is registered.
307    /// Random choices are deterministic under `--sched-seed`.
308    #[serde(default)]
309    #[clap(long, default_value = "child", value_name = "child|parent|random")]
310    pub runs_post_fork: RunsPostFork,
311
312    /// Use the optimized partial syscall subscription set instead of intercepting every syscall.
313    /// This permits unlisted syscalls to bypass Detcore and therefore weakens deterministic
314    /// accounting; leave it disabled for fail-closed execution.
315    #[serde(default)]
316    #[clap(long)]
317    pub passthru_opt: bool,
318
319    /// In chaos mode, uses much cheaper approximate preemption timers.  Only makes sense
320    /// when recording preemptions for later (precise) replay.
321    #[clap(long)]
322    pub imprecise_timers: bool,
323
324    /// Schedule threads chaotically.
325    ///
326    /// The behavior of this flag is subject to change. Current behavior is to randomize thread
327    /// priorities at every logical timeslice. Other randomization strategies are possible with
328    /// `--sched-heuristic`.
329    ///
330    /// Thread scheduling remains deterministic, determined by the random seed.
331    #[clap(long)]
332    pub chaos: bool,
333
334    /// Uses the `--fuzz-seed` to generate randomness and fuzz nondeterminism in the futex semantics.
335    #[clap(long)]
336    pub fuzz_futexes: bool,
337
338    /// Targeted chaos: bias scheduling toward known concurrency race patterns
339    /// instead of exploring interleavings uniformly. At the scheduler's existing
340    /// nondeterminism points it uses `--fuzz-seed` to (a) deliver a
341    /// process-directed signal to a randomly chosen thread in the group (signal
342    /// timing races) and (b) randomize the requeue position of a force-unblocked
343    /// thread (lock-ordering / wakeup races). Only takes effect with `--chaos`;
344    /// like the rest of chaos mode it remains reproducible under a fixed seed.
345    #[clap(long)]
346    pub chaos_target_races: bool,
347
348    // AUTONOMOUS-BOT-IMPLEMENTED
349    // TODO-HUMAN-REVIEW(PR-1149)
350    // TODO-HUMAN-REVIEW(PR-1151)
351    /// Reproducible per-thread slowdown factors for chaos mode. A factor greater
352    /// than one makes each RCB consume proportionally more virtual time, while a
353    /// factor below one makes it consume less. Thus scheduling deadlines and the
354    /// guest-visible virtual clock describe the same slowed execution rather than
355    /// applying an out-of-band scheduling bias. The factor is a pure function of
356    /// scheduler seed, stable deterministic thread id, and chaos epoch. A fixed
357    /// seed therefore reproduces both timing and interleavings.
358    #[clap(long)]
359    pub chaos_per_thread_slowdown: bool,
360
361    // AUTONOMOUS-BOT-IMPLEMENTED
362    // TODO-HUMAN-REVIEW(PR-1149)
363    // TODO-HUMAN-REVIEW(PR-1151)
364    /// Maximum ratio between the slowest and fastest per-thread slowdown factor
365    /// for `--chaos-per-thread-slowdown`. Each thread's factor is drawn
366    /// log-uniformly from `[1/R, R]` where `R` is this value. Must fit the Q32
367    /// virtual-time representation and be `>= 1.0`; `1.0` disables the spread.
368    #[clap(long, default_value = "10.0", value_name = "double")]
369    pub chaos_slowdown_max_factor: f64,
370
371    // AUTONOMOUS-BOT-IMPLEMENTED
372    // TODO-HUMAN-REVIEW(PR-1151)
373    /// Length of a deterministic slowdown epoch in elapsed per-thread logical
374    /// nanoseconds. At the first scheduler commit at or after each boundary the
375    /// factor is redrawn as `factor(seed, stable_dettid, epoch)`. This is never
376    /// wall time. `0` means one epoch for the entire run, making constant slowdown
377    /// the single-epoch special case. Recorded preemption artifacts carry exact
378    /// epoch transitions and factors for replay. Inert without chaos slowdown.
379    #[clap(long, default_value = "0", value_name = "nanos")]
380    pub chaos_epoch_length_ns: u64,
381
382    /// Record the timing of preemption events for future replay or experimentation.
383    /// This is only useful in chaos modes.
384    #[clap(long)]
385    pub record_preemptions: bool,
386
387    /// File to write the record of preemptions (in JSON).  Implies `--record-preemptions`.
388    #[clap(long, value_name = "filepath")]
389    pub record_preemptions_to: Option<PathBuf>,
390
391    /// JSON file to read recorded preemptions from.  When `--chaos` mode is activated, these
392    /// recorded preemption points take the place of randomized scheduling decisions.
393    #[clap(long, value_name = "filepath", conflicts_with = "replay_schedule_from")]
394    pub replay_preemptions_from: Option<PathBuf>,
395
396    /// File to read recorded schedule trace from. This execution will replay the schedule verbatim
397    /// from the file.
398    #[clap(
399        long,
400        value_name = "filepath",
401        conflicts_with = "replay_preemptions_from"
402    )]
403    pub replay_schedule_from: Option<PathBuf>,
404
405    /// If we run out of events while replaying a schedule, treat that as a fatal event and panic,
406    /// rather than continuing execution.
407    #[clap(long)]
408    pub replay_exhausted_panic: bool,
409
410    /// When playing a schedule trace from disk, bail out on the first time we desynchronize from
411    /// the event sequence specified in the trace.
412    #[clap(long)]
413    pub die_on_desync: bool,
414
415    /// Given schedule events traced on recording or replaying, print the stack trace at the moment
416    /// after the Nth event in the trace. Optionally, provide an output file into which the stack
417    /// trace will be printed, otherwise it goes to stderr.
418    #[clap(long,
419           short = 's',
420           value_name = "index[,path]",
421           value_parser = parse_index_with_path)]
422    pub stacktrace_event: Vec<(u64, Option<PathBuf>)>,
423
424    /// Internal feature used to signal the guest with SIGINT at every `--stacktrace-event`, this is
425    /// in-lieu of using hermit's internal stacktrace printing facility, to instead have an external
426    /// debugger handle it.  Accepts either signal names or numbers.
427    #[clap(long, value_name = "signame")]
428    pub stacktrace_signal: Option<SigWrapper>,
429
430    /// **Deprecated:** Print a stacktrace each time the program is preempted.  Only makes sense in `--chaos` mode
431    /// and typically goes with preemption recording/replaying.
432    #[clap(long)]
433    pub preemption_stacktrace: bool,
434
435    /// File to write preemption stacktraces to. Implies `--preemption-stacktrace`. If a
436    /// log file is not specified, preemption stacktraces are printed to stderr by default.
437    #[clap(long, value_name = "filepath")]
438    pub preemption_stacktrace_log_file: Option<PathBuf>,
439
440    /// Enable deterministic IO by reassuring we always read/write the maximum possible bytes
441    /// from IO syscalls. There might be cases that read/write syscalls return less bytes than
442    /// requests. Detcore, makes an effort to request additional bytes until we reach the ones
443    /// requested or EOF.
444    #[clap(long)]
445    pub deterministic_io: bool,
446
447    /// Fail immediately on unsupported syscalls instead of forwarding them.
448    /// Ordinary `hermit run` enables this policy; compatibility requires the
449    /// explicit `--allow-unsupported-syscalls` opt-out.
450    #[clap(long)]
451    pub panic_on_unsupported_syscalls: bool,
452
453    // AUTONOMOUS-BOT-IMPLEMENTED
454    // TODO-HUMAN-REVIEW(PR-644): Review backend-safe fail-closed termination.
455    /// Return a typed Tool error instead of unwinding through a backend callback.
456    #[serde(default)]
457    #[clap(skip)]
458    pub exit_on_unsupported_syscall: bool,
459    // AUTONOMOUS-BOT-IMPLEMENTED
460    // TODO-HUMAN-REVIEW(PR-644): Review process-tree shutdown for ptrace fail-closed mode.
461    /// Terminate the whole tracer when an unsupported syscall is observed.
462    #[serde(default)]
463    #[clap(skip)]
464    pub shutdown_on_unsupported_syscall: bool,
465
466    // AUTONOMOUS-BOT-IMPLEMENTED
467    // TODO-HUMAN-REVIEW(PR-644): Review the internal cross-process warning report channel.
468    /// Internal inherited file descriptor used to aggregate unsupported syscalls.
469    #[serde(default)]
470    #[clap(skip)]
471    pub unsupported_syscall_report_fd: Option<i32>,
472
473    /// Panic when a precise PMU timer overshoots its expected RCB target instead of logging an
474    /// error and continuing through normal timer handling. Intended for Detcore debugging.
475    #[serde(default)]
476    #[clap(
477        long = "panic-on-rbc-overshoot",
478        visible_alias = "panic-on-rcb-overshoot"
479    )]
480    pub panic_on_rcb_overshoot: bool,
481
482    /// **Internal:** Set to `true` if we're inside a UTS namespace.
483    // FIXME: This can be removed once spawn_fn-based tests support namespaces.
484    #[clap(skip)]
485    pub has_uts_namespace: bool,
486
487    /// **Internal:** Path to the replay data folder.
488    #[clap(skip)]
489    pub replay_data: Option<PathBuf>,
490
491    /// Kill all remaining tasks iff daemons are the only ones left.
492    /// Disabled by default.
493    #[clap(long)]
494    pub kill_daemons: bool,
495
496    /// Start gdbserver on `gdbserver_port` for remote debugging
497    /// Disabled by default.
498    #[clap(long)]
499    pub gdbserver: bool,
500    /// port gdbserver listening on
501    #[clap(
502        long,
503        value_name = "uint16",
504        help = "Port gdbserver listening on",
505        default_value = "1234"
506    )]
507    pub gdbserver_port: u16,
508
509    /// Configure the maximum time a guest thread may run without returning to Detcore. This is
510    /// measured in virtual nanoseconds and enforced with retired conditional branch (RCB)
511    /// counting. `--preemption-timeout` is retained as a deprecated alias.
512    ///
513    /// Set this to `disabled` or `0` to disable PMU-backed preemption. Positive values must be at
514    /// least one RCB (10 virtual nanoseconds at the default clock multiplier) and require
515    /// user-space hardware performance counters.
516    #[serde(alias = "preemption_timeout")]
517    #[clap(
518                long,
519                visible_alias = "preemption-timeout",
520                value_name = "uint64|'disabled'",
521                default_value = "200000000",
522                value_parser = parse_timeslice)]
523    pub max_timeslice: MaybeTimeslice,
524
525    /// Target logical timeslice checked at syscall boundaries, in virtual nanoseconds. This avoids
526    /// PMU preemption for workloads that enter the kernel frequently. Omit this option to use only
527    /// `--max-timeslice`.
528    #[serde(default)]
529    #[clap(long, value_name = "virtual-nanoseconds")]
530    pub target_timeslice: Option<NonZeroU64>,
531
532    /// Shut down immediately upon SIGINT, rather than letting the guest handle it.
533    #[clap(long)]
534    pub sigint_instakill: bool,
535
536    /// Warn if binds are non-zero.
537    #[clap(long)]
538    pub warn_non_zero_binds: bool,
539
540    /// Apply a specialized scheduling heuristic which may help exercise certain bugs.
541    #[clap(long, default_value = "none", value_name = "str")]
542    // TODO: Rename this to scheduler_strategy?
543    pub sched_heuristic: SchedHeuristic,
544
545    /// Use this number to seed the PRNG that supplies randomness to the scheduler.
546    #[clap(long, env = "HERMIT_SCHED_SEED", value_name = "uint64")]
547    pub sched_seed: Option<u64>,
548
549    /// Reserved configuration for a schedule-independent external-network trace.
550    ///
551    /// This is inert until a recorder/replayer integration explicitly consumes
552    /// it. In particular, its perturbation seed has no fallback to `seed` or
553    /// `sched_seed`.
554    #[serde(default)]
555    #[clap(skip)]
556    pub network_trace: NetworkTraceConfig,
557
558    /// Configure the probability for the Sticky Random scheduler to stay in a thread.
559    /// For value 0.0, we are behaving like Random.
560    /// For value 1.0, we are behaving like a DFS, where the same thread is
561    /// always picked as long as it is available in the Run queue. After
562    /// this thread is exhausted, the next thread will be chosen randomly.
563    /// For value 0.5, we have a 50/50 chance to pick the same thread.
564    #[clap(long, default_value = "0.0", value_name = "double")]
565    pub sched_sticky_random_param: f64,
566
567    /// **Internal:** An internal flag for indicating to Detcore whether we are in `hermit record` or
568    /// `hermit replay` mode.  This is necessary because there are DIFFERENT global
569    /// invariants in record mode (e.g. files dont exist).  If we move to a chroot model
570    /// and reproduce more, recording less, then this flag should become obsolete.
571    #[clap(skip = false)]
572    pub recordreplay_modes: bool,
573
574    /// **Internal:** debugging option to stop execution after a specific scheduler commit, aka turn number
575    /// (non-negative integer). This only makes sense if `--sequentialize-threads` is specified, as the scheduler is otherwise not engaged.
576    #[clap(long, value_name = "turn_N")]
577    pub stop_after_turn: Option<u64>,
578
579    /// **Internal:** debugging option to stop execution after a scheduler loop iteration (non-negative integer).
580    /// This only makes sense if `--sequentialize-threads` is specified, as the scheduler is otherwise not engaged.
581    #[clap(long, value_name = "iter_N")]
582    pub stop_after_iter: Option<u64>,
583
584    /// **Internal:** Debugging option to treat all sockets as mysterious external, nondeterministic
585    /// entities, rather than container-internal and determinstically scheduled.
586    #[clap(long)]
587    pub debug_externalize_sockets: bool,
588
589    /// **Internal:** Debugging option to change how futexes are implemented, either precisely modeled
590    /// by hermit, by polling the kernel with non-blocking futex operations, or treated as external
591    /// (nondeterministic) operations which unblock at imprecise times.
592    #[clap(
593        long,
594        value_name = "precise|polling|external",
595        default_value = "precise"
596    )]
597    pub debug_futex_mode: BlockingMode,
598
599    /// Do not count the retired conditional branches (RCBs) of each thread towards its logical
600    /// time.  Instead, count each checkin with the scheduler as a fixed increment to logical time.
601    /// Even when this option is set, HW RCB performance counters may still be enabled if a
602    /// max-timeslice is specified.
603    #[clap(long)]
604    pub no_rcb_time: bool,
605
606    /// An option to enable logging the hash of heap memory maps for the purpose of determinism checking
607    #[clap(long)]
608    pub detlog_heap: bool,
609
610    /// An option to enable logging the hash of stack memory maps for the purpose of determinism checking
611    ///
612    /// THIS HASH COVERS argv AND THE ENVIRONMENT, which the kernel places at the
613    /// top of the initial process stack. Two runs whose command lines differ by a
614    /// single character therefore produce different stack hashes from the first
615    /// sample, even when the command lines are the same LENGTH and every stack
616    /// address matches. Measured: equal-length-but-different argv diverged the
617    /// hash 14 records in, while byte-identical argv held it for 5023 records.
618    ///
619    /// Holding a run-directory name to a fixed WIDTH is a sufficient control when
620    /// only addresses matter, and is NOT sufficient here. Comparing two runs
621    /// under this flag requires byte-identical argv and environment; otherwise
622    /// the first divergence you find is your own input.
623    #[clap(long)]
624    pub detlog_stack: bool,
625
626    /// Log a hash of the guest REGISTER FILE at guest-logical-control points, for determinism
627    /// checking. stdout, the INFO log, the stack and the heap are all hashed today; the register
628    /// file is not, so two backends can differ in register state and every existing check still
629    /// reports parity.
630    ///
631    /// SAMPLED ONLY AT GUEST-LOGICAL-CONTROL POINTS -- see `Detcore::detlog_registers`. Registers
632    /// are NOT sampled inside a tool handler: a backend running its handler in-guest executes code
633    /// the ptrace reference never executes, so a difference there is correct behaviour, not a
634    /// determinism bug.
635    #[clap(long)]
636    pub detlog_regs: bool,
637
638    /// Log a hash of each syscall's OUTPUT BUFFER, taken at the syscall boundary from the
639    /// address and length in the syscall's own arguments.
640    ///
641    /// WHAT IT SEES THAT THE MAPPING HASHES DO NOT. `--detlog-heap` and `--detlog-stack` hash a
642    /// whole named mapping, so their coverage is decided by where the guest happened to ALLOCATE
643    /// a buffer. Measured, three runs per cell, same netlink exchange with only the receive
644    /// buffer's home changed: a `[stack]` buffer is missed by `--detlog-heap`, a `[heap]` buffer
645    /// is missed by `--detlog-stack`, and a BSS/static or anonymous-`mmap` buffer is missed by
646    /// BOTH even with both enabled. Anonymous `mmap` is where glibc puts any `malloc` above the
647    /// 128 KiB `M_MMAP_THRESHOLD`. Reading the extent out of the syscall arguments makes the
648    /// buffer's home irrelevant.
649    ///
650    /// WHY IT IS NOT REDUNDANT WITH `--verify`. A syscall whose buffer is a bare pointer in
651    /// Reverie prints the ADDRESS, not the contents, so a `recvmsg` returning a stable
652    /// `Ok(1468)` whose payload varies produces a character-identical record and `--verify`
653    /// reports `bitwise_parity: true`. 44.1% of the syscalls in a QEMU/Linux boot move bytes
654    /// through such a buffer.
655    ///
656    /// COST is proportional to bytes actually moved, NOT to syscall count or mapping size:
657    /// ~0.75 s per GB of guest I/O. A QEMU/Linux boot moves 139.1 MB through these buffers,
658    /// against the 10.9 TB `--detlog-heap` hashes over the same run.
659    ///
660    /// NAME IS PROVISIONAL: `io-buffers` is the owner's candidate and is not settled.
661    ///
662    /// ON BY DEFAULT. It was opt-in until 2026-08-24, and opt-in made the
663    /// determinism gate weaker than its name: with the hash absent, the netlink
664    /// `recvmsg` above compares equal and `--verify` reports success. A check
665    /// that must be requested is not a standard. The opt-out exists for the
666    /// deliberate case (bulk I/O where the cost matters and content parity is
667    /// not the question), not as the ordinary setting.
668    ///
669    /// COST OF THE DEFAULT, measured 2026-08-24 on a 316-core x86_64 Linux
670    /// build host: a typical small test guest pays about ONE MILLISECOND
671    /// (`/bin/true` 0.029s -> 0.030s, `/bin/ls` 0.041s -> 0.041s, 8 runs each).
672    /// 64 MiB through `cat` costs +0.07-0.10s in a RELEASE build, which is the
673    /// ~1.1-1.6 s/GB matching the figure quoted above. The same workload in a
674    /// DEBUG build costs +3.4s, roughly 50x more, because the hash loop is
675    /// unoptimized -- so a debug-built node moving tens of megabytes is the one
676    /// place the default is felt.
677    #[clap(long = "no-detlog-io-buffers", action = clap::ArgAction::SetFalse)]
678    pub detlog_io_buffers: bool,
679
680    /// Sampling cadence for `--detlog-regs`: hash every Nth guest-logical-control point.
681    ///
682    /// COST TIER. 1 (the default) is the FULL tier -- every control point hashed -- and is what a
683    /// short test should use. Measured cost at this scale is within run-to-run noise: /bin/true
684    /// (49 control points), `wc -l /etc/passwd` (135) and a 5-iteration shell loop (195) were
685    /// 0.04-0.07s with the flag on and the same with it off. A larger N is the SPOT-CHECK tier for
686    /// runs where full hashing is too expensive; it trades detection latency for cost, since a
687    /// divergence is only seen at the next sampled point. Every emitted line records the tier it
688    /// was produced under, so a cell can state which tier it met instead of leaving it implicit.
689    #[clap(long, default_value = "1", value_name = "uint64")]
690    pub detlog_regs_cadence: u64,
691
692    /// Configure a time offset (in seconds) between a container OS considered booted and a guest is executed
693    /// This primarily affects 'sysinfo' syscall's 'uptime' field reporting
694    #[clap(long, default_value = "120", value_name = "uint64")]
695    pub sysinfo_uptime_offset: u64,
696
697    /// Configure memory available for the container.  Takes a number of bytes, or shorthand (e.g.
698    /// "1GB"). Right now this doesn't enforce an upper bound, but does affect the amount of memory
699    /// reported to the guest.
700    #[clap(long, default_value = "1GB", value_parser = try_parse_memory, value_name = "bytesize")]
701    pub memory: u64,
702
703    /// Configure extra interrupt points based on thread id and rcb counter. Detcore will raise a precise
704    /// timer for this RCB whenever it detects that current current thread timeslice intercects any of the
705    /// interrupt points specified
706    #[clap(long, value_name = "tid:rcbs", value_parser = try_parse_numbers_with_colon)]
707    pub interrupt_at: Vec<(DetTid, u64)>,
708
709    /// Resolved happens-before program: deterministic ordering edges between
710    /// anchored events (see `detcore_model::happens_before`). This is populated
711    /// programmatically by hermit-cli after loading and resolving a
712    /// `--happens-before` spec against the guest binary; it is not a direct CLI
713    /// flag and is not serialized (it is reconstructed from the spec file each
714    /// run, so `#[serde(skip)]` avoids requiring serde on `Sysno`-bearing
715    /// positions and keeps save-config output stable). The scheduler enforces
716    /// these edges only when `sequentialize_threads` is set.
717    #[serde(skip)]
718    #[clap(skip)]
719    pub happens_before: Option<HappensBeforeProgram>,
720}
721
722fn try_parse_numbers_with_colon(from_str: &str) -> anyhow::Result<(DetTid, u64)> {
723    if let Some((thread_id_str, time_str)) = from_str.split_once(':') {
724        Ok((
725            thread_id_str
726                .parse::<DetTid>()
727                .map_err(anyhow::Error::msg)?,
728            time_str.parse::<u64>().map_err(anyhow::Error::msg)?,
729        ))
730    } else {
731        anyhow::bail!(
732            "unable to parse <thread_id>:<logical_time> from '{}'",
733            from_str
734        )
735    }
736}
737
738fn try_parse_memory(from_str: &str) -> anyhow::Result<u64> {
739    <bytesize::ByteSize as FromStr>::from_str(from_str)
740        .map(|res| res.as_u64())
741        .map_err(anyhow::Error::msg)
742}
743
744impl Config {
745    /// Whether the epoch is the stable library default omitted by `Display`.
746    pub fn has_default_epoch(&self) -> bool {
747        self.epoch == DEFAULT_EPOCH_STR.parse::<DateTime<Utc>>().unwrap()
748    }
749
750    /// Replace the stable library/test default with the host wall clock captured
751    /// by the outer `hermit run` invocation. The caller owns the single
752    /// host-clock read boundary; all guest clock and metadata observations
753    /// consume the resulting concrete epoch.
754    pub fn capture_epoch_from_host_time(&mut self, now: SystemTime) {
755        self.epoch = epoch_from_host_time(now);
756    }
757
758    /// Smallest PMU-backed maximum representable by one RCB at this clock multiplier.
759    pub fn minimum_max_timeslice_nanos(&self) -> u64 {
760        let slowdown = if self.chaos && self.chaos_per_thread_slowdown {
761            self.chaos_slowdown_max_factor
762        } else {
763            1.0
764        };
765        let multiplier = self.clock_multiplier.unwrap_or(1.0) * slowdown;
766        ((NANOS_PER_RCB * multiplier).ceil() as u64).max(NANOS_PER_RCB as u64)
767    }
768
769    /// Check invariants that must hold at every execution boundary without mutating the config.
770    pub fn validate_invariants(&self) {
771        assert!(self.sched_sticky_random_param >= 0.0);
772        assert!(self.sched_sticky_random_param <= 1.0);
773        // AUTONOMOUS-BOT-IMPLEMENTED
774        // TODO-HUMAN-REVIEW(PR-1149)
775        assert!(
776            self.chaos_slowdown_max_factor.is_finite()
777                && self.chaos_slowdown_max_factor >= 1.0
778                && self.chaos_slowdown_max_factor <= RcbTimeMultiplier::MAX,
779            "chaos_slowdown_max_factor must be finite and in [1.0, {}], got {}",
780            RcbTimeMultiplier::MAX,
781            self.chaos_slowdown_max_factor
782        );
783        if let Some(multiplier) = self.clock_multiplier {
784            assert!(
785                multiplier.is_finite() && multiplier > 0.0,
786                "clock_multiplier must be finite and positive"
787            );
788        }
789        let minimum_max_timeslice = self.minimum_max_timeslice_nanos();
790        assert!(
791            self.max_timeslice
792                .is_none_or(|timeslice| u64::from(timeslice) >= minimum_max_timeslice),
793            "max_timeslice must be at least one RCB ({} virtual nanoseconds)",
794            minimum_max_timeslice
795        );
796    }
797
798    /// Sanity check the flags, and update any wherever flag B is implied by A.
799    pub fn validate(&mut self) {
800        self.validate_invariants();
801
802        // TODO(T124429978) Restore the eprintln! calls below to tracing::warn! when the tracing
803        // subscriber is set up early enough for these warnings to print.
804
805        if self.record_preemptions_to.is_some() {
806            self.record_preemptions = true;
807        }
808        // TODO: separate out recording flags: --record-preemptions vs --record-schedule-trace
809        // if self.record_preemptions && !self.chaos {
810        //     tracing::warn!(
811        //         "Setting --record-preemptions when not in chaos mode doesn't do anything."
812        //     );
813        // }
814
815        if self.replay_schedule_from.is_some() && self.replay_preemptions_from.is_some() {
816            panic!("Cannot set both --replay-preemptions-from and --replay-schedule-from!!");
817        }
818
819        if self.chaos {
820            self.sequentialize_threads = true;
821        }
822
823        if self.replay_preemptions_from.is_some() && self.imprecise_timers {
824            eprintln!(
825                "WARNING: Setting --imprecise timers with --replay-preemptions-from is probably not what you want. They won't replay precisely."
826            );
827        }
828
829        if self.stop_after_turn.is_some() && !self.sequentialize_threads {
830            eprintln!(
831                "WARNING: --stop-after-turn will have no effect if --no-sequentialize-threads is enabled"
832            );
833            self.stop_after_turn = None;
834        }
835        if self.stop_after_iter.is_some() && !self.sequentialize_threads {
836            eprintln!(
837                "WARNING: --stop-after-iter will have no effect if --no-sequentialize-threads is enabled"
838            );
839            self.stop_after_iter = None;
840        }
841
842        if self.debug_externalize_sockets && !self.sequentialize_threads {
843            eprintln!(
844                "WARNING: --debug-externalize-sockets will have no effect if --no-sequentialize-threads is enabled"
845            );
846            self.debug_externalize_sockets = false;
847        }
848
849        if !self.stacktrace_event.is_empty()
850            && !self.record_preemptions
851            && self.replay_schedule_from.is_none()
852        {
853            eprintln!(
854                "WARNING: -s/--stacktrace-event has no effect if not recording/replaying events!"
855            );
856        }
857
858        if self.preemption_stacktrace_log_file.is_some() {
859            self.preemption_stacktrace = true;
860        }
861    }
862
863    /// Should we use RCB in computing logical time?
864    ///
865    /// The answer is NO either if `--no-rcb-time` is specified or if HW counters are disabled by
866    /// setting `--max-timeslice=disabled`.
867    pub fn use_rcb_time(&self) -> bool {
868        self.max_timeslice.is_some() && !self.no_rcb_time
869    }
870
871    /// Should we convert sockets to SOCK_NONBLOCK?
872    pub fn use_nonblocking_sockets(&self) -> bool {
873        self.sequentialize_threads && !self.debug_externalize_sockets
874    }
875
876    /// Should we call trace_schedevent to trace each SchedEvent?
877    /// This applies to both record and replay for scheduled events.
878    pub fn should_trace_schedevent(&self) -> bool {
879        self.record_preemptions || self.replay_schedule_from.is_some()
880    }
881
882    /// Returns manual interuption points for a given thread
883    pub fn interrupts_for_thread(&self, thread_id: DetTid) -> BTreeSet<u64> {
884        self.interrupt_at
885            .iter()
886            .filter_map(|(tid, time)| {
887                if tid.eq(&thread_id) {
888                    Some(*time)
889                } else {
890                    None
891                }
892            })
893            .collect::<BTreeSet<u64>>()
894    }
895}
896
897impl fmt::Display for Config {
898    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
899        if !self.virtualize_time {
900            write!(f, " --no-virtualize-time")?;
901        }
902        if !self.virtualize_cpuid {
903            write!(f, " --no-virtualize-cpuid")?;
904        }
905        if !self.virtualize_metadata {
906            write!(f, " --no-virtualize-metadata")?;
907        }
908        if self.passthru_opt {
909            write!(f, " --passthru-opt")?;
910        }
911        match self.runs_post_fork {
912            RunsPostFork::Child => {}
913            RunsPostFork::Parent => write!(f, " --runs-post-fork=parent")?,
914            RunsPostFork::Random => write!(f, " --runs-post-fork=random")?,
915        }
916        if !self.has_default_epoch() {
917            write!(f, " --epoch={}", self.epoch.to_rfc3339())?;
918        }
919        if self.seed != 0 {
920            write!(f, " --seed={}", self.seed)?;
921        }
922
923        if let Some(rng_seed) = self.rng_seed {
924            write!(f, " --rng-seed={}", rng_seed)?;
925        }
926        if let Some(fuzz_seed) = self.fuzz_seed {
927            write!(f, " --fuzz-seed={}", fuzz_seed)?;
928        }
929
930        if self.fuzz_futexes {
931            write!(f, " --fuzz-futexes")?;
932        }
933        if self.chaos_target_races {
934            write!(f, " --chaos-target-races")?;
935        }
936        // AUTONOMOUS-BOT-IMPLEMENTED
937        // TODO-HUMAN-REVIEW(PR-1149)
938        if self.chaos_per_thread_slowdown {
939            write!(f, " --chaos-per-thread-slowdown")?;
940            write!(
941                f,
942                " --chaos-slowdown-max-factor={}",
943                self.chaos_slowdown_max_factor
944            )?;
945            // AUTONOMOUS-BOT-IMPLEMENTED
946            // TODO-HUMAN-REVIEW(PR-1151)
947            if self.chaos_epoch_length_ns > 0 {
948                write!(f, " --chaos-epoch-length-ns={}", self.chaos_epoch_length_ns)?;
949            }
950        }
951        if let Some(m) = self.clock_multiplier {
952            write!(f, " --clock-multiplier={}", m)?;
953        }
954        if self.imprecise_timers {
955            write!(f, " --imprecise-timers")?;
956        }
957        if self.chaos {
958            write!(f, " --chaos")?;
959        }
960        if self.record_preemptions {
961            write!(f, " --record-preemptions")?;
962        }
963
964        if let Some(p) = &self.record_preemptions_to {
965            let s = p.to_str().expect("valid unicode path");
966            write!(f, " --record-preemptions-to={}", shell_words::quote(s))?;
967        }
968        if let Some(p) = &self.replay_preemptions_from {
969            let s = p.to_str().expect("valid unicode path");
970            write!(f, " --replay-preemptions-from={}", shell_words::quote(s))?;
971        }
972        if let Some(p) = &self.replay_schedule_from {
973            let s = p.to_str().expect("valid unicode path");
974            write!(f, " --replay-schedule-from={}", shell_words::quote(s))?;
975        }
976        if self.replay_exhausted_panic {
977            write!(f, " --replay-exhausted-panic")?;
978        }
979        if self.die_on_desync {
980            write!(f, " --die-on-desync")?;
981        }
982        for (index, path) in &self.stacktrace_event {
983            write!(f, " --stacktrace-event={}", index)?;
984            if let Some(p) = path {
985                let s = p.to_str().expect("valid unicode path");
986                write!(f, ",{}", shell_words::quote(s))?;
987            }
988        }
989        if self.preemption_stacktrace {
990            write!(f, " --preemption-stacktrace")?;
991        }
992        if self.panic_on_unsupported_syscalls {
993            write!(f, " --panic-on-unsupported-syscalls")?;
994        }
995        if self.panic_on_rcb_overshoot {
996            write!(f, " --panic-on-rbc-overshoot")?;
997        }
998        if self.kill_daemons {
999            write!(f, " --kill-daemons")?;
1000        }
1001        if self.gdbserver {
1002            write!(f, " --gdbserver")?;
1003        }
1004        if self.gdbserver_port != /* default */ 1234u16 {
1005            write!(f, " --gdbserver-port={}", self.gdbserver_port)?;
1006        }
1007        match &self.max_timeslice {
1008            Some(x) => {
1009                if *x != NonZeroU64::new(200_000_000).unwrap() {
1010                    write!(f, " --max-timeslice={}", x)?;
1011                }
1012            }
1013            None => {
1014                write!(f, " --max-timeslice=disabled")?;
1015            }
1016        }
1017        if let Some(target_timeslice) = self.target_timeslice {
1018            write!(f, " --target-timeslice={}", target_timeslice)?;
1019        }
1020        if self.sigint_instakill {
1021            write!(f, " --sigint-instakill")?;
1022        }
1023        if self.warn_non_zero_binds {
1024            write!(f, " --warn-non-zero-binds")?;
1025        }
1026        match &self.sched_heuristic {
1027            SchedHeuristic::None => {}
1028            SchedHeuristic::ConnectBind => {
1029                write!(f, " --sched-heuristic=connectbind")?;
1030            }
1031            SchedHeuristic::Random => {
1032                write!(f, " --sched-heuristic=random")?;
1033            }
1034            SchedHeuristic::StickyRandom => {
1035                write!(f, " --sched-heuristic=stickyrandom")?;
1036            }
1037        }
1038        if let Some(s) = self.sched_seed {
1039            write!(f, " --sched-seed={}", s)?;
1040        }
1041        if self.sched_sticky_random_param != 0.0 {
1042            write!(
1043                f,
1044                " --sched-sticky-random-param={}",
1045                self.sched_sticky_random_param
1046            )?;
1047        }
1048        if let Some(t) = self.stop_after_turn {
1049            write!(f, " --stop-after-turn={}", t)?;
1050        }
1051        if let Some(i) = self.stop_after_iter {
1052            write!(f, " --stop-after-iter={}", i)?;
1053        }
1054        if self.debug_externalize_sockets {
1055            write!(f, " --debug-externalize-sockets")?;
1056        }
1057        match &self.debug_futex_mode {
1058            BlockingMode::External => {
1059                write!(f, " --debug-futex-mode=external")?;
1060            }
1061            BlockingMode::Polling => {
1062                write!(f, " --debug-futex-mode=polling")?;
1063            }
1064            BlockingMode::Precise => { /* default */ }
1065        }
1066        if self.no_rcb_time {
1067            write!(f, " --no-rcb-time")?;
1068        }
1069        if self.detlog_heap {
1070            write!(f, " --detlog-heap")?;
1071        }
1072        if self.detlog_stack {
1073            write!(f, " --detlog-stack")?;
1074        }
1075        if self.detlog_regs {
1076            write!(f, " --detlog-regs")?;
1077        }
1078        if self.detlog_regs_cadence != /* default */ 1 {
1079            write!(f, " --detlog-regs-cadence={}", self.detlog_regs_cadence)?;
1080        }
1081        if !self.detlog_io_buffers {
1082            write!(f, " --no-detlog-io-buffers")?;
1083        }
1084        if self.sysinfo_uptime_offset != /* default */ 120 {
1085            write!(f, " --sysinfo-uptime-offset={}", self.sysinfo_uptime_offset)?;
1086        }
1087        if self.memory != 1_000_000_000 {
1088            write!(f, " --memory={}", self.memory)?;
1089        }
1090        for (tid, rcb) in &self.interrupt_at {
1091            write!(f, " --interrupt-at={}:{}", tid, rcb)?;
1092        }
1093        Ok(())
1094    }
1095}
1096
1097/// Which side of an ordinary fork/clone receives the first post-registration turn.
1098#[derive(
1099    Debug,
1100    Default,
1101    Clone,
1102    Copy,
1103    Serialize,
1104    Deserialize,
1105    Parser,
1106    PartialEq,
1107    Eq
1108)]
1109pub enum RunsPostFork {
1110    /// Run the newly registered child before its parent resumes.
1111    #[default]
1112    Child,
1113    /// Allow the parent to resume before the newly registered child starts.
1114    Parent,
1115    /// Deterministically choose child-first or parent-first from the scheduler seed.
1116    Random,
1117}
1118
1119impl FromStr for RunsPostFork {
1120    type Err = String;
1121
1122    fn from_str(s: &str) -> Result<Self, Self::Err> {
1123        match s.to_lowercase().as_str() {
1124            "child" => Ok(Self::Child),
1125            "parent" => Ok(Self::Parent),
1126            "random" => Ok(Self::Random),
1127            _ => Err(format!(
1128                "Expected Child|Parent|Random, could not parse: {:?}",
1129                s
1130            )),
1131        }
1132    }
1133}
1134
1135/// How should we handle syscalls which may block, but are internal to the hermit container?
1136/// These syscalls are determinizable, but there are multiple methods of doing so.
1137/// These choices *do not* apply to blocking syscalls that wait for external conditions outside the
1138/// container, such as network responses.
1139///
1140/// Mostly it helps to switch this as: (1) a debugging aid to figure out what is going wrong with a
1141/// given guest program, or (2) in order to find the more performant mode for a given guest program.
1142#[derive(Debug, Clone, Copy, Serialize, Deserialize, Parser, PartialEq, Eq)]
1143pub enum BlockingMode {
1144    /// Handle the internal blocking syscall as though it was external, and unblocks at an
1145    /// unpredictable nondeterministic time.  These blocked threads will be parked in the
1146    /// scheduler's BlockedPool.
1147    ///
1148    /// (TODO: In the future these scheduling decisions will be recorded, and this comment needs to
1149    /// be updated accordingly.)
1150    External,
1151    /// Transform each blocking syscall into non-blocking, and then the scheduler will use that
1152    /// non-blocking form to repeatedly poll for completion of the operation.  When polling occurs
1153    /// (and the backoff policy there on) is decided by the scheduler.
1154    /// See NOTE [Blocking Syscalls via Internal Polling] in this folder.
1155    Polling,
1156    /// Precisely model the blocking and unblocking behavior inside hermit.
1157    /// TODO: This work is not completed yet for all forms of blocking syscalls.
1158    Precise,
1159}
1160
1161impl FromStr for BlockingMode {
1162    type Err = String;
1163
1164    fn from_str(s: &str) -> Result<Self, Self::Err> {
1165        match s.to_lowercase().as_str() {
1166            "polling" => Ok(BlockingMode::Polling),
1167            "precise" => Ok(BlockingMode::Precise),
1168            "external" => Ok(BlockingMode::External),
1169            _ => Err(format!(
1170                "Expected Polling|Precise|External, could not parse: {:?}",
1171                s
1172            )),
1173        }
1174    }
1175}
1176
1177#[derive(
1178    Debug,
1179    Default,
1180    Clone,
1181    Copy,
1182    Serialize,
1183    Deserialize,
1184    Parser,
1185    PartialEq,
1186    Eq
1187)]
1188/// Apply a specialized scheduling heuristic which may help exercise certain bugs.
1189pub enum SchedHeuristic {
1190    /// Don't modify the scheduling algorithm.
1191    // TODO: Is the default a round robin?
1192    #[default]
1193    None,
1194    /// Prioritize connect and deprioritize bind to exercise races
1195    ConnectBind,
1196    /// Random: Randomly pick any available thread to make progress.
1197    Random,
1198    /// Sticky Random: Randomly pick any available thread. On the next round,
1199    /// and after the thread is parked, randomly choose if we will continue
1200    /// executing on the same thread, or picking another one.
1201    StickyRandom,
1202    // TODO: make all sleeps "instant".
1203}
1204
1205// Lame to not derive this, but even `derive_more` won't do enums.
1206impl FromStr for SchedHeuristic {
1207    type Err = String;
1208
1209    fn from_str(s: &str) -> Result<Self, Self::Err> {
1210        match s.to_lowercase().as_str() {
1211            "none" | "roundrobin" => Ok(SchedHeuristic::None),
1212            "connectbind" => Ok(SchedHeuristic::ConnectBind),
1213            "random" => Ok(SchedHeuristic::Random),
1214            "stickyrandom" => Ok(SchedHeuristic::StickyRandom),
1215            _ => Err(format!(
1216                "Expected None|ConnectBind|Random|StickyRandom, could not parse: {:?}",
1217                s
1218            )),
1219        }
1220    }
1221}
1222
1223/// An optional virtual-timeslice duration. `None` disables that preemption mechanism.
1224pub type MaybeTimeslice = Option<NonZeroU64>;
1225
1226/// Deprecated name for an optional PMU-backed virtual-timeslice duration.
1227#[deprecated(note = "use MaybeTimeslice")]
1228pub type MaybePreemptionTimeout = MaybeTimeslice;
1229
1230fn parse_timeslice(src: &str) -> Result<MaybeTimeslice, ParseTimesliceError> {
1231    if let Ok(n) = src.parse::<u64>() {
1232        if n != 0 && n < NANOS_PER_RCB as u64 {
1233            Err(ParseTimesliceError::new(
1234                "PMU-backed timeslices must be at least one RCB (10 virtual nanoseconds)",
1235            ))
1236        } else {
1237            Ok(NonZeroU64::new(n))
1238        }
1239    } else {
1240        match src {
1241            "disabled" => Ok(None),
1242            _ => Err(ParseTimesliceError::new(
1243                "Unable to parse timeslice, expected disabled or a non-negative integer",
1244            )),
1245        }
1246    }
1247}
1248
1249fn parse_index_with_path(src: &str) -> Result<(u64, Option<PathBuf>), String> {
1250    let convert = |e| format!("Failed to parse int index before comma: {e}");
1251    if let Some((index_str, path)) = src.split_once(',') {
1252        let ix = index_str.parse::<u64>().map_err(convert)?;
1253        let pathbuf = PathBuf::from_str(path).map_err(|_| "the impossible happened")?;
1254        Ok((ix, Some(pathbuf)))
1255    } else {
1256        let ix = src.parse::<u64>().map_err(convert)?;
1257        Ok((ix, None))
1258    }
1259}
1260
1261#[derive(Debug)]
1262struct ParseTimesliceError {
1263    details: String,
1264}
1265
1266impl fmt::Display for ParseTimesliceError {
1267    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
1268        write!(f, "{}", self.details)
1269    }
1270}
1271
1272impl ParseTimesliceError {
1273    fn new(msg: &str) -> ParseTimesliceError {
1274        ParseTimesliceError {
1275            details: msg.to_string(),
1276        }
1277    }
1278}
1279
1280impl std::error::Error for ParseTimesliceError {
1281    fn description(&self) -> &str {
1282        &self.details
1283    }
1284}
1285
1286/// The default epoch used by DetCore for things like initial file modtimes.
1287///
1288/// N.B. Default to a reasonable date. Some programs (like zip) have trouble with the
1289/// original unix epoch (time zero).
1290pub static DEFAULT_EPOCH_STR: &str = "2026-01-01T00:00:00Z";
1291
1292/// Convert one invocation's captured host instant without losing subsecond
1293/// precision. CLI and comparison orchestration share this input conversion;
1294/// guest clock progression never reads the host clock through it.
1295pub fn epoch_from_host_time(now: SystemTime) -> DateTime<Utc> {
1296    DateTime::<Utc>::from(now)
1297}
1298
1299impl Config {
1300    /// Construct the config using environment variables only, not CLI args.
1301    pub fn from_env() -> Self {
1302        let args: [OsString; 2] = [
1303            OsString::from("CMD"), // Silly/unused.
1304            OsString::from(format!("--epoch={}", DEFAULT_EPOCH_STR)),
1305        ];
1306        Config::parse_from(args.iter())
1307    }
1308
1309    /// Returns effective "rng-seed" parameter taking in account "seed"
1310    /// parameter if former isn't specified
1311    pub fn rng_seed(&self) -> u64 {
1312        self.rng_seed.unwrap_or(self.seed)
1313    }
1314
1315    /// Returns the fuzz_seed, as specified by the user or defaulting to the primary seed if
1316    /// unspecified.
1317    pub fn fuzz_seed(&self) -> u64 {
1318        self.fuzz_seed.unwrap_or(self.seed)
1319    }
1320
1321    /// Returns effective "sched-seed" parameter taking in account "seed"
1322    /// parameter if former isn't specified
1323    pub fn sched_seed(&self) -> u64 {
1324        self.sched_seed.unwrap_or(self.seed)
1325    }
1326}
1327
1328/// N.B. we don't want to specify two different notions of "default", so we use the
1329/// `Clap` instance above.
1330/// Environment variable carrying the coordinator's [`config_wire_fingerprint`]
1331/// to an out-of-process plugin.
1332///
1333/// Named alongside the other `REVERIE_SABRE_HERMIT_*` launch variables so the
1334/// two travel together and a reader finds them in one place.
1335pub const CONFIG_FINGERPRINT_ENV: &str = "REVERIE_SABRE_HERMIT_CONFIG_FINGERPRINT";
1336
1337const CONFIG_DEFINITION_SOURCES: &[&[u8]] = &[
1338    include_bytes!("config.rs"),
1339    include_bytes!("happens_before.rs"),
1340    include_bytes!("network_trace.rs"),
1341    include_bytes!("pid.rs"),
1342    include_bytes!("schedule.rs"),
1343    include_bytes!("time.rs"),
1344];
1345
1346/// A fingerprint of this build's [`Config`] payload and the configuration and
1347/// clock RPC definitions shared by a plugin and its coordinator.
1348///
1349/// # Why this exists
1350///
1351/// An out-of-process plugin such as `libdetcore_sabre.so` is a separate Cargo
1352/// artifact that lands in the same target directory as `hermit`. Changing
1353/// `Config` or `DetTime` -- or merely switching branches -- leaves the plugin
1354/// stale while everything still *looks* built. `Config` is transferred during
1355/// the RPC handshake, and `DetTime` is the first field in every Detcore request.
1356/// A stale plugin decodes either against the wrong layout and the failure
1357/// surfaces as an opaque codec error: measured, one added `bool` field
1358/// produced `Decode(InvalidBooleanValue(20))` at connect, which points nowhere
1359/// near "your plugin is from a different build" and cost a long diagnosis while
1360/// blocking every SaBRe measurement.
1361///
1362/// # What it measures
1363///
1364/// Two encodings of `Config::default()` and the source definitions for the
1365/// configuration and clock RPC fields are fingerprinted with separate domains:
1366///
1367/// - the exact legacy-bincode bytes used by Reverie RPC, which detect changes
1368///   such as `u32` to `u64` even when both default to JSON number zero; and
1369/// - the JSON encoding, which carries every field name and makes a pure rename
1370///   visible even though bincode is positional; and
1371/// - the source files defining `Config`, its local serialized field types, and
1372///   `DetTime`, which catch wire-incompatible changes hidden by a default such
1373///   as `Option<u64>::None` to `Option<u32>::None`, or an added clock field that
1374///   leaves both encodings of `Config` unchanged.
1375///
1376/// The source and JSON domains are deliberately stricter than the wire format
1377/// strictly requires. A documentation-only edit in one of these files can
1378/// require rebuilding the plugin; missing a wire-incompatible hidden variant
1379/// can make it decode the handshake or a subsequent request at the wrong offsets.
1380fn config_wire_default() -> Config {
1381    // `Config::default()` is intentionally environment-aware through Clap.
1382    // A coordinator may therefore inherit HERMIT_EPOCH/HERMIT_PRNG or
1383    // HERMIT_SCHED_SEED even though the separately loaded plugin receives a
1384    // minimal guest environment. Those invocation values are payload data,
1385    // not wire shape, and must not make two artifacts from the same source
1386    // reject one another. Explicit arguments take precedence over Clap's
1387    // environment provider; clear the scheduler override afterward to retain
1388    // the real environment-free default of `None` in the encoded shape.
1389    let mut config = Config::parse_from([
1390        "config-wire-fingerprint",
1391        &format!("--epoch={DEFAULT_EPOCH_STR}"),
1392        "--seed=0",
1393        "--sched-seed=0",
1394    ]);
1395    config.sched_seed = None;
1396    config
1397}
1398
1399pub fn config_wire_fingerprint() -> String {
1400    let config = config_wire_default();
1401    let wire = bincode::serde::encode_to_vec(&config, bincode::config::legacy())
1402        .expect("canonical Config wire default must encode with Reverie's bincode configuration");
1403    let named_shape = serde_json::to_string(&config)
1404        .expect("canonical Config wire default must encode as JSON for field-name checking");
1405    fingerprint_of_config_material(&wire, &named_shape, CONFIG_DEFINITION_SOURCES)
1406}
1407
1408/// Domain-separated FNV-1a over wire bytes, named JSON, and defining source.
1409/// This is a mismatch detector, not a security boundary. Length-prefixing each
1410/// domain prevents two different source-file boundaries from hashing the same
1411/// concatenation.
1412fn fingerprint_of_config_material(
1413    wire: &[u8],
1414    named_shape: &str,
1415    definition_sources: &[&[u8]],
1416) -> String {
1417    let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
1418    let mut update = |domain: u8, bytes: &[u8]| {
1419        for byte in std::iter::once(&domain)
1420            .chain((bytes.len() as u64).to_le_bytes().iter())
1421            .chain(bytes.iter())
1422        {
1423            hash ^= u64::from(*byte);
1424            hash = hash.wrapping_mul(0x1000_0000_01b3);
1425        }
1426    };
1427    update(0, wire);
1428    update(1, named_shape.as_bytes());
1429    for source in definition_sources {
1430        update(2, source);
1431    }
1432    format!("{hash:016x}")
1433}
1434
1435impl Default for Config {
1436    fn default() -> Self {
1437        let v: Vec<String> = vec![];
1438        Config::parse_from(v.iter())
1439    }
1440}
1441
1442#[cfg(test)]
1443mod tests {
1444    use super::*;
1445
1446    #[test]
1447    fn default_epoch_is_2026() {
1448        assert_eq!(DEFAULT_EPOCH_STR, "2026-01-01T00:00:00Z");
1449        let epoch = DEFAULT_EPOCH_STR.parse::<DateTime<Utc>>().unwrap();
1450        assert_eq!(epoch.timestamp(), 1_767_225_600);
1451    }
1452
1453    #[test]
1454    fn resolved_epoch_is_serialized_as_an_exact_config_input() {
1455        let epoch = "2000-12-31T23:59:59.123456789Z"
1456            .parse::<DateTime<Utc>>()
1457            .unwrap();
1458        let config = Config {
1459            epoch,
1460            ..Config::default()
1461        };
1462        let encoded = serde_json::to_value(&config).unwrap();
1463        assert_eq!(
1464            encoded["epoch"],
1465            serde_json::json!("2000-12-31T23:59:59.123456789Z")
1466        );
1467        let decoded: Config = serde_json::from_value(encoded).unwrap();
1468        assert_eq!(decoded.epoch, epoch);
1469    }
1470
1471    #[test]
1472    fn default_backend_capabilities_match_instrumented_backends() {
1473        let config = Config::default();
1474        assert!(!config.backend_reports_physical_process_exits);
1475        assert!(!config.backend_serializes_fork_children);
1476        assert!(config.backend_dispatches_thread_tools);
1477        assert!(config.backend_tracks_process_children);
1478        assert!(config.backend_runs_exit_robust_list);
1479        assert!(!config.backend_requires_thread_directed_process_signals);
1480        assert!(config.backend_supports_parked_write_signal_interruption);
1481        assert!(!config.backend_virtualizes_capability_prctls);
1482        assert!(!config.backend_defers_vfork_child_registration);
1483    }
1484
1485    #[test]
1486    fn network_perturbation_seed_never_falls_back_to_scheduler_seeds() {
1487        let mut config = Config {
1488            seed: 41,
1489            sched_seed: Some(42),
1490            ..Config::default()
1491        };
1492        assert_eq!(config.network_trace.network_perturb_seed, None);
1493
1494        config.network_trace = NetworkTraceConfig {
1495            mode: crate::network_trace::NetworkTraceMode::Replay,
1496            path: Some("network.trace".into()),
1497            network_perturb_seed: Some(43),
1498        };
1499        assert_eq!(config.seed, 41);
1500        assert_eq!(config.sched_seed(), 42);
1501        assert_eq!(config.network_trace.network_perturb_seed, Some(43));
1502    }
1503
1504    #[test]
1505    fn missing_mountinfo_provenance_deserializes_as_empty() {
1506        let mut value = serde_json::to_value(Config::default()).unwrap();
1507        value
1508            .as_object_mut()
1509            .unwrap()
1510            .remove("mountinfo_root_rewrites");
1511        value
1512            .as_object_mut()
1513            .unwrap()
1514            .remove("mountinfo_device_rewrites");
1515        value.as_object_mut().unwrap().remove("mountinfo_mount_ids");
1516        value
1517            .as_object_mut()
1518            .unwrap()
1519            .remove("mountinfo_mount_ids_captured");
1520        value
1521            .as_object_mut()
1522            .unwrap()
1523            .remove("fdinfo_unlisted_mount_ids");
1524        let restored: Config = serde_json::from_value(value).unwrap();
1525        assert!(restored.mountinfo_root_rewrites.is_empty());
1526        assert!(restored.mountinfo_device_rewrites.is_empty());
1527        assert!(restored.mountinfo_mount_ids.is_empty());
1528        assert!(!restored.mountinfo_mount_ids_captured);
1529        assert!(restored.fdinfo_unlisted_mount_ids.is_empty());
1530    }
1531
1532    #[test]
1533    fn runs_post_fork_parses_all_modes_and_defaults_to_child() {
1534        assert_eq!(Config::default().runs_post_fork, RunsPostFork::Child);
1535        assert_eq!(
1536            Config::parse_from(["detcore", "--runs-post-fork=parent"]).runs_post_fork,
1537            RunsPostFork::Parent
1538        );
1539        assert_eq!(
1540            Config::parse_from(["detcore", "--runs-post-fork=random"]).runs_post_fork,
1541            RunsPostFork::Random
1542        );
1543        assert!(Config::try_parse_from(["detcore", "--runs-post-fork=invalid"]).is_err());
1544    }
1545
1546    #[test]
1547    fn panic_on_rcb_overshoot_is_opt_in_and_round_trips() {
1548        assert!(!Config::default().panic_on_rcb_overshoot);
1549
1550        let config = Config::parse_from(["detcore", "--panic-on-rbc-overshoot"]);
1551        assert!(config.panic_on_rcb_overshoot);
1552        assert!(config.to_string().contains(" --panic-on-rbc-overshoot"));
1553
1554        let alias = Config::parse_from(["detcore", "--panic-on-rcb-overshoot"]);
1555        assert!(alias.panic_on_rcb_overshoot);
1556    }
1557
1558    #[test]
1559    fn config_display_preserves_nondefault_post_fork_modes() {
1560        let mut config = Config {
1561            runs_post_fork: RunsPostFork::Parent,
1562            ..Config::default()
1563        };
1564        assert!(config.to_string().contains(" --runs-post-fork=parent"));
1565
1566        config.runs_post_fork = RunsPostFork::Random;
1567        assert!(config.to_string().contains(" --runs-post-fork=random"));
1568    }
1569
1570    // AUTONOMOUS-BOT-IMPLEMENTED
1571    // TODO-HUMAN-REVIEW(PR-1149)
1572    #[test]
1573    fn chaos_per_thread_slowdown_is_opt_in_and_round_trips() {
1574        // Off by default; the factor default is present but inert.
1575        let dflt = Config::default();
1576        assert!(!dflt.chaos_per_thread_slowdown);
1577        assert_eq!(dflt.chaos_slowdown_max_factor, 10.0);
1578        // Default (disabled) config does not emit the flags.
1579        assert!(!dflt.to_string().contains("--chaos-per-thread-slowdown"));
1580
1581        let config = Config::parse_from([
1582            "detcore",
1583            "--chaos",
1584            "--chaos-per-thread-slowdown",
1585            "--chaos-slowdown-max-factor=4.5",
1586        ]);
1587        assert!(config.chaos_per_thread_slowdown);
1588        assert_eq!(config.chaos_slowdown_max_factor, 4.5);
1589
1590        // The Display round-trips both flags into the recorded schedule artifact.
1591        let rendered = config.to_string();
1592        assert!(rendered.contains(" --chaos-per-thread-slowdown"));
1593        assert!(rendered.contains(" --chaos-slowdown-max-factor=4.5"));
1594        let reparsed = Config::parse_from(
1595            std::iter::once("detcore".to_string())
1596                .chain(rendered.split_whitespace().map(String::from)),
1597        );
1598        assert!(reparsed.chaos_per_thread_slowdown);
1599        assert_eq!(reparsed.chaos_slowdown_max_factor, 4.5);
1600    }
1601
1602    // AUTONOMOUS-BOT-IMPLEMENTED
1603    // TODO-HUMAN-REVIEW(PR-1151)
1604    #[test]
1605    fn chaos_epoch_length_is_opt_in_and_round_trips() {
1606        // Off by default (single stable factor == plain per-thread-slowdown).
1607        let dflt = Config::default();
1608        assert_eq!(dflt.chaos_epoch_length_ns, 0);
1609        assert!(!dflt.to_string().contains("--chaos-epoch-length-ns"));
1610
1611        // Epochs are only emitted alongside per-thread-slowdown.
1612        let config = Config::parse_from([
1613            "detcore",
1614            "--chaos",
1615            "--chaos-per-thread-slowdown",
1616            "--chaos-epoch-length-ns=100000",
1617        ]);
1618        assert_eq!(config.chaos_epoch_length_ns, 100000);
1619
1620        let rendered = config.to_string();
1621        assert!(rendered.contains(" --chaos-epoch-length-ns=100000"));
1622        let reparsed = Config::parse_from(
1623            std::iter::once("detcore".to_string())
1624                .chain(rendered.split_whitespace().map(String::from)),
1625        );
1626        assert_eq!(reparsed.chaos_epoch_length_ns, 100000);
1627
1628        // Without per-thread-slowdown the epoch flag is inert and not rendered.
1629        let no_slowdown = Config::parse_from(["detcore", "--chaos", "--chaos-epoch-length-ns=100"]);
1630        assert_eq!(no_slowdown.chaos_epoch_length_ns, 100);
1631        assert!(!no_slowdown.to_string().contains("--chaos-epoch-length-ns"));
1632    }
1633
1634    // AUTONOMOUS-BOT-IMPLEMENTED
1635    // TODO-HUMAN-REVIEW(PR-1149)
1636    #[test]
1637    #[should_panic(expected = "chaos_slowdown_max_factor must be finite and in")]
1638    fn validate_rejects_chaos_slowdown_max_factor_below_one() {
1639        let mut config = Config {
1640            chaos_slowdown_max_factor: 0.5,
1641            ..Default::default()
1642        };
1643        config.validate();
1644    }
1645
1646    #[test]
1647    #[should_panic(expected = "max_timeslice must be at least one RCB")]
1648    fn validate_rejects_max_timeslice_below_one_rcb() {
1649        let mut config = Config {
1650            max_timeslice: NonZeroU64::new(NANOS_PER_RCB as u64 - 1),
1651            ..Default::default()
1652        };
1653
1654        config.validate();
1655    }
1656
1657    #[test]
1658    fn validate_accepts_one_rcb_max_timeslice() {
1659        let mut config = Config {
1660            max_timeslice: NonZeroU64::new(NANOS_PER_RCB as u64),
1661            ..Default::default()
1662        };
1663
1664        config.validate();
1665    }
1666
1667    #[test]
1668    #[should_panic(expected = "clock_multiplier must be finite and positive")]
1669    fn validate_rejects_invalid_clock_multiplier() {
1670        let mut config = Config {
1671            clock_multiplier: Some(0.0),
1672            ..Default::default()
1673        };
1674        config.validate();
1675    }
1676
1677    #[test]
1678    #[should_panic(expected = "max_timeslice must be at least one RCB")]
1679    fn validate_scales_one_rcb_minimum_with_clock_multiplier() {
1680        let mut config = Config {
1681            max_timeslice: NonZeroU64::new(10),
1682            clock_multiplier: Some(2.0),
1683            ..Default::default()
1684        };
1685        config.validate();
1686    }
1687
1688    #[test]
1689    fn config_fingerprint_includes_clock_rpc_definitions() {
1690        let config = config_wire_default();
1691        let wire = bincode::serde::encode_to_vec(&config, bincode::config::legacy()).unwrap();
1692        let named_shape = serde_json::to_string(&config).unwrap();
1693        let current = config_wire_fingerprint();
1694        let clock_source = include_bytes!("time.rs").as_slice();
1695
1696        // The previous guard covered these same Config bytes and definitions,
1697        // but omitted DetTime. Adding a positional clock field could therefore
1698        // pass the handshake guard and corrupt the following request on decode.
1699        let without_clock: Vec<_> = CONFIG_DEFINITION_SOURCES
1700            .iter()
1701            .copied()
1702            .filter(|source| *source != clock_source)
1703            .collect();
1704        assert_ne!(
1705            fingerprint_of_config_material(&wire, &named_shape, &without_clock),
1706            current,
1707            "the published fingerprint must reject source inputs that omit the RPC clock"
1708        );
1709
1710        // Hold all Config material fixed and remove only the added serialized
1711        // clock field from its definition. A future clock-only change must also
1712        // invalidate the existing artifact guard, independently of config.rs.
1713        let changed_clock =
1714            include_str!("time.rs").replacen("    inherited_nanos: LogicalDuration,", "", 1);
1715        assert_ne!(changed_clock.as_bytes(), clock_source);
1716        let changed_sources: Vec<_> = CONFIG_DEFINITION_SOURCES
1717            .iter()
1718            .map(|source| {
1719                if *source == clock_source {
1720                    changed_clock.as_bytes()
1721                } else {
1722                    *source
1723                }
1724            })
1725            .collect();
1726        assert_ne!(
1727            fingerprint_of_config_material(&wire, &named_shape, &changed_sources),
1728            current,
1729            "a clock-only serialized field change must invalidate the fingerprint"
1730        );
1731    }
1732
1733    #[test]
1734    fn config_fingerprint_is_stable_and_shape_sensitive() {
1735        // STABLE: a build must agree with itself, or the guard would reject a
1736        // MATCHED pair -- which would be worse than having no guard at all.
1737        assert_eq!(config_wire_fingerprint(), config_wire_fingerprint());
1738        assert_eq!(config_wire_fingerprint().len(), 16);
1739
1740        let config = config_wire_default();
1741        let base = serde_json::to_string(&config).unwrap();
1742        let wire = bincode::serde::encode_to_vec(&config, bincode::config::legacy()).unwrap();
1743        assert_eq!(
1744            fingerprint_of_config_material(&wire, &base, CONFIG_DEFINITION_SOURCES),
1745            config_wire_fingerprint()
1746        );
1747
1748        // SHAPE-SENSITIVE, checked on the same mechanism the real function uses.
1749        // One added field is exactly the change that caused the outage.
1750        let with_extra_field = format!("{},\"a_new_flag\":false}}", &base[..base.len() - 1]);
1751        assert_ne!(
1752            fingerprint_of_config_material(&wire, &with_extra_field, CONFIG_DEFINITION_SOURCES),
1753            config_wire_fingerprint()
1754        );
1755        // A removed field.
1756        let removed = base.replacen("\"virtualize_time\":true,", "", 1);
1757        assert_ne!(
1758            fingerprint_of_config_material(&wire, &removed, CONFIG_DEFINITION_SOURCES),
1759            config_wire_fingerprint()
1760        );
1761        // A pure rename, which bincode would tolerate but which we still refuse.
1762        let renamed = base.replacen("\"virtualize_time\"", "\"virtualise_time\"", 1);
1763        assert_ne!(
1764            fingerprint_of_config_material(&wire, &renamed, CONFIG_DEFINITION_SOURCES),
1765            config_wire_fingerprint()
1766        );
1767
1768        // The counterexample the JSON-only fingerprint missed: serde_json emits
1769        // the same text for integer zero regardless of width, but legacy bincode
1770        // changes the payload width. A stale peer would decode every following
1771        // field at the wrong offset.
1772        #[derive(Serialize)]
1773        struct U32Field {
1774            field: u32,
1775        }
1776        #[derive(Serialize)]
1777        struct U64Field {
1778            field: u64,
1779        }
1780        let u32_value = U32Field { field: 0 };
1781        let u64_value = U64Field { field: 0 };
1782        let u32_json = serde_json::to_string(&u32_value).unwrap();
1783        let u64_json = serde_json::to_string(&u64_value).unwrap();
1784        assert_eq!(
1785            u32_json, u64_json,
1786            "the planted JSON collision must be real"
1787        );
1788        let u32_wire =
1789            bincode::serde::encode_to_vec(&u32_value, bincode::config::legacy()).unwrap();
1790        let u64_wire =
1791            bincode::serde::encode_to_vec(&u64_value, bincode::config::legacy()).unwrap();
1792        assert_ne!(u32_wire, u64_wire, "the planted wire retype must be real");
1793        assert_ne!(
1794            fingerprint_of_config_material(&u32_wire, &u32_json, &[b"struct S { field: u32 }"]),
1795            fingerprint_of_config_material(&u64_wire, &u64_json, &[b"struct S { field: u64 }"]),
1796            "a wire-incompatible integer retype must change the fingerprint"
1797        );
1798
1799        // Defaults can hide an incompatible inner type in BOTH value encodings.
1800        // The definition source is therefore load-bearing, not decorative.
1801        #[derive(Serialize)]
1802        struct OptionalU32 {
1803            field: Option<u32>,
1804        }
1805        #[derive(Serialize)]
1806        struct OptionalU64 {
1807            field: Option<u64>,
1808        }
1809        let optional_u32 = OptionalU32 { field: None };
1810        let optional_u64 = OptionalU64 { field: None };
1811        let optional_u32_json = serde_json::to_string(&optional_u32).unwrap();
1812        let optional_u64_json = serde_json::to_string(&optional_u64).unwrap();
1813        assert_eq!(optional_u32_json, optional_u64_json);
1814        let optional_u32_wire =
1815            bincode::serde::encode_to_vec(&optional_u32, bincode::config::legacy()).unwrap();
1816        let optional_u64_wire =
1817            bincode::serde::encode_to_vec(&optional_u64, bincode::config::legacy()).unwrap();
1818        assert_eq!(
1819            optional_u32_wire, optional_u64_wire,
1820            "the planted default must be invisible in both value encodings"
1821        );
1822        assert_ne!(
1823            fingerprint_of_config_material(
1824                &optional_u32_wire,
1825                &optional_u32_json,
1826                &[b"struct S { field: Option<u32> }"]
1827            ),
1828            fingerprint_of_config_material(
1829                &optional_u64_wire,
1830                &optional_u64_json,
1831                &[b"struct S { field: Option<u64> }"]
1832            ),
1833            "a hidden wire-incompatible inner-type change must alter the fingerprint"
1834        );
1835    }
1836
1837    #[test]
1838    fn config_fingerprint_uses_environment_free_wire_defaults() {
1839        let environment_derived = Config {
1840            epoch: "2042-03-04T05:06:07.890123456Z".parse().unwrap(),
1841            seed: 41,
1842            sched_seed: Some(42),
1843            ..Config::default()
1844        };
1845
1846        let canonical = config_wire_default();
1847
1848        assert_ne!(
1849            serde_json::to_string(&environment_derived).unwrap(),
1850            serde_json::to_string(&canonical).unwrap()
1851        );
1852        assert_eq!(
1853            canonical.epoch,
1854            DEFAULT_EPOCH_STR.parse::<DateTime<Utc>>().unwrap()
1855        );
1856        assert_eq!(canonical.seed, 0);
1857        assert_eq!(canonical.sched_seed, None);
1858    }
1859
1860    // AUTONOMOUS-BOT-IMPLEMENTED
1861    // TODO-HUMAN-REVIEW(PR-1151)
1862    #[test]
1863    #[should_panic(expected = "max_timeslice must be at least one RCB")]
1864    fn validate_scales_one_rcb_minimum_with_chaos_slowdown() {
1865        let mut config = Config {
1866            chaos: true,
1867            chaos_per_thread_slowdown: true,
1868            chaos_slowdown_max_factor: 4.0,
1869            max_timeslice: NonZeroU64::new(39),
1870            ..Default::default()
1871        };
1872        config.validate();
1873    }
1874
1875    // AUTONOMOUS-BOT-IMPLEMENTED
1876    // TODO-HUMAN-REVIEW(PR-1151)
1877    #[test]
1878    #[should_panic(expected = "chaos_slowdown_max_factor must be finite and in")]
1879    fn validate_rejects_unrepresentable_chaos_slowdown_factor() {
1880        let mut config = Config {
1881            chaos_slowdown_max_factor: RcbTimeMultiplier::MAX * 2.0,
1882            ..Default::default()
1883        };
1884        config.validate();
1885    }
1886}