Skip to main content

thruput_node_supervisor/
process.rs

1//! Minimal one-child process supervision.
2
3use std::ffi::{OsStr, OsString};
4use std::fs::File;
5use std::path::PathBuf;
6use std::process::{ExitStatus, Stdio};
7
8use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command};
9
10use crate::error::{ChildSignal, NodeSupervisorError};
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13enum ChildStdio {
14    Null,
15    Piped,
16    Inherit,
17}
18
19impl ChildStdio {
20    fn into_stdio(self) -> Stdio {
21        match self {
22            Self::Null => Stdio::null(),
23            Self::Piped => Stdio::piped(),
24            Self::Inherit => Stdio::inherit(),
25        }
26    }
27}
28
29/// Command specification for a supervised child process.
30#[derive(Debug, Clone, PartialEq, Eq)]
31#[non_exhaustive]
32pub struct ChildProcessSpec {
33    program: OsString,
34    args: Vec<OsString>,
35    envs: Vec<(OsString, OsString)>,
36    current_dir: Option<PathBuf>,
37    stdin: ChildStdio,
38    stdout: ChildStdio,
39    stderr: ChildStdio,
40    kill_on_drop: bool,
41    #[cfg(unix)]
42    process_group: Option<i32>,
43}
44
45impl ChildProcessSpec {
46    /// Create a new child process specification.
47    pub fn new(program: impl Into<OsString>) -> Self {
48        Self {
49            program: program.into(),
50            args: Vec::new(),
51            envs: Vec::new(),
52            current_dir: None,
53            stdin: ChildStdio::Null,
54            stdout: ChildStdio::Inherit,
55            stderr: ChildStdio::Inherit,
56            kill_on_drop: true,
57            #[cfg(unix)]
58            process_group: None,
59        }
60    }
61
62    /// Add one command-line argument.
63    #[must_use]
64    pub fn arg(mut self, arg: impl Into<OsString>) -> Self {
65        self.args.push(arg.into());
66        self
67    }
68
69    /// Add multiple command-line arguments.
70    #[must_use]
71    pub fn args<I, S>(mut self, args: I) -> Self
72    where
73        I: IntoIterator<Item = S>,
74        S: Into<OsString>,
75    {
76        self.args.extend(args.into_iter().map(Into::into));
77        self
78    }
79
80    /// Add one environment variable override.
81    #[must_use]
82    pub fn env(mut self, key: impl Into<OsString>, value: impl Into<OsString>) -> Self {
83        self.envs.push((key.into(), value.into()));
84        self
85    }
86
87    /// Set the child process working directory.
88    #[must_use]
89    pub fn current_dir(mut self, current_dir: impl Into<PathBuf>) -> Self {
90        self.current_dir = Some(current_dir.into());
91        self
92    }
93
94    /// Pipe child stdin so the supervisor can write to it after spawn.
95    #[must_use]
96    pub fn stdin_piped(mut self) -> Self {
97        self.stdin = ChildStdio::Piped;
98        self
99    }
100
101    /// Pipe child stdout so the supervisor can read readiness output.
102    #[must_use]
103    pub fn stdout_piped(mut self) -> Self {
104        self.stdout = ChildStdio::Piped;
105        self
106    }
107
108    /// Pipe child stderr so the supervisor can forward log output.
109    #[must_use]
110    pub fn stderr_piped(mut self) -> Self {
111        self.stderr = ChildStdio::Piped;
112        self
113    }
114
115    /// Configure whether Tokio force-kills the direct child when its handle is dropped.
116    #[must_use]
117    pub fn kill_on_drop(mut self, kill_on_drop: bool) -> Self {
118        self.kill_on_drop = kill_on_drop;
119        self
120    }
121
122    /// Run the child in the requested Unix process group.
123    #[cfg(unix)]
124    #[must_use]
125    pub fn process_group(mut self, process_group: i32) -> Self {
126        self.process_group = Some(process_group);
127        self
128    }
129
130    /// Program path or name.
131    pub fn program(&self) -> &OsStr {
132        &self.program
133    }
134
135    /// Command-line arguments.
136    pub fn program_args(&self) -> &[OsString] {
137        &self.args
138    }
139
140    /// Environment variable overrides.
141    #[cfg(test)]
142    pub(crate) fn program_envs(&self) -> &[(OsString, OsString)] {
143        &self.envs
144    }
145
146    fn program_label(&self) -> String {
147        self.program.to_string_lossy().into_owned()
148    }
149
150    fn command(&self) -> Command {
151        let mut command = Command::new(&self.program);
152        command
153            .args(&self.args)
154            .stdin(self.stdin.into_stdio())
155            .stdout(self.stdout.into_stdio())
156            .stderr(self.stderr.into_stdio())
157            .kill_on_drop(self.kill_on_drop);
158
159        #[cfg(unix)]
160        if let Some(process_group) = self.process_group {
161            command.process_group(process_group);
162        }
163
164        for (key, value) in &self.envs {
165            command.env(key, value);
166        }
167
168        if let Some(current_dir) = &self.current_dir {
169            command.current_dir(current_dir);
170        }
171
172        command
173    }
174}
175
176/// One-child supervisor for a configured process.
177#[derive(Debug, Clone, PartialEq, Eq)]
178pub struct ChildSupervisor {
179    spec: ChildProcessSpec,
180}
181
182impl ChildSupervisor {
183    /// Create a supervisor for one child process.
184    pub fn new(spec: ChildProcessSpec) -> Self {
185        Self { spec }
186    }
187
188    /// Spawn the configured child process.
189    #[tracing::instrument(skip_all, fields(program = %self.spec.program_label()))]
190    pub fn spawn(&self) -> Result<SupervisedChild, NodeSupervisorError> {
191        self.spawn_with_inherited_file(None)
192    }
193
194    pub(crate) fn spawn_with_inherited_file(
195        &self,
196        inherited_file: Option<File>,
197    ) -> Result<SupervisedChild, NodeSupervisorError> {
198        self.spawn_with_files(inherited_file, None)
199    }
200
201    pub(crate) fn spawn_with_files(
202        &self,
203        inherited_file: Option<File>,
204        chain_lock: Option<File>,
205    ) -> Result<SupervisedChild, NodeSupervisorError> {
206        let program = self.spec.program_label();
207        let mut command = self.spec.command();
208        if let Some(file) = inherited_file {
209            inherit_file(&mut command, file).map_err(|source| NodeSupervisorError::Spawn {
210                program: program.clone(),
211                source,
212            })?;
213        }
214        if let Some(file) = chain_lock {
215            let fd =
216                inherit_file(&mut command, file).map_err(|source| NodeSupervisorError::Spawn {
217                    program: program.clone(),
218                    source,
219                })?;
220            command.env("HOG_DEVNODE_DATA_DIR_LOCK_FD", fd.to_string());
221        }
222        let child = command
223            .spawn()
224            .map_err(|source| NodeSupervisorError::Spawn {
225                program: program.clone(),
226                source,
227            })?;
228        #[cfg(unix)]
229        let process_group = resolve_process_group(self.spec.process_group, child.id(), &program)?;
230
231        Ok(SupervisedChild {
232            program,
233            child,
234            #[cfg(unix)]
235            process_group,
236        })
237    }
238}
239
240#[cfg(unix)]
241fn inherit_file(command: &mut Command, file: File) -> std::io::Result<i32> {
242    use std::os::fd::{AsRawFd, FromRawFd};
243
244    // A caller may have closed standard input/output before opening the file.
245    // Move inheritance above stdio so Command's pipe/null setup cannot replace it.
246    // SAFETY: file is live; fcntl returns a new descriptor owned below.
247    let fd = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_DUPFD_CLOEXEC, 3) };
248    if fd == -1 {
249        return Err(std::io::Error::last_os_error());
250    }
251    // SAFETY: fd is a fresh successful duplication and is owned exactly once.
252    let inherited = unsafe { File::from_raw_fd(fd) };
253    drop(file);
254
255    // SAFETY: pre_exec runs after fork. The closure calls only async-signal-safe
256    // fcntl and constructs an OS error without allocating. Owning the file keeps
257    // its descriptor valid until spawn completes. Only the child's descriptor
258    // loses CLOEXEC; unrelated commands in this process cannot inherit it.
259    unsafe {
260        command.pre_exec(move || {
261            let fd = inherited.as_raw_fd();
262            let flags = libc::fcntl(fd, libc::F_GETFD);
263            if flags == -1 || libc::fcntl(fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC) == -1 {
264                return Err(std::io::Error::last_os_error());
265            }
266            Ok(())
267        });
268    }
269    Ok(fd)
270}
271
272#[cfg(not(unix))]
273fn inherit_file(_command: &mut Command, _file: File) -> std::io::Result<i32> {
274    Err(std::io::Error::new(
275        std::io::ErrorKind::Unsupported,
276        "inherited installation leases require Unix file descriptors",
277    ))
278}
279
280#[cfg(unix)]
281fn resolve_process_group(
282    configured_process_group: Option<i32>,
283    child_id: Option<u32>,
284    program: &str,
285) -> Result<Option<i32>, NodeSupervisorError> {
286    match configured_process_group {
287        Some(0) => child_id_to_i32(child_id, program).map(Some),
288        Some(process_group) => Ok(Some(process_group)),
289        None => Ok(None),
290    }
291}
292
293#[cfg(unix)]
294fn child_id_to_i32(child_id: Option<u32>, program: &str) -> Result<i32, NodeSupervisorError> {
295    let Some(child_id) = child_id else {
296        return Err(NodeSupervisorError::Status {
297            program: program.to_string(),
298            source: std::io::Error::new(
299                std::io::ErrorKind::InvalidInput,
300                "child process id is unavailable",
301            ),
302        });
303    };
304
305    i32::try_from(child_id).map_err(|_| NodeSupervisorError::Status {
306        program: program.to_string(),
307        source: std::io::Error::new(
308            std::io::ErrorKind::InvalidInput,
309            "child pid does not fit in pid_t",
310        ),
311    })
312}
313
314/// Running child process owned by the supervisor.
315#[derive(Debug)]
316pub struct SupervisedChild {
317    program: String,
318    child: Child,
319    #[cfg(unix)]
320    process_group: Option<i32>,
321}
322
323impl Drop for SupervisedChild {
324    fn drop(&mut self) {
325        #[cfg(unix)]
326        if let Err(error) = self.terminate_process_group() {
327            tracing::debug!(
328                program = %self.program,
329                %error,
330                "failed to SIGTERM supervised child process group during drop"
331            );
332        }
333    }
334}
335
336impl SupervisedChild {
337    /// Program path or name used to spawn the child.
338    pub fn program(&self) -> &str {
339        &self.program
340    }
341
342    /// Operating-system process id, if the child has started and is still known.
343    pub fn id(&self) -> Option<u32> {
344        self.child.id()
345    }
346
347    /// Take the child's stdin pipe.
348    pub fn take_stdin(&mut self) -> Option<ChildStdin> {
349        self.child.stdin.take()
350    }
351
352    /// Take the child's stdout pipe.
353    pub fn take_stdout(&mut self) -> Option<ChildStdout> {
354        self.child.stdout.take()
355    }
356
357    /// Take the child's stderr pipe.
358    pub fn take_stderr(&mut self) -> Option<ChildStderr> {
359        self.child.stderr.take()
360    }
361
362    /// Return the child exit status if it has already exited.
363    pub fn try_wait(&mut self) -> Result<Option<ExitStatus>, NodeSupervisorError> {
364        self.child
365            .try_wait()
366            .map_err(|source| NodeSupervisorError::Status {
367                program: self.program.clone(),
368                source,
369            })
370    }
371
372    /// Wait for the child to exit.
373    #[tracing::instrument(skip_all, fields(program = %self.program))]
374    pub async fn wait(&mut self) -> Result<ExitStatus, NodeSupervisorError> {
375        self.child
376            .wait()
377            .await
378            .map_err(|source| NodeSupervisorError::Wait {
379                program: self.program.clone(),
380                source,
381            })
382    }
383
384    /// Force-stop the child process itself and wait for it to exit.
385    ///
386    /// This kills only the direct child; use the process-group shutdown path
387    /// when the child may have spawned its own descendants.
388    #[tracing::instrument(skip_all, fields(program = %self.program))]
389    pub async fn kill_and_wait(&mut self) -> Result<ExitStatus, NodeSupervisorError> {
390        if let Some(status) = self.try_wait()? {
391            return Ok(status);
392        }
393
394        self.child
395            .start_kill()
396            .map_err(|source| NodeSupervisorError::Stop {
397                program: self.program.clone(),
398                source,
399            })?;
400        self.wait().await
401    }
402
403    /// Send SIGTERM to the child's process group.
404    #[cfg(unix)]
405    pub fn terminate_process_group(&self) -> Result<(), NodeSupervisorError> {
406        self.signal_process_group(libc::SIGTERM)
407    }
408
409    /// Send SIGKILL to the child's process group.
410    #[cfg(unix)]
411    pub fn kill_process_group(&self) -> Result<(), NodeSupervisorError> {
412        self.signal_process_group(libc::SIGKILL)
413    }
414
415    /// Return true when the child's process group still has live members.
416    #[cfg(unix)]
417    pub fn process_group_exists(&self) -> Result<bool, NodeSupervisorError> {
418        let Some(target) = self.process_group_signal_target()? else {
419            return Ok(false);
420        };
421
422        process_group_has_live_member(-target).map_err(|source| NodeSupervisorError::Signal {
423            program: self.program.clone(),
424            signal: ChildSignal::from_raw(0),
425            source,
426        })
427    }
428
429    /// Unix process-group id used for child lifecycle signals.
430    #[cfg(unix)]
431    pub(crate) fn process_group_id(&self) -> Option<i32> {
432        self.process_group
433    }
434
435    #[cfg(unix)]
436    fn signal_process_group(&self, signal: i32) -> Result<(), NodeSupervisorError> {
437        let Some(target) = self.process_group_signal_target()? else {
438            return Ok(());
439        };
440
441        // SAFETY: kill(2) is called with the child's process-group id and a
442        // signal constant. It does not alias memory or retain pointers.
443        let result = unsafe { libc::kill(target, signal) };
444        if result == 0 {
445            return Ok(());
446        }
447
448        let source = std::io::Error::last_os_error();
449        if source.raw_os_error() == Some(libc::ESRCH) {
450            return Ok(());
451        }
452
453        Err(NodeSupervisorError::Signal {
454            program: self.program.clone(),
455            signal: ChildSignal::from_raw(signal),
456            source,
457        })
458    }
459
460    #[cfg(unix)]
461    fn process_group_signal_target(&self) -> Result<Option<i32>, NodeSupervisorError> {
462        let process_group = match self.process_group {
463            Some(process_group) => process_group,
464            None => match self.id() {
465                Some(_) => child_id_to_i32(self.id(), &self.program)?,
466                None => return Ok(None),
467            },
468        };
469
470        Ok(Some(-process_group))
471    }
472}
473
474/// Report whether `process_group` has a member that has not exited.
475///
476/// An exited process stays in its group as a zombie until its parent reaps it.
477/// An orphaned descendant waits on PID 1 for that, and in some containers PID 1
478/// reaps late or never, so `kill(-pgid, 0)` alone would report a stopped node
479/// as still running. A zombie holds no ports, files, or locks, so on Linux a
480/// group whose only members are zombies is reported as gone. Anything the scan
481/// cannot inspect counts as a live member, so an uncertain answer is "live".
482#[cfg(unix)]
483pub(crate) fn process_group_has_live_member(process_group: i32) -> std::io::Result<bool> {
484    // SAFETY: kill(2) with signal 0 performs existence/permission checks
485    // for the process group and does not deliver a signal.
486    let result = unsafe { libc::kill(-process_group, 0) };
487    if result != 0 {
488        let source = std::io::Error::last_os_error();
489        return match source.raw_os_error() {
490            Some(libc::ESRCH) => Ok(false),
491            Some(libc::EPERM) => Ok(true),
492            _ => Err(source),
493        };
494    }
495
496    #[cfg(target_os = "linux")]
497    return Ok(linux_process_group_has_running_member(process_group));
498
499    #[cfg(not(target_os = "linux"))]
500    Ok(true)
501}
502
503/// Scan `/proc` for a member of `process_group` with a thread that has not exited.
504///
505/// Only a process shown to have vanished or to belong to another group is ruled
506/// out. A process whose stat cannot be read might be a live member, so it makes
507/// the group live; with a restricted procfs this falls back to what `kill`
508/// reported.
509///
510/// A thread-group leader that exits before its other threads reports the zombie
511/// state while those threads run, so a zombie leader is checked thread by thread.
512#[cfg(target_os = "linux")]
513fn linux_process_group_has_running_member(process_group: i32) -> bool {
514    let Ok(entries) = std::fs::read_dir("/proc") else {
515        return true;
516    };
517
518    entries.into_iter().any(|entry| {
519        let Ok(entry) = entry else {
520            return true;
521        };
522        let is_pid = entry
523            .file_name()
524            .to_str()
525            .is_some_and(|name| !name.is_empty() && name.bytes().all(|b| b.is_ascii_digit()));
526        if !is_pid {
527            return false;
528        }
529
530        match read_linux_stat(&entry.path().join("stat")) {
531            LinuxStat::Vanished => false,
532            LinuxStat::Unreadable => true,
533            LinuxStat::Read {
534                process_group: member_group,
535                ..
536            } if member_group != process_group => false,
537            LinuxStat::Read { state, .. } if !linux_state_has_exited(state) => true,
538            LinuxStat::Read { .. } => linux_exited_leader_has_running_thread(&entry.path()),
539        }
540    })
541}
542
543#[cfg(target_os = "linux")]
544fn linux_exited_leader_has_running_thread(process_dir: &std::path::Path) -> bool {
545    let tasks = match std::fs::read_dir(process_dir.join("task")) {
546        Ok(tasks) => tasks,
547        Err(error) => return !linux_read_error_means_vanished(&error),
548    };
549
550    tasks.into_iter().any(|task| {
551        let Ok(task) = task else {
552            return true;
553        };
554        match read_linux_stat(&task.path().join("stat")) {
555            LinuxStat::Vanished => false,
556            LinuxStat::Unreadable => true,
557            LinuxStat::Read { state, .. } => !linux_state_has_exited(state),
558        }
559    })
560}
561
562/// What reading one `/proc` stat file showed about a process or thread.
563#[cfg(target_os = "linux")]
564#[derive(Debug, PartialEq, Eq)]
565enum LinuxStat {
566    /// The process or thread exited and was reaped before it could be read.
567    Vanished,
568    /// The file exists but could not be read or parsed.
569    Unreadable,
570    Read {
571        state: char,
572        process_group: i32,
573    },
574}
575
576#[cfg(target_os = "linux")]
577fn read_linux_stat(path: &std::path::Path) -> LinuxStat {
578    classify_linux_stat_read(std::fs::read_to_string(path))
579}
580
581#[cfg(target_os = "linux")]
582fn classify_linux_stat_read(read: std::io::Result<String>) -> LinuxStat {
583    match read {
584        Ok(stat) => parse_linux_stat_state_and_group(&stat).map_or(
585            LinuxStat::Unreadable,
586            |(state, process_group)| LinuxStat::Read {
587                state,
588                process_group,
589            },
590        ),
591        Err(error) if linux_read_error_means_vanished(&error) => LinuxStat::Vanished,
592        Err(_) => LinuxStat::Unreadable,
593    }
594}
595
596#[cfg(target_os = "linux")]
597fn linux_read_error_means_vanished(error: &std::io::Error) -> bool {
598    error.kind() == std::io::ErrorKind::NotFound || error.raw_os_error() == Some(libc::ESRCH)
599}
600
601/// Parse the state and process-group fields of a `/proc/<pid>/stat` line.
602///
603/// The command name is parenthesized and may itself contain spaces or `)`, so
604/// fields are counted from the last `)`.
605#[cfg(target_os = "linux")]
606fn parse_linux_stat_state_and_group(stat: &str) -> Option<(char, i32)> {
607    let mut fields = stat.get(stat.rfind(')')? + 1..)?.split_whitespace();
608    let state = fields.next()?.chars().next()?;
609    let _parent_pid = fields.next()?;
610    let process_group = fields.next()?.parse().ok()?;
611    Some((state, process_group))
612}
613
614#[cfg(target_os = "linux")]
615fn linux_state_has_exited(state: char) -> bool {
616    matches!(state, 'Z' | 'X' | 'x')
617}
618
619#[cfg(all(test, unix))]
620mod tests;