use crate::value::VmDictExt;
use std::collections::BTreeMap;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Arc, LazyLock, Mutex};
use std::time::Duration;
use tokio::io::AsyncReadExt;
use tokio::sync::Notify;
use crate::value::{VmError, VmValue};
use super::host::{
audited_utc_now_rfc3339, build_sandboxed_command, optional_i64, optional_string,
push_sandbox_profile_override, require_param,
};
const REGISTRY_CAP: usize = 64;
const RECEIPT_CAP: usize = 1024;
static HANDLE_COUNTER: AtomicU64 = AtomicU64::new(1);
static SPAWN_REGISTRY: LazyLock<Mutex<BTreeMap<String, Arc<SpawnEntry>>>> =
LazyLock::new(|| Mutex::new(BTreeMap::new()));
static SPAWN_RECEIPTS: LazyLock<Mutex<BTreeMap<String, TerminalReceipt>>> =
LazyLock::new(|| Mutex::new(BTreeMap::new()));
#[derive(Clone, Copy, PartialEq, Eq)]
enum SpawnStatus {
Running,
Exited,
Killed,
TimedOut,
}
impl SpawnStatus {
fn as_str(self) -> &'static str {
match self {
SpawnStatus::Running => "running",
SpawnStatus::Exited => "exited",
SpawnStatus::Killed => "killed",
SpawnStatus::TimedOut => "timed_out",
}
}
fn is_terminal(self) -> bool {
!matches!(self, SpawnStatus::Running)
}
}
#[derive(Default)]
struct SpawnState {
stdout: Vec<u8>,
stderr: Vec<u8>,
exit_code: Option<i32>,
status: Option<SpawnStatus>,
ended_at: Option<String>,
observed: bool,
}
struct SpawnEntry {
handle_id: String,
pid: Option<u32>,
cleanup_token: String,
owner_key: Option<usize>,
cleanup_registration: Mutex<Option<crate::op_interrupt::ActiveProcessCleanupGuard>>,
command_display: String,
started_at: String,
seq: u64,
state: Mutex<SpawnState>,
completion: Notify,
kill_signal: Notify,
}
impl SpawnEntry {
fn unregister_cleanup(&self) {
let _ = self
.cleanup_registration
.lock()
.expect("spawn cleanup registration poisoned")
.take();
}
fn current_status(&self) -> SpawnStatus {
self.state
.lock()
.expect("spawn state poisoned")
.status
.unwrap_or(SpawnStatus::Running)
}
fn is_observed(&self) -> bool {
self.state.lock().expect("spawn state poisoned").observed
}
fn record_terminal(&self, status: SpawnStatus, exit_code: i32) -> SpawnStatus {
let mut state = self.state.lock().expect("spawn state poisoned");
match state.status {
Some(existing) if existing.is_terminal() => existing,
_ => {
state.status = Some(status);
state.exit_code = Some(exit_code);
state.ended_at = Some(audited_utc_now_rfc3339("host_call/process.spawn.ended_at"));
status
}
}
}
}
fn next_handle(pid: Option<u32>) -> (String, u64) {
let n = HANDLE_COUNTER.fetch_add(1, Ordering::Relaxed);
let pid = pid.unwrap_or(0);
(format!("psh-{pid:x}-{n}"), n)
}
#[derive(Clone)]
struct TerminalReceipt {
handle_id: String,
pid: Option<u32>,
command_display: String,
started_at: String,
status: SpawnStatus,
exit_code: Option<i32>,
seq: u64,
}
fn receipt_from_entry(entry: &SpawnEntry, forced: Option<(SpawnStatus, i32)>) -> TerminalReceipt {
let state = entry.state.lock().expect("spawn state poisoned");
let (status, exit_code) = match forced {
Some((status, code)) => (status, Some(code)),
None => (state.status.unwrap_or(SpawnStatus::Exited), state.exit_code),
};
TerminalReceipt {
handle_id: entry.handle_id.clone(),
pid: entry.pid,
command_display: entry.command_display.clone(),
started_at: entry.started_at.clone(),
status,
exit_code,
seq: entry.seq,
}
}
fn store_receipt(receipts: &mut BTreeMap<String, TerminalReceipt>, receipt: TerminalReceipt) {
while receipts.len() >= RECEIPT_CAP {
let oldest = receipts
.values()
.min_by_key(|r| r.seq)
.map(|r| r.handle_id.clone());
match oldest {
Some(handle_id) => {
if let Some(dropped) = receipts.remove(&handle_id) {
tracing::warn!(
handle_id = %dropped.handle_id,
command = %dropped.command_display,
seq = dropped.seq,
cap = RECEIPT_CAP,
"process.spawn dropping unconsumed terminal receipt at cap; \
observe or release spawn handles promptly"
);
}
}
None => break,
}
}
receipts.insert(receipt.handle_id.clone(), receipt);
}
fn unknown_handle_error(handle_id: &str) -> VmError {
VmError::Thrown(VmValue::String(arcstr::ArcStr::from(format!(
"host_call process: unknown or expired spawn handle {handle_id:?} \
(terminal receipts are bounded; observe or release the handle promptly)"
))))
}
fn lookup_live(handle_id: &str) -> Option<Arc<SpawnEntry>> {
SPAWN_REGISTRY
.lock()
.expect("spawn registry poisoned")
.get(handle_id)
.cloned()
}
fn lookup_receipt(handle_id: &str) -> Option<TerminalReceipt> {
SPAWN_RECEIPTS
.lock()
.expect("spawn receipts poisoned")
.get(handle_id)
.cloned()
}
fn evict_if_needed(registry: &mut BTreeMap<String, Arc<SpawnEntry>>, owner_key: Option<usize>) {
while registry.len() >= REGISTRY_CAP {
let victim = registry
.values()
.filter(|e| e.current_status().is_terminal())
.min_by_key(|e| {
(
u8::from(!e.is_observed()),
u8::from(e.owner_key != owner_key),
e.seq,
)
})
.map(|e| e.handle_id.clone());
if let Some(handle_id) = victim {
if let Some(entry) = registry.remove(&handle_id) {
signal_entry_process_tree(&entry);
let receipt = receipt_from_entry(&entry, None);
store_receipt(
&mut SPAWN_RECEIPTS.lock().expect("spawn receipts poisoned"),
receipt,
);
tracing::info!(
handle_id = %handle_id,
cap = REGISTRY_CAP,
"process.spawn evicted terminal handle to receipt (registry at cap)"
);
}
continue;
}
let victim = match registry.values().min_by_key(|e| e.seq) {
Some(entry) => {
tracing::warn!(
handle_id = %entry.handle_id,
"process.spawn registry over cap with no terminal entries; \
killing oldest running handle to evict"
);
entry.kill_signal.notify_one();
entry.handle_id.clone()
}
None => return,
};
if let Some(entry) = registry.remove(&victim) {
signal_entry_process_tree(&entry);
let receipt = receipt_from_entry(&entry, Some((SpawnStatus::Killed, -1)));
store_receipt(
&mut SPAWN_RECEIPTS.lock().expect("spawn receipts poisoned"),
receipt,
);
}
}
}
pub(crate) async fn dispatch(
operation: &str,
params: &crate::value::DictMap,
owner_cancel_token: Option<Arc<AtomicBool>>,
) -> Option<Result<VmValue, VmError>> {
match operation {
"spawn" => Some(spawn(params, owner_cancel_token).await),
"poll" => Some(poll(params)),
"wait" => Some(wait(params).await),
"kill" => Some(kill(params).await),
"release" => Some(release(params)),
_ => None,
}
}
async fn spawn(
params: &crate::value::DictMap,
owner_cancel_token: Option<Arc<AtomicBool>>,
) -> Result<VmValue, VmError> {
let timeout_ms = optional_i64(params, "timeout")
.or_else(|| optional_i64(params, "timeout_ms"))
.filter(|value| *value > 0)
.map(|value| value as u64);
let profile_guard = match optional_string(params, "sandbox_profile") {
Some(value) => Some(push_sandbox_profile_override(&value)?),
None => None,
};
let mut cmd = build_sandboxed_command(params, "process.spawn")?;
crate::op_interrupt::configure_tokio_kill_group(&mut cmd);
let cleanup_token = crate::op_interrupt::new_process_cleanup_token();
cmd.env(
crate::op_interrupt::PROCESS_CLEANUP_TOKEN_ENV,
&cleanup_token,
);
cmd.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true);
let command_display = command_display(params);
let started_at = audited_utc_now_rfc3339("host_call/process.spawn.started_at");
let mut child = cmd
.spawn()
.map_err(|e| VmError::Runtime(format!("host_call process.spawn: {e}")))?;
drop(profile_guard);
let pid = child.id();
let (handle_id, seq) = next_handle(pid);
let owner_key = owner_cancel_token
.as_ref()
.map(|token| Arc::as_ptr(token) as usize);
let entry = Arc::new(SpawnEntry {
handle_id: handle_id.clone(),
pid,
cleanup_token: cleanup_token.clone(),
owner_key,
cleanup_registration: Mutex::new(Some(
crate::op_interrupt::register_active_process_cleanup(
pid,
&cleanup_token,
owner_cancel_token,
),
)),
command_display: command_display.clone(),
started_at: started_at.clone(),
seq,
state: Mutex::new(SpawnState::default()),
completion: Notify::new(),
kill_signal: Notify::new(),
});
let stdout_pipe = child.stdout.take();
let stderr_pipe = child.stderr.take();
let task_entry = Arc::clone(&entry);
tokio::spawn(async move {
run_to_completion(child, stdout_pipe, stderr_pipe, timeout_ms, task_entry).await;
});
{
let mut registry = SPAWN_REGISTRY.lock().expect("spawn registry poisoned");
evict_if_needed(&mut registry, owner_key);
registry.insert(handle_id.clone(), entry);
}
let mut result = BTreeMap::new();
result.put_str("handle_id", handle_id);
result.insert(
"pid".to_string(),
pid.map(|p| VmValue::Int(p as i64)).unwrap_or(VmValue::Nil),
);
result.put_str("started_at", started_at);
result.put_str("command", command_display);
result.put_str("status", "running");
Ok(VmValue::dict(result))
}
async fn run_to_completion(
mut child: tokio::process::Child,
stdout_pipe: Option<tokio::process::ChildStdout>,
stderr_pipe: Option<tokio::process::ChildStderr>,
timeout_ms: Option<u64>,
entry: Arc<SpawnEntry>,
) {
let stdout_buf = Arc::new(Mutex::new(Vec::<u8>::new()));
let stderr_buf = Arc::new(Mutex::new(Vec::<u8>::new()));
let stdout_task = stdout_pipe.map(|pipe| {
let buf = Arc::clone(&stdout_buf);
tokio::spawn(drain_into(pipe, buf))
});
let stderr_task = stderr_pipe.map(|pipe| {
let buf = Arc::clone(&stderr_buf);
tokio::spawn(drain_into(pipe, buf))
});
enum Outcome {
Exited(std::io::Result<std::process::ExitStatus>),
Terminate(SpawnStatus),
}
let mut timeout_sleep =
timeout_ms.map(|ms| Box::pin(tokio::time::sleep(Duration::from_millis(ms))));
let outcome = {
let wait = child.wait();
tokio::pin!(wait);
if let Some(sleep) = timeout_sleep.as_mut() {
tokio::select! {
result = &mut wait => Outcome::Exited(result),
_ = entry.kill_signal.notified() => Outcome::Terminate(SpawnStatus::Killed),
_ = sleep.as_mut() => Outcome::Terminate(SpawnStatus::TimedOut),
}
} else {
tokio::select! {
result = &mut wait => Outcome::Exited(result),
_ = entry.kill_signal.notified() => Outcome::Terminate(SpawnStatus::Killed),
}
}
};
let (status, exit_code) = match outcome {
Outcome::Exited(result) => exit_status(result),
Outcome::Terminate(status) => {
signal_entry_process_tree(&entry);
let _ = child.kill().await;
let _ = child.wait().await;
(status, -1)
}
};
let drain_output = async {
if let Some(task) = stdout_task {
let _ = task.await;
}
if let Some(task) = stderr_task {
let _ = task.await;
}
};
tokio::pin!(drain_output);
let (status, exit_code) = if status == SpawnStatus::Exited {
if let Some(sleep) = timeout_sleep.as_mut() {
tokio::select! {
_ = &mut drain_output => (status, exit_code),
_ = sleep.as_mut() => {
signal_entry_process_tree(&entry);
drain_output.await;
(SpawnStatus::TimedOut, -1)
}
}
} else {
drain_output.await;
(status, exit_code)
}
} else {
drain_output.await;
(status, exit_code)
};
entry.unregister_cleanup();
let stdout = std::mem::take(&mut *stdout_buf.lock().expect("stdout buf poisoned"));
let stderr = std::mem::take(&mut *stderr_buf.lock().expect("stderr buf poisoned"));
{
let mut state = entry.state.lock().expect("spawn state poisoned");
state.stdout = stdout;
state.stderr = stderr;
}
entry.record_terminal(status, exit_code);
entry.completion.notify_waiters();
}
fn exit_status(result: std::io::Result<std::process::ExitStatus>) -> (SpawnStatus, i32) {
match result {
Ok(status) => (SpawnStatus::Exited, status.code().unwrap_or(-1)),
Err(_) => (SpawnStatus::Exited, -1),
}
}
async fn drain_into<R: AsyncReadExt + Unpin>(mut reader: R, buf: Arc<Mutex<Vec<u8>>>) {
let mut chunk = [0u8; 8192];
loop {
match reader.read(&mut chunk).await {
Ok(0) | Err(_) => break,
Ok(n) => {
buf.lock()
.expect("drain buf poisoned")
.extend_from_slice(&chunk[..n]);
}
}
}
}
fn command_display(params: &crate::value::DictMap) -> String {
if let Some(command) = optional_string(params, "command") {
return command;
}
if let Some(VmValue::List(argv)) = params.get("argv") {
return argv
.iter()
.map(|v| v.display())
.collect::<Vec<_>>()
.join(" ");
}
String::new()
}
#[allow(clippy::too_many_arguments)]
fn poll_dict(
handle_id: &str,
status: SpawnStatus,
command: &str,
started_at: &str,
pid: Option<u32>,
exit_code: Option<i32>,
stdout: &[u8],
stderr: &[u8],
) -> VmValue {
let mut result = BTreeMap::new();
result.put_str("handle_id", handle_id);
result.put_str("status", status.as_str());
result.insert(
"running".to_string(),
VmValue::Bool(status == SpawnStatus::Running),
);
result.put_str("command", command);
result.put_str("started_at", started_at);
result.insert(
"pid".to_string(),
pid.map(|p| VmValue::Int(p as i64)).unwrap_or(VmValue::Nil),
);
result.insert(
"exit_code".to_string(),
exit_code
.map(|c| VmValue::Int(c as i64))
.unwrap_or(VmValue::Nil),
);
result.put_str("stdout", String::from_utf8_lossy(stdout));
result.put_str("stderr", String::from_utf8_lossy(stderr));
VmValue::dict(result)
}
fn poll(params: &crate::value::DictMap) -> Result<VmValue, VmError> {
let handle_id = require_param(params, "handle_id")?;
if let Some(entry) = lookup_live(&handle_id) {
let mut state = entry.state.lock().expect("spawn state poisoned");
let status = state.status.unwrap_or(SpawnStatus::Running);
if status.is_terminal() {
state.observed = true;
}
return Ok(poll_dict(
&entry.handle_id,
status,
&entry.command_display,
&entry.started_at,
entry.pid,
state.exit_code,
&state.stdout,
&state.stderr,
));
}
if let Some(receipt) = lookup_receipt(&handle_id) {
return Ok(poll_dict(
&receipt.handle_id,
receipt.status,
&receipt.command_display,
&receipt.started_at,
receipt.pid,
receipt.exit_code,
&[],
&[],
));
}
Err(unknown_handle_error(&handle_id))
}
fn wait_dict(status: SpawnStatus, exit_code: Option<i32>, stdout: &[u8], stderr: &[u8]) -> VmValue {
let mut result = BTreeMap::new();
result.put_str("status", status.as_str());
result.insert(
"exit_code".to_string(),
exit_code
.map(|c| VmValue::Int(c as i64))
.unwrap_or(VmValue::Nil),
);
result.put_str("stdout", String::from_utf8_lossy(stdout));
result.put_str("stderr", String::from_utf8_lossy(stderr));
result.insert(
"timed_out".to_string(),
VmValue::Bool(status == SpawnStatus::TimedOut),
);
result.insert("running".to_string(), VmValue::Bool(false));
VmValue::dict(result)
}
async fn wait(params: &crate::value::DictMap) -> Result<VmValue, VmError> {
let handle_id = require_param(params, "handle_id")?;
let entry = match lookup_live(&handle_id) {
Some(entry) => entry,
None => {
if let Some(receipt) = lookup_receipt(&handle_id) {
return Ok(wait_dict(receipt.status, receipt.exit_code, &[], &[]));
}
return Err(unknown_handle_error(&handle_id));
}
};
let timeout_ms = optional_i64(params, "timeout")
.or_else(|| optional_i64(params, "timeout_ms"))
.filter(|value| *value > 0)
.map(|value| value as u64);
let notified = entry.completion.notified();
tokio::pin!(notified);
if !entry.current_status().is_terminal() {
match timeout_ms {
Some(ms) => {
if tokio::time::timeout(Duration::from_millis(ms), &mut notified)
.await
.is_err()
&& !entry.current_status().is_terminal()
{
let mut result = BTreeMap::new();
result.insert("timed_out".to_string(), VmValue::Bool(true));
result.insert("running".to_string(), VmValue::Bool(true));
result.put_str("status", "running");
return Ok(VmValue::dict(result));
}
}
None => {
notified.await;
}
}
}
let mut state = entry.state.lock().expect("spawn state poisoned");
let status = state.status.unwrap_or(SpawnStatus::Running);
if status.is_terminal() {
state.observed = true;
}
Ok(wait_dict(
status,
state.exit_code,
&state.stdout,
&state.stderr,
))
}
async fn kill(params: &crate::value::DictMap) -> Result<VmValue, VmError> {
let handle_id = require_param(params, "handle_id")?;
let entry = match lookup_live(&handle_id) {
Some(entry) => entry,
None => {
if let Some(receipt) = lookup_receipt(&handle_id) {
return Ok(kill_result(true, receipt.status));
}
return Err(unknown_handle_error(&handle_id));
}
};
if entry.current_status().is_terminal() {
return Ok(kill_result(true, entry.current_status()));
}
let drained = entry.completion.notified();
tokio::pin!(drained);
signal_entry_process_tree(&entry);
entry.kill_signal.notify_one();
let status = entry.record_terminal(SpawnStatus::Killed, -1);
let _ = tokio::time::timeout(Duration::from_secs(5), &mut drained).await;
Ok(kill_result(true, status))
}
fn kill_result(success: bool, status: SpawnStatus) -> VmValue {
let mut result = BTreeMap::new();
result.insert("success".to_string(), VmValue::Bool(success));
result.put_str("status", status.as_str());
VmValue::dict(result)
}
fn signal_entry_process_tree(entry: &SpawnEntry) {
if let Some(pid) = entry.pid {
let mut report = crate::op_interrupt::signal_pid_tree_group_and_token_with_report(
pid,
Some(&entry.cleanup_token),
9,
);
report.refresh_survivor_status();
tracing::warn!(
handle_id = %entry.handle_id,
pid,
children = report.children.len(),
"host_call process.spawn signalled child process tree"
);
}
}
fn release(params: &crate::value::DictMap) -> Result<VmValue, VmError> {
let handle_id = require_param(params, "handle_id")?;
let removed = {
let mut registry = SPAWN_REGISTRY.lock().expect("spawn registry poisoned");
registry.remove(&handle_id)
};
if let Some(entry) = &removed {
if !entry.current_status().is_terminal() {
signal_entry_process_tree(entry);
entry.kill_signal.notify_one();
}
}
let released_receipt = SPAWN_RECEIPTS
.lock()
.expect("spawn receipts poisoned")
.remove(&handle_id)
.is_some();
let mut result = BTreeMap::new();
result.insert(
"released".to_string(),
VmValue::Bool(removed.is_some() || released_receipt),
);
Ok(VmValue::dict(result))
}
#[cfg(all(test, unix))]
pub(crate) fn registry_len_for_test() -> usize {
SPAWN_REGISTRY
.lock()
.expect("spawn registry poisoned")
.len()
}
#[cfg(all(test, unix))]
mod tests {
use super::*;
use std::sync::Arc as StdArc;
fn params(pairs: &[(&str, VmValue)]) -> crate::value::DictMap {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect()
}
fn vstr(s: &str) -> VmValue {
VmValue::String(arcstr::ArcStr::from(s))
}
fn argv(items: &[&str]) -> VmValue {
VmValue::List(StdArc::new(items.iter().map(|s| vstr(s)).collect()))
}
fn get_str(dict: &VmValue, key: &str) -> String {
match dict.as_dict().and_then(|d| d.get(key)) {
Some(VmValue::String(s)) => s.to_string(),
other => panic!("expected string for {key}, got {other:?}"),
}
}
fn get_bool(dict: &VmValue, key: &str) -> bool {
match dict.as_dict().and_then(|d| d.get(key)) {
Some(VmValue::Bool(b)) => *b,
other => panic!("expected bool for {key}, got {other:?}"),
}
}
fn get_int(dict: &VmValue, key: &str) -> i64 {
match dict.as_dict().and_then(|d| d.get(key)) {
Some(VmValue::Int(i)) => *i,
other => panic!("expected int for {key}, got {other:?}"),
}
}
async fn spawn_argv(items: &[&str]) -> VmValue {
spawn_argv_with_owner(items, None).await
}
async fn spawn_argv_with_owner(
items: &[&str],
owner_cancel_token: Option<StdArc<AtomicBool>>,
) -> VmValue {
let p = params(&[("mode", vstr("argv")), ("argv", argv(items))]);
dispatch("spawn", &p, owner_cancel_token)
.await
.expect("spawn dispatched")
.expect("spawn ok")
}
#[tokio::test]
async fn spawn_poll_wait_captures_stdout_and_exit_zero() {
let handle = spawn_argv(&["sh", "-c", "printf hello"]).await;
let handle_id = get_str(&handle, "handle_id");
assert!(handle_id.starts_with("psh-"));
assert_eq!(get_str(&handle, "status"), "running");
let waited = dispatch("wait", ¶ms(&[("handle_id", vstr(&handle_id))]), None)
.await
.expect("wait dispatched")
.expect("wait ok");
assert_eq!(get_str(&waited, "status"), "exited");
assert_eq!(get_int(&waited, "exit_code"), 0);
assert_eq!(get_str(&waited, "stdout"), "hello");
assert!(!get_bool(&waited, "timed_out"));
assert!(!get_bool(&waited, "running"));
let polled = dispatch("poll", ¶ms(&[("handle_id", vstr(&handle_id))]), None)
.await
.unwrap()
.unwrap();
assert_eq!(get_str(&polled, "status"), "exited");
assert!(!get_bool(&polled, "running"));
assert_eq!(get_str(&polled, "stdout"), "hello");
dispatch("release", ¶ms(&[("handle_id", vstr(&handle_id))]), None)
.await
.unwrap()
.unwrap();
}
#[tokio::test]
async fn poll_shows_running_then_exited() {
let handle = spawn_argv(&["sh", "-c", "sleep 0.4; printf done"]).await;
let handle_id = get_str(&handle, "handle_id");
let polled = dispatch("poll", ¶ms(&[("handle_id", vstr(&handle_id))]), None)
.await
.unwrap()
.unwrap();
assert_eq!(get_str(&polled, "status"), "running");
assert!(get_bool(&polled, "running"));
let waited = dispatch("wait", ¶ms(&[("handle_id", vstr(&handle_id))]), None)
.await
.unwrap()
.unwrap();
assert_eq!(get_str(&waited, "status"), "exited");
assert_eq!(get_str(&waited, "stdout"), "done");
dispatch("release", ¶ms(&[("handle_id", vstr(&handle_id))]), None)
.await
.unwrap()
.unwrap();
}
#[tokio::test]
async fn kill_terminates_running_process() {
let handle = spawn_argv(&["sh", "-c", "sleep 30"]).await;
let handle_id = get_str(&handle, "handle_id");
let killed = dispatch("kill", ¶ms(&[("handle_id", vstr(&handle_id))]), None)
.await
.unwrap()
.unwrap();
assert!(get_bool(&killed, "success"));
assert_eq!(get_str(&killed, "status"), "killed");
let polled = dispatch("poll", ¶ms(&[("handle_id", vstr(&handle_id))]), None)
.await
.unwrap()
.unwrap();
assert_eq!(get_str(&polled, "status"), "killed");
assert!(!get_bool(&polled, "running"));
dispatch("release", ¶ms(&[("handle_id", vstr(&handle_id))]), None)
.await
.unwrap()
.unwrap();
}
#[tokio::test]
async fn timeout_ms_auto_kills() {
let p = params(&[
("mode", vstr("argv")),
("argv", argv(&["sh", "-c", "sleep 30"])),
("timeout_ms", VmValue::Int(150)),
]);
let handle = dispatch("spawn", &p, None).await.unwrap().unwrap();
let handle_id = get_str(&handle, "handle_id");
let waited = dispatch("wait", ¶ms(&[("handle_id", vstr(&handle_id))]), None)
.await
.unwrap()
.unwrap();
assert_eq!(get_str(&waited, "status"), "timed_out");
assert!(get_bool(&waited, "timed_out"));
dispatch("release", ¶ms(&[("handle_id", vstr(&handle_id))]), None)
.await
.unwrap()
.unwrap();
}
#[tokio::test]
async fn wait_timeout_leaves_process_running() {
let handle = spawn_argv(&["sh", "-c", "sleep 1; printf later"]).await;
let handle_id = get_str(&handle, "handle_id");
let waited = dispatch(
"wait",
¶ms(&[
("handle_id", vstr(&handle_id)),
("timeout_ms", VmValue::Int(100)),
]),
None,
)
.await
.unwrap()
.unwrap();
assert!(get_bool(&waited, "timed_out"));
assert!(get_bool(&waited, "running"));
let finished = dispatch("wait", ¶ms(&[("handle_id", vstr(&handle_id))]), None)
.await
.unwrap()
.unwrap();
assert_eq!(get_str(&finished, "status"), "exited");
assert_eq!(get_str(&finished, "stdout"), "later");
dispatch("release", ¶ms(&[("handle_id", vstr(&handle_id))]), None)
.await
.unwrap()
.unwrap();
}
#[tokio::test]
async fn unknown_handle_errors_on_every_op() {
for op in ["poll", "wait", "kill"] {
let result = dispatch(
op,
¶ms(&[("handle_id", vstr("psh-deadbeef-999"))]),
None,
)
.await
.unwrap();
assert!(result.is_err(), "{op} should error on unknown handle");
}
let released = dispatch(
"release",
¶ms(&[("handle_id", vstr("psh-deadbeef-999"))]),
None,
)
.await
.unwrap()
.unwrap();
assert!(!get_bool(&released, "released"));
}
#[tokio::test]
async fn release_frees_entry() {
let handle = spawn_argv(&["sh", "-c", "printf x"]).await;
let handle_id = get_str(&handle, "handle_id");
dispatch("wait", ¶ms(&[("handle_id", vstr(&handle_id))]), None)
.await
.unwrap()
.unwrap();
let released = dispatch("release", ¶ms(&[("handle_id", vstr(&handle_id))]), None)
.await
.unwrap()
.unwrap();
assert!(get_bool(&released, "released"));
let polled = dispatch("poll", ¶ms(&[("handle_id", vstr(&handle_id))]), None)
.await
.unwrap();
assert!(polled.is_err());
}
#[tokio::test]
async fn concurrent_spawns_are_isolated() {
let a = spawn_argv(&["sh", "-c", "printf AAA"]).await;
let b = spawn_argv(&["sh", "-c", "printf BBB"]).await;
let ah = get_str(&a, "handle_id");
let bh = get_str(&b, "handle_id");
assert_ne!(ah, bh);
let aw = dispatch("wait", ¶ms(&[("handle_id", vstr(&ah))]), None)
.await
.unwrap()
.unwrap();
let bw = dispatch("wait", ¶ms(&[("handle_id", vstr(&bh))]), None)
.await
.unwrap()
.unwrap();
assert_eq!(get_str(&aw, "stdout"), "AAA");
assert_eq!(get_str(&bw, "stdout"), "BBB");
for h in [ah, bh] {
dispatch("release", ¶ms(&[("handle_id", vstr(&h))]), None)
.await
.unwrap()
.unwrap();
}
}
#[tokio::test]
async fn registry_evicts_oldest_terminal_over_cap() {
let owner = StdArc::new(AtomicBool::new(false));
let mut handles = Vec::new();
for _ in 0..(REGISTRY_CAP + 8) {
let handle =
spawn_argv_with_owner(&["sh", "-c", "printf z"], Some(StdArc::clone(&owner))).await;
let handle_id = get_str(&handle, "handle_id");
dispatch("wait", ¶ms(&[("handle_id", vstr(&handle_id))]), None)
.await
.unwrap()
.unwrap();
handles.push(handle_id);
}
assert!(
registry_len_for_test() <= REGISTRY_CAP,
"registry exceeded cap: {}",
registry_len_for_test()
);
for h in handles {
let _ = dispatch("release", ¶ms(&[("handle_id", vstr(&h))]), None).await;
}
}
fn fake_entry(
handle_id: &str,
seq: u64,
owner_key: Option<usize>,
status: Option<SpawnStatus>,
observed: bool,
) -> Arc<SpawnEntry> {
Arc::new(SpawnEntry {
handle_id: handle_id.to_string(),
pid: None,
cleanup_token: String::new(),
owner_key,
cleanup_registration: Mutex::new(None),
command_display: "fake".to_string(),
started_at: "1970-01-01T00:00:00Z".to_string(),
seq,
state: Mutex::new(SpawnState {
status,
observed,
..Default::default()
}),
completion: Notify::new(),
kill_signal: Notify::new(),
})
}
fn fake_receipt(handle_id: &str, seq: u64) -> TerminalReceipt {
TerminalReceipt {
handle_id: handle_id.to_string(),
pid: None,
command_display: "fake".to_string(),
started_at: "1970-01-01T00:00:00Z".to_string(),
status: SpawnStatus::Exited,
exit_code: Some(0),
seq,
}
}
#[test]
fn store_receipt_drops_oldest_at_cap() {
let mut receipts: BTreeMap<String, TerminalReceipt> = BTreeMap::new();
for seq in 0..(RECEIPT_CAP as u64) {
let handle_id = format!("psh-test-receipt-{seq}");
store_receipt(&mut receipts, fake_receipt(&handle_id, seq));
}
assert_eq!(receipts.len(), RECEIPT_CAP);
assert!(receipts.contains_key("psh-test-receipt-0"));
store_receipt(
&mut receipts,
fake_receipt("psh-test-receipt-new", RECEIPT_CAP as u64),
);
assert_eq!(
receipts.len(),
RECEIPT_CAP,
"receipt store must stay bounded at cap"
);
assert!(
!receipts.contains_key("psh-test-receipt-0"),
"oldest receipt (lowest seq) must be dropped at cap"
);
assert!(
receipts.contains_key("psh-test-receipt-1"),
"second-oldest receipt must survive"
);
assert!(
receipts.contains_key("psh-test-receipt-new"),
"newest receipt must be retained"
);
}
#[tokio::test]
async fn eviction_preserves_unobserved_terminal_as_receipt() {
let mut registry: BTreeMap<String, Arc<SpawnEntry>> = BTreeMap::new();
let a_handle = "psh-test5090-A-unobserved-killed";
registry.insert(
a_handle.to_string(),
fake_entry(a_handle, 1, None, Some(SpawnStatus::Killed), false),
);
let b_owner = Some(0xB0B_usize);
for i in 0..(REGISTRY_CAP - 1) {
let h = format!("psh-test5090-B-running-{i}");
registry.insert(
h.clone(),
fake_entry(
&h,
100 + i as u64,
b_owner,
Some(SpawnStatus::Running),
false,
),
);
}
assert_eq!(registry.len(), REGISTRY_CAP);
let c_owner = Some(0xC0C_usize);
evict_if_needed(&mut registry, c_owner);
assert!(
!registry.contains_key(a_handle),
"unobserved terminal handle must be evicted under cap pressure"
);
let polled = poll(¶ms(&[("handle_id", vstr(a_handle))]))
.expect("poll must resolve an evicted terminal handle via its receipt");
assert_eq!(get_str(&polled, "status"), "killed");
assert!(!get_bool(&polled, "running"));
let waited = dispatch("wait", ¶ms(&[("handle_id", vstr(a_handle))]), None)
.await
.unwrap()
.unwrap();
assert_eq!(get_str(&waited, "status"), "killed");
assert!(!get_bool(&waited, "running"));
let killed = dispatch("kill", ¶ms(&[("handle_id", vstr(a_handle))]), None)
.await
.unwrap()
.unwrap();
assert!(get_bool(&killed, "success"));
assert_eq!(get_str(&killed, "status"), "killed");
let released = dispatch("release", ¶ms(&[("handle_id", vstr(a_handle))]), None)
.await
.unwrap()
.unwrap();
assert!(get_bool(&released, "released"));
assert!(
poll(¶ms(&[("handle_id", vstr(a_handle))])).is_err(),
"released receipt must be gone"
);
}
#[tokio::test]
async fn eviction_prefers_observed_over_unobserved_terminal() {
let mut registry: BTreeMap<String, Arc<SpawnEntry>> = BTreeMap::new();
let unobserved = "psh-test5090-unobserved";
let observed = "psh-test5090-observed";
registry.insert(
unobserved.to_string(),
fake_entry(unobserved, 1, None, Some(SpawnStatus::Exited), false),
);
registry.insert(
observed.to_string(),
fake_entry(observed, 2, None, Some(SpawnStatus::Exited), true),
);
for i in 0..(REGISTRY_CAP - 2) {
let h = format!("psh-test5090-running-{i}");
registry.insert(
h.clone(),
fake_entry(&h, 100 + i as u64, None, Some(SpawnStatus::Running), false),
);
}
assert_eq!(registry.len(), REGISTRY_CAP);
evict_if_needed(&mut registry, None);
assert!(
registry.contains_key(unobserved),
"unobserved terminal entry must be preserved over an observed one"
);
assert!(
!registry.contains_key(observed),
"observed terminal entry must be evicted first"
);
for h in [unobserved, observed] {
let _ = dispatch("release", ¶ms(&[("handle_id", vstr(h))]), None).await;
}
}
}