Skip to main content

detcore_model/
lib.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//! Widely-shared type definitions.
10
11pub mod backend_engagement;
12pub mod build_info;
13pub mod canonical_test_epoch;
14pub mod cpu_profile;
15pub mod host_capability;
16
17/// Exit status for a run HERMIT DELIBERATELY REFUSED, as distinct from one
18/// where hermit itself broke.
19///
20/// ⚠️ "REFUSED" AND "BROKE" DEMAND OPPOSITE RESPONSES, AND THEY WERE THE SAME
21/// NUMBER. A fail-closed policy stopping a run is hermit working correctly: the
22/// operator must read the refusal and change their program or their flags.
23/// `HERMIT_INTERNAL_FAILURE_EXIT` (125) says hermit is broken and the operator
24/// must file a bug. Reporting the first as the second sends a reader looking
25/// for a defect in a shutdown path that behaved exactly as designed.
26///
27/// ⚠️ WHY IT IS NOT 1, WHICH IS WHAT THIS PATH USED TO EXIT.
28/// `unrecoverable_shutdown` called `std::process::exit(1)`, so the container
29/// child exited 1 and the parent's classifier — whose arm is documented "the
30/// child died with a status it did not pick" — turned a status hermit HAD
31/// picked into `class=container-child-exit` and 125. Restoring 1 would fix the
32/// misclassification and reintroduce a worse one: 1 is the commonest guest exit
33/// status, so it cannot distinguish "hermit refused" from "your program
34/// returned 1".
35///
36/// ⚠️ WHY 122. It sits immediately below the reserved band (123 log byte cap --
37/// hermit's `--max-log-bytes`, and the value reserved for `bin/safehermit`'s
38/// log-cap kill, which still exits 125 -- then 124 deadline, 125 hermit broke,
39/// 126/127 GNU exec-level), keeping the reserved codes contiguous, and it is
40/// the cheapest possible narrowing of the guest range. See the full allocation
41/// above the constants in `hermit-cli/src/lib.rs`.
42///
43/// ⚠️ IT LIVES IN `detcore-model` BECAUSE BOTH SIDES NEED IT. `detcore` emits it
44/// and `hermit-cli` recognises it; `detcore-model` is the only crate both
45/// depend on. A copy on each side is exactly the defect that left eight cli
46/// tests asserting a stale exit status for a day after the product moved.
47pub const HERMIT_POLICY_REFUSAL_EXIT: i32 = 122;
48
49// ⚠️ THE VALUE IS PINNED, NOT ONLY NAMED. `tests/cli.rs` and the allocation
50// table both assert 122; a one-character edit here would move every consumer
51// with it and nothing would fail. 0 is called out separately because it is the
52// dangerous drift: at 0 a refusal would report SUCCESS.
53const _: () = assert!(
54    HERMIT_POLICY_REFUSAL_EXIT == 122,
55    "122 keeps the reserved band contiguous below 123/124/125/126/127"
56);
57const _: () = assert!(
58    HERMIT_POLICY_REFUSAL_EXIT != 0,
59    "a refusal exiting 0 would report success"
60);
61
62/// Exit status for a run that DEADLOCKED UNDER HERMIT'S MODEL: Detcore's
63/// scheduler found every remaining guest thread blocked on something no later
64/// turn can supply (its "Deadlock detected" report) and ended the run.
65///
66/// ⚠️ A VERDICT OF HERMIT'S MODEL, NOT THE GUEST'S RESULT. "No possible wake"
67/// means no wake that Detcore models: under Hermit's sequentialized virtual
68/// time "no thread can be woken now" is also "no thread can ever be woken" BY
69/// ANYTHING HERMIT MODELS. When the model is complete for the program, Linux
70/// would leave it hung forever and the hang is reported instead of reproduced.
71/// When it is not, the deadlock is Hermit's bug: the tests record robust-futex
72/// owner death, dash's sigsuspend, raw exit, KVM's kill and happens-before
73/// re-admission each once reported this way. So the status is neither a guest
74/// status nor `HERMIT_INTERNAL_FAILURE_EXIT` (125), and `--verify`, chaos-stress
75/// and `analyze` do not take it as a guest result by default. A caller that
76/// needs to know which it was compares a native run (`docs/ERROR_CATALOG.md`).
77/// Before this, the scheduler exited the container child 1 and the parent's
78/// classifier turned the deadlock into `class=container-child-exit` and 125.
79///
80/// ⚠️ ONLY A GUEST DEADLOCK EXITS THIS. The scheduler's `HERMIT_DEADLOCK:`
81/// verdicts (an invariant of Hermit's own bookkeeping broke, or a backend never
82/// reported a death the kernel owed) still exit 1 and stay internal failures,
83/// and a happens-before refusal still exits [`HERMIT_POLICY_REFUSAL_EXIT`].
84///
85/// ⚠️ WHY 121. It extends the reserved band downwards by one (121 guest
86/// deadlock, 122 refusal, 123 log cap, 124 deadline, 125 hermit broke, 126/127
87/// exec-level), so the reserved codes stay contiguous and the guest range loses
88/// only its top value. It is not a signal status (`signal_from_exit_status` is
89/// `None`), so the classifier arms cannot both match. As with every other code,
90/// a guest may still exit 121 of its own accord: the stderr line
91/// [`HERMIT_MODEL_DEADLOCK_LINE`] is the discriminator, present only when the
92/// scheduler declared the deadlock. See the allocation table above the
93/// constants in `hermit-cli/src/lib.rs`.
94///
95/// It lives here for the reason [`HERMIT_POLICY_REFUSAL_EXIT`] does: `detcore`
96/// emits it and `hermit-cli` recognises it.
97pub const HERMIT_GUEST_DEADLOCK_EXIT: i32 = 121;
98
99/// The stderr class line Hermit prints, last, for a run that ended with
100/// [`HERMIT_GUEST_DEADLOCK_EXIT`]: a deadlock under Hermit's model. Named for
101/// the model, not the guest, because the verdict is only as complete as the
102/// model (see [`HERMIT_GUEST_DEADLOCK_EXIT`]).
103pub const HERMIT_MODEL_DEADLOCK_LINE: &str =
104    "HERMIT_MODEL_DEADLOCK class=deadlock-under-hermit-model";
105
106const _: () = assert!(
107    HERMIT_GUEST_DEADLOCK_EXIT == 121,
108    "121 extends the reserved band 122..=127 downwards and keeps it contiguous"
109);
110const _: () = assert!(
111    HERMIT_GUEST_DEADLOCK_EXIT != 0
112        && HERMIT_GUEST_DEADLOCK_EXIT != 1
113        && HERMIT_GUEST_DEADLOCK_EXIT != HERMIT_POLICY_REFUSAL_EXIT,
114    "a deadlock must not read as success, as the scheduler's old exit 1, or as a refusal"
115);
116
117/// Exit status for a run whose virtual clock passed `--virtual-timeout`: 124,
118/// the status every deadline in Hermit already uses (`hermit::HERMIT_DEADLINE_EXIT`,
119/// which pins itself to this value). The stderr class line, not the status,
120/// tells it from `--timeout`'s.
121const VIRTUAL_TIMEOUT_EXIT: i32 = 124;
122
123/// How Detcore's scheduler ended a run itself, with a status and a stderr class
124/// line of its own: NEITHER KIND IS A GUEST RESULT. A deadlock is a verdict of
125/// Hermit's model (see [`HERMIT_GUEST_DEADLOCK_EXIT`]) and a virtual timeout is
126/// a bound Hermit imposed, so `--verify` (under the default
127/// `--verify-allow=success`), chaos-stress and `analyze` do not take either as
128/// the guest's outcome. Neither is a Hermit failure (125) either.
129///
130/// The scheduler hands it to the run's owner when the backend can unwind the
131/// parked guest (ptrace), and otherwise exits the process with
132/// [`TerminalOutcome::exit_status`]. Both kinds stop a run at a committed
133/// scheduler turn, so both stop it at the same point of the same schedule in
134/// every run.
135#[derive(
136    Clone,
137    Copy,
138    Debug,
139    Eq,
140    PartialEq,
141    serde::Serialize,
142    serde::Deserialize
143)]
144pub enum TerminalOutcome {
145    /// Every remaining guest thread is blocked with no wake Hermit models: a
146    /// deadlock under Hermit's model, not a guest result
147    /// ([`HERMIT_GUEST_DEADLOCK_EXIT`], [`HERMIT_MODEL_DEADLOCK_LINE`]).
148    GuestDeadlock,
149    /// The run's virtual clock passed `--virtual-timeout`: at a committed
150    /// scheduler turn, Detcore's global virtual time was more than `bound_ns`
151    /// virtual nanoseconds past the run's start (the time a run summary
152    /// reports as `virttime_elapsed`). Exits 124, as a deadline does. A bound
153    /// Hermit imposed, not a guest result.
154    VirtualTimeout {
155        /// The configured bound, in virtual nanoseconds.
156        bound_ns: u64,
157    },
158}
159
160impl TerminalOutcome {
161    /// The status a run with this outcome ends with.
162    pub const fn exit_status(self) -> i32 {
163        match self {
164            Self::GuestDeadlock => HERMIT_GUEST_DEADLOCK_EXIT,
165            Self::VirtualTimeout { .. } => VIRTUAL_TIMEOUT_EXIT,
166        }
167    }
168
169    /// The machine-readable stderr line Hermit ends such a run with, which
170    /// tells it from a guest that exited the same status of its own accord
171    /// and, for a virtual timeout, from `--timeout`'s `class=run-timeout`.
172    pub fn class_line(self) -> String {
173        match self {
174            Self::GuestDeadlock => HERMIT_MODEL_DEADLOCK_LINE.to_owned(),
175            Self::VirtualTimeout { bound_ns } => {
176                format!("HERMIT_RUN_TIMEOUT class=virtual-timeout bound_ns={bound_ns}")
177            }
178        }
179    }
180
181    /// The `(guest_deadlock, virtual_timeout_ns)` pair a run summary and a
182    /// verification record store an outcome as: a deadlock keeps the
183    /// `guest_deadlock` flag it has always been stored as.
184    pub fn to_fields(outcome: Option<Self>) -> (bool, Option<u64>) {
185        match outcome {
186            None => (false, None),
187            Some(Self::GuestDeadlock) => (true, None),
188            Some(Self::VirtualTimeout { bound_ns }) => (false, Some(bound_ns)),
189        }
190    }
191
192    /// The outcome [`TerminalOutcome::to_fields`] stored.
193    pub fn from_fields(guest_deadlock: bool, virtual_timeout_ns: Option<u64>) -> Option<Self> {
194        match (guest_deadlock, virtual_timeout_ns) {
195            (true, _) => Some(Self::GuestDeadlock),
196            (false, Some(bound_ns)) => Some(Self::VirtualTimeout { bound_ns }),
197            (false, None) => None,
198        }
199    }
200}
201
202const _: () = assert!(
203    signal_from_exit_status(VIRTUAL_TIMEOUT_EXIT).is_none()
204        && VIRTUAL_TIMEOUT_EXIT != HERMIT_GUEST_DEADLOCK_EXIT
205        && VIRTUAL_TIMEOUT_EXIT != HERMIT_POLICY_REFUSAL_EXIT,
206    "a virtual timeout must not read as a signal death, a deadlock or a refusal"
207);
208
209/// The shell's base for "killed by signal N": a process killed by signal `N` is
210/// conventionally reported as `128 + N`.
211pub const SIGNAL_EXIT_BASE: i32 = 128;
212
213/// The status a signal-terminated run reports, `128 + signo`.
214///
215/// ⚠️ A SIGNAL DEATH IS NOT A POLICY REFUSAL, AND FOR ONE RELEASE IT WAS SPELLED
216/// AS ONE. `sigint_instakill` is one of four `unrecoverable_shutdown` callers.
217/// The other three are fail-closed policy decisions where
218/// `HERMIT_POLICY_REFUSAL_EXIT` is right: hermit examined the run and refused it.
219/// This one is an OPERATOR INTERRUPT — hermit refused nothing, somebody pressed
220/// Ctrl-C — so reporting it as a refusal tells the reader hermit made a decision
221/// it did not make. hermit#2659 moved all four off `exit(1)` together, which was
222/// an improvement for all four (they had been reported as `125`, "hermit broke")
223/// and correct for only three.
224///
225/// ⚠️ IT ALSO PUT TWO MEANINGS ON 122, WHICH IS THE DEFECT THIS REMOVES. With the
226/// SIGINT path exiting `HERMIT_POLICY_REFUSAL_EXIT`, 122 meant both "a fail-closed
227/// policy stopped the run" and "the operator killed it" — a legible number with
228/// two conditions behind it, which is the exact family `125` was split up to end.
229///
230/// ⚠️ WHY THIS IS NOT A GENUINE `WIFSIGNALED` STATUS. The faithful way to honour a
231/// signal is restore `SIG_DFL`, unblock, re-raise — and it is measurably
232/// unavailable here for the same reason it is unavailable to
233/// `on_container_init_stop_signal`: a self-sent signal does not come from an
234/// ancestor namespace, so a namespace init discards it exactly like the original
235/// and survives. `128 + signo` is the closest honest approximation, and it is the
236/// spelling that function already uses — this shares its convention rather than
237/// inventing a second one.
238pub const fn signal_exit_status(signo: i32) -> i32 {
239    SIGNAL_EXIT_BASE + signo
240}
241
242/// `128 + SIGINT`. Spelled from the shared helper so it cannot drift from the
243/// band, and pinned below so it cannot drift from 130.
244pub const HERMIT_SIGINT_DEATH_EXIT: i32 = signal_exit_status(2);
245
246/// The highest signal number this recognises, `SIGRTMAX` on Linux.
247const MAX_SIGNO: i32 = 64;
248
249/// Recover the signal from a `128 + signo` status, or `None` if it is not in the
250/// band.
251///
252/// ⚠️ THE PARENT NEEDS THIS BECAUSE THE CHILD CANNOT SEND A REAL SIGNAL STATUS.
253/// Without it the classifier's catch-all treats every non-refusal child exit as
254/// unaccounted and reports `125`, so making the SIGINT path exit `130` on its own
255/// would have moved Ctrl-C from "hermit refused" to "hermit broke" — worse than
256/// what it replaced. Emitting the code and recognising the code are one change.
257///
258/// ⚠️ IT IS DELIBERATELY A BAND AND NOT A SINGLE VALUE. `sigint_instakill` is not
259/// the only producer: `on_container_init_stop_signal` already exits
260/// `128 + signo` for SIGTERM, SIGINT and SIGHUP (143, 130, 129), so keying on 130
261/// alone would leave the other two misreported as internal failures.
262///
263/// The upper bound is `SIGRTMAX`; above it, a status is a guest's own number and
264/// not a signal this can honestly name.
265pub const fn signal_from_exit_status(status: i32) -> Option<i32> {
266    if status > SIGNAL_EXIT_BASE && status <= SIGNAL_EXIT_BASE + MAX_SIGNO {
267        Some(status - SIGNAL_EXIT_BASE)
268    } else {
269        None
270    }
271}
272
273// ⚠️ THE ROUND TRIP IS PINNED, INCLUDING THE EDGES. 128 is excluded (there is no
274// signal 0) and the refusal code must not be readable as a signal, or the two
275// classifier arms would both match and the order would silently decide meaning.
276const _: () = assert!(matches!(signal_from_exit_status(130), Some(2)));
277const _: () = assert!(matches!(signal_from_exit_status(143), Some(15)));
278const _: () = assert!(signal_from_exit_status(SIGNAL_EXIT_BASE).is_none());
279// ⚠️ THE UPPER EDGE, WHICH WAS UNPINNED WHILE THIS COMMENT CLAIMED IT WAS NOT.
280// agent(hermit-005)'s codex lane proved it vacuous: `MAX_SIGNO` 64 -> 15 left
281// every test green, so nothing held the top of the band. 192 is `128 + SIGRTMAX`
282// and is the last status that IS a signal; 193 is the first that is not and
283// belongs to the guest. A band needs both edges or it has one.
284const _: () = assert!(
285    matches!(
286        signal_from_exit_status(SIGNAL_EXIT_BASE + MAX_SIGNO),
287        Some(MAX_SIGNO)
288    ),
289    "128 + SIGRTMAX is the last status in the band"
290);
291const _: () = assert!(
292    signal_from_exit_status(SIGNAL_EXIT_BASE + MAX_SIGNO + 1).is_none(),
293    "above SIGRTMAX a status is the guest's own number, not a signal"
294);
295const _: () = assert!(
296    signal_from_exit_status(HERMIT_POLICY_REFUSAL_EXIT).is_none(),
297    "a policy refusal must not also parse as a signal death"
298);
299const _: () = assert!(
300    signal_from_exit_status(HERMIT_GUEST_DEADLOCK_EXIT).is_none(),
301    "a deadlock under Hermit's model must not also parse as a signal death"
302);
303
304// ⚠️ PINNED AGAINST BOTH FAILURE DIRECTIONS. The first pin catches the band
305// moving; the second catches the far worse edit, because the whole point of the
306// change is that these two values are DIFFERENT. If a refactor ever collapsed
307// them the build fails here rather than silently restoring the ambiguity.
308const _: () = assert!(
309    HERMIT_SIGINT_DEATH_EXIT == 130,
310    "128 + SIGINT(2); the shell convention every other tool reports"
311);
312const _: () = assert!(
313    HERMIT_SIGINT_DEATH_EXIT != HERMIT_POLICY_REFUSAL_EXIT,
314    "a signal death and a policy refusal must not share a code -- that collision is the defect"
315);
316
317pub mod access_trace;
318pub mod collections;
319pub mod config;
320pub mod container_mounts;
321pub mod fd;
322pub mod futex;
323pub mod happens_before;
324pub mod host_input;
325pub mod liteinst_initial_image;
326/// Bounded sealed native target catalog data, without admission authority.
327#[cfg(feature = "native-exec")]
328pub mod liteinst_native_exec_catalog;
329pub mod liteinst_native_exec_input;
330/// Exact versioned neutral cut envelope outside opaque Tool state.
331pub mod liteinst_native_exec_state;
332pub mod logdiff_policy;
333pub mod network_engine;
334pub mod network_trace;
335pub mod pedigree;
336pub mod pid;
337pub mod procfs;
338pub mod replay_departure;
339pub mod schedule;
340pub mod summary;
341pub mod syscall_histogram;
342pub mod time;
343pub mod virtual_timeout;
344
345/// Exact native runtime/dispatch-sequence envelope, separate from admission.
346pub mod liteinst_native_exec_runtime;
347
348#[cfg(feature = "native-exec")]
349pub mod liteinst_native_exec_sources;
350
351#[cfg(test)]
352mod terminal_outcome_tests {
353    use super::*;
354
355    /// Each terminal outcome has its own status and class line. A guest
356    /// deadlock keeps exactly the ones it had (121, [`HERMIT_MODEL_DEADLOCK_LINE`]); a
357    /// virtual timeout exits 124 like every deadline but names its own class
358    /// and bound, so `--timeout`'s `class=run-timeout` is never confused with
359    /// it. Both survive the summary and record fields they are stored as.
360    #[test]
361    fn terminal_outcomes_carry_their_status_and_class_line() {
362        let deadlock = TerminalOutcome::GuestDeadlock;
363        assert_eq!(deadlock.exit_status(), 121);
364        assert_eq!(
365            deadlock.class_line(),
366            "HERMIT_MODEL_DEADLOCK class=deadlock-under-hermit-model"
367        );
368        let timeout = TerminalOutcome::VirtualTimeout {
369            bound_ns: 3_500_000_000,
370        };
371        assert_eq!(timeout.exit_status(), 124);
372        assert_eq!(
373            timeout.class_line(),
374            "HERMIT_RUN_TIMEOUT class=virtual-timeout bound_ns=3500000000"
375        );
376        assert_ne!(timeout.class_line(), "HERMIT_RUN_TIMEOUT class=run-timeout");
377        for outcome in [None, Some(deadlock), Some(timeout)] {
378            let (guest_deadlock, virtual_timeout_ns) = TerminalOutcome::to_fields(outcome);
379            assert_eq!(
380                TerminalOutcome::from_fields(guest_deadlock, virtual_timeout_ns),
381                outcome
382            );
383        }
384        assert_eq!(TerminalOutcome::to_fields(Some(deadlock)), (true, None));
385    }
386}