1#[derive(Clone, Debug, Default)]
17pub struct SystemdProbeInputs {
18 pub invocation_id: Option<String>,
20 pub cgroup: Option<String>,
22 pub kill_mode_query: Option<Result<String, String>>,
26}
27
28#[derive(Clone, Debug, PartialEq, Eq)]
30pub enum KillModeAssessment {
31 NotSystemd,
33 Safe {
35 unit: String,
37 kill_mode: String,
39 },
40 ControlGroup {
43 unit: String,
45 },
46 Unknown {
48 unit: Option<String>,
50 reason: String,
52 },
53}
54
55impl KillModeAssessment {
56 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
80pub 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
122pub 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
140pub 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 if !trimmed.contains('=') && !trimmed.contains(char::is_whitespace) {
153 return Some(trimmed.to_string());
154 }
155 None
156}
157
158pub 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}