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, read through `sysinfo`'s
71/// process table - read-only, and no helper process is spawned.
72///
73/// A pid the table does not list reads as dead. On a platform `sysinfo` does
74/// not support the query fails, which reads as alive.
75///
76/// Every uncertain outcome reads as alive, on purpose. This exists so
77/// [`crate::daemon::sweep_stale_claims`] can reclaim a lock faster than its
78/// age-based fallback when the owning process is verifiably gone; the risk
79/// on the other side - reclaiming a lock a live process still holds - lets a
80/// second daemon start a second run on the same task, which costs far more
81/// than leaving one lock alone a little longer. So a helper program that is
82/// missing, output that cannot be parsed, or a permission error that merely
83/// proves the pid exists under another account, all count as "alive" rather
84/// than as license to reclaim.
85#[must_use]
86pub fn pid_alive(pid: u32) -> bool {
87    pid_alive_with(pid, platform_pid_alive)
88}
89
90/// Apply the conservative policy to one platform liveness query.
91///
92/// Kept separate from the OS command so queue and daemon tests can exercise
93/// dead, live, and unavailable answers without requiring permission to list
94/// the machine's processes.
95fn pid_alive_with<F>(pid: u32, query: F) -> bool
96where
97    F: FnOnce(u32) -> std::io::Result<bool>,
98{
99    match query(pid) {
100        Ok(alive) => alive,
101        Err(error) => {
102            // Sweeping is a poll-loop operation, so state the environment
103            // problem at the default log level without repeating it for every
104            // protected lock on every poll.
105            static REPORTED: std::sync::Once = std::sync::Once::new();
106            REPORTED.call_once(|| {
107                tracing::warn!(
108                    %pid,
109                    %error,
110                    "process liveness query unavailable; keeping locks rather than treating processes as dead"
111                );
112            });
113            true
114        }
115    }
116}
117
118/// A three-valued liveness read, for a caller that *displays* whether a
119/// process is running rather than deciding whether it is safe to reclaim a
120/// lock. [`pid_alive`]'s Err-means-alive policy exists to protect a lock a
121/// live process still holds — the wrong bias for a report that must never
122/// tell an operator a process is confirmed dead just because this build
123/// could not ask the platform. `None` here is the honest "could not tell",
124/// left for the caller to render as its own "unknown" rather than folded
125/// into either `Some` answer.
126#[must_use]
127pub fn pid_status(pid: u32) -> Option<bool> {
128    pid_status_with(pid, platform_pid_alive)
129}
130
131/// [`pid_status`] with its process-liveness query supplied by the caller —
132/// see [`pid_alive_with`] for why this split exists.
133fn pid_status_with<F>(pid: u32, query: F) -> Option<bool>
134where
135    F: FnOnce(u32) -> std::io::Result<bool>,
136{
137    query(pid).ok()
138}
139
140/// An opaque marker identifying *which* process currently holds `pid`, not
141/// merely whether the number is in use — the OS-reported moment it started.
142/// A plain integer string (epoch seconds from `sysinfo`), so it does not
143/// depend on the locale of the process asking - an `lstart` string recorded
144/// under one locale never matched the same process read under another.
145/// Compared only for equality by the caller; see [`is_identity_marker`].
146///
147/// A live pid alone never proves it is the process a caller thinks it is —
148/// pids get reused, sometimes within minutes on a busy machine — so
149/// [`crate::run::RunState::liveness`] uses this to corroborate a `driver_pid`
150/// that answered `pid_status(..) == Some(true)`: it records this marker
151/// alongside the pid, and a later mismatch means a *different* process now
152/// answers to that number, not that the original one is somehow still
153/// running under it. `None` when the platform could not say — a caller must
154/// treat that exactly like an unavailable [`pid_status`] query, not as
155/// either a match or a mismatch.
156#[must_use]
157pub fn process_started_at(pid: u32) -> Option<String> {
158    platform_process_started_at(pid).ok()
159}
160
161/// A per-request memo over [`pid_status`] and [`process_started_at`].
162///
163/// A listing of hundreds of runs asks about the same few pids over and over,
164/// and every ask walks the platform's process table. Asking once per pid is
165/// enough within one request; the probe is meant to be dropped with it, never
166/// kept, so a stale answer cannot outlive the moment it was read.
167pub struct ProcProbe<S, I> {
168    status: S,
169    identity: I,
170    alive: std::collections::HashMap<u32, Option<bool>>,
171    started: std::collections::HashMap<u32, Option<String>>,
172}
173
174impl ProcProbe<fn(u32) -> Option<bool>, fn(u32) -> Option<String>> {
175    /// A probe backed by the real platform queries.
176    #[must_use]
177    pub fn real() -> Self {
178        Self::new(pid_status, process_started_at)
179    }
180}
181
182impl<S, I> ProcProbe<S, I>
183where
184    S: FnMut(u32) -> Option<bool>,
185    I: FnMut(u32) -> Option<String>,
186{
187    /// A probe over caller-supplied queries.
188    #[must_use]
189    pub fn new(status: S, identity: I) -> Self {
190        Self {
191            status,
192            identity,
193            alive: std::collections::HashMap::new(),
194            started: std::collections::HashMap::new(),
195        }
196    }
197
198    /// [`pid_status`], asked at most once per pid.
199    pub fn status(&mut self, pid: u32) -> Option<bool> {
200        *self.alive.entry(pid).or_insert_with(|| (self.status)(pid))
201    }
202
203    /// [`process_started_at`], asked at most once per pid.
204    pub fn started_at(&mut self, pid: u32) -> Option<String> {
205        self.started
206            .entry(pid)
207            .or_insert_with(|| (self.identity)(pid))
208            .clone()
209    }
210}
211
212/// Ask the platform for one process's start time (epoch seconds), without
213/// shelling out to anything.
214///
215/// `Ok(Some(t))` is a process present in the table, `Ok(None)` is a pid with
216/// no process, and `Err` is a platform `sysinfo` does not support or a
217/// process table that cannot be read (detected by this process's own absence). Only the
218/// requested pid is refreshed, never the whole table. `sysinfo` cannot tell
219/// "no such process" from "not visible to this account", so a pid owned by
220/// another user that the platform hides reads as absent; identity queries
221/// never turn that into a verdict (see [`platform_process_started_at`]).
222fn query_process(pid: u32) -> std::io::Result<Option<u64>> {
223    use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};
224
225    if !sysinfo::IS_SUPPORTED_SYSTEM {
226        return Err(std::io::Error::other(
227            "process queries are unavailable on this platform",
228        ));
229    }
230    // Start times are `boot_time + ticks`; when `/proc/stat` has no `btime`
231    // sysinfo substitutes a moving clock, so the same live pid would get a
232    // different identity on every query.
233    #[cfg(target_os = "linux")]
234    if !linux_boot_time_readable() {
235        return Err(std::io::Error::other(
236            "boot time is unreadable: start times would not be stable",
237        ));
238    }
239    let pid = Pid::from_u32(pid);
240    let own = Pid::from_u32(std::process::id());
241    let mut system = System::new();
242    system.refresh_processes_specifics(
243        // A duplicate pid in the list (querying this process itself) makes
244        // `sysinfo` drop the entry, so the target is only added when distinct.
245        ProcessesToUpdate::Some(&if pid == own {
246            vec![own]
247        } else {
248            vec![pid, own]
249        }),
250        true,
251        ProcessRefreshKind::nothing(),
252    );
253    // A failed refresh (an unmounted or unreadable process table) looks
254    // exactly like an absent pid. This process is certainly alive, so if it
255    // is missing too the table cannot be trusted and nothing may be read
256    // from it - least of all "dead".
257    if system.process(own).is_none_or(|p| p.start_time() == 0) {
258        return Err(std::io::Error::other(
259            "process table is unreadable: this process is not listed",
260        ));
261    }
262    let found = system.process(pid).map(sysinfo::Process::start_time);
263    // This process being listed does not prove the target's own entry could
264    // be read: an unreadable `/proc/<pid>/stat` leaves a live pid out of the
265    // table. On Linux the directory itself is the independent witness - if it
266    // exists the pid is alive, so "not listed" is a failed read, not absence.
267    #[cfg(target_os = "linux")]
268    if found.is_none() && std::path::Path::new(&format!("/proc/{}", pid.as_u32())).exists() {
269        return Err(std::io::Error::other(
270            "process exists but its entry could not be read",
271        ));
272    }
273    // Other unixes have no `/proc` to consult, but `kill(pid, 0)` is an
274    // independent witness: anything but `ESRCH` means the pid exists, so a
275    // target `sysinfo` could not read (e.g. a denied `KERN_PROCARGS2` on
276    // macOS) is an unreadable entry, not an absent process.
277    #[cfg(all(unix, not(target_os = "linux")))]
278    if found.is_none() && unix_pid_exists(pid.as_u32()) {
279        return Err(std::io::Error::other(
280            "process exists but its entry could not be read",
281        ));
282    }
283    // A `/proc` mounted with `hidepid=1|2` hides other users' pids, so a miss
284    // is not proof of absence there; nor is one when the mount table cannot
285    // be read to tell.
286    #[cfg(target_os = "linux")]
287    if found.is_none() && !linux_proc_shows_all_pids() {
288        return Err(std::io::Error::other(
289            "absence is unprovable: /proc may hide other users' processes",
290        ));
291    }
292    Ok(found)
293}
294
295/// Whether `kill(pid, 0)` finds the pid: success or `EPERM` both mean it exists.
296#[cfg(all(unix, not(target_os = "linux")))]
297fn unix_pid_exists(pid: u32) -> bool {
298    let Ok(pid) = libc::pid_t::try_from(pid) else {
299        return false;
300    };
301    // SAFETY: signal 0 only checks for existence and delivers nothing.
302    let rc = unsafe { libc::kill(pid, 0) };
303    rc == 0 || std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
304}
305
306/// Whether `/proc/stat` carries a non-zero `btime` line.
307#[cfg(target_os = "linux")]
308fn linux_boot_time_readable() -> bool {
309    std::fs::read_to_string("/proc/stat").is_ok_and(|s| stat_has_btime(&s))
310}
311
312#[cfg(any(target_os = "linux", test))]
313fn stat_has_btime(stat: &str) -> bool {
314    stat.lines().any(|l| {
315        l.strip_prefix("btime ")
316            .and_then(|v| v.trim().parse::<u64>().ok())
317            .is_some_and(|v| v > 0)
318    })
319}
320
321/// Whether `/proc` is mounted without `hidepid`, judged from the mount table.
322/// An unreadable table or a missing `/proc` entry answers `false`.
323#[cfg(target_os = "linux")]
324fn linux_proc_shows_all_pids() -> bool {
325    std::fs::read_to_string("/proc/self/mountinfo").is_ok_and(|s| mountinfo_proc_unhidden(&s))
326}
327
328#[cfg(any(target_os = "linux", test))]
329fn mountinfo_proc_unhidden(mountinfo: &str) -> bool {
330    mountinfo
331        .lines()
332        .rfind(|l| l.split_whitespace().nth(4) == Some("/proc"))
333        .is_some_and(|l| {
334            // Only an explicit `hidepid=0` / `off` (or no option at all)
335            // shows every pid; `1`, `2`, `4`, their names and any value not
336            // known here all count as restricted.
337            l.split(|c: char| c.is_whitespace() || c == ',')
338                .filter_map(|o| o.strip_prefix("hidepid="))
339                .all(|v| matches!(v, "0" | "off"))
340        })
341}
342
343/// Whether the identity marker format is the current one: a plain integer
344/// (epoch seconds). Runs recorded before this format carried the locale
345/// dependent `ps -o lstart=` text, which can never be compared reliably.
346#[must_use]
347pub fn is_identity_marker(s: &str) -> bool {
348    !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
349}
350
351/// The start time as a locale-independent integer string. Absence, a zero
352/// (what `sysinfo` reports when the platform would not say) and an
353/// unsupported platform are all errors, so a comparison can never be built
354/// on a guess.
355fn platform_process_started_at(pid: u32) -> std::io::Result<String> {
356    match query_process(pid)? {
357        Some(0) => Err(std::io::Error::other("process start time is unavailable")),
358        Some(started) => Ok(started.to_string()),
359        None => Err(std::io::Error::other("no such process")),
360    }
361}
362
363fn platform_pid_alive(pid: u32) -> std::io::Result<bool> {
364    query_process(pid).map(|found| found.is_some())
365}
366
367#[cfg(test)]
368mod tests {
369    use super::*;
370
371    /// The flag is the one Windows documents, and not one of the two it is
372    /// easily confused with.
373    ///
374    /// `DETACHED_PROCESS` (0x8) is what leaves a process without a console -
375    /// which is what caused the windows this module exists to stop, because a
376    /// child of such a process gets a fresh console *with* a window.
377    /// `CREATE_NEW_CONSOLE` (0x10) asks for the window outright.
378    #[cfg(windows)]
379    #[test]
380    fn the_flag_hides_a_console_rather_than_removing_or_creating_one() {
381        assert_eq!(CREATE_NO_WINDOW, 0x0800_0000);
382        assert_ne!(CREATE_NO_WINDOW, 0x0000_0008, "DETACHED_PROCESS");
383        assert_ne!(CREATE_NO_WINDOW, 0x0000_0010, "CREATE_NEW_CONSOLE");
384    }
385
386    /// Applying it does not disturb the command being built.
387    ///
388    /// The trait returns `&mut Self` so it can sit in the middle of a builder
389    /// chain, and a call site that put it there must not lose its program or
390    /// arguments to it.
391    #[test]
392    fn quiet_leaves_the_command_it_was_handed_intact() {
393        let mut cmd = tokio::process::Command::new("git");
394        cmd.args(["status", "--short"]).quiet();
395        let built = cmd.as_std();
396        assert_eq!(built.get_program(), "git");
397        let args: Vec<_> = built.get_args().collect();
398        assert_eq!(args, ["status", "--short"]);
399    }
400
401    /// Every `Command::new` in this crate's own sources is either quieted or
402    /// carries one of the two exemptions this module's doc explains.
403    ///
404    /// A textual scan, not a lint: nothing in `cargo clippy` knows that a
405    /// console-app child of a console-less parent gets a window, so nothing
406    /// catches a spawn that forgot `.quiet()` short of a human reading every
407    /// call site - which is exactly how `disk.rs`'s PowerShell probe and
408    /// `graph.rs`'s `gh pr create` went unquieted despite every neighbouring
409    /// spawn getting it right. Each `Command::new` is checked against the
410    /// text between it and the next one in the same file (or end of file),
411    /// which is always enough to cover its own builder chain and never
412    /// bleeds into an unrelated spawn's exemption.
413    #[test]
414    fn every_spawn_in_the_crate_is_quiet_or_documented_as_exempt() {
415        // Read at run time: a shared target dir can hand this binary to a
416        // different worktree, and the compile-time path would then scan
417        // another tree's sources.
418        let manifest = std::env::var_os("CARGO_MANIFEST_DIR")
419            .map(std::path::PathBuf::from)
420            .unwrap_or_else(|| std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")));
421        let src_dir = manifest.join("src");
422        let mut offenders = Vec::new();
423        for entry in std::fs::read_dir(&src_dir).expect("read src dir") {
424            let path = entry.expect("dir entry").path();
425            if path.extension().and_then(|e| e.to_str()) != Some("rs") {
426                continue;
427            }
428            let file_name = path
429                .file_name()
430                .and_then(|n| n.to_str())
431                .unwrap_or("")
432                .to_owned();
433            if file_name == "tui.rs" {
434                // explorer / open / xdg-open: GUI launchers, not console
435                // children - out of scope by design (see AGENTS.md).
436                continue;
437            }
438            let text = std::fs::read_to_string(&path).expect("read source file");
439            let lines: Vec<&str> = text.lines().collect();
440            let spawn_at: Vec<usize> = lines
441                .iter()
442                .enumerate()
443                .filter(|(_, l)| l.contains("Command::new("))
444                .map(|(i, _)| i)
445                .collect();
446            for (pos, &start) in spawn_at.iter().enumerate() {
447                let end = spawn_at.get(pos + 1).copied().unwrap_or(lines.len());
448                let block = lines[start..end].join("\n");
449                if block.contains(".quiet()") {
450                    continue;
451                }
452                // `spawn_successor`'s DETACHED_PROCESS successor has no
453                // console to inherit in the first place; see its doc comment
454                // in `web.rs`.
455                if block.contains("DETACHED_PROCESS") {
456                    continue;
457                }
458                // A spawn guarded by `#[cfg(unix)]` a few lines above cannot
459                // hit the Windows console bug at all.
460                let preceding = lines[start.saturating_sub(5)..start].join("\n");
461                if preceding.contains("#[cfg(unix)]") {
462                    continue;
463                }
464                offenders.push(format!("{file_name}:{}", start + 1));
465            }
466        }
467        assert!(
468            offenders.is_empty(),
469            "Command::new without .quiet() and no documented exemption: {offenders:?}"
470        );
471    }
472
473    #[test]
474    fn btime_and_hidepid_parsers_distrust_what_they_cannot_confirm() {
475        assert!(stat_has_btime("cpu 1 2\nbtime 1700000000\n"));
476        assert!(!stat_has_btime("cpu 1 2\n"));
477        assert!(!stat_has_btime("btime 0\n"));
478        let open = "25 1 0:5 / /proc rw,nosuid - proc proc rw";
479        let hidden = "25 1 0:5 / /proc rw,nosuid - proc proc rw,hidepid=2";
480        assert!(mountinfo_proc_unhidden(open));
481        assert!(!mountinfo_proc_unhidden(hidden));
482        for v in ["1", "4", "ptraceable", "noaccess", "future"] {
483            let line = format!("25 1 0:5 / /proc rw - proc proc rw,hidepid={v}");
484            assert!(!mountinfo_proc_unhidden(&line), "hidepid={v}");
485        }
486        assert!(mountinfo_proc_unhidden(
487            "25 1 0:5 / /proc rw - proc proc rw,hidepid=0"
488        ));
489        assert!(!mountinfo_proc_unhidden(""));
490    }
491
492    #[test]
493    fn pid_liveness_policy_is_deterministic_without_an_os_process_query() {
494        assert!(pid_alive_with(42, |_| Ok(true)));
495        assert!(!pid_alive_with(42, |_| Ok(false)));
496    }
497
498    #[test]
499    fn an_unavailable_process_query_is_never_mistaken_for_a_dead_process() {
500        assert!(pid_alive_with(42, |_| Err(std::io::Error::other(
501            "access denied"
502        ))));
503    }
504
505    /// Unlike [`pid_alive_with`]'s Err-means-alive bias, the three-valued read
506    /// leaves an unavailable query as `None` rather than inventing either
507    /// answer — a display that guessed "dead" here would be exactly the wrong
508    /// kind of confidence this exists to avoid.
509    #[test]
510    fn pid_status_reports_alive_dead_and_unknown_as_three_distinct_answers() {
511        assert_eq!(pid_status_with(42, |_| Ok(true)), Some(true));
512        assert_eq!(pid_status_with(42, |_| Ok(false)), Some(false));
513        assert_eq!(
514            pid_status_with(42, |_| Err(std::io::Error::other("access denied"))),
515            None
516        );
517    }
518
519    /// このテスト自身の PID を OS に問い合わせるスモーク診断。
520    ///
521    /// CI では実際のコマンド実行と成功出力の解析を必須にする。制限された
522    /// 対話席で問い合わせ自体が使えない場合は、その事実を出力して成功結果や
523    /// 死んだプロセスと取り違えない。実行中の PID を dead と報告した場合と、
524    /// CI で問い合わせが利用不能な場合は失敗にする。
525    #[test]
526    fn platform_query_reports_this_running_process_as_alive_or_unavailable() {
527        let pid = std::process::id();
528        match platform_pid_alive(pid) {
529            Ok(true) => {}
530            Ok(false) => {
531                panic!("OS の PID 問い合わせが実行中のテストプロセス {pid} を dead と報告した")
532            }
533            Err(error) if std::env::var_os("CI").is_some() => {
534                panic!("CI で OS の PID 問い合わせを実行できない(テストプロセス {pid}): {error}")
535            }
536            Err(error) => {
537                eprintln!("OS の PID 問い合わせは利用できません(テストプロセス {pid}): {error}")
538            }
539        }
540    }
541
542    /// 同じスモーク診断を `process_started_at` にも適用する: 実行中の
543    /// このテストプロセス自身に対して呼ぶと、利用可能な環境では必ず何か
544    /// 返り、そして二回呼んでも同じ値を返す — 同一プロセスの起動時刻が
545    /// 問い合わせのたびにずれては、pid 再利用との判別に使えない。
546    #[test]
547    fn platform_query_reports_this_running_process_start_time_consistently_or_unavailable() {
548        let pid = std::process::id();
549        match (
550            platform_process_started_at(pid),
551            platform_process_started_at(pid),
552        ) {
553            (Ok(first), Ok(second)) => {
554                assert_eq!(
555                    first, second,
556                    "同一の生存プロセスへの二回の問い合わせが食い違った"
557                );
558                assert!(is_identity_marker(&first), "整数文字列でない: {first}");
559            }
560            (Err(error), _) | (_, Err(error)) if std::env::var_os("CI").is_some() => {
561                panic!("CI で起動時刻の問い合わせを実行できない(テストプロセス {pid}): {error}")
562            }
563            _ => eprintln!("起動時刻の問い合わせは利用できません(テストプロセス {pid})"),
564        }
565    }
566}