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#[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 StillRunning(u32),
29}
30
31impl Supervisor {
32 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 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 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 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 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
149fn group_alive(pid: u32) -> bool {
167 reap_group(pid);
168 let group = Pid::from_raw(pid as i32);
172 !matches!(signal::killpg(group, None), Err(Errno::ESRCH))
175}
176
177fn reap_group(pid: u32) {
181 let group = Pid::from_raw(-(pid as i32));
184 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 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
206fn 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
222pub 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
279pub 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 #[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 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 #[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}