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 relevant kinds of parent:
7//!
8//! - Terminal commands: ordinary console helpers inherit the console, while
9//!   redirected pipes keep their output out of the operator's screen.
10//! - Detached web, scheduler and execution workers: these have no console.
11//!   On Windows an ordinary console child would allocate and display a new
12//!   one unless its launcher suppresses the window. An implement wave can
13//!   otherwise open several black windows over the operator's browser.
14//!
15//! `CREATE_NO_WINDOW` is the answer to exactly that: the child still gets a
16//! console for its standard handles, and that console is never shown. It is
17//! not the same as `DETACHED_PROCESS`, which gives the child no console and
18//! would make a grandchild pop a window of its own for the same reason.
19//!
20//! Nothing here is conditional on how magi was started. A hidden console is
21//! correct in a terminal too: the pipes are redirected either way, so there
22//! was never anything to look at.
23
24/// `CREATE_NO_WINDOW` - run the child's console, but never draw it.
25///
26/// From `processthreadsapi.h`. Spelled out rather than pulled in from a
27/// bindings crate: it is one number that has been stable since Windows 2000,
28/// and the alternative is a dependency for it.
29#[cfg(windows)]
30const CREATE_NO_WINDOW: u32 = 0x0800_0000;
31
32/// Spawn without a visible console window.
33///
34/// Implemented for both `Command` types magi uses - `std` for the few
35/// synchronous calls, `tokio` for everything else - so a call site does not
36/// have to know which one it is holding, and so no call site has to repeat a
37/// `#[cfg(windows)]` block to get it.
38///
39/// A no-op off Windows, where a spawned process has no window to begin with.
40pub trait Quiet {
41    /// Apply it, and hand the command back for further building.
42    fn quiet(&mut self) -> &mut Self;
43}
44
45impl Quiet for std::process::Command {
46    fn quiet(&mut self) -> &mut Self {
47        #[cfg(windows)]
48        {
49            use std::os::windows::process::CommandExt as _;
50            self.creation_flags(CREATE_NO_WINDOW);
51        }
52        self
53    }
54}
55
56impl Quiet for tokio::process::Command {
57    fn quiet(&mut self) -> &mut Self {
58        #[cfg(windows)]
59        {
60            self.creation_flags(CREATE_NO_WINDOW);
61        }
62        self
63    }
64}
65
66/// Best-effort liveness check for a process id, read through `sysinfo`'s
67/// process table - read-only, and no helper process is spawned.
68///
69/// A pid the table does not list reads as dead. On a platform `sysinfo` does
70/// not support the query fails, which reads as alive.
71///
72/// Every uncertain outcome reads as alive, on purpose. This exists so
73/// [`crate::daemon::sweep_stale_claims`] can reclaim a lock faster than its
74/// age-based fallback when the owning process is verifiably gone; the risk
75/// on the other side - reclaiming a lock a live process still holds - lets a
76/// second daemon start a second run on the same task, which costs far more
77/// than leaving one lock alone a little longer. So a helper program that is
78/// missing, output that cannot be parsed, or a permission error that merely
79/// proves the pid exists under another account, all count as "alive" rather
80/// than as license to reclaim.
81#[must_use]
82pub fn pid_alive(pid: u32) -> bool {
83    pid_alive_with(pid, platform_pid_alive)
84}
85
86/// Apply the conservative policy to one platform liveness query.
87///
88/// Kept separate from the OS command so queue and daemon tests can exercise
89/// dead, live, and unavailable answers without requiring permission to list
90/// the machine's processes.
91fn pid_alive_with<F>(pid: u32, query: F) -> bool
92where
93    F: FnOnce(u32) -> std::io::Result<bool>,
94{
95    match query(pid) {
96        Ok(alive) => alive,
97        Err(error) => {
98            // Sweeping is a poll-loop operation, so state the environment
99            // problem at the default log level without repeating it for every
100            // protected lock on every poll.
101            static REPORTED: std::sync::Once = std::sync::Once::new();
102            REPORTED.call_once(|| {
103                tracing::warn!(
104                    %pid,
105                    %error,
106                    "process liveness query unavailable; keeping locks rather than treating processes as dead"
107                );
108            });
109            true
110        }
111    }
112}
113
114/// A three-valued liveness read, for a caller that *displays* whether a
115/// process is running rather than deciding whether it is safe to reclaim a
116/// lock. [`pid_alive`]'s Err-means-alive policy exists to protect a lock a
117/// live process still holds — the wrong bias for a report that must never
118/// tell an operator a process is confirmed dead just because this build
119/// could not ask the platform. `None` here is the honest "could not tell",
120/// left for the caller to render as its own "unknown" rather than folded
121/// into either `Some` answer.
122#[must_use]
123pub fn pid_status(pid: u32) -> Option<bool> {
124    pid_status_with(pid, platform_pid_alive)
125}
126
127/// [`pid_status`] with its process-liveness query supplied by the caller —
128/// see [`pid_alive_with`] for why this split exists.
129fn pid_status_with<F>(pid: u32, query: F) -> Option<bool>
130where
131    F: FnOnce(u32) -> std::io::Result<bool>,
132{
133    query(pid).ok()
134}
135
136/// An opaque marker identifying *which* process currently holds `pid`, not
137/// merely whether the number is in use — the OS-reported moment it started.
138/// A plain integer string (epoch seconds from `sysinfo`), so it does not
139/// depend on the locale of the process asking - an `lstart` string recorded
140/// under one locale never matched the same process read under another.
141/// Compared only for equality by the caller; see [`is_identity_marker`].
142///
143/// A live pid alone never proves it is the process a caller thinks it is —
144/// pids get reused, sometimes within minutes on a busy machine — so
145/// [`crate::run::RunState::liveness`] uses this to corroborate a `driver_pid`
146/// that answered `pid_status(..) == Some(true)`: it records this marker
147/// alongside the pid, and a later mismatch means a *different* process now
148/// answers to that number, not that the original one is somehow still
149/// running under it. `None` when the platform could not say — a caller must
150/// treat that exactly like an unavailable [`pid_status`] query, not as
151/// either a match or a mismatch.
152#[must_use]
153pub fn process_started_at(pid: u32) -> Option<String> {
154    platform_process_started_at(pid).ok()
155}
156
157/// A per-request memo over [`pid_status`] and [`process_started_at`].
158///
159/// A listing of hundreds of runs asks about the same few pids over and over,
160/// and every ask walks the platform's process table. Asking once per pid is
161/// enough within one request; the probe is meant to be dropped with it, never
162/// kept, so a stale answer cannot outlive the moment it was read.
163pub struct ProcProbe<S, I> {
164    status: S,
165    identity: I,
166    alive: std::collections::HashMap<u32, Option<bool>>,
167    started: std::collections::HashMap<u32, Option<String>>,
168}
169
170impl ProcProbe<fn(u32) -> Option<bool>, fn(u32) -> Option<String>> {
171    /// A probe backed by the real platform queries.
172    #[must_use]
173    pub fn real() -> Self {
174        Self::new(pid_status, process_started_at)
175    }
176}
177
178impl<S, I> ProcProbe<S, I>
179where
180    S: FnMut(u32) -> Option<bool>,
181    I: FnMut(u32) -> Option<String>,
182{
183    /// A probe over caller-supplied queries.
184    #[must_use]
185    pub fn new(status: S, identity: I) -> Self {
186        Self {
187            status,
188            identity,
189            alive: std::collections::HashMap::new(),
190            started: std::collections::HashMap::new(),
191        }
192    }
193
194    /// [`pid_status`], asked at most once per pid.
195    pub fn status(&mut self, pid: u32) -> Option<bool> {
196        *self.alive.entry(pid).or_insert_with(|| (self.status)(pid))
197    }
198
199    /// [`process_started_at`], asked at most once per pid.
200    pub fn started_at(&mut self, pid: u32) -> Option<String> {
201        self.started
202            .entry(pid)
203            .or_insert_with(|| (self.identity)(pid))
204            .clone()
205    }
206}
207
208/// Ask the platform for one process's start time (epoch seconds), without
209/// shelling out to anything.
210///
211/// `Ok(Some(t))` is a process present in the table, `Ok(None)` is a pid with
212/// no process, and `Err` is a platform `sysinfo` does not support or a
213/// process table that cannot be read (detected by this process's own absence). Only the
214/// requested pid is refreshed, never the whole table. `sysinfo` cannot tell
215/// "no such process" from "not visible to this account", so a pid owned by
216/// another user that the platform hides reads as absent; identity queries
217/// never turn that into a verdict (see [`platform_process_started_at`]).
218fn query_process(pid: u32) -> std::io::Result<Option<u64>> {
219    use sysinfo::{Pid, ProcessRefreshKind, ProcessesToUpdate, System};
220
221    if !sysinfo::IS_SUPPORTED_SYSTEM {
222        return Err(std::io::Error::other(
223            "process queries are unavailable on this platform",
224        ));
225    }
226    // Start times are `boot_time + ticks`; when `/proc/stat` has no `btime`
227    // sysinfo substitutes a moving clock, so the same live pid would get a
228    // different identity on every query.
229    #[cfg(target_os = "linux")]
230    if !linux_boot_time_readable() {
231        return Err(std::io::Error::other(
232            "boot time is unreadable: start times would not be stable",
233        ));
234    }
235    let pid = Pid::from_u32(pid);
236    let own = Pid::from_u32(std::process::id());
237    let mut system = System::new();
238    system.refresh_processes_specifics(
239        // A duplicate pid in the list (querying this process itself) makes
240        // `sysinfo` drop the entry, so the target is only added when distinct.
241        ProcessesToUpdate::Some(&if pid == own {
242            vec![own]
243        } else {
244            vec![pid, own]
245        }),
246        true,
247        ProcessRefreshKind::nothing(),
248    );
249    // A failed refresh (an unmounted or unreadable process table) looks
250    // exactly like an absent pid. This process is certainly alive, so if it
251    // is missing too the table cannot be trusted and nothing may be read
252    // from it - least of all "dead".
253    if system.process(own).is_none_or(|p| p.start_time() == 0) {
254        return Err(std::io::Error::other(
255            "process table is unreadable: this process is not listed",
256        ));
257    }
258    let found = system.process(pid).map(sysinfo::Process::start_time);
259    // This process being listed does not prove the target's own entry could
260    // be read: an unreadable `/proc/<pid>/stat` leaves a live pid out of the
261    // table. On Linux the directory itself is the independent witness - if it
262    // exists the pid is alive, so "not listed" is a failed read, not absence.
263    #[cfg(target_os = "linux")]
264    if found.is_none() && std::path::Path::new(&format!("/proc/{}", pid.as_u32())).exists() {
265        return Err(std::io::Error::other(
266            "process exists but its entry could not be read",
267        ));
268    }
269    // Other unixes have no `/proc` to consult, but `kill(pid, 0)` is an
270    // independent witness: anything but `ESRCH` means the pid exists, so a
271    // target `sysinfo` could not read (e.g. a denied `KERN_PROCARGS2` on
272    // macOS) is an unreadable entry, not an absent process.
273    #[cfg(all(unix, not(target_os = "linux")))]
274    if found.is_none() && unix_pid_exists(pid.as_u32()) {
275        return Err(std::io::Error::other(
276            "process exists but its entry could not be read",
277        ));
278    }
279    // A `/proc` mounted with `hidepid=1|2` hides other users' pids, so a miss
280    // is not proof of absence there; nor is one when the mount table cannot
281    // be read to tell.
282    #[cfg(target_os = "linux")]
283    if found.is_none() && !linux_proc_shows_all_pids() {
284        return Err(std::io::Error::other(
285            "absence is unprovable: /proc may hide other users' processes",
286        ));
287    }
288    Ok(found)
289}
290
291/// Whether `kill(pid, 0)` finds the pid: success or `EPERM` both mean it exists.
292#[cfg(all(unix, not(target_os = "linux")))]
293fn unix_pid_exists(pid: u32) -> bool {
294    let Ok(pid) = libc::pid_t::try_from(pid) else {
295        return false;
296    };
297    // SAFETY: signal 0 only checks for existence and delivers nothing.
298    let rc = unsafe { libc::kill(pid, 0) };
299    rc == 0 || std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
300}
301
302/// Whether `/proc/stat` carries a non-zero `btime` line.
303#[cfg(target_os = "linux")]
304fn linux_boot_time_readable() -> bool {
305    std::fs::read_to_string("/proc/stat").is_ok_and(|s| stat_has_btime(&s))
306}
307
308#[cfg(any(target_os = "linux", test))]
309fn stat_has_btime(stat: &str) -> bool {
310    stat.lines().any(|l| {
311        l.strip_prefix("btime ")
312            .and_then(|v| v.trim().parse::<u64>().ok())
313            .is_some_and(|v| v > 0)
314    })
315}
316
317/// Whether `/proc` is mounted without `hidepid`, judged from the mount table.
318/// An unreadable table or a missing `/proc` entry answers `false`.
319#[cfg(target_os = "linux")]
320fn linux_proc_shows_all_pids() -> bool {
321    std::fs::read_to_string("/proc/self/mountinfo").is_ok_and(|s| mountinfo_proc_unhidden(&s))
322}
323
324#[cfg(any(target_os = "linux", test))]
325fn mountinfo_proc_unhidden(mountinfo: &str) -> bool {
326    mountinfo
327        .lines()
328        .rfind(|l| l.split_whitespace().nth(4) == Some("/proc"))
329        .is_some_and(|l| {
330            // Only an explicit `hidepid=0` / `off` (or no option at all)
331            // shows every pid; `1`, `2`, `4`, their names and any value not
332            // known here all count as restricted.
333            l.split(|c: char| c.is_whitespace() || c == ',')
334                .filter_map(|o| o.strip_prefix("hidepid="))
335                .all(|v| matches!(v, "0" | "off"))
336        })
337}
338
339/// Whether the identity marker format is the current one: a plain integer
340/// (epoch seconds). Runs recorded before this format carried the locale
341/// dependent `ps -o lstart=` text, which can never be compared reliably.
342#[must_use]
343pub fn is_identity_marker(s: &str) -> bool {
344    !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit())
345}
346
347/// The start time as a locale-independent integer string. Absence, a zero
348/// (what `sysinfo` reports when the platform would not say) and an
349/// unsupported platform are all errors, so a comparison can never be built
350/// on a guess.
351fn platform_process_started_at(pid: u32) -> std::io::Result<String> {
352    match query_process(pid)? {
353        Some(0) => Err(std::io::Error::other("process start time is unavailable")),
354        Some(started) => Ok(started.to_string()),
355        None => Err(std::io::Error::other("no such process")),
356    }
357}
358
359fn platform_pid_alive(pid: u32) -> std::io::Result<bool> {
360    query_process(pid).map(|found| found.is_some())
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    /// The flag is the one Windows documents, and not one of the two it is
368    /// easily confused with.
369    ///
370    /// `DETACHED_PROCESS` (0x8) is what leaves a process without a console -
371    /// which is what caused the windows this module exists to stop, because a
372    /// child of such a process gets a fresh console *with* a window.
373    /// `CREATE_NEW_CONSOLE` (0x10) asks for the window outright.
374    #[cfg(windows)]
375    #[test]
376    fn the_flag_hides_a_console_rather_than_removing_or_creating_one() {
377        assert_eq!(CREATE_NO_WINDOW, 0x0800_0000);
378        assert_ne!(CREATE_NO_WINDOW, 0x0000_0008, "DETACHED_PROCESS");
379        assert_ne!(CREATE_NO_WINDOW, 0x0000_0010, "CREATE_NEW_CONSOLE");
380    }
381
382    #[test]
383    fn btime_and_hidepid_parsers_distrust_what_they_cannot_confirm() {
384        assert!(stat_has_btime("cpu 1 2\nbtime 1700000000\n"));
385        assert!(!stat_has_btime("cpu 1 2\n"));
386        assert!(!stat_has_btime("btime 0\n"));
387        let open = "25 1 0:5 / /proc rw,nosuid - proc proc rw";
388        let hidden = "25 1 0:5 / /proc rw,nosuid - proc proc rw,hidepid=2";
389        assert!(mountinfo_proc_unhidden(open));
390        assert!(!mountinfo_proc_unhidden(hidden));
391        for v in ["1", "4", "ptraceable", "noaccess", "future"] {
392            let line = format!("25 1 0:5 / /proc rw - proc proc rw,hidepid={v}");
393            assert!(!mountinfo_proc_unhidden(&line), "hidepid={v}");
394        }
395        assert!(mountinfo_proc_unhidden(
396            "25 1 0:5 / /proc rw - proc proc rw,hidepid=0"
397        ));
398        assert!(!mountinfo_proc_unhidden(""));
399    }
400
401    #[test]
402    fn pid_liveness_policy_is_deterministic_without_an_os_process_query() {
403        assert!(pid_alive_with(42, |_| Ok(true)));
404        assert!(!pid_alive_with(42, |_| Ok(false)));
405    }
406
407    #[test]
408    fn an_unavailable_process_query_is_never_mistaken_for_a_dead_process() {
409        assert!(pid_alive_with(42, |_| Err(std::io::Error::other(
410            "access denied"
411        ))));
412    }
413
414    /// Unlike [`pid_alive_with`]'s Err-means-alive bias, the three-valued read
415    /// leaves an unavailable query as `None` rather than inventing either
416    /// answer — a display that guessed "dead" here would be exactly the wrong
417    /// kind of confidence this exists to avoid.
418    #[test]
419    fn pid_status_reports_alive_dead_and_unknown_as_three_distinct_answers() {
420        assert_eq!(pid_status_with(42, |_| Ok(true)), Some(true));
421        assert_eq!(pid_status_with(42, |_| Ok(false)), Some(false));
422        assert_eq!(
423            pid_status_with(42, |_| Err(std::io::Error::other("access denied"))),
424            None
425        );
426    }
427
428    /// このテスト自身の PID を OS に問い合わせるスモーク診断。
429    ///
430    /// CI では実際のコマンド実行と成功出力の解析を必須にする。制限された
431    /// 対話席で問い合わせ自体が使えない場合は、その事実を出力して成功結果や
432    /// 死んだプロセスと取り違えない。実行中の PID を dead と報告した場合と、
433    /// CI で問い合わせが利用不能な場合は失敗にする。
434    #[test]
435    fn platform_query_reports_this_running_process_as_alive_or_unavailable() {
436        let pid = std::process::id();
437        match platform_pid_alive(pid) {
438            Ok(true) => {}
439            Ok(false) => {
440                panic!("OS の PID 問い合わせが実行中のテストプロセス {pid} を dead と報告した")
441            }
442            Err(error) if std::env::var_os("CI").is_some() => {
443                panic!("CI で OS の PID 問い合わせを実行できない(テストプロセス {pid}): {error}")
444            }
445            Err(error) => {
446                eprintln!("OS の PID 問い合わせは利用できません(テストプロセス {pid}): {error}")
447            }
448        }
449    }
450
451    /// 同じスモーク診断を `process_started_at` にも適用する: 実行中の
452    /// このテストプロセス自身に対して呼ぶと、利用可能な環境では必ず何か
453    /// 返り、そして二回呼んでも同じ値を返す — 同一プロセスの起動時刻が
454    /// 問い合わせのたびにずれては、pid 再利用との判別に使えない。
455    #[test]
456    fn platform_query_reports_this_running_process_start_time_consistently_or_unavailable() {
457        let pid = std::process::id();
458        match (
459            platform_process_started_at(pid),
460            platform_process_started_at(pid),
461        ) {
462            (Ok(first), Ok(second)) => {
463                assert_eq!(
464                    first, second,
465                    "同一の生存プロセスへの二回の問い合わせが食い違った"
466                );
467                assert!(is_identity_marker(&first), "整数文字列でない: {first}");
468            }
469            (Err(error), _) | (_, Err(error)) if std::env::var_os("CI").is_some() => {
470                panic!("CI で起動時刻の問い合わせを実行できない(テストプロセス {pid}): {error}")
471            }
472            _ => eprintln!("起動時刻の問い合わせは利用できません(テストプロセス {pid})"),
473        }
474    }
475}