Skip to main content

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
121/// A three-valued liveness read, for a caller that *displays* whether a
122/// process is running rather than deciding whether it is safe to reclaim a
123/// lock. [`pid_alive`]'s Err-means-alive policy exists to protect a lock a
124/// live process still holds — the wrong bias for a report that must never
125/// tell an operator a process is confirmed dead just because this build
126/// could not ask the platform. `None` here is the honest "could not tell",
127/// left for the caller to render as its own "unknown" rather than folded
128/// into either `Some` answer.
129#[must_use]
130pub fn pid_status(pid: u32) -> Option<bool> {
131    pid_status_with(pid, platform_pid_alive)
132}
133
134/// [`pid_status`] with its process-liveness query supplied by the caller —
135/// see [`pid_alive_with`] for why this split exists.
136fn pid_status_with<F>(pid: u32, query: F) -> Option<bool>
137where
138    F: FnOnce(u32) -> std::io::Result<bool>,
139{
140    query(pid).ok()
141}
142
143/// An opaque marker identifying *which* process currently holds `pid`, not
144/// merely whether the number is in use — the OS-reported moment it started.
145/// Compared only for equality by the caller, never parsed as a timestamp:
146/// the two platform formats are not on the same scale, and nothing here
147/// needs to be.
148///
149/// A live pid alone never proves it is the process a caller thinks it is —
150/// pids get reused, sometimes within minutes on a busy machine — so
151/// [`crate::run::RunState::liveness`] uses this to corroborate a `driver_pid`
152/// that answered `pid_status(..) == Some(true)`: it records this marker
153/// alongside the pid, and a later mismatch means a *different* process now
154/// answers to that number, not that the original one is somehow still
155/// running under it. `None` when the platform could not say — a caller must
156/// treat that exactly like an unavailable [`pid_status`] query, not as
157/// either a match or a mismatch.
158#[must_use]
159pub fn process_started_at(pid: u32) -> Option<String> {
160    platform_process_started_at(pid).ok()
161}
162
163/// A per-request memo over [`pid_status`] and [`process_started_at`].
164///
165/// A listing of hundreds of runs asks about the same few pids over and over,
166/// and on Windows every ask spawns a helper process. Asking once per pid is
167/// enough within one request; the probe is meant to be dropped with it, never
168/// kept, so a stale answer cannot outlive the moment it was read.
169pub struct ProcProbe<S, I> {
170    status: S,
171    identity: I,
172    alive: std::collections::HashMap<u32, Option<bool>>,
173    started: std::collections::HashMap<u32, Option<String>>,
174}
175
176impl ProcProbe<fn(u32) -> Option<bool>, fn(u32) -> Option<String>> {
177    /// A probe backed by the real platform queries.
178    #[must_use]
179    pub fn real() -> Self {
180        Self::new(pid_status, process_started_at)
181    }
182}
183
184impl<S, I> ProcProbe<S, I>
185where
186    S: FnMut(u32) -> Option<bool>,
187    I: FnMut(u32) -> Option<String>,
188{
189    /// A probe over caller-supplied queries.
190    #[must_use]
191    pub fn new(status: S, identity: I) -> Self {
192        Self {
193            status,
194            identity,
195            alive: std::collections::HashMap::new(),
196            started: std::collections::HashMap::new(),
197        }
198    }
199
200    /// [`pid_status`], asked at most once per pid.
201    pub fn status(&mut self, pid: u32) -> Option<bool> {
202        *self.alive.entry(pid).or_insert_with(|| (self.status)(pid))
203    }
204
205    /// [`process_started_at`], asked at most once per pid.
206    pub fn started_at(&mut self, pid: u32) -> Option<String> {
207        self.started
208            .entry(pid)
209            .or_insert_with(|| (self.identity)(pid))
210            .clone()
211    }
212}
213
214// `lstart` is `ps`'s own fixed-format wall-clock start time — POSIX portable
215// (unlike `/proc`, which does not exist on macOS/BSD), and a process never
216// reports a different one across its own lifetime, so two queries of the
217// same still-running process always agree byte for byte.
218fn platform_process_started_at(pid: u32) -> std::io::Result<String> {
219    #[cfg(unix)]
220    {
221        let out = std::process::Command::new("ps")
222            .args(["-o", "lstart=", "-p", &pid.to_string()])
223            .output()?;
224        if !out.status.success() {
225            return Err(std::io::Error::other(format!(
226                "ps exited with {}",
227                out.status
228            )));
229        }
230        let text = String::from_utf8_lossy(&out.stdout).trim().to_owned();
231        if text.is_empty() {
232            return Err(std::io::Error::other("ps reported no such process"));
233        }
234        Ok(text)
235    }
236    #[cfg(windows)]
237    {
238        // Round-trip ("o") format: sub-millisecond precision, so two
239        // processes started in the same second (`lstart`'s own granularity
240        // on the Unix side above) still do not collide here.
241        let script = format!("(Get-Process -Id {pid} -ErrorAction Stop).StartTime.ToString('o')");
242        let out = std::process::Command::new("powershell")
243            .args(["-NoProfile", "-NonInteractive", "-Command", &script])
244            .quiet()
245            .output()?;
246        if !out.status.success() {
247            return Err(std::io::Error::other(format!(
248                "PowerShell exited with {}: {}",
249                out.status,
250                String::from_utf8_lossy(&out.stderr).trim()
251            )));
252        }
253        let text = String::from_utf8_lossy(&out.stdout).trim().to_owned();
254        if text.is_empty() {
255            return Err(std::io::Error::other("PowerShell reported no start time"));
256        }
257        Ok(text)
258    }
259    #[cfg(not(any(unix, windows)))]
260    {
261        let _ = pid;
262        Err(std::io::Error::other(
263            "process start time is unavailable on this platform",
264        ))
265    }
266}
267
268fn platform_pid_alive(pid: u32) -> std::io::Result<bool> {
269    #[cfg(unix)]
270    {
271        match std::process::Command::new("kill")
272            .arg("-0")
273            .arg(pid.to_string())
274            .output()
275        {
276            Ok(o) => Ok(parse_unix_kill_output(o.status.success(), &o.stderr)),
277            Err(error) => Err(error),
278        }
279    }
280    #[cfg(windows)]
281    {
282        let out = std::process::Command::new("tasklist")
283            .quiet()
284            .args(["/FI", &format!("PID eq {pid}"), "/NH", "/FO", "CSV"])
285            .output();
286        match out {
287            Ok(o) => tasklist_result(
288                pid,
289                o.status.success(),
290                &o.stdout,
291                &o.stderr,
292                &o.status.to_string(),
293            ),
294            Err(error) => Err(error),
295        }
296    }
297    #[cfg(not(any(unix, windows)))]
298    {
299        let _ = pid;
300        Ok(true)
301    }
302}
303
304/// 数値の PID から推測せず、`kill -0` の終了状態と診断を解釈する。
305/// 明示的な "no such process" 診断だけを死亡の証拠とする。
306#[cfg(any(unix, test))]
307fn parse_unix_kill_output(success: bool, stderr: &[u8]) -> bool {
308    if success {
309        return true;
310    }
311    !String::from_utf8_lossy(stderr)
312        .to_lowercase()
313        .contains("no such process")
314}
315
316/// `tasklist /FO CSV` の出力を解釈する。一致しない場合、要求した PID の
317/// フィールドを持つ行は存在しない。
318#[cfg(any(windows, test))]
319fn parse_windows_tasklist_output(pid: u32, stdout: &[u8]) -> std::io::Result<bool> {
320    if stdout.iter().all(u8::is_ascii_whitespace) {
321        return Err(std::io::Error::other("tasklist produced no output"));
322    }
323    let expected = pid.to_string();
324    let rows = String::from_utf8_lossy(stdout)
325        .lines()
326        .map(tasklist_csv_fields)
327        .collect::<Option<Vec<_>>>()
328        .ok_or_else(|| std::io::Error::other("could not parse tasklist CSV output"))?;
329    Ok(rows
330        .into_iter()
331        .any(|fields| fields.get(1).is_some_and(|field| field == &expected)))
332}
333
334/// `tasklist` が出す、二重引用符と `""` エスケープを持つ CSV の一行を分ける。
335/// 壊れた CSV は呼び出し側が利用不能として保持できるよう `None` を返す。
336#[cfg(any(windows, test))]
337fn tasklist_csv_fields(line: &str) -> Option<Vec<String>> {
338    let mut fields = Vec::new();
339    let mut field = String::new();
340    let mut quoted = false;
341    let mut chars = line.chars().peekable();
342
343    while let Some(ch) = chars.next() {
344        match ch {
345            '"' if quoted && chars.peek() == Some(&'"') => {
346                field.push('"');
347                chars.next();
348            }
349            '"' => quoted = !quoted,
350            ',' if !quoted => fields.push(std::mem::take(&mut field)),
351            _ => field.push(ch),
352        }
353    }
354    (!quoted).then(|| {
355        fields.push(field);
356        fields
357    })
358}
359
360/// `tasklist` の失敗を、利用不能な問い合わせとして保持する。
361#[cfg(any(windows, test))]
362fn tasklist_result(
363    pid: u32,
364    success: bool,
365    stdout: &[u8],
366    stderr: &[u8],
367    status: &str,
368) -> std::io::Result<bool> {
369    if success {
370        parse_windows_tasklist_output(pid, stdout)
371    } else {
372        Err(std::io::Error::other(format!(
373            "tasklist exited {status}: {}",
374            String::from_utf8_lossy(stderr).trim()
375        )))
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382
383    /// The flag is the one Windows documents, and not one of the two it is
384    /// easily confused with.
385    ///
386    /// `DETACHED_PROCESS` (0x8) is what leaves a process without a console -
387    /// which is what caused the windows this module exists to stop, because a
388    /// child of such a process gets a fresh console *with* a window.
389    /// `CREATE_NEW_CONSOLE` (0x10) asks for the window outright.
390    #[cfg(windows)]
391    #[test]
392    fn the_flag_hides_a_console_rather_than_removing_or_creating_one() {
393        assert_eq!(CREATE_NO_WINDOW, 0x0800_0000);
394        assert_ne!(CREATE_NO_WINDOW, 0x0000_0008, "DETACHED_PROCESS");
395        assert_ne!(CREATE_NO_WINDOW, 0x0000_0010, "CREATE_NEW_CONSOLE");
396    }
397
398    /// Applying it does not disturb the command being built.
399    ///
400    /// The trait returns `&mut Self` so it can sit in the middle of a builder
401    /// chain, and a call site that put it there must not lose its program or
402    /// arguments to it.
403    #[test]
404    fn quiet_leaves_the_command_it_was_handed_intact() {
405        let mut cmd = tokio::process::Command::new("git");
406        cmd.args(["status", "--short"]).quiet();
407        let built = cmd.as_std();
408        assert_eq!(built.get_program(), "git");
409        let args: Vec<_> = built.get_args().collect();
410        assert_eq!(args, ["status", "--short"]);
411    }
412
413    /// Every `Command::new` in this crate's own sources is either quieted or
414    /// carries one of the two exemptions this module's doc explains.
415    ///
416    /// A textual scan, not a lint: nothing in `cargo clippy` knows that a
417    /// console-app child of a console-less parent gets a window, so nothing
418    /// catches a spawn that forgot `.quiet()` short of a human reading every
419    /// call site - which is exactly how `disk.rs`'s PowerShell probe and
420    /// `graph.rs`'s `gh pr create` went unquieted despite every neighbouring
421    /// spawn getting it right. Each `Command::new` is checked against the
422    /// text between it and the next one in the same file (or end of file),
423    /// which is always enough to cover its own builder chain and never
424    /// bleeds into an unrelated spawn's exemption.
425    #[test]
426    fn every_spawn_in_the_crate_is_quiet_or_documented_as_exempt() {
427        let src_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
428        let mut offenders = Vec::new();
429        for entry in std::fs::read_dir(&src_dir).expect("read src dir") {
430            let path = entry.expect("dir entry").path();
431            if path.extension().and_then(|e| e.to_str()) != Some("rs") {
432                continue;
433            }
434            let file_name = path
435                .file_name()
436                .and_then(|n| n.to_str())
437                .unwrap_or("")
438                .to_owned();
439            if file_name == "tui.rs" {
440                // explorer / open / xdg-open: GUI launchers, not console
441                // children - out of scope by design (see AGENTS.md).
442                continue;
443            }
444            let text = std::fs::read_to_string(&path).expect("read source file");
445            let lines: Vec<&str> = text.lines().collect();
446            let spawn_at: Vec<usize> = lines
447                .iter()
448                .enumerate()
449                .filter(|(_, l)| l.contains("Command::new("))
450                .map(|(i, _)| i)
451                .collect();
452            for (pos, &start) in spawn_at.iter().enumerate() {
453                let end = spawn_at.get(pos + 1).copied().unwrap_or(lines.len());
454                let block = lines[start..end].join("\n");
455                if block.contains(".quiet()") {
456                    continue;
457                }
458                // `spawn_successor`'s DETACHED_PROCESS successor has no
459                // console to inherit in the first place; see its doc comment
460                // in `web.rs`.
461                if block.contains("DETACHED_PROCESS") {
462                    continue;
463                }
464                // A spawn guarded by `#[cfg(unix)]` a few lines above cannot
465                // hit the Windows console bug at all.
466                let preceding = lines[start.saturating_sub(5)..start].join("\n");
467                if preceding.contains("#[cfg(unix)]") {
468                    continue;
469                }
470                offenders.push(format!("{file_name}:{}", start + 1));
471            }
472        }
473        assert!(
474            offenders.is_empty(),
475            "Command::new without .quiet() and no documented exemption: {offenders:?}"
476        );
477    }
478
479    #[test]
480    fn pid_liveness_policy_is_deterministic_without_an_os_process_query() {
481        assert!(pid_alive_with(42, |_| Ok(true)));
482        assert!(!pid_alive_with(42, |_| Ok(false)));
483    }
484
485    #[test]
486    fn an_unavailable_process_query_is_never_mistaken_for_a_dead_process() {
487        assert!(pid_alive_with(42, |_| Err(std::io::Error::other(
488            "access denied"
489        ))));
490    }
491
492    /// Unlike [`pid_alive_with`]'s Err-means-alive bias, the three-valued read
493    /// leaves an unavailable query as `None` rather than inventing either
494    /// answer — a display that guessed "dead" here would be exactly the wrong
495    /// kind of confidence this exists to avoid.
496    #[test]
497    fn pid_status_reports_alive_dead_and_unknown_as_three_distinct_answers() {
498        assert_eq!(pid_status_with(42, |_| Ok(true)), Some(true));
499        assert_eq!(pid_status_with(42, |_| Ok(false)), Some(false));
500        assert_eq!(
501            pid_status_with(42, |_| Err(std::io::Error::other("access denied"))),
502            None
503        );
504    }
505
506    /// 本番パーサー用のコマンド出力フィクスチャであり、特定 PID の OS 上の
507    /// 死亡状態を主張するものではない。
508    #[test]
509    fn unix_kill_output_only_marks_no_such_process_as_dead() {
510        assert!(parse_unix_kill_output(true, b""));
511        assert!(!parse_unix_kill_output(
512            false,
513            b"kill: (12345) - No such process\n"
514        ));
515        assert!(parse_unix_kill_output(
516            false,
517            b"kill: (12345) - Operation not permitted\n"
518        ));
519    }
520
521    /// 本番パーサー用のコマンド出力フィクスチャであり、OS の生存照会ではない。
522    /// 失敗した `tasklist` は死亡ではなく利用不能のままとする。
523    #[test]
524    fn windows_tasklist_csv_parsing_handles_match_no_match_and_error() {
525        let pid = 12345;
526        assert!(
527            parse_windows_tasklist_output(
528                pid,
529                b"\"magi.exe\",\"12345\",\"Console\",\"1\",\"10 K\"\r\n"
530            )
531            .expect("整形式 CSV の一致行は生存を示す")
532        );
533        assert!(
534            parse_windows_tasklist_output(
535                pid,
536                b"\"magi,worker.exe\",\"12345\",\"Console\",\"1\",\"10 K\"\r\n"
537            )
538            .expect("カンマ入りイメージ名でも PID 列を読む")
539        );
540        assert!(
541            !parse_windows_tasklist_output(
542                pid,
543                b"INFO: No tasks are running which match the specified criteria.\r\n"
544            )
545            .expect("tasklist の no-match 出力は整形式である")
546        );
547        assert!(
548            parse_windows_tasklist_output(pid, b"\"magi.exe\",\"12345").is_err(),
549            "壊れた CSV は死亡ではなく利用不能である"
550        );
551        assert!(
552            parse_windows_tasklist_output(pid, b"").is_err(),
553            "空出力は死亡ではなく利用不能である"
554        );
555        assert!(
556            parse_windows_tasklist_output(pid, b"\r\n").is_err(),
557            "空白だけの出力は死亡ではなく利用不能である"
558        );
559        assert!(
560            tasklist_result(
561                pid,
562                true,
563                b"\"magi.exe\",\"12345\",\"Console\",\"1\",\"10 K\"\r\n",
564                b"",
565                "exit status: 0",
566            )
567            .expect("CSV の一致行は生存を示す")
568        );
569
570        let error = tasklist_result(pid, false, b"", b"Access is denied.\r\n", "exit status: 1")
571            .expect_err("tasklist の失敗は死亡ではなく利用不能である");
572        assert!(error.to_string().contains("Access is denied."));
573    }
574
575    /// このテスト自身の PID を OS に問い合わせるスモーク診断。
576    ///
577    /// CI では実際のコマンド実行と成功出力の解析を必須にする。制限された
578    /// 対話席で問い合わせ自体が使えない場合は、その事実を出力して成功結果や
579    /// 死んだプロセスと取り違えない。実行中の PID を dead と報告した場合と、
580    /// CI で問い合わせが利用不能な場合は失敗にする。
581    #[test]
582    fn platform_query_reports_this_running_process_as_alive_or_unavailable() {
583        let pid = std::process::id();
584        match platform_pid_alive(pid) {
585            Ok(true) => {}
586            Ok(false) => {
587                panic!("OS の PID 問い合わせが実行中のテストプロセス {pid} を dead と報告した")
588            }
589            Err(error) if std::env::var_os("CI").is_some() => {
590                panic!("CI で OS の PID 問い合わせを実行できない(テストプロセス {pid}): {error}")
591            }
592            Err(error) => {
593                eprintln!("OS の PID 問い合わせは利用できません(テストプロセス {pid}): {error}")
594            }
595        }
596    }
597
598    /// 同じスモーク診断を `process_started_at` にも適用する: 実行中の
599    /// このテストプロセス自身に対して呼ぶと、利用可能な環境では必ず何か
600    /// 返り、そして二回呼んでも同じ値を返す — 同一プロセスの起動時刻が
601    /// 問い合わせのたびにずれては、pid 再利用との判別に使えない。
602    #[test]
603    fn platform_query_reports_this_running_process_start_time_consistently_or_unavailable() {
604        let pid = std::process::id();
605        match (
606            platform_process_started_at(pid),
607            platform_process_started_at(pid),
608        ) {
609            (Ok(first), Ok(second)) => assert_eq!(
610                first, second,
611                "同一の生存プロセスへの二回の問い合わせが食い違った"
612            ),
613            (Err(error), _) | (_, Err(error)) if std::env::var_os("CI").is_some() => {
614                panic!("CI で起動時刻の問い合わせを実行できない(テストプロセス {pid}): {error}")
615            }
616            _ => eprintln!("起動時刻の問い合わせは利用できません(テストプロセス {pid})"),
617        }
618    }
619}