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 pid_alive_with(pid, platform_pid_alive)
91}
92
93/// Apply the conservative policy to one platform liveness query.
94///
95/// Kept separate from the OS command so queue and daemon tests can exercise
96/// dead, live, and unavailable answers without requiring permission to list
97/// the machine's processes.
98fn pid_alive_with<F>(pid: u32, query: F) -> bool
99where
100 F: FnOnce(u32) -> std::io::Result<bool>,
101{
102 match query(pid) {
103 Ok(alive) => alive,
104 Err(error) => {
105 // Sweeping is a poll-loop operation, so state the environment
106 // problem at the default log level without repeating it for every
107 // protected lock on every poll.
108 static REPORTED: std::sync::Once = std::sync::Once::new();
109 REPORTED.call_once(|| {
110 tracing::warn!(
111 %pid,
112 %error,
113 "process liveness query unavailable; keeping locks rather than treating processes as dead"
114 );
115 });
116 true
117 }
118 }
119}
120
121fn platform_pid_alive(pid: u32) -> std::io::Result<bool> {
122 #[cfg(unix)]
123 {
124 match std::process::Command::new("kill")
125 .arg("-0")
126 .arg(pid.to_string())
127 .output()
128 {
129 Ok(o) => Ok(parse_unix_kill_output(o.status.success(), &o.stderr)),
130 Err(error) => Err(error),
131 }
132 }
133 #[cfg(windows)]
134 {
135 let out = std::process::Command::new("tasklist")
136 .quiet()
137 .args(["/FI", &format!("PID eq {pid}"), "/NH", "/FO", "CSV"])
138 .output();
139 match out {
140 Ok(o) => tasklist_result(
141 pid,
142 o.status.success(),
143 &o.stdout,
144 &o.stderr,
145 &o.status.to_string(),
146 ),
147 Err(error) => Err(error),
148 }
149 }
150 #[cfg(not(any(unix, windows)))]
151 {
152 let _ = pid;
153 Ok(true)
154 }
155}
156
157/// 数値の PID から推測せず、`kill -0` の終了状態と診断を解釈する。
158/// 明示的な "no such process" 診断だけを死亡の証拠とする。
159#[cfg(any(unix, test))]
160fn parse_unix_kill_output(success: bool, stderr: &[u8]) -> bool {
161 if success {
162 return true;
163 }
164 !String::from_utf8_lossy(stderr)
165 .to_lowercase()
166 .contains("no such process")
167}
168
169/// `tasklist /FO CSV` の出力を解釈する。一致しない場合、要求した PID の
170/// フィールドを持つ行は存在しない。
171#[cfg(any(windows, test))]
172fn parse_windows_tasklist_output(pid: u32, stdout: &[u8]) -> std::io::Result<bool> {
173 if stdout.iter().all(u8::is_ascii_whitespace) {
174 return Err(std::io::Error::other("tasklist produced no output"));
175 }
176 let expected = pid.to_string();
177 let rows = String::from_utf8_lossy(stdout)
178 .lines()
179 .map(tasklist_csv_fields)
180 .collect::<Option<Vec<_>>>()
181 .ok_or_else(|| std::io::Error::other("could not parse tasklist CSV output"))?;
182 Ok(rows
183 .into_iter()
184 .any(|fields| fields.get(1).is_some_and(|field| field == &expected)))
185}
186
187/// `tasklist` が出す、二重引用符と `""` エスケープを持つ CSV の一行を分ける。
188/// 壊れた CSV は呼び出し側が利用不能として保持できるよう `None` を返す。
189#[cfg(any(windows, test))]
190fn tasklist_csv_fields(line: &str) -> Option<Vec<String>> {
191 let mut fields = Vec::new();
192 let mut field = String::new();
193 let mut quoted = false;
194 let mut chars = line.chars().peekable();
195
196 while let Some(ch) = chars.next() {
197 match ch {
198 '"' if quoted && chars.peek() == Some(&'"') => {
199 field.push('"');
200 chars.next();
201 }
202 '"' => quoted = !quoted,
203 ',' if !quoted => fields.push(std::mem::take(&mut field)),
204 _ => field.push(ch),
205 }
206 }
207 (!quoted).then(|| {
208 fields.push(field);
209 fields
210 })
211}
212
213/// `tasklist` の失敗を、利用不能な問い合わせとして保持する。
214#[cfg(any(windows, test))]
215fn tasklist_result(
216 pid: u32,
217 success: bool,
218 stdout: &[u8],
219 stderr: &[u8],
220 status: &str,
221) -> std::io::Result<bool> {
222 if success {
223 parse_windows_tasklist_output(pid, stdout)
224 } else {
225 Err(std::io::Error::other(format!(
226 "tasklist exited {status}: {}",
227 String::from_utf8_lossy(stderr).trim()
228 )))
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::*;
235
236 /// The flag is the one Windows documents, and not one of the two it is
237 /// easily confused with.
238 ///
239 /// `DETACHED_PROCESS` (0x8) is what leaves a process without a console -
240 /// which is what caused the windows this module exists to stop, because a
241 /// child of such a process gets a fresh console *with* a window.
242 /// `CREATE_NEW_CONSOLE` (0x10) asks for the window outright.
243 #[cfg(windows)]
244 #[test]
245 fn the_flag_hides_a_console_rather_than_removing_or_creating_one() {
246 assert_eq!(CREATE_NO_WINDOW, 0x0800_0000);
247 assert_ne!(CREATE_NO_WINDOW, 0x0000_0008, "DETACHED_PROCESS");
248 assert_ne!(CREATE_NO_WINDOW, 0x0000_0010, "CREATE_NEW_CONSOLE");
249 }
250
251 /// Applying it does not disturb the command being built.
252 ///
253 /// The trait returns `&mut Self` so it can sit in the middle of a builder
254 /// chain, and a call site that put it there must not lose its program or
255 /// arguments to it.
256 #[test]
257 fn quiet_leaves_the_command_it_was_handed_intact() {
258 let mut cmd = tokio::process::Command::new("git");
259 cmd.args(["status", "--short"]).quiet();
260 let built = cmd.as_std();
261 assert_eq!(built.get_program(), "git");
262 let args: Vec<_> = built.get_args().collect();
263 assert_eq!(args, ["status", "--short"]);
264 }
265
266 /// Every `Command::new` in this crate's own sources is either quieted or
267 /// carries one of the two exemptions this module's doc explains.
268 ///
269 /// A textual scan, not a lint: nothing in `cargo clippy` knows that a
270 /// console-app child of a console-less parent gets a window, so nothing
271 /// catches a spawn that forgot `.quiet()` short of a human reading every
272 /// call site - which is exactly how `disk.rs`'s PowerShell probe and
273 /// `graph.rs`'s `gh pr create` went unquieted despite every neighbouring
274 /// spawn getting it right. Each `Command::new` is checked against the
275 /// text between it and the next one in the same file (or end of file),
276 /// which is always enough to cover its own builder chain and never
277 /// bleeds into an unrelated spawn's exemption.
278 #[test]
279 fn every_spawn_in_the_crate_is_quiet_or_documented_as_exempt() {
280 let src_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
281 let mut offenders = Vec::new();
282 for entry in std::fs::read_dir(&src_dir).expect("read src dir") {
283 let path = entry.expect("dir entry").path();
284 if path.extension().and_then(|e| e.to_str()) != Some("rs") {
285 continue;
286 }
287 let file_name = path
288 .file_name()
289 .and_then(|n| n.to_str())
290 .unwrap_or("")
291 .to_owned();
292 if file_name == "tui.rs" {
293 // explorer / open / xdg-open: GUI launchers, not console
294 // children - out of scope by design (see AGENTS.md).
295 continue;
296 }
297 let text = std::fs::read_to_string(&path).expect("read source file");
298 let lines: Vec<&str> = text.lines().collect();
299 let spawn_at: Vec<usize> = lines
300 .iter()
301 .enumerate()
302 .filter(|(_, l)| l.contains("Command::new("))
303 .map(|(i, _)| i)
304 .collect();
305 for (pos, &start) in spawn_at.iter().enumerate() {
306 let end = spawn_at.get(pos + 1).copied().unwrap_or(lines.len());
307 let block = lines[start..end].join("\n");
308 if block.contains(".quiet()") {
309 continue;
310 }
311 // `spawn_successor`'s DETACHED_PROCESS successor has no
312 // console to inherit in the first place; see its doc comment
313 // in `web.rs`.
314 if block.contains("DETACHED_PROCESS") {
315 continue;
316 }
317 // A spawn guarded by `#[cfg(unix)]` a few lines above cannot
318 // hit the Windows console bug at all.
319 let preceding = lines[start.saturating_sub(5)..start].join("\n");
320 if preceding.contains("#[cfg(unix)]") {
321 continue;
322 }
323 offenders.push(format!("{file_name}:{}", start + 1));
324 }
325 }
326 assert!(
327 offenders.is_empty(),
328 "Command::new without .quiet() and no documented exemption: {offenders:?}"
329 );
330 }
331
332 #[test]
333 fn pid_liveness_policy_is_deterministic_without_an_os_process_query() {
334 assert!(pid_alive_with(42, |_| Ok(true)));
335 assert!(!pid_alive_with(42, |_| Ok(false)));
336 }
337
338 #[test]
339 fn an_unavailable_process_query_is_never_mistaken_for_a_dead_process() {
340 assert!(pid_alive_with(42, |_| Err(std::io::Error::other(
341 "access denied"
342 ))));
343 }
344
345 /// 本番パーサー用のコマンド出力フィクスチャであり、特定 PID の OS 上の
346 /// 死亡状態を主張するものではない。
347 #[test]
348 fn unix_kill_output_only_marks_no_such_process_as_dead() {
349 assert!(parse_unix_kill_output(true, b""));
350 assert!(!parse_unix_kill_output(
351 false,
352 b"kill: (12345) - No such process\n"
353 ));
354 assert!(parse_unix_kill_output(
355 false,
356 b"kill: (12345) - Operation not permitted\n"
357 ));
358 }
359
360 /// 本番パーサー用のコマンド出力フィクスチャであり、OS の生存照会ではない。
361 /// 失敗した `tasklist` は死亡ではなく利用不能のままとする。
362 #[test]
363 fn windows_tasklist_csv_parsing_handles_match_no_match_and_error() {
364 let pid = 12345;
365 assert!(
366 parse_windows_tasklist_output(
367 pid,
368 b"\"magi.exe\",\"12345\",\"Console\",\"1\",\"10 K\"\r\n"
369 )
370 .expect("整形式 CSV の一致行は生存を示す")
371 );
372 assert!(
373 parse_windows_tasklist_output(
374 pid,
375 b"\"magi,worker.exe\",\"12345\",\"Console\",\"1\",\"10 K\"\r\n"
376 )
377 .expect("カンマ入りイメージ名でも PID 列を読む")
378 );
379 assert!(
380 !parse_windows_tasklist_output(
381 pid,
382 b"INFO: No tasks are running which match the specified criteria.\r\n"
383 )
384 .expect("tasklist の no-match 出力は整形式である")
385 );
386 assert!(
387 parse_windows_tasklist_output(pid, b"\"magi.exe\",\"12345").is_err(),
388 "壊れた CSV は死亡ではなく利用不能である"
389 );
390 assert!(
391 parse_windows_tasklist_output(pid, b"").is_err(),
392 "空出力は死亡ではなく利用不能である"
393 );
394 assert!(
395 parse_windows_tasklist_output(pid, b"\r\n").is_err(),
396 "空白だけの出力は死亡ではなく利用不能である"
397 );
398 assert!(
399 tasklist_result(
400 pid,
401 true,
402 b"\"magi.exe\",\"12345\",\"Console\",\"1\",\"10 K\"\r\n",
403 b"",
404 "exit status: 0",
405 )
406 .expect("CSV の一致行は生存を示す")
407 );
408
409 let error = tasklist_result(pid, false, b"", b"Access is denied.\r\n", "exit status: 1")
410 .expect_err("tasklist の失敗は死亡ではなく利用不能である");
411 assert!(error.to_string().contains("Access is denied."));
412 }
413
414 /// このテスト自身の PID を OS に問い合わせるスモーク診断。
415 ///
416 /// CI では実際のコマンド実行と成功出力の解析を必須にする。制限された
417 /// 対話席で問い合わせ自体が使えない場合は、その事実を出力して成功結果や
418 /// 死んだプロセスと取り違えない。実行中の PID を dead と報告した場合と、
419 /// CI で問い合わせが利用不能な場合は失敗にする。
420 #[test]
421 fn platform_query_reports_this_running_process_as_alive_or_unavailable() {
422 let pid = std::process::id();
423 match platform_pid_alive(pid) {
424 Ok(true) => {}
425 Ok(false) => {
426 panic!("OS の PID 問い合わせが実行中のテストプロセス {pid} を dead と報告した")
427 }
428 Err(error) if std::env::var_os("CI").is_some() => {
429 panic!("CI で OS の PID 問い合わせを実行できない(テストプロセス {pid}): {error}")
430 }
431 Err(error) => {
432 eprintln!("OS の PID 問い合わせは利用できません(テストプロセス {pid}): {error}")
433 }
434 }
435 }
436}