nextest_runner/
double_spawn.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Support for double-spawning test processes.
5//!
6//! Nextest has experimental support on Unix for spawning test processes twice, to enable better
7//! isolation and solve some thorny issues.
8//!
9//! ## Issues this currently solves
10//!
11//! ### `posix_spawn` SIGTSTP race
12//!
13//! It's been empirically observed that if nextest receives a `SIGTSTP` (Ctrl-Z) while it's running,
14//! it can get completely stuck sometimes. This is due to a race between the child being spawned and it
15//! receiving a `SIGTSTP` signal.
16//!
17//! For more details, see [this
18//! message](https://sourceware.org/pipermail/libc-help/2022-August/006263.html) on the glibc-help
19//! mailing list.
20//!
21//! To solve this issue, we do the following:
22//!
23//! 1. In the main nextest runner process, using `DoubleSpawnContext`, block `SIGTSTP` in the
24//!    current thread (using `pthread_sigmask`) before spawning the stub child cargo-nextest
25//!    process.
26//! 2. In the stub child process, unblock `SIGTSTP`.
27//!
28//! With this approach, the race condition between posix_spawn and `SIGTSTP` no longer exists.
29
30use std::path::Path;
31
32/// Information about double-spawning processes. This determines whether a process will be
33/// double-spawned.
34///
35/// This is used by the main nextest process.
36#[derive(Clone, Debug)]
37pub struct DoubleSpawnInfo {
38    inner: imp::DoubleSpawnInfo,
39}
40
41impl DoubleSpawnInfo {
42    /// The name of the double-spawn subcommand, used throughout nextest.
43    pub const SUBCOMMAND_NAME: &'static str = "__double-spawn";
44
45    /// Attempts to enable double-spawning and returns a new `DoubleSpawnInfo`.
46    ///
47    /// If double-spawning is not available, [`current_exe`](Self::current_exe) returns `None`.
48    pub fn try_enable() -> Self {
49        Self {
50            inner: imp::DoubleSpawnInfo::try_enable(),
51        }
52    }
53
54    /// Returns a `DoubleSpawnInfo` which doesn't perform any double-spawning.
55    pub fn disabled() -> Self {
56        Self {
57            inner: imp::DoubleSpawnInfo::disabled(),
58        }
59    }
60
61    /// Returns the current executable, if one is available.
62    ///
63    /// If `None`, double-spawning is not used.
64    pub fn current_exe(&self) -> Option<&Path> {
65        self.inner.current_exe()
66    }
67
68    /// Returns a context that is meant to be obtained before spawning processes and dropped afterwards.
69    pub fn spawn_context(&self) -> Option<DoubleSpawnContext> {
70        self.current_exe().map(|_| DoubleSpawnContext::new())
71    }
72}
73
74/// Context to be used before spawning processes and dropped afterwards.
75///
76/// Returned by [`DoubleSpawnInfo::spawn_context`].
77#[derive(Debug)]
78pub struct DoubleSpawnContext {
79    // Only used for the Drop impl.
80    #[expect(dead_code)]
81    inner: imp::DoubleSpawnContext,
82}
83
84impl DoubleSpawnContext {
85    #[inline]
86    fn new() -> Self {
87        Self {
88            inner: imp::DoubleSpawnContext::new(),
89        }
90    }
91
92    /// Close the double-spawn context, dropping any changes that needed to be done to it.
93    pub fn finish(self) {}
94}
95
96/// Initialization for the double-spawn child.
97pub fn double_spawn_child_init() {
98    imp::double_spawn_child_init()
99}
100
101#[cfg(unix)]
102mod imp {
103    use super::*;
104    use nix::sys::signal::{SigSet, Signal};
105    use std::path::PathBuf;
106    use tracing::warn;
107
108    #[derive(Clone, Debug)]
109    pub(super) struct DoubleSpawnInfo {
110        current_exe: Option<PathBuf>,
111    }
112
113    impl DoubleSpawnInfo {
114        #[inline]
115        pub(super) fn try_enable() -> Self {
116            // Attempt to obtain the current exe, and warn if it couldn't be found.
117            // TODO: maybe add an option to fail?
118            // TODO: Always use /proc/self/exe directly on Linux, just make sure it's always accessible
119            let current_exe = get_current_exe().map_or_else(
120                |error| {
121                    warn!(
122                        "unable to determine current exe, will not use double-spawning \
123                        for better isolation: {error}"
124                    );
125                    None
126                },
127                Some,
128            );
129            Self { current_exe }
130        }
131
132        #[inline]
133        pub(super) fn disabled() -> Self {
134            Self { current_exe: None }
135        }
136
137        #[inline]
138        pub(super) fn current_exe(&self) -> Option<&Path> {
139            self.current_exe.as_deref()
140        }
141    }
142
143    #[cfg(target_os = "linux")]
144    fn get_current_exe() -> std::io::Result<PathBuf> {
145        static PROC_SELF_EXE: &str = "/proc/self/exe";
146
147        // Always use /proc/self/exe directly rather than trying to readlink it. Just make sure it's
148        // accessible.
149        let path = Path::new(PROC_SELF_EXE);
150        match path.symlink_metadata() {
151            Ok(_) => Ok(path.to_owned()),
152            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(std::io::Error::other(
153                "no /proc/self/exe available. Is /proc mounted?",
154            )),
155            Err(e) => Err(e),
156        }
157    }
158
159    // TODO: Add other symlinks as well, e.g. /proc/self/path/a.out on solaris/illumos.
160
161    #[cfg(not(target_os = "linux"))]
162    #[inline]
163    fn get_current_exe() -> std::io::Result<PathBuf> {
164        std::env::current_exe()
165    }
166
167    #[derive(Debug)]
168    pub(super) struct DoubleSpawnContext {
169        to_unblock: Option<SigSet>,
170    }
171
172    impl DoubleSpawnContext {
173        #[inline]
174        pub(super) fn new() -> Self {
175            // Block SIGTSTP, unblocking it in the child process. This avoids a complex race
176            // condition.
177            let mut sigset = SigSet::empty();
178            sigset.add(Signal::SIGTSTP);
179            let to_unblock = sigset.thread_block().ok().map(|()| sigset);
180            Self { to_unblock }
181        }
182    }
183
184    impl Drop for DoubleSpawnContext {
185        fn drop(&mut self) {
186            if let Some(sigset) = &self.to_unblock {
187                _ = sigset.thread_unblock();
188            }
189        }
190    }
191
192    #[inline]
193    pub(super) fn double_spawn_child_init() {
194        let mut sigset = SigSet::empty();
195        sigset.add(Signal::SIGTSTP);
196        if sigset.thread_unblock().is_err() {
197            warn!("[double-spawn] unable to unblock SIGTSTP in child");
198        }
199    }
200}
201
202#[cfg(not(unix))]
203mod imp {
204    use super::*;
205
206    #[derive(Clone, Debug)]
207    pub(super) struct DoubleSpawnInfo {}
208
209    impl DoubleSpawnInfo {
210        #[inline]
211        pub(super) fn try_enable() -> Self {
212            Self {}
213        }
214
215        #[inline]
216        pub(super) fn disabled() -> Self {
217            Self {}
218        }
219
220        #[inline]
221        pub(super) fn current_exe(&self) -> Option<&Path> {
222            None
223        }
224    }
225
226    #[derive(Debug)]
227    pub(super) struct DoubleSpawnContext {}
228
229    impl DoubleSpawnContext {
230        #[inline]
231        pub(super) fn new() -> Self {
232            Self {}
233        }
234    }
235
236    #[inline]
237    pub(super) fn double_spawn_child_init() {}
238}