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