Skip to main content

capsule_runtime/
child_launch.rs

1//! Launching a delegated child as an operating-system process of the parent's own runtime.
2//!
3//! The daemon referees and hands back an approval; this is what turns that approval into a
4//! running capsule. The parent's runtime composes a directory for the child beneath its own
5//! accessible workdir, builds the child's environment from the child's declaration rather than
6//! its own, and starts the `mur` binary as a subprocess. The child's runtime registers with the
7//! daemon for itself (see [`crate::registration`]), so the parent never states what the child
8//! holds — it only names which artifact is running.
9//!
10//! Three properties fall out of the child being a process rather than a thread:
11//!
12//! * A daemon crash takes no child with it. Nothing the child needs to keep running lives in the
13//!   daemon's address space.
14//! * Each child has its own process environment and working directory, so a native subprocess
15//!   started by one child inherits nothing a sibling shares.
16//! * A child that declares the `sealed` containment floor enters a mount namespace of its own,
17//!   because the containment machinery installs per process and the child *is* a process.
18//!
19//! **A child joins its parent's formation; it never starts one.** A parent session that belongs to
20//! a formation hands its id to every child as [`FORMATION_ID_ENV`], beside the spawner handle and
21//! independent of it, so a launch that names no lineage still carries it. A parent in no
22//! formation hands none. Inheriting the id is not a grant: the child gets no formation channel, no
23//! formation token, no callee and no formation lifeline, so its door has no membership and refuses
24//! every formation token.
25//!
26//! **A child dies with the process that spawned it.** Every child, in a formation or not, is
27//! handed a spawner lifeline of its own ([`crate::lifeline::ChildLifeline`], named by
28//! [`SPAWNER_LIFELINE_ENV`]): the read end of a pipe whose only write end this process holds.
29//! When this process ends, by any means, the kernel closes the write end and the child winds down
30//! as a first `SIGTERM` would wind it down. A child that delegates hands its own children a
31//! lifeline of its own the same way, so a grandchild ends once its parent has. A launch whose
32//! lifeline cannot be created is refused rather than started without one.
33//!
34//! **The approval travels on the child's standard input.** Not on the argument vector and not in
35//! the environment: both are readable from `/proc/<pid>` by any process running as the same user,
36//! which is exactly what a sibling capsule's shell tool is.
37//!
38//! [`SPAWNER_LIFELINE_ENV`]: crate::lifeline::SPAWNER_LIFELINE_ENV
39
40use std::io::{BufRead, BufReader, Write};
41use std::path::{Path, PathBuf};
42use std::process::{Child, Command, ExitStatus, Stdio};
43use std::sync::{mpsc, Arc, Condvar, Mutex};
44use std::time::{Duration, Instant};
45
46use crate::delegation::{
47    self, CompletionAddress, DelegationOutcome, DelegationStatus, Reporter, Spawner, SpawnerHandle,
48    SPAWNER_ENV,
49};
50use crate::errors::RuntimeError;
51use crate::formation::{FormationId, FORMATION_ID_ENV};
52use crate::mac_token;
53use crate::spawn_credential::SpawnApproval;
54use crate::workdir_fs::WorkdirRoot;
55use crate::workdir_snapshot::{copy_workdir, SnapshotLimits, WorkdirSnapshot};
56
57/// Environment variable naming the `mur` binary to launch children with.
58///
59/// Defaults to [`std::env::current_exe`], which is correct in production: the process doing the
60/// launching *is* `mur`. A test harness that runs inside its own test binary sets this to the
61/// built `mur` instead.
62pub const MUR_BINARY_ENV: &str = "MURMUR_MUR_BINARY";
63
64/// The hidden `mur run` flag that puts every door token the session minted on its `--json`
65/// readiness line, under `tokens`. Passed only by a launcher reading that line on a pipe nothing
66/// else holds — a parent runtime launching a delegated child, and `mur run --roster` launching a
67/// peer — because it is the one `mur run` output that carries a token.
68pub const READINESS_TOKENS_FLAG: &str = "--readiness-tokens";
69
70/// The hidden `mur run` flag carrying `<st_dev>:<st_ino>` of the directory a delegating parent
71/// created for its child, which the child's staging compares with the `--workdir` it opened
72/// before writing anything there (`E-RUN-051` on a mismatch).
73pub const WORKDIR_IDENTITY_FLAG: &str = "--workdir-identity";
74
75/// The hidden `mur run` flag carrying, as [`WorkdirSnapshot`] JSON, what a delegating parent
76/// copied into the child's `--workdir` before starting it. A report of what the parent did, never
77/// a grant: the child's staging records it as `session_start.effective_grants.parent_workdir` and
78/// in `MURMUR.md`, and holds nothing more because of it.
79pub const PARENT_WORKDIR_SNAPSHOT_FLAG: &str = "--parent-workdir-snapshot";
80
81/// The hidden `mur run` flag every launch the runtime makes on the operator's behalf carries: a
82/// delegated child, a plan `capsule` step, a contained roster member and a deployed capsule. A
83/// launch carrying it runs contained, whatever `containment: unconfined` the global config says,
84/// and refuses `--unconfined` (`E-RUN-060`), so the unconfined stance never spreads to a capsule
85/// the operator did not launch unconfined themselves.
86pub const CONTAINED_LAUNCH_FLAG: &str = "--contained-launch";
87
88/// `(st_dev, st_ino)` in [`WORKDIR_IDENTITY_FLAG`]'s `<dev>:<ino>` form.
89#[must_use]
90pub fn format_workdir_identity(identity: (u64, u64)) -> String {
91    format!("{}:{}", identity.0, identity.1)
92}
93
94/// Parses [`WORKDIR_IDENTITY_FLAG`]'s `<dev>:<ino>` value.
95pub fn parse_workdir_identity(value: &str) -> Result<(u64, u64), String> {
96    let (dev, ino) = value
97        .split_once(':')
98        .ok_or_else(|| format!("{WORKDIR_IDENTITY_FLAG} takes <dev>:<ino>, not {value:?}"))?;
99    let number = |part: &str| {
100        part.parse::<u64>()
101            .map_err(|_| format!("{WORKDIR_IDENTITY_FLAG} takes <dev>:<ino>, not {value:?}"))
102    };
103    Ok((number(dev)?, number(ino)?))
104}
105
106/// How long the parent waits for a child to print its `--json` launch line.
107///
108/// Generous, because it bounds more than a handshake: a script capsule prints the line when its
109/// run has *finished*, and an agent capsule compiles its driver component before it binds a port,
110/// which on a cold host is the slowest thing in a launch.
111const CHILD_READY_TIMEOUT: Duration = Duration::from_secs(180);
112
113/// Owner-only, so a sibling capsule running as the same user cannot list or read a child's
114/// directory even though both sit under one parent.
115#[cfg(unix)]
116const CHILD_DIR_MODE: u32 = 0o700;
117
118/// How many of the child's last stderr lines are kept to explain a launch that never reported.
119///
120/// The child's diagnostics are the operator's, so every line is echoed to this process's stderr as
121/// it arrives; this bound is only what a *failure* quotes back, so a child that logged for an hour
122/// before dying does not turn into an unbounded error string.
123const CHILD_STDERR_TAIL_LINES: usize = 20;
124
125/// How long a post-mortem read of the tail waits for the drain to reach the end of the pipe.
126///
127/// Bounds a thread being scheduled, not the child: by the time anything asks for a dead child's
128/// tail the exit has already closed the write end, so the drain is at most one wake-up away.
129/// A pipe some surviving grandchild still holds open would never reach EOF at all, and the bound
130/// is what stops that inheriting process from wedging the parent's launch or its watcher.
131const CHILD_STDERR_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
132
133/// How often the completion watcher asks whether the child is still running.
134///
135/// It polls rather than blocking on `wait`, because the process handle is shared with
136/// [`LaunchedChild::shutdown`] and [`Drop`]: a watcher blocked inside `wait` would hold the lock
137/// those need, and a second `wait` on one child is not a thing two owners can both do.
138const CHILD_WATCH_INTERVAL: Duration = Duration::from_millis(100);
139
140/// How long a child sent `SIGTERM` on purpose is given to wind down before it is `SIGKILL`ed.
141///
142/// The child's own `SIGTERM` teardown cancels its task and writes `session_end`, so within this
143/// grace its trace says how it ended. Kept under `mur stop`'s default `--timeout`, so a parent
144/// that is stopping can end its children and still exit inside its own grace.
145///
146/// Shorter than the child's own [`crate::runtime::TERMINATE_TEARDOWN_DEADLINE`]: a child whose
147/// teardown runs past this grace (a long async-hook drain) is `SIGKILL`ed before it writes
148/// `session_end`, and `mur trace show` reports its trace as ended without one.
149pub const CHILD_END_GRACE: Duration = Duration::from_secs(5);
150
151/// How often a child that has been sent `SIGTERM` is polled for its exit during
152/// [`CHILD_END_GRACE`].
153const CHILD_END_POLL: Duration = Duration::from_millis(20);
154
155/// What the parent's runtime needs in order to start one approved child.
156///
157/// Carries no manifest and no capability declaration: what the child holds is decided by the
158/// daemon from the child's own registry manifest, both at the `POST /spawn` that granted `grant`
159/// and again at the `POST /register` the child performs for itself.
160#[derive(Debug)]
161pub struct ChildLaunchRequest {
162    /// The parent's own records directory. The child's work folder is created in its
163    /// [`CHILDREN_DIR`], with no link followed, outside the parent's work folder.
164    pub parent_records_root: WorkdirRoot,
165    pub capsule_name: String,
166    pub capsule_version: String,
167    /// The approval `POST /spawn` returned, spent by the child's own registration.
168    pub grant: SpawnApproval,
169    /// The child's `capabilities.env.allow`. Names listed here are copied from the parent's
170    /// process environment into the child's; every other host variable the parent holds is
171    /// absent from the child, because the child's environment is built from a cleared one.
172    pub child_env_allow: Vec<String>,
173    /// Base URL of the daemon the child registers with.
174    pub roost_url: String,
175    /// The session this child belongs to, or `None` for a launch that is not a delegation.
176    ///
177    /// `Some` is what turns the launch into a delegation: the launcher mints a delegation id and
178    /// injects a [`SpawnerHandle`] as [`SPAWNER_ENV`], which is where the child reads the lineage
179    /// it records in its own `session_start`. The watcher that reports for a child which could
180    /// not report for itself starts only when that spawner also names a
181    /// [`CompletionAddress`] — a parent waiting on its own connection wants no completion.
182    /// `None` injects nothing and starts nothing.
183    pub spawner: Option<Spawner>,
184    /// How long the completion watcher observes this child before ending it, or `None` to observe
185    /// it for as long as it runs.
186    ///
187    /// Meaningful only alongside a [`CompletionAddress`], because the watcher that enforces it
188    /// starts only for a spawner that names one. On expiry the watcher stops the process and
189    /// posts a [`DelegationStatus::Terminated`] outcome naming the bound, which is the only thing
190    /// that tells a parent about a child that never ends.
191    pub completion_deadline: Option<Duration>,
192    /// The parent session's formation, which the child joins: injected as [`FORMATION_ID_ENV`]
193    /// when `Some`, whether or not [`Self::spawner`] is. Inherited from the parent and never
194    /// minted here; `None` — a parent in no formation — injects nothing.
195    pub formation_id: Option<FormationId>,
196    /// The parent session's deadline, handed to the child as
197    /// [`crate::session_status::INHERITED_DEADLINE_FLAG`] and its moment, so the child's own runtime
198    /// ends it there. `None` bounds nothing. A script-capsule child takes no session deadline from
199    /// it, and ends with its parent through its spawner lifeline.
200    pub deadline: Option<crate::session_status::SessionDeadline>,
201    /// The parent's work folder to copy into the child's before the child starts, or `None` to
202    /// start it in an empty one. Set for a capsule the parent lists under
203    /// `capabilities.spawn.workdir_snapshot`.
204    pub parent_workdir_snapshot: Option<ParentWorkdirSource>,
205}
206
207/// A parent's work folder as [`launch_child_capsule`] copies it into a child's.
208#[derive(Debug, Clone)]
209pub struct ParentWorkdirSource {
210    /// The parent's accessible work folder, held. Only read, and never handed to the child.
211    pub root: WorkdirRoot,
212    /// The child's own `capabilities.resources.workdir_max_bytes`, and the parent's runtime and
213    /// records directories, which the copy never enters.
214    pub limits: SnapshotLimits,
215}
216
217/// How a child's process ended, as the watcher sees it.
218enum Ending {
219    /// The process exited on its own. `None` when it was reaped by someone who did not keep the
220    /// status.
221    Exited(Option<ExitStatus>),
222    /// The delegation was ended on purpose rather than by the child: by the parent, through
223    /// [`LaunchedChild::shutdown`] or `Drop`, or by the completion watcher at its deadline.
224    Deliberate,
225}
226
227/// The one owner of the child's process handle.
228///
229/// Shared behind a lock between [`LaunchedChild`] — which kills and reaps — and the completion
230/// watcher, which only ever asks whether the process is still there. Single ownership of the
231/// `Child` is what keeps `pid()`, `shutdown()` and `Drop` behaving as they did: nothing else
232/// takes the handle, and nothing else waits on it.
233struct ChildProcess {
234    /// `None` once [`LaunchedChild::shutdown`] or `Drop` has reaped the process.
235    child: Option<Child>,
236    /// Set before the kill by whoever ended the delegation on purpose, so a crash is never read
237    /// as a termination. It does not say *who*: the completion watcher sets it at its own
238    /// deadline too, and tells the two apart by a flag of its own.
239    deliberate: bool,
240    /// The exit status, once anyone has observed it.
241    status: Option<ExitStatus>,
242    /// When `SIGTERM` was sent, so a child signalled ahead of its reap — see
243    /// [`LaunchedChild::begin_ending`] — is given one [`CHILD_END_GRACE`] in all, not one per
244    /// call.
245    terminated_at: Option<Instant>,
246    /// The write end of the child's spawner lifeline, open for as long as the child is this
247    /// process's to end. Closed once the child is observed ended, when it is ended deliberately
248    /// — by [`LaunchedChild::shutdown`], `Drop` or the completion watcher's deadline — or when
249    /// this process exits. Nothing writes to it.
250    #[cfg(unix)]
251    lifeline: Option<crate::lifeline::ChildLifeline>,
252}
253
254impl ChildProcess {
255    /// Close the write end of the child's spawner lifeline, if this still holds it.
256    fn close_lifeline(&mut self) {
257        #[cfg(unix)]
258        if let Some(mut lifeline) = self.lifeline.take() {
259            lifeline.close();
260        }
261    }
262
263    /// How the process ended, or `None` while it is still running.
264    ///
265    /// Reaps a process that exited on its own, which is what makes a later `wait` in `shutdown`
266    /// return the cached status rather than block.
267    fn poll(&mut self) -> Option<Ending> {
268        if self.deliberate {
269            // A child still winding down from `SIGTERM` has not ended yet: whatever it records
270            // meanwhile must land before the watcher records the ending over it.
271            return (!self.is_being_ended()).then_some(Ending::Deliberate);
272        }
273        let Some(child) = self.child.as_mut() else {
274            // Reaped by `Drop` after [`LaunchedChild::has_exited`] had already seen the exit:
275            // the child ended on its own.
276            return Some(Ending::Exited(self.status));
277        };
278        let ending = match child.try_wait() {
279            Ok(Some(status)) => {
280                self.status = Some(status);
281                Ending::Exited(Some(status))
282            }
283            Ok(None) => return None,
284            // The handle is unusable; treating the child as gone is the only outcome that lets
285            // the delegation be reported at all.
286            Err(_) => Ending::Exited(self.status),
287        };
288        self.close_lifeline();
289        Some(ending)
290    }
291
292    /// Mark the ending deliberate and send `SIGTERM` to a child that has not exited, once. A
293    /// child already seen exited ended on its own and stays so; one found exited here is reaped
294    /// instead, so the signal can never reach a reused pid: an unreaped child's pid stays its own.
295    ///
296    /// Marked under the same lock as the signal, so the watcher never reads a deliberate ending
297    /// that has not been signalled.
298    #[allow(unsafe_code)]
299    fn terminate(&mut self) {
300        if self.status.is_none() {
301            self.deliberate = true;
302        }
303        if self.terminated_at.is_some() {
304            return;
305        }
306        let Some(child) = self.child.as_mut() else {
307            return;
308        };
309        if let Ok(Some(status)) = child.try_wait() {
310            self.status = Some(status);
311            return;
312        }
313        #[cfg(unix)]
314        if let Ok(pid) = libc::pid_t::try_from(child.id()) {
315            // SAFETY: `kill` takes no pointers. `pid` is this process's own unreaped child:
316            // `try_wait` above saw it running, under the lock every reap of it takes, so the pid
317            // cannot have been reused by an unrelated process.
318            unsafe {
319                libc::kill(pid, libc::SIGTERM);
320            }
321        }
322        #[cfg(not(unix))]
323        let _ = child.kill();
324        self.terminated_at = Some(Instant::now());
325    }
326
327    /// Whether the child has been sent `SIGTERM` on purpose and not yet reaped: it is winding
328    /// down, and anything it reports meanwhile is about an ending its parent chose.
329    fn is_being_ended(&self) -> bool {
330        self.deliberate && self.status.is_none() && self.terminated_at.is_some()
331    }
332}
333
334/// End the child: `SIGTERM`, up to [`CHILD_END_GRACE`] from that signal for it to exit, then
335/// `SIGKILL`; reap it, then close its lifeline. A child that has already exited is only reaped.
336/// Idempotent.
337///
338/// The lock is held only to signal and to record the reap, never across the grace, so `pid`,
339/// `poll` and [`LaunchedChild::is_being_ended`] still answer while the child winds down. A second
340/// call during that wait returns at once and leaves the reap to the first. The lifeline stays
341/// open until the child is reaped, so a child winding down from `SIGTERM` never also reads its
342/// spawner as gone.
343fn end_process(process: &Mutex<ChildProcess>) -> Result<(), std::io::Error> {
344    let (mut child, signalled) = {
345        let mut process = lock(process);
346        process.terminate();
347        let Some(child) = process.child.take() else {
348            // Taken by an ending still waiting out the grace, which closes the lifeline itself
349            // once it has reaped the child.
350            if process.status.is_some() || process.terminated_at.is_none() {
351                process.close_lifeline();
352            }
353            return Ok(());
354        };
355        (child, process.terminated_at)
356    };
357    let waited = match signalled {
358        None => child.wait(),
359        Some(signalled) => loop {
360            match child.try_wait() {
361                Ok(Some(status)) => break Ok(status),
362                Ok(None) if signalled.elapsed() < CHILD_END_GRACE => {
363                    std::thread::sleep(CHILD_END_POLL);
364                }
365                // Past the grace, or the handle cannot be polled: `kill` on an already-exited
366                // process is not an error worth surfacing, and the wait is what retires the entry
367                // in the process table.
368                _ => {
369                    let _ = child.kill();
370                    break child.wait();
371                }
372            }
373        },
374    };
375    let mut process = lock(process);
376    process.close_lifeline();
377    match waited {
378        Ok(status) => {
379            process.status = Some(status);
380            Ok(())
381        }
382        // The handle is gone and nothing is left to wait for: the ending is over, with no status.
383        Err(error) => {
384            process.terminated_at = None;
385            Err(error)
386        }
387    }
388}
389
390/// A running child capsule and the handles to end it.
391pub struct LaunchedChild {
392    /// The directory the parent created for this child in its records directory's
393    /// [`CHILDREN_DIR`], and passed as its `--workdir`: the child's work folder. Its trace, its
394    /// `completion.json` and an agent child's `out/` are in [`Self::records_dir`].
395    pub workdir: PathBuf,
396    /// [`Self::workdir`], held, reached from the parent's records root with no link followed and
397    /// sharing its refusal latch: every runtime read of a script child's result goes through it.
398    pub(crate) workdir_root: WorkdirRoot,
399    /// The session id the child's runtime minted for itself.
400    pub session_id: String,
401    /// The child's records directory, as the child reported it on its readiness line: where its
402    /// trace, its `completion.json` and an agent child's `out/` are. Never derived from
403    /// [`Self::workdir`].
404    pub records_dir: PathBuf,
405    /// [`Self::records_dir`], held, sharing [`Self::workdir_root`]'s refusal latch: every read and
406    /// write of the child's `completion.json` and every read of an agent child's result goes
407    /// through it.
408    pub(crate) records_root: WorkdirRoot,
409    /// The child's A2A endpoint, `http://host:port`. Empty for a script capsule, which binds no
410    /// port and has already finished by the time it reports.
411    pub capsule_url: String,
412    /// The argument vector the parent built, in order, starting with the binary.
413    ///
414    /// Exposed so a caller — a test, notably — can assert what was passed without reading
415    /// `/proc`. It carries no token: the approval goes in on standard input.
416    pub argv: Vec<String>,
417    /// The complete environment the parent built for this child, in insertion order. Also carries
418    /// no token.
419    pub env: Vec<(String, String)>,
420    /// The id this launch's completion reports under, `None` when no spawner was supplied.
421    ///
422    /// Minted here, injected into the child as part of [`SPAWNER_ENV`], and echoed back on the
423    /// completion — the one value that joins a delegation to the task it produces at the parent.
424    pub delegation_id: Option<String>,
425    /// The operator token of a child that declares `network.authentication`, read from
426    /// `tokens.operator` on its readiness line. The parent presents it on every call to the
427    /// child's door. Never printed by `Debug`.
428    pub door_token: Option<crate::door_auth::DoorToken>,
429    /// The formation the child reports it joined, read from `formation_id` on its readiness
430    /// line. `None` when the line carries no such key, or a value that is not a formation id.
431    pub formation_id: Option<FormationId>,
432    /// The id the child gave the task its parent delivered, once [`crate::delegation_plane`] has
433    /// delivered one: what names the child's per-task result file when its parent reads the
434    /// answer. `None` until then.
435    pub(crate) task_id: Option<String>,
436    process: Arc<Mutex<ChildProcess>>,
437    /// The child's last [`CHILD_STDERR_TAIL_LINES`] lines, retained so a crash can say why.
438    stderr_tail: Arc<StderrTail>,
439    /// When the child process was started, for the completion's `duration_ms`.
440    started: Instant,
441    /// The completion watcher's thread, for a launch whose spawner names an address.
442    watcher: Option<std::thread::JoinHandle<()>>,
443    /// What was copied from the parent's work folder into [`Self::workdir`] before the child
444    /// started, or `None` for a child that started in an empty one.
445    pub workdir_snapshot: Option<WorkdirSnapshot>,
446}
447
448impl std::fmt::Debug for LaunchedChild {
449    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
450        f.debug_struct("LaunchedChild")
451            .field("workdir", &self.workdir)
452            .field("session_id", &self.session_id)
453            .field("records_dir", &self.records_dir)
454            .field("capsule_url", &self.capsule_url)
455            .field("delegation_id", &self.delegation_id)
456            .field("formation_id", &self.formation_id)
457            .field(
458                "door_token",
459                &self.door_token.as_ref().map(|_| "<redacted>"),
460            )
461            .field("pid", &self.pid())
462            .finish()
463    }
464}
465
466impl LaunchedChild {
467    /// The child's operating-system process id. `0` once it has been reaped.
468    pub fn pid(&self) -> u32 {
469        lock(&self.process)
470            .child
471            .as_ref()
472            .map(Child::id)
473            .unwrap_or(0)
474    }
475
476    /// The child's last [`CHILD_STDERR_TAIL_LINES`] lines, oldest first.
477    ///
478    /// The same lines a crash completion quotes back, exposed so a caller can read what the child
479    /// said without scraping the stderr they were already echoed to.
480    pub fn stderr_tail(&self) -> Vec<String> {
481        self.stderr_tail.lines()
482    }
483
484    /// Mark the ending deliberate and send the child `SIGTERM`, without waiting for it.
485    ///
486    /// A parent ending several children calls this on each before [`Self::shutdown`] or `Drop` on
487    /// any, so they wind down together and the whole ending waits one [`CHILD_END_GRACE`] rather
488    /// than one per child. A child already exited is left to be reaped.
489    pub fn begin_ending(&self) {
490        lock(&self.process).terminate();
491    }
492
493    /// End the child — `SIGTERM`, up to [`CHILD_END_GRACE`] to exit, then `SIGKILL` — and reap
494    /// it. Idempotent: a second call, or a call after `Drop` has already run, does nothing.
495    ///
496    /// Marks the ending as deliberate, so the watcher records the delegation as `terminated` and
497    /// posts nothing: the only party that would be told is the party that did it.
498    pub fn shutdown(&mut self) -> Result<(), RuntimeError> {
499        end_process(&self.process).map_err(|error| {
500            RuntimeError::Runtime(format!("failed to reap child capsule: {error}"))
501        })
502    }
503
504    /// Whether the child is winding down from a deliberate `SIGTERM` and has not been reaped.
505    ///
506    /// The door asks this of a completion that arrives for a delegation still in the set: such a
507    /// completion is the child's own account of a cancel its parent's side chose, posted before
508    /// the launcher's `terminated` record replaces it, and it is not the delegation's outcome.
509    pub fn is_being_ended(&self) -> bool {
510        lock(&self.process).is_being_ended()
511    }
512
513    /// Whether the completion watcher behind this child has finished: it has seen the child end
514    /// and recorded, and posted, whatever it records and posts. `true` for a child no watcher
515    /// observes.
516    ///
517    /// A delegating task that ends a child asks this before it lets the child's handle go, so the
518    /// child's `completion.json` says how it ended before the parent's own process can exit.
519    pub fn watcher_finished(&self) -> bool {
520        self.watcher
521            .as_ref()
522            .is_none_or(std::thread::JoinHandle::is_finished)
523    }
524
525    /// Whether the child's process has ended, by its own exit or by being ended. The completion
526    /// watcher's own poll: a process that exited on its own is reaped and its lifeline closed.
527    ///
528    /// Never blocks: a delegating task asks this while it gives a child that has just reported
529    /// its outcome a moment to finish exiting.
530    pub fn has_exited(&self) -> bool {
531        lock(&self.process).poll().is_some()
532    }
533}
534
535impl Drop for LaunchedChild {
536    /// Ends and reaps as [`LaunchedChild::shutdown`] does, so a parent that returns early —
537    /// including by panicking — leaves no orphaned capsule process behind holding a port and a
538    /// directory. Deliberate on the same terms. A child that has already exited is only reaped.
539    fn drop(&mut self) {
540        let _ = end_process(&self.process);
541    }
542}
543
544/// A mutex this crate holds only for the length of one field access, so a poisoned lock is
545/// recovered from rather than turned into a panic in a `Drop`.
546fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
547    mutex
548        .lock()
549        .unwrap_or_else(|poisoned| poisoned.into_inner())
550}
551
552/// Where a child of this parent goes: `<parent records dir>/children/<name>-<16 hex>`.
553///
554/// The 16 hex characters are fresh per call, so delegating the same capsule name and version twice
555/// yields two directories rather than one shared one. In the parent's records directory, outside
556/// its work folder, so `mur trace rm` and `trace.retain` remove a child's work folder with the
557/// parent's records. `<name>` is the bare part of a full `capsule_name`, so the directory is one
558/// segment whatever the scope.
559pub fn child_workdir_for(parent_records_dir: &Path, capsule_name: &str) -> PathBuf {
560    parent_records_dir.join(child_workdir_rel(capsule_name))
561}
562
563/// Every child's directory sits directly beneath this one, relative to the parent's records
564/// directory.
565pub(crate) const CHILDREN_DIR: &str = "children";
566
567/// [`child_workdir_for`] relative to the parent's records directory: `children/<name>-<16 hex>`.
568fn child_workdir_rel(capsule_name: &str) -> PathBuf {
569    let suffix = mac_token::random_hex(8).unwrap_or_else(|_| {
570        // The OS CSPRNG is not expected to fail; a nanosecond clock reading still distinguishes
571        // two delegations rather than collapsing them onto one directory.
572        format!(
573            "{:016x}",
574            std::time::SystemTime::now()
575                .duration_since(std::time::UNIX_EPOCH)
576                .map(|elapsed| elapsed.as_nanos() as u64)
577                .unwrap_or(0)
578        )
579    });
580    Path::new(CHILDREN_DIR).join(format!(
581        "{}-{suffix}",
582        murmur_artifact::tool_name_of(capsule_name)
583    ))
584}
585
586/// Create the child's directory, start `mur` on it, and wait for the child to report itself.
587///
588/// The child is handed a spawner lifeline of its own; a launch whose lifeline cannot be created
589/// starts nothing.
590pub fn launch_child_capsule(request: ChildLaunchRequest) -> Result<LaunchedChild, RuntimeError> {
591    // First, so a launch that cannot have one creates nothing: a child without a lifeline would
592    // outlive this process.
593    #[cfg(unix)]
594    let mut lifeline = crate::lifeline::ChildLifeline::new().map_err(|error| {
595        RuntimeError::Runtime(format!(
596            "the child capsule's spawner lifeline could not be created: {error}"
597        ))
598    })?;
599    let workdir_rel = child_workdir_rel(&request.capsule_name);
600    let workdir_root = create_child_dir(&request.parent_records_root, &workdir_rel)?;
601    // Before any process exists, so a copy that fails for any reason starts nothing and leaves no
602    // directory behind.
603    let workdir_snapshot = match &request.parent_workdir_snapshot {
604        None => None,
605        Some(source) => match copy_workdir(&source.root, &workdir_root, &source.limits) {
606            Ok(snapshot) => Some(snapshot),
607            Err(error) => {
608                let _ = request.parent_records_root.remove_dir_all(&workdir_rel);
609                return Err(RuntimeError::Runtime(format!(
610                    "the child capsule '{}@{}' was not started: {error}",
611                    request.capsule_name, request.capsule_version
612                )));
613            }
614        },
615    };
616    let identity = workdir_root.identity()?;
617    let workdir = workdir_root.path().to_path_buf();
618
619    let binary = mur_binary()?;
620    let mut argv: Vec<String> = vec![
621        binary.display().to_string(),
622        "run".to_string(),
623        "--capsule".to_string(),
624        request.capsule_name.clone(),
625        "--capsule-version".to_string(),
626        request.capsule_version.clone(),
627        "--workdir".to_string(),
628        workdir.display().to_string(),
629        WORKDIR_IDENTITY_FLAG.to_string(),
630        format_workdir_identity(identity),
631        "--json".to_string(),
632        READINESS_TOKENS_FLAG.to_string(),
633        "--no-env-file".to_string(),
634        "--spawn-grant-stdin".to_string(),
635        CONTAINED_LAUNCH_FLAG.to_string(),
636    ];
637    if let Some(deadline) = request.deadline {
638        argv.push(crate::session_status::INHERITED_DEADLINE_FLAG.to_string());
639        argv.push(deadline.at_unix_millis().to_string());
640    }
641    if let Some(snapshot) = &workdir_snapshot {
642        argv.push(PARENT_WORKDIR_SNAPSHOT_FLAG.to_string());
643        argv.push(serde_json::to_string(snapshot).map_err(|error| {
644            RuntimeError::Runtime(format!(
645                "the child capsule's workdir snapshot could not be encoded: {error}"
646            ))
647        })?);
648    }
649    // Minted here, once per launch: a caller holding one `Spawner` that delegates twice gets two
650    // delegation ids, and neither child can report under the other's.
651    let handle = request
652        .spawner
653        .as_ref()
654        .map(|spawner| SpawnerHandle::for_delegation(spawner, delegation::new_delegation_id()));
655    #[cfg_attr(not(unix), allow(unused_mut))]
656    let mut env = child_environment(&request, handle.as_ref());
657    // Recorded among the runtime-owned names, just ahead of the spawner handle it sits beside
658    // (or the formation id, or the end), exactly as `hand_to` sets it in the child.
659    #[cfg(unix)]
660    if let Some((name, value)) = lifeline.env_pair() {
661        let at = env
662            .iter()
663            .position(|(key, _)| key == SPAWNER_ENV || key == FORMATION_ID_ENV)
664            .unwrap_or(env.len());
665        env.insert(at, (name.to_string(), value));
666    }
667    let started = Instant::now();
668
669    // The grant's one and only appearance outside the parent's memory: one line on a pipe that is
670    // closed immediately afterwards. Failing to write it is fatal — the child would otherwise sit
671    // waiting on a line that will never come.
672    let StartedProcess {
673        child,
674        stderr_tail,
675        stdin_written: write_result,
676    } = start_process(&ProcessLaunch {
677        binary: &binary,
678        args: &argv[1..],
679        cwd: Some(&workdir),
680        env: ProcessEnv::Cleared(&env),
681        stdin_line: Some(request.grant.expose()),
682        stderr_prefix: None,
683        inherit_output: false,
684        own_process_group: false,
685        inherit_fd: None,
686        #[cfg(unix)]
687        lifeline: Some(Lifeline::Child(&lifeline)),
688    })
689    .map_err(|error| {
690        RuntimeError::Runtime(format!(
691            "failed to start the child capsule process '{}': {error}",
692            binary.display()
693        ))
694    })?;
695    #[cfg(unix)]
696    lifeline.spawned();
697    let unclaimed = UnclaimedChild {
698        process: Arc::new(Mutex::new(ChildProcess {
699            child: Some(child),
700            deliberate: false,
701            status: None,
702            terminated_at: None,
703            #[cfg(unix)]
704            lifeline: Some(lifeline),
705        })),
706        claimed: false,
707    };
708    if let Err(error) = write_result {
709        return Err(RuntimeError::Runtime(format!(
710            "failed to hand the child its launch grant: {error}"
711        )));
712    }
713
714    let readiness = read_readiness(&unclaimed.process, &workdir, &stderr_tail, &workdir_root)?;
715    let mut launched = LaunchedChild {
716        workdir,
717        workdir_root,
718        session_id: readiness.session_id,
719        records_dir: readiness.records_dir,
720        records_root: readiness.records_root,
721        capsule_url: readiness.capsule_url,
722        argv,
723        env,
724        delegation_id: handle.as_ref().map(|handle| handle.delegation_id.clone()),
725        door_token: readiness.door_token,
726        formation_id: readiness.formation_id,
727        task_id: None,
728        process: unclaimed.claim(),
729        stderr_tail,
730        started,
731        watcher: None,
732        workdir_snapshot,
733    };
734
735    // Started only for a launch somebody wants told about. A lineage-only handle names no
736    // address, so a child whose parent waits on the connection it already holds is launched with
737    // no watcher behind it and posts nothing anywhere.
738    if let Some(handle) = handle {
739        if let Some(address) = handle.report_to.clone() {
740            launched.watcher = watch_for_completion(
741                &launched,
742                handle,
743                address,
744                &request.capsule_name,
745                &request.capsule_version,
746                request.completion_deadline,
747            );
748        }
749    }
750    Ok(launched)
751}
752
753/// A started child process not yet held by a [`LaunchedChild`]: dropped unclaimed, it ends and
754/// reaps the child as `Drop for LaunchedChild` does, so a launch that fails, or panics, between
755/// starting the process and reading its readiness line leaves no capsule process behind.
756struct UnclaimedChild {
757    process: Arc<Mutex<ChildProcess>>,
758    claimed: bool,
759}
760
761impl UnclaimedChild {
762    /// The process, for a [`LaunchedChild`] that ends it from here on.
763    fn claim(mut self) -> Arc<Mutex<ChildProcess>> {
764        self.claimed = true;
765        Arc::clone(&self.process)
766    }
767}
768
769impl Drop for UnclaimedChild {
770    fn drop(&mut self) {
771        if !self.claimed {
772            let _ = end_process(&self.process);
773        }
774    }
775}
776
777/// What a child's `--json` readiness line reports, validated.
778struct Readiness {
779    session_id: String,
780    records_dir: PathBuf,
781    /// `records_dir`, held, sharing the parent root's refusal latch.
782    records_root: WorkdirRoot,
783    door_token: Option<crate::door_auth::DoorToken>,
784    formation_id: Option<FormationId>,
785    /// `http://host:port`, or empty for a script capsule.
786    capsule_url: String,
787}
788
789/// Read the child's readiness line and validate what it reports. The `Err` refuses the
790/// delegation; ending the child is the caller's.
791fn read_readiness(
792    process: &Mutex<ChildProcess>,
793    workdir: &Path,
794    stderr_tail: &StderrTail,
795    latch_of: &WorkdirRoot,
796) -> Result<Readiness, RuntimeError> {
797    let line = first_json_line(process, workdir, stderr_tail)?;
798    let report: serde_json::Value = serde_json::from_str(&line).map_err(|error| {
799        RuntimeError::Runtime(format!(
800            "the child capsule's first --json line did not parse: {error}"
801        ))
802    })?;
803    let session_id = report
804        .get("session_id")
805        .and_then(serde_json::Value::as_str)
806        .ok_or_else(|| {
807            RuntimeError::Runtime(
808                "the child capsule's --json line carried no session_id".to_string(),
809            )
810        })?;
811    let url = report
812        .get("url")
813        .and_then(serde_json::Value::as_str)
814        .unwrap_or_default();
815
816    let records_dir = readiness_records_dir(&report, session_id)?;
817    let records_root =
818        WorkdirRoot::open_sharing_latch_absolute(&records_dir, latch_of).map_err(|error| {
819            RuntimeError::Runtime(format!(
820                "the child capsule's records directory could not be opened: {error}"
821            ))
822        })?;
823    Ok(Readiness {
824        session_id: session_id.to_string(),
825        records_dir,
826        records_root,
827        door_token: readiness_door_token(&report),
828        formation_id: report
829            .get("formation_id")
830            .and_then(serde_json::Value::as_str)
831            .and_then(|id| FormationId::parse(id).ok()),
832        // A script capsule binds no port and reports an empty url; promoting that to `http://`
833        // would manufacture an address nothing answers on.
834        capsule_url: if url.is_empty() {
835            String::new()
836        } else {
837            format!("http://{url}")
838        },
839    })
840}
841
842/// The records directory a child's `--json` readiness line carries under `records_dir`: an
843/// absolute path in this host's `~/.murmur/records`, ending in the child's own session id.
844/// Anything else refuses the delegation rather than reading the child's outcome from somewhere
845/// the line named.
846pub(crate) fn readiness_records_dir(
847    report: &serde_json::Value,
848    session_id: &str,
849) -> Result<PathBuf, RuntimeError> {
850    let refused = |why: String| {
851        RuntimeError::Runtime(format!(
852            "the child capsule's --json line {why}, so its outcome has nowhere to be read from"
853        ))
854    };
855    let reported = report
856        .get("records_dir")
857        .and_then(serde_json::Value::as_str)
858        .map(PathBuf::from)
859        .ok_or_else(|| refused("carried no records_dir".to_string()))?;
860    let records_root = crate::records_dir::records_dir_root()
861        .map_err(|error| refused(format!("named a records directory and this host's {error}")))?;
862    let well_formed = reported.is_absolute()
863        && reported.starts_with(&records_root)
864        && reported.file_name().and_then(|name| name.to_str()) == Some(session_id)
865        && !reported
866            .components()
867            .any(|part| matches!(part, std::path::Component::ParentDir));
868    if !well_formed {
869        return Err(refused(format!(
870            "named {} as its records directory, which is not {}/<workdir root>/{session_id}",
871            reported.display(),
872            records_root.display()
873        )));
874    }
875    Ok(reported)
876}
877
878/// The operator token a child's `--json` readiness line carries under `tokens.operator`, present
879/// only when the child declares `network.authentication` and was launched with
880/// [`READINESS_TOKENS_FLAG`].
881pub(crate) fn readiness_door_token(
882    report: &serde_json::Value,
883) -> Option<crate::door_auth::DoorToken> {
884    report
885        .pointer("/tokens/operator")
886        .and_then(serde_json::Value::as_str)
887        .filter(|token| !token.is_empty())
888        .map(|token| crate::door_auth::DoorToken::new(token.to_string()))
889}
890
891/// Report for a child that ended without reporting for itself, and stop one that never ends.
892///
893/// Polls the shared process handle rather than blocking on `wait`, so it never contends with
894/// [`LaunchedChild::shutdown`] or `Drop` for ownership of the `Child`. The handle stays with the
895/// delegating task, which ends the child through it; this thread observes, and reaps a child that
896/// exits on its own.
897///
898/// `deadline` bounds how long the child is watched, from the instant this watcher starts — which
899/// is the instant the child reported itself ready, not the instant its process was spawned.
900/// Getting to that point is bounded separately by [`CHILD_READY_TIMEOUT`], and folding a cold
901/// host's staging time into the delegation deadline would end a child for being slow to start
902/// rather than slow to work. On expiry the watcher stops the child itself and posts a
903/// [`DelegationStatus::Terminated`] outcome naming the bound — the deadline is the watcher's own,
904/// held in a local flag rather than read back off [`ChildProcess::deliberate`], so an ending the
905/// *parent* chose stays distinguishable from one this thread imposed. `None` watches for as long
906/// as the child runs.
907///
908/// What it does when the process ends on its own is decided by what the child left behind:
909///
910/// * A completion the child already delivered is left alone — exactly one completion per
911///   delegation reaches the parent.
912/// * A completion the child recorded but could not deliver is retried once, and the file is
913///   rewritten with the result. Nothing is retried after that.
914/// * No completion at all means the child died without a word: the watcher builds one with
915///   `status: crashed`, carrying the exit status and the child's bounded stderr tail, and posts
916///   it in the child's place.
917/// * An ending the parent chose is recorded as `terminated` and posted to nobody, because the
918///   only party that would be told is the party that did it.
919fn watch_for_completion(
920    launched: &LaunchedChild,
921    handle: SpawnerHandle,
922    address: CompletionAddress,
923    capsule_name: &str,
924    capsule_version: &str,
925    deadline: Option<Duration>,
926) -> Option<std::thread::JoinHandle<()>> {
927    let process = Arc::clone(&launched.process);
928    let stderr_tail = Arc::clone(&launched.stderr_tail);
929    let workdir = launched.workdir.clone();
930    let records_root = launched.records_root.clone();
931    let session_id = launched.session_id.clone();
932    let capsule_name = capsule_name.to_string();
933    let capsule_version = capsule_version.to_string();
934    let started = launched.started;
935
936    let watching_from = Instant::now();
937    let spawned = std::thread::Builder::new().spawn(move || {
938        let expires_at = deadline.map(|bound| watching_from + bound);
939        // Set here and read nowhere else: `ChildProcess::deliberate` is about to be set too, by
940        // this thread, so it can no longer say who chose the ending.
941        let mut expired = false;
942        let ending = loop {
943            if expires_at.is_some_and(|at| Instant::now() >= at) {
944                let _ = end_process(&process);
945                expired = true;
946                break Ending::Deliberate;
947            }
948            if let Some(ending) = lock(&process).poll() {
949                break ending;
950            }
951            std::thread::sleep(CHILD_WATCH_INTERVAL);
952        };
953        let duration_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
954
955        // A child ended on purpose may have recorded its own `canceled` completion while it wound
956        // down from `SIGTERM`. That record is not the outcome: the delegation was ended, and the
957        // launcher's record below is written over it.
958        let recorded = match ending {
959            Ending::Exited(_) => delegation::read_completion(&records_root),
960            Ending::Deliberate => None,
961        };
962        if let Some(recorded) = recorded {
963            if recorded.delivered {
964                return;
965            }
966            let retried =
967                delegation::report_completion(&handle, &address, recorded, &records_root);
968            if !retried.delivered {
969                // One retry, then the file and the line above it are the record.
970                crate::runtime_err!(
971                    "[capsule-runtime] delegation {}: the child's completion is undelivered after one retry",
972                    handle.delegation_id
973                );
974            }
975            return;
976        }
977
978        let outcome = |status: DelegationStatus, detail: String| DelegationOutcome {
979            delegation_id: handle.delegation_id.clone(),
980            capsule_name: capsule_name.clone(),
981            capsule_version: capsule_version.clone(),
982            session_id: session_id.clone(),
983            status,
984            // The launcher reports for a child that recorded nothing, so it makes no claim about
985            // a result file it never saw named.
986            result_path: None,
987            workdir: workdir.display().to_string(),
988            duration_ms,
989            detail: Some(detail),
990            reported_by: Reporter::Launcher,
991            delivered: false,
992            delivery_error: None,
993        };
994
995        match ending {
996            // The deadline is the parent's runtime acting on the parent's behalf while the parent
997            // is elsewhere, so unlike an ending the parent chose by hand this one is posted: the
998            // delegating agent asked for an outcome and this is the outcome.
999            Ending::Deliberate if expired => {
1000                let bound = deadline.map(|bound| bound.as_secs()).unwrap_or_default();
1001                delegation::report_completion(
1002                    &handle,
1003                    &address,
1004                    outcome(
1005                        DelegationStatus::Terminated,
1006                        format!(
1007                            "the capsule was still running after {bound}s and was ended at the \
1008                             delegation deadline"
1009                        ),
1010                    ),
1011                    &records_root,
1012                );
1013            }
1014            Ending::Deliberate => {
1015                delegation::record_terminated(
1016                    &records_root,
1017                    outcome(
1018                        DelegationStatus::Terminated,
1019                        delegation::PARENT_ENDED_DETAIL.to_string(),
1020                    ),
1021                );
1022            }
1023            Ending::Exited(status) => {
1024                let status_text = match status {
1025                    Some(status) => status.to_string(),
1026                    None => "unknown exit status".to_string(),
1027                };
1028                let tail = stderr_tail.lines_at_end().join("\n");
1029                let detail = if tail.is_empty() {
1030                    format!(
1031                        "the child process ended without recording a completion ({status_text})"
1032                    )
1033                } else {
1034                    format!(
1035                        "the child process ended without recording a completion ({status_text}); \
1036                         its last stderr lines were:\n{tail}"
1037                    )
1038                };
1039                delegation::report_completion(
1040                    &handle,
1041                    &address,
1042                    outcome(DelegationStatus::Crashed, detail),
1043                    &records_root,
1044                );
1045            }
1046        }
1047    });
1048    match spawned {
1049        Ok(watcher) => Some(watcher),
1050        Err(error) => {
1051            crate::runtime_err!(
1052                "[capsule-runtime] delegation {}: the child's completion watcher could not be \
1053                 started: {error}",
1054                launched.delegation_id.as_deref().unwrap_or_default()
1055            );
1056            None
1057        }
1058    }
1059}
1060
1061/// The child's complete environment, built from a cleared one.
1062///
1063/// `capabilities.env.allow` is the child's own, not the parent's: a sibling's declaration reaches
1064/// nothing here, and a variable the parent holds but the child did not declare is simply absent.
1065/// The runtime-owned names — `PATH`, `HOME`, `LOGNAME` and `USER` ([`crate::shell::ACCOUNT_ENV`]),
1066/// `MURMUR_ROOST_URL`, [`HOST_WARNINGS_REPORTED_ENV`], [`SPAWNER_ENV`] on a delegated launch and
1067/// [`FORMATION_ID_ENV`] for a parent in a formation, in that order — are applied last, so a child
1068/// cannot displace the daemon URL it is required to register with, the handle it reports its
1069/// outcome to, or the formation it joins, by allowlisting the name. `PATH`, `HOME` and the account
1070/// names carry this process's values when it holds them and are absent when it does not, so a
1071/// child's harness and commands run as the account this process runs as.
1072/// [`HOST_WARNINGS_REPORTED_ENV`] is always `1`: the host-level warnings were printed by the
1073/// process the operator started, so a child does not repeat them, and its manifest cannot choose
1074/// otherwise.
1075///
1076/// Two kinds of name are never copied from this process's environment, whatever the child
1077/// allowlists:
1078///
1079/// * A formation member's names ([`crate::formation::is_member_grant_env`]): inheriting a
1080///   formation is not a grant, so a member's child holds no channel, no token, no callee and no
1081///   formation lifeline.
1082/// * [`SPAWNER_LIFELINE_ENV`]: a value here names a descriptor of this process's own, the lifeline
1083///   its own spawner handed it. The child holds a spawner lifeline of its own instead, which
1084///   [`launch_child_capsule`] hands it alongside this environment.
1085///
1086/// [`HOST_WARNINGS_REPORTED_ENV`]: crate::host_warnings::HOST_WARNINGS_REPORTED_ENV
1087/// [`SPAWNER_LIFELINE_ENV`]: crate::lifeline::SPAWNER_LIFELINE_ENV
1088pub(crate) fn child_environment(
1089    request: &ChildLaunchRequest,
1090    handle: Option<&SpawnerHandle>,
1091) -> Vec<(String, String)> {
1092    let mut env: Vec<(String, String)> = Vec::new();
1093    for key in &request.child_env_allow {
1094        if let Ok(value) = std::env::var(key) {
1095            env.push((key.clone(), value));
1096        }
1097    }
1098    env.retain(|(key, _)| {
1099        !matches!(key.as_str(), "PATH" | "HOME")
1100            && !crate::shell::ACCOUNT_ENV.contains(&key.as_str())
1101            && !crate::shell::LINEAGE_ENV.contains(&key.as_str())
1102            && !crate::formation::is_member_grant_env(key)
1103    });
1104
1105    if let Ok(path) = std::env::var("PATH") {
1106        env.push(("PATH".to_string(), path));
1107    }
1108    if let Ok(home) = std::env::var("HOME") {
1109        env.push(("HOME".to_string(), home));
1110    }
1111    env.extend(
1112        crate::shell::HostAccountEnv::from_host()
1113            .set()
1114            .iter()
1115            .cloned(),
1116    );
1117    env.push(("MURMUR_ROOST_URL".to_string(), request.roost_url.clone()));
1118    env.push(crate::host_warnings::reported_env_pair());
1119    // Last, with the other runtime-owned names, and only for a launch that has a spawner: a child
1120    // nobody wants told holds no handle at all, whatever this process's own environment carries
1121    // and whatever the child declared.
1122    if let Some(handle) = handle {
1123        env.push((SPAWNER_ENV.to_string(), handle.to_env_value()));
1124    }
1125    // A parent in no formation hands none on, whatever this process's own environment carries.
1126    if let Some(formation_id) = &request.formation_id {
1127        let (name, value) = formation_id.env_pair();
1128        env.push((name.to_string(), value));
1129    }
1130    env
1131}
1132
1133/// The binary a child is started from: [`MUR_BINARY_ENV`] when set, else this process's own image.
1134pub(crate) fn mur_binary() -> Result<PathBuf, RuntimeError> {
1135    if let Some(path) = std::env::var_os(MUR_BINARY_ENV) {
1136        if !path.is_empty() {
1137            return Ok(PathBuf::from(path));
1138        }
1139    }
1140    std::env::current_exe().map_err(|error| {
1141        RuntimeError::Runtime(format!(
1142            "cannot locate the mur binary to launch a child capsule with: {error}"
1143        ))
1144    })
1145}
1146
1147/// Creates `rel` beneath `records_root`, [`CHILDREN_DIR`] and the directory itself both at
1148/// [`CHILD_DIR_MODE`], no link followed, and returns it held, reached the same way: its identity
1149/// is what the child's `--workdir` is checked against. A link on the way is
1150/// [`RuntimeError::WorkdirPathRefused`].
1151fn create_child_dir(records_root: &WorkdirRoot, rel: &Path) -> Result<WorkdirRoot, RuntimeError> {
1152    records_root.create_dir_all(CHILDREN_DIR, CHILD_DIR_MODE)?;
1153    records_root.create_dir_all(rel, CHILD_DIR_MODE)?;
1154    records_root
1155        .open_beneath(&rel.to_string_lossy())?
1156        .ok_or_else(|| {
1157            RuntimeError::Runtime(format!(
1158                "the child capsule's directory {} vanished after its creation",
1159                records_root.display_path(rel)
1160            ))
1161        })
1162}
1163
1164/// The child's stderr as the parent keeps it: every line echoed to this process's stderr as it
1165/// arrives, with the last [`CHILD_STDERR_TAIL_LINES`] retained so a child that dies before
1166/// reporting can say why.
1167pub(crate) struct StderrTail {
1168    state: Mutex<StderrState>,
1169    /// Notified once [`StderrState::at_eof`] is set.
1170    drained: Condvar,
1171}
1172
1173#[derive(Default)]
1174struct StderrState {
1175    lines: Vec<String>,
1176    /// Whether the pipe has been read to its end, which the child's exit is what causes.
1177    at_eof: bool,
1178}
1179
1180impl StderrTail {
1181    /// What the child has said so far, without waiting for anything. For a child that is still
1182    /// running, which is the only state in which "so far" is the question being asked.
1183    pub(crate) fn lines(&self) -> Vec<String> {
1184        lock(&self.state).lines.clone()
1185    }
1186
1187    /// What a child that has ended left behind, read only once the pipe is at its end.
1188    ///
1189    /// The exit that closes the child's stdout closes its stderr in the same breath, so the thread
1190    /// that notices the ending and the thread still draining stderr are racing, and the tail is
1191    /// empty for as long as the drain has not caught up. Reading it unsynchronised turns "refused
1192    /// because the declared floor is unmeetable here" into a refusal that names no reason —
1193    /// the one case the tail is kept for. Bounded by [`CHILD_STDERR_DRAIN_TIMEOUT`].
1194    pub(crate) fn lines_at_end(&self) -> Vec<String> {
1195        let (state, _) = self
1196            .drained
1197            .wait_timeout_while(lock(&self.state), CHILD_STDERR_DRAIN_TIMEOUT, |state| {
1198                !state.at_eof
1199            })
1200            .unwrap_or_else(|poisoned| poisoned.into_inner());
1201        state.lines.clone()
1202    }
1203
1204    /// Mark the pipe finished and wake every post-mortem reader waiting on it.
1205    fn finish(&self) {
1206        lock(&self.state).at_eof = true;
1207        self.drained.notify_all();
1208    }
1209}
1210
1211/// Start draining the child's stderr into a [`StderrTail`].
1212///
1213/// The child's diagnostics belong to the operator running the parent, so nothing is swallowed —
1214/// this only remembers, in addition to printing. `prefix`, when given, is written in front of
1215/// every echoed line and kept off the remembered ones.
1216fn drain_stderr(child: &mut Child, prefix: Option<&str>) -> Arc<StderrTail> {
1217    let tail = Arc::new(StderrTail {
1218        state: Mutex::new(StderrState::default()),
1219        drained: Condvar::new(),
1220    });
1221    let Some(stderr) = child.stderr.take() else {
1222        // No pipe is an end already reached: a reader that waited here would wait out the whole
1223        // bound for lines that can never arrive.
1224        tail.finish();
1225        return tail;
1226    };
1227    let collector = Arc::clone(&tail);
1228    let prefix = prefix.unwrap_or_default().to_string();
1229    std::thread::spawn(move || {
1230        for line in BufReader::new(stderr).lines().map_while(Result::ok) {
1231            crate::runtime_err!("{prefix}{line}");
1232            let mut state = lock(&collector.state);
1233            if state.lines.len() == CHILD_STDERR_TAIL_LINES {
1234                state.lines.remove(0);
1235            }
1236            state.lines.push(line);
1237        }
1238        collector.finish();
1239    });
1240    tail
1241}
1242
1243/// Read the child's first standard-output line, then keep draining the pipe.
1244///
1245/// The drain matters: a child whose stdout pipe fills blocks forever, and a capsule that logs
1246/// after its launch line would otherwise deadlock against a parent that had stopped reading.
1247fn first_json_line(
1248    process: &Mutex<ChildProcess>,
1249    workdir: &Path,
1250    stderr_tail: &StderrTail,
1251) -> Result<String, RuntimeError> {
1252    let stdout = lock(process)
1253        .child
1254        .as_mut()
1255        .and_then(|child| child.stdout.take())
1256        .ok_or_else(|| {
1257            RuntimeError::Runtime("the child process exposed no standard output".to_string())
1258        })?;
1259
1260    let rx = relay_readiness_line(stdout, AfterReadiness::Stdout);
1261
1262    match rx.recv_timeout(CHILD_READY_TIMEOUT) {
1263        Ok(Some(line)) => Ok(line),
1264        Ok(None) => {
1265            let status = lock(process)
1266                .child
1267                .as_mut()
1268                .and_then(|child| child.wait().ok())
1269                .map(|status| status.to_string())
1270                .unwrap_or_else(|| "unknown".to_string());
1271            // The child's own refusal — an unmeetable containment floor, a registration the
1272            // daemon declined — is written to its stderr and is the only thing that explains this
1273            // failure, so it is quoted rather than replaced by a generic "did not start".
1274            let reason = stderr_tail.lines_at_end().join("\n");
1275            Err(RuntimeError::Runtime(format!(
1276                "the child capsule '{}' exited without reporting a launch line ({status}): {reason}",
1277                workdir.display()
1278            )))
1279        }
1280        Err(_) => Err(RuntimeError::Runtime(format!(
1281            "the child capsule '{}' did not report a launch line within {}s",
1282            workdir.display(),
1283            CHILD_READY_TIMEOUT.as_secs()
1284        ))),
1285    }
1286}
1287
1288/// How a launched `mur run` process's environment is built.
1289pub(crate) enum ProcessEnv<'a> {
1290    /// Cleared, then exactly these pairs: a delegated child, which holds nothing of the parent's
1291    /// that it did not declare.
1292    Cleared(&'a [(String, String)]),
1293    /// This process's own, with `set` applied over it and every name in `remove` taken out: a
1294    /// formation member, which the operator launched and which reads the operator's environment.
1295    Inherited {
1296        set: &'a [(String, String)],
1297        remove: &'a [&'a str],
1298    },
1299}
1300
1301/// Where a launched process's standard output goes once its readiness line has been read.
1302pub(crate) enum AfterReadiness {
1303    /// To this process's standard output, as written.
1304    Stdout,
1305    /// To this process's standard error, one line at a time behind this prefix.
1306    PrefixedStderr(String),
1307}
1308
1309/// One `mur run` subprocess to start.
1310pub(crate) struct ProcessLaunch<'a> {
1311    pub(crate) binary: &'a Path,
1312    /// The argument vector after the binary.
1313    pub(crate) args: &'a [String],
1314    /// The working directory, or `None` for this process's own.
1315    pub(crate) cwd: Option<&'a Path>,
1316    pub(crate) env: ProcessEnv<'a>,
1317    /// One line written to the process's standard input, which is then closed; `None` gives it a
1318    /// null standard input.
1319    pub(crate) stdin_line: Option<&'a str>,
1320    /// Written in front of every stderr line echoed to this process's stderr.
1321    pub(crate) stderr_prefix: Option<&'a str>,
1322    /// Hand the process this process's own standard output and error rather than pipes. Its
1323    /// stdout is then not read here, and its stderr tail stays empty.
1324    pub(crate) inherit_output: bool,
1325    /// Start the process as the leader of a process group of its own, so a signal meant for this
1326    /// process's group — a terminal's `^C` — does not reach it, and its whole tree can be
1327    /// signalled at once.
1328    pub(crate) own_process_group: bool,
1329    /// One descriptor of this process's to hand the process at the same number: a formation
1330    /// member's channel. It is close-on-exec here, so no other process this one starts inherits
1331    /// it, and the flag is cleared in the started process alone, just before it execs.
1332    pub(crate) inherit_fd: Option<i32>,
1333    /// The lifeline whose read end the process is handed. Every lifeline end is created
1334    /// close-on-exec, and this is the one the process's spawn clears it on.
1335    #[cfg(unix)]
1336    pub(crate) lifeline: Option<Lifeline<'a>>,
1337}
1338
1339/// The lifeline a started process is handed: a formation member's, or a delegated child's.
1340#[cfg(unix)]
1341#[derive(Clone, Copy)]
1342pub(crate) enum Lifeline<'a> {
1343    Member(&'a crate::lifeline::MemberLifeline),
1344    Child(&'a crate::lifeline::ChildLifeline),
1345}
1346
1347#[cfg(unix)]
1348impl Lifeline<'_> {
1349    fn hand_to(self, command: &mut Command) {
1350        match self {
1351            Lifeline::Member(lifeline) => lifeline.hand_to(command),
1352            Lifeline::Child(lifeline) => lifeline.hand_to(command),
1353        }
1354    }
1355}
1356
1357/// A process [`start_process`] started, with its stderr already being drained.
1358pub(crate) struct StartedProcess {
1359    /// Its standard output, unless inherited, is still piped and unread.
1360    pub(crate) child: Child,
1361    pub(crate) stderr_tail: Arc<StderrTail>,
1362    /// Whether [`ProcessLaunch::stdin_line`] was written: `Ok` when there was none to write.
1363    pub(crate) stdin_written: Result<(), String>,
1364}
1365
1366/// Start one `mur run` subprocess: spawn it, write its standard-input line, and start draining its
1367/// stderr. The one spawn site for a delegated child and a formation member alike.
1368pub(crate) fn start_process(launch: &ProcessLaunch<'_>) -> std::io::Result<StartedProcess> {
1369    let mut command = Command::new(launch.binary);
1370    command
1371        .args(launch.args)
1372        .stdin(if launch.stdin_line.is_some() {
1373            Stdio::piped()
1374        } else {
1375            Stdio::null()
1376        })
1377        .stdout(if launch.inherit_output {
1378            Stdio::inherit()
1379        } else {
1380            Stdio::piped()
1381        })
1382        .stderr(if launch.inherit_output {
1383            Stdio::inherit()
1384        } else {
1385            Stdio::piped()
1386        });
1387    if let Some(cwd) = launch.cwd {
1388        command.current_dir(cwd);
1389    }
1390    match launch.env {
1391        ProcessEnv::Cleared(pairs) => {
1392            command.env_clear();
1393            for (key, value) in pairs {
1394                command.env(key, value);
1395            }
1396        }
1397        ProcessEnv::Inherited { set, remove } => {
1398            for name in remove {
1399                command.env_remove(name);
1400            }
1401            for (key, value) in set {
1402                command.env(key, value);
1403            }
1404        }
1405    }
1406    #[cfg(unix)]
1407    if launch.own_process_group {
1408        use std::os::unix::process::CommandExt;
1409        command.process_group(0);
1410    }
1411    #[cfg(unix)]
1412    if let Some(fd) = launch.inherit_fd {
1413        keep_across_exec(&mut command, fd);
1414    }
1415    // No fd hygiene (`mark_inherited_fds_cloexec`) here: it fails the spawn outright on a kernel
1416    // without `CLOSE_RANGE_CLOEXEC`, and a lifeline needs none, since both ends are created
1417    // close-on-exec.
1418    // The lifeline's `pre_exec` is the last one registered.
1419    #[cfg(unix)]
1420    if let Some(lifeline) = launch.lifeline {
1421        lifeline.hand_to(&mut command);
1422    }
1423
1424    let mut child = command.spawn()?;
1425    let stdin_written = match launch.stdin_line {
1426        None => Ok(()),
1427        Some(line) => child
1428            .stdin
1429            .take()
1430            .ok_or_else(|| "the process exposed no standard input".to_string())
1431            .and_then(|mut stdin| writeln!(stdin, "{line}").map_err(|error| error.to_string())),
1432    };
1433    let stderr_tail = drain_stderr(&mut child, launch.stderr_prefix);
1434    Ok(StartedProcess {
1435        child,
1436        stderr_tail,
1437        stdin_written,
1438    })
1439}
1440
1441/// Clear `FD_CLOEXEC` on `fd` in the started process alone, after everything else that runs before
1442/// its `exec`, so that process inherits `fd` and no other does.
1443#[cfg(unix)]
1444#[allow(unsafe_code)]
1445fn keep_across_exec(command: &mut Command, fd: i32) {
1446    use std::os::unix::process::CommandExt;
1447    // SAFETY: the closure runs in the forked child between `fork` and `exec`, where only
1448    // async-signal-safe calls are allowed; it makes two `fcntl` calls on an integer descriptor,
1449    // which allocate nothing and take no lock.
1450    unsafe {
1451        command.pre_exec(move || {
1452            let flags = libc::fcntl(fd, libc::F_GETFD);
1453            if flags == -1 || libc::fcntl(fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC) == -1 {
1454                return Err(std::io::Error::last_os_error());
1455            }
1456            Ok(())
1457        });
1458    }
1459}
1460
1461/// Read a launched process's first standard-output line on a thread of its own, and hand it back
1462/// on the returned channel: `Some(line)` without its line ending, or `None` when the stream ended
1463/// first.
1464///
1465/// The thread then keeps draining the pipe to `after` for as long as the process lives. The drain
1466/// matters: a process whose stdout pipe fills blocks forever, and a capsule that logs after its
1467/// launch line would otherwise deadlock against a reader that had stopped reading. This thread
1468/// outlives the readiness line by the whole life of the process, so it is the one relay most
1469/// likely to meet a reader that has already gone: it relays each line and keeps draining either
1470/// way.
1471pub(crate) fn relay_readiness_line(
1472    stdout: std::process::ChildStdout,
1473    after: AfterReadiness,
1474) -> mpsc::Receiver<Option<String>> {
1475    let (tx, rx) = mpsc::channel::<Option<String>>();
1476    std::thread::spawn(move || {
1477        let mut reader = BufReader::new(stdout);
1478        let mut line = String::new();
1479        let first = match reader.read_line(&mut line) {
1480            Ok(0) | Err(_) => None,
1481            Ok(_) => Some(line.trim_end().to_string()),
1482        };
1483        let _ = tx.send(first);
1484        let mut rest = String::new();
1485        while let Ok(read) = reader.read_line(&mut rest) {
1486            if read == 0 {
1487                break;
1488            }
1489            match &after {
1490                AfterReadiness::Stdout => {
1491                    crate::diagnostic::raw_to_stdout(&rest);
1492                }
1493                AfterReadiness::PrefixedStderr(prefix) => {
1494                    crate::runtime_err!("{prefix}{}", rest.trim_end_matches(['\r', '\n']));
1495                }
1496            }
1497            rest.clear();
1498        }
1499    });
1500    rx
1501}
1502
1503#[cfg(test)]
1504mod tests {
1505    use super::*;
1506    use crate::host_warnings::HOST_WARNINGS_REPORTED_ENV;
1507
1508    #[test]
1509    fn a_child_directory_is_unique_per_delegation() {
1510        let parent = PathBuf::from("/tmp/parent");
1511        let first = child_workdir_for(&parent, "worker");
1512        let second = child_workdir_for(&parent, "worker");
1513
1514        assert_ne!(first, second);
1515        assert_eq!(first.parent(), Some(parent.join(CHILDREN_DIR).as_path()));
1516        assert!(first
1517            .file_name()
1518            .unwrap()
1519            .to_string_lossy()
1520            .starts_with("worker-"));
1521        // `<name>-<16 hex>`
1522        assert_eq!(
1523            first.file_name().unwrap().to_string_lossy().len(),
1524            "worker-".len() + 16
1525        );
1526    }
1527
1528    /// A full name's scope adds no directory level: the child of `local/worker` sits beside the
1529    /// child of `worker`.
1530    #[test]
1531    fn a_child_directory_is_named_by_the_bare_part_of_a_full_name() {
1532        let parent = PathBuf::from("/tmp/parent");
1533        let scoped = child_workdir_for(&parent, "local/worker");
1534
1535        assert_eq!(scoped.parent(), Some(parent.join(CHILDREN_DIR).as_path()));
1536        let name = scoped.file_name().unwrap().to_string_lossy().into_owned();
1537        assert!(name.starts_with("worker-"), "{name}");
1538        assert_eq!(name.len(), "worker-".len() + 16);
1539    }
1540
1541    fn request(env_allow: &[&str], spawner: Option<Spawner>) -> ChildLaunchRequest {
1542        ChildLaunchRequest {
1543            parent_records_root: WorkdirRoot::open(&std::env::temp_dir()).unwrap(),
1544            capsule_name: "worker".to_string(),
1545            capsule_version: "0.1.0".to_string(),
1546            grant: SpawnApproval::new("msa1.token".to_string()),
1547            child_env_allow: env_allow.iter().map(|name| name.to_string()).collect(),
1548            roost_url: "http://127.0.0.1:7700".to_string(),
1549            spawner,
1550            completion_deadline: None,
1551            formation_id: None,
1552            deadline: None,
1553            parent_workdir_snapshot: None,
1554        }
1555    }
1556
1557    /// A handle naming some *other* delegation, for the two cases that prove a child cannot be
1558    /// handed this process's own.
1559    ///
1560    /// Readable rather than nonsense, because `SPAWNER_ENV` is process-wide and every other test
1561    /// in this binary shares it: an unreadable value here refuses an unrelated `stage_session`
1562    /// running beside it.
1563    fn decoy() -> String {
1564        SpawnerHandle {
1565            session_id: "ses_decoy".to_string(),
1566            context_id: "ctx_decoy".to_string(),
1567            delegation_id: "dlg_decoy".to_string(),
1568            report_to: None,
1569        }
1570        .to_env_value()
1571    }
1572
1573    fn spawner() -> Spawner {
1574        Spawner {
1575            session_id: "ses_parent".to_string(),
1576            context_id: "ctx_parent".to_string(),
1577            report_to: Some(CompletionAddress {
1578                url: "http://127.0.0.1:7000".to_string(),
1579            }),
1580        }
1581    }
1582
1583    #[test]
1584    fn a_childs_environment_is_built_from_its_own_declaration() {
1585        std::env::set_var("MURMUR_CHILD_LAUNCH_TEST_A", "a");
1586        std::env::set_var("MURMUR_CHILD_LAUNCH_TEST_B", "b");
1587
1588        let env = child_environment(&request(&["MURMUR_CHILD_LAUNCH_TEST_A"], None), None);
1589        let names: Vec<&str> = env.iter().map(|(key, _)| key.as_str()).collect();
1590
1591        assert!(names.contains(&"MURMUR_CHILD_LAUNCH_TEST_A"));
1592        assert!(!names.contains(&"MURMUR_CHILD_LAUNCH_TEST_B"));
1593        assert!(names.contains(&"MURMUR_ROOST_URL"));
1594        assert!(!env.iter().any(|(_, value)| value.contains("msa1.token")));
1595    }
1596
1597    #[test]
1598    fn a_runtime_owned_name_cannot_be_displaced_by_a_declaration() {
1599        std::env::set_var("MURMUR_ROOST_URL", "http://127.0.0.1:1");
1600
1601        let env = child_environment(&request(&["MURMUR_ROOST_URL"], None), None);
1602        let urls: Vec<&String> = env
1603            .iter()
1604            .filter(|(key, _)| key == "MURMUR_ROOST_URL")
1605            .map(|(_, value)| value)
1606            .collect();
1607
1608        assert_eq!(urls, vec!["http://127.0.0.1:7700"]);
1609    }
1610
1611    /// The injected handle is the one the parent composed, whatever this process's own
1612    /// environment holds and whatever the child declared.
1613    #[test]
1614    fn the_spawner_handle_is_injected_last_and_cannot_be_displaced() {
1615        std::env::set_var(SPAWNER_ENV, decoy());
1616        let handle = SpawnerHandle::for_delegation(&spawner(), "dlg_0001".to_string());
1617
1618        let env = child_environment(&request(&[SPAWNER_ENV], Some(spawner())), Some(&handle));
1619        let injected: Vec<&String> = env
1620            .iter()
1621            .filter(|(key, _)| key == SPAWNER_ENV)
1622            .map(|(_, value)| value)
1623            .collect();
1624
1625        assert_eq!(injected.len(), 1, "{env:?}");
1626        assert_eq!(
1627            SpawnerHandle::parse(injected[0]).expect("the injected value is a handle"),
1628            handle
1629        );
1630        assert_eq!(
1631            env.last().map(|(key, _)| key.as_str()),
1632            Some(SPAWNER_ENV),
1633            "the handle is applied in the runtime-owned tail: {env:?}"
1634        );
1635        std::env::remove_var(SPAWNER_ENV);
1636    }
1637
1638    /// A launch with no spawner injects nothing, even from a decoy the launching process holds.
1639    #[test]
1640    fn a_launch_without_a_spawner_injects_no_handle() {
1641        std::env::set_var(SPAWNER_ENV, decoy());
1642
1643        let env = child_environment(&request(&[SPAWNER_ENV], None), None);
1644
1645        assert!(!env.iter().any(|(key, _)| key == SPAWNER_ENV), "{env:?}");
1646        std::env::remove_var(SPAWNER_ENV);
1647    }
1648
1649    /// A child's formation is the one the parent's request names, applied after the spawner
1650    /// handle, whatever this process's own environment holds and whatever the child declared.
1651    #[test]
1652    fn the_formation_id_is_injected_last_and_cannot_be_displaced() {
1653        let _guard = crate::formation::FORMATION_ENV_LOCK
1654            .lock()
1655            .unwrap_or_else(|poisoned| poisoned.into_inner());
1656        let decoy = FormationId::mint();
1657        std::env::set_var(FORMATION_ID_ENV, decoy.as_str());
1658        let injected = |env: &[(String, String)]| -> Vec<String> {
1659            env.iter()
1660                .filter(|(key, _)| key == FORMATION_ID_ENV)
1661                .map(|(_, value)| value.clone())
1662                .collect()
1663        };
1664
1665        let outside = child_environment(&request(&[FORMATION_ID_ENV], None), None);
1666        assert!(injected(&outside).is_empty(), "{outside:?}");
1667
1668        let other = FormationId::mint();
1669        let handle = SpawnerHandle::for_delegation(&spawner(), "dlg_0001".to_string());
1670        for (spawner, handle) in [(Some(spawner()), Some(&handle)), (None, None)] {
1671            let mut member = request(&[FORMATION_ID_ENV], spawner);
1672            member.formation_id = Some(other.clone());
1673            let env = child_environment(&member, handle);
1674            assert_eq!(injected(&env), vec![other.as_str().to_string()], "{env:?}");
1675            assert_eq!(
1676                env.last(),
1677                Some(&(FORMATION_ID_ENV.to_string(), other.as_str().to_string())),
1678                "the formation id is the last runtime-owned name: {env:?}"
1679            );
1680        }
1681        std::env::remove_var(FORMATION_ID_ENV);
1682    }
1683
1684    /// Every child is told its launcher already printed the host-level warnings, once, whatever
1685    /// this process's own environment holds and whatever the child declared.
1686    #[test]
1687    fn every_child_is_told_host_warnings_were_reported() {
1688        std::env::set_var(HOST_WARNINGS_REPORTED_ENV, "0");
1689        let handle = SpawnerHandle::for_delegation(&spawner(), "dlg_0001".to_string());
1690        for (spawner, handle) in [(Some(spawner()), Some(&handle)), (None, None)] {
1691            for allow in [&[][..], &[HOST_WARNINGS_REPORTED_ENV][..]] {
1692                let env = child_environment(&request(allow, spawner.clone()), handle);
1693                let values: Vec<&str> = env
1694                    .iter()
1695                    .filter(|(key, _)| key == HOST_WARNINGS_REPORTED_ENV)
1696                    .map(|(_, value)| value.as_str())
1697                    .collect();
1698                assert_eq!(values, vec!["1"], "{env:?}");
1699            }
1700        }
1701        std::env::remove_var(HOST_WARNINGS_REPORTED_ENV);
1702    }
1703
1704    fn values_of<'a>(env: &'a [(String, String)], name: &str) -> Vec<&'a str> {
1705        env.iter()
1706            .filter(|(key, _)| key == name)
1707            .map(|(_, value)| value.as_str())
1708            .collect()
1709    }
1710
1711    /// A child runs as the account its parent runs as: one `USER` and one `LOGNAME`, the parent's,
1712    /// whether or not the child allowlists either name.
1713    #[test]
1714    fn a_child_carries_the_parents_account_names() {
1715        let _guard = crate::shell::ACCOUNT_ENV_LOCK
1716            .lock()
1717            .unwrap_or_else(|poisoned| poisoned.into_inner());
1718        std::env::set_var("USER", "murmur-parent-user");
1719        std::env::set_var("LOGNAME", "murmur-parent-login");
1720        for allow in [&[][..], &["USER"][..], &["USER", "LOGNAME"][..]] {
1721            let env = child_environment(&request(allow, None), None);
1722            assert_eq!(
1723                values_of(&env, "USER"),
1724                vec!["murmur-parent-user"],
1725                "{env:?}"
1726            );
1727            assert_eq!(
1728                values_of(&env, "LOGNAME"),
1729                vec!["murmur-parent-login"],
1730                "{env:?}"
1731            );
1732        }
1733    }
1734
1735    /// A parent that holds neither account name hands neither on, and none is made up.
1736    #[test]
1737    fn a_child_of_a_parent_without_account_names_has_none() {
1738        let _guard = crate::shell::ACCOUNT_ENV_LOCK
1739            .lock()
1740            .unwrap_or_else(|poisoned| poisoned.into_inner());
1741        let saved: Vec<_> = crate::shell::ACCOUNT_ENV
1742            .iter()
1743            .map(|name| (*name, std::env::var_os(name)))
1744            .collect();
1745        for name in crate::shell::ACCOUNT_ENV {
1746            std::env::remove_var(name);
1747        }
1748        let env = child_environment(&request(&["USER", "LOGNAME"], None), None);
1749        for (name, value) in saved {
1750            if let Some(value) = value {
1751                std::env::set_var(name, value);
1752            }
1753        }
1754        assert!(values_of(&env, "USER").is_empty(), "{env:?}");
1755        assert!(values_of(&env, "LOGNAME").is_empty(), "{env:?}");
1756    }
1757
1758    /// A child's exit closes its stdout and its stderr together, so the reader that reports the
1759    /// ending can reach the tail before the drain has appended to it. The post-mortem read is the
1760    /// one that must not: an empty tail is a refusal that names no reason.
1761    #[test]
1762    fn a_tail_read_after_the_end_waits_for_the_drain_to_catch_up() {
1763        let tail = Arc::new(StderrTail {
1764            state: Mutex::new(StderrState::default()),
1765            drained: Condvar::new(),
1766        });
1767
1768        let writer = Arc::clone(&tail);
1769        std::thread::spawn(move || {
1770            std::thread::sleep(Duration::from_millis(200));
1771            lock(&writer.state)
1772                .lines
1773                .push("error[E-CAP-003]: not achievable on this host".to_string());
1774            writer.finish();
1775        });
1776
1777        assert!(tail.lines().is_empty());
1778        assert_eq!(
1779            tail.lines_at_end(),
1780            vec!["error[E-CAP-003]: not achievable on this host".to_string()]
1781        );
1782    }
1783
1784    /// A child that exposed no stderr pipe has nothing to wait for, so the bound is never spent on
1785    /// lines that cannot arrive.
1786    #[test]
1787    fn a_tail_with_no_pipe_behind_it_is_at_its_end_already() {
1788        let tail = StderrTail {
1789            state: Mutex::new(StderrState::default()),
1790            drained: Condvar::new(),
1791        };
1792        tail.finish();
1793
1794        let started = Instant::now();
1795        assert!(tail.lines_at_end().is_empty());
1796        assert!(
1797            started.elapsed() < CHILD_STDERR_DRAIN_TIMEOUT,
1798            "{started:?}"
1799        );
1800    }
1801
1802    #[test]
1803    fn child_launch_reads_the_operator_token_from_the_readiness_line() {
1804        let report = serde_json::json!({
1805            "url": "localhost:1",
1806            "session_id": "ses_x",
1807            "tokens": {"operator": "mdt1.a.b", "watcher": "mdt1.c.d"},
1808        });
1809        assert_eq!(
1810            readiness_door_token(&report).map(|token| token.expose().to_string()),
1811            Some("mdt1.a.b".to_string())
1812        );
1813        let public = serde_json::json!({"url": "localhost:1", "session_id": "ses_x"});
1814        assert_eq!(readiness_door_token(&public), None);
1815    }
1816
1817    #[test]
1818    fn child_launch_debug_prints_no_door_token() {
1819        let child = LaunchedChild {
1820            workdir: PathBuf::from("/tmp/child"),
1821            workdir_root: WorkdirRoot::open(&std::env::temp_dir()).unwrap(),
1822            session_id: "ses_child".to_string(),
1823            records_dir: PathBuf::new(),
1824            records_root: WorkdirRoot::open(&std::env::temp_dir()).unwrap(),
1825            capsule_url: "http://localhost:1".to_string(),
1826            argv: Vec::new(),
1827            env: Vec::new(),
1828            delegation_id: None,
1829            formation_id: None,
1830            task_id: None,
1831            door_token: Some(crate::door_auth::DoorToken::new(
1832                "mdt1.secretpayload.secretmac".to_string(),
1833            )),
1834            process: Arc::new(Mutex::new(ChildProcess {
1835                child: None,
1836                deliberate: false,
1837                status: None,
1838                terminated_at: None,
1839                #[cfg(unix)]
1840                lifeline: None,
1841            })),
1842            stderr_tail: Arc::new(StderrTail {
1843                state: Mutex::new(StderrState::default()),
1844                drained: Condvar::new(),
1845            }),
1846            started: Instant::now(),
1847            watcher: None,
1848            workdir_snapshot: None,
1849        };
1850        let debug = format!("{child:?}");
1851        assert!(!debug.contains("secretpayload"), "{debug}");
1852        assert!(debug.contains("door_token"), "{debug}");
1853    }
1854
1855    /// The code of `start_process`, with its comments taken out.
1856    fn start_process_code() -> String {
1857        let source = include_str!("child_launch.rs");
1858        let start = source
1859            .find("pub(crate) fn start_process(")
1860            .expect("start_process is defined here");
1861        let end = start
1862            + source[start..]
1863                .find("\n}\n")
1864                .expect("start_process ends at a closing brace");
1865        source[start..end]
1866            .lines()
1867            .filter(|line| !line.trim_start().starts_with("//"))
1868            .collect::<Vec<_>>()
1869            .join("\n")
1870    }
1871
1872    /// The one spawn site for delegated children and formation members registers no fd hygiene:
1873    /// `mark_inherited_fds_cloexec` is a `close_range(CLOSE_RANGE_CLOEXEC)`, which fails the spawn
1874    /// outright on a kernel below 5.11. The lifeline's two-`fcntl` `pre_exec` is the last one
1875    /// registered.
1876    #[test]
1877    fn start_process_registers_no_fd_hygiene() {
1878        let code = start_process_code();
1879        for name in [
1880            "prepare_enforcement",
1881            "mark_inherited_fds_cloexec",
1882            "close_range",
1883        ] {
1884            assert!(!code.contains(name), "start_process names {name}:\n{code}");
1885        }
1886        let handed = code
1887            .rfind("lifeline.hand_to")
1888            .expect("the lifeline is handed");
1889        let kept = code
1890            .find("keep_across_exec")
1891            .expect("an inherited descriptor is kept");
1892        assert!(kept < handed, "the lifeline is registered last:\n{code}");
1893        assert!(
1894            !code[handed..].contains("pre_exec"),
1895            "nothing is registered after the lifeline:\n{code}"
1896        );
1897    }
1898
1899    #[cfg(unix)]
1900    fn process_with_lifeline(program: &str, args: &[&str]) -> (ChildProcess, i32) {
1901        let lifeline = crate::lifeline::ChildLifeline::new().unwrap();
1902        let write = lifeline.write_fd().unwrap();
1903        let child = Command::new(program).args(args).spawn().unwrap();
1904        (
1905            ChildProcess {
1906                child: Some(child),
1907                deliberate: false,
1908                status: None,
1909                terminated_at: None,
1910                lifeline: Some(lifeline),
1911            },
1912            write,
1913        )
1914    }
1915
1916    /// Ending a child deliberately closes the write end of its lifeline after the reap.
1917    #[cfg(unix)]
1918    #[test]
1919    fn ending_a_child_closes_its_lifeline() {
1920        let (process, _) = process_with_lifeline("sleep", &["30"]);
1921        let process = Mutex::new(process);
1922        end_process(&process).unwrap();
1923        assert!(lock(&process).lifeline.is_none());
1924        assert!(lock(&process).status.is_some());
1925        end_process(&process).unwrap();
1926    }
1927
1928    /// A child ended on purpose is sent `SIGTERM` first and given the chance to wind down: a
1929    /// child that exits on the signal is reaped with its own exit status, well inside the grace.
1930    #[cfg(unix)]
1931    #[test]
1932    fn ending_a_child_sends_sigterm_and_waits_for_it_to_wind_down() {
1933        let dir = tempfile::tempdir().unwrap();
1934        let marker = dir.path().join("wound-down");
1935        let script = format!(
1936            "trap 'echo wound-down > {}; exit 7' TERM; sleep 30 & wait",
1937            marker.display()
1938        );
1939        let (process, _) = process_with_lifeline("bash", &["-c", &script]);
1940        let process = Mutex::new(process);
1941        // Long enough for bash to install its trap before the signal arrives.
1942        std::thread::sleep(Duration::from_millis(300));
1943        let began = Instant::now();
1944        end_process(&process).unwrap();
1945        assert!(
1946            began.elapsed() < CHILD_END_GRACE,
1947            "took {:?}",
1948            began.elapsed()
1949        );
1950        assert_eq!(
1951            std::fs::read_to_string(&marker).unwrap().trim(),
1952            "wound-down"
1953        );
1954        assert_eq!(
1955            lock(&process).status.and_then(|status| status.code()),
1956            Some(7)
1957        );
1958        assert!(lock(&process).lifeline.is_none());
1959    }
1960
1961    /// A child that ignores `SIGTERM` is killed once [`CHILD_END_GRACE`] has passed, and no later.
1962    #[cfg(unix)]
1963    #[test]
1964    fn a_child_that_ignores_sigterm_is_killed_at_the_grace() {
1965        use std::os::unix::process::ExitStatusExt as _;
1966        let (process, _) =
1967            process_with_lifeline("bash", &["-c", "trap '' TERM; while :; do sleep 0.1; done"]);
1968        let process = Mutex::new(process);
1969        std::thread::sleep(Duration::from_millis(300));
1970        let began = Instant::now();
1971        end_process(&process).unwrap();
1972        let took = began.elapsed();
1973        assert!(took >= CHILD_END_GRACE, "killed before the grace: {took:?}");
1974        assert!(
1975            took < CHILD_END_GRACE + Duration::from_secs(5),
1976            "took {took:?}"
1977        );
1978        assert_eq!(
1979            lock(&process).status.and_then(|status| status.signal()),
1980            Some(libc::SIGKILL)
1981        );
1982    }
1983
1984    /// Children signalled together with [`LaunchedChild::begin_ending`] share one grace: ending
1985    /// two that both ignore `SIGTERM` takes one [`CHILD_END_GRACE`], not two.
1986    #[cfg(unix)]
1987    #[test]
1988    fn children_signalled_together_wait_one_grace_between_them() {
1989        let wedged = || {
1990            let (process, _) =
1991                process_with_lifeline("bash", &["-c", "trap '' TERM; while :; do sleep 0.1; done"]);
1992            LaunchedChild {
1993                workdir: PathBuf::from("/tmp/child"),
1994                workdir_root: WorkdirRoot::open(&std::env::temp_dir()).unwrap(),
1995                session_id: "ses_child".to_string(),
1996                records_dir: PathBuf::new(),
1997                records_root: WorkdirRoot::open(&std::env::temp_dir()).unwrap(),
1998                capsule_url: String::new(),
1999                argv: Vec::new(),
2000                env: Vec::new(),
2001                delegation_id: None,
2002                formation_id: None,
2003                task_id: None,
2004                door_token: None,
2005                process: Arc::new(Mutex::new(process)),
2006                stderr_tail: Arc::new(StderrTail {
2007                    state: Mutex::new(StderrState::default()),
2008                    drained: Condvar::new(),
2009                }),
2010                started: Instant::now(),
2011                watcher: None,
2012                workdir_snapshot: None,
2013            }
2014        };
2015        let children = vec![wedged(), wedged()];
2016        std::thread::sleep(Duration::from_millis(300));
2017        let began = Instant::now();
2018        for child in &children {
2019            child.begin_ending();
2020        }
2021        drop(children);
2022        let took = began.elapsed();
2023        assert!(took >= CHILD_END_GRACE, "took {took:?}");
2024        assert!(
2025            took < CHILD_END_GRACE * 2,
2026            "two children waited a grace each: {took:?}"
2027        );
2028    }
2029
2030    /// A child observed to have exited on its own has its lifeline closed by the observer.
2031    #[cfg(unix)]
2032    #[test]
2033    fn a_child_seen_exited_has_its_lifeline_closed() {
2034        let (mut process, _) = process_with_lifeline("true", &[]);
2035        let deadline = Instant::now() + Duration::from_secs(10);
2036        let ending = loop {
2037            if let Some(ending) = process.poll() {
2038                break ending;
2039            }
2040            assert!(Instant::now() < deadline, "`true` never exited");
2041            std::thread::sleep(Duration::from_millis(20));
2042        };
2043        assert!(matches!(ending, Ending::Exited(Some(_))));
2044        assert!(process.lifeline.is_none());
2045    }
2046
2047    /// A delegated child lives exactly as long as its handle or its own run. Dropping the handle
2048    /// of a running child kills it and closes the write end of its lifeline; a child that exits on
2049    /// its own is seen exited by [`LaunchedChild::has_exited`], which closes the write end too,
2050    /// and its handle's drop then only reaps it.
2051    #[cfg(unix)]
2052    #[test]
2053    #[allow(unsafe_code)]
2054    fn a_childs_lifeline_closes_when_its_handle_drops_or_its_end_is_seen() {
2055        let launched = |program: &str, args: &[&str]| {
2056            let (process, write) = process_with_lifeline(program, args);
2057            let process = Arc::new(Mutex::new(process));
2058            let child = LaunchedChild {
2059                workdir: PathBuf::from("/tmp/child"),
2060                workdir_root: WorkdirRoot::open(&std::env::temp_dir()).unwrap(),
2061                session_id: "ses_child".to_string(),
2062                records_dir: PathBuf::new(),
2063                records_root: WorkdirRoot::open(&std::env::temp_dir()).unwrap(),
2064                capsule_url: String::new(),
2065                argv: Vec::new(),
2066                env: Vec::new(),
2067                delegation_id: None,
2068                formation_id: None,
2069                task_id: None,
2070                door_token: None,
2071                process: Arc::clone(&process),
2072                stderr_tail: Arc::new(StderrTail {
2073                    state: Mutex::new(StderrState::default()),
2074                    drained: Condvar::new(),
2075                }),
2076                started: Instant::now(),
2077                watcher: None,
2078                workdir_snapshot: None,
2079            };
2080            (child, process, write)
2081        };
2082        let is_open = |fd: i32| {
2083            // SAFETY: `F_GETFD` takes and returns integers.
2084            unsafe { libc::fcntl(fd, libc::F_GETFD) >= 0 }
2085        };
2086
2087        // Held: the child runs and the write end stays open for as long as the handle does.
2088        let (child, process, write) = launched("sleep", &["30"]);
2089        assert!(!child.has_exited());
2090        assert!(is_open(write), "a held child's write end is open");
2091        drop(child);
2092        assert!(
2093            lock(&process).lifeline.is_none(),
2094            "dropping the handle closes it"
2095        );
2096        assert!(
2097            lock(&process).deliberate,
2098            "a running child's drop is deliberate"
2099        );
2100        // A status is set only by a `wait` that returned, so the child is reaped and gone.
2101        assert!(lock(&process).status.is_some(), "and reaps the child");
2102
2103        // Ran to its end: seen exited, the write end closes without the handle being dropped.
2104        let (child, process, _) = launched("true", &[]);
2105        let deadline = Instant::now() + Duration::from_secs(10);
2106        while !child.has_exited() {
2107            assert!(Instant::now() < deadline, "`true` never exited");
2108            std::thread::sleep(Duration::from_millis(20));
2109        }
2110        assert!(
2111            lock(&process).lifeline.is_none(),
2112            "seeing the end closes it"
2113        );
2114        drop(child);
2115        assert!(
2116            !lock(&process).deliberate,
2117            "a child that ended on its own is not recorded as ended by its parent"
2118        );
2119    }
2120
2121    /// A child its parent ends on purpose is recorded `terminated` by the launcher, in the words
2122    /// every such record uses, and posted to nobody: the address here answers nothing.
2123    #[cfg(unix)]
2124    #[test]
2125    fn a_child_its_parent_ends_is_recorded_in_the_shared_words() {
2126        let dir = tempfile::tempdir().unwrap();
2127        let (process, _) = process_with_lifeline("sleep", &["30"]);
2128        let mut child = LaunchedChild {
2129            workdir: dir.path().to_path_buf(),
2130            workdir_root: WorkdirRoot::open(dir.path()).unwrap(),
2131            session_id: "ses_child".to_string(),
2132            records_dir: dir.path().to_path_buf(),
2133            records_root: WorkdirRoot::open(dir.path()).unwrap(),
2134            capsule_url: String::new(),
2135            argv: Vec::new(),
2136            env: Vec::new(),
2137            delegation_id: Some("dlg_watched".to_string()),
2138            formation_id: None,
2139            task_id: None,
2140            door_token: None,
2141            process: Arc::new(Mutex::new(process)),
2142            stderr_tail: Arc::new(StderrTail {
2143                state: Mutex::new(StderrState::default()),
2144                drained: Condvar::new(),
2145            }),
2146            started: Instant::now(),
2147            watcher: None,
2148            workdir_snapshot: None,
2149        };
2150        let watcher = watch_for_completion(
2151            &child,
2152            SpawnerHandle::for_delegation(&spawner(), "dlg_watched".to_string()),
2153            CompletionAddress {
2154                url: "http://127.0.0.1:1".to_string(),
2155            },
2156            "worker",
2157            "0.1.0",
2158            None,
2159        )
2160        .expect("the watcher starts");
2161
2162        child.shutdown().unwrap();
2163        watcher.join().unwrap();
2164
2165        let recorded = delegation::read_completion(&WorkdirRoot::open(dir.path()).unwrap())
2166            .expect("the ending is recorded");
2167        assert_eq!(recorded.status, DelegationStatus::Terminated);
2168        assert_eq!(recorded.reported_by, Reporter::Launcher);
2169        assert!(!recorded.delivered);
2170        assert_eq!(
2171            recorded.detail.as_deref(),
2172            Some(delegation::PARENT_ENDED_DETAIL)
2173        );
2174    }
2175
2176    /// A child that records its own completion while it winds down from `SIGTERM` does not
2177    /// decide the outcome of a delegation its parent ended: the launcher's `terminated` record is
2178    /// written over it, and the child's is never re-posted.
2179    #[cfg(unix)]
2180    #[test]
2181    fn a_completion_the_child_records_while_being_ended_is_overwritten_by_the_launcher() {
2182        let dir = tempfile::tempdir().unwrap();
2183        let own = DelegationOutcome {
2184            delegation_id: "dlg_watched".to_string(),
2185            capsule_name: "worker".to_string(),
2186            capsule_version: "0.1.0".to_string(),
2187            session_id: "ses_child".to_string(),
2188            status: DelegationStatus::Error,
2189            result_path: None,
2190            workdir: dir.path().display().to_string(),
2191            duration_ms: 1,
2192            detail: None,
2193            reported_by: Reporter::Child,
2194            delivered: false,
2195            delivery_error: None,
2196        };
2197        let staged = dir.path().join("own-record.json");
2198        std::fs::write(&staged, serde_json::to_vec(&own).unwrap()).unwrap();
2199        let script = format!(
2200            "trap 'cp {} {}; exit 0' TERM; sleep 30 & wait",
2201            staged.display(),
2202            dir.path().join(delegation::COMPLETION_FILE).display()
2203        );
2204        let (process, _) = process_with_lifeline("bash", &["-c", &script]);
2205        let mut child = LaunchedChild {
2206            workdir: dir.path().to_path_buf(),
2207            workdir_root: WorkdirRoot::open(dir.path()).unwrap(),
2208            session_id: "ses_child".to_string(),
2209            records_dir: dir.path().to_path_buf(),
2210            records_root: WorkdirRoot::open(dir.path()).unwrap(),
2211            capsule_url: String::new(),
2212            argv: Vec::new(),
2213            env: Vec::new(),
2214            delegation_id: Some("dlg_watched".to_string()),
2215            formation_id: None,
2216            task_id: None,
2217            door_token: None,
2218            process: Arc::new(Mutex::new(process)),
2219            stderr_tail: Arc::new(StderrTail {
2220                state: Mutex::new(StderrState::default()),
2221                drained: Condvar::new(),
2222            }),
2223            started: Instant::now(),
2224            watcher: None,
2225            workdir_snapshot: None,
2226        };
2227        let watcher = watch_for_completion(
2228            &child,
2229            SpawnerHandle::for_delegation(&spawner(), "dlg_watched".to_string()),
2230            CompletionAddress {
2231                url: "http://127.0.0.1:1".to_string(),
2232            },
2233            "worker",
2234            "0.1.0",
2235            None,
2236        )
2237        .expect("the watcher starts");
2238        std::thread::sleep(Duration::from_millis(300));
2239
2240        child.shutdown().unwrap();
2241        watcher.join().unwrap();
2242        let recorded = delegation::read_completion(&WorkdirRoot::open(dir.path()).unwrap())
2243            .expect("the ending is recorded");
2244        assert_eq!(recorded.status, DelegationStatus::Terminated);
2245        assert_eq!(recorded.reported_by, Reporter::Launcher);
2246        assert!(!recorded.delivered);
2247        assert_eq!(recorded.delivery_error, None);
2248    }
2249}