Skip to main content

player_platform_process/
unix_process.rs

1use std::io;
2use std::os::unix::process::CommandExt;
3use std::process::Command;
4
5use nix::sys::signal::{SaFlags, SigAction, SigHandler, SigSet, Signal, sigaction};
6
7/// Places a child in a new process group and makes terminal diagnostics safe.
8///
9/// Supervised CLI children run in the background relative to the caller's
10/// terminal process group. Ignoring `SIGTTOU` before `exec` lets tools that
11/// inspect or write inherited diagnostic streams finish instead of being
12/// stopped by terminal job control. stdin remains the caller's responsibility;
13/// non-interactive callers should set it to `Stdio::null()`.
14pub fn configure_background_process_group(command: &mut Command) {
15    command.process_group(0);
16
17    // SAFETY: `pre_exec` runs after fork and before exec. The closure captures
18    // no Rust state and only invokes the async-signal-safe `sigaction` syscall
19    // with stack-owned values, so it does not allocate or touch shared locks.
20    unsafe {
21        command.pre_exec(|| {
22            let action = SigAction::new(SigHandler::SigIgn, SaFlags::empty(), SigSet::empty());
23            // `SIGTTOU` is intentionally ignored only in the child. The
24            // parent's terminal signal disposition is never changed.
25            sigaction(Signal::SIGTTOU, &action)
26                .map(|_| ())
27                .map_err(|error| io::Error::from_raw_os_error(error as i32))
28        });
29    }
30}
31
32#[cfg(test)]
33mod tests {
34    use super::configure_background_process_group;
35    use std::ffi::CString;
36    use std::fs::File;
37    use std::io::Read;
38    use std::os::unix::ffi::OsStrExt;
39    use std::process::{Command, Stdio};
40    use std::time::{Duration, Instant};
41
42    use nix::libc;
43    use nix::pty::{ForkptyResult, forkpty};
44    use nix::sys::signal::{Signal, killpg};
45    use nix::sys::termios::{LocalFlags, SetArg, tcgetattr, tcsetattr};
46    use nix::sys::wait::{WaitPidFlag, WaitStatus, waitpid};
47
48    const PTY_CONTROLLER_ENV: &str = "VESPER_PROCESS_TEST_PTY_CONTROLLER";
49
50    #[cfg(unix)]
51    #[test]
52    fn child_ignores_sigttou_before_exec() {
53        let mut command = Command::new("sh");
54        command.args(["-c", "kill -s TTOU $$"]);
55        configure_background_process_group(&mut command);
56
57        let mut child = command.spawn().expect("run SIGTTOU fixture");
58        let status = wait_with_deadline(&mut child, Duration::from_secs(2))
59            .unwrap_or_else(|| terminate_test_group(&mut child));
60
61        assert!(status.success(), "child was stopped by SIGTTOU: {status}");
62    }
63
64    #[test]
65    fn background_process_group_can_write_to_a_tostop_controlling_pty() {
66        if std::env::var_os(PTY_CONTROLLER_ENV).is_some() {
67            run_pty_controller();
68            return;
69        }
70
71        let env_program = CString::new("/usr/bin/env").expect("static env path has no NUL");
72        let controller_env = CString::new(format!("{PTY_CONTROLLER_ENV}=1"))
73            .expect("controller environment has no NUL");
74        let executable_path = std::env::current_exe().expect("locate test executable");
75        let executable = CString::new(executable_path.as_os_str().as_bytes())
76            .expect("test executable path has no NUL");
77        let exact = CString::new("--exact").expect("static argument has no NUL");
78        let test_name = CString::new(
79            "unix_process::tests::background_process_group_can_write_to_a_tostop_controlling_pty",
80        )
81        .expect("static test name has no NUL");
82        let nocapture = CString::new("--nocapture").expect("static argument has no NUL");
83        let argv = [
84            env_program.as_ptr(),
85            controller_env.as_ptr(),
86            executable.as_ptr(),
87            exact.as_ptr(),
88            test_name.as_ptr(),
89            nocapture.as_ptr(),
90            std::ptr::null(),
91        ];
92
93        // SAFETY: forkpty is called only to create an isolated controller. The
94        // child immediately invokes async-signal-safe execv with pointers built
95        // before fork, and calls _exit if exec fails. It does not allocate or
96        // access Rust synchronization state after fork.
97        let fork = unsafe { forkpty(None, None) }.expect("fork PTY controller");
98        let (controller, master) = match fork {
99            ForkptyResult::Child => {
100                // SAFETY: every pointer in argv refers to a live, NUL-terminated
101                // CString created before fork, and argv itself has a trailing null.
102                // execv and _exit are async-signal-safe; _exit runs only when execv
103                // fails and terminates the child without unwinding Rust state.
104                unsafe {
105                    libc::execv(env_program.as_ptr(), argv.as_ptr());
106                    libc::_exit(127);
107                }
108            }
109            ForkptyResult::Parent { child, master } => (child, master),
110        };
111        let output_reader = std::thread::spawn(move || {
112            let master = File::from(master);
113            let mut output = Vec::new();
114            let result = master.take(64 * 1024).read_to_end(&mut output);
115            if let Err(error) = result
116                && error.raw_os_error() != Some(libc::EIO)
117            {
118                panic!("read PTY controller output: {error}");
119            }
120            output
121        });
122        let status = wait_for_pty_controller(controller, Duration::from_secs(5));
123        let controller_output = output_reader.join().expect("join PTY output reader");
124        assert!(
125            matches!(status, WaitStatus::Exited(_, 0)),
126            "PTY controller failed with {status:?}: {}",
127            String::from_utf8_lossy(&controller_output)
128        );
129    }
130
131    fn run_pty_controller() {
132        let stdin = std::io::stdin();
133        let mut termios = tcgetattr(&stdin).expect("read PTY terminal settings");
134        termios.local_flags.insert(LocalFlags::TOSTOP);
135        tcsetattr(&stdin, SetArg::TCSANOW, &termios).expect("enable TOSTOP on controlling PTY");
136
137        let mut command = Command::new("sh");
138        command
139            .args(["-c", "printf 'background diagnostic\\n' >&2"])
140            .stdin(Stdio::null())
141            .stdout(Stdio::null())
142            .stderr(Stdio::inherit());
143        configure_background_process_group(&mut command);
144
145        let mut child = command.spawn().expect("start background PTY writer");
146        let status = wait_with_deadline(&mut child, Duration::from_secs(2))
147            .unwrap_or_else(|| terminate_test_group(&mut child));
148        assert!(
149            status.success(),
150            "background PTY writer did not exit successfully: {status}"
151        );
152    }
153
154    fn wait_for_pty_controller(controller: nix::unistd::Pid, timeout: Duration) -> WaitStatus {
155        let deadline = Instant::now() + timeout;
156        loop {
157            match waitpid(
158                controller,
159                Some(WaitPidFlag::WNOHANG | WaitPidFlag::WUNTRACED),
160            )
161            .expect("poll PTY controller")
162            {
163                WaitStatus::StillAlive if Instant::now() < deadline => {
164                    std::thread::sleep(Duration::from_millis(10));
165                }
166                WaitStatus::StillAlive => return terminate_pty_controller(controller),
167                status @ (WaitStatus::Exited(_, _)
168                | WaitStatus::Signaled(_, _, _)
169                | WaitStatus::Stopped(_, _)) => return status,
170                _ if Instant::now() < deadline => {
171                    std::thread::sleep(Duration::from_millis(10));
172                }
173                _ => return terminate_pty_controller(controller),
174            }
175        }
176    }
177
178    fn terminate_pty_controller(controller: nix::unistd::Pid) -> WaitStatus {
179        let _ = killpg(controller, Signal::SIGCONT);
180        let _ = killpg(controller, Signal::SIGKILL);
181        waitpid(controller, None).expect("reap PTY controller")
182    }
183
184    fn wait_with_deadline(
185        child: &mut std::process::Child,
186        timeout: Duration,
187    ) -> Option<std::process::ExitStatus> {
188        let deadline = Instant::now() + timeout;
189        while Instant::now() < deadline {
190            match child.try_wait() {
191                Ok(Some(status)) => return Some(status),
192                Ok(None) => std::thread::sleep(Duration::from_millis(10)),
193                Err(error) => panic!("failed to poll process test child: {error}"),
194            }
195        }
196        None
197    }
198
199    fn terminate_test_group(child: &mut std::process::Child) -> std::process::ExitStatus {
200        use nix::sys::signal::{Signal, killpg};
201        use nix::unistd::Pid;
202
203        let process_group = i32::try_from(child.id()).expect("test child pid fits process group");
204        let _ = killpg(Pid::from_raw(process_group), Signal::SIGCONT);
205        let _ = killpg(Pid::from_raw(process_group), Signal::SIGKILL);
206        let _ = child.kill();
207        child.wait().expect("reap timed-out process test child")
208    }
209}