Skip to main content

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/// Return the command line of a specific pid, or `None` if the pid
153/// is missing / unreadable / exited between query and answer.
154///
155/// v0.14.2 (#162 diagnostic, post-supervisor #170): when `wire status`
156/// surfaces orphan pids, the operator wants to know "which session
157/// is that daemon serving?" without grepping `ps` themselves —
158/// closes the launchd-vs-session-isolation diagnostic gap honey-pine
159/// burned multiple sessions on.
160///
161/// - Linux: read `/proc/<pid>/cmdline` (NUL-separated, replace with spaces).
162/// - macOS / BSD: `ps -p <pid> -o command=` (no header, single column).
163/// - Windows: PowerShell CIM `Get-CimInstance Win32_Process | Where
164///   {$_.ProcessId -eq <pid>} | Select CommandLine`.
165///
166/// Conservative on failure: returns `None` rather than synthesizing a
167/// placeholder. Callers should treat None as "annotation unavailable",
168/// not "process is dead" — `process_alive` is the liveness oracle.
169pub fn pid_cmdline(pid: u32) -> Option<String> {
170    #[cfg(target_os = "linux")]
171    {
172        let path = format!("/proc/{pid}/cmdline");
173        let bytes = std::fs::read(&path).ok()?;
174        // `/proc/<pid>/cmdline` is NUL-separated argv. Convert NULs to
175        // spaces for human-readable output; trim trailing NUL.
176        let s: String = bytes
177            .into_iter()
178            .map(|b| if b == 0 { b' ' } else { b })
179            .map(|b| b as char)
180            .collect();
181        let trimmed = s.trim().to_string();
182        if trimmed.is_empty() {
183            None
184        } else {
185            Some(trimmed)
186        }
187    }
188    #[cfg(all(unix, not(target_os = "linux")))]
189    {
190        let out = Command::new("ps")
191            .args(["-p", &pid.to_string(), "-o", "command="])
192            .output()
193            .ok()?;
194        if !out.status.success() {
195            return None;
196        }
197        let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
198        if s.is_empty() { None } else { Some(s) }
199    }
200    #[cfg(windows)]
201    {
202        let ps = format!(
203            "Get-CimInstance Win32_Process | \
204             Where-Object {{ $_.ProcessId -eq {pid} }} | \
205             Select-Object -ExpandProperty CommandLine"
206        );
207        let out = Command::new("powershell.exe")
208            .args(["-NoProfile", "-NonInteractive", "-Command", &ps])
209            .output()
210            .ok()?;
211        if !out.status.success() {
212            return None;
213        }
214        let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
215        if s.is_empty() { None } else { Some(s) }
216    }
217    #[cfg(not(any(unix, windows)))]
218    {
219        let _ = pid;
220        None
221    }
222}
223
224/// Parse `--session <name>` from a wire daemon command line. Returns
225/// `None` if not present. v0.14.2 (#170 supervisor pairs a `--session
226/// <name>` arg with the WIRE_HOME the daemon serves; this extracts it
227/// for orphan-pid diagnostic display).
228pub fn parse_session_arg(cmdline: &str) -> Option<&str> {
229    let parts: Vec<&str> = cmdline.split_whitespace().collect();
230    let i = parts.iter().position(|p| *p == "--session")?;
231    parts.get(i + 1).copied()
232}
233
234/// Signal a pid to exit. Returns true on successful dispatch (NOT on
235/// confirmed exit — poll [`process_alive`] for that). `force=true` is
236/// SIGKILL / `taskkill /F`; `force=false` is SIGTERM / `taskkill`
237/// (graceful).
238///
239/// Windows note: we pass `/T` so the whole process tree dies, not just
240/// the root. The daemon's `wire daemon` invocation is single-process
241/// today but the relay-server spawns hyper worker threads; `/T` is
242/// the safe default.
243pub fn kill_process(pid: u32, force: bool) -> bool {
244    #[cfg(unix)]
245    {
246        let sig = if force { "-9" } else { "-15" };
247        Command::new("kill")
248            .args([sig, &pid.to_string()])
249            .stdin(std::process::Stdio::null())
250            .stdout(std::process::Stdio::null())
251            .stderr(std::process::Stdio::null())
252            .status()
253            .map(|s| s.success())
254            .unwrap_or(false)
255    }
256    #[cfg(windows)]
257    {
258        let pid_str = pid.to_string();
259        let mut args: Vec<&str> = vec!["/PID", &pid_str, "/T"];
260        if force {
261            args.push("/F");
262        }
263        Command::new("taskkill.exe")
264            .args(&args)
265            .stdin(std::process::Stdio::null())
266            .stdout(std::process::Stdio::null())
267            .stderr(std::process::Stdio::null())
268            .status()
269            .map(|s| s.success())
270            .unwrap_or(false)
271    }
272    #[cfg(not(any(unix, windows)))]
273    {
274        let _ = (pid, force);
275        false
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    #[test]
284    fn cmdline_role_strips_wire_prefix() {
285        // Locks the Windows .exe-match logic on every platform's CI: the role
286        // is what we match against `wire.exe daemon`, not the full pattern.
287        assert_eq!(cmdline_role("wire daemon"), "daemon");
288        assert_eq!(cmdline_role("wire relay-server"), "relay-server");
289        // No `wire ` prefix → unchanged (custom patterns pass through).
290        assert_eq!(cmdline_role("daemon"), "daemon");
291        assert_eq!(cmdline_role("relay-server"), "relay-server");
292    }
293
294    #[test]
295    fn process_alive_returns_true_for_self() {
296        // Our own pid is alive by definition.
297        let me = std::process::id();
298        assert!(
299            process_alive(me),
300            "process_alive should return true for self pid {me}"
301        );
302    }
303
304    #[test]
305    fn process_alive_returns_false_for_clearly_dead_pid() {
306        // pid 0 is reserved on every Unix; on Windows it's the
307        // "System Idle Process" pseudo-pid and tasklist won't list
308        // it under a numeric filter. Either way: should report dead.
309        // Use a high pid that's astronomically unlikely to be alive
310        // to dodge the pid=0 edge case ambiguity on Windows.
311        let dead = 4_000_000_001;
312        assert!(
313            !process_alive(dead),
314            "process_alive should return false for synthetic dead pid {dead}"
315        );
316    }
317
318    #[test]
319    fn parse_session_arg_extracts_following_value() {
320        assert_eq!(
321            parse_session_arg("wire daemon --session slancha-mesh --interval 5"),
322            Some("slancha-mesh")
323        );
324        assert_eq!(
325            parse_session_arg("wire daemon --interval 5 --session wire-dev"),
326            Some("wire-dev")
327        );
328        // Mid-cmdline + extra whitespace is fine — split_whitespace handles it.
329        assert_eq!(
330            parse_session_arg("/path/to/wire   daemon   --session   foo"),
331            Some("foo")
332        );
333    }
334
335    #[test]
336    fn parse_session_arg_returns_none_without_flag() {
337        assert_eq!(parse_session_arg("wire daemon --interval 5"), None);
338        // Bare `wire daemon --all-sessions` is the supervisor itself —
339        // it doesn't carry a single `--session`. Operators reading the
340        // supervisor's cmdline should see no annotation, not a
341        // misleading session attribution.
342        assert_eq!(
343            parse_session_arg("wire daemon --all-sessions --interval 5"),
344            None
345        );
346        // Empty input is safe.
347        assert_eq!(parse_session_arg(""), None);
348    }
349
350    #[test]
351    fn parse_session_arg_returns_none_when_flag_is_last_token() {
352        // `--session` at end with no value following → None, not a panic.
353        assert_eq!(parse_session_arg("wire daemon --session"), None);
354    }
355
356    #[test]
357    fn pid_cmdline_returns_something_for_self() {
358        // Cross-platform sanity: our own process must have a cmdline.
359        // We can't assert exact content (test runner cmdlines vary) —
360        // just that it returns Some and is non-empty.
361        let me = std::process::id();
362        let cmd = pid_cmdline(me);
363        assert!(
364            cmd.is_some() && !cmd.as_ref().unwrap().is_empty(),
365            "pid_cmdline(self) should return a non-empty cmdline, got {cmd:?}"
366        );
367    }
368
369    #[test]
370    fn pid_cmdline_returns_none_for_dead_pid() {
371        // Use the same astronomically-unlikely pid pattern as
372        // process_alive_returns_false_for_clearly_dead_pid above.
373        let dead = 4_000_000_003;
374        assert_eq!(
375            pid_cmdline(dead),
376            None,
377            "pid_cmdline should return None for synthetic dead pid"
378        );
379    }
380
381    #[test]
382    fn kill_process_on_nonexistent_pid_returns_false_or_noop() {
383        // Asserting on the return value is brittle because `kill -15`
384        // against a missing pid returns 1 on linux but 0 on some
385        // BSDs. The contract is "does not panic" — that alone is
386        // worth a test, given the cfg-gated dispatch.
387        let dead = 4_000_000_002;
388        let _ = kill_process(dead, false);
389    }
390}