Skip to main content

strop_core/
process.rs

1//! Worker-only child ownership. Cancellation revokes a private Unix process group
2//! or a helper's lease; only its owner may revoke the capability and reap the PID.
3mod capture;
4use crate::worker::{CancelToken, Failure, FailureKind};
5pub use capture::{
6    capture, capture_with, stream_with, CaptureError, CapturePolicy, CommandOutput, StdinPolicy,
7    StreamError, StreamOutput, StreamPolicy,
8};
9use parking_lot::Mutex;
10use std::io;
11use std::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, ExitStatus};
12use std::sync::Arc;
13
14#[derive(Default)]
15struct Group {
16    pid: Option<u32>,
17    cancelled: bool,
18    #[cfg(unix)]
19    lease: Option<std::os::unix::net::UnixStream>,
20}
21impl Group {
22    fn signal(&self) -> Result<(), Failure> {
23        #[cfg(unix)]
24        if let Some(lease) = &self.lease {
25            return match lease.shutdown(std::net::Shutdown::Write) {
26                Ok(()) => Ok(()),
27                Err(error) if error.kind() == io::ErrorKind::NotConnected => Ok(()),
28                Err(error) => Err(Failure::new(
29                    FailureKind::Io,
30                    format!("close process lease: {error}"),
31                )),
32            };
33        }
34        #[cfg(unix)]
35        if let Some(pid) = self.pid {
36            // SAFETY: this positive PID belongs to our unreaped child, launched
37            // with process_group(0). The mutex serializes signalling and revocation;
38            // negation targets only that private group. std cannot signal groups.
39            if unsafe { libc::kill(-(pid as libc::pid_t), libc::SIGKILL) } == -1 {
40                let error = io::Error::last_os_error();
41                if error.raw_os_error() != Some(libc::ESRCH) {
42                    return Err(Failure::new(
43                        FailureKind::Io,
44                        format!("kill process group: {error}"),
45                    ));
46                }
47            }
48        }
49        Ok(())
50    }
51    fn cancel(&mut self) -> Result<(), Failure> {
52        self.cancelled = true;
53        self.signal()
54    }
55}
56
57/// Owns both the child and the cancellation capability. Never put this in editor
58/// state: Drop may wait, so it belongs exclusively to a worker stack.
59pub struct OwnedProcess {
60    child: Child,
61    group: Arc<Mutex<Group>>,
62    token: CancelToken,
63    reaped: bool,
64}
65impl OwnedProcess {
66    pub fn spawn(command: &mut Command, token: &CancelToken) -> Result<Self, Failure> {
67        #[cfg(not(unix))]
68        return Err(Failure::new(
69            FailureKind::Unavailable,
70            "process-group supervision requires Unix",
71        ));
72        #[cfg(unix)]
73        Self::spawn_unix(command, token, None)
74    }
75
76    /// The helper must own descendant cleanup and exit after lease EOF.
77    /// Unlike an ordinary group child, it may establish a controlling session.
78    #[cfg(unix)]
79    pub fn spawn_leased(
80        command: &mut Command,
81        token: &CancelToken,
82        lease: std::os::unix::net::UnixStream,
83    ) -> Result<Self, Failure> {
84        Self::spawn_unix(command, token, Some(lease))
85    }
86
87    #[cfg(unix)]
88    fn spawn_unix(
89        command: &mut Command,
90        token: &CancelToken,
91        lease: Option<std::os::unix::net::UnixStream>,
92    ) -> Result<Self, Failure> {
93        use std::os::unix::process::CommandExt;
94        if lease.is_none() {
95            command.process_group(0);
96        }
97        let group = Arc::new(Mutex::new(Group {
98            lease,
99            ..Group::default()
100        }));
101        let callback = group.clone();
102        token.register_cancel_resource(move || callback.lock().cancel())?;
103        if token.is_cancelled() {
104            token.clear_cancel_resource();
105            return Err(Failure::new(
106                FailureKind::Unavailable,
107                "process cancelled before spawn",
108            ));
109        }
110        let child = match command.spawn() {
111            Ok(child) => child,
112            Err(error) => {
113                token.clear_cancel_resource();
114                return Err(Failure::new(FailureKind::Spawn, error.to_string()));
115            }
116        };
117        let process = Self {
118            child,
119            group,
120            token: token.clone(),
121            reaped: false,
122        };
123        {
124            let mut group = process.group.lock();
125            group.pid = Some(process.child.id());
126            if group.cancelled || token.is_cancelled() {
127                group.cancel()?;
128            }
129        }
130        Ok(process)
131    }
132    pub fn take_stdin(&mut self) -> Option<ChildStdin> {
133        self.child.stdin.take()
134    }
135    pub fn take_stdout(&mut self) -> Option<ChildStdout> {
136        self.child.stdout.take()
137    }
138    pub fn take_stderr(&mut self) -> Option<ChildStderr> {
139        self.child.stderr.take()
140    }
141    pub fn terminate(&mut self) -> Result<(), Failure> {
142        self.group.lock().signal()
143    }
144
145    /// Observe without reaping: descendants may still hold pipes, and cancellation
146    /// must retain its PID reservation until those descendants have been killed.
147    pub fn has_exited(&self) -> Result<bool, Failure> {
148        child_has_exited(&self.child)
149    }
150    /// Wait with cancellation intact, then revoke before reaping. A callback
151    /// detached from CancelToken can never signal a reused PID.
152    pub fn wait(&mut self) -> Result<ExitStatus, Failure> {
153        // Retain the signalling capability throughout the wait. Even a caller
154        // waiting before termination must remain cancellable.
155        while !self.reaped && !self.has_exited()? {
156            std::thread::park_timeout(std::time::Duration::from_millis(20));
157        }
158        self.group.lock().pid = None;
159        loop {
160            match self.child.wait() {
161                Ok(status) => {
162                    self.reaped = true;
163                    self.token.clear_cancel_resource();
164                    return Ok(status);
165                }
166                Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
167                Err(error) => return Err(Failure::new(FailureKind::Wait, error.to_string())),
168            }
169        }
170    }
171}
172impl Drop for OwnedProcess {
173    fn drop(&mut self) {
174        if !self.reaped {
175            let _ = self.terminate();
176            // A leased helper must finish its own cross-group cleanup; killing it
177            // here would bypass that ownership protocol and orphan its descendants.
178            #[cfg(unix)]
179            if self.group.lock().lease.is_none() {
180                let _ = self.child.kill();
181            }
182            #[cfg(not(unix))]
183            let _ = self.child.kill();
184            let _ = self.wait();
185        }
186        self.token.clear_cancel_resource();
187    }
188}
189
190/// Worker-only observation that retains an owned child's PID reservation.
191/// Call before `Child::wait`/`try_wait`; reaping ends that reservation.
192pub fn child_has_exited(child: &Child) -> Result<bool, Failure> {
193    #[cfg(not(unix))]
194    return Err(Failure::new(
195        FailureKind::Unavailable,
196        "process supervision requires Unix",
197    ));
198    #[cfg(unix)]
199    {
200        // SAFETY: zero initializes siginfo_t; waitid writes it on success.
201        let mut info: libc::siginfo_t = unsafe { std::mem::zeroed() };
202        loop {
203            // SAFETY: child is owned and unreaped. WNOWAIT preserves its PID;
204            // WNOHANG prevents blocking. The output pointer is valid.
205            let result = unsafe {
206                libc::waitid(
207                    libc::P_PID,
208                    child.id() as libc::id_t,
209                    &mut info,
210                    libc::WEXITED | libc::WNOHANG | libc::WNOWAIT,
211                )
212            };
213            if result == 0 {
214                // SAFETY: successful waitid initialized the platform layout.
215                return Ok(unsafe { info.si_pid() } != 0);
216            }
217            let error = io::Error::last_os_error();
218            if error.kind() != io::ErrorKind::Interrupted {
219                return Err(Failure::new(FailureKind::Wait, error.to_string()));
220            }
221        }
222    }
223}
224
225#[cfg(all(test, unix))]
226mod tests {
227    use super::*;
228    use crate::worker::{self, CancelReason, Outcome};
229    use std::io::Read;
230    use std::os::fd::OwnedFd;
231    use std::os::unix::net::UnixStream;
232    use std::process::Stdio;
233    use std::sync::mpsc;
234    use std::time::Duration;
235
236    #[test]
237    fn leased_cancellation_allows_helper_cleanup_before_reaping() {
238        let (ready_tx, ready_rx) = mpsc::channel();
239        let (done_tx, done_rx) = mpsc::channel();
240        let handle = worker::spawn_effect(
241            "leased-cleanup-test",
242            move |outcome| {
243                done_tx.send(outcome).unwrap();
244            },
245            move |token| {
246                let (lease, child_lease) = UnixStream::pair().unwrap();
247                let mut command = Command::new("/bin/sh");
248                command
249                    .args(["-c", "printf READY; cat >/dev/null; printf CLEANED"])
250                    .stdin(Stdio::from(OwnedFd::from(child_lease)))
251                    .stdout(Stdio::piped())
252                    .stderr(Stdio::null());
253                let mut process = OwnedProcess::spawn_leased(&mut command, &token, lease).unwrap();
254                let mut stdout = process.take_stdout().unwrap();
255                let mut ready = [0; 5];
256                stdout.read_exact(&mut ready).unwrap();
257                assert_eq!(&ready, b"READY");
258                ready_tx.send(()).unwrap();
259                let mut output = Vec::new();
260                stdout.read_to_end(&mut output).unwrap();
261                let status = process.wait().unwrap();
262                Outcome::Success((output, status.success()))
263            },
264        );
265        ready_rx.recv_timeout(Duration::from_secs(5)).unwrap();
266        handle.cancel(CancelReason::Shutdown);
267        let outcome = done_rx.recv_timeout(Duration::from_secs(5)).unwrap();
268        let Outcome::Success((output, successful)) = outcome else {
269            panic!("cleanup outcome lost: {outcome:?}");
270        };
271        assert!(successful);
272        assert_eq!(output, b"CLEANED");
273    }
274}