Skip to main content

running_process/
systemd_killmode.rs

1//! systemd `KillMode=control-group` detection (#391, part of #354).
2//!
3//! When the daemon runs inside a systemd unit whose `KillMode` is
4//! `control-group` (systemd's default), stopping the unit kills every
5//! process in the unit's cgroup — including spawned children the daemon
6//! expected to outlive it. At startup the daemon probes for this and emits
7//! a WARN; `broker doctor` surfaces the same assessment.
8//!
9//! The decision logic ([`assess`]) is a pure function over
10//! [`SystemdProbeInputs`] so it is testable on every platform with
11//! simulated environments. Only [`probe`]'s input gathering is
12//! Linux-specific; on other platforms it reports [`KillModeAssessment::NotSystemd`].
13
14/// Inputs to the KillMode assessment, gathered by [`probe`] or injected
15/// by tests.
16#[derive(Clone, Debug, Default)]
17pub struct SystemdProbeInputs {
18    /// `$INVOCATION_ID` — set by systemd for managed services.
19    pub invocation_id: Option<String>,
20    /// Contents of `/proc/self/cgroup`.
21    pub cgroup: Option<String>,
22    /// Output of `systemctl show -p KillMode <unit>`, or the failure
23    /// reason when systemctl is unavailable / errored. `None` when the
24    /// query was never attempted (no unit resolved).
25    pub kill_mode_query: Option<Result<String, String>>,
26}
27
28/// Result of the KillMode assessment.
29#[derive(Clone, Debug, PartialEq, Eq)]
30pub enum KillModeAssessment {
31    /// Not running under systemd; nothing to report.
32    NotSystemd,
33    /// systemd-managed with a KillMode that leaves spawned children alone.
34    Safe {
35        /// Owning unit name.
36        unit: String,
37        /// Reported KillMode value (e.g. `process`, `mixed`, `none`).
38        kill_mode: String,
39    },
40    /// systemd-managed with `KillMode=control-group`: stopping the unit
41    /// reaps every spawned child.
42    ControlGroup {
43        /// Owning unit name.
44        unit: String,
45    },
46    /// systemd-managed but the KillMode could not be determined.
47    Unknown {
48        /// Owning unit name, when it could be resolved.
49        unit: Option<String>,
50        /// Why the KillMode is unknown.
51        reason: String,
52    },
53}
54
55impl KillModeAssessment {
56    /// Startup warning message, `Some` only when operators should act:
57    /// `KillMode=control-group`, or systemd-managed with an undetermined
58    /// KillMode. Silent when not under systemd or when the KillMode is
59    /// known-safe.
60    pub fn warning(&self) -> Option<String> {
61        match self {
62            KillModeAssessment::NotSystemd | KillModeAssessment::Safe { .. } => None,
63            KillModeAssessment::ControlGroup { unit } => Some(format!(
64                "running under systemd unit {unit} with KillMode=control-group: stopping the \
65                 unit will kill every spawned child process; set KillMode=process (or mixed) \
66                 in the unit to let children outlive the daemon"
67            )),
68            KillModeAssessment::Unknown { unit, reason } => {
69                let unit = unit.as_deref().unwrap_or("<unresolved>");
70                Some(format!(
71                    "running under systemd (unit {unit}) but KillMode could not be determined \
72                     ({reason}); if the unit uses the default KillMode=control-group, stopping \
73                     it will kill every spawned child process"
74                ))
75            }
76        }
77    }
78}
79
80/// Pure KillMode assessment over injected inputs.
81pub fn assess(inputs: &SystemdProbeInputs) -> KillModeAssessment {
82    let systemd_managed = inputs
83        .invocation_id
84        .as_deref()
85        .map(|id| !id.trim().is_empty())
86        .unwrap_or(false);
87    if !systemd_managed {
88        return KillModeAssessment::NotSystemd;
89    }
90    let unit = inputs.cgroup.as_deref().and_then(unit_from_cgroup);
91    let Some(unit) = unit else {
92        return KillModeAssessment::Unknown {
93            unit: None,
94            reason: "owning unit could not be resolved from /proc/self/cgroup".into(),
95        };
96    };
97    match &inputs.kill_mode_query {
98        None => KillModeAssessment::Unknown {
99            unit: Some(unit),
100            reason: "KillMode was not queried".into(),
101        },
102        Some(Err(err)) => KillModeAssessment::Unknown {
103            unit: Some(unit),
104            reason: format!("systemctl query failed: {err}"),
105        },
106        Some(Ok(output)) => match parse_kill_mode(output) {
107            Some(mode) if mode.eq_ignore_ascii_case("control-group") => {
108                KillModeAssessment::ControlGroup { unit }
109            }
110            Some(mode) => KillModeAssessment::Safe {
111                unit,
112                kill_mode: mode,
113            },
114            None => KillModeAssessment::Unknown {
115                unit: Some(unit),
116                reason: format!("unparsable systemctl output {output:?}"),
117            },
118        },
119    }
120}
121
122/// Resolve the owning systemd unit name from `/proc/self/cgroup` contents.
123///
124/// Handles cgroup v2 (`0::/system.slice/foo.service`) and v1
125/// (`1:name=systemd:/system.slice/foo.service`) layouts; the deepest
126/// `.service` / `.scope` path component wins.
127pub fn unit_from_cgroup(cgroup: &str) -> Option<String> {
128    for line in cgroup.lines() {
129        let path = line.rsplit_once(':').map(|(_, path)| path)?;
130        let unit = path
131            .split('/')
132            .rfind(|component| component.ends_with(".service") || component.ends_with(".scope"));
133        if let Some(unit) = unit {
134            return Some(unit.to_string());
135        }
136    }
137    None
138}
139
140/// Extract the KillMode value from `systemctl show -p KillMode <unit>`
141/// output (`KillMode=control-group`), tolerating a bare `--value` form.
142pub fn parse_kill_mode(output: &str) -> Option<String> {
143    let trimmed = output.trim();
144    if trimmed.is_empty() {
145        return None;
146    }
147    if let Some(value) = trimmed.strip_prefix("KillMode=") {
148        let value = value.trim();
149        return (!value.is_empty()).then(|| value.to_string());
150    }
151    // `systemctl show --value` prints the bare value.
152    if !trimmed.contains('=') && !trimmed.contains(char::is_whitespace) {
153        return Some(trimmed.to_string());
154    }
155    None
156}
157
158/// Probe the live environment. A host without control groups cannot be
159/// running under systemd, so it always reports
160/// [`KillModeAssessment::NotSystemd`].
161pub fn probe() -> KillModeAssessment {
162    match crate::platform::host::process_cgroup() {
163        None => KillModeAssessment::NotSystemd,
164        Some(cgroup) => assess(&gather_inputs(cgroup.ok())),
165    }
166}
167
168fn gather_inputs(cgroup: Option<String>) -> SystemdProbeInputs {
169    let invocation_id = crate::env_vars::INVOCATION_ID.text();
170    let systemd_managed = invocation_id
171        .as_deref()
172        .map(|id| !id.trim().is_empty())
173        .unwrap_or(false);
174    let kill_mode_query = if systemd_managed {
175        cgroup
176            .as_deref()
177            .and_then(unit_from_cgroup)
178            .map(|unit| query_kill_mode(&unit))
179    } else {
180        None
181    };
182    SystemdProbeInputs {
183        invocation_id,
184        cgroup,
185        kill_mode_query,
186    }
187}
188
189fn query_kill_mode(unit: &str) -> Result<String, String> {
190    let output = std::process::Command::new("systemctl")
191        .args(["show", "-p", "KillMode", unit])
192        .output()
193        .map_err(|err| format!("cannot run systemctl: {err}"))?;
194    if !output.status.success() {
195        return Err(format!(
196            "systemctl exited with {}: {}",
197            output.status,
198            String::from_utf8_lossy(&output.stderr).trim()
199        ));
200    }
201    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    fn inputs(
209        invocation_id: Option<&str>,
210        cgroup: Option<&str>,
211        query: Option<Result<&str, &str>>,
212    ) -> SystemdProbeInputs {
213        SystemdProbeInputs {
214            invocation_id: invocation_id.map(str::to_string),
215            cgroup: cgroup.map(str::to_string),
216            kill_mode_query: query.map(|result| result.map(str::to_string).map_err(str::to_string)),
217        }
218    }
219
220    #[test]
221    fn silent_without_invocation_id() {
222        let assessment = assess(&inputs(None, Some("0::/user.slice"), None));
223        assert_eq!(assessment, KillModeAssessment::NotSystemd);
224        assert!(assessment.warning().is_none());
225
226        let empty = assess(&inputs(Some("  "), Some("0::/user.slice"), None));
227        assert_eq!(empty, KillModeAssessment::NotSystemd);
228    }
229
230    #[test]
231    fn control_group_warns() {
232        let assessment = assess(&inputs(
233            Some("abc123"),
234            Some("0::/system.slice/myapp.service"),
235            Some(Ok("KillMode=control-group\n")),
236        ));
237        assert_eq!(
238            assessment,
239            KillModeAssessment::ControlGroup {
240                unit: "myapp.service".into()
241            }
242        );
243        let warning = assessment.warning().expect("warns");
244        assert!(warning.contains("myapp.service"));
245        assert!(warning.contains("KillMode=control-group"));
246    }
247
248    #[test]
249    fn safe_kill_mode_is_silent() {
250        let assessment = assess(&inputs(
251            Some("abc123"),
252            Some("0::/system.slice/myapp.service"),
253            Some(Ok("KillMode=process\n")),
254        ));
255        assert_eq!(
256            assessment,
257            KillModeAssessment::Safe {
258                unit: "myapp.service".into(),
259                kill_mode: "process".into()
260            }
261        );
262        assert!(assessment.warning().is_none());
263    }
264
265    #[test]
266    fn systemctl_failure_warns_as_unknown() {
267        let assessment = assess(&inputs(
268            Some("abc123"),
269            Some("0::/system.slice/myapp.service"),
270            Some(Err("cannot run systemctl: No such file or directory")),
271        ));
272        match &assessment {
273            KillModeAssessment::Unknown { unit, reason } => {
274                assert_eq!(unit.as_deref(), Some("myapp.service"));
275                assert!(reason.contains("systemctl query failed"));
276            }
277            other => panic!("unexpected assessment: {other:?}"),
278        }
279        assert!(assessment.warning().is_some());
280    }
281
282    #[test]
283    fn unresolved_unit_warns_as_unknown() {
284        let assessment = assess(&inputs(Some("abc123"), Some("0::/user.slice"), None));
285        assert_eq!(
286            assessment,
287            KillModeAssessment::Unknown {
288                unit: None,
289                reason: "owning unit could not be resolved from /proc/self/cgroup".into()
290            }
291        );
292        assert!(assessment.warning().unwrap().contains("<unresolved>"));
293    }
294
295    #[test]
296    fn unit_resolution_handles_v1_and_v2_and_scopes() {
297        assert_eq!(
298            unit_from_cgroup("0::/system.slice/foo.service"),
299            Some("foo.service".into())
300        );
301        assert_eq!(
302            unit_from_cgroup("1:name=systemd:/system.slice/bar.service\n2:cpu:/"),
303            Some("bar.service".into())
304        );
305        assert_eq!(
306            unit_from_cgroup(
307                "0::/user.slice/user-1000.slice/user@1000.service/app.slice/run-u123.scope"
308            ),
309            Some("run-u123.scope".into())
310        );
311        assert_eq!(unit_from_cgroup("0::/"), None);
312        assert_eq!(unit_from_cgroup(""), None);
313    }
314
315    #[test]
316    fn kill_mode_parsing() {
317        assert_eq!(
318            parse_kill_mode("KillMode=control-group\n"),
319            Some("control-group".into())
320        );
321        assert_eq!(parse_kill_mode("KillMode=mixed"), Some("mixed".into()));
322        assert_eq!(
323            parse_kill_mode("control-group\n"),
324            Some("control-group".into())
325        );
326        assert_eq!(parse_kill_mode("KillMode="), None);
327        assert_eq!(parse_kill_mode(""), None);
328        assert_eq!(parse_kill_mode("Failed to get properties"), None);
329    }
330
331    #[test]
332    fn probe_is_not_systemd_on_a_host_without_cgroups() {
333        if crate::platform::host::process_cgroup().is_none() {
334            assert_eq!(probe(), KillModeAssessment::NotSystemd);
335        }
336    }
337}