Skip to main content

tatara_init/
supervisor.rs

1//! The supervision engine — abstract `Supervisor` trait + two impls.
2//!
3//! `LinuxSupervisor` does the real PID-1 work via `fork(2)`/`execve(2)`;
4//! `MockSupervisor` records actions for unit tests. The orchestration loop
5//! in [`run_once`] is written against the trait, so testing the scheduler
6//! logic requires no privileges.
7
8use std::collections::HashMap;
9use thiserror::Error;
10
11use crate::config::{InitConfig, RestartPolicy, Service};
12
13pub type Pid = i32;
14
15#[derive(Debug, Error)]
16pub enum SupervisorError {
17    #[error("failed to spawn {name}: {reason}")]
18    Spawn { name: String, reason: String },
19
20    #[error("failed to signal pid {pid}: {reason}")]
21    Signal { pid: Pid, reason: String },
22
23    #[error("io: {0}")]
24    Io(#[from] std::io::Error),
25}
26
27pub type Result<T> = std::result::Result<T, SupervisorError>;
28
29/// Minimum interface a supervisor backend must expose.
30///
31/// Designed small enough for a mock to be a few dozen lines and big enough
32/// that the scheduler loop doesn't need to reach past it for anything
33/// Linux-specific.
34pub trait Supervisor {
35    /// Spawn a service; return its PID.
36    fn spawn(&mut self, svc: &Service) -> Result<Pid>;
37
38    /// Deliver SIGTERM to a PID. For graceful termination.
39    fn terminate(&mut self, pid: Pid) -> Result<()>;
40
41    /// Deliver SIGKILL. For the last resort.
42    fn kill(&mut self, pid: Pid) -> Result<()>;
43
44    /// Block until any child exits, or return None immediately if no
45    /// children have exited. Returns `(pid, exit_status)` when available.
46    fn reap_one(&mut self) -> Result<Option<(Pid, i32)>>;
47
48    /// Live children tracked by this supervisor.
49    fn children(&self) -> Vec<(Pid, String)>;
50}
51
52// ── Mock ────────────────────────────────────────────────────────────────
53
54/// In-memory supervisor for tests. Records spawns + signals; `reap_one`
55/// returns queued exits in FIFO order.
56#[derive(Default)]
57pub struct MockSupervisor {
58    next_pid: Pid,
59    live: HashMap<Pid, String>,
60    /// Pre-queued (pid, exit_status) pairs the test wants `reap_one` to
61    /// return next.
62    pub queued_exits: Vec<(Pid, i32)>,
63    /// Log of every action the scheduler took; lets tests assert on order.
64    pub log: Vec<String>,
65}
66
67impl MockSupervisor {
68    pub fn new() -> Self {
69        Self {
70            next_pid: 100,
71            ..Default::default()
72        }
73    }
74
75    /// Inject an exit — `reap_one` will surface this next.
76    pub fn queue_exit(&mut self, pid: Pid, status: i32) {
77        self.queued_exits.push((pid, status));
78    }
79}
80
81impl Supervisor for MockSupervisor {
82    fn spawn(&mut self, svc: &Service) -> Result<Pid> {
83        let pid = self.next_pid;
84        self.next_pid += 1;
85        self.live.insert(pid, svc.name.clone());
86        self.log.push(format!("spawn {} ({})", svc.name, pid));
87        Ok(pid)
88    }
89
90    fn terminate(&mut self, pid: Pid) -> Result<()> {
91        self.log.push(format!("SIGTERM {pid}"));
92        Ok(())
93    }
94
95    fn kill(&mut self, pid: Pid) -> Result<()> {
96        self.log.push(format!("SIGKILL {pid}"));
97        self.live.remove(&pid);
98        Ok(())
99    }
100
101    fn reap_one(&mut self) -> Result<Option<(Pid, i32)>> {
102        if let Some((pid, status)) = self.queued_exits.pop() {
103            let name = self.live.remove(&pid).unwrap_or_else(|| "?".into());
104            self.log.push(format!("reap {name} ({pid}) -> {status}"));
105            Ok(Some((pid, status)))
106        } else {
107            Ok(None)
108        }
109    }
110
111    fn children(&self) -> Vec<(Pid, String)> {
112        self.live.iter().map(|(p, n)| (*p, n.clone())).collect()
113    }
114}
115
116// ── Linux ───────────────────────────────────────────────────────────────
117
118/// Real PID-1 backend. Uses libc `fork`/`execve`/`waitpid`/`kill`. Safe to
119/// compile on macOS (same libc surface); running meaningfully requires
120/// being PID 1 in a Linux kernel.
121pub struct LinuxSupervisor {
122    live: HashMap<Pid, String>,
123}
124
125impl Default for LinuxSupervisor {
126    fn default() -> Self {
127        Self::new()
128    }
129}
130
131impl LinuxSupervisor {
132    pub fn new() -> Self {
133        Self {
134            live: HashMap::new(),
135        }
136    }
137}
138
139#[cfg(unix)]
140impl Supervisor for LinuxSupervisor {
141    fn spawn(&mut self, svc: &Service) -> Result<Pid> {
142        use std::ffi::CString;
143        // `resolved_exec()` honors the `body` (Lisp form) shortcut when
144        // present, rewriting the service to invoke `tatara-init --eval`.
145        let argv: Vec<String> = split_exec(&svc.resolved_exec());
146        if argv.is_empty() {
147            return Err(SupervisorError::Spawn {
148                name: svc.name.clone(),
149                reason: "empty exec line".into(),
150            });
151        }
152        let argv_c: Vec<CString> = argv
153            .iter()
154            .map(|s| CString::new(s.as_str()).unwrap_or_default())
155            .collect();
156        let cwd_c = svc.workdir.as_deref().and_then(|c| CString::new(c).ok());
157        let env_c: Vec<(CString, CString)> = svc
158            .env
159            .iter()
160            .filter_map(|(k, v)| {
161                Some((
162                    CString::new(k.as_str()).ok()?,
163                    CString::new(v.as_str()).ok()?,
164                ))
165            })
166            .collect();
167
168        // SAFETY: called before we start threads. fork()==0 is the child.
169        let pid = unsafe { libc::fork() };
170        if pid < 0 {
171            return Err(SupervisorError::Spawn {
172                name: svc.name.clone(),
173                reason: std::io::Error::last_os_error().to_string(),
174            });
175        }
176        if pid == 0 {
177            // ── child path: set env, chdir, execvp. No Rust-level panics
178            // (would poison shared state in the parent via atexit etc.).
179            if let Some(c) = cwd_c.as_ref() {
180                unsafe { libc::chdir(c.as_ptr()) };
181            }
182            for (k, v) in &env_c {
183                unsafe { libc::setenv(k.as_ptr(), v.as_ptr(), 1) };
184            }
185            let mut argv_ptr: Vec<*const libc::c_char> =
186                argv_c.iter().map(|s| s.as_ptr()).collect();
187            argv_ptr.push(std::ptr::null());
188            unsafe { libc::execvp(argv_c[0].as_ptr(), argv_ptr.as_ptr()) };
189            // execvp only returns on failure.
190            unsafe { libc::_exit(127) };
191        }
192
193        self.live.insert(pid, svc.name.clone());
194        Ok(pid)
195    }
196
197    fn terminate(&mut self, pid: Pid) -> Result<()> {
198        send_signal(pid, libc::SIGTERM)
199    }
200
201    fn kill(&mut self, pid: Pid) -> Result<()> {
202        let out = send_signal(pid, libc::SIGKILL);
203        self.live.remove(&pid);
204        out
205    }
206
207    fn reap_one(&mut self) -> Result<Option<(Pid, i32)>> {
208        let mut status: libc::c_int = 0;
209        // SAFETY: waitpid is async-signal-safe; -1 reaps any child, WNOHANG
210        // makes it non-blocking.
211        let r = unsafe { libc::waitpid(-1, &mut status, libc::WNOHANG) };
212        if r == 0 {
213            return Ok(None);
214        }
215        if r < 0 {
216            let e = std::io::Error::last_os_error();
217            if e.raw_os_error() == Some(libc::ECHILD) {
218                return Ok(None);
219            }
220            return Err(SupervisorError::Io(e));
221        }
222        self.live.remove(&r);
223        // libc::WIFEXITED/WEXITSTATUS/WTERMSIG are const fns on modern libc
224        // and no longer require an unsafe block.
225        let exit_code = if libc::WIFEXITED(status) {
226            libc::WEXITSTATUS(status)
227        } else {
228            128 + libc::WTERMSIG(status)
229        };
230        Ok(Some((r, exit_code)))
231    }
232
233    fn children(&self) -> Vec<(Pid, String)> {
234        self.live.iter().map(|(p, n)| (*p, n.clone())).collect()
235    }
236}
237
238#[cfg(not(unix))]
239impl Supervisor for LinuxSupervisor {
240    fn spawn(&mut self, _svc: &Service) -> Result<Pid> {
241        Err(SupervisorError::Spawn {
242            name: "n/a".into(),
243            reason: "LinuxSupervisor requires a Unix host".into(),
244        })
245    }
246    fn terminate(&mut self, _pid: Pid) -> Result<()> {
247        Ok(())
248    }
249    fn kill(&mut self, _pid: Pid) -> Result<()> {
250        Ok(())
251    }
252    fn reap_one(&mut self) -> Result<Option<(Pid, i32)>> {
253        Ok(None)
254    }
255    fn children(&self) -> Vec<(Pid, String)> {
256        vec![]
257    }
258}
259
260#[cfg(unix)]
261fn send_signal(pid: Pid, sig: libc::c_int) -> Result<()> {
262    // SAFETY: kill(2) takes a PID + signum; no aliasing concerns.
263    let r = unsafe { libc::kill(pid, sig) };
264    if r != 0 {
265        let e = std::io::Error::last_os_error();
266        if e.raw_os_error() == Some(libc::ESRCH) {
267            return Ok(()); // already dead
268        }
269        return Err(SupervisorError::Signal {
270            pid,
271            reason: e.to_string(),
272        });
273    }
274    Ok(())
275}
276
277#[cfg(not(unix))]
278fn send_signal(_pid: Pid, _sig: i32) -> Result<()> {
279    Ok(())
280}
281
282// ── scheduler core — written once, reused by real + mock backends ────────
283
284/// Walk the config and spawn every enabled service. Returns the per-name
285/// PIDs. Deterministic; errors on the first spawn failure with no partial
286/// state (caller can retry with a new env).
287pub fn boot<S: Supervisor>(sup: &mut S, cfg: &InitConfig) -> Result<HashMap<String, Pid>> {
288    let mut by_name = HashMap::new();
289    for svc in &cfg.services {
290        if !svc.enable {
291            continue;
292        }
293        let pid = sup.spawn(svc)?;
294        by_name.insert(svc.name.clone(), pid);
295    }
296    Ok(by_name)
297}
298
299/// Drain any pending child exits. Applies the restart policy for each
300/// service keyed by the PID we spawned for it.
301pub fn run_once<S: Supervisor>(
302    sup: &mut S,
303    cfg: &InitConfig,
304    tracking: &mut HashMap<Pid, String>,
305) -> Result<Vec<ReapedEvent>> {
306    let mut events = Vec::new();
307    while let Some((pid, status)) = sup.reap_one()? {
308        let name = tracking.remove(&pid);
309        let svc = name
310            .as_deref()
311            .and_then(|n| cfg.services.iter().find(|s| s.name == n));
312        let restart = svc.map(|s| s.restart).unwrap_or(RestartPolicy::Never);
313        let should_restart = match (restart, status) {
314            (RestartPolicy::Always, _) => true,
315            (RestartPolicy::OnFailure, 0) => false,
316            (RestartPolicy::OnFailure, _) => true,
317            (RestartPolicy::Never, _) => false,
318        };
319        let new_pid = if should_restart {
320            if let Some(s) = svc {
321                let new_pid = sup.spawn(s)?;
322                tracking.insert(new_pid, s.name.clone());
323                Some(new_pid)
324            } else {
325                None
326            }
327        } else {
328            None
329        };
330        events.push(ReapedEvent {
331            pid,
332            name: name.unwrap_or_else(|| "unknown".into()),
333            exit_status: status,
334            restarted_as: new_pid,
335        });
336    }
337    Ok(events)
338}
339
340#[derive(Debug, Clone)]
341pub struct ReapedEvent {
342    pub pid: Pid,
343    pub name: String,
344    pub exit_status: i32,
345    pub restarted_as: Option<Pid>,
346}
347
348// ── helpers ─────────────────────────────────────────────────────────────
349
350fn split_exec(cmd: &str) -> Vec<String> {
351    cmd.split_whitespace().map(String::from).collect()
352}
353
354#[cfg(test)]
355mod tests {
356    use super::*;
357    use crate::config::{RestartPolicy, Service};
358
359    fn svc(name: &str, exec: &str, restart: RestartPolicy, enable: bool) -> Service {
360        Service {
361            name: name.into(),
362            exec: exec.into(),
363            body: None,
364            restart,
365            env: vec![],
366            workdir: None,
367            enable,
368        }
369    }
370
371    #[test]
372    fn boot_spawns_every_enabled_service() {
373        let mut sup = MockSupervisor::new();
374        let cfg = InitConfig {
375            services: vec![
376                svc("a", "/a", RestartPolicy::Never, true),
377                svc("b", "/b", RestartPolicy::Never, false), // disabled
378                svc("c", "/c", RestartPolicy::Never, true),
379            ],
380            ..Default::default()
381        };
382        let by_name = boot(&mut sup, &cfg).unwrap();
383        assert_eq!(by_name.len(), 2);
384        assert!(by_name.contains_key("a"));
385        assert!(by_name.contains_key("c"));
386        assert_eq!(sup.children().len(), 2);
387    }
388
389    #[test]
390    fn restart_policy_always_respawns_on_zero_exit() {
391        let mut sup = MockSupervisor::new();
392        let cfg = InitConfig {
393            services: vec![svc("daemon", "/daemon", RestartPolicy::Always, true)],
394            ..Default::default()
395        };
396        let by_name = boot(&mut sup, &cfg).unwrap();
397        let mut tracking: HashMap<Pid, String> =
398            by_name.iter().map(|(n, p)| (*p, n.clone())).collect();
399        sup.queue_exit(tracking.keys().next().copied().unwrap(), 0);
400        let events = run_once(&mut sup, &cfg, &mut tracking).unwrap();
401        assert_eq!(events.len(), 1);
402        assert!(events[0].restarted_as.is_some());
403        assert_eq!(sup.children().len(), 1);
404    }
405
406    #[test]
407    fn restart_policy_on_failure_respawns_only_on_nonzero() {
408        let mut sup = MockSupervisor::new();
409        let cfg = InitConfig {
410            services: vec![svc("job", "/job", RestartPolicy::OnFailure, true)],
411            ..Default::default()
412        };
413        let by_name = boot(&mut sup, &cfg).unwrap();
414        let mut tracking: HashMap<Pid, String> =
415            by_name.iter().map(|(n, p)| (*p, n.clone())).collect();
416        let pid = *tracking.keys().next().unwrap();
417        sup.queue_exit(pid, 0);
418        let ev = run_once(&mut sup, &cfg, &mut tracking).unwrap();
419        assert_eq!(ev.len(), 1);
420        assert!(ev[0].restarted_as.is_none());
421        assert_eq!(sup.children().len(), 0);
422    }
423
424    #[test]
425    fn restart_policy_never_never_respawns() {
426        let mut sup = MockSupervisor::new();
427        let cfg = InitConfig {
428            services: vec![svc("oneshot", "/oneshot", RestartPolicy::Never, true)],
429            ..Default::default()
430        };
431        let by_name = boot(&mut sup, &cfg).unwrap();
432        let mut tracking: HashMap<Pid, String> =
433            by_name.iter().map(|(n, p)| (*p, n.clone())).collect();
434        sup.queue_exit(*tracking.keys().next().unwrap(), 1);
435        let ev = run_once(&mut sup, &cfg, &mut tracking).unwrap();
436        assert_eq!(ev.len(), 1);
437        assert!(ev[0].restarted_as.is_none());
438    }
439
440    #[test]
441    fn reap_one_returns_none_when_nothing_queued() {
442        let mut sup = MockSupervisor::new();
443        assert!(sup.reap_one().unwrap().is_none());
444    }
445
446    #[test]
447    fn mock_logs_are_sequential_and_useful() {
448        let mut sup = MockSupervisor::new();
449        let s = svc("x", "/x", RestartPolicy::Never, true);
450        sup.spawn(&s).unwrap();
451        let children = sup.children();
452        assert_eq!(children.len(), 1);
453        let pid = children[0].0;
454        sup.terminate(pid).unwrap();
455        sup.queue_exit(pid, 0);
456        sup.reap_one().unwrap();
457        assert_eq!(sup.log[0], "spawn x (100)");
458        assert_eq!(sup.log[1], "SIGTERM 100");
459        assert_eq!(sup.log[2], "reap x (100) -> 0");
460    }
461}