Skip to main content

newgit_core/
supervisor.rs

1use std::io::Write as _;
2use std::process::{Command, Stdio};
3use std::str::FromStr as _;
4use std::time::Duration;
5
6use camino::{Utf8Path, Utf8PathBuf};
7use nix::errno::Errno;
8use nix::sys::signal::{self, Signal};
9use nix::sys::wait::{WaitPidFlag, WaitStatus, waitpid};
10use nix::unistd::Pid;
11
12use crate::error::{NewgitError, Result};
13use crate::materializer::create_dir_all;
14
15/// Minimal PID-file supervision for `long_running` actions: start detached
16/// in a fresh process group with output to a log file, record the PID, stop
17/// by signaling the group. No daemon, no restart policy.
18#[derive(Debug, Clone)]
19pub struct Supervisor {
20    state_dir: Utf8PathBuf,
21}
22
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum StopOutcome {
25    Stopped(u32),
26    NotRunning,
27    /// The group ignored the signal within the grace period.
28    StillRunning(u32),
29}
30
31impl Supervisor {
32    /// `state_dir` is per instance: `.newgit/state/<slug>/`.
33    pub fn new(state_dir: Utf8PathBuf) -> Self {
34        Self { state_dir }
35    }
36
37    pub fn running_pid(&self, resource: &str) -> Option<u32> {
38        let pid = std::fs::read_to_string(self.pid_path(resource))
39            .ok()?
40            .trim()
41            .parse::<u32>()
42            .ok()?;
43        group_alive(pid).then_some(pid)
44    }
45
46    pub fn start(
47        &self,
48        resource: &str,
49        command: &str,
50        cwd: &Utf8Path,
51        env: &[(String, String)],
52        log_path: &Utf8Path,
53    ) -> Result<u32> {
54        if let Some(pid) = self.running_pid(resource) {
55            return Err(NewgitError::AlreadyRunning {
56                resource: resource.to_owned(),
57                pid,
58            });
59        }
60
61        if let Some(parent) = log_path.parent() {
62            create_dir_all(parent)?;
63        }
64        let mut log =
65            std::fs::File::create(log_path).map_err(|source| NewgitError::io(log_path, source))?;
66        writeln!(log, "[newgit] $ {command}")
67            .map_err(|source| NewgitError::io(log_path, source))?;
68        let stderr_log = log
69            .try_clone()
70            .map_err(|source| NewgitError::io(log_path, source))?;
71
72        let mut cmd = Command::new("sh");
73        cmd.arg("-c")
74            .arg(command)
75            .current_dir(cwd)
76            .stdin(Stdio::null())
77            .stdout(Stdio::from(log))
78            .stderr(Stdio::from(stderr_log));
79        cmd.envs(env.iter().map(|(key, value)| (key, value)));
80        #[cfg(unix)]
81        {
82            use std::os::unix::process::CommandExt as _;
83            // Fresh group so `stop` can signal the whole process tree.
84            cmd.process_group(0);
85        }
86
87        let child = cmd.spawn().map_err(|source| NewgitError::SourceCommand {
88            command: format!("sh -c {command}"),
89            stderr: source.to_string(),
90        })?;
91        let pid = child.id();
92        // Deliberately not waited on: the process outlives this invocation.
93        drop(child);
94
95        create_dir_all(&self.state_dir)?;
96        let pid_path = self.pid_path(resource);
97        std::fs::write(&pid_path, format!("{pid}\n"))
98            .map_err(|source| NewgitError::io(pid_path, source))?;
99        Ok(pid)
100    }
101
102    /// Signal the process group and wait up to ~5s for it to exit.
103    pub fn stop(&self, resource: &str, signal: &str) -> Result<StopOutcome> {
104        let Some(pid) = self.running_pid(resource) else {
105            let _ = std::fs::remove_file(self.pid_path(resource));
106            return Ok(StopOutcome::NotRunning);
107        };
108
109        signal_group(pid, signal)?;
110        for _ in 0..50 {
111            if !group_alive(pid) {
112                let _ = std::fs::remove_file(self.pid_path(resource));
113                return Ok(StopOutcome::Stopped(pid));
114            }
115            std::thread::sleep(Duration::from_millis(100));
116        }
117        Ok(StopOutcome::StillRunning(pid))
118    }
119
120    /// Remove PID files whose process group is gone — a process that died
121    /// on its own, or outlived a reboot. Returns what was removed, or what
122    /// would be under `dry_run`.
123    pub fn prune_dead_pids(&self, dry_run: bool) -> Result<Vec<Utf8PathBuf>> {
124        let mut removed = Vec::new();
125        for path in crate::store::read_dir_sorted(&self.state_dir)? {
126            if path.extension() != Some("pid") {
127                continue;
128            }
129            let alive = std::fs::read_to_string(&path)
130                .ok()
131                .and_then(|text| text.trim().parse::<u32>().ok())
132                .is_some_and(group_alive);
133            if alive {
134                continue;
135            }
136            if !dry_run {
137                std::fs::remove_file(&path).map_err(|source| NewgitError::io(&path, source))?;
138            }
139            removed.push(path);
140        }
141        Ok(removed)
142    }
143
144    fn pid_path(&self, resource: &str) -> Utf8PathBuf {
145        self.state_dir.join(format!("{resource}.pid"))
146    }
147}
148
149/// Whether any live process remains in the group led by `pid`.
150///
151/// Two things make this harder than `kill(pgid, 0)`:
152///
153/// A killed child that nobody reaps becomes a zombie, and a zombie still
154/// answers signal 0 — on Linux, though not on macOS, which is why this was
155/// a platform-specific failure. Whenever the caller is the process that
156/// started the supervised command (a test harness, or anything embedding
157/// newgit-core in a long-lived process), a stopped process would otherwise
158/// look alive forever: `stop` would poll until it timed out, leave the PID
159/// file in place, and the next `start` would refuse as already running.
160/// The CLI hid this, because it exits immediately and its orphans get
161/// reparented to init, which reaps them.
162///
163/// So reap first, then ask. Reaping only ever touches this process's own
164/// children; a process group inherited from an earlier CLI invocation has
165/// no children here and `waitpid` simply reports `ECHILD`.
166fn group_alive(pid: u32) -> bool {
167    reap_group(pid);
168    // `killpg` takes the group id positively and negates it itself; passing
169    // an already-negative value is EINVAL on Linux and, worse, silently
170    // signals the single process on macOS.
171    let group = Pid::from_raw(pid as i32);
172    // ESRCH means nothing is left; EPERM means something is alive but not
173    // ours to signal, which still counts as alive.
174    !matches!(signal::killpg(group, None), Err(Errno::ESRCH))
175}
176
177/// Clear any of our own finished children in this group, so they stop
178/// answering signals. Best-effort by design: every outcome other than
179/// "reaped something" means there is nothing more to collect.
180fn reap_group(pid: u32) {
181    // `waitpid` is the mirror image of `killpg`: here the negative form is
182    // what means "any child in this process group".
183    let group = Pid::from_raw(-(pid as i32));
184    // Bounded so a pathological stream of exiting children cannot spin here.
185    for _ in 0..64 {
186        match waitpid(group, Some(WaitPidFlag::WNOHANG)) {
187            Ok(WaitStatus::StillAlive) | Err(_) => return,
188            Ok(_) => continue,
189        }
190    }
191}
192
193fn signal_group(pid: u32, signal: &str) -> Result<()> {
194    let parsed = parse_signal(signal)?;
195    let group = Pid::from_raw(pid as i32);
196    match signal::killpg(group, parsed) {
197        // Already gone is the outcome `stop` wanted, not a failure.
198        Ok(()) | Err(Errno::ESRCH) => Ok(()),
199        Err(errno) => Err(NewgitError::SourceCommand {
200            command: format!("killpg(-{pid}, {parsed})"),
201            stderr: errno.to_string(),
202        }),
203    }
204}
205
206/// Accept what a user would write in a resource definition: `term`, `TERM`,
207/// `-TERM`, or `SIGTERM` all mean the same signal.
208fn parse_signal(signal: &str) -> Result<Signal> {
209    let name = signal.trim().trim_start_matches('-').to_uppercase();
210    let name = if name.starts_with("SIG") {
211        name
212    } else {
213        format!("SIG{name}")
214    };
215    Signal::from_str(&name).map_err(|_| {
216        NewgitError::Unsupported(format!(
217            "`{signal}` is not a signal name; use term, kill, int, hup, or another SIG name"
218        ))
219    })
220}
221
222/// Run a one-shot command in the workspace, teeing output to the terminal
223/// and a log file. Returns the exit code.
224pub fn run_foreground(
225    command_line: &[String],
226    cwd: &Utf8Path,
227    env: &[(String, String)],
228    log_path: &Utf8Path,
229) -> Result<i32> {
230    if let Some(parent) = log_path.parent() {
231        create_dir_all(parent)?;
232    }
233    let mut log =
234        std::fs::File::create(log_path).map_err(|source| NewgitError::io(log_path, source))?;
235    writeln!(log, "[newgit] $ {}", command_line.join(" "))
236        .map_err(|source| NewgitError::io(log_path, source))?;
237
238    let (program, args) = command_line
239        .split_first()
240        .ok_or_else(|| NewgitError::Unsupported("empty command".to_owned()))?;
241
242    let mut child = Command::new(program)
243        .args(args)
244        .current_dir(cwd)
245        .envs(env.iter().map(|(key, value)| (key, value)))
246        .stdin(Stdio::inherit())
247        .stdout(Stdio::piped())
248        .stderr(Stdio::piped())
249        .spawn()
250        .map_err(|source| NewgitError::SourceCommand {
251            command: command_line.join(" "),
252            stderr: source.to_string(),
253        })?;
254
255    let stdout = child.stdout.take();
256    let stderr = child.stderr.take();
257    let log_err = log
258        .try_clone()
259        .map_err(|source| NewgitError::io(log_path, source))?;
260
261    let out_thread =
262        stdout.map(|stream| std::thread::spawn(move || tee(stream, std::io::stdout(), log)));
263    let err_thread =
264        stderr.map(|stream| std::thread::spawn(move || tee(stream, std::io::stderr(), log_err)));
265
266    let status = child.wait().map_err(|source| NewgitError::SourceCommand {
267        command: command_line.join(" "),
268        stderr: source.to_string(),
269    })?;
270    if let Some(thread) = out_thread {
271        let _ = thread.join();
272    }
273    if let Some(thread) = err_thread {
274        let _ = thread.join();
275    }
276    Ok(status.code().unwrap_or(-1))
277}
278
279/// Run a one-shot shell command, capturing stdout (for state refs) while
280/// still logging both streams. Unlike `run_foreground`, output does not go
281/// to the terminal — checkpoint machinery consumes it instead.
282pub fn run_captured(
283    command: &str,
284    cwd: &Utf8Path,
285    env: &[(String, String)],
286    log_path: &Utf8Path,
287) -> Result<(i32, String)> {
288    if let Some(parent) = log_path.parent() {
289        create_dir_all(parent)?;
290    }
291    let mut log =
292        std::fs::File::create(log_path).map_err(|source| NewgitError::io(log_path, source))?;
293    writeln!(log, "[newgit] $ {command}").map_err(|source| NewgitError::io(log_path, source))?;
294
295    let output = Command::new("sh")
296        .args(["-c", command])
297        .current_dir(cwd)
298        .envs(env.iter().map(|(key, value)| (key, value)))
299        .stdin(Stdio::null())
300        .output()
301        .map_err(|source| NewgitError::SourceCommand {
302            command: format!("sh -c {command}"),
303            stderr: source.to_string(),
304        })?;
305
306    log.write_all(&output.stdout)
307        .and_then(|()| log.write_all(&output.stderr))
308        .map_err(|source| NewgitError::io(log_path, source))?;
309
310    Ok((
311        output.status.code().unwrap_or(-1),
312        String::from_utf8_lossy(&output.stdout).trim().to_owned(),
313    ))
314}
315
316fn tee(
317    mut from: impl std::io::Read,
318    mut to_terminal: impl std::io::Write,
319    mut to_log: std::fs::File,
320) {
321    let mut buffer = [0u8; 8192];
322    loop {
323        match from.read(&mut buffer) {
324            Ok(0) | Err(_) => break,
325            Ok(read) => {
326                let _ = to_terminal.write_all(&buffer[..read]);
327                let _ = to_terminal.flush();
328                let _ = to_log.write_all(&buffer[..read]);
329            }
330        }
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    /// Stopping a process this very process started must actually report it
339    /// stopped. The supervised child is nobody's job to reap but ours, and a
340    /// zombie keeps answering signals on Linux — so before this was fixed,
341    /// `stop` timed out, kept the PID file, and the next `start` refused as
342    /// already running. The CLI never saw it, because it exits and lets init
343    /// reap; anything long-lived did.
344    #[test]
345    fn stopping_an_unreaped_child_reports_stopped_not_still_running() {
346        let temp = tempfile::tempdir().expect("tempdir");
347        let dir = Utf8PathBuf::from_path_buf(temp.path().to_path_buf()).expect("utf8");
348        let supervisor = Supervisor::new(dir.clone());
349
350        let pid = supervisor
351            .start("app", "sleep 30", &dir, &[], &dir.join("app.log"))
352            .expect("start");
353        assert_eq!(supervisor.running_pid("app"), Some(pid));
354
355        let outcome = supervisor.stop("app", "term").expect("stop");
356        assert_eq!(
357            outcome,
358            StopOutcome::Stopped(pid),
359            "a killed child must not linger as a zombie that still answers signals"
360        );
361        assert!(
362            supervisor.running_pid("app").is_none(),
363            "the PID file must be cleared so the resource can start again"
364        );
365
366        // And starting again works, which is what undo depends on.
367        let restarted = supervisor
368            .start("app", "sleep 30", &dir, &[], &dir.join("app.log"))
369            .expect("restart");
370        assert_ne!(restarted, pid);
371        supervisor.stop("app", "term").expect("stop again");
372    }
373
374    #[test]
375    fn signal_names_are_accepted_in_the_forms_people_write_them() {
376        for name in ["term", "TERM", "-TERM", "SIGTERM", "sigterm"] {
377            assert_eq!(parse_signal(name).expect(name), Signal::SIGTERM);
378        }
379        assert_eq!(parse_signal("kill").expect("kill"), Signal::SIGKILL);
380        assert_eq!(parse_signal("int").expect("int"), Signal::SIGINT);
381        assert!(parse_signal("banana").is_err());
382    }
383
384    /// Signalling a group that is already gone is the outcome `stop` wanted.
385    #[test]
386    fn signalling_a_dead_group_is_not_an_error() {
387        assert!(signal_group(999_999, "term").is_ok());
388        assert!(!group_alive(999_999));
389    }
390}