Skip to main content

agentd/supervisor/
kill.rs

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