Skip to main content

running_process/broker/lifecycle/
process_tree.rs

1//! Process-tree cleanup setup for the broker.
2//!
3//! The broker can launch backend processes. Installing cleanup before
4//! argument dispatch ensures later serve modes inherit the same
5//! parent-death / kill-on-close containment behavior from process start.
6
7use std::{io, time::Duration};
8
9use crate::platform::process::{OwnerDeathCleanup, OwnerDeathCleanupError, OwnerDeathCleanupStage};
10
11/// Cleanup mechanism installed, or concrete lifecycle contract selected, for
12/// the current broker process.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum ProcessTreeCleanup {
15    /// Linux `PR_SET_PDEATHSIG` was installed for the broker process.
16    LinuxParentDeathSignal,
17    /// Windows kill-on-job-close containment was installed.
18    WindowsKillOnJobClose,
19    /// Windows reported that the process already belongs to a Job Object.
20    WindowsAlreadyInJob,
21    /// macOS kqueue-supervisor containment is the Phase 5 contract.
22    MacosKqueueSupervisorContract,
23    /// The current platform has no broker process-tree primitive yet.
24    UnsupportedNoop,
25}
26
27/// Maximum Phase 5 cleanup budget for a macOS backend after broker exit.
28pub const MACOS_SUPERVISOR_KILL_DEADLINE: Duration = Duration::from_secs(5);
29
30/// Concrete macOS supervisor contract for Phase 5 process-tree cleanup.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub struct MacosSupervisorContract {
33    /// PID that the supervisor child watches.
34    pub watch_pid: MacosSupervisorWatchPid,
35    /// kqueue filter registered by the supervisor.
36    pub kqueue_filter: MacosKqueueFilter,
37    /// kqueue note that reports broker exit.
38    pub kqueue_note: MacosKqueueNote,
39    /// Startup barrier before the backend endpoint can be published.
40    pub registration_barrier: MacosSupervisorRegistrationBarrier,
41    /// Race guard after kqueue registration.
42    pub race_guard: MacosSupervisorRaceGuard,
43    /// Action the supervisor performs after observing broker exit.
44    pub exit_action: MacosSupervisorExitAction,
45    /// Required cleanup deadline after broker exit.
46    pub kill_deadline: Duration,
47}
48
49impl MacosSupervisorContract {
50    /// Return the Phase 5 macOS supervisor contract.
51    pub const fn phase5() -> Self {
52        Self {
53            watch_pid: MacosSupervisorWatchPid::BrokerParent,
54            kqueue_filter: MacosKqueueFilter::Process,
55            kqueue_note: MacosKqueueNote::Exit,
56            registration_barrier: MacosSupervisorRegistrationBarrier::BeforeBackendPipePublication,
57            race_guard: MacosSupervisorRaceGuard::RecheckBrokerAliveAfterRegistration,
58            exit_action: MacosSupervisorExitAction::SigkillBackend,
59            kill_deadline: MACOS_SUPERVISOR_KILL_DEADLINE,
60        }
61    }
62
63    /// Return the kqueue filter syscall name.
64    pub const fn kqueue_filter_name(&self) -> &'static str {
65        match self.kqueue_filter {
66            MacosKqueueFilter::Process => "EVFILT_PROC",
67        }
68    }
69
70    /// Return the kqueue note syscall name.
71    pub const fn kqueue_note_name(&self) -> &'static str {
72        match self.kqueue_note {
73            MacosKqueueNote::Exit => "NOTE_EXIT",
74        }
75    }
76
77    /// Return the supervisor termination signal name.
78    pub const fn termination_signal_name(&self) -> &'static str {
79        match self.exit_action {
80            MacosSupervisorExitAction::SigkillBackend => "SIGKILL",
81        }
82    }
83}
84
85/// PID watched by the macOS supervisor child.
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
87pub enum MacosSupervisorWatchPid {
88    /// Watch the broker parent process.
89    BrokerParent,
90}
91
92/// kqueue filter used by the macOS supervisor child.
93#[derive(Debug, Clone, Copy, PartialEq, Eq)]
94pub enum MacosKqueueFilter {
95    /// `EVFILT_PROC`.
96    Process,
97}
98
99/// kqueue process note used by the macOS supervisor child.
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
101pub enum MacosKqueueNote {
102    /// `NOTE_EXIT`.
103    Exit,
104}
105
106/// Required startup barrier for the macOS supervisor child.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108pub enum MacosSupervisorRegistrationBarrier {
109    /// Register kqueue before the backend pipe is published.
110    BeforeBackendPipePublication,
111}
112
113/// Required startup race guard for the macOS supervisor child.
114#[derive(Debug, Clone, Copy, PartialEq, Eq)]
115pub enum MacosSupervisorRaceGuard {
116    /// Re-check that the broker is alive after kqueue registration.
117    RecheckBrokerAliveAfterRegistration,
118}
119
120/// Action performed by the macOS supervisor child after broker exit.
121#[derive(Debug, Clone, Copy, PartialEq, Eq)]
122pub enum MacosSupervisorExitAction {
123    /// Send `SIGKILL` to the backend process.
124    SigkillBackend,
125}
126
127/// Return the concrete macOS kqueue-supervisor contract for Phase 5.
128pub const fn macos_supervisor_contract() -> MacosSupervisorContract {
129    MacosSupervisorContract::phase5()
130}
131
132/// Errors returned while installing process-tree cleanup.
133#[derive(Debug, thiserror::Error)]
134pub enum ProcessTreeError {
135    /// Linux `prctl(PR_SET_PDEATHSIG, ...)` failed.
136    #[error("failed to install Linux parent-death signal: {0}")]
137    LinuxParentDeathSignal(io::Error),
138    /// Windows could not create or configure a kill-on-close job.
139    #[error("failed to create Windows kill-on-close Job Object: {0}")]
140    WindowsJobCreate(io::Error),
141    /// Windows could not assign the broker process to the job.
142    #[error("failed to assign broker process to Windows Job Object: {0}")]
143    WindowsJobAssign(io::Error),
144}
145
146/// Install process-tree cleanup for the current broker process.
147///
148/// On Linux this sets `PR_SET_PDEATHSIG` to `SIGTERM`. On Windows this assigns
149/// the broker to a kill-on-close Job Object unless it already belongs to one.
150/// On macOS this selects
151/// [`ProcessTreeCleanup::MacosKqueueSupervisorContract`] and the concrete
152/// [`MacosSupervisorContract`] that backend spawn wiring must honor before
153/// publishing a backend pipe.
154/// Other platforms currently return
155/// [`ProcessTreeCleanup::UnsupportedNoop`].
156pub fn install_cleanup() -> Result<ProcessTreeCleanup, ProcessTreeError> {
157    crate::platform::process::install_owner_death_cleanup()
158        .map(from_facade)
159        .map_err(from_facade_error)
160}
161
162/// Return the cleanup mechanism this platform attempts to install.
163pub fn cleanup_target() -> ProcessTreeCleanup {
164    from_facade(crate::platform::process::owner_death_cleanup_target())
165}
166
167/// Read this host's answer and say it in the broker's own vocabulary.
168///
169/// The facade reports the *guarantee* it installed; this maps that onto the
170/// names this module's public API has always used. The mapping is not purely
171/// mechanical: `SupervisorRequired` becomes the macOS kqueue contract,
172/// because on that host "the kernel will not reap for you" is precisely what
173/// obliges the supervisor the contract describes.
174fn from_facade(cleanup: OwnerDeathCleanup) -> ProcessTreeCleanup {
175    match cleanup {
176        OwnerDeathCleanup::OwnerDeathSignal => ProcessTreeCleanup::LinuxParentDeathSignal,
177        OwnerDeathCleanup::KillOnOwnerHandleClose => ProcessTreeCleanup::WindowsKillOnJobClose,
178        OwnerDeathCleanup::AlreadyContained => ProcessTreeCleanup::WindowsAlreadyInJob,
179        OwnerDeathCleanup::SupervisorRequired => ProcessTreeCleanup::MacosKqueueSupervisorContract,
180        OwnerDeathCleanup::Unsupported => ProcessTreeCleanup::UnsupportedNoop,
181    }
182}
183
184/// Map a facade failure onto the variant this module has always reported.
185///
186/// The stage travels with the error precisely so this is a lookup rather than
187/// a guess: "could not build the container" and "built it, could not join it"
188/// are different situations for an operator, and these messages have
189/// distinguished them since #427.
190fn from_facade_error(error: OwnerDeathCleanupError) -> ProcessTreeError {
191    match error.stage {
192        OwnerDeathCleanupStage::RequestSignal => {
193            ProcessTreeError::LinuxParentDeathSignal(error.source)
194        }
195        OwnerDeathCleanupStage::CreateContainer => ProcessTreeError::WindowsJobCreate(error.source),
196        OwnerDeathCleanupStage::JoinContainer => ProcessTreeError::WindowsJobAssign(error.source),
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203
204    /// Every guarantee a host can report maps to a stated contract.
205    ///
206    /// This used to walk a private `CleanupPlatform` enum that restated the
207    /// mapping it was checking, so it could only ever agree with itself. It
208    /// now walks the facade's own variants: if a host learns to report
209    /// something new, this stops compiling until someone decides what the
210    /// broker should call it.
211    #[test]
212    fn cleanup_target_model_states_phase_5_platform_contracts() {
213        for (reported, expected) in [
214            (
215                OwnerDeathCleanup::OwnerDeathSignal,
216                ProcessTreeCleanup::LinuxParentDeathSignal,
217            ),
218            (
219                OwnerDeathCleanup::KillOnOwnerHandleClose,
220                ProcessTreeCleanup::WindowsKillOnJobClose,
221            ),
222            (
223                OwnerDeathCleanup::AlreadyContained,
224                ProcessTreeCleanup::WindowsAlreadyInJob,
225            ),
226            (
227                OwnerDeathCleanup::SupervisorRequired,
228                ProcessTreeCleanup::MacosKqueueSupervisorContract,
229            ),
230            (
231                OwnerDeathCleanup::Unsupported,
232                ProcessTreeCleanup::UnsupportedNoop,
233            ),
234        ] {
235            assert_eq!(from_facade(reported), expected, "{reported:?}");
236        }
237    }
238
239    /// A failure keeps the step it failed at.
240    ///
241    /// The three variants have distinguished "could not build the container"
242    /// from "built it, could not join it" since #427, and an operator reads
243    /// them differently. Routing through the facade must not flatten that.
244    #[test]
245    fn a_failure_keeps_the_step_it_failed_at() {
246        use crate::platform::process::OwnerDeathCleanupStage;
247
248        let staged = |stage| OwnerDeathCleanupError {
249            stage,
250            source: io::Error::from_raw_os_error(5),
251        };
252        assert!(matches!(
253            from_facade_error(staged(OwnerDeathCleanupStage::RequestSignal)),
254            ProcessTreeError::LinuxParentDeathSignal(_)
255        ));
256        assert!(matches!(
257            from_facade_error(staged(OwnerDeathCleanupStage::CreateContainer)),
258            ProcessTreeError::WindowsJobCreate(_)
259        ));
260        assert!(matches!(
261            from_facade_error(staged(OwnerDeathCleanupStage::JoinContainer)),
262            ProcessTreeError::WindowsJobAssign(_)
263        ));
264    }
265
266    /// Whatever this host is, it names a contract.
267    ///
268    /// This used to spell out the expected answer per host, which duplicated
269    /// what the platform trees now assert next to the code that produces it.
270    /// The claim worth making *here* is the one a caller depends on: every
271    /// host the broker ships for has a stated containment story, so a caller
272    /// deciding whether to spawn a supervisor always gets an answer.
273    #[test]
274    fn cleanup_target_is_explicit_for_current_platform() {
275        let target = cleanup_target();
276        assert_ne!(
277            target,
278            ProcessTreeCleanup::UnsupportedNoop,
279            "every shipped host names a contract; UnsupportedNoop means one was not taught"
280        );
281        assert_eq!(
282            target,
283            install_cleanup().expect("installing must succeed where a target is claimed"),
284            "the target must be what installing actually reports"
285        );
286    }
287
288    #[test]
289    fn macos_supervisor_contract_pins_phase_5_cleanup_requirements() {
290        let contract = macos_supervisor_contract();
291
292        assert_eq!(contract.watch_pid, MacosSupervisorWatchPid::BrokerParent);
293        assert_eq!(contract.kqueue_filter_name(), "EVFILT_PROC");
294        assert_eq!(contract.kqueue_note_name(), "NOTE_EXIT");
295        assert_eq!(
296            contract.registration_barrier,
297            MacosSupervisorRegistrationBarrier::BeforeBackendPipePublication
298        );
299        assert_eq!(
300            contract.race_guard,
301            MacosSupervisorRaceGuard::RecheckBrokerAliveAfterRegistration
302        );
303        assert_eq!(contract.termination_signal_name(), "SIGKILL");
304        assert_eq!(contract.kill_deadline, Duration::from_secs(5));
305    }
306}