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 {
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}