magi/proc.rs
1//! Spawning child processes without putting a window on the operator's screen.
2//!
3//! Every external program magi runs - the agent CLIs, `git`, `gh`, the
4//! configured verification commands - is a console application. What happens
5//! when one is spawned depends on whether the *parent* has a console, and
6//! magi has two kinds of parent:
7//!
8//! - `magi run` / `magi review` in a terminal. The child inherits that
9//! console, writes nowhere visible because its pipes are redirected, and
10//! nothing appears.
11//! - `magi web`, which serves the deck. Its successor is spawned
12//! `DETACHED_PROCESS` on purpose (see [`crate::web`]): it has to outlive the
13//! process that started it and must not hold a pipe a terminal is waiting
14//! on. **That process has no console at all**, so Windows allocates a brand
15//! new one for each console child - and draws it. An implement wave is
16//! three agents, so three black windows opened over whatever the operator
17//! was doing, in front of the browser they were reading the deck in.
18//!
19//! `CREATE_NO_WINDOW` is the answer to exactly that: the child still gets a
20//! console for its standard handles, and that console is never shown. It is
21//! not the same as `DETACHED_PROCESS`, which gives the child no console and
22//! would make a grandchild pop a window of its own for the same reason.
23//!
24//! Nothing here is conditional on how magi was started. A hidden console is
25//! correct in a terminal too: the pipes are redirected either way, so there
26//! was never anything to look at.
27
28/// `CREATE_NO_WINDOW` - run the child's console, but never draw it.
29///
30/// From `processthreadsapi.h`. Spelled out rather than pulled in from a
31/// bindings crate: it is one number that has been stable since Windows 2000,
32/// and the alternative is a dependency for it.
33#[cfg(windows)]
34const CREATE_NO_WINDOW: u32 = 0x0800_0000;
35
36/// Spawn without a visible console window.
37///
38/// Implemented for both `Command` types magi uses - `std` for the few
39/// synchronous calls, `tokio` for everything else - so a call site does not
40/// have to know which one it is holding, and so no call site has to repeat a
41/// `#[cfg(windows)]` block to get it.
42///
43/// A no-op off Windows, where a spawned process has no window to begin with.
44pub trait Quiet {
45 /// Apply it, and hand the command back for further building.
46 fn quiet(&mut self) -> &mut Self;
47}
48
49impl Quiet for std::process::Command {
50 fn quiet(&mut self) -> &mut Self {
51 #[cfg(windows)]
52 {
53 use std::os::windows::process::CommandExt as _;
54 self.creation_flags(CREATE_NO_WINDOW);
55 }
56 self
57 }
58}
59
60impl Quiet for tokio::process::Command {
61 fn quiet(&mut self) -> &mut Self {
62 #[cfg(windows)]
63 {
64 self.creation_flags(CREATE_NO_WINDOW);
65 }
66 self
67 }
68}
69
70/// Best-effort liveness check for a process id, with no dependency beyond
71/// what the platform ships.
72///
73/// There is no portable way in the standard library to ask "is this pid
74/// alive" - no `libc`, no `sysinfo`, nothing magi already depends on binds
75/// the signals API - so this shells out to whatever each platform already
76/// provides: `kill -0` on Unix, `tasklist` on Windows. Both are read-only:
77/// `kill -0` sends no signal, it only checks whether one *could* be sent.
78///
79/// Every uncertain outcome reads as alive, on purpose. This exists so
80/// [`crate::daemon::sweep_stale_claims`] can reclaim a lock faster than its
81/// age-based fallback when the owning process is verifiably gone; the risk
82/// on the other side - reclaiming a lock a live process still holds - lets a
83/// second daemon start a second run on the same task, which costs far more
84/// than leaving one lock alone a little longer. So a helper program that is
85/// missing, output that cannot be parsed, or a permission error that merely
86/// proves the pid exists under another account, all count as "alive" rather
87/// than as license to reclaim.
88#[must_use]
89pub fn pid_alive(pid: u32) -> bool {
90 #[cfg(unix)]
91 {
92 match std::process::Command::new("kill")
93 .arg("-0")
94 .arg(pid.to_string())
95 .output()
96 {
97 Ok(o) if o.status.success() => true,
98 Ok(o) => {
99 // "No such process" is the one answer that actually means the
100 // pid is gone. Anything else - most commonly "Operation not
101 // permitted" for a pid that exists under another account - is
102 // not evidence of that.
103 let stderr = String::from_utf8_lossy(&o.stderr).to_lowercase();
104 !stderr.contains("no such process")
105 }
106 Err(_) => true,
107 }
108 }
109 #[cfg(windows)]
110 {
111 let out = std::process::Command::new("tasklist")
112 .quiet()
113 .args(["/FI", &format!("PID eq {pid}"), "/NH", "/FO", "CSV"])
114 .output();
115 match out {
116 Ok(o) if o.status.success() => {
117 String::from_utf8_lossy(&o.stdout).contains(&format!("\"{pid}\""))
118 }
119 _ => true,
120 }
121 }
122 #[cfg(not(any(unix, windows)))]
123 {
124 true
125 }
126}
127
128#[cfg(test)]
129mod tests {
130 use super::*;
131
132 /// The flag is the one Windows documents, and not one of the two it is
133 /// easily confused with.
134 ///
135 /// `DETACHED_PROCESS` (0x8) is what leaves a process without a console -
136 /// which is what caused the windows this module exists to stop, because a
137 /// child of such a process gets a fresh console *with* a window.
138 /// `CREATE_NEW_CONSOLE` (0x10) asks for the window outright.
139 #[cfg(windows)]
140 #[test]
141 fn the_flag_hides_a_console_rather_than_removing_or_creating_one() {
142 assert_eq!(CREATE_NO_WINDOW, 0x0800_0000);
143 assert_ne!(CREATE_NO_WINDOW, 0x0000_0008, "DETACHED_PROCESS");
144 assert_ne!(CREATE_NO_WINDOW, 0x0000_0010, "CREATE_NEW_CONSOLE");
145 }
146
147 /// Applying it does not disturb the command being built.
148 ///
149 /// The trait returns `&mut Self` so it can sit in the middle of a builder
150 /// chain, and a call site that put it there must not lose its program or
151 /// arguments to it.
152 #[test]
153 fn quiet_leaves_the_command_it_was_handed_intact() {
154 let mut cmd = tokio::process::Command::new("git");
155 cmd.args(["status", "--short"]).quiet();
156 let built = cmd.as_std();
157 assert_eq!(built.get_program(), "git");
158 let args: Vec<_> = built.get_args().collect();
159 assert_eq!(args, ["status", "--short"]);
160 }
161
162 #[test]
163 fn this_process_is_alive_and_a_pid_nothing_ever_reuses_is_not() {
164 assert!(pid_alive(std::process::id()), "this test is running");
165 // Not `u32::MAX`: Windows' `tasklist` answers a pid that large with
166 // "invalid query" rather than "no such process", which this helper
167 // - correctly - cannot tell apart from a check it simply could not
168 // run, so it reads as alive. A pid past any real process table but
169 // still a value `tasklist` accepts as a query is the one this test
170 // can assert on without racing whatever else is running on the
171 // machine.
172 assert!(!pid_alive(999_999_999));
173 }
174}