Skip to main content

capsule_runtime/
formation_launch.rs

1//! Launching a formation: every member of an admitted roster as a `mur run` subprocess of its own,
2//! peers first and the entry member last, and every member stopped once the entry member's process
3//! has ended.
4//!
5//! **The launcher supervises; it runs no session.** Each member, the entry member included, is a
6//! `mur run --capsule <capsule> --capsule-version <version>` process this module starts and owns.
7//! The launcher waits on the entry member's process, so the peers are stopped however the entry
8//! member ends — its task finishing, its own `exit(143)`, or a `SIGKILL` of its process.
9//!
10//! **The launch decides what the entry member does** ([`entry_args`]). Launched with a task, it
11//! runs that one task and exits, which ends the formation. Launched without one, it takes tasks
12//! at its door one after another, with no idle timeout, until the formation is stopped: `mur stop`,
13//! a signal to the launcher, or the deadline.
14//!
15//! **Every member holds a lifeline.** Each member is handed the read end of a pipe of its own
16//! ([`crate::lifeline`]), whose write end only the launcher holds and never writes to. A member
17//! that reads EOF winds down as a first `SIGTERM` would wind it down. The launcher ends a
18//! formation by closing lifelines; when the launcher itself dies — `SIGKILL` and the OOM killer
19//! included — the kernel closes them, so no member outlives its formation.
20//!
21//! **Ready means the door answers as the session that reported it.** A peer is ready when the door
22//! at the URL its readiness line reported serves an agent card naming the session id that line
23//! reported, read through [`crate::running`]'s door probe. A live pid proves nothing, and a door
24//! some other process holds on the reported port names another session. Every member has one
25//! deadline, counted from its spawn.
26//!
27//! **All or nothing.** A peer that fails to come up refuses the whole launch: every member already
28//! started is stopped and reaped before the refusal is returned, and the entry member is never
29//! started.
30//!
31//! **The launcher is the formation's one principal.** It generates the formation's signing key
32//! ([`FormationAuthority`]) after the id and holds it in memory alone. Each member gets a pipe of
33//! its own, whose read end only that member's `mur run` inherits, named by
34//! [`FORMATION_CHANNEL_ENV`]. Its first line — written before the member is spawned — carries the
35//! formation id, the member's name, the verification key and one token per member it may call.
36//! Once every peer's door answers, each peer that calls other peers is written one address line
37//! naming their doors; the entry member's first line and address line are both written before it is
38//! spawned. Nothing here writes a token to stdout, stderr, a file, argv or an environment
39//! variable. Callees never include the entry member: it takes the formation's tasks at its door
40//! from the operator or a client and serves no peer, and admission never admits an edge into it. The
41//! key and the write ends are dropped once every member is reaped.
42//!
43//! **Teardown.**
44//!
45//! * One owner holds every started member, and every return path stops and reaps them. Its `Drop`
46//!   does the same, so an early return or an unwinding panic leaves nothing running.
47//! * Stopping the members is closing every lifeline, up to [`MEMBER_STOP_GRACE`] for each to end
48//!   in order, `SIGKILL` to the process group of any still running, and a reap. The launcher sends
49//!   no member `SIGTERM`. Members are waited on concurrently.
50//! * Every lifeline is created before the first member is spawned, and the launcher spawns
51//!   nothing but members, so no process but the launcher ever holds a write end.
52//! * Peers lead process groups of their own, so a terminal's `^C` reaches only the launcher and
53//!   the entry member, and the launcher stops the peers in order. The entry member stays in the
54//!   launcher's group and behaves at a terminal as a hand-run `mur run` does.
55//! * Under `panic = "abort"` no destructor runs, so a panic hook — installed once per process —
56//!   `SIGKILL`s every registered member's process group before the abort. It reads a lock-free
57//!   registry of member pids, never a mutex a panicking thread might hold.
58//! * [`catch_launcher_signals`] turns `SIGINT`, `SIGTERM` and `SIGHUP` into a flag every wait here
59//!   polls, so a signalled launcher stops its members — the entry member through its lifeline,
60//!   like every other — before it exits.
61
62use std::io::Write;
63use std::path::{Path, PathBuf};
64use std::process::{Child, ExitStatus};
65use std::sync::atomic::{AtomicBool, AtomicI32, AtomicI64, Ordering};
66use std::sync::{mpsc, Once};
67use std::time::{Duration, Instant};
68
69use crate::child_launch::{self, AfterReadiness, ProcessEnv, ProcessLaunch, StartedProcess};
70use crate::delegation::SPAWNER_ENV;
71use crate::door_auth::DoorToken;
72use crate::formation::{FormationId, FormationPeer, FORMATION_PEERS_ENV};
73use crate::formation_credentials::{
74    render_address_line, FormationAuthority, FORMATION_CHANNEL_ENV,
75};
76use crate::lifeline::SPAWNER_LIFELINE_ENV;
77use crate::roster::AdmittedRoster;
78use crate::running::ProcessIdentity;
79use crate::session_status::{SessionDeadline, INHERITED_DEADLINE_FLAG};
80use serde::{Deserialize, Serialize};
81
82/// How long a member has, from its spawn, to report itself and have its door answer.
83///
84/// The bound a delegated child gets for its readiness line, for the same reason: it bounds one
85/// `mur run` subprocess becoming usable on a cold or loaded host, where compiling the driver
86/// component is the slowest step.
87pub const MEMBER_READY_TIMEOUT: Duration = Duration::from_secs(180);
88
89/// How long a member has, after its lifeline is closed, to end in order before its process group
90/// is killed.
91///
92/// Longer than the runtime's own teardown deadline, so a member whose session is ending in order
93/// finishes before it is killed and its trace stays readable.
94pub const MEMBER_STOP_GRACE: Duration = Duration::from_secs(25);
95
96/// How often a peer's door is probed while it is not yet ready.
97const READY_PROBE_INTERVAL: Duration = Duration::from_millis(100);
98
99/// How often a wait here looks at a member's process, at the launcher's signal flag, and at
100/// whether another peer has already failed.
101const POLL_INTERVAL: Duration = Duration::from_millis(50);
102
103/// How long a member whose stdout has closed is given to exit on its own before it is ended.
104const EXIT_AFTER_EOF_WAIT: Duration = Duration::from_secs(2);
105
106/// The address every peer binds its door on. Members of one formation share a host.
107pub const PEER_BIND_ADDR: &str = "127.0.0.1";
108
109/// The hidden `mur run` flag carrying the sha256 admission bound a member to.
110pub const CAPSULE_SHA256_FLAG: &str = "--capsule-sha256";
111
112/// The hidden `mur run` flag naming the directory a peer resolves its artifacts, its lock and its
113/// workspace from, apart from its `--workdir`.
114pub const STORE_ROOT_FLAG: &str = "--store-root";
115
116/// The directory under the murmur home that holds every formation's peer directories.
117pub const FORMATIONS_DIR: &str = "formations";
118
119/// The ownership marker in `<murmur home>/formations/<frm_id>/`, a [`FormationMarker`]. Member
120/// names start with a letter and hold no `.`, so it never collides with a member's directory.
121pub const FORMATION_MARKER_FILE: &str = "formation.json";
122
123/// What a formation launch is given besides its roster.
124#[derive(Debug, Clone)]
125pub struct FormationLaunchOptions {
126    /// The id every member is launched with, minted once for this launch.
127    pub formation_id: FormationId,
128    /// The roster's project directory: the entry member's `--workdir`, and every peer's
129    /// `--store-root`, so every member resolves from the stores admission read.
130    pub project_dir: PathBuf,
131    /// `--task`, passed to the entry member only.
132    pub task: Option<String>,
133    /// `--json`, passed to the entry member only; peers always run with it.
134    pub json: bool,
135    /// `--verbose`, passed to the entry member only.
136    pub verbose: bool,
137    /// `--no-env-file`, passed to every member.
138    pub no_env_file: bool,
139    /// `--containment <class>`, passed to every member.
140    pub containment: Option<String>,
141    /// Each member's deadline to become ready, from its spawn. [`MEMBER_READY_TIMEOUT`] in
142    /// production.
143    pub ready_timeout: Duration,
144    /// How long a stopped member has after its lifeline is closed. [`MEMBER_STOP_GRACE`] in
145    /// production.
146    pub stop_grace: Duration,
147    /// `--deadline`: the formation's one deadline, counted from the launcher's start and passed to
148    /// every member as [`crate::session_status::INHERITED_DEADLINE_FLAG`] and its moment. It also
149    /// bounds every peer's wait to become ready. `None` bounds nothing.
150    pub deadline: Option<SessionDeadline>,
151    /// Where peers' directories are made in place of `<murmur home>/formations`. `None` in
152    /// production; set by tests that must not write under the developer's home.
153    pub(crate) formations_dir: Option<PathBuf>,
154}
155
156impl FormationLaunchOptions {
157    /// Options with the production deadline and grace, and nothing passed through.
158    pub fn new(formation_id: FormationId, project_dir: PathBuf) -> Self {
159        Self {
160            formation_id,
161            project_dir,
162            task: None,
163            json: false,
164            verbose: false,
165            no_env_file: false,
166            containment: None,
167            ready_timeout: MEMBER_READY_TIMEOUT,
168            stop_grace: MEMBER_STOP_GRACE,
169            deadline: None,
170            formations_dir: None,
171        }
172    }
173}
174
175/// One member as the launcher starts it.
176#[derive(Debug, Clone, PartialEq, Eq)]
177pub(crate) struct PlannedMember {
178    pub(crate) name: String,
179    pub(crate) capsule: String,
180    pub(crate) version: String,
181    /// The sha256 admission bound the member to, handed to its `mur run` to check.
182    pub(crate) sha256: String,
183}
184
185/// What a launch starts: the peers in roster order, the entry member, and the members each member
186/// may call.
187#[derive(Debug, Clone)]
188pub(crate) struct FormationPlan {
189    pub(crate) peers: Vec<PlannedMember>,
190    pub(crate) entry: PlannedMember,
191    /// Every member's callees in roster order. Admission never admits an edge into the entry
192    /// member, so it is never among them. A member with none is absent.
193    pub(crate) callees: Vec<(String, Vec<String>)>,
194}
195
196impl FormationPlan {
197    pub(crate) fn from_roster(roster: &AdmittedRoster) -> Self {
198        let planned = |member: &crate::roster::AdmittedMember| PlannedMember {
199            name: member.name.clone(),
200            capsule: member.capsule.clone(),
201            version: member.version.clone(),
202            sha256: member.sha256.clone(),
203        };
204        let entry = roster.entry();
205        Self {
206            peers: roster
207                .members()
208                .iter()
209                .filter(|member| !member.entry)
210                .map(planned)
211                .collect(),
212            entry: planned(entry),
213            callees: roster
214                .members()
215                .iter()
216                .map(|member| {
217                    let callees: Vec<String> = roster
218                        .callees(&member.name)
219                        .map(|callee| callee.name.clone())
220                        .collect();
221                    (member.name.clone(), callees)
222                })
223                .filter(|(_, callees)| !callees.is_empty())
224                .collect(),
225        }
226    }
227
228    /// The members `member` may call, in roster order.
229    pub(crate) fn callees_of(&self, member: &str) -> &[String] {
230        self.callees
231            .iter()
232            .find(|(name, _)| name == member)
233            .map(|(_, callees)| callees.as_slice())
234            .unwrap_or_default()
235    }
236
237    /// `member`'s channel first line, signed by `authority`, naming `launcher` as the process
238    /// that started it, and saying whether `member` is the plan's entry member.
239    fn first_line(
240        &self,
241        authority: &FormationAuthority,
242        launcher: Option<&ProcessIdentity>,
243        member: &str,
244    ) -> String {
245        let callees: Vec<&str> = self.callees_of(member).iter().map(String::as_str).collect();
246        let mut bundle = authority.member_bundle(member, &callees);
247        bundle.launcher = launcher.cloned();
248        bundle.entry = member == self.entry.name;
249        bundle.render_line()
250    }
251}
252
253/// A peer whose door answered as the session its readiness line named.
254pub struct ReadyPeer {
255    pub name: String,
256    pub capsule: String,
257    pub version: String,
258    pub pid: u32,
259    pub session_id: String,
260    /// `http://host:port`.
261    pub url: String,
262    /// The peer's accessible directory, `<murmur home>/formations/<frm_id>/<member>`: its
263    /// `--workdir`, where its `task.md` is written and its sessions nest.
264    pub workdir: PathBuf,
265    /// The operator token from the peer's readiness line, present when the peer declares
266    /// `network.authentication`. Held for the readiness probe and never printed.
267    door_token: Option<DoorToken>,
268}
269
270impl ReadyPeer {
271    /// The operator token for this peer's door, or `None` for a public door.
272    pub fn door_token(&self) -> Option<&DoorToken> {
273        self.door_token.as_ref()
274    }
275}
276
277impl std::fmt::Debug for ReadyPeer {
278    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279        f.debug_struct("ReadyPeer")
280            .field("name", &self.name)
281            .field("capsule", &self.capsule)
282            .field("version", &self.version)
283            .field("pid", &self.pid)
284            .field("session_id", &self.session_id)
285            .field("url", &self.url)
286            .field("workdir", &self.workdir)
287            .field(
288                "door_token",
289                &self.door_token.as_ref().map(|_| "<redacted>"),
290            )
291            .finish()
292    }
293}
294
295/// Why one member did not come up.
296#[derive(Debug, Clone, PartialEq, Eq)]
297pub enum MemberFailureReason {
298    /// Its process could not be started.
299    NotStarted { error: String },
300    /// Its process ended before it printed a readiness line.
301    ExitedBeforeReporting {
302        status: String,
303        stderr_tail: Vec<String>,
304    },
305    /// Its process reported and then ended before its door answered.
306    ExitedBeforeDoorAnswered {
307        status: String,
308        stderr_tail: Vec<String>,
309    },
310    /// No readiness line within the deadline.
311    DidNotReport { deadline: Duration },
312    /// The first line it printed is not a readiness line.
313    UnreadableReport { why: String },
314    /// Its readiness line names no door to probe.
315    ReportedNoDoor,
316    /// Its readiness line names another formation, or none.
317    WrongFormation {
318        reported: Option<String>,
319        expected: String,
320    },
321    /// Its door never answered as the session it reported, within the deadline.
322    DoorDidNotAnswer {
323        url: String,
324        session_id: String,
325        deadline: Duration,
326        last_error: String,
327    },
328}
329
330impl std::fmt::Display for MemberFailureReason {
331    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
332        let tail = |f: &mut std::fmt::Formatter<'_>, lines: &[String]| -> std::fmt::Result {
333            if lines.is_empty() {
334                return f.write_str("; it wrote nothing to stderr");
335            }
336            f.write_str("; its last stderr lines were:")?;
337            for line in lines {
338                write!(f, "\n      {line}")?;
339            }
340            Ok(())
341        };
342        match self {
343            Self::NotStarted { error } => write!(f, "its process could not be started: {error}"),
344            Self::ExitedBeforeReporting {
345                status,
346                stderr_tail,
347            } => {
348                write!(f, "its process exited with {status} before reporting")?;
349                tail(f, stderr_tail)
350            }
351            Self::ExitedBeforeDoorAnswered {
352                status,
353                stderr_tail,
354            } => {
355                write!(
356                    f,
357                    "its process reported and then exited with {status} before its door answered"
358                )?;
359                tail(f, stderr_tail)
360            }
361            Self::DidNotReport { deadline } => {
362                write!(f, "it did not report within {}s", deadline.as_secs_f32())
363            }
364            Self::UnreadableReport { why } => {
365                write!(f, "its first line is not a readiness line: {why}")
366            }
367            Self::ReportedNoDoor => f.write_str("it reported no door"),
368            Self::WrongFormation { reported, expected } => match reported {
369                Some(reported) => {
370                    write!(f, "it reported formation {reported} instead of {expected}")
371                }
372                None => write!(f, "it reported no formation instead of {expected}"),
373            },
374            Self::DoorDidNotAnswer {
375                url,
376                session_id,
377                deadline,
378                last_error,
379            } => write!(
380                f,
381                "its door at {url} did not answer as session {session_id} within {}s; the last \
382                 probe said: {last_error}",
383                deadline.as_secs_f32()
384            ),
385        }
386    }
387}
388
389/// One member that did not come up, and why.
390#[derive(Debug, Clone, PartialEq, Eq)]
391pub struct MemberFailure {
392    pub name: String,
393    pub capsule: String,
394    pub version: String,
395    pub reason: MemberFailureReason,
396}
397
398/// A member whose process was still running when the launcher stopped it.
399#[derive(Debug, Clone, PartialEq, Eq)]
400pub struct StoppedMember {
401    pub name: String,
402    pub pid: u32,
403}
404
405/// A launch that did not complete. By the time this is returned, nothing it started is running.
406#[derive(Debug, Clone, PartialEq, Eq)]
407pub struct MemberLaunchFailure {
408    /// Every member known to have failed, in roster order. Empty when the launch was interrupted
409    /// by a signal before any member failed.
410    pub failed: Vec<MemberFailure>,
411    /// Every member that had started and was stopped, in roster order.
412    pub stopped: Vec<StoppedMember>,
413    /// The signal the launcher caught while members were coming up, when that is what ended the
414    /// launch.
415    pub interrupted_by: Option<i32>,
416    /// Whether the formation's deadline fell before its entry member started, and ended the
417    /// launch. A member the deadline cut off is not among [`Self::failed`].
418    pub deadline_reached: bool,
419}
420
421impl std::fmt::Display for MemberLaunchFailure {
422    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
423        match self.interrupted_by {
424            Some(signal) if self.failed.is_empty() => write!(
425                f,
426                "the formation launch was interrupted by {} before every member was ready",
427                signal_name(signal)
428            )?,
429            None if self.deadline_reached && self.failed.is_empty() => {
430                f.write_str("the formation reached its deadline before its entry member started")?
431            }
432            _ => f.write_str("the formation could not be launched")?,
433        }
434        for failure in &self.failed {
435            write!(
436                f,
437                "\n  '{}' ({}@{}): {}",
438                failure.name, failure.capsule, failure.version, failure.reason
439            )?;
440        }
441        write!(f, "\n  stopped: {}", render_stopped(&self.stopped))
442    }
443}
444
445/// `name (pid N), …`, or `none`.
446pub fn render_stopped(stopped: &[StoppedMember]) -> String {
447    if stopped.is_empty() {
448        return "none".to_string();
449    }
450    stopped
451        .iter()
452        .map(|member| format!("{} (pid {})", member.name, member.pid))
453        .collect::<Vec<_>>()
454        .join(", ")
455}
456
457/// The conventional name of the three signals a launcher catches.
458pub fn signal_name(signal: i32) -> String {
459    match signal {
460        libc::SIGINT => "SIGINT".to_string(),
461        libc::SIGTERM => "SIGTERM".to_string(),
462        libc::SIGHUP => "SIGHUP".to_string(),
463        other => format!("signal {other}"),
464    }
465}
466
467/// How the entry member's process, and with it the formation, ended.
468#[derive(Debug, Clone, PartialEq, Eq)]
469pub enum FormationEnding {
470    /// The entry member's process ended on its own, or by a signal the launcher did not send.
471    EntryExited(ExitStatus),
472    /// The launcher caught this signal and stopped the formation, the entry member included.
473    Signalled(i32),
474}
475
476/// A formation that has ended, every member stopped and reaped.
477#[derive(Debug, Clone)]
478pub struct FormationExit {
479    pub ending: FormationEnding,
480    /// Every member still running when the formation was stopped, in roster order.
481    pub stopped: Vec<StoppedMember>,
482}
483
484impl FormationExit {
485    /// The launcher's exit status: the entry member's code, or 128 plus the signal that ended it,
486    /// or 128 plus the signal the launcher caught.
487    pub fn exit_code(&self) -> i32 {
488        match &self.ending {
489            FormationEnding::EntryExited(status) => crate::shell::exit_code_of(status),
490            FormationEnding::Signalled(signal) => 128 + signal,
491        }
492    }
493}
494
495/// `status N` or `signal N`, for a refusal.
496fn describe_status(status: ExitStatus) -> String {
497    if let Some(code) = status.code() {
498        return format!("status {code}");
499    }
500    #[cfg(unix)]
501    {
502        use std::os::unix::process::ExitStatusExt;
503        if let Some(signal) = status.signal() {
504            return format!("signal {signal}");
505        }
506    }
507    status.to_string()
508}
509
510// ── The launcher's signals ────────────────────────────────────────────────────
511
512/// The signal the launcher caught, or 0. Written only by [`record_signal`].
513static CAUGHT_SIGNAL: AtomicI32 = AtomicI32::new(0);
514
515#[cfg(unix)]
516extern "C" fn record_signal(signal: libc::c_int) {
517    // An atomic store is async-signal-safe; nothing else happens in the handler.
518    CAUGHT_SIGNAL.store(signal, Ordering::SeqCst);
519}
520
521/// Catch `SIGINT`, `SIGTERM` and `SIGHUP` for the rest of this process's life, so a formation
522/// launcher stops its members before it exits rather than dying with them running.
523///
524/// Every wait in this module polls the flag the handler sets. Called by the launcher only: a test
525/// harness that launched formations in-process would otherwise stop answering `^C`.
526#[cfg(unix)]
527#[allow(unsafe_code)]
528pub fn catch_launcher_signals() -> std::io::Result<()> {
529    for signal in [libc::SIGINT, libc::SIGTERM, libc::SIGHUP] {
530        // SAFETY: `sigaction` is given a zeroed, then fully initialised, struct whose handler is
531        // an `extern "C"` function that only stores to an atomic, which is async-signal-safe. The
532        // old-action pointer is null, so nothing is written back.
533        let installed = unsafe {
534            let mut action: libc::sigaction = std::mem::zeroed();
535            action.sa_sigaction = record_signal as extern "C" fn(libc::c_int) as libc::sighandler_t;
536            action.sa_flags = libc::SA_RESTART;
537            libc::sigemptyset(&mut action.sa_mask);
538            libc::sigaction(signal, &action, std::ptr::null_mut())
539        };
540        if installed != 0 {
541            return Err(std::io::Error::last_os_error());
542        }
543    }
544    Ok(())
545}
546
547#[cfg(not(unix))]
548pub fn catch_launcher_signals() -> std::io::Result<()> {
549    Ok(())
550}
551
552/// The signal [`catch_launcher_signals`] caught, if any.
553pub fn caught_signal() -> Option<i32> {
554    match CAUGHT_SIGNAL.load(Ordering::SeqCst) {
555        0 => None,
556        signal => Some(signal),
557    }
558}
559
560// ── The member registry the panic hook reads ──────────────────────────────────
561
562/// How many members one process can have registered at once. A slot that cannot be had leaves
563/// that member to the owner's `Drop` alone; nothing else depends on the registry.
564const REGISTRY_SLOTS: usize = 1024;
565
566/// Every running member's pid: positive for a member leading its own process group, which the
567/// hook kills as a group; negative for the entry member, which shares the launcher's group and is
568/// killed alone; 0 for a free slot.
569static MEMBER_REGISTRY: [AtomicI64; REGISTRY_SLOTS] = [const { AtomicI64::new(0) }; REGISTRY_SLOTS];
570
571/// One registered member. Releasing it — on reap, or on drop — frees the slot.
572struct RegistrySlot(Option<usize>);
573
574impl RegistrySlot {
575    fn register(pid: u32, own_group: bool) -> Self {
576        let pid = i64::from(pid);
577        let value = if own_group { pid } else { -pid };
578        let slot = MEMBER_REGISTRY.iter().position(|slot| {
579            slot.compare_exchange(0, value, Ordering::SeqCst, Ordering::SeqCst)
580                .is_ok()
581        });
582        Self(slot)
583    }
584
585    fn release(&mut self) {
586        if let Some(index) = self.0.take() {
587            MEMBER_REGISTRY[index].store(0, Ordering::SeqCst);
588        }
589    }
590}
591
592impl Drop for RegistrySlot {
593    fn drop(&mut self) {
594        self.release();
595    }
596}
597
598/// `SIGKILL` every registered member: a member leading its own group as a group, the entry member
599/// alone. Reads atomics only, so a panic hook can call it whatever lock the panicking thread holds.
600pub(crate) fn kill_registered_members() {
601    for slot in &MEMBER_REGISTRY {
602        match slot.load(Ordering::SeqCst) {
603            0 => {}
604            pid if pid > 0 => {
605                signal_group(pid, libc::SIGKILL);
606            }
607            pid => signal_pid(-pid, libc::SIGKILL),
608        }
609    }
610}
611
612/// Install, once per process, the hook that kills every registered member before a
613/// `panic = "abort"` build aborts. An unwinding build runs the owner's `Drop` instead, which stops
614/// members in order.
615fn install_panic_hook() {
616    static INSTALLED: Once = Once::new();
617    INSTALLED.call_once(|| {
618        let previous = std::panic::take_hook();
619        std::panic::set_hook(Box::new(move |info| {
620            previous(info);
621            if cfg!(panic = "abort") {
622                kill_registered_members();
623            }
624        }));
625    });
626}
627
628#[cfg(unix)]
629#[allow(unsafe_code)]
630fn signal_pid(pid: i64, signal: libc::c_int) {
631    let Ok(pid) = libc::pid_t::try_from(pid) else {
632        return;
633    };
634    if pid <= 0 {
635        return;
636    }
637    // SAFETY: `kill` takes two integers and dereferences nothing. `pid` is positive, so it names
638    // one process, never a group or every process.
639    unsafe {
640        libc::kill(pid, signal);
641    }
642}
643
644/// Send `signal` to process group `pgid`. `true` when it was delivered; `false` when the group
645/// is gone, or `pgid` is outside `2..=pid_t::MAX` and so names no single group.
646#[cfg(unix)]
647#[allow(unsafe_code)]
648pub(crate) fn signal_group(pgid: i64, signal: libc::c_int) -> bool {
649    let Ok(pgid) = libc::pid_t::try_from(pgid) else {
650        return false;
651    };
652    if pgid <= 1 {
653        return false;
654    }
655    // SAFETY: `killpg` takes two integers and dereferences nothing. `pgid` is greater than 1, so
656    // it names one process group, never the caller's own or every process.
657    unsafe { libc::killpg(pgid, signal) == 0 }
658}
659
660#[cfg(not(unix))]
661fn signal_pid(_pid: i64, _signal: i32) {}
662
663#[cfg(not(unix))]
664pub(crate) fn signal_group(_pgid: i64, _signal: i32) -> bool {
665    false
666}
667
668// ── One member's process ──────────────────────────────────────────────────────
669
670/// A started member's process, owned here and nowhere else.
671struct MemberProcess {
672    name: String,
673    pid: u32,
674    child: Child,
675    own_group: bool,
676    /// The launcher's side of the member's lifeline: the write end, open until the member is
677    /// stopped.
678    #[cfg(unix)]
679    lifeline: Option<crate::lifeline::MemberLifeline>,
680    /// The exit status, once reaped. A reaped pid may already belong to another process, so
681    /// nothing signals or probes it after this is set.
682    status: Option<ExitStatus>,
683    slot: RegistrySlot,
684    grace: Duration,
685    /// The write end of the member's formation channel, kept open while the member runs so an
686    /// address line can follow its first line, and closed once it is reaped.
687    channel: Option<std::io::PipeWriter>,
688}
689
690impl MemberProcess {
691    fn new(
692        name: &str,
693        child: Child,
694        own_group: bool,
695        grace: Duration,
696        channel: std::io::PipeWriter,
697        #[cfg(unix)] lifeline: Option<crate::lifeline::MemberLifeline>,
698    ) -> Self {
699        let pid = child.id();
700        Self {
701            name: name.to_string(),
702            pid,
703            child,
704            own_group,
705            #[cfg(unix)]
706            lifeline,
707            status: None,
708            slot: RegistrySlot::register(pid, own_group),
709            grace,
710            channel: Some(channel),
711        }
712    }
713
714    /// Write one line to the member's channel. A member that has already gone has closed its end;
715    /// the line then reaches nobody, which is what it is for.
716    fn send_line(&mut self, line: &str) {
717        if let Some(channel) = self.channel.as_mut() {
718            let _ = channel.write_all(format!("{line}\n").as_bytes());
719        }
720    }
721
722    /// Whether the process has ended, reaped or not.
723    fn has_exited(&self) -> bool {
724        self.status.is_some() || !crate::running::pid_is_alive(self.pid)
725    }
726
727    /// The exit status, reaping the process if it has ended. `None` while it runs.
728    ///
729    /// A member leading its own group has the group killed first, while its unreaped process
730    /// still holds the group id, so nothing it started outlives it and no recycled id is signalled.
731    fn exit_status_now(&mut self) -> Option<ExitStatus> {
732        if self.status.is_none() && self.has_exited() {
733            self.reap();
734        }
735        self.status
736    }
737
738    fn reap(&mut self) {
739        if self.status.is_some() {
740            return;
741        }
742        if self.own_group {
743            signal_group(i64::from(self.pid), libc::SIGKILL);
744        } else if !self.has_exited() {
745            signal_pid(i64::from(self.pid), libc::SIGKILL);
746        }
747        self.status = self.child.wait().ok();
748        if self.status.is_none() {
749            // `wait` failing leaves nothing further to ask; the process is not ours to probe.
750            self.status = Some(ExitStatus::default());
751        }
752        self.slot.release();
753        self.channel = None;
754    }
755
756    /// Close the member's lifeline: it reads EOF and winds down. Idempotent.
757    fn close_lifeline(&mut self) {
758        #[cfg(unix)]
759        if let Some(lifeline) = &mut self.lifeline {
760            lifeline.close();
761        }
762    }
763
764    /// Whether the process is running and not yet reaped.
765    fn is_running(&self) -> bool {
766        self.status.is_none() && !self.has_exited()
767    }
768
769    /// Close its lifeline, up to the grace for it to end, `SIGKILL` to its group, and a reap.
770    /// Idempotent. `Some` when the process was still running when the stop began.
771    fn stop(&mut self) -> Option<StoppedMember> {
772        let was_running = self.is_running();
773        self.stop_from(was_running)
774    }
775
776    /// [`Self::stop`], with whether the process was running when the stop began read by the
777    /// caller: a member can end on its lifeline's EOF the moment that lifeline closes.
778    fn stop_from(&mut self, was_running: bool) -> Option<StoppedMember> {
779        if self.status.is_some() {
780            return None;
781        }
782        self.close_lifeline();
783        if was_running {
784            let deadline = Instant::now() + self.grace;
785            while !self.has_exited() && Instant::now() < deadline {
786                std::thread::sleep(POLL_INTERVAL);
787            }
788        }
789        self.reap();
790        was_running.then(|| StoppedMember {
791            name: self.name.clone(),
792            pid: self.pid,
793        })
794    }
795}
796
797impl Drop for MemberProcess {
798    fn drop(&mut self) {
799        let _ = self.stop();
800    }
801}
802
803/// Every started member, in roster order: the one owner, and what every exit path stops.
804struct MemberSet(Vec<MemberProcess>);
805
806impl MemberSet {
807    /// Close every member's lifeline, then wait on, kill if need be, and reap every member
808    /// concurrently. Idempotent.
809    fn stop_all(&mut self) -> Vec<StoppedMember> {
810        // Read before any lifeline closes: a member that ends on its EOF while another lifeline is
811        // still being closed was running when the stop began, and is reported as stopped.
812        let running: Vec<bool> = self.0.iter().map(MemberProcess::is_running).collect();
813        for member in &mut self.0 {
814            member.close_lifeline();
815        }
816        std::thread::scope(|scope| {
817            let stopping: Vec<_> = self
818                .0
819                .iter_mut()
820                .zip(running)
821                .map(|(member, was_running)| scope.spawn(move || member.stop_from(was_running)))
822                .collect();
823            stopping
824                .into_iter()
825                .filter_map(|handle| handle.join().ok().flatten())
826                .collect()
827        })
828    }
829}
830
831impl Drop for MemberSet {
832    fn drop(&mut self) {
833        let _ = self.stop_all();
834    }
835}
836
837// ── Starting members ──────────────────────────────────────────────────────────
838
839/// The arguments every member's `mur run` starts with, running in `workdir`.
840fn member_args(member: &PlannedMember, workdir: &Path) -> Vec<String> {
841    vec![
842        "run".to_string(),
843        "--capsule".to_string(),
844        member.capsule.clone(),
845        "--capsule-version".to_string(),
846        member.version.clone(),
847        CAPSULE_SHA256_FLAG.to_string(),
848        member.sha256.clone(),
849        "--workdir".to_string(),
850        workdir.display().to_string(),
851    ]
852}
853
854/// The pass-through flags every member gets. The formation's deadline goes last, as one moment,
855/// so every member ends at the same instant however far apart they started.
856fn pass_through(args: &mut Vec<String>, options: &FormationLaunchOptions) {
857    if options.no_env_file {
858        args.push("--no-env-file".to_string());
859    }
860    if let Some(class) = &options.containment {
861        args.push("--containment".to_string());
862        args.push(class.clone());
863    }
864    if let Some(deadline) = &options.deadline {
865        args.push(INHERITED_DEADLINE_FLAG.to_string());
866        args.push(deadline.at_unix_millis().to_string());
867    }
868}
869
870/// A peer's arguments after the binary: it runs in `workdir`, a directory of its own, and resolves
871/// from the project's stores. Its readiness line carries its tokens, which this launcher reads on
872/// the peer's stdout pipe for the door probe.
873pub(crate) fn peer_args(
874    member: &PlannedMember,
875    options: &FormationLaunchOptions,
876    workdir: &Path,
877) -> Vec<String> {
878    let mut args = member_args(member, workdir);
879    args.extend([
880        STORE_ROOT_FLAG.to_string(),
881        options.project_dir.display().to_string(),
882        "--json".to_string(),
883        child_launch::READINESS_TOKENS_FLAG.to_string(),
884        "--bind".to_string(),
885        PEER_BIND_ADDR.to_string(),
886    ]);
887    pass_through(&mut args, options);
888    args
889}
890
891/// The entry member's arguments after the binary. It runs in the project directory, where its
892/// `--task` is written. Never [`child_launch::READINESS_TOKENS_FLAG`]: the entry's output is the
893/// operator's.
894///
895/// The launch decides the entry member's lifecycle, whatever its manifest declares. Given a task,
896/// it runs that one task and exits (`single` + `exit`), which ends the formation. Given none, it
897/// takes tasks at its door one after another with no idle timeout (`queue` + `sleep`), until the
898/// formation is stopped.
899pub(crate) fn entry_args(member: &PlannedMember, options: &FormationLaunchOptions) -> Vec<String> {
900    let mut args = member_args(member, &options.project_dir);
901    let (task_acceptance, after_task) = match options.task {
902        Some(_) => ("single", "exit"),
903        None => ("queue", "sleep"),
904    };
905    args.extend([
906        "--lifecycle-task-acceptance".to_string(),
907        task_acceptance.to_string(),
908        "--lifecycle-after-task".to_string(),
909        after_task.to_string(),
910    ]);
911    if let Some(task) = &options.task {
912        args.push("--task".to_string());
913        args.push(task.clone());
914    }
915    if options.json {
916        args.push("--json".to_string());
917    }
918    if options.verbose {
919        args.push("--verbose".to_string());
920    }
921    pass_through(&mut args, options);
922    args
923}
924
925/// `<murmur home>/formations`, resolved without creating anything.
926fn formations_dir() -> Result<PathBuf, String> {
927    Ok(crate::state_store::murmur_home_dir()?.join(FORMATIONS_DIR))
928}
929
930/// Every records group a formation's peers record under: the records group of each directory
931/// under `<murmur home>/formations/<frm_id>/`, sorted, for each one that exists.
932///
933/// Reads the directory and creates nothing. A formation with no directory, or a `HOME` that does
934/// not resolve, has no roots. A formation directory that exists and cannot be listed is returned
935/// as a root itself, so the caller's read of it fails and is reported as an unreadable root.
936pub fn formation_member_roots(formation_id: &FormationId) -> Vec<PathBuf> {
937    match formations_dir() {
938        Ok(dir) => formation_member_roots_in(&dir, formation_id),
939        Err(_) => Vec::new(),
940    }
941}
942
943/// [`formation_member_roots`] under `formations_dir` in place of `<murmur home>/formations`. Each
944/// member's records group is still resolved under this host's `~/.murmur/records`.
945pub fn formation_member_roots_in(
946    formations_dir: &Path,
947    formation_id: &FormationId,
948) -> Vec<PathBuf> {
949    let dir = formations_dir.join(formation_id.as_str());
950    let entries = match std::fs::read_dir(&dir) {
951        Ok(entries) => entries,
952        Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
953        Err(_) => return vec![dir],
954    };
955    let mut roots: Vec<PathBuf> = entries
956        .filter_map(Result::ok)
957        .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir()))
958        .filter_map(|entry| crate::records_dir::records_group(&entry.path()).ok())
959        .filter(|root| root.exists())
960        .collect();
961    roots.sort();
962    roots
963}
964
965/// Who launched a formation, from which project: the contents of [`FORMATION_MARKER_FILE`].
966///
967/// The launcher writes it once, before the first peer's directory is made, and nothing rewrites
968/// it. Retention reads it to decide whether a formation directory is this project's to remove and
969/// whether the launcher that made it is still running; a directory without one is never removed.
970#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
971pub struct FormationMarker {
972    /// The roster's project directory, canonicalized when the launcher could, as given otherwise.
973    pub project_dir: PathBuf,
974    /// The launcher's pid.
975    pub launcher_pid: u32,
976    /// The launcher's start token, read as [`crate::running::process_start_token`] reads it, or
977    /// `""` when it could not be read.
978    pub launcher_start: String,
979}
980
981impl FormationMarker {
982    /// The marker for a formation this process launches from `project_dir`.
983    pub fn for_this_launcher(project_dir: &Path) -> Self {
984        let pid = std::process::id();
985        Self {
986            project_dir: canonical_project_dir(project_dir),
987            launcher_pid: pid,
988            launcher_start: crate::running::process_start_token(pid).unwrap_or_default(),
989        }
990    }
991
992    /// The marker in `formation_dir`, or `None` when it is missing or does not parse.
993    pub fn read(formation_dir: &Path) -> Option<Self> {
994        let raw = std::fs::read_to_string(formation_dir.join(FORMATION_MARKER_FILE)).ok()?;
995        serde_json::from_str(&raw).ok()
996    }
997}
998
999/// `project_dir` canonicalized, or as given when it cannot be: the one form a marker records and
1000/// the one ownership is compared in.
1001pub(crate) fn canonical_project_dir(project_dir: &Path) -> PathBuf {
1002    std::fs::canonicalize(project_dir).unwrap_or_else(|_| project_dir.to_path_buf())
1003}
1004
1005/// Write this launch's [`FormationMarker`] into `<formations dir>/<frm_id>/`, holding both
1006/// directories owner-only first. The marker itself is written at `0600`.
1007fn write_formation_marker(options: &FormationLaunchOptions) -> Result<(), String> {
1008    let formations_dir = launch_formations_dir(options)?;
1009    let formation_dir = formations_dir.join(options.formation_id.as_str());
1010    for dir in [formations_dir.as_path(), formation_dir.as_path()] {
1011        crate::state_store::ensure_private_dir(dir, crate::murmur_home::MURMUR_HOME_DIR_MODE)
1012            .map_err(|reason| format!("{}: {reason}", dir.display()))?;
1013    }
1014    let marker = FormationMarker::for_this_launcher(&options.project_dir);
1015    let json = serde_json::to_string(&marker).map_err(|error| error.to_string())?;
1016    crate::murmur_home::write_private_file(
1017        &formation_dir.join(FORMATION_MARKER_FILE),
1018        format!("{json}\n").as_bytes(),
1019    )
1020}
1021
1022/// Remove the ended formation directories of `project_dir` that the entry member's `policy` does
1023/// not keep, and return what went.
1024///
1025/// Called by the launcher once every member of `current` has been stopped and reaped. `current`
1026/// and every formation minted after it are never removed; see [`crate::retention::prune_formations`]
1027/// for what else never is. A `HOME` that does not resolve removes nothing.
1028pub fn prune_ended_formations(
1029    current: &FormationId,
1030    project_dir: &Path,
1031    policy: &murmur_artifact::TraceRetainConfig,
1032) -> Vec<crate::retention::PrunedFormation> {
1033    let (Ok(formations_dir), Ok(running_dir)) =
1034        (formations_dir(), crate::running::running_dir_location())
1035    else {
1036        return Vec::new();
1037    };
1038    prune_ended_formations_in(&formations_dir, &running_dir, current, project_dir, policy)
1039}
1040
1041/// [`prune_ended_formations`] over `formations_dir` and `running_dir` in place of the murmur
1042/// home's.
1043pub fn prune_ended_formations_in(
1044    formations_dir: &Path,
1045    running_dir: &Path,
1046    current: &FormationId,
1047    project_dir: &Path,
1048    policy: &murmur_artifact::TraceRetainConfig,
1049) -> Vec<crate::retention::PrunedFormation> {
1050    // One scan for the whole pass, and only a scan: nothing here unlinks a record.
1051    let records = crate::running::scan(running_dir)
1052        .ok()
1053        .map(|scan| scan.records);
1054    crate::retention::prune_formations(
1055        formations_dir,
1056        current,
1057        project_dir,
1058        policy,
1059        crate::retention::now_ms(),
1060        |id, marker| formation_is_live(id, marker, records.as_deref()),
1061    )
1062}
1063
1064/// Whether any part of formation `id` may still be running: its launcher is not gone, or a running
1065/// record names `id` and its process is not gone.
1066///
1067/// `records` is `None` when the running directory could not be listed, and then every formation
1068/// is live. [`crate::running::ProcessState::Unverified`] is live: a reading that failed says
1069/// nothing about the process.
1070fn formation_is_live(
1071    id: &FormationId,
1072    marker: &FormationMarker,
1073    records: Option<&[crate::running::RunningRecord]>,
1074) -> bool {
1075    let Some(records) = records else {
1076        return true;
1077    };
1078    crate::running::process_state_of(marker.launcher_pid, &marker.launcher_start).is_running()
1079        || records.iter().any(|record| {
1080            record.formation_id.as_ref() == Some(id)
1081                && crate::running::process_state(record).is_running()
1082        })
1083}
1084
1085/// Make `member`'s directory under `formations_dir`, owner-only, and return it.
1086///
1087/// `formations_dir` and the formation's directory are held at `0700` whether or not this call made
1088/// them. The member's own directory is made with a single non-recursive create at `0700`, so a
1089/// path that already exists — a directory, a file or a symlink — is refused rather than reused:
1090/// nothing another process placed there becomes a member's preopen.
1091fn make_member_workdir(
1092    formations_dir: &Path,
1093    formation_id: &FormationId,
1094    member: &str,
1095) -> Result<PathBuf, String> {
1096    use std::os::unix::fs::DirBuilderExt;
1097
1098    let formation_dir = formations_dir.join(formation_id.as_str());
1099    let workdir = formation_dir.join(member);
1100    let failed = |path: &Path, reason: String| {
1101        format!(
1102            "its directory {} could not be made: {}: {reason}",
1103            workdir.display(),
1104            path.display()
1105        )
1106    };
1107    for dir in [formations_dir, formation_dir.as_path()] {
1108        crate::state_store::ensure_private_dir(dir, crate::murmur_home::MURMUR_HOME_DIR_MODE)
1109            .map_err(|reason| failed(dir, reason))?;
1110    }
1111    std::fs::DirBuilder::new()
1112        .mode(crate::murmur_home::MURMUR_HOME_DIR_MODE)
1113        .create(&workdir)
1114        .map_err(|error| failed(&workdir, error.to_string()))?;
1115    // The umask may have narrowed the create's mode further; it can never have widened it.
1116    Ok(workdir)
1117}
1118
1119/// The directory peers' directories are made under for this launch: the override, or
1120/// `<murmur home>/formations` with the home held owner-only first.
1121fn launch_formations_dir(options: &FormationLaunchOptions) -> Result<PathBuf, String> {
1122    match &options.formations_dir {
1123        Some(dir) => Ok(dir.clone()),
1124        None => {
1125            crate::murmur_home::ensure_murmur_home()?;
1126            formations_dir()
1127        }
1128    }
1129}
1130
1131/// What became of one peer's start.
1132enum PeerOutcome {
1133    Ready(ReadyPeer),
1134    Failed(MemberFailureReason),
1135    /// Given up on because another peer failed or the launcher was signalled.
1136    Abandoned,
1137    /// Cut off by the formation's deadline before it was ready.
1138    DeadlineReached,
1139}
1140
1141/// A member's formation channel: the write end the launcher keeps, already holding the member's
1142/// first line, and the read end the member's process inherits.
1143struct Channel {
1144    writer: std::io::PipeWriter,
1145    reader: std::io::PipeReader,
1146}
1147
1148impl Channel {
1149    /// A fresh pipe with `first_line` already written into it. Both ends are close-on-exec, so no
1150    /// process inherits either unless [`ProcessLaunch::inherit_fd`] names it.
1151    fn open(first_line: &str) -> Result<Self, MemberFailureReason> {
1152        let (reader, mut writer) = std::io::pipe().map_err(|error| {
1153            not_started(&format!(
1154                "its formation channel could not be created: {error}"
1155            ))
1156        })?;
1157        writer
1158            .write_all(format!("{first_line}\n").as_bytes())
1159            .map_err(|error| {
1160                not_started(&format!(
1161                    "its formation channel could not be written: {error}"
1162                ))
1163            })?;
1164        Ok(Self { writer, reader })
1165    }
1166
1167    /// The descriptor number the member reads its channel from.
1168    fn fd(&self) -> i32 {
1169        use std::os::fd::AsRawFd;
1170        self.reader.as_raw_fd()
1171    }
1172
1173    /// The environment every member is started with: its formation id, its channel, and
1174    /// [`crate::host_warnings::HOST_WARNINGS_REPORTED_ENV`], because the launcher prints the
1175    /// host-level warnings once before any member starts.
1176    fn member_env(&self, formation_id: &FormationId) -> Vec<(String, String)> {
1177        let (id_name, id_value) = formation_id.env_pair();
1178        vec![
1179            (id_name.to_string(), id_value),
1180            (FORMATION_CHANNEL_ENV.to_string(), self.fd().to_string()),
1181            crate::host_warnings::reported_env_pair(),
1182        ]
1183    }
1184}
1185
1186/// Start one peer and wait until its door answers, it fails, or `abort` is raised.
1187///
1188/// The process, when one was started, is returned whatever the outcome, so the caller owns
1189/// stopping it.
1190fn start_peer(
1191    binary: &Path,
1192    member: &PlannedMember,
1193    first_line: &str,
1194    options: &FormationLaunchOptions,
1195    abort: &AtomicBool,
1196    #[cfg(unix)] mut lifeline: crate::lifeline::MemberLifeline,
1197) -> (Option<MemberProcess>, PeerOutcome) {
1198    let workdir = match launch_formations_dir(options)
1199        .and_then(|dir| make_member_workdir(&dir, &options.formation_id, &member.name))
1200    {
1201        Ok(workdir) => workdir,
1202        Err(error) => return (None, PeerOutcome::Failed(not_started(&error))),
1203    };
1204    let args = peer_args(member, options, &workdir);
1205    let prefix = format!("[{}] ", member.name);
1206    let channel = match Channel::open(first_line) {
1207        Ok(channel) => channel,
1208        Err(reason) => return (None, PeerOutcome::Failed(reason)),
1209    };
1210    let set = channel.member_env(&options.formation_id);
1211    let spawned_at = Instant::now();
1212    // The earlier of its own ready bound and the formation's deadline. Once the formation's
1213    // deadline has passed, whatever ends the wait is that deadline: a member that exits then is
1214    // ending itself at the deadline it was handed.
1215    let deadline = options
1216        .deadline
1217        .map_or(spawned_at + options.ready_timeout, |formation| {
1218            (spawned_at + options.ready_timeout).min(formation.instant())
1219        });
1220    let deadline_passed = || {
1221        options
1222            .deadline
1223            .is_some_and(|formation| formation.left(Instant::now()).is_zero())
1224    };
1225    let started = child_launch::start_process(&ProcessLaunch {
1226        binary,
1227        args: &args,
1228        cwd: None,
1229        env: ProcessEnv::Inherited {
1230            set: &set,
1231            remove: &[FORMATION_PEERS_ENV, SPAWNER_ENV, SPAWNER_LIFELINE_ENV],
1232        },
1233        stdin_line: None,
1234        stderr_prefix: Some(&prefix),
1235        inherit_output: false,
1236        own_process_group: true,
1237        inherit_fd: Some(channel.fd()),
1238        #[cfg(unix)]
1239        lifeline: Some(crate::child_launch::Lifeline::Member(&lifeline)),
1240    });
1241    // The member holds its own copy of the read end now, or never will.
1242    let Channel { writer, reader } = channel;
1243    drop(reader);
1244    let StartedProcess {
1245        mut child,
1246        stderr_tail,
1247        ..
1248    } = match started {
1249        Ok(started) => started,
1250        Err(error) => {
1251            return (
1252                None,
1253                PeerOutcome::Failed(MemberFailureReason::NotStarted {
1254                    error: format!("{}: {error}", binary.display()),
1255                }),
1256            )
1257        }
1258    };
1259    #[cfg(unix)]
1260    lifeline.spawned();
1261    let stdout = child.stdout.take();
1262    let mut process = MemberProcess::new(
1263        &member.name,
1264        child,
1265        true,
1266        options.stop_grace,
1267        writer,
1268        #[cfg(unix)]
1269        Some(lifeline),
1270    );
1271    let Some(stdout) = stdout else {
1272        return (
1273            Some(process),
1274            PeerOutcome::Failed(MemberFailureReason::NotStarted {
1275                error: "the process exposed no standard output".to_string(),
1276            }),
1277        );
1278    };
1279    let lines = child_launch::relay_readiness_line(stdout, AfterReadiness::PrefixedStderr(prefix));
1280
1281    // The readiness line.
1282    let line = loop {
1283        if abort.load(Ordering::SeqCst) {
1284            return (Some(process), PeerOutcome::Abandoned);
1285        }
1286        let now = Instant::now();
1287        if now >= deadline && deadline_passed() {
1288            return (Some(process), PeerOutcome::DeadlineReached);
1289        }
1290        if now >= deadline {
1291            return (
1292                Some(process),
1293                PeerOutcome::Failed(MemberFailureReason::DidNotReport {
1294                    deadline: options.ready_timeout,
1295                }),
1296            );
1297        }
1298        match lines.recv_timeout(POLL_INTERVAL.min(deadline - now)) {
1299            Ok(Some(line)) => break line,
1300            Ok(None) | Err(mpsc::RecvTimeoutError::Disconnected) => {
1301                // Its stdout closes as it exits, so the exit itself is at most a moment behind. A
1302                // process that closed stdout and stayed up is as good as gone, and is ended.
1303                let closed_at = Instant::now();
1304                let status = loop {
1305                    if let Some(status) = process.exit_status_now() {
1306                        break status;
1307                    }
1308                    if closed_at.elapsed() >= EXIT_AFTER_EOF_WAIT {
1309                        process.reap();
1310                        break process.status.unwrap_or_default();
1311                    }
1312                    std::thread::sleep(POLL_INTERVAL);
1313                };
1314                if deadline_passed() {
1315                    return (Some(process), PeerOutcome::DeadlineReached);
1316                }
1317                return (
1318                    Some(process),
1319                    PeerOutcome::Failed(MemberFailureReason::ExitedBeforeReporting {
1320                        status: describe_status(status),
1321                        stderr_tail: stderr_tail.lines_at_end(),
1322                    }),
1323                );
1324            }
1325            Err(mpsc::RecvTimeoutError::Timeout) => {}
1326        }
1327    };
1328
1329    let report = match read_report(&line, &options.formation_id) {
1330        Ok(report) => report,
1331        Err(reason) => return (Some(process), PeerOutcome::Failed(reason)),
1332    };
1333
1334    // The door.
1335    let mut last_error;
1336    loop {
1337        if abort.load(Ordering::SeqCst) {
1338            return (Some(process), PeerOutcome::Abandoned);
1339        }
1340        if let Some(status) = process.exit_status_now() {
1341            if deadline_passed() {
1342                return (Some(process), PeerOutcome::DeadlineReached);
1343            }
1344            return (
1345                Some(process),
1346                PeerOutcome::Failed(MemberFailureReason::ExitedBeforeDoorAnswered {
1347                    status: describe_status(status),
1348                    stderr_tail: stderr_tail.lines_at_end(),
1349                }),
1350            );
1351        }
1352        match door_answers_as(
1353            crate::running::probe_door_session_id(&report.url, report.door_token.as_ref()),
1354            &report.session_id,
1355        ) {
1356            Ok(()) => {
1357                let pid = process.pid;
1358                return (
1359                    Some(process),
1360                    PeerOutcome::Ready(ReadyPeer {
1361                        name: member.name.clone(),
1362                        capsule: member.capsule.clone(),
1363                        version: member.version.clone(),
1364                        pid,
1365                        session_id: report.session_id,
1366                        url: report.url,
1367                        workdir,
1368                        door_token: report.door_token,
1369                    }),
1370                );
1371            }
1372            Err(why) => last_error = why,
1373        }
1374        let now = Instant::now();
1375        if now >= deadline && deadline_passed() {
1376            return (Some(process), PeerOutcome::DeadlineReached);
1377        }
1378        if now >= deadline {
1379            return (
1380                Some(process),
1381                PeerOutcome::Failed(MemberFailureReason::DoorDidNotAnswer {
1382                    url: report.url,
1383                    session_id: report.session_id,
1384                    deadline: options.ready_timeout,
1385                    last_error,
1386                }),
1387            );
1388        }
1389        std::thread::sleep(READY_PROBE_INTERVAL.min(deadline - now));
1390    }
1391}
1392
1393/// Whether a door probe's answer counts as the reported session being ready: `Ok` only when the
1394/// card names `reported`. A card naming another session, or a failed probe, is the `Err` that
1395/// becomes the refusal's `last_error` if the deadline passes.
1396fn door_answers_as(probed: Result<String, String>, reported: &str) -> Result<(), String> {
1397    match probed {
1398        Ok(session_id) if session_id == reported => Ok(()),
1399        Ok(other) => Err(format!("the door answers as session {other}")),
1400        Err(error) => Err(error),
1401    }
1402}
1403
1404/// What a peer's readiness line says about its door.
1405struct Report {
1406    session_id: String,
1407    /// `http://host:port`.
1408    url: String,
1409    door_token: Option<DoorToken>,
1410}
1411
1412/// Read a readiness line, refusing one that names another formation or no door.
1413fn read_report(line: &str, expected: &FormationId) -> Result<Report, MemberFailureReason> {
1414    let report: serde_json::Value =
1415        serde_json::from_str(line).map_err(|error| MemberFailureReason::UnreadableReport {
1416            why: format!("it does not parse as JSON ({error})"),
1417        })?;
1418    let reported = report
1419        .get("formation_id")
1420        .and_then(serde_json::Value::as_str);
1421    if reported != Some(expected.as_str()) {
1422        return Err(MemberFailureReason::WrongFormation {
1423            reported: reported.map(str::to_string),
1424            expected: expected.as_str().to_string(),
1425        });
1426    }
1427    let session_id = report
1428        .get("session_id")
1429        .and_then(serde_json::Value::as_str)
1430        .filter(|session_id| !session_id.is_empty())
1431        .ok_or_else(|| MemberFailureReason::UnreadableReport {
1432            why: "it names no session_id".to_string(),
1433        })?;
1434    let url = report
1435        .get("url")
1436        .and_then(serde_json::Value::as_str)
1437        .unwrap_or_default();
1438    if url.is_empty() {
1439        return Err(MemberFailureReason::ReportedNoDoor);
1440    }
1441    Ok(Report {
1442        session_id: session_id.to_string(),
1443        url: format!("http://{}", url.trim_start_matches("http://")),
1444        door_token: child_launch::readiness_door_token(&report),
1445    })
1446}
1447
1448// ── The launch ────────────────────────────────────────────────────────────────
1449
1450/// Start every peer of `roster` at once and wait for each door to answer.
1451///
1452/// On success the peers are running and ready, and the entry member is not yet started: the caller
1453/// announces the formation, then calls [`RunningFormation::start_entry`]. On failure nothing this
1454/// started is still running.
1455pub fn launch_formation(
1456    roster: &AdmittedRoster,
1457    options: FormationLaunchOptions,
1458) -> Result<RunningFormation, MemberLaunchFailure> {
1459    launch_plan(FormationPlan::from_roster(roster), options)
1460}
1461
1462pub(crate) fn launch_plan(
1463    plan: FormationPlan,
1464    options: FormationLaunchOptions,
1465) -> Result<RunningFormation, MemberLaunchFailure> {
1466    install_panic_hook();
1467    let binary = match child_launch::mur_binary() {
1468        Ok(binary) => binary,
1469        Err(error) => {
1470            return Err(MemberLaunchFailure {
1471                failed: plan
1472                    .peers
1473                    .iter()
1474                    .chain([&plan.entry])
1475                    .map(|member| failure(member, not_started(&error.to_string())))
1476                    .collect(),
1477                stopped: Vec::new(),
1478                interrupted_by: None,
1479                deadline_reached: false,
1480            })
1481        }
1482    };
1483
1484    let authority = match FormationAuthority::generate(&options.formation_id) {
1485        Ok(authority) => authority,
1486        Err(error) => {
1487            return Err(MemberLaunchFailure {
1488                failed: plan
1489                    .peers
1490                    .iter()
1491                    .chain([&plan.entry])
1492                    .map(|member| failure(member, not_started(&error)))
1493                    .collect(),
1494                stopped: Vec::new(),
1495                interrupted_by: None,
1496                deadline_reached: false,
1497            })
1498        }
1499    };
1500    // One per peer and one for the entry member, all before the first spawn: where the
1501    // close-on-exec flag is set after `pipe` returns, a member forked in between would inherit
1502    // another member's write end and keep that member's formation alive.
1503    #[cfg(unix)]
1504    let (peer_lifelines, entry_lifeline) = match member_lifelines(plan.peers.len() + 1) {
1505        Ok(mut lifelines) => {
1506            let entry = lifelines.pop();
1507            (lifelines, entry)
1508        }
1509        Err(error) => {
1510            return Err(MemberLaunchFailure {
1511                failed: plan
1512                    .peers
1513                    .iter()
1514                    .chain([&plan.entry])
1515                    .map(|member| {
1516                        failure(
1517                            member,
1518                            not_started(&format!("its lifeline could not be created: {error}")),
1519                        )
1520                    })
1521                    .collect(),
1522                stopped: Vec::new(),
1523                interrupted_by: None,
1524                deadline_reached: false,
1525            })
1526        }
1527    };
1528    // Read once, so every member, the entry member included, records the same launcher. A
1529    // launcher that cannot read its own start time names none, and `mur stop` then cannot reach
1530    // it through its members.
1531    let launcher = ProcessIdentity::of_this_process();
1532    let first_lines: Vec<String> = plan
1533        .peers
1534        .iter()
1535        .map(|member| plan.first_line(&authority, launcher.as_ref(), &member.name))
1536        .collect();
1537    // Before any peer's directory exists, so no formation directory a launch made is without one.
1538    // A launch with no peers makes no formation directory, and so no marker.
1539    if !plan.peers.is_empty() {
1540        if let Err(reason) = write_formation_marker(&options) {
1541            crate::runtime_err!(
1542                "[mur run] warning: formation {} has no ownership marker ({reason}); its \
1543                 directory will never be removed automatically",
1544                options.formation_id
1545            );
1546        }
1547    }
1548
1549    let abort = AtomicBool::new(false);
1550    let mut outcomes: Vec<Option<(Option<MemberProcess>, PeerOutcome)>> =
1551        plan.peers.iter().map(|_| None).collect();
1552    let mut interrupted_by = caught_signal();
1553    if interrupted_by.is_some() {
1554        abort.store(true, Ordering::SeqCst);
1555    }
1556    std::thread::scope(|scope| {
1557        let (tx, rx) = mpsc::channel();
1558        #[cfg(unix)]
1559        let mut peer_lifelines = peer_lifelines.into_iter();
1560        for (index, member) in plan.peers.iter().enumerate() {
1561            let tx = tx.clone();
1562            let (binary, options, abort) = (&binary, &options, &abort);
1563            let first_line = first_lines[index].as_str();
1564            #[cfg(unix)]
1565            let lifeline = peer_lifelines
1566                .next()
1567                .expect("member_lifelines made one lifeline per peer");
1568            scope.spawn(move || {
1569                let started = start_peer(
1570                    binary,
1571                    member,
1572                    first_line,
1573                    options,
1574                    abort,
1575                    #[cfg(unix)]
1576                    lifeline,
1577                );
1578                let _ = tx.send((index, started));
1579            });
1580        }
1581        drop(tx);
1582        loop {
1583            match rx.recv_timeout(POLL_INTERVAL) {
1584                Ok((index, outcome)) => {
1585                    if matches!(
1586                        outcome.1,
1587                        PeerOutcome::Failed(_) | PeerOutcome::DeadlineReached
1588                    ) {
1589                        abort.store(true, Ordering::SeqCst);
1590                    }
1591                    outcomes[index] = Some(outcome);
1592                }
1593                Err(mpsc::RecvTimeoutError::Timeout) => {}
1594                Err(mpsc::RecvTimeoutError::Disconnected) => break,
1595            }
1596            if interrupted_by.is_none() {
1597                if let Some(signal) = caught_signal() {
1598                    interrupted_by = Some(signal);
1599                    abort.store(true, Ordering::SeqCst);
1600                }
1601            }
1602        }
1603    });
1604
1605    let mut members = MemberSet(Vec::new());
1606    let mut peers = Vec::new();
1607    let mut failed = Vec::new();
1608    let mut deadline_reached = false;
1609    for (member, outcome) in plan.peers.iter().zip(outcomes) {
1610        let Some((process, outcome)) = outcome else {
1611            continue;
1612        };
1613        members.0.extend(process);
1614        match outcome {
1615            PeerOutcome::Ready(peer) => peers.push(peer),
1616            PeerOutcome::Failed(reason) => failed.push(failure(member, reason)),
1617            PeerOutcome::Abandoned => {}
1618            PeerOutcome::DeadlineReached => deadline_reached = true,
1619        }
1620    }
1621
1622    if !failed.is_empty()
1623        || interrupted_by.is_some()
1624        || deadline_reached
1625        || peers.len() != plan.peers.len()
1626    {
1627        let stopped = members.stop_all();
1628        return Err(MemberLaunchFailure {
1629            failed,
1630            stopped,
1631            interrupted_by,
1632            deadline_reached,
1633        });
1634    }
1635
1636    // Every peer's door answers, so every address is known: each peer that calls other peers is
1637    // told where they are.
1638    for member in &mut members.0 {
1639        let addresses = addresses_of(&peers, plan.callees_of(&member.name));
1640        if !addresses.is_empty() {
1641            member.send_line(&render_address_line(&addresses));
1642        }
1643    }
1644
1645    Ok(RunningFormation {
1646        formation_id: options.formation_id.clone(),
1647        entry: plan.entry.clone(),
1648        plan,
1649        peers,
1650        members,
1651        entry_index: None,
1652        #[cfg(unix)]
1653        entry_lifeline,
1654        binary,
1655        options,
1656        authority,
1657        launcher,
1658    })
1659}
1660
1661/// The door of each of `callees` among the ready peers, in roster order.
1662fn addresses_of(ready: &[ReadyPeer], callees: &[String]) -> Vec<FormationPeer> {
1663    ready
1664        .iter()
1665        .filter(|peer| callees.contains(&peer.name))
1666        .map(|peer| FormationPeer {
1667            name: peer.name.clone(),
1668            url: peer.url.clone(),
1669        })
1670        .collect()
1671}
1672
1673/// `count` new lifelines, or the first error creating one.
1674#[cfg(unix)]
1675fn member_lifelines(count: usize) -> std::io::Result<Vec<crate::lifeline::MemberLifeline>> {
1676    (0..count)
1677        .map(|_| crate::lifeline::MemberLifeline::new())
1678        .collect()
1679}
1680
1681fn failure(member: &PlannedMember, reason: MemberFailureReason) -> MemberFailure {
1682    MemberFailure {
1683        name: member.name.clone(),
1684        capsule: member.capsule.clone(),
1685        version: member.version.clone(),
1686        reason,
1687    }
1688}
1689
1690fn not_started(error: &str) -> MemberFailureReason {
1691    MemberFailureReason::NotStarted {
1692        error: error.to_string(),
1693    }
1694}
1695
1696/// A formation whose peers are ready: the one owner of every member it started, and of the
1697/// formation's signing key.
1698///
1699/// Dropping it stops and reaps every member, then drops the key: nothing can mint for this
1700/// formation again.
1701pub struct RunningFormation {
1702    formation_id: FormationId,
1703    entry: PlannedMember,
1704    plan: FormationPlan,
1705    peers: Vec<ReadyPeer>,
1706    /// Declared before `authority`, so the members are stopped and reaped before the key is
1707    /// dropped.
1708    members: MemberSet,
1709    /// The entry member's position in `members`, once started.
1710    entry_index: Option<usize>,
1711    /// The entry member's lifeline, created with the peers' and held here until the entry
1712    /// member is started with it.
1713    #[cfg(unix)]
1714    entry_lifeline: Option<crate::lifeline::MemberLifeline>,
1715    binary: PathBuf,
1716    options: FormationLaunchOptions,
1717    authority: FormationAuthority,
1718    /// This process, as every member's first line names it.
1719    launcher: Option<ProcessIdentity>,
1720}
1721
1722impl std::fmt::Debug for RunningFormation {
1723    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1724        f.debug_struct("RunningFormation")
1725            .field("formation_id", &self.formation_id)
1726            .field("entry", &self.entry.name)
1727            .field("peers", &self.peers)
1728            .finish()
1729    }
1730}
1731
1732impl RunningFormation {
1733    pub fn formation_id(&self) -> &FormationId {
1734        &self.formation_id
1735    }
1736
1737    /// The entry member's roster name.
1738    pub fn entry_name(&self) -> &str {
1739        &self.entry.name
1740    }
1741
1742    /// Every peer, ready, in roster order.
1743    pub fn peers(&self) -> &[ReadyPeer] {
1744        &self.peers
1745    }
1746
1747    /// The members `member` is handed a credential and an address for, in roster order: its
1748    /// roster callees.
1749    pub fn callees_of(&self, member: &str) -> &[String] {
1750        self.plan.callees_of(member)
1751    }
1752
1753    /// The entry member's pid, once started.
1754    pub fn entry_pid(&self) -> Option<u32> {
1755        self.entry_index.map(|index| self.members.0[index].pid)
1756    }
1757
1758    /// Start the entry member, last, with its standard output and error this process's own.
1759    ///
1760    /// On failure every peer has been stopped and reaped.
1761    pub fn start_entry(&mut self) -> Result<u32, MemberLaunchFailure> {
1762        // A deadline that has already fallen starts nothing more.
1763        if self
1764            .options
1765            .deadline
1766            .is_some_and(|deadline| deadline.left(Instant::now()).is_zero())
1767        {
1768            let stopped = self.members.stop_all();
1769            return Err(MemberLaunchFailure {
1770                failed: Vec::new(),
1771                stopped,
1772                interrupted_by: None,
1773                deadline_reached: true,
1774            });
1775        }
1776        let args = entry_args(&self.entry, &self.options);
1777        // Its whole channel is written before it starts: every address it may need is known.
1778        let channel = Channel::open(&self.plan.first_line(
1779            &self.authority,
1780            self.launcher.as_ref(),
1781            &self.entry.name,
1782        ))
1783        .and_then(|mut channel| {
1784            let addresses = addresses_of(&self.peers, self.plan.callees_of(&self.entry.name));
1785            if !addresses.is_empty() {
1786                channel
1787                    .writer
1788                    .write_all(format!("{}\n", render_address_line(&addresses)).as_bytes())
1789                    .map_err(|error| {
1790                        not_started(&format!(
1791                            "its formation channel could not be written: {error}"
1792                        ))
1793                    })?;
1794            }
1795            Ok(channel)
1796        });
1797        let channel = match channel {
1798            Ok(channel) => channel,
1799            Err(reason) => {
1800                let stopped = self.members.stop_all();
1801                return Err(MemberLaunchFailure {
1802                    failed: vec![failure(&self.entry, reason)],
1803                    stopped,
1804                    interrupted_by: None,
1805                    deadline_reached: false,
1806                });
1807            }
1808        };
1809        let set = channel.member_env(&self.formation_id);
1810        let started = child_launch::start_process(&ProcessLaunch {
1811            binary: &self.binary,
1812            args: &args,
1813            cwd: None,
1814            env: ProcessEnv::Inherited {
1815                set: &set,
1816                remove: &[SPAWNER_ENV, FORMATION_PEERS_ENV, SPAWNER_LIFELINE_ENV],
1817            },
1818            stdin_line: None,
1819            stderr_prefix: None,
1820            inherit_output: true,
1821            own_process_group: false,
1822            inherit_fd: Some(channel.fd()),
1823            #[cfg(unix)]
1824            lifeline: self
1825                .entry_lifeline
1826                .as_ref()
1827                .map(crate::child_launch::Lifeline::Member),
1828        });
1829        let Channel { writer, reader } = channel;
1830        drop(reader);
1831        match started {
1832            Ok(started) => {
1833                #[cfg(unix)]
1834                let lifeline = self.entry_lifeline.take().map(|mut lifeline| {
1835                    lifeline.spawned();
1836                    lifeline
1837                });
1838                let process = MemberProcess::new(
1839                    &self.entry.name,
1840                    started.child,
1841                    false,
1842                    self.options.stop_grace,
1843                    writer,
1844                    #[cfg(unix)]
1845                    lifeline,
1846                );
1847                let pid = process.pid;
1848                self.members.0.push(process);
1849                self.entry_index = Some(self.members.0.len() - 1);
1850                Ok(pid)
1851            }
1852            Err(error) => {
1853                let stopped = self.members.stop_all();
1854                Err(MemberLaunchFailure {
1855                    failed: vec![failure(
1856                        &self.entry,
1857                        not_started(&format!("{}: {error}", self.binary.display())),
1858                    )],
1859                    stopped,
1860                    interrupted_by: None,
1861                    deadline_reached: false,
1862                })
1863            }
1864        }
1865    }
1866
1867    /// Wait for the entry member's process to end, or for the launcher to be signalled, then stop
1868    /// and reap every member: every lifeline closed, the entry member's included.
1869    pub fn wait(mut self) -> FormationExit {
1870        let ending = match self.entry_index {
1871            None => caught_signal()
1872                .map(FormationEnding::Signalled)
1873                .unwrap_or(FormationEnding::EntryExited(ExitStatus::default())),
1874            Some(index) => loop {
1875                if let Some(status) = self.members.0[index].exit_status_now() {
1876                    break FormationEnding::EntryExited(status);
1877                }
1878                if let Some(signal) = caught_signal() {
1879                    break FormationEnding::Signalled(signal);
1880                }
1881                std::thread::sleep(POLL_INTERVAL);
1882            },
1883        };
1884        let stopped = self.members.stop_all();
1885        FormationExit { ending, stopped }
1886    }
1887}
1888
1889#[cfg(test)]
1890mod tests {
1891    use super::*;
1892    use std::io::{Read, Write};
1893    use std::net::TcpListener;
1894    use std::sync::atomic::AtomicUsize;
1895    use std::sync::Arc;
1896
1897    /// How long a launch that is expected to succeed may take before the test gives up on it: a
1898    /// guard against a launch that never completes on a loaded host, not a claim about how fast
1899    /// one does.
1900    const LIVENESS_BOUND: Duration = Duration::from_secs(120);
1901
1902    /// The host's 1-, 5- and 15-minute load averages, for failure messages that may be load.
1903    fn load_average() -> String {
1904        std::fs::read_to_string("/proc/loadavg")
1905            .unwrap_or_default()
1906            .split(' ')
1907            .take(3)
1908            .collect::<Vec<_>>()
1909            .join(" ")
1910    }
1911
1912    /// The production bounds, stated: the readiness deadline a delegated child also gets, and a
1913    /// grace that outlasts the runtime's own teardown deadline.
1914    #[test]
1915    fn the_production_bounds_are_180s_and_a_grace_past_the_runtime_teardown() {
1916        assert_eq!(MEMBER_READY_TIMEOUT, Duration::from_secs(180));
1917        assert_eq!(MEMBER_STOP_GRACE, Duration::from_secs(25));
1918        assert!(MEMBER_STOP_GRACE > crate::runtime::TERMINATE_TEARDOWN_DEADLINE);
1919    }
1920
1921    fn member(name: &str) -> PlannedMember {
1922        PlannedMember {
1923            name: name.to_string(),
1924            capsule: name.to_string(),
1925            version: "0.1.0".to_string(),
1926            sha256: "ab".repeat(32),
1927        }
1928    }
1929
1930    /// Every member, peer or entry, is started told its launcher already printed the host-level
1931    /// warnings.
1932    #[test]
1933    fn every_member_is_told_host_warnings_were_reported() {
1934        let channel = Channel::open("first").expect("a channel opens");
1935        let env = channel.member_env(&FormationId::mint());
1936        let values: Vec<&str> = env
1937            .iter()
1938            .filter(|(key, _)| key == crate::host_warnings::HOST_WARNINGS_REPORTED_ENV)
1939            .map(|(_, value)| value.as_str())
1940            .collect();
1941        assert_eq!(values, vec!["1"], "{env:?}");
1942    }
1943
1944    fn options(deadline: Duration) -> FormationLaunchOptions {
1945        let mut options =
1946            FormationLaunchOptions::new(FormationId::mint(), PathBuf::from("/tmp/project"));
1947        options.ready_timeout = deadline;
1948        options.stop_grace = Duration::from_secs(2);
1949        // Peers' directories go under a scratch directory, never the developer's home.
1950        options.formations_dir = Some(tempfile::tempdir().unwrap().keep());
1951        options
1952    }
1953
1954    #[test]
1955    fn member_argv_is_exactly_the_documented_shape() {
1956        let mut options = options(MEMBER_READY_TIMEOUT);
1957        let coder = member("coder");
1958        let workdir = PathBuf::from("/home/u/.murmur/formations/frm_x/coder");
1959        assert_eq!(
1960            peer_args(&coder, &options, &workdir),
1961            [
1962                "run",
1963                "--capsule",
1964                "coder",
1965                "--capsule-version",
1966                "0.1.0",
1967                "--capsule-sha256",
1968                &"ab".repeat(32),
1969                "--workdir",
1970                "/home/u/.murmur/formations/frm_x/coder",
1971                "--store-root",
1972                "/tmp/project",
1973                "--json",
1974                "--readiness-tokens",
1975                "--bind",
1976                "127.0.0.1",
1977            ]
1978        );
1979        options.task = Some("probe".to_string());
1980        options.json = true;
1981        options.verbose = true;
1982        options.no_env_file = true;
1983        options.containment = Some("scoped".to_string());
1984        let entry = entry_args(&member("planner"), &options);
1985        assert_eq!(&entry[7..9], ["--workdir", "/tmp/project"]);
1986        assert_eq!(
1987            &entry[9..],
1988            [
1989                "--lifecycle-task-acceptance",
1990                "single",
1991                "--lifecycle-after-task",
1992                "exit",
1993                "--task",
1994                "probe",
1995                "--json",
1996                "--verbose",
1997                "--no-env-file",
1998                "--containment",
1999                "scoped",
2000            ]
2001        );
2002        assert!(
2003            !entry.iter().any(|arg| arg == "--bind"
2004                || arg == STORE_ROOT_FLAG
2005                || arg == child_launch::READINESS_TOKENS_FLAG),
2006            "{entry:?}"
2007        );
2008        let peer = peer_args(&coder, &options, &workdir);
2009        assert!(peer.ends_with(&[
2010            "--no-env-file".to_string(),
2011            "--containment".to_string(),
2012            "scoped".to_string()
2013        ]));
2014        assert!(!peer.iter().any(|arg| arg == "--task" || arg == "--verbose"));
2015        assert!(
2016            !peer
2017                .iter()
2018                .chain(&entry)
2019                .any(|arg| arg == INHERITED_DEADLINE_FLAG),
2020            "no deadline, no flag: {peer:?} {entry:?}"
2021        );
2022
2023        // Without a task the entry member takes tasks at its door until the formation is
2024        // stopped, and is handed no `--task`; the peers' argv does not change.
2025        options.task = None;
2026        let untasked = entry_args(&member("planner"), &options);
2027        assert_eq!(
2028            &untasked[9..],
2029            [
2030                "--lifecycle-task-acceptance",
2031                "queue",
2032                "--lifecycle-after-task",
2033                "sleep",
2034                "--json",
2035                "--verbose",
2036                "--no-env-file",
2037                "--containment",
2038                "scoped",
2039            ]
2040        );
2041        assert_eq!(peer_args(&coder, &options, &workdir), peer);
2042        options.task = Some("probe".to_string());
2043
2044        // The formation's deadline goes to every member last, as one moment.
2045        let deadline = SessionDeadline::after(
2046            Instant::now(),
2047            std::time::SystemTime::UNIX_EPOCH + Duration::from_millis(1_792_497_600_250),
2048            60,
2049        );
2050        options.deadline = Some(deadline);
2051        let flag = [
2052            INHERITED_DEADLINE_FLAG.to_string(),
2053            "1792497660250".to_string(),
2054        ];
2055        let peer = peer_args(&coder, &options, &workdir);
2056        assert!(peer.ends_with(&flag), "{peer:?}");
2057        let entry = entry_args(&member("planner"), &options);
2058        assert!(entry.ends_with(&flag), "{entry:?}");
2059        assert_eq!(
2060            entry
2061                .iter()
2062                .filter(|arg| *arg == INHERITED_DEADLINE_FLAG)
2063                .count(),
2064            1
2065        );
2066    }
2067
2068    /// Serializes the tests that point `MURMUR_MUR_BINARY` at a stand-in; the variable is
2069    /// process-wide.
2070    fn binary_guard() -> std::sync::MutexGuard<'static, ()> {
2071        crate::formation::FORMATION_ENV_LOCK
2072            .lock()
2073            .unwrap_or_else(|poisoned| poisoned.into_inner())
2074    }
2075
2076    /// A stand-in `mur` that prints `line` (when given), records any `SIGTERM` it is sent in
2077    /// `dir/sigterm`, and runs until its lifeline reads EOF, whatever it is asked to run.
2078    fn stand_in(dir: &Path, line: Option<&str>) -> PathBuf {
2079        let script = dir.join("mur");
2080        let print = match line {
2081            Some(line) => {
2082                std::fs::write(dir.join("line"), format!("{line}\n")).unwrap();
2083                format!("cat '{}'\n", dir.join("line").display())
2084            }
2085            None => String::new(),
2086        };
2087        // Through `/dev/fd`: a POSIX shell's `<&` takes a single-digit descriptor only, and a
2088        // lifeline is numbered wherever `pipe` put it.
2089        let body = format!(
2090            "#!/bin/sh\ntrap 'echo TERM >> \"{dir}/sigterm\"' TERM\n{print}\
2091             cat \"/dev/fd/$MURMUR_FORMATION_LIFELINE\" >/dev/null\n",
2092            dir = dir.display()
2093        );
2094        std::fs::write(&script, body).unwrap();
2095        use std::os::unix::fs::PermissionsExt;
2096        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
2097        script
2098    }
2099
2100    fn plan_of(peer: &str) -> FormationPlan {
2101        FormationPlan {
2102            peers: vec![member(peer)],
2103            entry: member("planner"),
2104            callees: vec![("planner".to_string(), vec![peer.to_string()])],
2105        }
2106    }
2107
2108    /// Launch one peer from a stand-in printing `line`, returning the refusal and the stand-in's
2109    /// pid as the refusal named it.
2110    fn refused(line: Option<&str>, deadline: Duration) -> (MemberLaunchFailure, FormationId) {
2111        let _guard = binary_guard();
2112        let dir = tempfile::tempdir().unwrap();
2113        let options = options(deadline);
2114        let id = options.formation_id.clone();
2115        let line = line.map(|line| line.replace("{formation}", id.as_str()));
2116        std::env::set_var(
2117            child_launch::MUR_BINARY_ENV,
2118            stand_in(dir.path(), line.as_deref()),
2119        );
2120        let result = launch_plan(plan_of("coder"), options);
2121        std::env::remove_var(child_launch::MUR_BINARY_ENV);
2122        match result {
2123            Ok(_) => panic!("the launch was expected to be refused"),
2124            Err(failure) => (failure, id),
2125        }
2126    }
2127
2128    fn assert_reaped(failure: &MemberLaunchFailure) {
2129        assert_eq!(failure.stopped.len(), 1, "{failure:?}");
2130        let pid = failure.stopped[0].pid;
2131        assert_eq!(failure.stopped[0].name, "coder");
2132        assert!(
2133            !crate::running::pid_is_alive(pid),
2134            "the stand-in {pid} is still running"
2135        );
2136        assert!(
2137            !Path::new(&format!("/proc/{pid}")).exists() || cfg!(not(target_os = "linux")),
2138            "the stand-in {pid} was not reaped"
2139        );
2140    }
2141
2142    fn only_reason(failure: &MemberLaunchFailure) -> &MemberFailureReason {
2143        assert_eq!(failure.failed.len(), 1, "{failure:?}");
2144        assert_eq!(failure.failed[0].name, "coder");
2145        &failure.failed[0].reason
2146    }
2147
2148    /// A port with nothing listening on it: claimed, and never bound.
2149    fn silent_port() -> u16 {
2150        crate::pinned_port::claim()
2151    }
2152
2153    #[test]
2154    fn a_door_nothing_answers_on_is_refused_at_the_deadline() {
2155        let deadline = Duration::from_secs(10);
2156        let port = silent_port();
2157        let started = Instant::now();
2158        let (failure, _) = refused(
2159            Some(&format!(
2160                r#"{{"formation_id":"{{formation}}","session_id":"ses_standin","url":"localhost:{port}"}}"#
2161            )),
2162            deadline,
2163        );
2164        assert!(started.elapsed() >= deadline);
2165        match only_reason(&failure) {
2166            MemberFailureReason::DoorDidNotAnswer {
2167                url, session_id, ..
2168            } => {
2169                assert_eq!(url, &format!("http://localhost:{port}"));
2170                assert_eq!(session_id, "ses_standin");
2171            }
2172            // One deadline covers the readiness line and the door, so a stand-in that is not
2173            // scheduled in time is refused before its door is ever probed.
2174            MemberFailureReason::DidNotReport { .. } => panic!(
2175                "the stand-in's readiness line did not arrive within {deadline:?} (load average {}); \
2176                 host load is the likely cause, since the stand-in prints it at once: {failure:?}",
2177                load_average()
2178            ),
2179            other => panic!("{other:?}"),
2180        }
2181        let message = failure.to_string();
2182        assert!(
2183            message.contains("did not answer as session ses_standin"),
2184            "{message}"
2185        );
2186        assert!(message.contains("'coder' (coder@0.1.0)"), "{message}");
2187        assert_reaped(&failure);
2188    }
2189
2190    /// A formation deadline that falls while a peer has not reported ends the launch as the
2191    /// deadline, well before the peer's own ready bound: the peer is reaped, no member is failed
2192    /// for it, and no entry member is started.
2193    #[test]
2194    fn a_formation_deadline_before_the_entry_member_ends_the_launch_at_the_deadline() {
2195        let _guard = binary_guard();
2196        let dir = tempfile::tempdir().unwrap();
2197        let mut options = options(Duration::from_secs(60));
2198        let started = Instant::now();
2199        options.deadline = Some(SessionDeadline::after(
2200            started,
2201            std::time::SystemTime::now(),
2202            2,
2203        ));
2204        std::env::set_var(child_launch::MUR_BINARY_ENV, stand_in(dir.path(), None));
2205        let result = launch_plan(plan_of("coder"), options);
2206        std::env::remove_var(child_launch::MUR_BINARY_ENV);
2207        let Err(failure) = result else {
2208            panic!("the launch was expected to end at the deadline");
2209        };
2210        let took = started.elapsed();
2211        assert!(took >= Duration::from_secs(2), "{took:?}");
2212        assert!(took < Duration::from_secs(20), "{took:?}: {failure:?}");
2213        assert!(failure.deadline_reached, "{failure:?}");
2214        assert!(failure.failed.is_empty(), "{failure:?}");
2215        assert_eq!(failure.interrupted_by, None);
2216        assert_reaped(&failure);
2217        assert_eq!(
2218            failure.to_string(),
2219            format!(
2220                "the formation reached its deadline before its entry member started\n  \
2221                 stopped: coder (pid {})",
2222                failure.stopped[0].pid
2223            )
2224        );
2225    }
2226
2227    /// `start_entry` after the formation's deadline starts nothing and reports the deadline.
2228    #[test]
2229    fn start_entry_after_the_formation_deadline_starts_nothing() {
2230        let _guard = binary_guard();
2231        let dir = tempfile::tempdir().unwrap();
2232        let mut options = options(Duration::from_secs(60));
2233        let started = Instant::now();
2234        options.deadline = Some(SessionDeadline::after(
2235            started,
2236            std::time::SystemTime::now(),
2237            1,
2238        ));
2239        std::env::set_var(child_launch::MUR_BINARY_ENV, stand_in(dir.path(), None));
2240        let plan = FormationPlan {
2241            peers: Vec::new(),
2242            entry: member("planner"),
2243            callees: Vec::new(),
2244        };
2245        let launched = launch_plan(plan, options);
2246        std::env::remove_var(child_launch::MUR_BINARY_ENV);
2247        let mut formation = launched.expect("a formation with no peers launches at once");
2248        std::thread::sleep(Duration::from_millis(1_100));
2249        let failure = formation
2250            .start_entry()
2251            .expect_err("the deadline has fallen");
2252        assert!(failure.deadline_reached, "{failure:?}");
2253        assert!(
2254            failure.failed.is_empty() && failure.stopped.is_empty(),
2255            "{failure:?}"
2256        );
2257        assert_eq!(formation.entry_pid(), None);
2258    }
2259
2260    /// A one-route door on `127.0.0.1`, served forever: each connection's request is read, then
2261    /// `GET /.well-known/agent-card.json` is answered with a card naming the session `answer`
2262    /// returns for that request, or with `{}` when it returns `None`.
2263    fn card_door(mut answer: impl FnMut() -> Option<&'static str> + Send + 'static) -> u16 {
2264        let listener = TcpListener::bind("127.0.0.1:0").unwrap();
2265        let port = listener.local_addr().unwrap().port();
2266        std::thread::spawn(move || {
2267            for stream in listener.incoming().flatten() {
2268                let mut stream = stream;
2269                let mut buffer = [0u8; 4096];
2270                let _ = stream.read(&mut buffer);
2271                let body = match answer() {
2272                    Some(session_id) => serde_json::json!({
2273                        "name": "coder",
2274                        "capabilities": {"extensions": [{
2275                            "uri": crate::identity::CAPSULE_EXTENSION_URI,
2276                            "params": {"sessionId": session_id},
2277                        }]},
2278                    })
2279                    .to_string(),
2280                    None => "{}".to_string(),
2281                };
2282                let _ = write!(
2283                    stream,
2284                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\
2285                     connection: close\r\n\r\n{body}",
2286                    body.len()
2287                );
2288            }
2289        });
2290        port
2291    }
2292
2293    /// A door answering with a card naming `session_id` from `answer_after` on, and with a card
2294    /// naming no session before then.
2295    fn door(session_id: &'static str, answer_after: Duration) -> u16 {
2296        let opened = Instant::now();
2297        card_door(move || (opened.elapsed() >= answer_after).then_some(session_id))
2298    }
2299
2300    /// How many card requests [`door_naming_another_session_first`] answers as another session.
2301    const WRONG_ANSWERS: usize = 3;
2302
2303    /// A door that answers as `ses_somebody_else` to its first [`WRONG_ANSWERS`] card requests and
2304    /// as `ses_standin` to every one after, with the count of requests it has read. The count is
2305    /// raised before the answer is written, so it covers every answer a client has seen.
2306    fn door_naming_another_session_first() -> (u16, Arc<AtomicUsize>) {
2307        let served = Arc::new(AtomicUsize::new(0));
2308        let counted = Arc::clone(&served);
2309        let port = card_door(move || {
2310            if counted.fetch_add(1, Ordering::SeqCst) < WRONG_ANSWERS {
2311                Some("ses_somebody_else")
2312            } else {
2313                Some("ses_standin")
2314            }
2315        });
2316        (port, served)
2317    }
2318
2319    /// The launcher probes past every answer naming another session and is ready only on the one
2320    /// naming the reported session. Accepting any answer would stop at the door's first request.
2321    #[test]
2322    fn a_door_naming_another_session_is_not_ready() {
2323        let _guard = binary_guard();
2324        let (port, served) = door_naming_another_session_first();
2325        let dir = tempfile::tempdir().unwrap();
2326        let options = options(LIVENESS_BOUND);
2327        let line = format!(
2328            r#"{{"formation_id":"{}","session_id":"ses_standin","url":"localhost:{port}"}}"#,
2329            options.formation_id
2330        );
2331        std::env::set_var(
2332            child_launch::MUR_BINARY_ENV,
2333            stand_in(dir.path(), Some(&line)),
2334        );
2335        let result = launch_plan(plan_of("coder"), options);
2336        std::env::remove_var(child_launch::MUR_BINARY_ENV);
2337        let formation = match result {
2338            Ok(formation) => formation,
2339            Err(failure) => match only_reason(&failure) {
2340                MemberFailureReason::DidNotReport { .. } => panic!(
2341                    "the stand-in's readiness line did not arrive within {LIVENESS_BOUND:?} \
2342                     (load average {}); host load is the likely cause: {failure:?}",
2343                    load_average()
2344                ),
2345                _ => panic!(
2346                    "the launch was refused although the door answered as ses_standin from its \
2347                     request {} on: {failure:?}",
2348                    WRONG_ANSWERS + 1
2349                ),
2350            },
2351        };
2352        let peer = &formation.peers()[0];
2353        assert_eq!(peer.session_id, "ses_standin");
2354        let served = served.load(Ordering::SeqCst);
2355        assert!(
2356            served > WRONG_ANSWERS,
2357            "an answer naming another session was counted as ready: the door served {served} \
2358             request(s), and its first {WRONG_ANSWERS} named ses_somebody_else"
2359        );
2360        let pid = peer.pid;
2361        drop(formation);
2362        assert!(
2363            !crate::running::pid_is_alive(pid),
2364            "dropping the owner stops the peer"
2365        );
2366    }
2367
2368    #[test]
2369    fn a_door_counts_as_ready_only_when_it_answers_as_the_reported_session() {
2370        let port = door("ses_somebody_else", Duration::ZERO);
2371        let probed = crate::running::probe_door_session_id(&format!("localhost:{port}"), None);
2372        match door_answers_as(probed, "ses_standin") {
2373            Err(why) => assert!(
2374                why.contains("the door answers as session ses_somebody_else"),
2375                "{why}"
2376            ),
2377            Ok(()) => panic!("a door answering as ses_somebody_else counted as ses_standin"),
2378        }
2379        assert_eq!(
2380            door_answers_as(Ok("ses_standin".to_string()), "ses_standin"),
2381            Ok(())
2382        );
2383        assert_eq!(
2384            door_answers_as(Err("connection refused".to_string()), "ses_standin"),
2385            Err("connection refused".to_string())
2386        );
2387    }
2388
2389    #[test]
2390    fn an_empty_url_is_refused_as_no_door() {
2391        let (failure, _) = refused(
2392            Some(r#"{"formation_id":"{formation}","session_id":"ses_standin","url":""}"#),
2393            Duration::from_secs(10),
2394        );
2395        assert_eq!(only_reason(&failure), &MemberFailureReason::ReportedNoDoor);
2396        assert!(failure.to_string().contains("it reported no door"));
2397        assert_reaped(&failure);
2398    }
2399
2400    #[test]
2401    fn a_wrong_formation_or_none_is_refused_naming_what_was_reported() {
2402        let other = FormationId::mint();
2403        let (failure, id) = refused(
2404            Some(&format!(
2405                r#"{{"formation_id":"{}","session_id":"ses_standin","url":"localhost:1"}}"#,
2406                other.as_str()
2407            )),
2408            Duration::from_secs(10),
2409        );
2410        assert_eq!(
2411            only_reason(&failure),
2412            &MemberFailureReason::WrongFormation {
2413                reported: Some(other.as_str().to_string()),
2414                expected: id.as_str().to_string(),
2415            }
2416        );
2417        assert!(failure
2418            .to_string()
2419            .contains(&format!("it reported formation {other} instead of {id}")));
2420        assert_reaped(&failure);
2421
2422        let (failure, id) = refused(
2423            Some(r#"{"session_id":"ses_standin","url":"localhost:1"}"#),
2424            Duration::from_secs(10),
2425        );
2426        assert!(failure
2427            .to_string()
2428            .contains(&format!("it reported no formation instead of {id}")));
2429        assert_reaped(&failure);
2430    }
2431
2432    #[test]
2433    fn no_line_at_all_is_refused_at_the_deadline() {
2434        let started = Instant::now();
2435        let (failure, _) = refused(None, Duration::from_millis(600));
2436        assert!(started.elapsed() >= Duration::from_millis(600));
2437        assert_eq!(
2438            only_reason(&failure),
2439            &MemberFailureReason::DidNotReport {
2440                deadline: Duration::from_millis(600)
2441            }
2442        );
2443        assert_reaped(&failure);
2444    }
2445
2446    #[test]
2447    fn a_door_that_starts_answering_before_the_deadline_is_ready() {
2448        let _guard = binary_guard();
2449        let port = door("ses_standin", Duration::from_millis(400));
2450        let dir = tempfile::tempdir().unwrap();
2451        let options = options(Duration::from_secs(20));
2452        let line = format!(
2453            r#"{{"formation_id":"{}","session_id":"ses_standin","url":"localhost:{port}"}}"#,
2454            options.formation_id
2455        );
2456        std::env::set_var(
2457            child_launch::MUR_BINARY_ENV,
2458            stand_in(dir.path(), Some(&line)),
2459        );
2460        let result = launch_plan(plan_of("coder"), options);
2461        std::env::remove_var(child_launch::MUR_BINARY_ENV);
2462        let formation = result.expect("the door answered before the deadline");
2463        assert_eq!(formation.peers().len(), 1);
2464        let peer = &formation.peers()[0];
2465        assert_eq!(peer.session_id, "ses_standin");
2466        assert_eq!(peer.url, format!("http://localhost:{port}"));
2467        assert_eq!(formation.callees_of("planner"), ["coder"]);
2468        assert!(formation.callees_of("coder").is_empty());
2469        let pid = peer.pid;
2470        assert!(crate::running::pid_is_alive(pid));
2471        let dropped = Instant::now();
2472        drop(formation);
2473        assert!(
2474            !crate::running::pid_is_alive(pid),
2475            "dropping the owner stops the peer"
2476        );
2477        assert!(
2478            dropped.elapsed() < Duration::from_secs(2),
2479            "the peer ended on its lifeline's EOF, inside the grace: {:?}",
2480            dropped.elapsed()
2481        );
2482        assert!(
2483            !dir.path().join("sigterm").exists(),
2484            "the launcher sent the peer SIGTERM"
2485        );
2486    }
2487
2488    /// A member that does not end on EOF is killed with its process group once the grace has run.
2489    #[test]
2490    fn a_member_deaf_to_its_lifeline_is_killed_after_the_grace() {
2491        let _guard = binary_guard();
2492        let port = door("ses_standin", Duration::ZERO);
2493        let dir = tempfile::tempdir().unwrap();
2494        let mut options = options(Duration::from_secs(20));
2495        options.stop_grace = Duration::from_millis(600);
2496        let line = format!(
2497            r#"{{"formation_id":"{}","session_id":"ses_standin","url":"localhost:{port}"}}"#,
2498            options.formation_id
2499        );
2500        let script = dir.path().join("mur");
2501        std::fs::write(dir.path().join("line"), format!("{line}\n")).unwrap();
2502        std::fs::write(
2503            &script,
2504            format!(
2505                "#!/bin/sh\ncat '{}'\nexec sleep 600\n",
2506                dir.path().join("line").display()
2507            ),
2508        )
2509        .unwrap();
2510        use std::os::unix::fs::PermissionsExt;
2511        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
2512        std::env::set_var(child_launch::MUR_BINARY_ENV, &script);
2513        let result = launch_plan(plan_of("coder"), options);
2514        std::env::remove_var(child_launch::MUR_BINARY_ENV);
2515        let formation = result.expect("the door answers");
2516        let pid = formation.peers()[0].pid;
2517        let dropped = Instant::now();
2518        drop(formation);
2519        assert!(dropped.elapsed() >= Duration::from_millis(600));
2520        assert!(!crate::running::pid_is_alive(pid));
2521    }
2522
2523    /// Every member reads exactly its own bundle on the descriptor `MURMUR_FORMATION_CHANNEL`
2524    /// names: a token per callee and nothing about anyone else, then — once every peer is ready —
2525    /// its callees' doors. The entry member gets both lines before it starts, and an edge into it
2526    /// is served to nobody. No token is in any member's argv or environment.
2527    #[test]
2528    fn each_member_reads_only_its_own_credentials_and_callees_on_its_channel() {
2529        use crate::formation_credentials::{parse_address_line, FormationBundle};
2530        let _guard = binary_guard();
2531        let dir = tempfile::tempdir().unwrap();
2532        let coder_port = door("ses_coder", Duration::ZERO);
2533        let reviewer_port = door("ses_reviewer", Duration::ZERO);
2534        let options = options(Duration::from_secs(20));
2535        let id = options.formation_id.clone();
2536        let line = |session: &str, port: u16| {
2537            format!(
2538                r#"{{"formation_id":"{id}","session_id":"{session}","url":"localhost:{port}"}}"#
2539            )
2540        };
2541        let out = dir.path().display().to_string();
2542        let script = dir.path().join("mur");
2543        std::fs::write(
2544            &script,
2545            format!(
2546                "#!/bin/sh\n\
2547                 case \" $* \" in\n\
2548                 *' --capsule coder '*) name=coder; line='{coder}';;\n\
2549                 *' --capsule reviewer '*) name=reviewer; line='{reviewer}';;\n\
2550                 *) name=planner; line='';;\n\
2551                 esac\n\
2552                 env > '{out}/'$name.env\n\
2553                 printf '%s\\n' \"$*\" > '{out}/'$name.argv\n\
2554                 cat /dev/fd/$MURMUR_FORMATION_CHANNEL >> '{out}/'$name.channel &\n\
2555                 [ -n \"$line\" ] && printf '%s\\n' \"$line\"\n\
2556                 exec sleep 600\n",
2557                coder = line("ses_coder", coder_port),
2558                reviewer = line("ses_reviewer", reviewer_port),
2559            ),
2560        )
2561        .unwrap();
2562        use std::os::unix::fs::PermissionsExt;
2563        std::fs::set_permissions(&script, std::fs::Permissions::from_mode(0o755)).unwrap();
2564        std::env::set_var(child_launch::MUR_BINARY_ENV, &script);
2565        let plan = FormationPlan {
2566            peers: vec![member("coder"), member("reviewer")],
2567            entry: member("planner"),
2568            // `reviewer` calls no one, so it is absent; no plan has a callee that is the entry
2569            // member, because admission never admits an edge into it.
2570            callees: vec![
2571                ("planner".to_string(), vec!["coder".to_string()]),
2572                ("coder".to_string(), vec!["reviewer".to_string()]),
2573            ],
2574        };
2575        let result = launch_plan(plan, options);
2576        let mut formation = result.expect("both stand-ins came up");
2577        formation.start_entry().expect("the entry stand-in starts");
2578        std::env::remove_var(child_launch::MUR_BINARY_ENV);
2579
2580        let read_lines = |name: &str, count: usize| -> Vec<String> {
2581            let path = dir.path().join(format!("{name}.channel"));
2582            let deadline = Instant::now() + Duration::from_secs(10);
2583            loop {
2584                let lines: Vec<String> = std::fs::read_to_string(&path)
2585                    .unwrap_or_default()
2586                    .lines()
2587                    .map(str::to_string)
2588                    .collect();
2589                if lines.len() >= count || Instant::now() >= deadline {
2590                    return lines;
2591                }
2592                std::thread::sleep(POLL_INTERVAL);
2593            }
2594        };
2595        let coder = read_lines("coder", 2);
2596        let planner = read_lines("planner", 2);
2597        let reviewer = read_lines("reviewer", 1);
2598        std::thread::sleep(Duration::from_millis(300));
2599        assert_eq!(read_lines("reviewer", 1).len(), 1, "reviewer calls nobody");
2600
2601        let bundle = |line: &str| FormationBundle::parse_line(line).expect("a member's bundle");
2602        let names = |bundle: &FormationBundle| -> Vec<String> {
2603            bundle.calls.iter().map(|(name, _)| name.clone()).collect()
2604        };
2605        let (coder_bundle, planner_bundle, reviewer_bundle) =
2606            (bundle(&coder[0]), bundle(&planner[0]), bundle(&reviewer[0]));
2607        assert_eq!(coder_bundle.member, "coder");
2608        assert_eq!(names(&coder_bundle), ["reviewer"]);
2609        assert_eq!(names(&planner_bundle), ["coder"]);
2610        assert!(names(&reviewer_bundle).is_empty());
2611        // Every member, the entry member included, is told the one launcher that started it:
2612        // this test process.
2613        let launcher = crate::running::ProcessIdentity::of_this_process();
2614        assert!(launcher.is_some());
2615        for bundle in [&coder_bundle, &planner_bundle, &reviewer_bundle] {
2616            assert_eq!(bundle.formation_id, id);
2617            assert_eq!(bundle.verify_key, coder_bundle.verify_key);
2618            assert_eq!(bundle.launcher, launcher, "{}", bundle.member);
2619        }
2620        // Only the entry member is told it is the entry member; a peer's line has no key at all.
2621        assert!(planner_bundle.entry);
2622        assert!(!coder_bundle.entry && !reviewer_bundle.entry);
2623        assert!(planner[0].contains("\"entry\":true"), "{planner:?}");
2624        assert!(
2625            !coder[0].contains("\"entry\"") && !reviewer[0].contains("\"entry\""),
2626            "{coder:?} {reviewer:?}"
2627        );
2628        assert!(!planner.join("\n").contains("reviewer"), "{planner:?}");
2629        let addresses = |line: &str| -> Vec<(String, String)> {
2630            parse_address_line(line)
2631                .unwrap()
2632                .into_iter()
2633                .map(|peer| (peer.name, peer.url))
2634                .collect()
2635        };
2636        assert_eq!(
2637            addresses(&coder[1]),
2638            [(
2639                "reviewer".to_string(),
2640                format!("http://localhost:{reviewer_port}")
2641            )]
2642        );
2643        assert_eq!(
2644            addresses(&planner[1]),
2645            [(
2646                "coder".to_string(),
2647                format!("http://localhost:{coder_port}")
2648            )]
2649        );
2650
2651        for name in ["coder", "reviewer", "planner"] {
2652            let env = std::fs::read_to_string(dir.path().join(format!("{name}.env"))).unwrap();
2653            let argv = std::fs::read_to_string(dir.path().join(format!("{name}.argv"))).unwrap();
2654            assert!(!env.contains("mft1.") && !argv.contains("mft1."), "{name}");
2655            assert!(!env.contains(FORMATION_PEERS_ENV), "{name}: {env}");
2656            assert!(
2657                env.contains(&format!("{FORMATION_CHANNEL_ENV}=")),
2658                "{name}: {env}"
2659            );
2660        }
2661        let pids: Vec<u32> = formation.peers().iter().map(|peer| peer.pid).collect();
2662        drop(formation);
2663        for pid in pids {
2664            assert!(!crate::running::pid_is_alive(pid), "{pid}");
2665        }
2666    }
2667
2668    #[test]
2669    fn a_registered_member_is_killed_by_the_panic_hooks_sweep() {
2670        use std::os::unix::process::CommandExt;
2671        // The sweep kills every member this process registered, so no other launch may be live.
2672        let _guard = binary_guard();
2673        let mut child = std::process::Command::new("sleep")
2674            .arg("600")
2675            .process_group(0)
2676            .spawn()
2677            .unwrap();
2678        let slot = RegistrySlot::register(child.id(), true);
2679        assert!(slot.0.is_some());
2680        kill_registered_members();
2681        let status = child.wait().unwrap();
2682        use std::os::unix::process::ExitStatusExt;
2683        assert_eq!(status.signal(), Some(libc::SIGKILL));
2684        drop(slot);
2685        assert!(MEMBER_REGISTRY
2686            .iter()
2687            .all(|slot| slot.load(Ordering::SeqCst) != i64::from(child.id())));
2688    }
2689
2690    #[test]
2691    fn a_launch_failure_names_each_member_and_every_stopped_one() {
2692        let failure = MemberLaunchFailure {
2693            failed: vec![failure(
2694                &member("broken"),
2695                MemberFailureReason::ExitedBeforeReporting {
2696                    status: "status 1".to_string(),
2697                    stderr_tail: vec!["error[E-RUN-008]: missing".to_string()],
2698                },
2699            )],
2700            stopped: vec![
2701                StoppedMember {
2702                    name: "coder".to_string(),
2703                    pid: 7,
2704                },
2705                StoppedMember {
2706                    name: "reviewer".to_string(),
2707                    pid: 8,
2708                },
2709            ],
2710            interrupted_by: None,
2711            deadline_reached: false,
2712        };
2713        assert_eq!(
2714            failure.to_string(),
2715            "the formation could not be launched\n  'broken' (broken@0.1.0): its process exited \
2716             with status 1 before reporting; its last stderr lines were:\n      \
2717             error[E-RUN-008]: missing\n  stopped: coder (pid 7), reviewer (pid 8)"
2718        );
2719    }
2720
2721    #[test]
2722    fn ready_peer_debug_prints_no_door_token() {
2723        let peer = ReadyPeer {
2724            name: "coder".to_string(),
2725            capsule: "coder".to_string(),
2726            version: "1.2.0".to_string(),
2727            pid: 1,
2728            session_id: "ses_x".to_string(),
2729            url: "http://localhost:1".to_string(),
2730            workdir: PathBuf::from("/home/u/.murmur/formations/frm_x/coder"),
2731            door_token: Some(DoorToken::new("mdt1.secretpayload.mac".to_string())),
2732        };
2733        let debug = format!("{peer:?}");
2734        assert!(!debug.contains("secretpayload"), "{debug}");
2735        assert!(debug.contains("<redacted>"), "{debug}");
2736    }
2737
2738    /// A peer's directory is `<home>/formations/<frm_id>/<member>`, every level owner-only.
2739    #[test]
2740    fn a_member_directory_is_made_owner_only_under_its_formation() {
2741        use std::os::unix::fs::PermissionsExt;
2742
2743        let home = tempfile::tempdir().unwrap();
2744        let formations = home.path().join(FORMATIONS_DIR);
2745        let id = FormationId::mint();
2746        let workdir = make_member_workdir(&formations, &id, "worker").unwrap();
2747        assert_eq!(workdir, formations.join(id.as_str()).join("worker"));
2748        for dir in [&formations, &formations.join(id.as_str()), &workdir] {
2749            let mode = std::fs::metadata(dir).unwrap().permissions().mode() & 0o777;
2750            assert_eq!(mode, 0o700, "{}", dir.display());
2751        }
2752    }
2753
2754    /// Nothing already at a member's path becomes its preopen: a directory, a file or a symlink
2755    /// there refuses the member, naming the path.
2756    #[test]
2757    fn an_existing_member_path_is_refused_and_named() {
2758        let home = tempfile::tempdir().unwrap();
2759        let formations = home.path().join(FORMATIONS_DIR);
2760        let id = FormationId::mint();
2761        make_member_workdir(&formations, &id, "worker").unwrap();
2762        let again = make_member_workdir(&formations, &id, "worker").unwrap_err();
2763        let path = formations.join(id.as_str()).join("worker");
2764        assert!(again.contains(&path.display().to_string()), "{again}");
2765        std::os::unix::fs::symlink(home.path(), formations.join(id.as_str()).join("linked"))
2766            .unwrap();
2767        assert!(make_member_workdir(&formations, &id, "linked").is_err());
2768    }
2769
2770    /// A peer whose directory cannot be made is not started, and the launch is refused naming it.
2771    #[test]
2772    fn a_peer_whose_directory_cannot_be_made_refuses_the_launch() {
2773        let _guard = binary_guard();
2774        let dir = tempfile::tempdir().unwrap();
2775        let options = options(Duration::from_secs(5));
2776        let formations = options.formations_dir.clone().unwrap();
2777        let taken = formations.join(options.formation_id.as_str()).join("coder");
2778        std::fs::create_dir_all(&taken).unwrap();
2779        std::env::set_var(child_launch::MUR_BINARY_ENV, stand_in(dir.path(), None));
2780        let result = launch_plan(plan_of("coder"), options);
2781        std::env::remove_var(child_launch::MUR_BINARY_ENV);
2782        let Err(failure) = result else {
2783            panic!("the launch was expected to be refused");
2784        };
2785        assert!(failure.stopped.is_empty(), "{failure:?}");
2786        match only_reason(&failure) {
2787            MemberFailureReason::NotStarted { error } => {
2788                assert!(error.contains(&taken.display().to_string()), "{error}")
2789            }
2790            other => panic!("expected NotStarted, got {other:?}"),
2791        }
2792    }
2793
2794    /// A member's directory is `formations/<frm_id>/<member>`, and the roots `mur trace show
2795    /// frm_<id>` searches are the records group of each member directory that has one, sorted,
2796    /// and none for a formation with no directory.
2797    #[test]
2798    fn member_roots_are_the_records_groups_of_member_directories() {
2799        let home = tempfile::tempdir().unwrap();
2800        crate::murmur_home::run_with_home(
2801            "formation_launch::tests::member_roots_inner",
2802            home.path(),
2803        );
2804    }
2805
2806    #[test]
2807    #[ignore = "run by member_roots_are_the_records_groups_of_member_directories"]
2808    fn member_roots_inner() {
2809        if !crate::murmur_home::in_scratch_home() {
2810            return;
2811        }
2812        assert!(formation_member_roots(&FormationId::mint()).is_empty());
2813
2814        let id = FormationId::mint();
2815        let home = PathBuf::from(std::env::var_os("HOME").unwrap());
2816        let formations = home.join(".murmur").join(FORMATIONS_DIR);
2817        for member in ["worker", "reviewer", "idle"] {
2818            assert_eq!(
2819                make_member_workdir(&formations, &id, member).unwrap(),
2820                formations.join(id.as_str()).join(member)
2821            );
2822        }
2823        let group = |member: &str| {
2824            crate::records_dir::records_group(&formations.join(id.as_str()).join(member)).unwrap()
2825        };
2826        for member in ["worker", "reviewer"] {
2827            std::fs::create_dir_all(group(member)).unwrap();
2828        }
2829        assert_eq!(
2830            formation_member_roots_in(&formations, &id),
2831            [group("reviewer"), group("worker")]
2832        );
2833        assert!(group("worker").starts_with(home.join(".murmur/records")));
2834    }
2835
2836    /// The marker is three keys, written and read back unchanged.
2837    #[test]
2838    fn formation_marker_round_trips_as_three_keys() {
2839        let marker = FormationMarker {
2840            project_dir: PathBuf::from("/srv/project"),
2841            launcher_pid: 4242,
2842            launcher_start: "12345".to_string(),
2843        };
2844        let value = serde_json::to_value(&marker).unwrap();
2845        assert_eq!(
2846            value,
2847            serde_json::json!({
2848                "project_dir": "/srv/project",
2849                "launcher_pid": 4242,
2850                "launcher_start": "12345",
2851            })
2852        );
2853        let dir = tempfile::tempdir().unwrap();
2854        std::fs::write(dir.path().join(FORMATION_MARKER_FILE), value.to_string()).unwrap();
2855        assert_eq!(FormationMarker::read(dir.path()), Some(marker));
2856        std::fs::write(dir.path().join(FORMATION_MARKER_FILE), "{").unwrap();
2857        assert_eq!(FormationMarker::read(dir.path()), None);
2858        assert_eq!(FormationMarker::read(&dir.path().join("missing")), None);
2859    }
2860
2861    /// A launch with peers writes its marker at `0600` into its formation's directory before the
2862    /// first peer's directory is made, naming the canonical project directory and this process as
2863    /// its launcher — and writes it even when the launch is then refused.
2864    #[test]
2865    fn formation_marker_is_written_owner_only_before_the_first_peer_directory() {
2866        use std::os::unix::fs::PermissionsExt;
2867
2868        let _guard = binary_guard();
2869        let dir = tempfile::tempdir().unwrap();
2870        let project = tempfile::tempdir().unwrap();
2871        let mut options = options(Duration::from_secs(1));
2872        options.project_dir = project.path().join(".");
2873        let formation_dir = options
2874            .formations_dir
2875            .clone()
2876            .unwrap()
2877            .join(options.formation_id.as_str());
2878        std::env::set_var(child_launch::MUR_BINARY_ENV, stand_in(dir.path(), None));
2879        let result = launch_plan(plan_of("coder"), options);
2880        std::env::remove_var(child_launch::MUR_BINARY_ENV);
2881        assert!(result.is_err(), "the stand-in never reports itself");
2882
2883        let path = formation_dir.join(FORMATION_MARKER_FILE);
2884        let metadata = std::fs::metadata(&path).unwrap();
2885        assert_eq!(metadata.permissions().mode() & 0o777, 0o600);
2886        let marker = FormationMarker::read(&formation_dir).unwrap();
2887        let pid = std::process::id();
2888        assert_eq!(
2889            marker,
2890            FormationMarker {
2891                project_dir: std::fs::canonicalize(project.path()).unwrap(),
2892                launcher_pid: pid,
2893                launcher_start: crate::running::process_start_token(pid).unwrap_or_default(),
2894            }
2895        );
2896        let peer_dir = std::fs::metadata(formation_dir.join("coder")).unwrap();
2897        assert!(
2898            metadata.modified().unwrap() <= peer_dir.modified().unwrap(),
2899            "the marker was written after the peer's directory was made"
2900        );
2901    }
2902
2903    /// A plan with no peers makes no formation directory, so there is nowhere for a marker.
2904    #[test]
2905    fn formation_marker_is_never_written_without_peers() {
2906        let _guard = binary_guard();
2907        let dir = tempfile::tempdir().unwrap();
2908        let options = options(Duration::from_secs(5));
2909        let formations = options.formations_dir.clone().unwrap();
2910        std::env::set_var(child_launch::MUR_BINARY_ENV, stand_in(dir.path(), None));
2911        let plan = FormationPlan {
2912            peers: Vec::new(),
2913            entry: member("planner"),
2914            callees: Vec::new(),
2915        };
2916        let result = launch_plan(plan, options);
2917        std::env::remove_var(child_launch::MUR_BINARY_ENV);
2918        let formation = result.expect("a formation with no peers has nothing to wait for");
2919        drop(formation);
2920        assert!(
2921            std::fs::read_dir(&formations).map_or(true, |mut entries| entries.next().is_none()),
2922            "{}",
2923            formations.display()
2924        );
2925    }
2926
2927    fn marker_of(pid: u32, start: &str) -> FormationMarker {
2928        FormationMarker {
2929            project_dir: PathBuf::from("/srv/project"),
2930            launcher_pid: pid,
2931            launcher_start: start.to_string(),
2932        }
2933    }
2934
2935    /// A pid this test started and has already reaped: no process holds it.
2936    fn reaped_pid() -> u32 {
2937        let mut child = std::process::Command::new("true").spawn().unwrap();
2938        let pid = child.id();
2939        child.wait().unwrap();
2940        pid
2941    }
2942
2943    fn running_record(pid: u32, formation: Option<&FormationId>) -> crate::running::RunningRecord {
2944        crate::running::RunningRecord {
2945            session_id: "ses_0199c4e2f1b7712a9d3e4f5061728394".to_string(),
2946            url: "127.0.0.1:1".to_string(),
2947            pid,
2948            process_start: crate::running::process_start_token(pid).unwrap_or_default(),
2949            capsule_name: "worker".to_string(),
2950            capsule_version: "0.1.0".to_string(),
2951            workdir: PathBuf::from("/tmp/worker"),
2952            records_dir: PathBuf::from("/tmp/worker").join("records"),
2953            outlives_launcher: false,
2954            started_at: "2026-01-01T00:00:00Z".to_string(),
2955            door_token: None,
2956            credentials: Default::default(),
2957            formation_id: formation.cloned(),
2958            formation_member: None,
2959            formation_entry: false,
2960            formation_lifeline: false,
2961            formation_launcher: None,
2962            spawned_by: None,
2963        }
2964    }
2965
2966    /// A formation is live while its launcher is — this process with its own start token — and
2967    /// not once its launcher's pid is held by nothing.
2968    #[test]
2969    fn prune_formations_liveness_reads_the_launcher_from_the_marker() {
2970        let id = FormationId::mint();
2971        let own = std::process::id();
2972        let token = crate::running::process_start_token(own).unwrap();
2973        assert!(formation_is_live(&id, &marker_of(own, &token), Some(&[])));
2974        assert!(!formation_is_live(
2975            &id,
2976            &marker_of(reaped_pid(), &token),
2977            Some(&[])
2978        ));
2979    }
2980
2981    /// A formation whose launcher is gone is live while a running record names it and that
2982    /// record's process is alive; a record naming another formation, or a gone process, is not
2983    /// enough.
2984    #[test]
2985    fn prune_formations_liveness_reads_running_records_that_name_the_formation() {
2986        let id = FormationId::mint();
2987        let dead = marker_of(reaped_pid(), "1");
2988        let mut sleeper = std::process::Command::new("sleep")
2989            .arg("30")
2990            .spawn()
2991            .unwrap();
2992        let live = running_record(sleeper.id(), Some(&id));
2993        let other = running_record(sleeper.id(), Some(&FormationId::mint()));
2994        let unnamed = running_record(sleeper.id(), None);
2995        let gone = running_record(reaped_pid(), Some(&id));
2996
2997        assert!(formation_is_live(&id, &dead, Some(&[live])));
2998        assert!(!formation_is_live(
2999            &id,
3000            &dead,
3001            Some(&[other, unnamed, gone])
3002        ));
3003        let _ = sleeper.kill();
3004        let _ = sleeper.wait();
3005    }
3006
3007    /// A running directory that cannot be listed says nothing about what is running, so every
3008    /// formation is live and nothing is removed.
3009    #[test]
3010    fn prune_formations_liveness_treats_an_unlistable_running_directory_as_all_live() {
3011        let id = FormationId::mint();
3012        assert!(formation_is_live(&id, &marker_of(reaped_pid(), "1"), None));
3013
3014        let home = tempfile::tempdir().unwrap();
3015        let project = tempfile::tempdir().unwrap();
3016        let formations = home.path().join(FORMATIONS_DIR);
3017        let old = FormationId::parse(&format!("frm_{:012x}{:020x}", 1_000_000u64, 1)).unwrap();
3018        let old_dir = formations.join(old.as_str());
3019        std::fs::create_dir_all(old_dir.join("worker")).unwrap();
3020        let marker = FormationMarker {
3021            project_dir: std::fs::canonicalize(project.path()).unwrap(),
3022            ..marker_of(reaped_pid(), "1")
3023        };
3024        std::fs::write(
3025            old_dir.join(FORMATION_MARKER_FILE),
3026            serde_json::to_string(&marker).unwrap(),
3027        )
3028        .unwrap();
3029        let policy = murmur_artifact::TraceRetainConfig {
3030            max_sessions: Some(1),
3031            max_age_secs: Some(1),
3032        };
3033
3034        // A regular file where the running directory should be cannot be listed.
3035        let running = home.path().join("running");
3036        std::fs::write(&running, "").unwrap();
3037        let current = FormationId::mint();
3038        assert!(prune_ended_formations_in(
3039            &formations,
3040            &running,
3041            &current,
3042            project.path(),
3043            &policy
3044        )
3045        .is_empty());
3046        assert!(old_dir.is_dir());
3047
3048        // A running directory that does not exist is an empty machine, and the ended one goes.
3049        std::fs::remove_file(&running).unwrap();
3050        let pruned =
3051            prune_ended_formations_in(&formations, &running, &current, project.path(), &policy);
3052        assert_eq!(pruned.len(), 1, "{pruned:?}");
3053        assert_eq!(pruned[0].formation_id, old);
3054        assert_eq!(pruned[0].reason, crate::trace::RETENTION_REASON_MAX_AGE);
3055        assert!(!old_dir.exists());
3056    }
3057}