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/// The role/subcommand of a `wire <role> ...` process pattern —
62/// `cmdline_role("wire daemon") == "daemon"`, `cmdline_role("wire
63/// relay-server") == "relay-server"`. A pattern without the `wire ` prefix
64/// passes through unchanged.
65///
66/// The Windows process scan matches this role (not the full `wire daemon`
67/// string) against the command line, because the image is `wire.exe` and the
68/// contiguous `wire daemon` never matches the real `wire.exe daemon` cmdline.
69/// Hoisted out of the `cfg(windows)` block + unit-tested so the `.exe`-match
70/// regression (which caused `wire upgrade` to accumulate daemons) is locked on
71/// EVERY platform's CI, not only on a Windows runner.
72#[cfg_attr(not(windows), allow(dead_code))]
73pub(crate) fn cmdline_role(pattern: &str) -> &str {
74 pattern.strip_prefix("wire ").unwrap_or(pattern)
75}
76
77/// `pgrep -f <pattern>` equivalent: every pid whose command line
78/// contains `pattern`. Empty Vec on tool error or zero matches.
79///
80/// - Unix: `pgrep -f <pattern>` (one fork, parses pid-per-line stdout).
81/// - Windows: PowerShell + CIM (`Get-CimInstance Win32_Process` with
82/// `CommandLine` filter). `wmic` was the old path but is deprecated
83/// in Windows 11 24H2; CIM is the supported replacement and works
84/// back to Windows 10. Pattern is single-quoted into the PowerShell
85/// `-like` operator so most metacharacters pass through verbatim;
86/// callers that need literal `'` or `[`/`]` should escape per
87/// PowerShell rules.
88pub fn find_processes_by_cmdline(pattern: &str) -> Vec<u32> {
89 #[cfg(unix)]
90 {
91 Command::new("pgrep")
92 .args(["-f", pattern])
93 .output()
94 .ok()
95 .filter(|o| o.status.success())
96 .map(|o| {
97 String::from_utf8_lossy(&o.stdout)
98 .split_whitespace()
99 .filter_map(|s| s.parse::<u32>().ok())
100 .collect()
101 })
102 .unwrap_or_default()
103 }
104 #[cfg(windows)]
105 {
106 // Single-quote the pattern in the PowerShell string. Inside
107 // single-quoted PS strings, the only escape is `''` for a
108 // literal single quote; we replace pre-emptively.
109 // The Windows process image is `wire.exe`, so a Unix-style full
110 // pattern like "wire daemon" does NOT match the actual command line
111 // "wire.exe daemon" (the ".exe " breaks the contiguous match). Match
112 // the wire image by Name and the ROLE/subcommand (the pattern minus a
113 // leading "wire ") in the command line. Without this, find returned
114 // nothing for the real daemon on Windows, so `wire upgrade` killed no
115 // daemons and they ACCUMULATED (glossy-magnolia: 2->3->4->5 over three
116 // upgrade cycles — the exact multi-daemon cursor race doctor warns of).
117 //
118 // Two further guards (glossy-magnolia repro):
119 // - `$_.Name -like 'wire*'` — only wire processes count. Without it
120 // the query SELF-MATCHED: this PowerShell process's own command
121 // line contains the pattern literal, so it showed up as a phantom
122 // "orphan daemon" with a new pid every call (doctor FAILed on
123 // every healthy box).
124 // - `$_.ProcessId -ne $PID` — belt-and-suspenders self-exclusion.
125 let role = cmdline_role(pattern);
126 let escaped = role.replace('\'', "''");
127 let ps = format!(
128 "Get-CimInstance Win32_Process | \
129 Where-Object {{ $_.Name -like 'wire*' -and $_.ProcessId -ne $PID -and $_.CommandLine -like '*{escaped}*' }} | \
130 Select-Object -ExpandProperty ProcessId"
131 );
132 Command::new("powershell.exe")
133 .args(["-NoProfile", "-NonInteractive", "-Command", &ps])
134 .output()
135 .ok()
136 .filter(|o| o.status.success())
137 .map(|o| {
138 String::from_utf8_lossy(&o.stdout)
139 .split_whitespace()
140 .filter_map(|s| s.parse::<u32>().ok())
141 .collect()
142 })
143 .unwrap_or_default()
144 }
145 #[cfg(not(any(unix, windows)))]
146 {
147 let _ = pattern;
148 Vec::new()
149 }
150}
151
152/// Signal a pid to exit. Returns true on successful dispatch (NOT on
153/// confirmed exit — poll [`process_alive`] for that). `force=true` is
154/// SIGKILL / `taskkill /F`; `force=false` is SIGTERM / `taskkill`
155/// (graceful).
156///
157/// Windows note: we pass `/T` so the whole process tree dies, not just
158/// the root. The daemon's `wire daemon` invocation is single-process
159/// today but the relay-server spawns hyper worker threads; `/T` is
160/// the safe default.
161pub fn kill_process(pid: u32, force: bool) -> bool {
162 #[cfg(unix)]
163 {
164 let sig = if force { "-9" } else { "-15" };
165 Command::new("kill")
166 .args([sig, &pid.to_string()])
167 .stdin(std::process::Stdio::null())
168 .stdout(std::process::Stdio::null())
169 .stderr(std::process::Stdio::null())
170 .status()
171 .map(|s| s.success())
172 .unwrap_or(false)
173 }
174 #[cfg(windows)]
175 {
176 let pid_str = pid.to_string();
177 let mut args: Vec<&str> = vec!["/PID", &pid_str, "/T"];
178 if force {
179 args.push("/F");
180 }
181 Command::new("taskkill.exe")
182 .args(&args)
183 .stdin(std::process::Stdio::null())
184 .stdout(std::process::Stdio::null())
185 .stderr(std::process::Stdio::null())
186 .status()
187 .map(|s| s.success())
188 .unwrap_or(false)
189 }
190 #[cfg(not(any(unix, windows)))]
191 {
192 let _ = (pid, force);
193 false
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200
201 #[test]
202 fn cmdline_role_strips_wire_prefix() {
203 // Locks the Windows .exe-match logic on every platform's CI: the role
204 // is what we match against `wire.exe daemon`, not the full pattern.
205 assert_eq!(cmdline_role("wire daemon"), "daemon");
206 assert_eq!(cmdline_role("wire relay-server"), "relay-server");
207 // No `wire ` prefix → unchanged (custom patterns pass through).
208 assert_eq!(cmdline_role("daemon"), "daemon");
209 assert_eq!(cmdline_role("relay-server"), "relay-server");
210 }
211
212 #[test]
213 fn process_alive_returns_true_for_self() {
214 // Our own pid is alive by definition.
215 let me = std::process::id();
216 assert!(
217 process_alive(me),
218 "process_alive should return true for self pid {me}"
219 );
220 }
221
222 #[test]
223 fn process_alive_returns_false_for_clearly_dead_pid() {
224 // pid 0 is reserved on every Unix; on Windows it's the
225 // "System Idle Process" pseudo-pid and tasklist won't list
226 // it under a numeric filter. Either way: should report dead.
227 // Use a high pid that's astronomically unlikely to be alive
228 // to dodge the pid=0 edge case ambiguity on Windows.
229 let dead = 4_000_000_001;
230 assert!(
231 !process_alive(dead),
232 "process_alive should return false for synthetic dead pid {dead}"
233 );
234 }
235
236 #[test]
237 fn kill_process_on_nonexistent_pid_returns_false_or_noop() {
238 // Asserting on the return value is brittle because `kill -15`
239 // against a missing pid returns 1 on linux but 0 on some
240 // BSDs. The contract is "does not panic" — that alone is
241 // worth a test, given the cfg-gated dispatch.
242 let dead = 4_000_000_002;
243 let _ = kill_process(dead, false);
244 }
245}