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}