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