Skip to main content

detcore/
race_probe.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//! Race probes: env-gated perturbation points on Hermit's own host-timed
10//! edges, for reproducing rare races in Hermit itself (docs/RACE_PROBES.md).
11//!
12//! Off unless [`RACE_PROBE_ENV`] is set to a non-empty list of
13//! `point=LO[-HI][@P]` items. At `point`, with probability `P` (default 1), a
14//! visit is perturbed: an async point waits a uniform `LO..=HI` milliseconds,
15//! and a synchronous point takes its perturbed branch (its numbers ignored).
16//! [`RACE_PROBE_SEED_ENV`] fixes the choices; without it a seed is drawn from
17//! the host clock. Either way the configuration and seed are printed once to
18//! stderr, so a failing run names its seed.
19//!
20//! `point=hold` makes every visit to an async point wait, without a time
21//! limit, until a file named after the point exists in
22//! [`RACE_PROBE_HOLD_DIR_ENV`]: a test can then act on a task while it is
23//! known to be at the point, and release it after. A held visit prints one
24//! line to stderr naming the file it waits for.
25//!
26//! Unset, every point is one read of an initialized `OnceLock` and a branch;
27//! nothing is logged, and no tracing event is emitted.
28//!
29//! A probe only moves host timing that Hermit already treats as
30//! nondeterministic input (when a backgrounded call returns, and when the
31//! scheduler samples that it has), so an outcome reached under a probe is one
32//! host timing alone could produce. Decisions are drawn in visit order from one
33//! process-wide counter, so a seed replays exactly only when the number of
34//! visits before the deciding one does not itself depend on host timing.
35
36use std::collections::BTreeMap;
37use std::path::Path;
38use std::path::PathBuf;
39use std::sync::OnceLock;
40use std::sync::atomic::AtomicU64;
41use std::sync::atomic::Ordering;
42use std::time::Duration;
43
44use crate::random::splitmix64;
45
46/// The variable that enables race probes and lists their settings.
47pub const RACE_PROBE_ENV: &str = "HERMIT_RACE_PROBE";
48
49/// The variable that fixes the seed of the race probes' choices.
50pub const RACE_PROBE_SEED_ENV: &str = "HERMIT_RACE_PROBE_SEED";
51
52/// The directory whose files release held points (`point=hold`).
53pub const RACE_PROBE_HOLD_DIR_ENV: &str = "HERMIT_RACE_PROBE_HOLD_DIR";
54
55/// How often a held visit looks for its release file.
56const HOLD_POLL: Duration = Duration::from_millis(10);
57
58/// An async wait just before a backgrounded blocking call runs.
59pub(crate) const BG_PRE_CALL: &str = "bg_pre_call";
60/// An async wait after a backgrounded call returns, before it rejoins.
61pub(crate) const BG_POST_CALL: &str = "bg_post_call";
62/// The scheduler treats a finished backgrounded call as still running on one
63/// of its checks.
64pub(crate) const BG_HARVEST_SKIP: &str = "bg_harvest_skip";
65/// An async wait after a FIFO open's staging page is mapped, before anything
66/// is written to it: a task killed by a host process then leaves the page
67/// mapped and empty, as one killed during the `mmap` does.
68pub(crate) const FIFO_STAGING_MAPPED: &str = "fifo_staging_mapped";
69/// An async wait before a task unmaps a staging page that a killed task left:
70/// a task killed by a host process then leaves the page to the next task.
71pub(crate) const FIFO_STAGING_RECLAIM: &str = "fifo_staging_reclaim";
72/// An async wait in `openat` after Detcore has named the directory a relative
73/// lookup starts from, before the kernel's open: without sequentialized
74/// threads another guest thread can replace that directory's descriptor in
75/// between, as host timing alone can let it.
76pub(crate) const PROCFS_SPELLING_RESOLVED: &str = "procfs_spelling_resolved";
77/// A wait at the end of Detcore's thread exit hook, after its last call to
78/// the global state and before the backend releases the thread's state, and
79/// with it the descriptor table it may be the last to hold: the backend runs
80/// that hook at no particular point of the guest's schedule (KVM after the
81/// parent can already wait for the child), so the release comes late, as
82/// host timing alone can make it. Only in builds with debug assertions (the
83/// debug and validate profiles), as is [`RECEIPT_DECISION`]; both wait
84/// through [`delay_on_any_backend`].
85pub(crate) const EXIT_HOOK_TAIL: &str = "exit_hook_tail";
86/// A wait in the recording of a descriptor received over `SCM_RIGHTS`,
87/// before the receipt decides how to record it
88/// (`Detcore::record_received_facts`): exited senders' exit hooks run
89/// meanwhile, as they can while a receive's probes wait for the host.
90pub(crate) const RECEIPT_DECISION: &str = "receipt_decision";
91
92/// Every probe point, for validating a configuration.
93const POINTS: [&str; 8] = [
94    BG_PRE_CALL,
95    BG_POST_CALL,
96    BG_HARVEST_SKIP,
97    FIFO_STAGING_MAPPED,
98    FIFO_STAGING_RECLAIM,
99    PROCFS_SPELLING_RESOLVED,
100    EXIT_HOOK_TAIL,
101    RECEIPT_DECISION,
102];
103
104#[derive(Clone, Copy, Debug, PartialEq)]
105struct Spec {
106    lo_ms: u64,
107    hi_ms: u64,
108    probability: f64,
109    /// Every visit waits for the point's release file (`point=hold`).
110    hold: bool,
111}
112
113#[derive(Debug)]
114struct Probes {
115    seed: u64,
116    counter: AtomicU64,
117    points: BTreeMap<String, Spec>,
118    hold_dir: Option<PathBuf>,
119}
120
121/// What a perturbed visit does.
122#[derive(Clone, Debug, PartialEq)]
123enum Perturbation {
124    /// An async point waits this long; a synchronous point takes its branch.
125    Wait(Duration),
126    /// An async point waits until this file exists.
127    Hold(PathBuf),
128}
129
130fn parse_item(item: &str) -> Result<(String, Spec), String> {
131    let (name, rest) = item
132        .split_once('=')
133        .ok_or_else(|| format!("{item:?} is not point=LO[-HI][@P]"))?;
134    if !POINTS.contains(&name) {
135        return Err(format!("unknown point {name:?}; points are {POINTS:?}"));
136    }
137    if rest == "hold" {
138        return Ok((
139            name.to_owned(),
140            Spec {
141                lo_ms: 0,
142                hi_ms: 0,
143                probability: 1.0,
144                hold: true,
145            },
146        ));
147    }
148    let (range, probability) = match rest.split_once('@') {
149        Some((range, p)) => (
150            range,
151            p.parse::<f64>()
152                .ok()
153                .filter(|p| (0.0..=1.0).contains(p))
154                .ok_or_else(|| format!("{item:?}: probability must be in [0, 1]"))?,
155        ),
156        None => (rest, 1.0),
157    };
158    let number = |text: &str| {
159        text.parse::<u64>()
160            .map_err(|_| format!("{item:?}: {text:?} is not a number of milliseconds"))
161    };
162    let (lo_ms, hi_ms) = match range.split_once('-') {
163        Some((lo, hi)) => (number(lo)?, number(hi)?),
164        None => {
165            let ms = number(range)?;
166            (ms, ms)
167        }
168    };
169    if hi_ms < lo_ms {
170        return Err(format!("{item:?}: the range is empty"));
171    }
172    Ok((
173        name.to_owned(),
174        Spec {
175            lo_ms,
176            hi_ms,
177            probability,
178            hold: false,
179        },
180    ))
181}
182
183impl Probes {
184    /// The probes `config`, `seed` and `hold_dir` (the three variables'
185    /// values) describe: `None` when probes are off. A malformed item is
186    /// reported and skipped, as is a hold without a hold directory.
187    fn from_settings(
188        config: Option<&str>,
189        seed: Option<&str>,
190        hold_dir: Option<&str>,
191    ) -> Option<Self> {
192        let config = config?;
193        let hold_dir = hold_dir.filter(|dir| !dir.is_empty()).map(PathBuf::from);
194        // A disabled probe must not create randomized hash state: even an
195        // empty HashMap initializes RandomState's thread-local random keys.
196        // These few exact names need no hashing, and insert still makes the
197        // last valid duplicate win without changing lookup or draw order.
198        let mut points = BTreeMap::new();
199        for item in config.split(',').map(str::trim).filter(|s| !s.is_empty()) {
200            match parse_item(item) {
201                Ok((_, spec)) if spec.hold && hold_dir.is_none() => {
202                    eprintln!(
203                        "race_probe: ignoring {item:?}: {RACE_PROBE_HOLD_DIR_ENV} is not set"
204                    );
205                }
206                Ok((name, spec)) => {
207                    points.insert(name, spec);
208                }
209                Err(error) => eprintln!("race_probe: ignoring {error}"),
210            }
211        }
212        if points.is_empty() {
213            return None;
214        }
215        let seed = match seed.map(str::parse::<u64>) {
216            Some(Ok(seed)) => seed,
217            _ => {
218                std::time::SystemTime::now()
219                    .duration_since(std::time::UNIX_EPOCH)
220                    .map_or(0, |d| d.as_nanos() as u64)
221                    ^ u64::from(std::process::id())
222            }
223        };
224        Some(Probes {
225            seed,
226            counter: AtomicU64::new(0),
227            points,
228            hold_dir,
229        })
230    }
231
232    /// The decision at `point` for this visit: `None` when the point is not
233    /// configured or this visit is not perturbed, otherwise what it does.
234    fn choose(&self, point: &str) -> Option<Perturbation> {
235        let spec = self.points.get(point)?;
236        if spec.hold {
237            // Configured only with a hold directory (`from_settings`).
238            return Some(Perturbation::Hold(self.hold_dir.as_ref()?.join(point)));
239        }
240        let visit = self.counter.fetch_add(1, Ordering::Relaxed);
241        let r = splitmix64(self.seed ^ splitmix64(visit));
242        let unit = (r >> 11) as f64 / (1u64 << 53) as f64;
243        if unit >= spec.probability {
244            return None;
245        }
246        let span = spec.hi_ms - spec.lo_ms;
247        let ms = spec.lo_ms
248            + if span == 0 {
249                0
250            } else {
251                splitmix64(r) % (span + 1)
252            };
253        Some(Perturbation::Wait(Duration::from_millis(ms)))
254    }
255}
256
257fn probes() -> Option<&'static Probes> {
258    static PROBES: OnceLock<Option<Probes>> = OnceLock::new();
259    PROBES
260        .get_or_init(|| {
261            let config = std::env::var(RACE_PROBE_ENV).ok()?;
262            let seed = std::env::var(RACE_PROBE_SEED_ENV).ok();
263            let hold_dir = std::env::var(RACE_PROBE_HOLD_DIR_ENV).ok();
264            let probes =
265                Probes::from_settings(Some(&config), seed.as_deref(), hold_dir.as_deref())?;
266            eprintln!(
267                "race_probe: {RACE_PROBE_ENV}={config} {RACE_PROBE_SEED_ENV}={}",
268                probes.seed
269            );
270            Some(probes)
271        })
272        .as_ref()
273}
274
275fn choose(point: &str) -> Option<Perturbation> {
276    let perturbation = probes()?.choose(point)?;
277    tracing::debug!(target: "race_probe", "race_probe: {point} perturbed ({perturbation:?})");
278    Some(perturbation)
279}
280
281/// An async wait at `point`, when configured.
282pub(crate) async fn delay(point: &str) {
283    match choose(point) {
284        Some(Perturbation::Wait(wait)) if !wait.is_zero() => tokio::time::sleep(wait).await,
285        Some(Perturbation::Hold(release)) => hold(point, &release).await,
286        _ => {}
287    }
288}
289
290/// A wait at `point`, when configured, in Detcore code that every backend
291/// runs ([`EXIT_HOOK_TAIL`], [`RECEIPT_DECISION`]). Where the tool runs
292/// inside the guest's process (`in_guest`: the in-guest backends), whose
293/// threads must touch no thread-local state a runtime keeps, it blocks the
294/// calling thread and touches none: the disabled path is only [`choose`]'s
295/// read of an initialized `OnceLock`. Elsewhere it waits on Tokio's timer
296/// where the calling task runs on a Tokio runtime (the ptrace tracer, whose
297/// one thread serves every guest task), so that only that task waits, and
298/// blocks the calling thread where none runs (KVM drives each guest
299/// process's and thread's tool on a thread of its own), which then serves
300/// that task alone.
301pub(crate) async fn delay_on_any_backend(point: &str, in_guest: bool) {
302    let Some(perturbation) = choose(point) else {
303        return;
304    };
305    let on_tokio = !in_guest && tokio::runtime::Handle::try_current().is_ok();
306    match perturbation {
307        Perturbation::Wait(wait) if wait.is_zero() => {}
308        Perturbation::Wait(wait) if on_tokio => tokio::time::sleep(wait).await,
309        Perturbation::Wait(wait) => std::thread::sleep(wait),
310        Perturbation::Hold(release) if on_tokio => hold(point, &release).await,
311        Perturbation::Hold(release) => hold_blocking(point, &release),
312    }
313}
314
315/// Waits until `release` exists, saying so on stderr unless it already does.
316async fn hold(point: &str, release: &Path) {
317    if release.exists() {
318        return;
319    }
320    eprintln!(
321        "race_probe: {point} holding until {} exists",
322        release.display()
323    );
324    while !release.exists() {
325        tokio::time::sleep(HOLD_POLL).await;
326    }
327}
328
329/// [`hold`], blocking the calling thread.
330fn hold_blocking(point: &str, release: &Path) {
331    if release.exists() {
332        return;
333    }
334    eprintln!(
335        "race_probe: {point} holding until {} exists",
336        release.display()
337    );
338    while !release.exists() {
339        std::thread::sleep(HOLD_POLL);
340    }
341}
342
343/// Whether a synchronous point takes its perturbed branch on this visit.
344pub(crate) fn perturb(point: &str) -> bool {
345    choose(point).is_some()
346}
347
348#[cfg(test)]
349mod tests {
350    use super::*;
351
352    #[test]
353    fn race_probes_are_off_unless_configured() {
354        // Unset, empty, or naming no valid point: no probes, so every point
355        // returns before drawing a decision.
356        assert!(Probes::from_settings(None, None, None).is_none());
357        assert!(Probes::from_settings(None, Some("7"), None).is_none());
358        assert!(Probes::from_settings(Some(""), Some("7"), None).is_none());
359        assert!(Probes::from_settings(Some(" , "), None, None).is_none());
360        assert!(Probes::from_settings(Some("no_such_point=5"), None, None).is_none());
361        // This test process does not set the variable, so the process-wide
362        // probes are off and no point perturbs.
363        assert!(std::env::var_os(RACE_PROBE_ENV).is_none());
364        assert!(probes().is_none());
365        for point in POINTS {
366            assert!(!perturb(point));
367            assert_eq!(choose(point), None);
368        }
369    }
370
371    #[test]
372    fn race_probe_settings_parse_as_documented() {
373        assert_eq!(
374            parse_item("bg_pre_call=300"),
375            Ok((
376                BG_PRE_CALL.to_owned(),
377                Spec {
378                    lo_ms: 300,
379                    hi_ms: 300,
380                    probability: 1.0,
381                    hold: false,
382                }
383            ))
384        );
385        assert_eq!(
386            parse_item("bg_post_call=0-50@0.25"),
387            Ok((
388                BG_POST_CALL.to_owned(),
389                Spec {
390                    lo_ms: 0,
391                    hi_ms: 50,
392                    probability: 0.25,
393                    hold: false,
394                }
395            ))
396        );
397        for bad in [
398            "bg_pre_call",
399            "bg_pre_call=x",
400            "bg_pre_call=5-2",
401            "bg_pre_call=1@2",
402            "bg_harvest_skip=0@-0.1",
403            "unknown=1",
404        ] {
405            assert!(parse_item(bad).is_err(), "{bad} parsed");
406        }
407        // A bad item is skipped, the good ones stay.
408        let probes = Probes::from_settings(
409            Some("bg_pre_call=x, bg_harvest_skip=0@0.5"),
410            Some("1"),
411            None,
412        )
413        .unwrap();
414        assert_eq!(probes.points.len(), 1);
415        assert!(probes.points.contains_key(BG_HARVEST_SKIP));
416
417        // A later valid duplicate replaces the earlier setting. A malformed
418        // duplicate is ignored and must not erase that last valid value.
419        let probes = Probes::from_settings(
420            Some("bg_pre_call=10@0,bg_pre_call=20@1,bg_pre_call=x,bg_harvest_skip=0@0.5"),
421            Some("7"),
422            None,
423        )
424        .unwrap();
425        assert_eq!(probes.points.len(), 2);
426        assert_eq!(
427            probes.points.get(BG_PRE_CALL),
428            Some(&Spec {
429                lo_ms: 20,
430                hi_ms: 20,
431                probability: 1.0,
432                hold: false,
433            })
434        );
435        assert_eq!(
436            probes.choose(BG_PRE_CALL),
437            Some(Perturbation::Wait(Duration::from_millis(20)))
438        );
439    }
440
441    #[test]
442    fn race_probe_choices_follow_probability_range_and_seed() {
443        let draw = |config: &str, seed: &str, point: &str| {
444            let probes = Probes::from_settings(Some(config), Some(seed), None).unwrap();
445            (0..1000).map(|_| probes.choose(point)).collect::<Vec<_>>()
446        };
447        assert!(
448            draw("bg_harvest_skip=0@0", "1", BG_HARVEST_SKIP)
449                .iter()
450                .all(Option::is_none)
451        );
452        assert!(
453            draw("bg_harvest_skip=0@1", "1", BG_HARVEST_SKIP)
454                .iter()
455                .all(|wait| *wait == Some(Perturbation::Wait(Duration::ZERO)))
456        );
457        // A point that is not configured never perturbs.
458        assert!(
459            draw("bg_harvest_skip=0@1", "1", BG_PRE_CALL)
460                .iter()
461                .all(Option::is_none)
462        );
463        let half = draw("bg_pre_call=10-20@0.5", "3", BG_PRE_CALL);
464        let taken = half.iter().flatten().count();
465        assert!((400..600).contains(&taken), "{taken} of 1000 at 0.5");
466        assert!(half.iter().flatten().all(|wait| {
467            matches!(wait, Perturbation::Wait(wait)
468                if (Duration::from_millis(10)..=Duration::from_millis(20)).contains(wait))
469        }));
470        // The same seed draws the same sequence; another seed does not.
471        assert_eq!(half, draw("bg_pre_call=10-20@0.5", "3", BG_PRE_CALL));
472        assert_ne!(half, draw("bg_pre_call=10-20@0.5", "4", BG_PRE_CALL));
473
474        // Missing-point lookups must not consume a visit before the actual
475        // seeded draws, including when configured points are interleaved.
476        let config = Some("bg_pre_call=10-20@0.5,bg_harvest_skip=0@0.5");
477        let with_missing = Probes::from_settings(config, Some("3"), None).unwrap();
478        let direct = Probes::from_settings(config, Some("3"), None).unwrap();
479        for _ in 0..1000 {
480            assert_eq!(with_missing.choose("no_such_point"), None);
481            assert_eq!(with_missing.choose(BG_POST_CALL), None);
482            assert_eq!(with_missing.choose(BG_PRE_CALL), direct.choose(BG_PRE_CALL));
483            assert_eq!(
484                with_missing.choose(BG_HARVEST_SKIP),
485                direct.choose(BG_HARVEST_SKIP)
486            );
487        }
488    }
489
490    #[test]
491    fn a_hold_needs_a_hold_directory_and_names_its_release_file() {
492        assert_eq!(
493            parse_item("fifo_staging_mapped=hold"),
494            Ok((
495                FIFO_STAGING_MAPPED.to_owned(),
496                Spec {
497                    lo_ms: 0,
498                    hi_ms: 0,
499                    probability: 1.0,
500                    hold: true,
501                }
502            ))
503        );
504        for bad in ["fifo_staging_mapped=hold@0.5", "fifo_staging_mapped=holds"] {
505            assert!(parse_item(bad).is_err(), "{bad} parsed");
506        }
507        // Without a directory the hold is skipped, and the other items stay.
508        let probes = Probes::from_settings(
509            Some("fifo_staging_mapped=hold,bg_harvest_skip=0"),
510            Some("1"),
511            None,
512        )
513        .unwrap();
514        assert!(!probes.points.contains_key(FIFO_STAGING_MAPPED));
515        assert!(Probes::from_settings(Some("fifo_staging_mapped=hold"), None, Some("")).is_none());
516        // With one, every visit holds on the point's own file, and draws no
517        // visit from the seeded sequence of the other points.
518        let config = Some("fifo_staging_mapped=hold,fifo_staging_reclaim=hold,bg_pre_call=1-9@0.5");
519        let holding = Probes::from_settings(config, Some("5"), Some("/hold")).unwrap();
520        let direct = Probes::from_settings(Some("bg_pre_call=1-9@0.5"), Some("5"), None).unwrap();
521        for _ in 0..100 {
522            assert_eq!(
523                holding.choose(FIFO_STAGING_MAPPED),
524                Some(Perturbation::Hold(PathBuf::from(
525                    "/hold/fifo_staging_mapped"
526                )))
527            );
528            assert_eq!(
529                holding.choose(FIFO_STAGING_RECLAIM),
530                Some(Perturbation::Hold(PathBuf::from(
531                    "/hold/fifo_staging_reclaim"
532                )))
533            );
534            assert_eq!(holding.choose(BG_PRE_CALL), direct.choose(BG_PRE_CALL));
535        }
536    }
537
538    /// A held visit returns only once its release file exists, and at once
539    /// when it already does.
540    #[tokio::test(start_paused = true)]
541    async fn a_held_visit_waits_for_its_release_file() {
542        let dir = tempfile::tempdir().unwrap();
543        let release = dir.path().join(FIFO_STAGING_MAPPED);
544        let mut held = Box::pin(hold(FIFO_STAGING_MAPPED, &release));
545        // A minute of (paused, auto-advancing) time does not release it.
546        tokio::select! {
547            _ = &mut held => panic!("released before its file existed"),
548            _ = tokio::time::sleep(Duration::from_secs(60)) => {}
549        }
550        std::fs::write(&release, b"").unwrap();
551        tokio::time::timeout(Duration::from_secs(1), held)
552            .await
553            .expect("released once its file exists");
554        tokio::time::timeout(Duration::ZERO, hold(FIFO_STAGING_MAPPED, &release))
555            .await
556            .expect("an existing file releases at once");
557    }
558}