Skip to main content

capsule_runtime/
running.rs

1//! The machine-wide map of capsules that are currently reachable: one record per session that
2//! opened an A2A door, at `~/.murmur/running/<session_id>.json`.
3//!
4//! A record is a **hint, never a truth**. Nothing can guarantee it is removed — a `SIGKILL`ed
5//! capsule writes no farewell — so a reader never trusts one. [`verify`] puts three layers between
6//! a record and the claim that a session is live, and each is insufficient alone:
7//!
8//! 1. **The pid is alive.** Necessary, nowhere near sufficient.
9//! 2. **The process's start time matches the recorded one.** Pids are reused, so without this a
10//!    record can name an unrelated process that happens to hold the number now.
11//! 3. **The door answers and identifies itself.** A live pid proves neither that the process is
12//!    the capsule nor that it can still respond, so the agent card is fetched and its `session_id`
13//!    compared.
14//!
15//! Reading prunes a record only on evidence: layer 1 finding no process, or layer 2 reading a start
16//! time that differs from the recorded one, which means the pid was reused. That is the only reaper
17//! there is, and it is enough because the record is a hint. A reading that could not be taken is
18//! not evidence. A live pid whose start time cannot be read is [`ProcessState::Unverified`]: kept,
19//! reported as unreachable, and never signalled. A record that passes 1 and 2 and fails 3 is kept
20//! too: it names a process that is alive, perhaps suspended, and unlinking it would throw away the
21//! only handle to a running capsule because it was slow to answer.
22//!
23//! The record names the process and stores nothing from the environment. The directory is `0700`
24//! and each record `0600`, because the set of records is a map of reachable capsules to anything
25//! on the machine that can read it.
26
27use std::{
28    path::{Path, PathBuf},
29    time::Duration,
30};
31
32use serde::{Deserialize, Serialize};
33
34/// Directory under the murmur home holding one record per running session.
35const RUNNING_DIR: &str = "running";
36
37/// Mode held on the running directory, and on `~/.murmur` on the way to it.
38const RUNNING_DIR_MODE: u32 = 0o700;
39
40/// Mode held on each record file.
41const RECORD_FILE_MODE: u32 = 0o600;
42
43/// How long the door probe waits for a TCP connection: long enough for a loopback accept, short
44/// enough that an address nothing holds is reported rather than waited on.
45const PROBE_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
46
47/// How long the door probe waits for the agent card once connected. Longer than the connect
48/// deadline: a capsule mid-turn has answered, and is allowed to be slow about the rest.
49const PROBE_READ_TIMEOUT: Duration = Duration::from_secs(5);
50
51/// Where one running session's door is, and which process holds it.
52///
53/// Every field is required. There is no version field: a record that does not deserialize names
54/// no process that could be checked, and is pruned, which is what "the record is a hint" already
55/// means.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
57pub struct RunningRecord {
58    /// The session this door answers for, compared against the agent card by layer 3.
59    pub session_id: String,
60    /// `host:port` the A2A door is bound to.
61    pub url: String,
62    /// The process holding the door.
63    pub pid: u32,
64    /// That process's start time, as an opaque platform-local token — see [`process_start_token`].
65    pub process_start: String,
66    pub capsule_name: String,
67    pub capsule_version: String,
68    /// The session directory, so a reader can find the trace of what it is watching.
69    pub workdir: PathBuf,
70    /// Whether the capsule survives the window that launched it — `true` when the launching
71    /// process had no controlling terminal.
72    pub outlives_launcher: bool,
73    /// RFC 3339, in UTC.
74    pub started_at: String,
75}
76
77/// `~/.murmur/running`, created if missing and held at `0700`, inside a `~/.murmur` held at `0700`.
78///
79/// The directory is global rather than per-workdir because the question a live address answers is
80/// "what is running on this machine", which no single project directory can answer.
81///
82/// The `Err` names the path that failed and why.
83pub fn running_dir() -> Result<PathBuf, String> {
84    let dir = crate::murmur_home::ensure_murmur_home()?.join(RUNNING_DIR);
85    crate::state_store::ensure_private_dir(&dir, RUNNING_DIR_MODE)
86        .map_err(|reason| format!("{}: {reason}", dir.display()))?;
87    Ok(dir)
88}
89
90/// Every record that parses, most recent session first.
91///
92/// Session ids are time-ordered, so a lexical sort descending is chronological. A file that does
93/// not parse is unlinked on the way past: nothing can verify it, and leaving it would mean
94/// carrying a permanent unreadable entry in a directory whose whole purpose is to be read.
95///
96/// The `Err` is the directory being unusable — `~/.murmur/running` cannot be created, held at
97/// `0700`, or listed — and names the path and the OS error. An empty machine is `Ok` with no
98/// records, so a caller can tell the two apart.
99pub fn list() -> Result<Vec<RunningRecord>, String> {
100    let dir = running_dir()?;
101    let entries = std::fs::read_dir(&dir).map_err(|err| format!("{}: {err}", dir.display()))?;
102
103    let mut records = Vec::new();
104    for entry in entries.flatten() {
105        let path = entry.path();
106        if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
107            continue;
108        }
109        match read_record(&path) {
110            Some(record) => records.push(record),
111            None => {
112                let _ = std::fs::remove_file(&path);
113            }
114        }
115    }
116    records.sort_by(|left, right| right.session_id.cmp(&left.session_id));
117    Ok(records)
118}
119
120/// What layers 1 and 2 say about the process a record names.
121///
122/// Only `Gone` licenses unlinking the record. `Unverified` is a reading that failed, which says
123/// nothing about the process either way.
124#[derive(Debug, Clone, PartialEq, Eq)]
125pub enum ProcessState {
126    /// The pid is held by the process that wrote the record.
127    Alive,
128    /// The pid is held and its start time could not be read, so whether the holder wrote the record
129    /// is unknown. The record is kept and the pid is never signalled; the string is the reason.
130    Unverified(String),
131    /// The process is gone — nothing holds the pid, or the process holding it started at another
132    /// time than the recorded one. The string says which.
133    Gone(String),
134}
135
136/// Layers 1 and 2: the pid is alive, and the process holding it is the one that wrote the record.
137///
138/// Local and cheap, which is what makes this the candidacy test — a reader can apply it to every
139/// record without a single network round trip. The start time is read only for a live pid; see
140/// [`classify`] for how the two readings combine.
141pub fn process_state(record: &RunningRecord) -> ProcessState {
142    let alive = pid_is_alive(record.pid);
143    let token = alive.then(|| process_start_token(record.pid)).flatten();
144    classify(record, alive, token.as_deref())
145}
146
147/// Layers 1 and 2 over readings already taken: whether `record.pid` is held, and its start token
148/// read afterwards, `None` when it could not be read.
149///
150/// A recorded `process_start` of `""` is what a writer that could not read its own start time
151/// stores, and it differs from every token the host reports, so that record reads as `Gone`.
152fn classify(record: &RunningRecord, pid_alive: bool, fresh_token: Option<&str>) -> ProcessState {
153    if !pid_alive {
154        return ProcessState::Gone(format!("no process holds pid {}", record.pid));
155    }
156    match fresh_token {
157        Some(token) if token == record.process_start => ProcessState::Alive,
158        Some(_) => ProcessState::Gone(format!(
159            "pid {} is held by a process that started at another time",
160            record.pid
161        )),
162        None => {
163            ProcessState::Unverified(format!("pid {}'s start time could not be read", record.pid))
164        }
165    }
166}
167
168/// What all three layers say about a record.
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub enum Liveness {
171    /// The process is the one that wrote the record and its door answers for that session.
172    Live,
173    /// The process is alive and either the door did not answer for it or its identity could not be
174    /// read. The record is kept.
175    Unreachable(String),
176    /// The process that wrote the record is gone. The record is stale and can be pruned.
177    Gone(String),
178}
179
180/// All three layers, door probe included.
181///
182/// The probe is one blocking `GET /.well-known/agent-card.json`, so this is callable from outside
183/// any async runtime. A record whose process is not [`ProcessState::Alive`] is answered without a
184/// probe.
185pub fn verify(record: &RunningRecord) -> Liveness {
186    if let Some(liveness) = liveness_without_probe(process_state(record)) {
187        return liveness;
188    }
189    match probe_session_id(&record.url) {
190        Ok(session_id) if session_id == record.session_id => Liveness::Live,
191        Ok(other) => Liveness::Unreachable(format!(
192            "the capsule at {} answers for session {other}",
193            record.url
194        )),
195        Err(reason) => Liveness::Unreachable(reason),
196    }
197}
198
199/// The [`Liveness`] layers 1 and 2 decide on their own, or `None` for [`ProcessState::Alive`],
200/// which only the door probe can settle.
201fn liveness_without_probe(state: ProcessState) -> Option<Liveness> {
202    match state {
203        ProcessState::Alive => None,
204        ProcessState::Unverified(reason) => Some(Liveness::Unreachable(reason)),
205        ProcessState::Gone(reason) => Some(Liveness::Gone(reason)),
206    }
207}
208
209/// What sending one signal to a record's process did.
210#[derive(Debug, Clone, PartialEq, Eq)]
211pub enum SignalOutcome {
212    /// The signal was delivered to the process that wrote the record.
213    Sent,
214    /// Nothing was signalled: the process that wrote the record is no longer there.
215    AlreadyGone,
216    /// Something holds the pid and no signal reached it: the kernel refused `kill(2)`, and the
217    /// string is the errno description, `EPERM` included; or the pid is
218    /// [`ProcessState::Unverified`], `kill(2)` was never called, and the string is that reason.
219    Refused(String),
220}
221
222/// `SIGTERM` the process a record names, if it is still that process.
223pub fn signal_term(record: &RunningRecord) -> SignalOutcome {
224    signal(record, libc::SIGTERM)
225}
226
227/// `SIGKILL` the process a record names, if it is still that process.
228pub fn signal_kill(record: &RunningRecord) -> SignalOutcome {
229    signal(record, libc::SIGKILL)
230}
231
232/// Re-verify layers 1 and 2, then signal — in that order, in this one place.
233///
234/// A record is a hint, and a pid whose recorded start time no longer matches names a process that
235/// inherited the number. Signalling it would end something the operator never launched, so the
236/// check is not a caller's to have made earlier: it is run here, immediately before the
237/// `kill(2)`, so the window between the two is two adjacent syscalls. No userspace program can
238/// close that window; narrowing it to this is the whole of what can be done about it.
239#[allow(unsafe_code)]
240fn signal(record: &RunningRecord, signal: libc::c_int) -> SignalOutcome {
241    if let Some(outcome) = refusal_before_signal(&process_state(record)) {
242        return outcome;
243    }
244    // SAFETY: `kill` takes a pid and a signal number by value and dereferences no pointer. The
245    // pid is the one the check above just confirmed is held by the process that wrote the record.
246    if unsafe { libc::kill(record.pid as libc::pid_t, signal) } == 0 {
247        return SignalOutcome::Sent;
248    }
249    let err = std::io::Error::last_os_error();
250    // The process exited between the check and the call — the race this ordering narrows but
251    // cannot close. Nothing was signalled, and nothing needed to be.
252    if err.raw_os_error() == Some(libc::ESRCH) {
253        return SignalOutcome::AlreadyGone;
254    }
255    SignalOutcome::Refused(err.to_string())
256}
257
258/// What a signal to a process in `state` comes to without `kill(2)` being called, or `None` when
259/// the process is confirmed as the one that wrote the record and may be signalled.
260///
261/// A pid whose identity could not be read is refused rather than reported as gone: something holds
262/// it, and it may be the capsule.
263fn refusal_before_signal(state: &ProcessState) -> Option<SignalOutcome> {
264    match state {
265        ProcessState::Alive => None,
266        ProcessState::Unverified(reason) => Some(SignalOutcome::Refused(reason.clone())),
267        ProcessState::Gone(_) => Some(SignalOutcome::AlreadyGone),
268    }
269}
270
271/// Unlink one record. For a [`Liveness::Gone`] reading only.
272pub fn prune(record: &RunningRecord) {
273    if let Ok(dir) = running_dir() {
274        let _ = std::fs::remove_file(record_path(&dir, &record.session_id));
275    }
276}
277
278/// One session's record, written for exactly as long as the session runs.
279///
280/// `launch_session` has one `?` per staging step and two success returns, so removal is a guard
281/// rather than a line at each of them: a session that ended without retiring its record would
282/// leave an address that resolves to a port nothing holds.
283pub struct RunningGuard {
284    path: PathBuf,
285}
286
287impl RunningGuard {
288    /// Writes `record` and holds it until this guard drops.
289    ///
290    /// The `Err` names why the record could not be written. Failing to record never fails a
291    /// launch: the caller warns and runs on, and what it has lost is the session being addressable
292    /// by session address.
293    pub fn write(record: &RunningRecord) -> Result<Self, String> {
294        let dir = running_dir()?;
295        let path = record_path(&dir, &record.session_id);
296        let body = serde_json::to_vec_pretty(record)
297            .map_err(|err| format!("the record could not be serialized: {err}"))?;
298
299        // Staged beside the record and renamed onto it, because a reader prunes what it cannot
300        // parse: a reader landing between the create and the write would otherwise unlink a
301        // record that was about to be complete.
302        crate::retention::StagedRewrite::stage_with_mode(&path, &body, Some(RECORD_FILE_MODE))
303            .and_then(crate::retention::StagedRewrite::commit)
304            .map_err(|reason| format!("failed to write {}: {reason}", path.display()))?;
305        Ok(Self { path })
306    }
307}
308
309impl Drop for RunningGuard {
310    fn drop(&mut self) {
311        let _ = std::fs::remove_file(&self.path);
312    }
313}
314
315/// Whether the calling process has a controlling terminal, which is what decides
316/// `outlives_launcher`: a capsule attached to a terminal dies with that window.
317///
318/// `/dev/tty` rather than `isatty` on stdio, so redirecting a stream does not change the answer.
319#[must_use]
320pub fn has_controlling_terminal() -> bool {
321    std::fs::File::open("/dev/tty").is_ok()
322}
323
324fn record_path(dir: &Path, session_id: &str) -> PathBuf {
325    dir.join(format!("{session_id}.json"))
326}
327
328fn read_record(path: &Path) -> Option<RunningRecord> {
329    let body = std::fs::read_to_string(path).ok()?;
330    serde_json::from_str(&body).ok()
331}
332
333/// Layer 1. `EPERM` counts as alive: the pid is held, by a process this user may not signal.
334///
335/// A zombie does not. It still holds its pid and still answers `kill(pid, 0)`, but it has exited
336/// — its door is closed and its memory reclaimed, and it stays in the table only until whoever
337/// started it waits on it. Counting one as alive would mean `mur ps` listing an exited capsule as
338/// `unreachable` for as long as its launcher neglected to reap it, and `mur stop` never able to
339/// confirm that the process it just signalled had gone.
340///
341/// A pid outside `1..=pid_t::MAX` is held by no process. `kill(2)` reads `0` as the caller's own
342/// process group and a negative `pid_t` as a process group or every process, so either would
343/// otherwise answer for processes the record never named.
344#[allow(unsafe_code)]
345fn pid_is_alive(pid: u32) -> bool {
346    let Ok(target) = libc::pid_t::try_from(pid) else {
347        return false;
348    };
349    if target == 0 {
350        return false;
351    }
352    // SAFETY: `kill` with signal 0 runs the existence and permission checks without delivering
353    // anything, and dereferences no pointer.
354    let held = unsafe { libc::kill(target, 0) } == 0
355        || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM);
356    held && !platform::is_zombie(pid)
357}
358
359/// Layer 2's reading: an opaque, platform-local token for when `pid`'s process started.
360///
361/// Compared only by string equality against a token read freshly on the same machine. Nothing
362/// converts between platform encodings and nothing parses either one, so the two readings below
363/// never have to be reconciled.
364///
365/// | Platform | Token |
366/// |---|---|
367/// | Linux | field 22 of `/proc/<pid>/stat`, clock ticks since boot |
368/// | macOS | `proc_bsdinfo.pbi_start_tvsec` / `pbi_start_tvusec`, as `seconds.microseconds` |
369#[must_use]
370pub fn process_start_token(pid: u32) -> Option<String> {
371    platform::process_start_token(pid)
372}
373
374#[cfg(target_os = "linux")]
375mod platform {
376    /// Field 22 of `/proc/<pid>/stat`.
377    ///
378    /// The second field is the executable name in parentheses and may itself contain spaces and
379    /// parentheses, so the split starts after its last `)`: the remainder begins at field 3.
380    /// Field 22, counted from the field after the executable name, which is field 3.
381    const STARTTIME_OFFSET_AFTER_COMM: usize = 22 - 3;
382
383    pub(super) fn process_start_token(pid: u32) -> Option<String> {
384        let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
385        start_token_from_stat(&stat)
386    }
387
388    /// Field 22 of one `/proc/<pid>/stat` line, or `None` when the line has no `)` or ends before
389    /// field 22.
390    pub(super) fn start_token_from_stat(stat: &str) -> Option<String> {
391        fields_after_comm(stat)?
392            .nth(STARTTIME_OFFSET_AFTER_COMM)
393            .map(str::to_string)
394    }
395
396    /// The fields of a `/proc/<pid>/stat` line from field 3 on, or `None` when it has no `)`.
397    fn fields_after_comm(stat: &str) -> Option<std::str::SplitWhitespace<'_>> {
398        Some(stat[stat.rfind(')')? + 1..].split_whitespace())
399    }
400
401    /// Field 3 of `/proc/<pid>/stat` — the first after the executable name — is the state
402    /// character, and `Z` is a process that has exited and not yet been waited on.
403    ///
404    /// A pid whose `stat` cannot be read is not reported as a zombie: the reading failed, which
405    /// says nothing about the process, and layer 2 refuses it a moment later anyway.
406    pub(super) fn is_zombie(pid: u32) -> bool {
407        let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
408            return false;
409        };
410        fields_after_comm(&stat).and_then(|mut fields| fields.next()) == Some("Z")
411    }
412}
413
414#[cfg(target_os = "macos")]
415mod platform {
416    use std::mem::size_of;
417
418    /// `proc_bsdinfo.pbi_start_tvsec` and `pbi_start_tvusec`, rendered as `seconds.microseconds`.
419    pub(super) fn process_start_token(pid: u32) -> Option<String> {
420        let info = bsd_info(pid).ok()?;
421        Some(format!(
422            "{}.{:06}",
423            info.pbi_start_tvsec, info.pbi_start_tvusec
424        ))
425    }
426
427    /// A process that has exited and not yet been waited on. `kill(pid, 0)` still reports its pid
428    /// held, but `PROC_PIDTBSDINFO` refuses to describe it with `ESRCH`; one described mid-exit
429    /// carries `pbi_status` `SZOMB`. `ESRCH` read straight after layer 1 found the pid held means
430    /// either that or a process that exited in between, and both are gone.
431    ///
432    /// Any other failed reading, `EPERM` or a short write, is not reported as a zombie, on the
433    /// same terms as the Linux reading: layer 2 reads the pid as `Unverified`.
434    pub(super) fn is_zombie(pid: u32) -> bool {
435        match bsd_info(pid) {
436            Ok(info) => info.pbi_status == libc::SZOMB,
437            Err(err) => err.raw_os_error() == Some(libc::ESRCH),
438        }
439    }
440
441    /// The kernel's `PROC_PIDTBSDINFO` description of `pid`. `Err` carries the `errno` when the
442    /// call failed, and no OS error when it wrote anything other than exactly one `proc_bsdinfo`.
443    #[allow(unsafe_code)]
444    fn bsd_info(pid: u32) -> std::io::Result<libc::proc_bsdinfo> {
445        // SAFETY: `proc_bsdinfo` is a plain C struct of integers and fixed-size integer arrays,
446        // with no pointer, niche or validity invariant, so an all-zero value is a valid one.
447        let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
448        let size = size_of::<libc::proc_bsdinfo>();
449
450        // SAFETY: the buffer is one `proc_bsdinfo` and the size passed is that struct's own size,
451        // which bounds what `proc_pidinfo` writes. The kernel copies into it and returns; nothing
452        // retains the pointer past the call.
453        let written = unsafe {
454            libc::proc_pidinfo(
455                pid as libc::c_int,
456                libc::PROC_PIDTBSDINFO,
457                0,
458                std::ptr::addr_of_mut!(info).cast(),
459                size as libc::c_int,
460            )
461        };
462        if written <= 0 {
463            return Err(std::io::Error::last_os_error());
464        }
465        if usize::try_from(written).ok() != Some(size) {
466            return Err(std::io::Error::other(format!(
467                "proc_pidinfo wrote {written} bytes, not one proc_bsdinfo of {size}"
468            )));
469        }
470        Ok(info)
471    }
472}
473
474/// Layer 3: the session id the door at `url` claims on its agent card.
475fn probe_session_id(url: &str) -> Result<String, String> {
476    let addr = url
477        .trim_start_matches("http://")
478        .trim_start_matches("https://");
479    crate::http_client::http_json_with_timeouts(
480        "GET",
481        &format!("http://{addr}/.well-known/agent-card.json"),
482        None,
483        &[("Accept", "application/json")],
484        PROBE_CONNECT_TIMEOUT,
485        PROBE_READ_TIMEOUT,
486    )?
487    .get("session_id")
488    .and_then(serde_json::Value::as_str)
489    .map(str::to_string)
490    .ok_or_else(|| format!("the agent card from {addr} names no session"))
491}
492
493#[cfg(test)]
494mod tests {
495    use super::*;
496
497    fn record(pid: u32, process_start: &str) -> RunningRecord {
498        RunningRecord {
499            session_id: "ses_0199c4e2f1b7712a9d3e4f5061728394".to_string(),
500            url: "127.0.0.1:1".to_string(),
501            pid,
502            process_start: process_start.to_string(),
503            capsule_name: "demo".to_string(),
504            capsule_version: "0.1.0".to_string(),
505            workdir: PathBuf::from("/tmp/demo"),
506            outlives_launcher: true,
507            started_at: "2026-01-01T00:00:00Z".to_string(),
508        }
509    }
510
511    /// The serialized shape is the whole contract with the CLI and with an operator reading the
512    /// file, so the key set is asserted rather than assumed.
513    #[test]
514    fn a_record_serializes_exactly_the_nine_fields() {
515        let value = serde_json::to_value(record(1, "42")).unwrap();
516        let mut keys: Vec<&str> = value
517            .as_object()
518            .unwrap()
519            .keys()
520            .map(String::as_str)
521            .collect();
522        keys.sort_unstable();
523        assert_eq!(
524            keys,
525            [
526                "capsule_name",
527                "capsule_version",
528                "outlives_launcher",
529                "pid",
530                "process_start",
531                "session_id",
532                "started_at",
533                "url",
534                "workdir",
535            ]
536        );
537    }
538
539    /// Every field is required, so a record missing one is unverifiable rather than partly
540    /// usable — which is what makes pruning it on read the whole of the compatibility story.
541    #[test]
542    fn a_record_missing_a_field_does_not_parse() {
543        let mut value = serde_json::to_value(record(1, "42")).unwrap();
544        value.as_object_mut().unwrap().remove("process_start");
545        assert!(serde_json::from_value::<RunningRecord>(value).is_err());
546    }
547
548    /// This process is alive and its own start time is what the host reports for it.
549    #[test]
550    fn this_process_reads_as_alive() {
551        let pid = std::process::id();
552        let token = process_start_token(pid).expect("this process has a start time");
553        assert_eq!(process_state(&record(pid, &token)), ProcessState::Alive);
554    }
555
556    /// Layer 2 alone: the pid is this live process, and the recorded start time is not its.
557    #[test]
558    fn a_start_time_that_disagrees_reads_as_gone() {
559        let pid = std::process::id();
560        let state = process_state(&record(pid, "not-this-process"));
561        match state {
562            ProcessState::Gone(reason) => assert!(
563                reason.contains("another time"),
564                "the reason must say the pid belongs to another process now: {reason}"
565            ),
566            other => panic!("a mismatched start time must read as gone, not {other:?}"),
567        }
568    }
569
570    /// A pid no process holds fails layer 1, whatever start time the record carries.
571    #[test]
572    fn a_pid_nothing_holds_reads_as_gone() {
573        // The kernel hands out pids below this only after wrapping, and pid 0 is never a process.
574        let state = process_state(&record(0, "42"));
575        assert!(matches!(state, ProcessState::Gone(_)));
576    }
577
578    /// The token is read the same way twice, which is the only property string comparison needs.
579    #[test]
580    fn the_start_token_is_stable_for_one_process() {
581        let pid = std::process::id();
582        assert_eq!(process_start_token(pid), process_start_token(pid));
583        assert!(process_start_token(pid).is_some_and(|token| !token.is_empty()));
584    }
585
586    /// A process that has exited and not been waited on still holds its pid and still answers
587    /// `kill(pid, 0)`, and is not running.
588    #[test]
589    fn a_zombie_does_not_read_as_alive() {
590        let mut child = std::process::Command::new("sh")
591            .args(["-c", "exit 0"])
592            .spawn()
593            .expect("sh should start");
594        let pid = child.id();
595        let token = process_start_token(pid).expect("a live child has a start time");
596
597        // Deliberately not waited on: the pid stays in the table as a zombie until it is.
598        let deadline = std::time::Instant::now() + Duration::from_secs(10);
599        loop {
600            if matches!(process_state(&record(pid, &token)), ProcessState::Gone(_)) {
601                break;
602            }
603            assert!(
604                std::time::Instant::now() < deadline,
605                "a zombie kept reading as alive"
606            );
607            std::thread::sleep(Duration::from_millis(50));
608        }
609        let _ = child.wait();
610    }
611
612    /// The load-bearing invariant: a record whose recorded start time no longer matches names a
613    /// process that inherited the pid, and nothing is signalled at it.
614    #[test]
615    fn a_signal_at_a_mismatched_start_time_is_not_sent() {
616        // This process is alive and would die of a `SIGKILL` it were actually sent one.
617        let record = record(std::process::id(), "not-this-process");
618        assert_eq!(signal_kill(&record), SignalOutcome::AlreadyGone);
619        assert_eq!(signal_term(&record), SignalOutcome::AlreadyGone);
620    }
621
622    /// A pid nothing holds is reported as already gone rather than as a refusal — there is
623    /// nothing there to refuse.
624    #[test]
625    fn a_signal_at_a_pid_nothing_holds_is_already_gone() {
626        assert_eq!(signal_term(&record(0, "42")), SignalOutcome::AlreadyGone);
627    }
628
629    /// A live process whose door answers nothing is unreachable, not gone — the distinction the
630    /// whole three-layer split exists for.
631    #[test]
632    fn a_live_process_behind_a_silent_port_is_unreachable() {
633        let pid = std::process::id();
634        let token = process_start_token(pid).unwrap();
635        let mut record = record(pid, &token);
636        // Bound and immediately dropped: the port is one nothing listens on.
637        let port = {
638            let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
639            listener.local_addr().unwrap().port()
640        };
641        record.url = format!("127.0.0.1:{port}");
642        assert!(matches!(verify(&record), Liveness::Unreachable(_)));
643    }
644
645    const PID: u32 = 4242;
646
647    #[test]
648    fn classify_a_dead_pid_is_gone_whatever_the_token() {
649        for token in [None, Some("123"), Some("")] {
650            assert_eq!(
651                classify(&record(PID, "123"), false, token),
652                ProcessState::Gone("no process holds pid 4242".to_string()),
653                "token {token:?}"
654            );
655        }
656    }
657
658    #[test]
659    fn classify_a_live_pid_with_the_recorded_token_is_alive() {
660        assert_eq!(
661            classify(&record(PID, "123"), true, Some("123")),
662            ProcessState::Alive
663        );
664    }
665
666    #[test]
667    fn classify_a_live_pid_with_another_token_is_gone() {
668        assert_eq!(
669            classify(&record(PID, "123"), true, Some("456")),
670            ProcessState::Gone("pid 4242 is held by a process that started at another time".into())
671        );
672    }
673
674    /// The writer stores `""` when it could not read its own start time, and that must never match.
675    #[test]
676    fn classify_a_recorded_empty_token_against_a_fresh_one_is_gone() {
677        assert!(matches!(
678            classify(&record(PID, ""), true, Some("123")),
679            ProcessState::Gone(reason) if reason.contains("another time")
680        ));
681    }
682
683    #[test]
684    fn classify_a_live_pid_whose_token_cannot_be_read_is_unverified() {
685        assert_eq!(
686            classify(&record(PID, "123"), true, None),
687            ProcessState::Unverified("pid 4242's start time could not be read".into())
688        );
689    }
690
691    #[test]
692    fn nothing_is_refused_before_signalling_a_confirmed_process() {
693        assert_eq!(refusal_before_signal(&ProcessState::Alive), None);
694    }
695
696    #[test]
697    fn a_gone_process_is_already_gone_before_any_signal() {
698        assert_eq!(
699            refusal_before_signal(&ProcessState::Gone("no process holds pid 1".into())),
700            Some(SignalOutcome::AlreadyGone)
701        );
702    }
703
704    #[test]
705    fn an_unverified_process_is_refused_before_any_signal() {
706        let reason = "pid 1's start time could not be read".to_string();
707        assert_eq!(
708            refusal_before_signal(&ProcessState::Unverified(reason.clone())),
709            Some(SignalOutcome::Refused(reason))
710        );
711    }
712
713    /// `liveness_without_probe` has no URL to probe, so an answer for `Unverified` is one reached
714    /// without the network.
715    #[test]
716    fn an_unverified_process_is_unreachable_without_a_probe() {
717        let reason = "pid 1's start time could not be read".to_string();
718        assert_eq!(
719            liveness_without_probe(ProcessState::Unverified(reason.clone())),
720            Some(Liveness::Unreachable(reason))
721        );
722        assert_eq!(
723            liveness_without_probe(ProcessState::Gone("gone".into())),
724            Some(Liveness::Gone("gone".into()))
725        );
726        assert_eq!(liveness_without_probe(ProcessState::Alive), None);
727    }
728
729    /// A stat line with field 22 set to `987654`: pid, comm, then fields 3 through 22.
730    #[cfg(target_os = "linux")]
731    fn stat_line(comm: &str) -> String {
732        let fields: Vec<String> = (3..=21).map(|field| field.to_string()).collect();
733        format!("1234 ({comm}) {} 987654 23 24\n", fields.join(" "))
734    }
735
736    #[cfg(target_os = "linux")]
737    #[test]
738    fn the_start_token_is_field_22_of_a_plain_stat_line() {
739        assert_eq!(
740            platform::start_token_from_stat(&stat_line("sleep")),
741            Some("987654".to_string())
742        );
743    }
744
745    #[cfg(target_os = "linux")]
746    #[test]
747    fn the_start_token_is_counted_from_the_last_close_paren() {
748        assert_eq!(
749            platform::start_token_from_stat(&stat_line("a) (b c")),
750            Some("987654".to_string())
751        );
752    }
753
754    #[cfg(target_os = "linux")]
755    #[test]
756    fn a_stat_line_with_no_close_paren_has_no_start_token() {
757        assert_eq!(platform::start_token_from_stat("1234 sleep S 1 2 3"), None);
758    }
759
760    #[cfg(target_os = "linux")]
761    #[test]
762    fn a_stat_line_truncated_before_field_22_has_no_start_token() {
763        let line = stat_line("sleep");
764        let truncated = &line[..line.find(" 987654").unwrap()];
765        assert_eq!(platform::start_token_from_stat(truncated), None);
766    }
767}
768
769#[cfg(test)]
770mod home_tests {
771    use super::*;
772
773    #[test]
774    fn running_dir_holds_a_wide_murmur_home_owner_only() {
775        let home = tempfile::tempdir().unwrap();
776        crate::murmur_home::wide_dir(&home.path().join(".murmur"), 0o755);
777        crate::murmur_home::run_with_home(
778            "running::home_tests::inner_running_dir_under_scratch_home",
779            home.path(),
780        );
781        assert_eq!(
782            crate::murmur_home::mode_of(&home.path().join(".murmur")),
783            0o700
784        );
785        assert_eq!(
786            crate::murmur_home::mode_of(&home.path().join(".murmur").join(RUNNING_DIR)),
787            RUNNING_DIR_MODE
788        );
789    }
790
791    #[test]
792    #[ignore = "run by running_dir_holds_a_wide_murmur_home_owner_only"]
793    fn inner_running_dir_under_scratch_home() {
794        if !crate::murmur_home::in_scratch_home() {
795            return;
796        }
797        running_dir().unwrap();
798    }
799}