use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, ExitStatus, Stdio};
use std::sync::{mpsc, Arc, Condvar, Mutex};
use std::time::{Duration, Instant};
use crate::delegation::{
self, CompletionAddress, DelegationOutcome, DelegationStatus, Reporter, Spawner, SpawnerHandle,
SPAWNER_ENV,
};
use crate::errors::RuntimeError;
use crate::formation::{FormationId, FORMATION_ID_ENV};
use crate::host_warnings::HOST_WARNINGS_REPORTED_ENV;
use crate::lifeline::SPAWNER_LIFELINE_ENV;
use crate::mac_token;
use crate::spawn_credential::SpawnApproval;
pub const MUR_BINARY_ENV: &str = "MURMUR_MUR_BINARY";
pub const READINESS_TOKENS_FLAG: &str = "--readiness-tokens";
const CHILD_READY_TIMEOUT: Duration = Duration::from_secs(180);
#[cfg(unix)]
const CHILD_DIR_MODE: u32 = 0o700;
const CHILD_STDERR_TAIL_LINES: usize = 20;
const CHILD_STDERR_DRAIN_TIMEOUT: Duration = Duration::from_secs(5);
const CHILD_WATCH_INTERVAL: Duration = Duration::from_millis(100);
pub const CHILD_END_GRACE: Duration = Duration::from_secs(5);
const CHILD_END_POLL: Duration = Duration::from_millis(20);
#[derive(Debug)]
pub struct ChildLaunchRequest {
pub parent_accessible_workdir: PathBuf,
pub capsule_name: String,
pub capsule_version: String,
pub grant: SpawnApproval,
pub child_env_allow: Vec<String>,
pub roost_url: String,
pub spawner: Option<Spawner>,
pub completion_deadline: Option<Duration>,
pub formation_id: Option<FormationId>,
}
enum Ending {
Exited(Option<ExitStatus>),
Deliberate,
}
struct ChildProcess {
child: Option<Child>,
deliberate: bool,
status: Option<ExitStatus>,
terminated_at: Option<Instant>,
#[cfg(unix)]
lifeline: Option<crate::lifeline::ChildLifeline>,
}
impl ChildProcess {
fn close_lifeline(&mut self) {
#[cfg(unix)]
if let Some(mut lifeline) = self.lifeline.take() {
lifeline.close();
}
}
fn poll(&mut self) -> Option<Ending> {
if self.deliberate {
return (!self.is_being_ended()).then_some(Ending::Deliberate);
}
let Some(child) = self.child.as_mut() else {
return Some(Ending::Exited(self.status));
};
let ending = match child.try_wait() {
Ok(Some(status)) => {
self.status = Some(status);
Ending::Exited(Some(status))
}
Ok(None) => return None,
Err(_) => Ending::Exited(self.status),
};
self.close_lifeline();
Some(ending)
}
#[allow(unsafe_code)]
fn terminate(&mut self) {
if self.status.is_none() {
self.deliberate = true;
}
if self.terminated_at.is_some() {
return;
}
let Some(child) = self.child.as_mut() else {
return;
};
if let Ok(Some(status)) = child.try_wait() {
self.status = Some(status);
return;
}
#[cfg(unix)]
if let Ok(pid) = libc::pid_t::try_from(child.id()) {
unsafe {
libc::kill(pid, libc::SIGTERM);
}
}
#[cfg(not(unix))]
let _ = child.kill();
self.terminated_at = Some(Instant::now());
}
fn is_being_ended(&self) -> bool {
self.deliberate && self.status.is_none() && self.terminated_at.is_some()
}
}
fn end_process(process: &Mutex<ChildProcess>) -> Result<(), std::io::Error> {
let (mut child, signalled) = {
let mut process = lock(process);
process.terminate();
let Some(child) = process.child.take() else {
if process.status.is_some() || process.terminated_at.is_none() {
process.close_lifeline();
}
return Ok(());
};
(child, process.terminated_at)
};
let waited = match signalled {
None => child.wait(),
Some(signalled) => loop {
match child.try_wait() {
Ok(Some(status)) => break Ok(status),
Ok(None) if signalled.elapsed() < CHILD_END_GRACE => {
std::thread::sleep(CHILD_END_POLL);
}
_ => {
let _ = child.kill();
break child.wait();
}
}
},
};
let mut process = lock(process);
process.close_lifeline();
match waited {
Ok(status) => {
process.status = Some(status);
Ok(())
}
Err(error) => {
process.terminated_at = None;
Err(error)
}
}
}
pub struct LaunchedChild {
pub workdir: PathBuf,
pub session_id: String,
pub capsule_url: String,
pub argv: Vec<String>,
pub env: Vec<(String, String)>,
pub delegation_id: Option<String>,
pub door_token: Option<crate::door_auth::DoorToken>,
pub formation_id: Option<FormationId>,
process: Arc<Mutex<ChildProcess>>,
stderr_tail: Arc<StderrTail>,
started: Instant,
watcher: Option<std::thread::JoinHandle<()>>,
}
impl std::fmt::Debug for LaunchedChild {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LaunchedChild")
.field("workdir", &self.workdir)
.field("session_id", &self.session_id)
.field("capsule_url", &self.capsule_url)
.field("delegation_id", &self.delegation_id)
.field("formation_id", &self.formation_id)
.field(
"door_token",
&self.door_token.as_ref().map(|_| "<redacted>"),
)
.field("pid", &self.pid())
.finish()
}
}
impl LaunchedChild {
pub fn pid(&self) -> u32 {
lock(&self.process)
.child
.as_ref()
.map(Child::id)
.unwrap_or(0)
}
pub fn stderr_tail(&self) -> Vec<String> {
self.stderr_tail.lines()
}
pub fn begin_ending(&self) {
lock(&self.process).terminate();
}
pub fn shutdown(&mut self) -> Result<(), RuntimeError> {
end_process(&self.process).map_err(|error| {
RuntimeError::Runtime(format!("failed to reap child capsule: {error}"))
})
}
pub fn is_being_ended(&self) -> bool {
lock(&self.process).is_being_ended()
}
pub fn watcher_finished(&self) -> bool {
self.watcher
.as_ref()
.is_none_or(std::thread::JoinHandle::is_finished)
}
pub fn has_exited(&self) -> bool {
lock(&self.process).poll().is_some()
}
}
impl Drop for LaunchedChild {
fn drop(&mut self) {
let _ = end_process(&self.process);
}
}
pub(crate) fn workdir_relative_to(workdir: &Path, root: &Path) -> String {
workdir
.strip_prefix(root)
.unwrap_or(workdir)
.to_string_lossy()
.replace('\\', "/")
}
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
mutex
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
pub fn child_workdir_for(parent_accessible_workdir: &Path, capsule_name: &str) -> PathBuf {
let suffix = mac_token::random_hex(8).unwrap_or_else(|_| {
format!(
"{:016x}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|elapsed| elapsed.as_nanos() as u64)
.unwrap_or(0)
)
});
parent_accessible_workdir
.join(".murmur")
.join("children")
.join(format!("{capsule_name}-{suffix}"))
}
pub fn launch_child_capsule(request: ChildLaunchRequest) -> Result<LaunchedChild, RuntimeError> {
#[cfg(unix)]
let mut lifeline = crate::lifeline::ChildLifeline::new().map_err(|error| {
RuntimeError::Runtime(format!(
"the child capsule's spawner lifeline could not be created: {error}"
))
})?;
let workdir = child_workdir_for(&request.parent_accessible_workdir, &request.capsule_name);
create_child_dir(&workdir)?;
let binary = mur_binary()?;
let argv: Vec<String> = vec![
binary.display().to_string(),
"run".to_string(),
"--capsule".to_string(),
request.capsule_name.clone(),
"--capsule-version".to_string(),
request.capsule_version.clone(),
"--workdir".to_string(),
workdir.display().to_string(),
"--json".to_string(),
READINESS_TOKENS_FLAG.to_string(),
"--no-env-file".to_string(),
"--spawn-grant-stdin".to_string(),
];
let handle = request
.spawner
.as_ref()
.map(|spawner| SpawnerHandle::for_delegation(spawner, delegation::new_delegation_id()));
#[cfg_attr(not(unix), allow(unused_mut))]
let mut env = child_environment(&request, handle.as_ref());
#[cfg(unix)]
if let Some((name, value)) = lifeline.env_pair() {
let at = env
.iter()
.position(|(key, _)| key == SPAWNER_ENV || key == FORMATION_ID_ENV)
.unwrap_or(env.len());
env.insert(at, (name.to_string(), value));
}
let started = Instant::now();
let StartedProcess {
child,
stderr_tail,
stdin_written: write_result,
} = start_process(&ProcessLaunch {
binary: &binary,
args: &argv[1..],
cwd: Some(&workdir),
env: ProcessEnv::Cleared(&env),
stdin_line: Some(request.grant.expose()),
stderr_prefix: None,
inherit_output: false,
own_process_group: false,
inherit_fd: None,
#[cfg(unix)]
lifeline: Some(Lifeline::Child(&lifeline)),
})
.map_err(|error| {
RuntimeError::Runtime(format!(
"failed to start the child capsule process '{}': {error}",
binary.display()
))
})?;
#[cfg(unix)]
lifeline.spawned();
let write_result =
write_result.map_err(|error| format!("failed to hand the child its launch grant: {error}"));
let mut launched = LaunchedChild {
workdir,
session_id: String::new(),
capsule_url: String::new(),
argv,
env,
delegation_id: handle.as_ref().map(|handle| handle.delegation_id.clone()),
door_token: None,
formation_id: None,
process: Arc::new(Mutex::new(ChildProcess {
child: Some(child),
deliberate: false,
status: None,
terminated_at: None,
#[cfg(unix)]
lifeline: Some(lifeline),
})),
stderr_tail: Arc::clone(&stderr_tail),
started,
watcher: None,
};
if let Err(reason) = write_result {
return Err(RuntimeError::Runtime(reason));
}
let line = first_json_line(&mut launched, &stderr_tail)?;
let report: serde_json::Value = serde_json::from_str(&line).map_err(|error| {
RuntimeError::Runtime(format!(
"the child capsule's first --json line did not parse: {error}"
))
})?;
let session_id = report
.get("session_id")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
RuntimeError::Runtime(
"the child capsule's --json line carried no session_id".to_string(),
)
})?;
let url = report
.get("url")
.and_then(serde_json::Value::as_str)
.unwrap_or_default();
launched.session_id = session_id.to_string();
launched.door_token = readiness_door_token(&report);
launched.formation_id = report
.get("formation_id")
.and_then(serde_json::Value::as_str)
.and_then(|id| FormationId::parse(id).ok());
launched.capsule_url = if url.is_empty() {
String::new()
} else {
format!("http://{url}")
};
if let Some(handle) = handle {
if let Some(address) = handle.report_to.clone() {
launched.watcher = watch_for_completion(
&launched,
handle,
address,
&request.capsule_name,
&request.capsule_version,
request.completion_deadline,
);
}
}
Ok(launched)
}
pub(crate) fn readiness_door_token(
report: &serde_json::Value,
) -> Option<crate::door_auth::DoorToken> {
report
.pointer("/tokens/operator")
.and_then(serde_json::Value::as_str)
.filter(|token| !token.is_empty())
.map(|token| crate::door_auth::DoorToken::new(token.to_string()))
}
fn watch_for_completion(
launched: &LaunchedChild,
handle: SpawnerHandle,
address: CompletionAddress,
capsule_name: &str,
capsule_version: &str,
deadline: Option<Duration>,
) -> Option<std::thread::JoinHandle<()>> {
let process = Arc::clone(&launched.process);
let stderr_tail = Arc::clone(&launched.stderr_tail);
let workdir = launched.workdir.clone();
let session_id = launched.session_id.clone();
let capsule_name = capsule_name.to_string();
let capsule_version = capsule_version.to_string();
let started = launched.started;
let watching_from = Instant::now();
let spawned = std::thread::Builder::new().spawn(move || {
let expires_at = deadline.map(|bound| watching_from + bound);
let mut expired = false;
let ending = loop {
if expires_at.is_some_and(|at| Instant::now() >= at) {
let _ = end_process(&process);
expired = true;
break Ending::Deliberate;
}
if let Some(ending) = lock(&process).poll() {
break ending;
}
std::thread::sleep(CHILD_WATCH_INTERVAL);
};
let duration_ms = started.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
let recorded = match ending {
Ending::Exited(_) => delegation::read_completion(&workdir),
Ending::Deliberate => None,
};
if let Some(recorded) = recorded {
if recorded.delivered {
return;
}
let retried = delegation::report_completion(&handle, &address, recorded, &workdir);
if !retried.delivered {
crate::runtime_err!(
"[capsule-runtime] delegation {}: the child's completion is undelivered after one retry",
handle.delegation_id
);
}
return;
}
let outcome = |status: DelegationStatus, detail: String| DelegationOutcome {
delegation_id: handle.delegation_id.clone(),
capsule_name: capsule_name.clone(),
capsule_version: capsule_version.clone(),
session_id: session_id.clone(),
status,
result_path: None,
workdir: workdir.display().to_string(),
duration_ms,
detail: Some(detail),
reported_by: Reporter::Launcher,
delivered: false,
delivery_error: None,
};
match ending {
Ending::Deliberate if expired => {
let bound = deadline.map(|bound| bound.as_secs()).unwrap_or_default();
delegation::report_completion(
&handle,
&address,
outcome(
DelegationStatus::Terminated,
format!(
"the capsule was still running after {bound}s and was ended at the \
delegation deadline"
),
),
&workdir,
);
}
Ending::Deliberate => {
delegation::record_terminated(
&workdir,
outcome(
DelegationStatus::Terminated,
delegation::PARENT_ENDED_DETAIL.to_string(),
),
);
}
Ending::Exited(status) => {
let status_text = match status {
Some(status) => status.to_string(),
None => "unknown exit status".to_string(),
};
let tail = stderr_tail.lines_at_end().join("\n");
let detail = if tail.is_empty() {
format!(
"the child process ended without recording a completion ({status_text})"
)
} else {
format!(
"the child process ended without recording a completion ({status_text}); \
its last stderr lines were:\n{tail}"
)
};
delegation::report_completion(
&handle,
&address,
outcome(DelegationStatus::Crashed, detail),
&workdir,
);
}
}
});
match spawned {
Ok(watcher) => Some(watcher),
Err(error) => {
crate::runtime_err!(
"[capsule-runtime] delegation {}: the child's completion watcher could not be \
started: {error}",
launched.delegation_id.as_deref().unwrap_or_default()
);
None
}
}
}
pub(crate) fn child_environment(
request: &ChildLaunchRequest,
handle: Option<&SpawnerHandle>,
) -> Vec<(String, String)> {
let mut env: Vec<(String, String)> = Vec::new();
for key in &request.child_env_allow {
if let Ok(value) = std::env::var(key) {
env.push((key.clone(), value));
}
}
env.retain(|(key, _)| {
!matches!(key.as_str(), "PATH" | "HOME" | "MURMUR_ROOST_URL")
&& key != SPAWNER_ENV
&& key != FORMATION_ID_ENV
&& key != SPAWNER_LIFELINE_ENV
&& key != HOST_WARNINGS_REPORTED_ENV
&& !crate::formation::is_member_grant_env(key)
});
if let Ok(path) = std::env::var("PATH") {
env.push(("PATH".to_string(), path));
}
if let Ok(home) = std::env::var("HOME") {
env.push(("HOME".to_string(), home));
}
env.push(("MURMUR_ROOST_URL".to_string(), request.roost_url.clone()));
env.push(crate::host_warnings::reported_env_pair());
if let Some(handle) = handle {
env.push((SPAWNER_ENV.to_string(), handle.to_env_value()));
}
if let Some(formation_id) = &request.formation_id {
let (name, value) = formation_id.env_pair();
env.push((name.to_string(), value));
}
env
}
pub(crate) fn mur_binary() -> Result<PathBuf, RuntimeError> {
if let Some(path) = std::env::var_os(MUR_BINARY_ENV) {
if !path.is_empty() {
return Ok(PathBuf::from(path));
}
}
std::env::current_exe().map_err(|error| {
RuntimeError::Runtime(format!(
"cannot locate the mur binary to launch a child capsule with: {error}"
))
})
}
fn create_child_dir(workdir: &Path) -> Result<(), RuntimeError> {
std::fs::create_dir_all(workdir).map_err(|source| RuntimeError::CreateWorkdir {
path: workdir.display().to_string(),
source,
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(workdir, std::fs::Permissions::from_mode(CHILD_DIR_MODE))
.map_err(|source| RuntimeError::CreateWorkdir {
path: workdir.display().to_string(),
source,
})?;
}
Ok(())
}
pub(crate) struct StderrTail {
state: Mutex<StderrState>,
drained: Condvar,
}
#[derive(Default)]
struct StderrState {
lines: Vec<String>,
at_eof: bool,
}
impl StderrTail {
pub(crate) fn lines(&self) -> Vec<String> {
lock(&self.state).lines.clone()
}
pub(crate) fn lines_at_end(&self) -> Vec<String> {
let (state, _) = self
.drained
.wait_timeout_while(lock(&self.state), CHILD_STDERR_DRAIN_TIMEOUT, |state| {
!state.at_eof
})
.unwrap_or_else(|poisoned| poisoned.into_inner());
state.lines.clone()
}
fn finish(&self) {
lock(&self.state).at_eof = true;
self.drained.notify_all();
}
}
fn drain_stderr(child: &mut Child, prefix: Option<&str>) -> Arc<StderrTail> {
let tail = Arc::new(StderrTail {
state: Mutex::new(StderrState::default()),
drained: Condvar::new(),
});
let Some(stderr) = child.stderr.take() else {
tail.finish();
return tail;
};
let collector = Arc::clone(&tail);
let prefix = prefix.unwrap_or_default().to_string();
std::thread::spawn(move || {
for line in BufReader::new(stderr).lines().map_while(Result::ok) {
crate::runtime_err!("{prefix}{line}");
let mut state = lock(&collector.state);
if state.lines.len() == CHILD_STDERR_TAIL_LINES {
state.lines.remove(0);
}
state.lines.push(line);
}
collector.finish();
});
tail
}
fn first_json_line(
launched: &mut LaunchedChild,
stderr_tail: &StderrTail,
) -> Result<String, RuntimeError> {
let stdout = lock(&launched.process)
.child
.as_mut()
.and_then(|child| child.stdout.take())
.ok_or_else(|| {
RuntimeError::Runtime("the child process exposed no standard output".to_string())
})?;
let rx = relay_readiness_line(stdout, AfterReadiness::Stdout);
match rx.recv_timeout(CHILD_READY_TIMEOUT) {
Ok(Some(line)) => Ok(line),
Ok(None) => {
let status = lock(&launched.process)
.child
.as_mut()
.and_then(|child| child.wait().ok())
.map(|status| status.to_string())
.unwrap_or_else(|| "unknown".to_string());
let reason = stderr_tail.lines_at_end().join("\n");
Err(RuntimeError::Runtime(format!(
"the child capsule '{}' exited without reporting a launch line ({status}): {reason}",
launched.workdir.display()
)))
}
Err(_) => Err(RuntimeError::Runtime(format!(
"the child capsule '{}' did not report a launch line within {}s",
launched.workdir.display(),
CHILD_READY_TIMEOUT.as_secs()
))),
}
}
pub(crate) enum ProcessEnv<'a> {
Cleared(&'a [(String, String)]),
Inherited {
set: &'a [(String, String)],
remove: &'a [&'a str],
},
}
pub(crate) enum AfterReadiness {
Stdout,
PrefixedStderr(String),
}
pub(crate) struct ProcessLaunch<'a> {
pub(crate) binary: &'a Path,
pub(crate) args: &'a [String],
pub(crate) cwd: Option<&'a Path>,
pub(crate) env: ProcessEnv<'a>,
pub(crate) stdin_line: Option<&'a str>,
pub(crate) stderr_prefix: Option<&'a str>,
pub(crate) inherit_output: bool,
pub(crate) own_process_group: bool,
pub(crate) inherit_fd: Option<i32>,
#[cfg(unix)]
pub(crate) lifeline: Option<Lifeline<'a>>,
}
#[cfg(unix)]
#[derive(Clone, Copy)]
pub(crate) enum Lifeline<'a> {
Member(&'a crate::lifeline::MemberLifeline),
Child(&'a crate::lifeline::ChildLifeline),
}
#[cfg(unix)]
impl Lifeline<'_> {
fn hand_to(self, command: &mut Command) {
match self {
Lifeline::Member(lifeline) => lifeline.hand_to(command),
Lifeline::Child(lifeline) => lifeline.hand_to(command),
}
}
}
pub(crate) struct StartedProcess {
pub(crate) child: Child,
pub(crate) stderr_tail: Arc<StderrTail>,
pub(crate) stdin_written: Result<(), String>,
}
pub(crate) fn start_process(launch: &ProcessLaunch<'_>) -> std::io::Result<StartedProcess> {
let mut command = Command::new(launch.binary);
command
.args(launch.args)
.stdin(if launch.stdin_line.is_some() {
Stdio::piped()
} else {
Stdio::null()
})
.stdout(if launch.inherit_output {
Stdio::inherit()
} else {
Stdio::piped()
})
.stderr(if launch.inherit_output {
Stdio::inherit()
} else {
Stdio::piped()
});
if let Some(cwd) = launch.cwd {
command.current_dir(cwd);
}
match launch.env {
ProcessEnv::Cleared(pairs) => {
command.env_clear();
for (key, value) in pairs {
command.env(key, value);
}
}
ProcessEnv::Inherited { set, remove } => {
for name in remove {
command.env_remove(name);
}
for (key, value) in set {
command.env(key, value);
}
}
}
#[cfg(unix)]
if launch.own_process_group {
use std::os::unix::process::CommandExt;
command.process_group(0);
}
#[cfg(unix)]
if let Some(fd) = launch.inherit_fd {
keep_across_exec(&mut command, fd);
}
#[cfg(unix)]
if let Some(lifeline) = launch.lifeline {
lifeline.hand_to(&mut command);
}
let mut child = command.spawn()?;
let stdin_written = match launch.stdin_line {
None => Ok(()),
Some(line) => child
.stdin
.take()
.ok_or_else(|| "the process exposed no standard input".to_string())
.and_then(|mut stdin| writeln!(stdin, "{line}").map_err(|error| error.to_string())),
};
let stderr_tail = drain_stderr(&mut child, launch.stderr_prefix);
Ok(StartedProcess {
child,
stderr_tail,
stdin_written,
})
}
#[cfg(unix)]
#[allow(unsafe_code)]
fn keep_across_exec(command: &mut Command, fd: i32) {
use std::os::unix::process::CommandExt;
unsafe {
command.pre_exec(move || {
let flags = libc::fcntl(fd, libc::F_GETFD);
if flags == -1 || libc::fcntl(fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC) == -1 {
return Err(std::io::Error::last_os_error());
}
Ok(())
});
}
}
pub(crate) fn relay_readiness_line(
stdout: std::process::ChildStdout,
after: AfterReadiness,
) -> mpsc::Receiver<Option<String>> {
let (tx, rx) = mpsc::channel::<Option<String>>();
std::thread::spawn(move || {
let mut reader = BufReader::new(stdout);
let mut line = String::new();
let first = match reader.read_line(&mut line) {
Ok(0) | Err(_) => None,
Ok(_) => Some(line.trim_end().to_string()),
};
let _ = tx.send(first);
let mut rest = String::new();
while let Ok(read) = reader.read_line(&mut rest) {
if read == 0 {
break;
}
match &after {
AfterReadiness::Stdout => {
crate::diagnostic::raw_to_stdout(&rest);
}
AfterReadiness::PrefixedStderr(prefix) => {
crate::runtime_err!("{prefix}{}", rest.trim_end_matches(['\r', '\n']));
}
}
rest.clear();
}
});
rx
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_child_directory_is_unique_per_delegation() {
let parent = PathBuf::from("/tmp/parent");
let first = child_workdir_for(&parent, "worker");
let second = child_workdir_for(&parent, "worker");
assert_ne!(first, second);
assert_eq!(
first.parent(),
Some(parent.join(".murmur").join("children").as_path())
);
assert!(first
.file_name()
.unwrap()
.to_string_lossy()
.starts_with("worker-"));
assert_eq!(
first.file_name().unwrap().to_string_lossy().len(),
"worker-".len() + 16
);
}
fn request(env_allow: &[&str], spawner: Option<Spawner>) -> ChildLaunchRequest {
ChildLaunchRequest {
parent_accessible_workdir: PathBuf::from("/tmp/parent"),
capsule_name: "worker".to_string(),
capsule_version: "0.1.0".to_string(),
grant: SpawnApproval::new("msa1.token".to_string()),
child_env_allow: env_allow.iter().map(|name| name.to_string()).collect(),
roost_url: "http://127.0.0.1:7700".to_string(),
spawner,
completion_deadline: None,
formation_id: None,
}
}
fn decoy() -> String {
SpawnerHandle {
session_id: "ses_decoy".to_string(),
context_id: "ctx_decoy".to_string(),
delegation_id: "dlg_decoy".to_string(),
report_to: None,
}
.to_env_value()
}
fn spawner() -> Spawner {
Spawner {
session_id: "ses_parent".to_string(),
context_id: "ctx_parent".to_string(),
report_to: Some(CompletionAddress {
url: "http://127.0.0.1:7000".to_string(),
}),
}
}
#[test]
fn a_childs_environment_is_built_from_its_own_declaration() {
std::env::set_var("MURMUR_CHILD_LAUNCH_TEST_A", "a");
std::env::set_var("MURMUR_CHILD_LAUNCH_TEST_B", "b");
let env = child_environment(&request(&["MURMUR_CHILD_LAUNCH_TEST_A"], None), None);
let names: Vec<&str> = env.iter().map(|(key, _)| key.as_str()).collect();
assert!(names.contains(&"MURMUR_CHILD_LAUNCH_TEST_A"));
assert!(!names.contains(&"MURMUR_CHILD_LAUNCH_TEST_B"));
assert!(names.contains(&"MURMUR_ROOST_URL"));
assert!(!env.iter().any(|(_, value)| value.contains("msa1.token")));
}
#[test]
fn a_runtime_owned_name_cannot_be_displaced_by_a_declaration() {
std::env::set_var("MURMUR_ROOST_URL", "http://127.0.0.1:1");
let env = child_environment(&request(&["MURMUR_ROOST_URL"], None), None);
let urls: Vec<&String> = env
.iter()
.filter(|(key, _)| key == "MURMUR_ROOST_URL")
.map(|(_, value)| value)
.collect();
assert_eq!(urls, vec!["http://127.0.0.1:7700"]);
}
#[test]
fn the_spawner_handle_is_injected_last_and_cannot_be_displaced() {
std::env::set_var(SPAWNER_ENV, decoy());
let handle = SpawnerHandle::for_delegation(&spawner(), "dlg_0001".to_string());
let env = child_environment(&request(&[SPAWNER_ENV], Some(spawner())), Some(&handle));
let injected: Vec<&String> = env
.iter()
.filter(|(key, _)| key == SPAWNER_ENV)
.map(|(_, value)| value)
.collect();
assert_eq!(injected.len(), 1, "{env:?}");
assert_eq!(
SpawnerHandle::parse(injected[0]).expect("the injected value is a handle"),
handle
);
assert_eq!(
env.last().map(|(key, _)| key.as_str()),
Some(SPAWNER_ENV),
"the handle is applied in the runtime-owned tail: {env:?}"
);
std::env::remove_var(SPAWNER_ENV);
}
#[test]
fn a_launch_without_a_spawner_injects_no_handle() {
std::env::set_var(SPAWNER_ENV, decoy());
let env = child_environment(&request(&[SPAWNER_ENV], None), None);
assert!(!env.iter().any(|(key, _)| key == SPAWNER_ENV), "{env:?}");
std::env::remove_var(SPAWNER_ENV);
}
#[test]
fn the_formation_id_is_injected_last_and_cannot_be_displaced() {
let _guard = crate::formation::FORMATION_ENV_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
let decoy = FormationId::mint();
std::env::set_var(FORMATION_ID_ENV, decoy.as_str());
let injected = |env: &[(String, String)]| -> Vec<String> {
env.iter()
.filter(|(key, _)| key == FORMATION_ID_ENV)
.map(|(_, value)| value.clone())
.collect()
};
let outside = child_environment(&request(&[FORMATION_ID_ENV], None), None);
assert!(injected(&outside).is_empty(), "{outside:?}");
let other = FormationId::mint();
let handle = SpawnerHandle::for_delegation(&spawner(), "dlg_0001".to_string());
for (spawner, handle) in [(Some(spawner()), Some(&handle)), (None, None)] {
let mut member = request(&[FORMATION_ID_ENV], spawner);
member.formation_id = Some(other.clone());
let env = child_environment(&member, handle);
assert_eq!(injected(&env), vec![other.as_str().to_string()], "{env:?}");
assert_eq!(
env.last(),
Some(&(FORMATION_ID_ENV.to_string(), other.as_str().to_string())),
"the formation id is the last runtime-owned name: {env:?}"
);
}
std::env::remove_var(FORMATION_ID_ENV);
}
#[test]
fn every_child_is_told_host_warnings_were_reported() {
std::env::set_var(HOST_WARNINGS_REPORTED_ENV, "0");
let handle = SpawnerHandle::for_delegation(&spawner(), "dlg_0001".to_string());
for (spawner, handle) in [(Some(spawner()), Some(&handle)), (None, None)] {
for allow in [&[][..], &[HOST_WARNINGS_REPORTED_ENV][..]] {
let env = child_environment(&request(allow, spawner.clone()), handle);
let values: Vec<&str> = env
.iter()
.filter(|(key, _)| key == HOST_WARNINGS_REPORTED_ENV)
.map(|(_, value)| value.as_str())
.collect();
assert_eq!(values, vec!["1"], "{env:?}");
}
}
std::env::remove_var(HOST_WARNINGS_REPORTED_ENV);
}
#[test]
fn a_tail_read_after_the_end_waits_for_the_drain_to_catch_up() {
let tail = Arc::new(StderrTail {
state: Mutex::new(StderrState::default()),
drained: Condvar::new(),
});
let writer = Arc::clone(&tail);
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(200));
lock(&writer.state)
.lines
.push("error[E-CAP-003]: not achievable on this host".to_string());
writer.finish();
});
assert!(tail.lines().is_empty());
assert_eq!(
tail.lines_at_end(),
vec!["error[E-CAP-003]: not achievable on this host".to_string()]
);
}
#[test]
fn a_tail_with_no_pipe_behind_it_is_at_its_end_already() {
let tail = StderrTail {
state: Mutex::new(StderrState::default()),
drained: Condvar::new(),
};
tail.finish();
let started = Instant::now();
assert!(tail.lines_at_end().is_empty());
assert!(
started.elapsed() < CHILD_STDERR_DRAIN_TIMEOUT,
"{started:?}"
);
}
#[test]
fn child_launch_reads_the_operator_token_from_the_readiness_line() {
let report = serde_json::json!({
"url": "localhost:1",
"session_id": "ses_x",
"tokens": {"operator": "mdt1.a.b", "watcher": "mdt1.c.d"},
});
assert_eq!(
readiness_door_token(&report).map(|token| token.expose().to_string()),
Some("mdt1.a.b".to_string())
);
let public = serde_json::json!({"url": "localhost:1", "session_id": "ses_x"});
assert_eq!(readiness_door_token(&public), None);
}
#[test]
fn child_launch_debug_prints_no_door_token() {
let child = LaunchedChild {
workdir: PathBuf::from("/tmp/child"),
session_id: "ses_child".to_string(),
capsule_url: "http://localhost:1".to_string(),
argv: Vec::new(),
env: Vec::new(),
delegation_id: None,
formation_id: None,
door_token: Some(crate::door_auth::DoorToken::new(
"mdt1.secretpayload.secretmac".to_string(),
)),
process: Arc::new(Mutex::new(ChildProcess {
child: None,
deliberate: false,
status: None,
terminated_at: None,
#[cfg(unix)]
lifeline: None,
})),
stderr_tail: Arc::new(StderrTail {
state: Mutex::new(StderrState::default()),
drained: Condvar::new(),
}),
started: Instant::now(),
watcher: None,
};
let debug = format!("{child:?}");
assert!(!debug.contains("secretpayload"), "{debug}");
assert!(debug.contains("door_token"), "{debug}");
}
fn start_process_code() -> String {
let source = include_str!("child_launch.rs");
let start = source
.find("pub(crate) fn start_process(")
.expect("start_process is defined here");
let end = start
+ source[start..]
.find("\n}\n")
.expect("start_process ends at a closing brace");
source[start..end]
.lines()
.filter(|line| !line.trim_start().starts_with("//"))
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn start_process_registers_no_fd_hygiene() {
let code = start_process_code();
for name in [
"apply_fd_hygiene",
"mark_inherited_fds_cloexec",
"close_range",
] {
assert!(!code.contains(name), "start_process names {name}:\n{code}");
}
let handed = code
.rfind("lifeline.hand_to")
.expect("the lifeline is handed");
let kept = code
.find("keep_across_exec")
.expect("an inherited descriptor is kept");
assert!(kept < handed, "the lifeline is registered last:\n{code}");
assert!(
!code[handed..].contains("pre_exec"),
"nothing is registered after the lifeline:\n{code}"
);
}
#[cfg(unix)]
fn process_with_lifeline(program: &str, args: &[&str]) -> (ChildProcess, i32) {
let lifeline = crate::lifeline::ChildLifeline::new().unwrap();
let write = lifeline.write_fd().unwrap();
let child = Command::new(program).args(args).spawn().unwrap();
(
ChildProcess {
child: Some(child),
deliberate: false,
status: None,
terminated_at: None,
lifeline: Some(lifeline),
},
write,
)
}
#[cfg(unix)]
#[test]
fn ending_a_child_closes_its_lifeline() {
let (process, _) = process_with_lifeline("sleep", &["30"]);
let process = Mutex::new(process);
end_process(&process).unwrap();
assert!(lock(&process).lifeline.is_none());
assert!(lock(&process).status.is_some());
end_process(&process).unwrap();
}
#[cfg(unix)]
#[test]
fn ending_a_child_sends_sigterm_and_waits_for_it_to_wind_down() {
let dir = tempfile::tempdir().unwrap();
let marker = dir.path().join("wound-down");
let script = format!(
"trap 'echo wound-down > {}; exit 7' TERM; sleep 30 & wait",
marker.display()
);
let (process, _) = process_with_lifeline("bash", &["-c", &script]);
let process = Mutex::new(process);
std::thread::sleep(Duration::from_millis(300));
let began = Instant::now();
end_process(&process).unwrap();
assert!(
began.elapsed() < CHILD_END_GRACE,
"took {:?}",
began.elapsed()
);
assert_eq!(
std::fs::read_to_string(&marker).unwrap().trim(),
"wound-down"
);
assert_eq!(
lock(&process).status.and_then(|status| status.code()),
Some(7)
);
assert!(lock(&process).lifeline.is_none());
}
#[cfg(unix)]
#[test]
fn a_child_that_ignores_sigterm_is_killed_at_the_grace() {
use std::os::unix::process::ExitStatusExt as _;
let (process, _) =
process_with_lifeline("bash", &["-c", "trap '' TERM; while :; do sleep 0.1; done"]);
let process = Mutex::new(process);
std::thread::sleep(Duration::from_millis(300));
let began = Instant::now();
end_process(&process).unwrap();
let took = began.elapsed();
assert!(took >= CHILD_END_GRACE, "killed before the grace: {took:?}");
assert!(
took < CHILD_END_GRACE + Duration::from_secs(5),
"took {took:?}"
);
assert_eq!(
lock(&process).status.and_then(|status| status.signal()),
Some(libc::SIGKILL)
);
}
#[cfg(unix)]
#[test]
fn children_signalled_together_wait_one_grace_between_them() {
let wedged = || {
let (process, _) =
process_with_lifeline("bash", &["-c", "trap '' TERM; while :; do sleep 0.1; done"]);
LaunchedChild {
workdir: PathBuf::from("/tmp/child"),
session_id: "ses_child".to_string(),
capsule_url: String::new(),
argv: Vec::new(),
env: Vec::new(),
delegation_id: None,
formation_id: None,
door_token: None,
process: Arc::new(Mutex::new(process)),
stderr_tail: Arc::new(StderrTail {
state: Mutex::new(StderrState::default()),
drained: Condvar::new(),
}),
started: Instant::now(),
watcher: None,
}
};
let children = vec![wedged(), wedged()];
std::thread::sleep(Duration::from_millis(300));
let began = Instant::now();
for child in &children {
child.begin_ending();
}
drop(children);
let took = began.elapsed();
assert!(took >= CHILD_END_GRACE, "took {took:?}");
assert!(
took < CHILD_END_GRACE * 2,
"two children waited a grace each: {took:?}"
);
}
#[cfg(unix)]
#[test]
fn a_child_seen_exited_has_its_lifeline_closed() {
let (mut process, _) = process_with_lifeline("true", &[]);
let deadline = Instant::now() + Duration::from_secs(10);
let ending = loop {
if let Some(ending) = process.poll() {
break ending;
}
assert!(Instant::now() < deadline, "`true` never exited");
std::thread::sleep(Duration::from_millis(20));
};
assert!(matches!(ending, Ending::Exited(Some(_))));
assert!(process.lifeline.is_none());
}
#[cfg(unix)]
#[test]
#[allow(unsafe_code)]
fn a_childs_lifeline_closes_when_its_handle_drops_or_its_end_is_seen() {
let launched = |program: &str, args: &[&str]| {
let (process, write) = process_with_lifeline(program, args);
let process = Arc::new(Mutex::new(process));
let child = LaunchedChild {
workdir: PathBuf::from("/tmp/child"),
session_id: "ses_child".to_string(),
capsule_url: String::new(),
argv: Vec::new(),
env: Vec::new(),
delegation_id: None,
formation_id: None,
door_token: None,
process: Arc::clone(&process),
stderr_tail: Arc::new(StderrTail {
state: Mutex::new(StderrState::default()),
drained: Condvar::new(),
}),
started: Instant::now(),
watcher: None,
};
(child, process, write)
};
let is_open = |fd: i32| {
unsafe { libc::fcntl(fd, libc::F_GETFD) >= 0 }
};
let (child, process, write) = launched("sleep", &["30"]);
assert!(!child.has_exited());
assert!(is_open(write), "a held child's write end is open");
drop(child);
assert!(
lock(&process).lifeline.is_none(),
"dropping the handle closes it"
);
assert!(
lock(&process).deliberate,
"a running child's drop is deliberate"
);
assert!(lock(&process).status.is_some(), "and reaps the child");
let (child, process, _) = launched("true", &[]);
let deadline = Instant::now() + Duration::from_secs(10);
while !child.has_exited() {
assert!(Instant::now() < deadline, "`true` never exited");
std::thread::sleep(Duration::from_millis(20));
}
assert!(
lock(&process).lifeline.is_none(),
"seeing the end closes it"
);
drop(child);
assert!(
!lock(&process).deliberate,
"a child that ended on its own is not recorded as ended by its parent"
);
}
#[cfg(unix)]
#[test]
fn a_child_its_parent_ends_is_recorded_in_the_shared_words() {
let dir = tempfile::tempdir().unwrap();
let (process, _) = process_with_lifeline("sleep", &["30"]);
let mut child = LaunchedChild {
workdir: dir.path().to_path_buf(),
session_id: "ses_child".to_string(),
capsule_url: String::new(),
argv: Vec::new(),
env: Vec::new(),
delegation_id: Some("dlg_watched".to_string()),
formation_id: None,
door_token: None,
process: Arc::new(Mutex::new(process)),
stderr_tail: Arc::new(StderrTail {
state: Mutex::new(StderrState::default()),
drained: Condvar::new(),
}),
started: Instant::now(),
watcher: None,
};
let watcher = watch_for_completion(
&child,
SpawnerHandle::for_delegation(&spawner(), "dlg_watched".to_string()),
CompletionAddress {
url: "http://127.0.0.1:1".to_string(),
},
"worker",
"0.1.0",
None,
)
.expect("the watcher starts");
child.shutdown().unwrap();
watcher.join().unwrap();
let recorded = delegation::read_completion(dir.path()).expect("the ending is recorded");
assert_eq!(recorded.status, DelegationStatus::Terminated);
assert_eq!(recorded.reported_by, Reporter::Launcher);
assert!(!recorded.delivered);
assert_eq!(
recorded.detail.as_deref(),
Some(delegation::PARENT_ENDED_DETAIL)
);
}
#[cfg(unix)]
#[test]
fn a_completion_the_child_records_while_being_ended_is_overwritten_by_the_launcher() {
let dir = tempfile::tempdir().unwrap();
let own = DelegationOutcome {
delegation_id: "dlg_watched".to_string(),
capsule_name: "worker".to_string(),
capsule_version: "0.1.0".to_string(),
session_id: "ses_child".to_string(),
status: DelegationStatus::Error,
result_path: None,
workdir: dir.path().display().to_string(),
duration_ms: 1,
detail: None,
reported_by: Reporter::Child,
delivered: false,
delivery_error: None,
};
let staged = dir.path().join("own-record.json");
std::fs::write(&staged, serde_json::to_vec(&own).unwrap()).unwrap();
let script = format!(
"trap 'cp {} {}; exit 0' TERM; sleep 30 & wait",
staged.display(),
dir.path().join(delegation::COMPLETION_FILE).display()
);
let (process, _) = process_with_lifeline("bash", &["-c", &script]);
let mut child = LaunchedChild {
workdir: dir.path().to_path_buf(),
session_id: "ses_child".to_string(),
capsule_url: String::new(),
argv: Vec::new(),
env: Vec::new(),
delegation_id: Some("dlg_watched".to_string()),
formation_id: None,
door_token: None,
process: Arc::new(Mutex::new(process)),
stderr_tail: Arc::new(StderrTail {
state: Mutex::new(StderrState::default()),
drained: Condvar::new(),
}),
started: Instant::now(),
watcher: None,
};
let watcher = watch_for_completion(
&child,
SpawnerHandle::for_delegation(&spawner(), "dlg_watched".to_string()),
CompletionAddress {
url: "http://127.0.0.1:1".to_string(),
},
"worker",
"0.1.0",
None,
)
.expect("the watcher starts");
std::thread::sleep(Duration::from_millis(300));
child.shutdown().unwrap();
watcher.join().unwrap();
let recorded = delegation::read_completion(dir.path()).expect("the ending is recorded");
assert_eq!(recorded.status, DelegationStatus::Terminated);
assert_eq!(recorded.reported_by, Reporter::Launcher);
assert!(!recorded.delivered);
assert_eq!(recorded.delivery_error, None);
}
}