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 every door token it minted: the operator token, which is
25//! how the host tools call its door, and one per declared credential, which `mur token` hands out.
26//! Layer 3 reads the session id off the extended card with the operator token. The record is the
27//! only place a token is written: `mur run` prints none. The directory is `0700` and each record
28//! `0600`, because the set of records is a map of reachable capsules, and of the tokens that drive
29//! them, to anything on the machine that can read it.
30//!
31//! A session whose manifest declares `control:` also holds `<session_id>.control` beside its
32//! record: the control token, at the same mode, written before the record and removed with it.
33//! It is the only place the token exists outside the process, and it is useless once the process
34//! is gone, because the key that verifies it was never written anywhere.
35
36use std::{
37    collections::BTreeMap,
38    path::{Path, PathBuf},
39    time::Duration,
40};
41
42use serde::{Deserialize, Serialize};
43
44/// Directory under the murmur home holding one record per running session.
45const RUNNING_DIR: &str = "running";
46
47/// Mode held on the running directory, and on `~/.murmur` on the way to it.
48const RUNNING_DIR_MODE: u32 = 0o700;
49
50/// Mode held on each record file, and on each control token file.
51const RECORD_FILE_MODE: u32 = 0o600;
52
53/// Extension of a session's control token file, beside its `.json` record.
54const CONTROL_TOKEN_EXTENSION: &str = "control";
55
56/// How long the door probe waits for a TCP connection: long enough for a loopback accept, short
57/// enough that an address nothing holds is reported rather than waited on.
58const PROBE_CONNECT_TIMEOUT: Duration = Duration::from_secs(2);
59
60/// How long the door probe waits for the agent card once connected. Longer than the connect
61/// deadline: a capsule mid-turn has answered, and is allowed to be slow about the rest.
62const PROBE_READ_TIMEOUT: Duration = Duration::from_secs(5);
63
64/// Where one running session's door is, and which process holds it.
65///
66/// Every field but `door_token`, `credentials`, `formation_id`, `formation_lifeline`,
67/// `formation_launcher` and `spawned_by` is required, and each of those six is omitted when it does
68/// not apply, so a standalone session's record carries the nine required keys alone. There is no version field: a
69/// record that does not deserialize names no process that could be checked, and is pruned, which
70/// is what "the record is a hint" already means.
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct RunningRecord {
73    /// The session this door answers for, compared against the agent card by layer 3.
74    pub session_id: String,
75    /// `host:port` the A2A door is bound to.
76    pub url: String,
77    /// The process holding the door.
78    pub pid: u32,
79    /// That process's start time, as an opaque platform-local token — see [`process_start_token`].
80    pub process_start: String,
81    pub capsule_name: String,
82    pub capsule_version: String,
83    /// The session directory, so a reader can find the trace of what it is watching.
84    pub workdir: PathBuf,
85    /// Whether the capsule survives the window that launched it — `true` when the launching
86    /// process had no controlling terminal.
87    pub outlives_launcher: bool,
88    /// RFC 3339, in UTC.
89    pub started_at: String,
90    /// The operator token of a door declaring `network.authentication`, which the host tools
91    /// present to it. Absent for a public door, and in a record an older runtime wrote. Its
92    /// `Debug` is redacted, and it is serialized only here, into a file held at `0600`.
93    #[serde(
94        default,
95        skip_serializing_if = "Option::is_none",
96        with = "door_token_field"
97    )]
98    pub door_token: Option<crate::door_auth::DoorToken>,
99    /// The tokens of the credentials `network.authentication.credentials` declares, by name. The
100    /// operator token is `door_token`, never an entry here. Empty, and omitted, for a public door,
101    /// for one declaring no credentials, and in a record an older runtime wrote. Every scope of
102    /// every entry is also a scope of `door_token`, so holding them beside it exposes nothing the
103    /// file did not already.
104    #[serde(
105        default,
106        skip_serializing_if = "BTreeMap::is_empty",
107        with = "door_credentials_field"
108    )]
109    pub credentials: BTreeMap<String, crate::door_auth::DoorToken>,
110    /// The formation this session is a member of, so a reader can tell a formation's records from
111    /// unrelated sessions on the machine. Absent for a session in no formation, and in a record an
112    /// older runtime wrote. It groups records and grants nothing. A value that is not a formation
113    /// id makes the whole record unreadable, which [`list`] prunes like any other.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub formation_id: Option<crate::formation::FormationId>,
116    /// The session holds a formation lifeline: its launcher ends it by closing that lifeline, and
117    /// nothing else needs to. Absent when `false`.
118    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
119    pub formation_lifeline: bool,
120    /// The formation launcher that started this session, as the launcher named itself on the
121    /// member's formation channel. Absent for a session no launcher started, and for a member
122    /// whose launcher could not read its own start time. A value that is not an object with a
123    /// `pid` and a `process_start` makes the whole record unreadable.
124    #[serde(default, skip_serializing_if = "Option::is_none")]
125    pub formation_launcher: Option<ProcessIdentity>,
126    /// The session id of the session that delegated to this one. A delegated child holds its
127    /// spawner's lifeline and ends with it. Absent for a session nothing delegated to.
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub spawned_by: Option<String>,
130}
131
132/// A process named so that a recycled pid is never mistaken for it: its pid, and its start time as
133/// [`process_start_token`] reads it.
134#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
135pub struct ProcessIdentity {
136    pub pid: u32,
137    pub process_start: String,
138}
139
140impl ProcessIdentity {
141    /// The calling process. `None` when its own start time cannot be read, because an identity
142    /// with no start time names no process anyone could verify.
143    pub fn of_this_process() -> Option<Self> {
144        let pid = std::process::id();
145        Some(Self {
146            pid,
147            process_start: process_start_token(pid)?,
148        })
149    }
150}
151
152/// How the running record serializes its operator token. With [`door_credentials_field`], the one
153/// place a [`crate::door_auth::DoorToken`] is serialized.
154mod door_token_field {
155    use crate::door_auth::DoorToken;
156    use serde::{Deserialize, Deserializer, Serializer};
157
158    pub(super) fn serialize<S: Serializer>(
159        token: &Option<DoorToken>,
160        serializer: S,
161    ) -> Result<S::Ok, S::Error> {
162        match token {
163            Some(token) => serializer.serialize_some(token.expose()),
164            None => serializer.serialize_none(),
165        }
166    }
167
168    pub(super) fn deserialize<'de, D: Deserializer<'de>>(
169        deserializer: D,
170    ) -> Result<Option<DoorToken>, D::Error> {
171        Ok(Option::<String>::deserialize(deserializer)?.map(DoorToken::new))
172    }
173}
174
175/// How the running record serializes its declared credentials' tokens: a JSON object of credential
176/// name to token. With [`door_token_field`], the one place a [`crate::door_auth::DoorToken`] is
177/// serialized.
178mod door_credentials_field {
179    use std::collections::BTreeMap;
180
181    use crate::door_auth::DoorToken;
182    use serde::{ser::SerializeMap, Deserialize, Deserializer, Serializer};
183
184    pub(super) fn serialize<S: Serializer>(
185        credentials: &BTreeMap<String, DoorToken>,
186        serializer: S,
187    ) -> Result<S::Ok, S::Error> {
188        let mut map = serializer.serialize_map(Some(credentials.len()))?;
189        for (name, token) in credentials {
190            map.serialize_entry(name, token.expose())?;
191        }
192        map.end()
193    }
194
195    pub(super) fn deserialize<'de, D: Deserializer<'de>>(
196        deserializer: D,
197    ) -> Result<BTreeMap<String, DoorToken>, D::Error> {
198        Ok(BTreeMap::<String, String>::deserialize(deserializer)?
199            .into_iter()
200            .map(|(name, token)| (name, DoorToken::new(token)))
201            .collect())
202    }
203}
204
205/// `~/.murmur/running`, created if missing and held at `0700`, inside a `~/.murmur` held at `0700`.
206///
207/// The directory is global rather than per-workdir because the question a live address answers is
208/// "what is running on this machine", which no single project directory can answer.
209///
210/// The `Err` names the path that failed and why.
211pub fn running_dir() -> Result<PathBuf, String> {
212    let dir = crate::murmur_home::ensure_murmur_home()?.join(RUNNING_DIR);
213    crate::state_store::ensure_private_dir(&dir, RUNNING_DIR_MODE)
214        .map_err(|reason| format!("{}: {reason}", dir.display()))?;
215    Ok(dir)
216}
217
218/// `~/.murmur/running`, resolved from `HOME` without creating or re-moding anything.
219///
220/// For a reader that must leave the directory exactly as it found it — a missing directory stays
221/// missing. [`running_dir`] is the resolver for anything that writes.
222///
223/// The `Err` is `HOME` being unset or not absolute.
224pub fn running_dir_location() -> Result<PathBuf, String> {
225    Ok(crate::state_store::murmur_home_dir()?.join(RUNNING_DIR))
226}
227
228/// What one pass over a running directory found.
229#[derive(Debug, Default)]
230pub struct RecordScan {
231    /// Every record that parses, most recent session first.
232    pub records: Vec<RunningRecord>,
233    /// Every `.json` file that could not be read or did not parse, left where it is.
234    pub unreadable: Vec<PathBuf>,
235}
236
237/// Every `.json` entry of `dir`, read and parsed, with nothing created, re-moded or removed.
238///
239/// Session ids are time-ordered, so a lexical sort descending is chronological. Only the `json`
240/// extension is read: the staging file [`RunningGuard::write`] renames into place ends in `.tmp`,
241/// so a record mid-write is neither a record nor unreadable.
242///
243/// A `dir` that does not exist is an empty machine. The `Err` is any other failure to list it —
244/// a path that is a regular file, or one this user may not read — and names the path and the OS
245/// error.
246pub fn scan(dir: &Path) -> Result<RecordScan, String> {
247    let entries = match std::fs::read_dir(dir) {
248        Ok(entries) => entries,
249        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(RecordScan::default()),
250        Err(err) => return Err(format!("{}: {err}", dir.display())),
251    };
252
253    let mut scan = RecordScan::default();
254    for entry in entries.flatten() {
255        let path = entry.path();
256        if path.extension().and_then(|extension| extension.to_str()) != Some("json") {
257            continue;
258        }
259        match read_record(&path) {
260            Some(record) => scan.records.push(record),
261            None => scan.unreadable.push(path),
262        }
263    }
264    scan.records
265        .sort_by(|left, right| right.session_id.cmp(&left.session_id));
266    Ok(scan)
267}
268
269/// Every record that parses, most recent session first.
270///
271/// A file that does not parse is unlinked on the way past: nothing can verify it, and leaving it
272/// would mean carrying a permanent unreadable entry in a directory whose whole purpose is to be
273/// read.
274///
275/// The `Err` is the directory being unusable — `~/.murmur/running` cannot be created, held at
276/// `0700`, or listed — and names the path and the OS error. An empty machine is `Ok` with no
277/// records, so a caller can tell the two apart.
278pub fn list() -> Result<Vec<RunningRecord>, String> {
279    list_reporting().map(|listing| listing.records)
280}
281
282/// What [`list_reporting`] read, and what it removed on the way past.
283#[derive(Debug, Default)]
284pub struct RecordListing {
285    /// Every record that parses, most recent session first.
286    pub records: Vec<RunningRecord>,
287    /// Every `.json` file that could not be read or did not parse and that this read unlinked. A
288    /// file the unlink failed on is not here.
289    pub removed_unreadable: Vec<PathBuf>,
290}
291
292/// [`list`], also naming the unreadable files it removed, for a reader that reports its pruning.
293///
294/// The `Err` is [`list`]'s.
295pub fn list_reporting() -> Result<RecordListing, String> {
296    let dir = running_dir()?;
297    let scan = scan(&dir)?;
298    let removed_unreadable = scan
299        .unreadable
300        .into_iter()
301        .filter(|path| std::fs::remove_file(path).is_ok())
302        .collect();
303    Ok(RecordListing {
304        records: scan.records,
305        removed_unreadable,
306    })
307}
308
309/// What layers 1 and 2 say about the process a record names.
310///
311/// Only `Gone` licenses unlinking the record. `Unverified` is a reading that failed, which says
312/// nothing about the process either way.
313#[derive(Debug, Clone, PartialEq, Eq)]
314pub enum ProcessState {
315    /// The pid is held by the process that wrote the record.
316    Alive,
317    /// The pid is held and its start time could not be read, so whether the holder wrote the record
318    /// is unknown. The record is kept and the pid is never signalled; the string is the reason.
319    Unverified(String),
320    /// The process is gone — nothing holds the pid, or the process holding it started at another
321    /// time than the recorded one. The string says which.
322    Gone(String),
323}
324
325/// Layers 1 and 2: the pid is alive, and the process holding it is the one that wrote the record.
326///
327/// Local and cheap, which is what makes this the candidacy test — a reader can apply it to every
328/// record without a single network round trip. The start time is read only for a live pid; see
329/// [`classify`] for how the two readings combine.
330pub fn process_state(record: &RunningRecord) -> ProcessState {
331    process_state_of(record.pid, &record.process_start)
332}
333
334/// Layers 1 and 2 for any process named by pid and start time, record or not.
335pub fn identity_state(identity: &ProcessIdentity) -> ProcessState {
336    process_state_of(identity.pid, &identity.process_start)
337}
338
339impl RunningRecord {
340    /// The process this record names.
341    pub fn identity(&self) -> ProcessIdentity {
342        ProcessIdentity {
343            pid: self.pid,
344            process_start: self.process_start.clone(),
345        }
346    }
347}
348
349/// Layers 1 and 2 for any process recorded as `pid` with the start token `recorded_start` — a
350/// running record's process, or a formation's launcher as its ownership marker names it.
351pub(crate) fn process_state_of(pid: u32, recorded_start: &str) -> ProcessState {
352    let alive = pid_is_alive(pid);
353    let token = alive.then(|| process_start_token(pid)).flatten();
354    classify(pid, recorded_start, alive, token.as_deref())
355}
356
357/// Layers 1 and 2 over readings already taken: whether `pid` is held, and its start token read
358/// afterwards, `None` when it could not be read.
359///
360/// A recorded start of `""` is what a writer that could not read its own start time stores, and
361/// it differs from every token the host reports, so that process reads as `Gone`.
362fn classify(
363    pid: u32,
364    recorded_start: &str,
365    pid_alive: bool,
366    fresh_token: Option<&str>,
367) -> ProcessState {
368    if !pid_alive {
369        return ProcessState::Gone(format!("no process holds pid {pid}"));
370    }
371    match fresh_token {
372        Some(token) if token == recorded_start => ProcessState::Alive,
373        Some(_) => ProcessState::Gone(format!(
374            "pid {pid} is held by a process that started at another time"
375        )),
376        None => ProcessState::Unverified(format!("pid {pid}'s start time could not be read")),
377    }
378}
379
380/// What all three layers say about a record.
381#[derive(Debug, Clone, PartialEq, Eq)]
382pub enum Liveness {
383    /// The process is the one that wrote the record and its door answers for that session.
384    Live,
385    /// The process is alive and either the door did not answer for it or its identity could not be
386    /// read. The record is kept.
387    Unreachable(String),
388    /// The process that wrote the record is gone. The record is stale and can be pruned.
389    Gone(String),
390}
391
392/// All three layers, door probe included.
393///
394/// The probe is one blocking `GET /.well-known/agent-card.json`, so this is callable from outside
395/// any async runtime. A record whose process is not [`ProcessState::Alive`] is answered without a
396/// probe.
397pub fn verify(record: &RunningRecord) -> Liveness {
398    if let Some(liveness) = liveness_without_probe(process_state(record)) {
399        return liveness;
400    }
401    match probe_session_id(record) {
402        Ok(session_id) if session_id == record.session_id => Liveness::Live,
403        Ok(other) => Liveness::Unreachable(format!(
404            "the capsule at {} answers for session {other}",
405            record.url
406        )),
407        Err(reason) => Liveness::Unreachable(reason),
408    }
409}
410
411/// The [`Liveness`] layers 1 and 2 decide on their own, or `None` for [`ProcessState::Alive`],
412/// which only the door probe can settle.
413fn liveness_without_probe(state: ProcessState) -> Option<Liveness> {
414    match state {
415        ProcessState::Alive => None,
416        ProcessState::Unverified(reason) => Some(Liveness::Unreachable(reason)),
417        ProcessState::Gone(reason) => Some(Liveness::Gone(reason)),
418    }
419}
420
421/// What sending one signal to a record's process did.
422#[derive(Debug, Clone, PartialEq, Eq)]
423pub enum SignalOutcome {
424    /// The signal was delivered to the process that wrote the record.
425    Sent,
426    /// Nothing was signalled: the process that wrote the record is no longer there.
427    AlreadyGone,
428    /// Something holds the pid and no signal reached it: the kernel refused `kill(2)`, and the
429    /// string is the errno description, `EPERM` included; or the pid is
430    /// [`ProcessState::Unverified`], `kill(2)` was never called, and the string is that reason.
431    Refused(String),
432}
433
434/// `SIGTERM` the process a record names, if it is still that process.
435pub fn signal_term(record: &RunningRecord) -> SignalOutcome {
436    signal(&record.identity(), libc::SIGTERM)
437}
438
439/// `SIGKILL` the process a record names, if it is still that process.
440pub fn signal_kill(record: &RunningRecord) -> SignalOutcome {
441    signal(&record.identity(), libc::SIGKILL)
442}
443
444/// `SIGTERM` the process `identity` names, if it is still that process.
445pub fn signal_term_process(identity: &ProcessIdentity) -> SignalOutcome {
446    signal(identity, libc::SIGTERM)
447}
448
449/// `SIGKILL` the process `identity` names, and that process alone, never its group, if it is
450/// still that process.
451pub fn signal_kill_process(identity: &ProcessIdentity) -> SignalOutcome {
452    signal(identity, libc::SIGKILL)
453}
454
455/// Re-verify layers 1 and 2, then signal — in that order, in this one place.
456///
457/// A record is a hint, and a pid whose recorded start time no longer matches names a process that
458/// inherited the number. Signalling it would end something the operator never launched, so the
459/// check is not a caller's to have made earlier: it is run here, immediately before the
460/// `kill(2)`, so the window between the two is two adjacent syscalls. No userspace program can
461/// close that window; narrowing it to this is the whole of what can be done about it.
462#[allow(unsafe_code)]
463fn signal(identity: &ProcessIdentity, signal: libc::c_int) -> SignalOutcome {
464    if let Some(outcome) = refusal_before_signal(&identity_state(identity)) {
465        return outcome;
466    }
467    // SAFETY: `kill` takes a pid and a signal number by value and dereferences no pointer. The
468    // pid is the one the check above just confirmed is held by the process `identity` names, and
469    // `pid_is_alive` reads only a pid in `1..=pid_t::MAX` as held, so the cast names that one
470    // process and never a group.
471    if unsafe { libc::kill(identity.pid as libc::pid_t, signal) } == 0 {
472        return SignalOutcome::Sent;
473    }
474    let err = std::io::Error::last_os_error();
475    // The process exited between the check and the call — the race this ordering narrows but
476    // cannot close. Nothing was signalled, and nothing needed to be.
477    if err.raw_os_error() == Some(libc::ESRCH) {
478        return SignalOutcome::AlreadyGone;
479    }
480    SignalOutcome::Refused(err.to_string())
481}
482
483/// What a signal to a process in `state` comes to without `kill(2)` being called, or `None` when
484/// the process is confirmed as the one that wrote the record and may be signalled.
485///
486/// A pid whose identity could not be read is refused rather than reported as gone: something holds
487/// it, and it may be the capsule.
488fn refusal_before_signal(state: &ProcessState) -> Option<SignalOutcome> {
489    match state {
490        ProcessState::Alive => None,
491        ProcessState::Unverified(reason) => Some(SignalOutcome::Refused(reason.clone())),
492        ProcessState::Gone(_) => Some(SignalOutcome::AlreadyGone),
493    }
494}
495
496/// Unlink one record, and its control token file if it has one. For a [`Liveness::Gone`] reading
497/// only.
498pub fn prune(record: &RunningRecord) {
499    if let Ok(dir) = running_dir() {
500        let _ = std::fs::remove_file(record_path(&dir, &record.session_id));
501        let _ = std::fs::remove_file(token_path(&dir, &record.session_id));
502    }
503}
504
505/// `~/.murmur/running/<session_id>.control`: where a session declaring `control:` keeps its
506/// control token for as long as it runs.
507///
508/// The `Err` is the running directory being unusable, as [`running_dir`] reports it.
509pub fn control_token_path(session_id: &str) -> Result<PathBuf, String> {
510    Ok(token_path(&running_dir()?, session_id))
511}
512
513/// One session's control token file, written for exactly as long as the session runs.
514///
515/// A guard of its own rather than part of [`RunningGuard`]: the token is written before the
516/// record, and a session whose record could not be written still runs and still holds a token.
517pub struct ControlTokenGuard {
518    path: PathBuf,
519}
520
521impl ControlTokenGuard {
522    /// Writes `token` to [`control_token_path`] at owner-only mode and holds it until this guard
523    /// drops.
524    ///
525    /// The `Err` names the path and why; it never carries the token.
526    pub fn write(session_id: &str, token: &str) -> Result<Self, String> {
527        let path = control_token_path(session_id)?;
528        crate::retention::StagedRewrite::stage_with_mode(
529            &path,
530            token.as_bytes(),
531            Some(RECORD_FILE_MODE),
532        )
533        .and_then(crate::retention::StagedRewrite::commit)
534        .map_err(|reason| format!("failed to write {}: {reason}", path.display()))?;
535        Ok(Self { path })
536    }
537}
538
539impl Drop for ControlTokenGuard {
540    fn drop(&mut self) {
541        let _ = std::fs::remove_file(&self.path);
542    }
543}
544
545/// One session's record, written for exactly as long as the session runs.
546///
547/// `launch_session` has one `?` per staging step and two success returns, so removal is a guard
548/// rather than a line at each of them: a session that ended without retiring its record would
549/// leave an address that resolves to a port nothing holds.
550pub struct RunningGuard {
551    path: PathBuf,
552}
553
554impl RunningGuard {
555    /// Writes `record` and holds it until this guard drops.
556    ///
557    /// The `Err` names why the record could not be written. Failing to record never fails a
558    /// launch: the caller warns and runs on, and what it has lost is the session being addressable
559    /// by session address.
560    pub fn write(record: &RunningRecord) -> Result<Self, String> {
561        let dir = running_dir()?;
562        let path = record_path(&dir, &record.session_id);
563        let body = serde_json::to_vec_pretty(record)
564            .map_err(|err| format!("the record could not be serialized: {err}"))?;
565
566        // Staged beside the record and renamed onto it, because a reader prunes what it cannot
567        // parse: a reader landing between the create and the write would otherwise unlink a
568        // record that was about to be complete.
569        crate::retention::StagedRewrite::stage_with_mode(&path, &body, Some(RECORD_FILE_MODE))
570            .and_then(crate::retention::StagedRewrite::commit)
571            .map_err(|reason| format!("failed to write {}: {reason}", path.display()))?;
572        Ok(Self { path })
573    }
574}
575
576impl Drop for RunningGuard {
577    fn drop(&mut self) {
578        let _ = std::fs::remove_file(&self.path);
579        // The record's control token goes with it, whichever guard drops first.
580        let _ = std::fs::remove_file(self.path.with_extension(CONTROL_TOKEN_EXTENSION));
581    }
582}
583
584/// Whether the calling process has a controlling terminal, which is what decides
585/// `outlives_launcher`: a capsule attached to a terminal dies with that window.
586///
587/// `/dev/tty` rather than `isatty` on stdio, so redirecting a stream does not change the answer.
588#[must_use]
589pub fn has_controlling_terminal() -> bool {
590    std::fs::File::open("/dev/tty").is_ok()
591}
592
593fn record_path(dir: &Path, session_id: &str) -> PathBuf {
594    dir.join(format!("{session_id}.json"))
595}
596
597fn token_path(dir: &Path, session_id: &str) -> PathBuf {
598    dir.join(format!("{session_id}.{CONTROL_TOKEN_EXTENSION}"))
599}
600
601fn read_record(path: &Path) -> Option<RunningRecord> {
602    let body = std::fs::read_to_string(path).ok()?;
603    serde_json::from_str(&body).ok()
604}
605
606/// Layer 1. `EPERM` counts as alive: the pid is held, by a process this user may not signal.
607///
608/// A zombie does not. It still holds its pid and still answers `kill(pid, 0)`, but it has exited
609/// — its door is closed and its memory reclaimed, and it stays in the table only until whoever
610/// started it waits on it. Counting one as alive would mean `mur ps` listing an exited capsule as
611/// `unreachable` for as long as its launcher neglected to reap it, and `mur stop` never able to
612/// confirm that the process it just signalled had gone.
613///
614/// A pid outside `1..=pid_t::MAX` is held by no process. `kill(2)` reads `0` as the caller's own
615/// process group and a negative `pid_t` as a process group or every process, so either would
616/// otherwise answer for processes the record never named.
617#[allow(unsafe_code)]
618pub(crate) fn pid_is_alive(pid: u32) -> bool {
619    let Ok(target) = libc::pid_t::try_from(pid) else {
620        return false;
621    };
622    if target == 0 {
623        return false;
624    }
625    // SAFETY: `kill` with signal 0 runs the existence and permission checks without delivering
626    // anything, and dereferences no pointer.
627    let held = unsafe { libc::kill(target, 0) } == 0
628        || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM);
629    held && !platform::is_zombie(pid)
630}
631
632/// Layer 2's reading: an opaque, platform-local token for when `pid`'s process started.
633///
634/// Compared only by string equality against a token read freshly on the same machine. Nothing
635/// converts between platform encodings and nothing parses either one, so the two readings below
636/// never have to be reconciled.
637///
638/// | Platform | Token |
639/// |---|---|
640/// | Linux | field 22 of `/proc/<pid>/stat`, clock ticks since boot |
641/// | macOS | `proc_bsdinfo.pbi_start_tvsec` / `pbi_start_tvusec`, as `seconds.microseconds` |
642#[must_use]
643pub fn process_start_token(pid: u32) -> Option<String> {
644    platform::process_start_token(pid)
645}
646
647#[cfg(target_os = "linux")]
648mod platform {
649    /// Field 22 of `/proc/<pid>/stat`.
650    ///
651    /// The second field is the executable name in parentheses and may itself contain spaces and
652    /// parentheses, so the split starts after its last `)`: the remainder begins at field 3.
653    /// Field 22, counted from the field after the executable name, which is field 3.
654    const STARTTIME_OFFSET_AFTER_COMM: usize = 22 - 3;
655
656    pub(super) fn process_start_token(pid: u32) -> Option<String> {
657        let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
658        start_token_from_stat(&stat)
659    }
660
661    /// Field 22 of one `/proc/<pid>/stat` line, or `None` when the line has no `)` or ends before
662    /// field 22.
663    pub(super) fn start_token_from_stat(stat: &str) -> Option<String> {
664        fields_after_comm(stat)?
665            .nth(STARTTIME_OFFSET_AFTER_COMM)
666            .map(str::to_string)
667    }
668
669    /// The fields of a `/proc/<pid>/stat` line from field 3 on, or `None` when it has no `)`.
670    fn fields_after_comm(stat: &str) -> Option<std::str::SplitWhitespace<'_>> {
671        Some(stat[stat.rfind(')')? + 1..].split_whitespace())
672    }
673
674    /// Field 3 of `/proc/<pid>/stat` — the first after the executable name — is the state
675    /// character, and `Z` is a process that has exited and not yet been waited on.
676    ///
677    /// A pid whose `stat` cannot be read is not reported as a zombie: the reading failed, which
678    /// says nothing about the process, and layer 2 refuses it a moment later anyway.
679    pub(super) fn is_zombie(pid: u32) -> bool {
680        let Ok(stat) = std::fs::read_to_string(format!("/proc/{pid}/stat")) else {
681            return false;
682        };
683        fields_after_comm(&stat).and_then(|mut fields| fields.next()) == Some("Z")
684    }
685}
686
687#[cfg(target_os = "macos")]
688mod platform {
689    use std::mem::size_of;
690
691    /// `proc_bsdinfo.pbi_start_tvsec` and `pbi_start_tvusec`, rendered as `seconds.microseconds`.
692    pub(super) fn process_start_token(pid: u32) -> Option<String> {
693        let info = bsd_info(pid).ok()?;
694        Some(format!(
695            "{}.{:06}",
696            info.pbi_start_tvsec, info.pbi_start_tvusec
697        ))
698    }
699
700    /// A process that has exited and not yet been waited on. `kill(pid, 0)` still reports its pid
701    /// held, but `PROC_PIDTBSDINFO` refuses to describe it with `ESRCH`; one described mid-exit
702    /// carries `pbi_status` `SZOMB`. `ESRCH` read straight after layer 1 found the pid held means
703    /// either that or a process that exited in between, and both are gone.
704    ///
705    /// Any other failed reading, `EPERM` or a short write, is not reported as a zombie, on the
706    /// same terms as the Linux reading: layer 2 reads the pid as `Unverified`.
707    pub(super) fn is_zombie(pid: u32) -> bool {
708        match bsd_info(pid) {
709            Ok(info) => info.pbi_status == libc::SZOMB,
710            Err(err) => err.raw_os_error() == Some(libc::ESRCH),
711        }
712    }
713
714    /// The kernel's `PROC_PIDTBSDINFO` description of `pid`. `Err` carries the `errno` when the
715    /// call failed, and no OS error when it wrote anything other than exactly one `proc_bsdinfo`.
716    #[allow(unsafe_code)]
717    fn bsd_info(pid: u32) -> std::io::Result<libc::proc_bsdinfo> {
718        // SAFETY: `proc_bsdinfo` is a plain C struct of integers and fixed-size integer arrays,
719        // with no pointer, niche or validity invariant, so an all-zero value is a valid one.
720        let mut info: libc::proc_bsdinfo = unsafe { std::mem::zeroed() };
721        let size = size_of::<libc::proc_bsdinfo>();
722
723        // SAFETY: the buffer is one `proc_bsdinfo` and the size passed is that struct's own size,
724        // which bounds what `proc_pidinfo` writes. The kernel copies into it and returns; nothing
725        // retains the pointer past the call.
726        let written = unsafe {
727            libc::proc_pidinfo(
728                pid as libc::c_int,
729                libc::PROC_PIDTBSDINFO,
730                0,
731                std::ptr::addr_of_mut!(info).cast(),
732                size as libc::c_int,
733            )
734        };
735        if written <= 0 {
736            return Err(std::io::Error::last_os_error());
737        }
738        if usize::try_from(written).ok() != Some(size) {
739            return Err(std::io::Error::other(format!(
740                "proc_pidinfo wrote {written} bytes, not one proc_bsdinfo of {size}"
741            )));
742        }
743        Ok(info)
744    }
745}
746
747/// Layer 3: the session id the door at `record.url` claims on its agent card.
748///
749/// A record carrying a door token reads the extended card, the only one an authenticated door
750/// names its session on, by `agent/getAuthenticatedExtendedCard`; any other record reads the
751/// public card. A card with no capsule extension naming a session — one served by a runtime that
752/// predates the A2A card, among others — names no session, so its capsule reads as unreachable.
753fn probe_session_id(record: &RunningRecord) -> Result<String, String> {
754    probe_door_session_id(&record.url, record.door_token.as_ref())
755}
756
757/// The session id the door at `url` — `host:port`, with or without `http://` — names on its agent
758/// card: the extended card read with `token` when one is given, else the public card.
759///
760/// The one door probe: [`verify`]'s layer 3 and a formation launcher's readiness check both ask
761/// it, so "this door answers as that session" means the same thing to both.
762pub(crate) fn probe_door_session_id(
763    url: &str,
764    token: Option<&crate::door_auth::DoorToken>,
765) -> Result<String, String> {
766    let addr = url
767        .trim_start_matches("http://")
768        .trim_start_matches("https://");
769    let card = match token {
770        Some(token) => {
771            let body = serde_json::json!({
772                "jsonrpc": "2.0",
773                "id": 1,
774                "method": crate::identity::DoorMethod::GetAuthenticatedExtendedCard.wire_name(),
775                "params": {},
776            })
777            .to_string();
778            let authorization = crate::door_auth::bearer_header(token);
779            let response = crate::http_client::http_json_with_timeouts(
780                "POST",
781                &format!("http://{addr}/"),
782                Some(&body),
783                &[("Authorization", authorization.as_str())],
784                PROBE_CONNECT_TIMEOUT,
785                PROBE_READ_TIMEOUT,
786            )?;
787            response.get("result").cloned().ok_or_else(|| {
788                format!("the capsule at {addr} did not return its extended agent card")
789            })?
790        }
791        None => crate::http_client::http_json_with_timeouts(
792            "GET",
793            &format!("http://{addr}/.well-known/agent-card.json"),
794            None,
795            &[("Accept", "application/json")],
796            PROBE_CONNECT_TIMEOUT,
797            PROBE_READ_TIMEOUT,
798        )?,
799    };
800    crate::identity::session_id_from_card(&card)
801        .map(str::to_string)
802        .ok_or_else(|| format!("the agent card from {addr} names no session"))
803}
804
805#[cfg(test)]
806mod tests {
807    use super::*;
808
809    #[test]
810    fn prune_removes_the_record_and_its_control_token() {
811        let home = tempfile::tempdir().unwrap();
812        crate::murmur_home::run_with_home(
813            "running::tests::inner_prune_removes_the_record_and_its_control_token",
814            home.path(),
815        );
816    }
817
818    #[test]
819    #[ignore = "run by prune_removes_the_record_and_its_control_token"]
820    fn inner_prune_removes_the_record_and_its_control_token() {
821        if !crate::murmur_home::in_scratch_home() {
822            return;
823        }
824        let record = RunningRecord {
825            session_id: "ses_0199prunecontroltoken000000000000".to_string(),
826            url: "localhost:1".to_string(),
827            pid: std::process::id(),
828            process_start: String::new(),
829            capsule_name: "c".to_string(),
830            capsule_version: "0.1.0".to_string(),
831            workdir: PathBuf::from("/tmp"),
832            outlives_launcher: false,
833            started_at: "2026-01-01T00:00:00Z".to_string(),
834            door_token: None,
835            credentials: BTreeMap::new(),
836            formation_id: None,
837            formation_lifeline: false,
838            formation_launcher: None,
839            spawned_by: None,
840        };
841        let token = ControlTokenGuard::write(&record.session_id, "ctl1.token.mac").unwrap();
842        let record_guard = RunningGuard::write(&record).unwrap();
843        let dir = running_dir().unwrap();
844        let token_file = control_token_path(&record.session_id).unwrap();
845        assert_eq!(
846            token_file,
847            dir.join(format!("{}.control", record.session_id))
848        );
849        assert_eq!(crate::murmur_home::mode_of(&token_file), RECORD_FILE_MODE);
850        assert_eq!(
851            std::fs::read_to_string(&token_file).unwrap(),
852            "ctl1.token.mac"
853        );
854
855        prune(&record);
856        assert!(!record_path(&dir, &record.session_id).exists());
857        assert!(!token_file.exists());
858
859        // Either guard dropping takes both files.
860        std::mem::forget(token);
861        std::mem::forget(record_guard);
862        let _token = ControlTokenGuard::write(&record.session_id, "ctl1.token.mac").unwrap();
863        drop(RunningGuard::write(&record).unwrap());
864        assert!(!token_file.exists());
865    }
866
867    fn record(pid: u32, process_start: &str) -> RunningRecord {
868        RunningRecord {
869            session_id: "ses_0199c4e2f1b7712a9d3e4f5061728394".to_string(),
870            url: "127.0.0.1:1".to_string(),
871            pid,
872            process_start: process_start.to_string(),
873            capsule_name: "demo".to_string(),
874            capsule_version: "0.1.0".to_string(),
875            workdir: PathBuf::from("/tmp/demo"),
876            outlives_launcher: true,
877            started_at: "2026-01-01T00:00:00Z".to_string(),
878            door_token: None,
879            credentials: BTreeMap::new(),
880            formation_id: None,
881            formation_lifeline: false,
882            formation_launcher: None,
883            spawned_by: None,
884        }
885    }
886
887    /// The serialized shape is the whole contract with the CLI and with an operator reading the
888    /// file, so the key set is asserted rather than assumed.
889    #[test]
890    fn a_record_serializes_exactly_the_nine_fields() {
891        let value = serde_json::to_value(record(1, "42")).unwrap();
892        let mut keys: Vec<&str> = value
893            .as_object()
894            .unwrap()
895            .keys()
896            .map(String::as_str)
897            .collect();
898        keys.sort_unstable();
899        assert_eq!(
900            keys,
901            [
902                "capsule_name",
903                "capsule_version",
904                "outlives_launcher",
905                "pid",
906                "process_start",
907                "session_id",
908                "started_at",
909                "url",
910                "workdir",
911            ]
912        );
913    }
914
915    #[test]
916    fn a_record_with_a_door_token_serializes_it_beside_the_nine_fields() {
917        let mut with_token = record(1, "42");
918        with_token.door_token = Some(crate::door_auth::DoorToken::new(
919            "mdt1.payload.mac".to_string(),
920        ));
921        let value = serde_json::to_value(&with_token).unwrap();
922        let object = value.as_object().unwrap();
923        assert_eq!(object.len(), 10);
924        assert_eq!(object["door_token"], "mdt1.payload.mac");
925        let back: RunningRecord = serde_json::from_value(value).unwrap();
926        assert_eq!(back, with_token);
927    }
928
929    #[test]
930    fn a_record_debug_prints_no_door_token() {
931        let mut with_token = record(1, "42");
932        with_token.door_token = Some(crate::door_auth::DoorToken::new(
933            "mdt1.secretpayload.secretmac".to_string(),
934        ));
935        let debug = format!("{with_token:?}");
936        assert!(!debug.contains("secretpayload"), "{debug}");
937        assert!(debug.contains("DoorToken(<redacted>)"), "{debug}");
938    }
939
940    fn with_credentials() -> RunningRecord {
941        let mut authenticated = record(1, "42");
942        authenticated.door_token = Some(crate::door_auth::DoorToken::new(
943            "mdt1.operatorpayload.mac".to_string(),
944        ));
945        for name in ["watcher", "reader"] {
946            authenticated.credentials.insert(
947                name.to_string(),
948                crate::door_auth::DoorToken::new(format!("mdt1.{name}secret.mac")),
949            );
950        }
951        authenticated
952    }
953
954    #[test]
955    fn a_record_with_credentials_serializes_them_as_an_object_by_name() {
956        let authenticated = with_credentials();
957        let value = serde_json::to_value(&authenticated).unwrap();
958        let object = value.as_object().unwrap();
959        assert_eq!(object.len(), 11);
960        assert_eq!(
961            object["credentials"],
962            serde_json::json!({
963                "reader": "mdt1.readersecret.mac",
964                "watcher": "mdt1.watchersecret.mac",
965            })
966        );
967        let back: RunningRecord = serde_json::from_value(value).unwrap();
968        assert_eq!(back, authenticated);
969    }
970
971    #[test]
972    fn a_record_debug_prints_no_credential_token() {
973        let debug = format!("{:?}", with_credentials());
974        assert!(!debug.contains("secret"), "{debug}");
975        assert!(!debug.contains("operatorpayload"), "{debug}");
976        assert!(
977            debug.contains("\"watcher\": DoorToken(<redacted>)"),
978            "{debug}"
979        );
980    }
981
982    /// An operator token alone — a door declaring no credentials — writes no `credentials` key.
983    #[test]
984    fn a_record_with_no_declared_credentials_omits_the_key() {
985        let mut operator_only = record(1, "42");
986        operator_only.door_token = Some(crate::door_auth::DoorToken::new(
987            "mdt1.payload.mac".to_string(),
988        ));
989        let value = serde_json::to_value(&operator_only).unwrap();
990        assert!(value.get("credentials").is_none(), "{value}");
991    }
992
993    /// A record an older runtime wrote has no `credentials` key, and reads as declaring none.
994    #[test]
995    fn a_record_without_a_credentials_key_parses_as_empty() {
996        let mut value = serde_json::to_value(with_credentials()).unwrap();
997        value.as_object_mut().unwrap().remove("credentials");
998        let parsed: RunningRecord = serde_json::from_value(value).unwrap();
999        assert!(parsed.credentials.is_empty());
1000        assert!(parsed.door_token.is_some());
1001    }
1002
1003    /// A record an older runtime wrote has no `door_token` key, and still names a process.
1004    #[test]
1005    fn a_record_without_a_door_token_key_parses() {
1006        let value = serde_json::to_value(record(1, "42")).unwrap();
1007        assert!(value.get("door_token").is_none());
1008        let parsed: RunningRecord = serde_json::from_value(value).unwrap();
1009        assert_eq!(parsed.door_token, None);
1010    }
1011
1012    #[test]
1013    fn a_member_record_serializes_its_formation_beside_the_nine_fields() {
1014        let formation = crate::formation::FormationId::mint();
1015        let mut member = record(1, "42");
1016        member.formation_id = Some(formation.clone());
1017        let value = serde_json::to_value(&member).unwrap();
1018        let object = value.as_object().unwrap();
1019        assert_eq!(object.len(), 10);
1020        assert_eq!(object["formation_id"], formation.as_str());
1021        let back: RunningRecord = serde_json::from_value(value).unwrap();
1022        assert_eq!(back, member);
1023    }
1024
1025    /// A standalone session's record carries no formation key at all, not a null one.
1026    #[test]
1027    fn a_standalone_record_has_no_formation_key() {
1028        let body = serde_json::to_string_pretty(&record(1, "42")).unwrap();
1029        assert!(!body.contains("formation"), "{body}");
1030        let parsed: RunningRecord = serde_json::from_str(&body).unwrap();
1031        assert_eq!(parsed.formation_id, None);
1032    }
1033
1034    #[test]
1035    fn a_record_with_a_malformed_formation_id_does_not_parse() {
1036        let mut value = serde_json::to_value(record(1, "42")).unwrap();
1037        value
1038            .as_object_mut()
1039            .unwrap()
1040            .insert("formation_id".to_string(), "frm_ABC".into());
1041        assert!(serde_json::from_value::<RunningRecord>(value).is_err());
1042    }
1043
1044    #[test]
1045    fn list_prunes_a_record_with_a_malformed_formation_id() {
1046        let home = tempfile::tempdir().unwrap();
1047        crate::murmur_home::run_with_home(
1048            "running::tests::inner_list_prunes_a_record_with_a_malformed_formation_id",
1049            home.path(),
1050        );
1051    }
1052
1053    #[test]
1054    #[ignore = "run by list_prunes_a_record_with_a_malformed_formation_id"]
1055    fn inner_list_prunes_a_record_with_a_malformed_formation_id() {
1056        if !crate::murmur_home::in_scratch_home() {
1057            return;
1058        }
1059        let dir = running_dir().unwrap();
1060        let mut member = record(1, "42");
1061        member.formation_id = Some(crate::formation::FormationId::mint());
1062        let mut malformed = serde_json::to_value(record(2, "42")).unwrap();
1063        malformed["session_id"] = "ses_0199c4e2f1b7712a9d3e4f5061728395".into();
1064        malformed["formation_id"] = "frm_ABC".into();
1065        let member_path = record_path(&dir, &member.session_id);
1066        let malformed_path = dir.join("ses_0199c4e2f1b7712a9d3e4f5061728395.json");
1067        std::fs::write(&member_path, serde_json::to_vec_pretty(&member).unwrap()).unwrap();
1068        std::fs::write(
1069            &malformed_path,
1070            serde_json::to_vec_pretty(&malformed).unwrap(),
1071        )
1072        .unwrap();
1073
1074        assert_eq!(list().unwrap(), vec![member]);
1075        assert!(member_path.exists());
1076        assert!(!malformed_path.exists());
1077    }
1078
1079    fn launched_member() -> RunningRecord {
1080        let mut member = record(1, "42");
1081        member.formation_id = Some(crate::formation::FormationId::mint());
1082        member.formation_lifeline = true;
1083        member.formation_launcher = Some(ProcessIdentity {
1084            pid: 4242,
1085            process_start: "987654".to_string(),
1086        });
1087        member.spawned_by = Some("ses_0199c4e2f1b7712a9d3e4f5061728390".to_string());
1088        member
1089    }
1090
1091    #[test]
1092    fn a_launched_members_record_round_trips_its_launcher_and_lineage() {
1093        let member = launched_member();
1094        let value = serde_json::to_value(&member).unwrap();
1095        assert_eq!(value["formation_lifeline"], true);
1096        assert_eq!(
1097            value["formation_launcher"],
1098            serde_json::json!({"pid": 4242, "process_start": "987654"})
1099        );
1100        assert_eq!(value["spawned_by"], "ses_0199c4e2f1b7712a9d3e4f5061728390");
1101        let back: RunningRecord = serde_json::from_value(value).unwrap();
1102        assert_eq!(back, member);
1103    }
1104
1105    /// The three keys are written only when they apply, so a record without them is byte for
1106    /// byte the record a standalone session always wrote.
1107    #[test]
1108    fn a_record_holding_no_lifeline_launcher_or_spawner_omits_all_three_keys() {
1109        let mut member = launched_member();
1110        member.formation_lifeline = false;
1111        member.formation_launcher = None;
1112        member.spawned_by = None;
1113        let body = serde_json::to_string_pretty(&member).unwrap();
1114        for key in ["formation_lifeline", "formation_launcher", "spawned_by"] {
1115            assert!(!body.contains(key), "{key} in {body}");
1116        }
1117        assert_eq!(
1118            serde_json::from_str::<RunningRecord>(&body).unwrap(),
1119            member
1120        );
1121    }
1122
1123    #[test]
1124    fn a_record_with_a_malformed_formation_launcher_does_not_parse() {
1125        for launcher in [
1126            serde_json::json!(4242),
1127            serde_json::json!({"pid": 4242}),
1128            serde_json::json!({"process_start": "1"}),
1129            serde_json::json!({"pid": -1, "process_start": "1"}),
1130            serde_json::json!({"pid": "4242", "process_start": "1"}),
1131        ] {
1132            let mut value = serde_json::to_value(record(1, "42")).unwrap();
1133            value["formation_launcher"] = launcher.clone();
1134            assert!(
1135                serde_json::from_value::<RunningRecord>(value).is_err(),
1136                "{launcher}"
1137            );
1138        }
1139    }
1140
1141    /// An identity and a record naming the same pid and start time are one process to layers 1
1142    /// and 2, and to the signal that re-checks them.
1143    #[test]
1144    fn an_identity_reads_and_signals_as_the_record_naming_the_same_process() {
1145        let pid = std::process::id();
1146        let token = process_start_token(pid).unwrap();
1147        for start in [token.as_str(), "not-this-process", ""] {
1148            let record = record(pid, start);
1149            let identity = ProcessIdentity {
1150                pid,
1151                process_start: start.to_string(),
1152            };
1153            assert_eq!(identity_state(&identity), process_state(&record), "{start}");
1154        }
1155        // A mismatched start time is the one case safe to signal here: nothing is sent.
1156        let stale = ProcessIdentity {
1157            pid,
1158            process_start: "not-this-process".to_string(),
1159        };
1160        assert_eq!(
1161            signal_term_process(&stale),
1162            signal_term(&record(pid, "not-this-process"))
1163        );
1164        assert_eq!(signal_kill_process(&stale), SignalOutcome::AlreadyGone);
1165        let nobody = ProcessIdentity {
1166            pid: 0,
1167            process_start: "42".to_string(),
1168        };
1169        assert_eq!(signal_term_process(&nobody), signal_term(&record(0, "42")));
1170        assert_eq!(
1171            ProcessIdentity::of_this_process(),
1172            Some(ProcessIdentity {
1173                pid,
1174                process_start: token
1175            })
1176        );
1177    }
1178
1179    /// A live process this test owns, signalled through its identity, is sent the signal and goes.
1180    #[test]
1181    fn a_term_sent_through_an_identity_reaches_the_process() {
1182        let mut child = std::process::Command::new("sleep")
1183            .arg("30")
1184            .spawn()
1185            .expect("sleep should start");
1186        let pid = child.id();
1187        let identity = ProcessIdentity {
1188            pid,
1189            process_start: process_start_token(pid).unwrap(),
1190        };
1191        assert_eq!(identity_state(&identity), ProcessState::Alive);
1192        assert_eq!(signal_term_process(&identity), SignalOutcome::Sent);
1193        let status = child.wait().unwrap();
1194        use std::os::unix::process::ExitStatusExt;
1195        assert_eq!(status.signal(), Some(libc::SIGTERM));
1196        assert!(matches!(identity_state(&identity), ProcessState::Gone(_)));
1197    }
1198
1199    /// Every field is required, so a record missing one is unverifiable rather than partly
1200    /// usable — which is what makes pruning it on read the whole of the compatibility story.
1201    #[test]
1202    fn a_record_missing_a_field_does_not_parse() {
1203        let mut value = serde_json::to_value(record(1, "42")).unwrap();
1204        value.as_object_mut().unwrap().remove("process_start");
1205        assert!(serde_json::from_value::<RunningRecord>(value).is_err());
1206    }
1207
1208    /// This process is alive and its own start time is what the host reports for it.
1209    #[test]
1210    fn this_process_reads_as_alive() {
1211        let pid = std::process::id();
1212        let token = process_start_token(pid).expect("this process has a start time");
1213        assert_eq!(process_state(&record(pid, &token)), ProcessState::Alive);
1214    }
1215
1216    /// Layer 2 alone: the pid is this live process, and the recorded start time is not its.
1217    #[test]
1218    fn a_start_time_that_disagrees_reads_as_gone() {
1219        let pid = std::process::id();
1220        let state = process_state(&record(pid, "not-this-process"));
1221        match state {
1222            ProcessState::Gone(reason) => assert!(
1223                reason.contains("another time"),
1224                "the reason must say the pid belongs to another process now: {reason}"
1225            ),
1226            other => panic!("a mismatched start time must read as gone, not {other:?}"),
1227        }
1228    }
1229
1230    /// A pid no process holds fails layer 1, whatever start time the record carries.
1231    #[test]
1232    fn a_pid_nothing_holds_reads_as_gone() {
1233        // The kernel hands out pids below this only after wrapping, and pid 0 is never a process.
1234        let state = process_state(&record(0, "42"));
1235        assert!(matches!(state, ProcessState::Gone(_)));
1236    }
1237
1238    /// The token is read the same way twice, which is the only property string comparison needs.
1239    #[test]
1240    fn the_start_token_is_stable_for_one_process() {
1241        let pid = std::process::id();
1242        assert_eq!(process_start_token(pid), process_start_token(pid));
1243        assert!(process_start_token(pid).is_some_and(|token| !token.is_empty()));
1244    }
1245
1246    /// A process that has exited and not been waited on still holds its pid and still answers
1247    /// `kill(pid, 0)`, and is not running.
1248    #[test]
1249    fn a_zombie_does_not_read_as_alive() {
1250        let mut child = std::process::Command::new("sh")
1251            .args(["-c", "exit 0"])
1252            .spawn()
1253            .expect("sh should start");
1254        let pid = child.id();
1255        let token = process_start_token(pid).expect("a live child has a start time");
1256
1257        // Deliberately not waited on: the pid stays in the table as a zombie until it is.
1258        let deadline = std::time::Instant::now() + Duration::from_secs(10);
1259        loop {
1260            if matches!(process_state(&record(pid, &token)), ProcessState::Gone(_)) {
1261                break;
1262            }
1263            assert!(
1264                std::time::Instant::now() < deadline,
1265                "a zombie kept reading as alive"
1266            );
1267            std::thread::sleep(Duration::from_millis(50));
1268        }
1269        let _ = child.wait();
1270    }
1271
1272    /// The load-bearing invariant: a record whose recorded start time no longer matches names a
1273    /// process that inherited the pid, and nothing is signalled at it.
1274    #[test]
1275    fn a_signal_at_a_mismatched_start_time_is_not_sent() {
1276        // This process is alive and would die of a `SIGKILL` it were actually sent one.
1277        let record = record(std::process::id(), "not-this-process");
1278        assert_eq!(signal_kill(&record), SignalOutcome::AlreadyGone);
1279        assert_eq!(signal_term(&record), SignalOutcome::AlreadyGone);
1280    }
1281
1282    /// A pid nothing holds is reported as already gone rather than as a refusal — there is
1283    /// nothing there to refuse.
1284    #[test]
1285    fn a_signal_at_a_pid_nothing_holds_is_already_gone() {
1286        assert_eq!(signal_term(&record(0, "42")), SignalOutcome::AlreadyGone);
1287    }
1288
1289    /// A live process whose door answers nothing is unreachable, not gone — the distinction the
1290    /// whole three-layer split exists for.
1291    #[test]
1292    fn a_live_process_behind_a_silent_port_is_unreachable() {
1293        let pid = std::process::id();
1294        let token = process_start_token(pid).unwrap();
1295        let mut record = record(pid, &token);
1296        // Claimed and never bound: the port is one nothing listens on.
1297        let port = crate::pinned_port::claim();
1298        record.url = format!("127.0.0.1:{port}");
1299        assert!(matches!(verify(&record), Liveness::Unreachable(_)));
1300    }
1301
1302    const PID: u32 = 4242;
1303
1304    #[test]
1305    fn classify_a_dead_pid_is_gone_whatever_the_token() {
1306        for token in [None, Some("123"), Some("")] {
1307            assert_eq!(
1308                classify(PID, "123", false, token),
1309                ProcessState::Gone("no process holds pid 4242".to_string()),
1310                "token {token:?}"
1311            );
1312        }
1313    }
1314
1315    #[test]
1316    fn classify_a_live_pid_with_the_recorded_token_is_alive() {
1317        assert_eq!(classify(PID, "123", true, Some("123")), ProcessState::Alive);
1318    }
1319
1320    #[test]
1321    fn classify_a_live_pid_with_another_token_is_gone() {
1322        assert_eq!(
1323            classify(PID, "123", true, Some("456")),
1324            ProcessState::Gone("pid 4242 is held by a process that started at another time".into())
1325        );
1326    }
1327
1328    /// The writer stores `""` when it could not read its own start time, and that must never match.
1329    #[test]
1330    fn classify_a_recorded_empty_token_against_a_fresh_one_is_gone() {
1331        assert!(matches!(
1332            classify(PID, "", true, Some("123")),
1333            ProcessState::Gone(reason) if reason.contains("another time")
1334        ));
1335    }
1336
1337    #[test]
1338    fn classify_a_live_pid_whose_token_cannot_be_read_is_unverified() {
1339        assert_eq!(
1340            classify(PID, "123", true, None),
1341            ProcessState::Unverified("pid 4242's start time could not be read".into())
1342        );
1343    }
1344
1345    #[test]
1346    fn nothing_is_refused_before_signalling_a_confirmed_process() {
1347        assert_eq!(refusal_before_signal(&ProcessState::Alive), None);
1348    }
1349
1350    #[test]
1351    fn a_gone_process_is_already_gone_before_any_signal() {
1352        assert_eq!(
1353            refusal_before_signal(&ProcessState::Gone("no process holds pid 1".into())),
1354            Some(SignalOutcome::AlreadyGone)
1355        );
1356    }
1357
1358    #[test]
1359    fn an_unverified_process_is_refused_before_any_signal() {
1360        let reason = "pid 1's start time could not be read".to_string();
1361        assert_eq!(
1362            refusal_before_signal(&ProcessState::Unverified(reason.clone())),
1363            Some(SignalOutcome::Refused(reason))
1364        );
1365    }
1366
1367    /// `liveness_without_probe` has no URL to probe, so an answer for `Unverified` is one reached
1368    /// without the network.
1369    #[test]
1370    fn an_unverified_process_is_unreachable_without_a_probe() {
1371        let reason = "pid 1's start time could not be read".to_string();
1372        assert_eq!(
1373            liveness_without_probe(ProcessState::Unverified(reason.clone())),
1374            Some(Liveness::Unreachable(reason))
1375        );
1376        assert_eq!(
1377            liveness_without_probe(ProcessState::Gone("gone".into())),
1378            Some(Liveness::Gone("gone".into()))
1379        );
1380        assert_eq!(liveness_without_probe(ProcessState::Alive), None);
1381    }
1382
1383    /// A stat line with field 22 set to `987654`: pid, comm, then fields 3 through 22.
1384    #[cfg(target_os = "linux")]
1385    fn stat_line(comm: &str) -> String {
1386        let fields: Vec<String> = (3..=21).map(|field| field.to_string()).collect();
1387        format!("1234 ({comm}) {} 987654 23 24\n", fields.join(" "))
1388    }
1389
1390    #[cfg(target_os = "linux")]
1391    #[test]
1392    fn the_start_token_is_field_22_of_a_plain_stat_line() {
1393        assert_eq!(
1394            platform::start_token_from_stat(&stat_line("sleep")),
1395            Some("987654".to_string())
1396        );
1397    }
1398
1399    #[cfg(target_os = "linux")]
1400    #[test]
1401    fn the_start_token_is_counted_from_the_last_close_paren() {
1402        assert_eq!(
1403            platform::start_token_from_stat(&stat_line("a) (b c")),
1404            Some("987654".to_string())
1405        );
1406    }
1407
1408    #[cfg(target_os = "linux")]
1409    #[test]
1410    fn a_stat_line_with_no_close_paren_has_no_start_token() {
1411        assert_eq!(platform::start_token_from_stat("1234 sleep S 1 2 3"), None);
1412    }
1413
1414    #[cfg(target_os = "linux")]
1415    #[test]
1416    fn a_stat_line_truncated_before_field_22_has_no_start_token() {
1417        let line = stat_line("sleep");
1418        let truncated = &line[..line.find(" 987654").unwrap()];
1419        assert_eq!(platform::start_token_from_stat(truncated), None);
1420    }
1421}
1422
1423#[cfg(test)]
1424mod home_tests {
1425    use super::*;
1426
1427    #[test]
1428    fn running_dir_holds_a_wide_murmur_home_owner_only() {
1429        let home = tempfile::tempdir().unwrap();
1430        crate::murmur_home::wide_dir(&home.path().join(".murmur"), 0o755);
1431        crate::murmur_home::run_with_home(
1432            "running::home_tests::inner_running_dir_under_scratch_home",
1433            home.path(),
1434        );
1435        assert_eq!(
1436            crate::murmur_home::mode_of(&home.path().join(".murmur")),
1437            0o700
1438        );
1439        assert_eq!(
1440            crate::murmur_home::mode_of(&home.path().join(".murmur").join(RUNNING_DIR)),
1441            RUNNING_DIR_MODE
1442        );
1443    }
1444
1445    #[test]
1446    #[ignore = "run by running_dir_holds_a_wide_murmur_home_owner_only"]
1447    fn inner_running_dir_under_scratch_home() {
1448        if !crate::murmur_home::in_scratch_home() {
1449            return;
1450        }
1451        running_dir().unwrap();
1452    }
1453
1454    #[test]
1455    fn running_dir_location_creates_nothing() {
1456        let home = tempfile::tempdir().unwrap();
1457        crate::murmur_home::run_with_home(
1458            "running::home_tests::inner_running_dir_location_creates_nothing",
1459            home.path(),
1460        );
1461        assert!(!home.path().join(".murmur").exists());
1462    }
1463
1464    #[test]
1465    #[ignore = "run by running_dir_location_creates_nothing"]
1466    fn inner_running_dir_location_creates_nothing() {
1467        if !crate::murmur_home::in_scratch_home() {
1468            return;
1469        }
1470        let home = PathBuf::from(std::env::var_os("HOME").unwrap());
1471        assert_eq!(
1472            running_dir_location().unwrap(),
1473            home.join(".murmur").join(RUNNING_DIR)
1474        );
1475        assert!(!home.join(".murmur").exists());
1476    }
1477
1478    fn record(session_id: &str) -> RunningRecord {
1479        RunningRecord {
1480            session_id: session_id.to_string(),
1481            url: "127.0.0.1:1".to_string(),
1482            pid: 1,
1483            process_start: "42".to_string(),
1484            capsule_name: "demo".to_string(),
1485            capsule_version: "0.1.0".to_string(),
1486            workdir: PathBuf::from("/tmp/demo"),
1487            outlives_launcher: true,
1488            started_at: "2026-01-01T00:00:00Z".to_string(),
1489            door_token: None,
1490            credentials: BTreeMap::new(),
1491            formation_id: None,
1492            formation_lifeline: false,
1493            formation_launcher: None,
1494            spawned_by: None,
1495        }
1496    }
1497
1498    #[test]
1499    fn scan_reports_an_unparseable_file_and_leaves_it_in_place() {
1500        let dir = tempfile::tempdir().unwrap();
1501        let older = record("ses_0199c4e2f1b7712a9d3e4f5061728390");
1502        let newer = record("ses_0199c4e2f1b7712a9d3e4f5061728391");
1503        for record in [&older, &newer] {
1504            std::fs::write(
1505                record_path(dir.path(), &record.session_id),
1506                serde_json::to_vec(record).unwrap(),
1507            )
1508            .unwrap();
1509        }
1510        let broken = dir.path().join("ses_broken.json");
1511        std::fs::write(&broken, "{").unwrap();
1512        let staging = dir.path().join(".ses_x.json.1.2.tmp");
1513        std::fs::write(&staging, "{").unwrap();
1514        let control = token_path(dir.path(), &older.session_id);
1515        std::fs::write(&control, "ctl1.token.mac").unwrap();
1516
1517        let scan = scan(dir.path()).unwrap();
1518
1519        assert_eq!(scan.records, vec![newer, older]);
1520        assert_eq!(scan.unreadable, vec![broken.clone()]);
1521        assert_eq!(std::fs::read_to_string(&broken).unwrap(), "{");
1522        assert!(staging.exists());
1523        assert!(control.exists());
1524    }
1525
1526    #[test]
1527    fn scan_of_a_missing_directory_is_empty_and_creates_nothing() {
1528        let parent = tempfile::tempdir().unwrap();
1529        let dir = parent.path().join(".murmur").join(RUNNING_DIR);
1530
1531        let scan = scan(&dir).unwrap();
1532
1533        assert!(scan.records.is_empty());
1534        assert!(scan.unreadable.is_empty());
1535        assert!(!parent.path().join(".murmur").exists());
1536    }
1537
1538    #[test]
1539    fn scan_of_a_regular_file_errs_naming_it() {
1540        let parent = tempfile::tempdir().unwrap();
1541        let file = parent.path().join(RUNNING_DIR);
1542        std::fs::write(&file, "").unwrap();
1543
1544        let err = scan(&file).unwrap_err();
1545
1546        assert!(err.starts_with(&format!("{}: ", file.display())), "{err}");
1547    }
1548}