Skip to main content

rightkit_process/
owned.rs

1use std::{
2    ffi::OsStr,
3    io,
4    num::NonZeroU32,
5    process::{Child, ChildStderr, ChildStdin, ChildStdout, Command, ExitStatus},
6    sync::{
7        atomic::{AtomicU32, Ordering},
8        Arc,
9    },
10    thread,
11    time::{Duration, Instant},
12};
13
14use process_wrap::std::{StdChildWrapper, StdCommandWrap, StdCommandWrapper};
15
16#[derive(Debug)]
17pub struct OwnedCommand {
18    command: Command,
19    windows_hidden: bool,
20}
21
22impl OwnedCommand {
23    pub fn new(program: impl AsRef<OsStr>) -> Self {
24        // windowless: from_command enables CREATE_NO_WINDOW for every OwnedCommand.
25        Self::from_command(Command::new(program))
26    }
27
28    /// On Windows the child gets CREATE_NO_WINDOW by default: RightKit hosts are GUI apps and
29    /// background services, so a console window would only ever flash on the user's desktop.
30    pub fn from_command(command: Command) -> Self {
31        Self {
32            command,
33            windows_hidden: true,
34        }
35    }
36
37    pub fn command_mut(&mut self) -> &mut Command {
38        &mut self.command
39    }
40
41    /// Run the child with a scrubbed environment: everything is cleared, then
42    /// only the named variables that are set in this process are copied
43    /// across (HeardRight's sidecar whitelist). Include `PATH` and the
44    /// platform basics yourself, or start from [`DEFAULT_ENV_ALLOWLIST`].
45    /// Variables set explicitly afterwards through [`Self::command_mut`] win.
46    pub fn env_allowlist<I, S>(&mut self, keys: I) -> &mut Self
47    where
48        I: IntoIterator<Item = S>,
49        S: AsRef<OsStr>,
50    {
51        let kept: Vec<_> = keys
52            .into_iter()
53            .filter_map(|k| std::env::var_os(k.as_ref()).map(|v| (k.as_ref().to_os_string(), v)))
54            .collect();
55        self.command.env_clear();
56        for (k, v) in kept {
57            self.command.env(k, v);
58        }
59        self
60    }
61
62    /// Remove the named variables from the child's inherited environment
63    /// (secrets, BYOK keys) without clearing the rest.
64    pub fn env_strip<I, S>(&mut self, keys: I) -> &mut Self
65    where
66        I: IntoIterator<Item = S>,
67        S: AsRef<OsStr>,
68    {
69        for k in keys {
70            self.command.env_remove(k);
71        }
72        self
73    }
74
75    /// Kept for callers written before hiding became the default; it is now a no-op.
76    pub fn windows_hide(&mut self) -> &mut Self {
77        self.windows_hidden = true;
78        self
79    }
80
81    pub fn spawn(self) -> io::Result<OwnedChild> {
82        spawn_owned(self.command, self.windows_hidden, false, None)
83    }
84}
85
86/// Variables a sidecar normally needs to run at all; a base for
87/// [`OwnedCommand::env_allowlist`].
88pub const DEFAULT_ENV_ALLOWLIST: &[&str] = &[
89    "PATH",
90    "HOME",
91    "USER",
92    "LOGNAME",
93    "LANG",
94    "LC_ALL",
95    "TMPDIR",
96    "TEMP",
97    "TMP",
98    "SystemRoot",
99    "SYSTEMROOT",
100    "USERPROFILE",
101    "APPDATA",
102    "LOCALAPPDATA",
103    "ProgramData",
104    "COMSPEC",
105    "PATHEXT",
106    "XDG_DATA_HOME",
107    "XDG_RUNTIME_DIR",
108];
109
110#[derive(Debug)]
111pub struct OwnedChild {
112    child: Option<Box<dyn StdChildWrapper>>,
113}
114
115/// Result of [`OwnedChild::terminate_tree_bounded`].
116#[derive(Clone, Copy, Debug, Eq, PartialEq)]
117pub enum ReapOutcome {
118    /// Already gone, or the exit was observed within the bound.
119    Exited(ExitStatus),
120    /// The kill was issued but the OS did not report the exit in time (a
121    /// process wedged in the kernel, e.g. releasing a GPU model). The caller is
122    /// not blocked; the owned handle still reaps on drop.
123    TimedOut,
124}
125
126#[derive(Clone, Copy, Debug, Eq, PartialEq)]
127pub enum WaitOutcome {
128    Exited(ExitStatus),
129    Terminated(ExitStatus),
130}
131
132impl OwnedChild {
133    pub fn id(&self) -> u32 {
134        self.child().id()
135    }
136
137    pub fn take_stdin(&mut self) -> Option<ChildStdin> {
138        self.child_mut().stdin().take()
139    }
140
141    pub fn take_stdout(&mut self) -> Option<ChildStdout> {
142        self.child_mut().stdout().take()
143    }
144
145    pub fn take_stderr(&mut self) -> Option<ChildStderr> {
146        self.child_mut().stderr().take()
147    }
148
149    pub fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
150        self.child_mut().try_wait()
151    }
152
153    pub fn wait(&mut self) -> io::Result<ExitStatus> {
154        self.child_mut().wait()
155    }
156
157    pub fn wait_timeout(&mut self, timeout: Duration) -> io::Result<Option<ExitStatus>> {
158        let started = Instant::now();
159        loop {
160            if let Some(status) = self.try_wait()? {
161                return Ok(Some(status));
162            }
163            if started.elapsed() >= timeout {
164                return Ok(None);
165            }
166            thread::sleep(Duration::from_millis(5).min(timeout.saturating_sub(started.elapsed())));
167        }
168    }
169
170    pub fn wait_or_kill(&mut self, grace: Duration) -> io::Result<WaitOutcome> {
171        #[cfg(unix)]
172        self.child().signal(nix::libc::SIGTERM)?;
173
174        if let Some(status) = self.wait_timeout(grace)? {
175            return Ok(WaitOutcome::Exited(status));
176        }
177        self.terminate_tree().map(WaitOutcome::Terminated)
178    }
179
180    pub fn terminate_tree(&mut self) -> io::Result<ExitStatus> {
181        if let Some(status) = self.try_wait()? {
182            return Ok(status);
183        }
184        self.child_mut().start_kill()?;
185        self.child_mut().wait()
186    }
187
188    /// Kill the whole tree now and wait at most `timeout` for the OS to report
189    /// the exit. Unlike [`Self::terminate_tree`] this never blocks past the
190    /// bound, so tray-quit / window-close paths stay responsive even if the
191    /// child cannot be reaped promptly (HeardRight's bounded reap).
192    pub fn terminate_tree_bounded(&mut self, timeout: Duration) -> io::Result<ReapOutcome> {
193        if let Some(status) = self.try_wait()? {
194            return Ok(ReapOutcome::Exited(status));
195        }
196        self.child_mut().start_kill()?;
197        Ok(match self.wait_timeout(timeout)? {
198            Some(status) => ReapOutcome::Exited(status),
199            None => ReapOutcome::TimedOut,
200        })
201    }
202
203    fn child(&self) -> &dyn StdChildWrapper {
204        self.child
205            .as_deref()
206            .expect("owned child is always present")
207    }
208
209    fn child_mut(&mut self) -> &mut dyn StdChildWrapper {
210        self.child
211            .as_deref_mut()
212            .expect("owned child is always present")
213    }
214}
215
216impl Drop for OwnedChild {
217    fn drop(&mut self) {
218        let _ = self.terminate_tree();
219    }
220}
221
222#[derive(Clone, Copy, Debug, Eq, PartialEq)]
223pub struct AdoptedProcess {
224    pid: NonZeroU32,
225}
226
227impl AdoptedProcess {
228    pub fn new(pid: NonZeroU32) -> Self {
229        Self { pid }
230    }
231
232    pub fn id(&self) -> u32 {
233        self.pid.get()
234    }
235
236    pub fn is_running(&self) -> io::Result<bool> {
237        process_is_running(self.pid)
238    }
239}
240
241#[derive(Debug)]
242struct CleanupWrapper {
243    captured_pid: Option<Arc<AtomicU32>>,
244}
245
246impl StdCommandWrapper for CleanupWrapper {
247    fn post_spawn(&mut self, child: &mut Child, _core: &StdCommandWrap) -> io::Result<()> {
248        if let Some(pid) = &self.captured_pid {
249            pid.store(child.id(), Ordering::SeqCst);
250        }
251        Ok(())
252    }
253
254    fn wrap_child(
255        &mut self,
256        child: Box<dyn StdChildWrapper>,
257        _core: &StdCommandWrap,
258    ) -> io::Result<Box<dyn StdChildWrapper>> {
259        // Runs first, while process-wrap's JobObject still holds the child suspended, so the
260        // child and everything it later spawns land in this kill-on-close job.
261        #[cfg(windows)]
262        let kill_on_close = Some(KillOnCloseJob::assign(child.inner())?);
263        Ok(Box::new(CleanupChild {
264            child: Some(child),
265            #[cfg(windows)]
266            _kill_on_close: kill_on_close,
267        }))
268    }
269}
270
271/// process-wrap's std `JobObject` never sets JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, so a host that
272/// dies without running destructors (crash, TerminateProcess) left its tree running on Windows.
273/// This second job's only handle lives in the host; when the host goes away for any reason the
274/// kernel closes it and kills every process in the tree.
275#[cfg(windows)]
276#[derive(Debug)]
277struct KillOnCloseJob(windows::Win32::Foundation::HANDLE);
278
279#[cfg(windows)]
280impl KillOnCloseJob {
281    fn assign(child: &Child) -> io::Result<Self> {
282        use std::os::windows::io::AsRawHandle;
283        use windows::Win32::{
284            Foundation::HANDLE,
285            System::JobObjects::{
286                AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
287                SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
288                JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
289            },
290        };
291        let job = Self(unsafe { CreateJobObjectW(None, None) }.map_err(io::Error::other)?);
292        let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
293        info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
294        unsafe {
295            SetInformationJobObject(
296                job.0,
297                JobObjectExtendedLimitInformation,
298                &info as *const _ as *const _,
299                std::mem::size_of_val(&info) as u32,
300            )
301        }
302        .map_err(io::Error::other)?;
303        unsafe { AssignProcessToJobObject(job.0, HANDLE(child.as_raw_handle() as _)) }
304            .map_err(io::Error::other)?;
305        Ok(job)
306    }
307}
308
309#[cfg(windows)]
310impl Drop for KillOnCloseJob {
311    fn drop(&mut self) {
312        let _ = unsafe { windows::Win32::Foundation::CloseHandle(self.0) };
313    }
314}
315
316// The raw job handle is only closed on drop; moving it between threads is sound.
317#[cfg(windows)]
318unsafe impl Send for KillOnCloseJob {}
319#[cfg(windows)]
320unsafe impl Sync for KillOnCloseJob {}
321
322#[derive(Debug)]
323struct CleanupChild {
324    child: Option<Box<dyn StdChildWrapper>>,
325    // Dropped after `child`'s own Drop ran; closing it kills anything still in the tree.
326    #[cfg(windows)]
327    _kill_on_close: Option<KillOnCloseJob>,
328}
329
330impl CleanupChild {
331    fn child(&self) -> &dyn StdChildWrapper {
332        self.child.as_deref().expect("cleanup child is present")
333    }
334
335    fn child_mut(&mut self) -> &mut dyn StdChildWrapper {
336        self.child.as_deref_mut().expect("cleanup child is present")
337    }
338}
339
340impl StdChildWrapper for CleanupChild {
341    fn inner(&self) -> &Child {
342        self.child().inner()
343    }
344
345    fn inner_mut(&mut self) -> &mut Child {
346        self.child_mut().inner_mut()
347    }
348
349    fn into_inner(mut self: Box<Self>) -> Child {
350        self.child
351            .take()
352            .expect("cleanup child is present")
353            .into_inner()
354    }
355
356    fn stdin(&mut self) -> &mut Option<ChildStdin> {
357        self.child_mut().stdin()
358    }
359
360    fn stdout(&mut self) -> &mut Option<ChildStdout> {
361        self.child_mut().stdout()
362    }
363
364    fn stderr(&mut self) -> &mut Option<ChildStderr> {
365        self.child_mut().stderr()
366    }
367
368    fn id(&self) -> u32 {
369        self.child().id()
370    }
371
372    fn start_kill(&mut self) -> io::Result<()> {
373        self.child_mut().start_kill()
374    }
375
376    fn try_wait(&mut self) -> io::Result<Option<ExitStatus>> {
377        self.child_mut().try_wait()
378    }
379
380    fn wait(&mut self) -> io::Result<ExitStatus> {
381        self.child_mut().wait()
382    }
383
384    #[cfg(unix)]
385    fn signal(&self, signal: i32) -> io::Result<()> {
386        self.child().signal(signal)
387    }
388}
389
390impl Drop for CleanupChild {
391    fn drop(&mut self) {
392        let Some(child) = self.child.as_deref_mut() else {
393            return;
394        };
395        if !matches!(child.try_wait(), Ok(Some(_))) {
396            let _ = child.start_kill();
397            let _ = child.wait();
398        }
399    }
400}
401
402#[derive(Debug)]
403struct FailAfterSpawn;
404
405impl StdCommandWrapper for FailAfterSpawn {
406    fn wrap_child(
407        &mut self,
408        _child: Box<dyn StdChildWrapper>,
409        _core: &StdCommandWrap,
410    ) -> io::Result<Box<dyn StdChildWrapper>> {
411        Err(io::Error::other("injected post-spawn wrapping failure"))
412    }
413}
414
415fn spawn_owned(
416    command: Command,
417    windows_hidden: bool,
418    fail_after_spawn: bool,
419    captured_pid: Option<Arc<AtomicU32>>,
420) -> io::Result<OwnedChild> {
421    let mut command = StdCommandWrap::from(command);
422    command.wrap(CleanupWrapper { captured_pid });
423
424    #[cfg(windows)]
425    {
426        use process_wrap::std::{CreationFlags, JobObject};
427        use windows::Win32::System::Threading::CREATE_NO_WINDOW;
428
429        if windows_hidden {
430            command.wrap(CreationFlags(CREATE_NO_WINDOW));
431        }
432        command.wrap(JobObject);
433    }
434
435    #[cfg(unix)]
436    {
437        use process_wrap::std::ProcessGroup;
438        let _ = windows_hidden;
439        command.wrap(ProcessGroup::leader());
440    }
441
442    if fail_after_spawn {
443        command.wrap(FailAfterSpawn);
444    }
445
446    command
447        .spawn()
448        .map(|child| OwnedChild { child: Some(child) })
449}
450
451#[cfg(test)]
452fn spawn_for_test(command: Command, captured_pid: Arc<AtomicU32>) -> io::Result<OwnedChild> {
453    spawn_owned(command, true, true, Some(captured_pid))
454}
455
456#[cfg(windows)]
457fn process_is_running(pid: NonZeroU32) -> io::Result<bool> {
458    use windows::Win32::{
459        Foundation::{CloseHandle, ERROR_INVALID_PARAMETER, WAIT_TIMEOUT},
460        System::Threading::{
461            OpenProcess, WaitForSingleObject, PROCESS_QUERY_LIMITED_INFORMATION,
462            PROCESS_SYNCHRONIZE,
463        },
464    };
465
466    let handle = unsafe {
467        OpenProcess(
468            PROCESS_QUERY_LIMITED_INFORMATION | PROCESS_SYNCHRONIZE,
469            false,
470            pid.get(),
471        )
472    };
473    let handle = match handle {
474        Ok(handle) => handle,
475        Err(error) => {
476            if error.code() == ERROR_INVALID_PARAMETER.to_hresult() {
477                return Ok(false);
478            }
479            return Err(io::Error::other(error.to_string()));
480        }
481    };
482    let wait = unsafe { WaitForSingleObject(handle, 0) };
483    let close = unsafe { CloseHandle(handle) };
484    close.map_err(|error| io::Error::other(error.to_string()))?;
485    match wait.0 {
486        0 => Ok(false),
487        value if value == WAIT_TIMEOUT.0 => Ok(true),
488        _ => Err(io::Error::last_os_error()),
489    }
490}
491
492#[cfg(unix)]
493fn process_is_running(pid: NonZeroU32) -> io::Result<bool> {
494    use nix::{errno::Errno, sys::signal::kill, unistd::Pid};
495
496    let pid = i32::try_from(pid.get())
497        .map(Pid::from_raw)
498        .map_err(io::Error::other)?;
499    match kill(pid, None) {
500        Ok(()) | Err(Errno::EPERM) => Ok(true),
501        Err(Errno::ESRCH) => Ok(false),
502        Err(error) => Err(io::Error::from(error)),
503    }
504}
505
506#[cfg(test)]
507mod tests {
508    #[test]
509    fn owned_commands_hide_their_console_window_by_default() {
510        assert!(super::OwnedCommand::new("x").windows_hidden);
511        assert!(super::OwnedCommand::from_command(std::process::Command::new("x")).windows_hidden);
512    }
513
514    #[test]
515    fn a_failure_after_spawn_cleans_up_the_partially_wrapped_child() {
516        use std::{
517            process::Command,
518            sync::{
519                atomic::{AtomicU32, Ordering},
520                Arc,
521            },
522            thread,
523            time::{Duration, Instant},
524        };
525
526        // A long-lived child that survives on its own if the partial-spawn cleanup fails.
527        let command = if cfg!(windows) {
528            let mut command = Command::new("powershell.exe");
529            command.args([
530                "-NoLogo",
531                "-NoProfile",
532                "-NonInteractive",
533                "-Command",
534                "Start-Sleep -Seconds 60",
535            ]);
536            command
537        } else {
538            let mut command = Command::new("sleep");
539            command.arg("60");
540            command
541        };
542        let spawned_pid = Arc::new(AtomicU32::new(0));
543
544        assert!(super::spawn_for_test(command, Arc::clone(&spawned_pid)).is_err());
545        let pid = spawned_pid.load(Ordering::SeqCst);
546        assert_ne!(pid, 0, "test must fail after the OS process was spawned");
547        let process = super::AdoptedProcess::new(std::num::NonZeroU32::new(pid).unwrap());
548        let deadline = Instant::now() + Duration::from_secs(5);
549        while process.is_running().unwrap_or(false) {
550            assert!(
551                Instant::now() < deadline,
552                "partially spawned process leaked"
553            );
554            thread::sleep(Duration::from_millis(20));
555        }
556    }
557}