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