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. Linux-only gathering; other platforms
159/// always report [`KillModeAssessment::NotSystemd`].
160pub fn probe() -> KillModeAssessment {
161    #[cfg(target_os = "linux")]
162    {
163        assess(&gather_inputs_linux())
164    }
165    #[cfg(not(target_os = "linux"))]
166    {
167        KillModeAssessment::NotSystemd
168    }
169}
170
171#[cfg(target_os = "linux")]
172fn gather_inputs_linux() -> SystemdProbeInputs {
173    let invocation_id = std::env::var("INVOCATION_ID").ok();
174    let systemd_managed = invocation_id
175        .as_deref()
176        .map(|id| !id.trim().is_empty())
177        .unwrap_or(false);
178    let cgroup = std::fs::read_to_string("/proc/self/cgroup").ok();
179    let kill_mode_query = if systemd_managed {
180        cgroup
181            .as_deref()
182            .and_then(unit_from_cgroup)
183            .map(|unit| query_kill_mode_linux(&unit))
184    } else {
185        None
186    };
187    SystemdProbeInputs {
188        invocation_id,
189        cgroup,
190        kill_mode_query,
191    }
192}
193
194#[cfg(target_os = "linux")]
195fn query_kill_mode_linux(unit: &str) -> Result<String, String> {
196    let output = std::process::Command::new("systemctl")
197        .args(["show", "-p", "KillMode", unit])
198        .output()
199        .map_err(|err| format!("cannot run systemctl: {err}"))?;
200    if !output.status.success() {
201        return Err(format!(
202            "systemctl exited with {}: {}",
203            output.status,
204            String::from_utf8_lossy(&output.stderr).trim()
205        ));
206    }
207    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
208}
209
210#[cfg(test)]
211mod tests {
212    use super::*;
213
214    fn inputs(
215        invocation_id: Option<&str>,
216        cgroup: Option<&str>,
217        query: Option<Result<&str, &str>>,
218    ) -> SystemdProbeInputs {
219        SystemdProbeInputs {
220            invocation_id: invocation_id.map(str::to_string),
221            cgroup: cgroup.map(str::to_string),
222            kill_mode_query: query.map(|result| result.map(str::to_string).map_err(str::to_string)),
223        }
224    }
225
226    #[test]
227    fn silent_without_invocation_id() {
228        let assessment = assess(&inputs(None, Some("0::/user.slice"), None));
229        assert_eq!(assessment, KillModeAssessment::NotSystemd);
230        assert!(assessment.warning().is_none());
231
232        let empty = assess(&inputs(Some("  "), Some("0::/user.slice"), None));
233        assert_eq!(empty, KillModeAssessment::NotSystemd);
234    }
235
236    #[test]
237    fn control_group_warns() {
238        let assessment = assess(&inputs(
239            Some("abc123"),
240            Some("0::/system.slice/myapp.service"),
241            Some(Ok("KillMode=control-group\n")),
242        ));
243        assert_eq!(
244            assessment,
245            KillModeAssessment::ControlGroup {
246                unit: "myapp.service".into()
247            }
248        );
249        let warning = assessment.warning().expect("warns");
250        assert!(warning.contains("myapp.service"));
251        assert!(warning.contains("KillMode=control-group"));
252    }
253
254    #[test]
255    fn safe_kill_mode_is_silent() {
256        let assessment = assess(&inputs(
257            Some("abc123"),
258            Some("0::/system.slice/myapp.service"),
259            Some(Ok("KillMode=process\n")),
260        ));
261        assert_eq!(
262            assessment,
263            KillModeAssessment::Safe {
264                unit: "myapp.service".into(),
265                kill_mode: "process".into()
266            }
267        );
268        assert!(assessment.warning().is_none());
269    }
270
271    #[test]
272    fn systemctl_failure_warns_as_unknown() {
273        let assessment = assess(&inputs(
274            Some("abc123"),
275            Some("0::/system.slice/myapp.service"),
276            Some(Err("cannot run systemctl: No such file or directory")),
277        ));
278        match &assessment {
279            KillModeAssessment::Unknown { unit, reason } => {
280                assert_eq!(unit.as_deref(), Some("myapp.service"));
281                assert!(reason.contains("systemctl query failed"));
282            }
283            other => panic!("unexpected assessment: {other:?}"),
284        }
285        assert!(assessment.warning().is_some());
286    }
287
288    #[test]
289    fn unresolved_unit_warns_as_unknown() {
290        let assessment = assess(&inputs(Some("abc123"), Some("0::/user.slice"), None));
291        assert_eq!(
292            assessment,
293            KillModeAssessment::Unknown {
294                unit: None,
295                reason: "owning unit could not be resolved from /proc/self/cgroup".into()
296            }
297        );
298        assert!(assessment.warning().unwrap().contains("<unresolved>"));
299    }
300
301    #[test]
302    fn unit_resolution_handles_v1_and_v2_and_scopes() {
303        assert_eq!(
304            unit_from_cgroup("0::/system.slice/foo.service"),
305            Some("foo.service".into())
306        );
307        assert_eq!(
308            unit_from_cgroup("1:name=systemd:/system.slice/bar.service\n2:cpu:/"),
309            Some("bar.service".into())
310        );
311        assert_eq!(
312            unit_from_cgroup(
313                "0::/user.slice/user-1000.slice/user@1000.service/app.slice/run-u123.scope"
314            ),
315            Some("run-u123.scope".into())
316        );
317        assert_eq!(unit_from_cgroup("0::/"), None);
318        assert_eq!(unit_from_cgroup(""), None);
319    }
320
321    #[test]
322    fn kill_mode_parsing() {
323        assert_eq!(
324            parse_kill_mode("KillMode=control-group\n"),
325            Some("control-group".into())
326        );
327        assert_eq!(parse_kill_mode("KillMode=mixed"), Some("mixed".into()));
328        assert_eq!(
329            parse_kill_mode("control-group\n"),
330            Some("control-group".into())
331        );
332        assert_eq!(parse_kill_mode("KillMode="), None);
333        assert_eq!(parse_kill_mode(""), None);
334        assert_eq!(parse_kill_mode("Failed to get properties"), None);
335    }
336
337    #[cfg(not(target_os = "linux"))]
338    #[test]
339    fn probe_is_not_systemd_off_linux() {
340        assert_eq!(probe(), KillModeAssessment::NotSystemd);
341    }
342}