wire/platform.rs
1//! Cross-platform process-management primitives.
2//!
3//! Wire historically called `pgrep` + `kill` directly, which gave us
4//! "unsupported platform" rot on Windows. v0.7.3 funnels every
5//! liveness check / command-line search / SIGTERM through this module
6//! so the Windows daemon + relay paths get the same teardown +
7//! respawn behavior the Linux + macOS paths have always had.
8//!
9//! ## Helpers
10//!
11//! - [`process_alive`] — "is pid <N> still around?"
12//! - [`find_processes_by_cmdline`] — `pgrep -f <pattern>` equivalent
13//! - [`kill_process`] — SIGTERM / SIGKILL equivalent (taskkill /T on
14//! Windows so the tree dies, not just the parent)
15//!
16//! Each helper returns conservative defaults on tool failure (empty
17//! Vec, `false`) so callers can chain them without aborting an upgrade
18//! mid-flight when one query hiccups.
19
20use std::process::Command;
21
22/// True iff pid is alive.
23///
24/// - Linux: `/proc/<pid>` exists (no fork, no shell-out).
25/// - macOS / BSD: `kill -0 <pid>` (signal 0 = check only).
26/// - Windows: `tasklist /FI "PID eq <pid>" /FO CSV /NH`. A miss prints
27/// `INFO: No tasks are running...` to stdout AND exits 0, so we
28/// detect by content rather than exit code.
29pub fn process_alive(pid: u32) -> bool {
30 #[cfg(target_os = "linux")]
31 {
32 std::path::Path::new(&format!("/proc/{pid}")).exists()
33 }
34 #[cfg(all(unix, not(target_os = "linux")))]
35 {
36 Command::new("kill")
37 .args(["-0", &pid.to_string()])
38 .stdin(std::process::Stdio::null())
39 .stdout(std::process::Stdio::null())
40 .stderr(std::process::Stdio::null())
41 .status()
42 .map(|s| s.success())
43 .unwrap_or(false)
44 }
45 #[cfg(windows)]
46 {
47 let out = Command::new("tasklist.exe")
48 .args(["/FI", &format!("PID eq {pid}"), "/FO", "CSV", "/NH"])
49 .output();
50 match out {
51 Ok(o) if o.status.success() => {
52 let s = String::from_utf8_lossy(&o.stdout);
53 let trimmed = s.trim();
54 !trimmed.is_empty() && !trimmed.starts_with("INFO:")
55 }
56 _ => false,
57 }
58 }
59}
60
61/// `pgrep -f <pattern>` equivalent: every pid whose command line
62/// contains `pattern`. Empty Vec on tool error or zero matches.
63///
64/// - Unix: `pgrep -f <pattern>` (one fork, parses pid-per-line stdout).
65/// - Windows: PowerShell + CIM (`Get-CimInstance Win32_Process` with
66/// `CommandLine` filter). `wmic` was the old path but is deprecated
67/// in Windows 11 24H2; CIM is the supported replacement and works
68/// back to Windows 10. Pattern is single-quoted into the PowerShell
69/// `-like` operator so most metacharacters pass through verbatim;
70/// callers that need literal `'` or `[`/`]` should escape per
71/// PowerShell rules.
72pub fn find_processes_by_cmdline(pattern: &str) -> Vec<u32> {
73 #[cfg(unix)]
74 {
75 Command::new("pgrep")
76 .args(["-f", pattern])
77 .output()
78 .ok()
79 .filter(|o| o.status.success())
80 .map(|o| {
81 String::from_utf8_lossy(&o.stdout)
82 .split_whitespace()
83 .filter_map(|s| s.parse::<u32>().ok())
84 .collect()
85 })
86 .unwrap_or_default()
87 }
88 #[cfg(windows)]
89 {
90 // Single-quote the pattern in the PowerShell string. Inside
91 // single-quoted PS strings, the only escape is `''` for a
92 // literal single quote; we replace pre-emptively.
93 let escaped = pattern.replace('\'', "''");
94 let ps = format!(
95 "Get-CimInstance Win32_Process | \
96 Where-Object {{ $_.CommandLine -like '*{escaped}*' }} | \
97 Select-Object -ExpandProperty ProcessId"
98 );
99 Command::new("powershell.exe")
100 .args(["-NoProfile", "-NonInteractive", "-Command", &ps])
101 .output()
102 .ok()
103 .filter(|o| o.status.success())
104 .map(|o| {
105 String::from_utf8_lossy(&o.stdout)
106 .split_whitespace()
107 .filter_map(|s| s.parse::<u32>().ok())
108 .collect()
109 })
110 .unwrap_or_default()
111 }
112 #[cfg(not(any(unix, windows)))]
113 {
114 let _ = pattern;
115 Vec::new()
116 }
117}
118
119/// Signal a pid to exit. Returns true on successful dispatch (NOT on
120/// confirmed exit — poll [`process_alive`] for that). `force=true` is
121/// SIGKILL / `taskkill /F`; `force=false` is SIGTERM / `taskkill`
122/// (graceful).
123///
124/// Windows note: we pass `/T` so the whole process tree dies, not just
125/// the root. The daemon's `wire daemon` invocation is single-process
126/// today but the relay-server spawns hyper worker threads; `/T` is
127/// the safe default.
128pub fn kill_process(pid: u32, force: bool) -> bool {
129 #[cfg(unix)]
130 {
131 let sig = if force { "-9" } else { "-15" };
132 Command::new("kill")
133 .args([sig, &pid.to_string()])
134 .stdin(std::process::Stdio::null())
135 .stdout(std::process::Stdio::null())
136 .stderr(std::process::Stdio::null())
137 .status()
138 .map(|s| s.success())
139 .unwrap_or(false)
140 }
141 #[cfg(windows)]
142 {
143 let pid_str = pid.to_string();
144 let mut args: Vec<&str> = vec!["/PID", &pid_str, "/T"];
145 if force {
146 args.push("/F");
147 }
148 Command::new("taskkill.exe")
149 .args(&args)
150 .stdin(std::process::Stdio::null())
151 .stdout(std::process::Stdio::null())
152 .stderr(std::process::Stdio::null())
153 .status()
154 .map(|s| s.success())
155 .unwrap_or(false)
156 }
157 #[cfg(not(any(unix, windows)))]
158 {
159 let _ = (pid, force);
160 false
161 }
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167
168 #[test]
169 fn process_alive_returns_true_for_self() {
170 // Our own pid is alive by definition.
171 let me = std::process::id();
172 assert!(
173 process_alive(me),
174 "process_alive should return true for self pid {me}"
175 );
176 }
177
178 #[test]
179 fn process_alive_returns_false_for_clearly_dead_pid() {
180 // pid 0 is reserved on every Unix; on Windows it's the
181 // "System Idle Process" pseudo-pid and tasklist won't list
182 // it under a numeric filter. Either way: should report dead.
183 // Use a high pid that's astronomically unlikely to be alive
184 // to dodge the pid=0 edge case ambiguity on Windows.
185 let dead = 4_000_000_001;
186 assert!(
187 !process_alive(dead),
188 "process_alive should return false for synthetic dead pid {dead}"
189 );
190 }
191
192 #[test]
193 fn kill_process_on_nonexistent_pid_returns_false_or_noop() {
194 // Asserting on the return value is brittle because `kill -15`
195 // against a missing pid returns 1 on linux but 0 on some
196 // BSDs. The contract is "does not panic" — that alone is
197 // worth a test, given the cfg-gated dispatch.
198 let dead = 4_000_000_002;
199 let _ = kill_process(dead, false);
200 }
201}