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