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::new(
153                std::io::ErrorKind::Other,
154                "no /proc/self/exe available. Is /proc mounted?",
155            )),
156            Err(e) => Err(e),
157        }
158    }
159
160    // TODO: Add other symlinks as well, e.g. /proc/self/path/a.out on solaris/illumos.
161
162    #[cfg(not(target_os = "linux"))]
163    #[inline]
164    fn get_current_exe() -> std::io::Result<PathBuf> {
165        std::env::current_exe()
166    }
167
168    #[derive(Debug)]
169    pub(super) struct DoubleSpawnContext {
170        to_unblock: Option<SigSet>,
171    }
172
173    impl DoubleSpawnContext {
174        #[inline]
175        pub(super) fn new() -> Self {
176            // Block SIGTSTP, unblocking it in the child process. This avoids a complex race
177            // condition.
178            let mut sigset = SigSet::empty();
179            sigset.add(Signal::SIGTSTP);
180            let to_unblock = sigset.thread_block().ok().map(|()| sigset);
181            Self { to_unblock }
182        }
183    }
184
185    impl Drop for DoubleSpawnContext {
186        fn drop(&mut self) {
187            if let Some(sigset) = &self.to_unblock {
188                _ = sigset.thread_unblock();
189            }
190        }
191    }
192
193    #[inline]
194    pub(super) fn double_spawn_child_init() {
195        let mut sigset = SigSet::empty();
196        sigset.add(Signal::SIGTSTP);
197        if sigset.thread_unblock().is_err() {
198            warn!("[double-spawn] unable to unblock SIGTSTP in child");
199        }
200    }
201}
202
203#[cfg(not(unix))]
204mod imp {
205    use super::*;
206
207    #[derive(Clone, Debug)]
208    pub(super) struct DoubleSpawnInfo {}
209
210    impl DoubleSpawnInfo {
211        #[inline]
212        pub(super) fn try_enable() -> Self {
213            Self {}
214        }
215
216        #[inline]
217        pub(super) fn disabled() -> Self {
218            Self {}
219        }
220
221        #[inline]
222        pub(super) fn current_exe(&self) -> Option<&Path> {
223            None
224        }
225    }
226
227    #[derive(Debug)]
228    pub(super) struct DoubleSpawnContext {}
229
230    impl DoubleSpawnContext {
231        #[inline]
232        pub(super) fn new() -> Self {
233            Self {}
234        }
235    }
236
237    #[inline]
238    pub(super) fn double_spawn_child_init() {}
239}