Skip to main content

capsule_runtime/
running.rs

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