Skip to main content

agentd/supervisor/
kill.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! The bounded teardown ladder.
3//!
4//! When a subtree must die (SIGTERM-to-agentd, a deadline/stuck verdict, or a
5//! tree-budget breach), the reactor tears it down **deepest-first** (children
6//! before parents, via `tree.deepest_first()`, so a parent can't spawn
7//! replacements mid-teardown — the `draining` flag also blocks new spawns) and
8//! escalates per the ladder: graceful `Cancel` → `killpg(SIGTERM)` after a
9//! grace → `killpg(SIGKILL)` after a kill-grace → reap. A second SIGTERM/SIGINT
10//! (`force`) collapses straight to SIGKILL. The whole budget is bounded and
11//! must stay **< the orchestrator's `terminationGracePeriodSeconds`**, or the
12//! orchestrator SIGKILLs agentd mid-teardown and the tree leaks; the reactor
13//! enforces that ceiling.
14//!
15//! The `killpg` calls are thin libc; the **escalation timing** is the pure,
16//! unit-tested [`Ladder`] state machine.
17
18use std::time::{Duration, Instant};
19
20/// Default grace before SIGTERM (let the child wind down at a turn boundary).
21pub const DEFAULT_GRACE: Duration = Duration::from_secs(5);
22/// Default grace between SIGTERM and SIGKILL.
23pub const DEFAULT_KILL_GRACE: Duration = Duration::from_secs(2);
24
25/// What the reactor should do on this tick of a teardown.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum LadderAction {
28    /// Nothing yet — keep waiting for children to exit.
29    Wait,
30    /// `killpg(SIGTERM)` every still-live target.
31    Term,
32    /// `killpg(SIGKILL)` every still-live target.
33    Kill,
34    /// Everything has exited (or been killed and reaped) — teardown complete.
35    Done,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
39enum Phase {
40    Cancel,
41    Term,
42    Kill,
43    Done,
44}
45
46/// The escalation timer for one teardown. Construct it, send `Cancel` to the
47/// targets, then call [`Ladder::poll`] each reactor tick; perform whatever
48/// action it returns on the still-live set.
49#[derive(Debug)]
50pub struct Ladder {
51    started: Instant,
52    grace: Duration,
53    kill_grace: Duration,
54    phase: Phase,
55}
56
57impl Ladder {
58    /// Begin a teardown at `now`. The caller should immediately send a
59    /// graceful `Cancel` to every target (the ladder assumes that happened).
60    pub fn new(now: Instant, grace: Duration, kill_grace: Duration) -> Ladder {
61        Ladder {
62            started: now,
63            grace,
64            kill_grace,
65            phase: Phase::Cancel,
66        }
67    }
68
69    pub fn with_defaults(now: Instant) -> Ladder {
70        Ladder::new(now, DEFAULT_GRACE, DEFAULT_KILL_GRACE)
71    }
72
73    /// Decide the next action. `all_exited` is whether every target has been
74    /// reaped; `force` collapses straight to SIGKILL (second signal).
75    pub fn poll(&mut self, now: Instant, all_exited: bool, force: bool) -> LadderAction {
76        if all_exited {
77            self.phase = Phase::Done;
78            return LadderAction::Done;
79        }
80        if force && self.phase < Phase::Kill {
81            self.phase = Phase::Kill;
82            return LadderAction::Kill;
83        }
84        let elapsed = now.saturating_duration_since(self.started);
85        match self.phase {
86            Phase::Cancel if elapsed >= self.grace => {
87                self.phase = Phase::Term;
88                LadderAction::Term
89            }
90            Phase::Term if elapsed >= self.grace + self.kill_grace => {
91                self.phase = Phase::Kill;
92                LadderAction::Kill
93            }
94            Phase::Done => LadderAction::Done,
95            // Still within a grace window, or already SIGKILL'd and waiting to
96            // reap (a process that ignores SIGKILL is in uninterruptible sleep;
97            // the reactor's overall budget bounds the wait and logs a leak).
98            _ => LadderAction::Wait,
99        }
100    }
101}
102
103/// `killpg(pgid, sig)`. Guards `pgid > 1` so we never signal pgid 0 (our own
104/// group) or 1 (init).
105#[cfg(unix)]
106pub fn signal_group(pgid: i32, sig: i32) {
107    if pgid > 1 {
108        unsafe {
109            libc::killpg(pgid, sig);
110        }
111    }
112}
113
114#[cfg(unix)]
115pub fn term_group(pgid: i32) {
116    signal_group(pgid, libc::SIGTERM);
117}
118
119#[cfg(unix)]
120pub fn kill_group(pgid: i32) {
121    signal_group(pgid, libc::SIGKILL);
122}
123
124#[cfg(not(unix))]
125pub fn signal_group(_pgid: i32, _sig: i32) {}
126#[cfg(not(unix))]
127pub fn term_group(_pgid: i32) {}
128#[cfg(not(unix))]
129pub fn kill_group(_pgid: i32) {}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    fn ladder(now: Instant) -> Ladder {
136        Ladder::new(now, Duration::from_secs(5), Duration::from_secs(2))
137    }
138
139    #[test]
140    fn waits_during_grace() {
141        let t0 = Instant::now();
142        let mut l = ladder(t0);
143        assert_eq!(
144            l.poll(t0 + Duration::from_secs(1), false, false),
145            LadderAction::Wait
146        );
147    }
148
149    #[test]
150    fn escalates_term_then_kill() {
151        let t0 = Instant::now();
152        let mut l = ladder(t0);
153        assert_eq!(
154            l.poll(t0 + Duration::from_secs(5), false, false),
155            LadderAction::Term
156        );
157        // still waiting between term and kill
158        assert_eq!(
159            l.poll(t0 + Duration::from_secs(6), false, false),
160            LadderAction::Wait
161        );
162        // after grace + kill_grace = 7s
163        assert_eq!(
164            l.poll(t0 + Duration::from_secs(7), false, false),
165            LadderAction::Kill
166        );
167    }
168
169    #[test]
170    fn all_exited_is_done() {
171        let t0 = Instant::now();
172        let mut l = ladder(t0);
173        assert_eq!(
174            l.poll(t0 + Duration::from_secs(1), true, false),
175            LadderAction::Done
176        );
177    }
178
179    #[test]
180    fn force_collapses_to_kill() {
181        let t0 = Instant::now();
182        let mut l = ladder(t0);
183        // even at t0, force jumps straight to SIGKILL
184        assert_eq!(l.poll(t0, false, true), LadderAction::Kill);
185        // and won't re-issue Kill on the next poll (waits to reap)
186        assert_eq!(
187            l.poll(t0 + Duration::from_millis(1), false, true),
188            LadderAction::Wait
189        );
190    }
191
192    #[test]
193    fn done_after_kill_when_reaped() {
194        let t0 = Instant::now();
195        let mut l = ladder(t0);
196        l.poll(t0, false, true); // Kill
197        assert_eq!(
198            l.poll(t0 + Duration::from_secs(1), true, false),
199            LadderAction::Done
200        );
201    }
202}