2026-06-25 12:30:18 +00:00
|
|
|
//! Minimal provisioning model and local Docker plugin for the MVP system.
|
|
|
|
|
//!
|
|
|
|
|
//! Swactor actors own provisioning control. Plugins only perform concrete I/O and
|
|
|
|
|
//! report observations back to the provisioner actor through [`PluginSink`].
|
|
|
|
|
|
|
|
|
|
use std::collections::BTreeMap;
|
2026-07-05 09:59:51 +00:00
|
|
|
use std::fs;
|
2026-07-01 08:44:25 +00:00
|
|
|
use std::io::Write;
|
2026-07-12 06:14:34 +00:00
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
|
use std::process::{Child, ChildStdin, Command, Stdio};
|
|
|
|
|
use std::sync::{Arc, Mutex};
|
2026-06-25 12:30:18 +00:00
|
|
|
use std::thread;
|
2026-07-12 06:14:34 +00:00
|
|
|
use std::time::{Duration, Instant, UNIX_EPOCH};
|
|
|
|
|
|
|
|
|
|
#[cfg(target_os = "linux")]
|
|
|
|
|
use std::os::unix::process::CommandExt;
|
2026-06-25 12:30:18 +00:00
|
|
|
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
2026-07-01 08:44:25 +00:00
|
|
|
use crate::bootstrap_datastream::BootstrapDatastreamBridge;
|
|
|
|
|
|
2026-06-25 12:30:18 +00:00
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
pub struct NodeProvisionSpec {
|
|
|
|
|
pub run_id: u64,
|
|
|
|
|
pub node_id: u64,
|
|
|
|
|
pub stage_index: Option<u32>,
|
|
|
|
|
pub image: String,
|
|
|
|
|
pub env: Vec<(String, String)>,
|
|
|
|
|
pub args: Vec<String>,
|
2026-07-05 09:59:51 +00:00
|
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
|
|
|
pub mounts: Vec<ProviderMount>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
pub struct ProviderMount {
|
|
|
|
|
pub host_path: String,
|
|
|
|
|
pub container_path: String,
|
|
|
|
|
pub readonly: bool,
|
2026-06-25 12:30:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
pub struct ProvisionEvent {
|
|
|
|
|
pub run_id: u64,
|
|
|
|
|
pub node_id: u64,
|
|
|
|
|
pub kind: ProvisionEventKind,
|
2026-07-05 09:59:51 +00:00
|
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
|
|
|
pub provider: Option<String>,
|
2026-06-25 12:30:18 +00:00
|
|
|
pub message: Option<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
pub enum ProvisionEventKind {
|
|
|
|
|
ProvisionStart,
|
|
|
|
|
NodeLive,
|
|
|
|
|
ProvisionFailed,
|
|
|
|
|
NodeStopped,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
pub struct ProvisionLogLine {
|
|
|
|
|
pub run_id: u64,
|
|
|
|
|
pub node_id: u64,
|
|
|
|
|
pub stream: ProvisionLogStream,
|
|
|
|
|
pub line: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
pub enum ProvisionLogStream {
|
|
|
|
|
Stdout,
|
|
|
|
|
Stderr,
|
|
|
|
|
Provider,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
|
|
|
|
pub enum PluginObservation {
|
|
|
|
|
StdoutLine {
|
|
|
|
|
run_id: u64,
|
|
|
|
|
node_id: u64,
|
|
|
|
|
line: String,
|
|
|
|
|
},
|
|
|
|
|
StderrLine {
|
|
|
|
|
run_id: u64,
|
|
|
|
|
node_id: u64,
|
|
|
|
|
line: String,
|
|
|
|
|
},
|
2026-07-05 09:59:51 +00:00
|
|
|
DatastreamFrame {
|
|
|
|
|
run_id: u64,
|
|
|
|
|
node_id: u64,
|
|
|
|
|
channel: String,
|
|
|
|
|
payload: String,
|
|
|
|
|
},
|
2026-06-25 12:30:18 +00:00
|
|
|
ProviderLine {
|
|
|
|
|
run_id: u64,
|
|
|
|
|
node_id: u64,
|
|
|
|
|
line: String,
|
|
|
|
|
},
|
|
|
|
|
Exited {
|
|
|
|
|
run_id: u64,
|
|
|
|
|
node_id: u64,
|
|
|
|
|
status: Option<i32>,
|
|
|
|
|
},
|
|
|
|
|
Failed {
|
|
|
|
|
run_id: u64,
|
|
|
|
|
node_id: u64,
|
|
|
|
|
reason: String,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
|
|
|
|
pub struct PluginNodeHandle {
|
|
|
|
|
pub id: u64,
|
|
|
|
|
pub provider_process_id: Option<u32>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub trait PluginObservationSink: Send + Sync {
|
|
|
|
|
fn observe(&self, observation: PluginObservation);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Clone)]
|
|
|
|
|
pub struct PluginSink {
|
|
|
|
|
inner: Arc<dyn PluginObservationSink>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl PluginSink {
|
|
|
|
|
pub fn new(inner: Arc<dyn PluginObservationSink>) -> Self {
|
|
|
|
|
Self { inner }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn observe(&self, observation: PluginObservation) {
|
|
|
|
|
self.inner.observe(observation);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub trait ProvisionPlugin: Send {
|
|
|
|
|
fn start_node(
|
|
|
|
|
&mut self,
|
|
|
|
|
spec: NodeProvisionSpec,
|
|
|
|
|
sink: PluginSink,
|
|
|
|
|
) -> Result<PluginNodeHandle, String>;
|
|
|
|
|
|
2026-07-26 09:01:18 +00:00
|
|
|
fn start_nodes(
|
|
|
|
|
&mut self,
|
|
|
|
|
specs: Vec<NodeProvisionSpec>,
|
|
|
|
|
sink: PluginSink,
|
|
|
|
|
) -> Vec<(NodeProvisionSpec, Result<PluginNodeHandle, String>)> {
|
|
|
|
|
specs
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|spec| {
|
|
|
|
|
let result = self.start_node(spec.clone(), sink.clone());
|
|
|
|
|
(spec, result)
|
|
|
|
|
})
|
|
|
|
|
.collect()
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-08 14:06:00 +00:00
|
|
|
fn complete_bootstrap(&mut self, handle: &PluginNodeHandle) -> Result<(), String>;
|
|
|
|
|
|
2026-06-25 12:30:18 +00:00
|
|
|
fn stop_node(&mut self, handle: &PluginNodeHandle) -> Result<(), String>;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub struct LocalDockerPlugin {
|
|
|
|
|
container_name_prefix: String,
|
|
|
|
|
next_handle_id: u64,
|
|
|
|
|
nodes: BTreeMap<u64, LocalDockerNode>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct LocalDockerNode {
|
|
|
|
|
container_name: String,
|
|
|
|
|
stdin: ChildStdin,
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 06:14:34 +00:00
|
|
|
pub struct LocalProcessPlugin {
|
|
|
|
|
program: PathBuf,
|
|
|
|
|
next_handle_id: u64,
|
|
|
|
|
nodes: BTreeMap<u64, LocalProcessNode>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
struct LocalProcessNode {
|
|
|
|
|
stdin: ChildStdin,
|
|
|
|
|
child: Arc<Mutex<Option<Child>>>,
|
|
|
|
|
spec: NodeProvisionSpec,
|
|
|
|
|
sink: PluginSink,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl LocalProcessPlugin {
|
|
|
|
|
pub fn new(program: impl Into<PathBuf>) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
program: program.into(),
|
|
|
|
|
next_handle_id: 1,
|
|
|
|
|
nodes: BTreeMap::new(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 12:30:18 +00:00
|
|
|
impl LocalDockerPlugin {
|
|
|
|
|
pub fn new(container_name_prefix: impl Into<String>) -> Self {
|
|
|
|
|
Self {
|
|
|
|
|
container_name_prefix: container_name_prefix.into(),
|
|
|
|
|
next_handle_id: 1,
|
|
|
|
|
nodes: BTreeMap::new(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-05 09:59:51 +00:00
|
|
|
fn docker_mount_arg(mount: &ProviderMount) -> String {
|
|
|
|
|
let mut arg = format!(
|
|
|
|
|
"type=bind,src={},dst={}",
|
|
|
|
|
mount.host_path, mount.container_path
|
|
|
|
|
);
|
|
|
|
|
if mount.readonly {
|
|
|
|
|
arg.push_str(",readonly");
|
|
|
|
|
}
|
|
|
|
|
arg
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn docker_runtime_mount_arg(image: &str, mount: &ProviderMount) -> Result<String, String> {
|
|
|
|
|
let host_path = Path::new(&mount.host_path);
|
|
|
|
|
if host_path.is_file() {
|
|
|
|
|
return prepare_docker_file_volume(image, mount, host_path);
|
|
|
|
|
}
|
|
|
|
|
Ok(docker_mount_arg(mount))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn prepare_docker_file_volume(
|
|
|
|
|
image: &str,
|
|
|
|
|
mount: &ProviderMount,
|
|
|
|
|
host_path: &Path,
|
|
|
|
|
) -> Result<String, String> {
|
|
|
|
|
let container_path = Path::new(&mount.container_path);
|
|
|
|
|
let container_dir = container_path
|
|
|
|
|
.parent()
|
|
|
|
|
.and_then(|path| path.to_str())
|
|
|
|
|
.filter(|path| !path.is_empty())
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
format!(
|
|
|
|
|
"cached model container path has no parent: {}",
|
|
|
|
|
mount.container_path
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let file_name = container_path
|
|
|
|
|
.file_name()
|
|
|
|
|
.and_then(|name| name.to_str())
|
|
|
|
|
.filter(|name| !name.is_empty())
|
|
|
|
|
.ok_or_else(|| {
|
|
|
|
|
format!(
|
|
|
|
|
"cached model container path has no file name: {}",
|
|
|
|
|
mount.container_path
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let volume = docker_file_cache_volume_name(host_path)?;
|
|
|
|
|
docker_status(
|
|
|
|
|
&["volume", "create", &volume],
|
|
|
|
|
"create cached model docker volume",
|
|
|
|
|
)?;
|
|
|
|
|
let loader_name = format!("mvp-cache-load-{}-{volume}", std::process::id());
|
|
|
|
|
let _ = Command::new("docker")
|
|
|
|
|
.args(["rm", "-f", &loader_name])
|
|
|
|
|
.stdout(Stdio::null())
|
|
|
|
|
.stderr(Stdio::null())
|
|
|
|
|
.status();
|
|
|
|
|
docker_status_vec(
|
|
|
|
|
vec![
|
|
|
|
|
"create".to_owned(),
|
|
|
|
|
"--name".to_owned(),
|
|
|
|
|
loader_name.clone(),
|
|
|
|
|
"--mount".to_owned(),
|
|
|
|
|
format!("type=volume,src={volume},dst={container_dir}"),
|
|
|
|
|
"--entrypoint".to_owned(),
|
|
|
|
|
"/bin/sh".to_owned(),
|
|
|
|
|
image.to_owned(),
|
|
|
|
|
"-c".to_owned(),
|
|
|
|
|
"true".to_owned(),
|
|
|
|
|
],
|
|
|
|
|
"create cached model loader container",
|
|
|
|
|
)?;
|
|
|
|
|
let copy_result = docker_status_vec(
|
|
|
|
|
vec![
|
|
|
|
|
"cp".to_owned(),
|
|
|
|
|
host_path.to_string_lossy().to_string(),
|
|
|
|
|
format!("{loader_name}:{container_dir}/{file_name}"),
|
|
|
|
|
],
|
|
|
|
|
"copy cached model into docker volume",
|
|
|
|
|
);
|
|
|
|
|
let _ = Command::new("docker")
|
|
|
|
|
.args(["rm", &loader_name])
|
|
|
|
|
.stdout(Stdio::null())
|
|
|
|
|
.stderr(Stdio::null())
|
|
|
|
|
.status();
|
|
|
|
|
copy_result?;
|
|
|
|
|
|
|
|
|
|
// tinygrad opens GGUF files read-write even when it does not intend to mutate
|
|
|
|
|
// the original artifact. The Docker volume is a copied cache, so keeping the
|
|
|
|
|
// runtime mount writable preserves the host cache while satisfying the loader.
|
|
|
|
|
Ok(format!("type=volume,src={volume},dst={container_dir}"))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn docker_file_cache_volume_name(path: &Path) -> Result<String, String> {
|
|
|
|
|
let metadata = fs::metadata(path).map_err(|e| format!("stat {}: {e}", path.display()))?;
|
|
|
|
|
let modified = metadata
|
|
|
|
|
.modified()
|
|
|
|
|
.ok()
|
|
|
|
|
.and_then(|time| time.duration_since(UNIX_EPOCH).ok())
|
|
|
|
|
.map(|duration| duration.as_secs())
|
|
|
|
|
.unwrap_or(0);
|
|
|
|
|
let file = path
|
|
|
|
|
.file_name()
|
|
|
|
|
.and_then(|name| name.to_str())
|
|
|
|
|
.map(safe_docker_volume_component)
|
|
|
|
|
.unwrap_or_else(|| "model".to_owned());
|
|
|
|
|
Ok(format!("mvp-cache-{file}-{}-{modified}", metadata.len()))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn safe_docker_volume_component(value: &str) -> String {
|
|
|
|
|
let mut out = String::with_capacity(value.len().min(40));
|
|
|
|
|
for ch in value.chars() {
|
|
|
|
|
if out.len() >= 40 {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
if ch.is_ascii_alphanumeric() {
|
|
|
|
|
out.push(ch.to_ascii_lowercase());
|
|
|
|
|
} else if !out.ends_with('-') {
|
|
|
|
|
out.push('-');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
let trimmed = out.trim_matches('-');
|
|
|
|
|
if trimmed.is_empty() {
|
|
|
|
|
"model".to_owned()
|
|
|
|
|
} else {
|
|
|
|
|
trimmed.to_owned()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn docker_status(args: &[&str], label: &str) -> Result<(), String> {
|
|
|
|
|
let status = Command::new("docker")
|
|
|
|
|
.args(args)
|
|
|
|
|
.stdin(Stdio::null())
|
|
|
|
|
.stdout(Stdio::null())
|
|
|
|
|
.stderr(Stdio::inherit())
|
|
|
|
|
.status()
|
|
|
|
|
.map_err(|e| format!("{label}: {e}"))?;
|
|
|
|
|
if status.success() {
|
|
|
|
|
Ok(())
|
|
|
|
|
} else {
|
|
|
|
|
Err(format!("{label} failed with {status}"))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn docker_status_vec(args: Vec<String>, label: &str) -> Result<(), String> {
|
|
|
|
|
let status = Command::new("docker")
|
|
|
|
|
.args(&args)
|
|
|
|
|
.stdin(Stdio::null())
|
|
|
|
|
.stdout(Stdio::null())
|
|
|
|
|
.stderr(Stdio::inherit())
|
|
|
|
|
.status()
|
|
|
|
|
.map_err(|e| format!("{label}: {e}"))?;
|
|
|
|
|
if status.success() {
|
|
|
|
|
Ok(())
|
|
|
|
|
} else {
|
|
|
|
|
Err(format!("{label} failed with {status}"))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 06:14:34 +00:00
|
|
|
fn lock_process_child(
|
|
|
|
|
child: &Arc<Mutex<Option<Child>>>,
|
|
|
|
|
) -> std::sync::MutexGuard<'_, Option<Child>> {
|
|
|
|
|
child
|
|
|
|
|
.lock()
|
|
|
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl ProvisionPlugin for LocalProcessPlugin {
|
|
|
|
|
fn start_node(
|
|
|
|
|
&mut self,
|
|
|
|
|
spec: NodeProvisionSpec,
|
|
|
|
|
sink: PluginSink,
|
|
|
|
|
) -> Result<PluginNodeHandle, String> {
|
|
|
|
|
let mut command = Command::new(&self.program);
|
|
|
|
|
for (key, value) in &spec.env {
|
|
|
|
|
command.env(key, value);
|
|
|
|
|
}
|
|
|
|
|
command.args(&spec.args);
|
|
|
|
|
command
|
|
|
|
|
.stdin(Stdio::piped())
|
|
|
|
|
.stdout(Stdio::piped())
|
|
|
|
|
.stderr(Stdio::piped());
|
|
|
|
|
#[cfg(target_os = "linux")]
|
|
|
|
|
unsafe {
|
|
|
|
|
command.pre_exec(|| {
|
|
|
|
|
if libc::setpgid(0, 0) == 0 {
|
|
|
|
|
Ok(())
|
|
|
|
|
} else {
|
|
|
|
|
Err(std::io::Error::last_os_error())
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut child = command.spawn().map_err(|e| {
|
|
|
|
|
format!(
|
|
|
|
|
"spawn local process node {} with {}: {e}",
|
|
|
|
|
spec.node_id,
|
|
|
|
|
self.program.display()
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
let provider_process_id = child.id();
|
|
|
|
|
let stdin = child
|
|
|
|
|
.stdin
|
|
|
|
|
.take()
|
|
|
|
|
.ok_or_else(|| format!("local process node {} stdin missing", spec.node_id))?;
|
|
|
|
|
let stdout = child
|
|
|
|
|
.stdout
|
|
|
|
|
.take()
|
|
|
|
|
.ok_or_else(|| format!("local process node {} stdout missing", spec.node_id))?;
|
|
|
|
|
let stderr = child
|
|
|
|
|
.stderr
|
|
|
|
|
.take()
|
|
|
|
|
.ok_or_else(|| format!("local process node {} stderr missing", spec.node_id))?;
|
|
|
|
|
|
|
|
|
|
let child = Arc::new(Mutex::new(Some(child)));
|
|
|
|
|
let handle = PluginNodeHandle {
|
|
|
|
|
id: self.next_handle_id,
|
|
|
|
|
provider_process_id: Some(provider_process_id),
|
|
|
|
|
};
|
|
|
|
|
self.next_handle_id = self.next_handle_id.wrapping_add(1).max(1);
|
|
|
|
|
self.nodes.insert(
|
|
|
|
|
handle.id,
|
|
|
|
|
LocalProcessNode {
|
|
|
|
|
stdin,
|
|
|
|
|
child: Arc::clone(&child),
|
|
|
|
|
spec: spec.clone(),
|
|
|
|
|
sink: sink.clone(),
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
spawn_stdout_reader(spec.clone(), sink.clone(), stdout);
|
|
|
|
|
spawn_stderr_reader(spec.clone(), sink.clone(), stderr);
|
|
|
|
|
thread::spawn(move || {
|
|
|
|
|
loop {
|
|
|
|
|
let observation = {
|
|
|
|
|
let mut slot = lock_process_child(&child);
|
|
|
|
|
let Some(child) = slot.as_mut() else {
|
|
|
|
|
return;
|
|
|
|
|
};
|
|
|
|
|
match child.try_wait() {
|
|
|
|
|
Ok(Some(status)) => {
|
|
|
|
|
*slot = None;
|
|
|
|
|
Some(PluginObservation::Exited {
|
|
|
|
|
run_id: spec.run_id,
|
|
|
|
|
node_id: spec.node_id,
|
|
|
|
|
status: status.code(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
Ok(None) => None,
|
|
|
|
|
Err(error) => {
|
|
|
|
|
*slot = None;
|
|
|
|
|
Some(PluginObservation::Failed {
|
|
|
|
|
run_id: spec.run_id,
|
|
|
|
|
node_id: spec.node_id,
|
|
|
|
|
reason: format!("wait local process node: {error}"),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
if let Some(observation) = observation {
|
|
|
|
|
sink.observe(observation);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
thread::sleep(Duration::from_millis(100));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
Ok(handle)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn complete_bootstrap(&mut self, _handle: &PluginNodeHandle) -> Result<(), String> {
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn stop_node(&mut self, handle: &PluginNodeHandle) -> Result<(), String> {
|
|
|
|
|
let Some(mut node) = self.nodes.remove(&handle.id) else {
|
|
|
|
|
return Ok(());
|
|
|
|
|
};
|
|
|
|
|
let _ = node.stdin.write_all(b"shutdown\n");
|
|
|
|
|
let _ = node.stdin.flush();
|
|
|
|
|
|
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(2);
|
|
|
|
|
while Instant::now() < deadline {
|
|
|
|
|
let observation = {
|
|
|
|
|
let mut slot = lock_process_child(&node.child);
|
|
|
|
|
let Some(child) = slot.as_mut() else {
|
|
|
|
|
return Ok(());
|
|
|
|
|
};
|
|
|
|
|
match child.try_wait() {
|
|
|
|
|
Ok(Some(status)) => {
|
|
|
|
|
*slot = None;
|
|
|
|
|
Some(PluginObservation::Exited {
|
|
|
|
|
run_id: node.spec.run_id,
|
|
|
|
|
node_id: node.spec.node_id,
|
|
|
|
|
status: status.code(),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
Ok(None) => None,
|
|
|
|
|
Err(error) => {
|
|
|
|
|
*slot = None;
|
|
|
|
|
Some(PluginObservation::Failed {
|
|
|
|
|
run_id: node.spec.run_id,
|
|
|
|
|
node_id: node.spec.node_id,
|
|
|
|
|
reason: format!("wait local process node: {error}"),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
if let Some(observation) = observation {
|
|
|
|
|
node.sink.observe(observation);
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
thread::sleep(Duration::from_millis(50));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let observation = {
|
|
|
|
|
let mut slot = lock_process_child(&node.child);
|
|
|
|
|
let Some(child) = slot.as_mut() else {
|
|
|
|
|
return Ok(());
|
|
|
|
|
};
|
|
|
|
|
#[cfg(target_os = "linux")]
|
|
|
|
|
unsafe {
|
|
|
|
|
let _ = libc::kill(-(child.id() as i32), libc::SIGKILL);
|
|
|
|
|
}
|
|
|
|
|
let _ = child.kill();
|
|
|
|
|
match child.wait() {
|
|
|
|
|
Ok(status) => {
|
|
|
|
|
*slot = None;
|
|
|
|
|
PluginObservation::Exited {
|
|
|
|
|
run_id: node.spec.run_id,
|
|
|
|
|
node_id: node.spec.node_id,
|
|
|
|
|
status: status.code(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
Err(error) => {
|
|
|
|
|
*slot = None;
|
|
|
|
|
PluginObservation::Failed {
|
|
|
|
|
run_id: node.spec.run_id,
|
|
|
|
|
node_id: node.spec.node_id,
|
|
|
|
|
reason: format!("kill local process node: {error}"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
node.sink.observe(observation);
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Drop for LocalProcessPlugin {
|
|
|
|
|
fn drop(&mut self) {
|
|
|
|
|
let handles = self
|
|
|
|
|
.nodes
|
|
|
|
|
.keys()
|
|
|
|
|
.copied()
|
|
|
|
|
.map(|id| PluginNodeHandle {
|
|
|
|
|
id,
|
|
|
|
|
provider_process_id: None,
|
|
|
|
|
})
|
|
|
|
|
.collect::<Vec<_>>();
|
|
|
|
|
for handle in handles {
|
|
|
|
|
let _ = self.stop_node(&handle);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 12:30:18 +00:00
|
|
|
impl ProvisionPlugin for LocalDockerPlugin {
|
|
|
|
|
fn start_node(
|
|
|
|
|
&mut self,
|
|
|
|
|
spec: NodeProvisionSpec,
|
|
|
|
|
sink: PluginSink,
|
|
|
|
|
) -> Result<PluginNodeHandle, String> {
|
|
|
|
|
let container_name = format!(
|
|
|
|
|
"{}-{}-{}",
|
|
|
|
|
self.container_name_prefix, spec.run_id, spec.node_id
|
|
|
|
|
);
|
|
|
|
|
let mut command = Command::new("docker");
|
|
|
|
|
command
|
|
|
|
|
.arg("run")
|
|
|
|
|
.arg("--rm")
|
|
|
|
|
.arg("--add-host")
|
|
|
|
|
.arg("host.docker.internal:host-gateway")
|
|
|
|
|
.arg("--name")
|
|
|
|
|
.arg(&container_name)
|
|
|
|
|
.arg("-i");
|
2026-07-05 09:59:51 +00:00
|
|
|
let docker_gpus = spec
|
2026-07-01 08:44:25 +00:00
|
|
|
.env
|
|
|
|
|
.iter()
|
|
|
|
|
.find(|(key, _)| key == "MVP_DOCKER_GPUS")
|
2026-07-05 09:59:51 +00:00
|
|
|
.map(|(_, value)| value.clone())
|
2026-07-01 08:44:25 +00:00
|
|
|
.or_else(|| std::env::var("MVP_DOCKER_GPUS").ok())
|
2026-07-05 09:59:51 +00:00
|
|
|
.filter(|value| !value.trim().is_empty());
|
|
|
|
|
sink.observe(PluginObservation::DatastreamFrame {
|
|
|
|
|
run_id: spec.run_id,
|
|
|
|
|
node_id: spec.node_id,
|
|
|
|
|
channel: "mvp.node.bootstrap".to_owned(),
|
|
|
|
|
payload: serde_json::json!({
|
|
|
|
|
"type":"DockerGpuConfigResolved",
|
|
|
|
|
"provider":"Docker",
|
|
|
|
|
"gpus_arg":docker_gpus.as_deref(),
|
|
|
|
|
"has_gpus_arg":docker_gpus.is_some(),
|
|
|
|
|
})
|
|
|
|
|
.to_string(),
|
|
|
|
|
});
|
|
|
|
|
if let Some(gpus) = docker_gpus.as_deref() {
|
2026-07-01 08:44:25 +00:00
|
|
|
command.arg("--gpus").arg(gpus);
|
|
|
|
|
}
|
2026-07-05 09:59:51 +00:00
|
|
|
for mount in &spec.mounts {
|
|
|
|
|
command
|
|
|
|
|
.arg("--mount")
|
|
|
|
|
.arg(docker_runtime_mount_arg(&spec.image, mount)?);
|
|
|
|
|
}
|
2026-06-25 12:30:18 +00:00
|
|
|
for (key, value) in &spec.env {
|
|
|
|
|
command.arg("-e").arg(format!("{key}={value}"));
|
|
|
|
|
}
|
|
|
|
|
command.arg(&spec.image);
|
|
|
|
|
for arg in &spec.args {
|
|
|
|
|
command.arg(arg);
|
|
|
|
|
}
|
|
|
|
|
let mut child = command
|
|
|
|
|
.stdin(Stdio::piped())
|
|
|
|
|
.stdout(Stdio::piped())
|
|
|
|
|
.stderr(Stdio::piped())
|
|
|
|
|
.spawn()
|
|
|
|
|
.map_err(|e| format!("spawn Docker node {}: {e}", spec.node_id))?;
|
|
|
|
|
|
|
|
|
|
let provider_process_id = child.id();
|
|
|
|
|
let stdin = child
|
|
|
|
|
.stdin
|
|
|
|
|
.take()
|
|
|
|
|
.ok_or_else(|| format!("Docker node {} stdin missing", spec.node_id))?;
|
|
|
|
|
let stdout = child
|
|
|
|
|
.stdout
|
|
|
|
|
.take()
|
|
|
|
|
.ok_or_else(|| format!("Docker node {} stdout missing", spec.node_id))?;
|
|
|
|
|
let stderr = child
|
|
|
|
|
.stderr
|
|
|
|
|
.take()
|
|
|
|
|
.ok_or_else(|| format!("Docker node {} stderr missing", spec.node_id))?;
|
|
|
|
|
|
|
|
|
|
let handle = PluginNodeHandle {
|
|
|
|
|
id: self.next_handle_id,
|
|
|
|
|
provider_process_id: Some(provider_process_id),
|
|
|
|
|
};
|
|
|
|
|
self.next_handle_id = self.next_handle_id.wrapping_add(1).max(1);
|
|
|
|
|
self.nodes.insert(
|
|
|
|
|
handle.id,
|
|
|
|
|
LocalDockerNode {
|
|
|
|
|
container_name: container_name.clone(),
|
|
|
|
|
stdin,
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
spawn_stdout_reader(spec.clone(), sink.clone(), stdout);
|
|
|
|
|
spawn_stderr_reader(spec.clone(), sink.clone(), stderr);
|
|
|
|
|
thread::spawn(move || match child.wait() {
|
|
|
|
|
Ok(status) => sink.observe(PluginObservation::Exited {
|
|
|
|
|
run_id: spec.run_id,
|
|
|
|
|
node_id: spec.node_id,
|
|
|
|
|
status: status.code(),
|
|
|
|
|
}),
|
|
|
|
|
Err(error) => sink.observe(PluginObservation::Failed {
|
|
|
|
|
run_id: spec.run_id,
|
|
|
|
|
node_id: spec.node_id,
|
|
|
|
|
reason: format!("wait Docker node: {error}"),
|
|
|
|
|
}),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
Ok(handle)
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-08 14:06:00 +00:00
|
|
|
fn complete_bootstrap(&mut self, _handle: &PluginNodeHandle) -> Result<(), String> {
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-25 12:30:18 +00:00
|
|
|
fn stop_node(&mut self, handle: &PluginNodeHandle) -> Result<(), String> {
|
|
|
|
|
let Some(mut node) = self.nodes.remove(&handle.id) else {
|
|
|
|
|
return Ok(());
|
|
|
|
|
};
|
|
|
|
|
let _ = writeln!(node.stdin, "shutdown");
|
|
|
|
|
let _ = node.stdin.flush();
|
|
|
|
|
let status = Command::new("docker")
|
|
|
|
|
.arg("stop")
|
|
|
|
|
.arg("-t")
|
|
|
|
|
.arg("2")
|
|
|
|
|
.arg(&node.container_name)
|
|
|
|
|
.stdout(Stdio::null())
|
|
|
|
|
.stderr(Stdio::null())
|
|
|
|
|
.status()
|
|
|
|
|
.map_err(|e| format!("docker stop {}: {e}", node.container_name))?;
|
|
|
|
|
if status.success() {
|
|
|
|
|
Ok(())
|
|
|
|
|
} else {
|
|
|
|
|
Err(format!(
|
|
|
|
|
"docker stop {} exited with {status}",
|
|
|
|
|
node.container_name
|
|
|
|
|
))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn spawn_stdout_reader(
|
|
|
|
|
spec: NodeProvisionSpec,
|
|
|
|
|
sink: PluginSink,
|
|
|
|
|
stdout: impl std::io::Read + Send + 'static,
|
|
|
|
|
) {
|
2026-07-01 08:44:25 +00:00
|
|
|
BootstrapDatastreamBridge::new(spec, sink, None).spawn_stdout_reader(stdout);
|
2026-06-25 12:30:18 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn spawn_stderr_reader(
|
|
|
|
|
spec: NodeProvisionSpec,
|
|
|
|
|
sink: PluginSink,
|
|
|
|
|
stderr: impl std::io::Read + Send + 'static,
|
|
|
|
|
) {
|
2026-07-01 08:44:25 +00:00
|
|
|
BootstrapDatastreamBridge::new(spec, sink, None).spawn_stderr_reader(stderr);
|
2026-06-25 12:30:18 +00:00
|
|
|
}
|
2026-07-05 09:59:51 +00:00
|
|
|
|
|
|
|
|
#[cfg(test)]
|
|
|
|
|
mod tests {
|
|
|
|
|
use super::*;
|
2026-07-12 06:14:34 +00:00
|
|
|
use std::sync::Mutex;
|
|
|
|
|
use std::time::{Duration, Instant};
|
|
|
|
|
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
|
|
|
|
|
|
#[derive(Default)]
|
|
|
|
|
struct RecordingSink {
|
|
|
|
|
observations: Mutex<Vec<PluginObservation>>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl RecordingSink {
|
|
|
|
|
fn observations(&self) -> Vec<PluginObservation> {
|
|
|
|
|
self.observations
|
|
|
|
|
.lock()
|
|
|
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
|
|
|
.clone()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl PluginObservationSink for RecordingSink {
|
|
|
|
|
fn observe(&self, observation: PluginObservation) {
|
|
|
|
|
self.observations
|
|
|
|
|
.lock()
|
|
|
|
|
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
|
|
|
|
.push(observation);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn recording_sink() -> (Arc<RecordingSink>, PluginSink) {
|
|
|
|
|
let recorder = Arc::new(RecordingSink::default());
|
|
|
|
|
(Arc::clone(&recorder), PluginSink::new(recorder))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn wait_for_observation(
|
|
|
|
|
recorder: &RecordingSink,
|
|
|
|
|
predicate: impl Fn(&PluginObservation) -> bool,
|
|
|
|
|
) -> PluginObservation {
|
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(5);
|
|
|
|
|
loop {
|
|
|
|
|
if let Some(observation) = recorder
|
|
|
|
|
.observations()
|
|
|
|
|
.into_iter()
|
|
|
|
|
.find(|observation| predicate(observation))
|
|
|
|
|
{
|
|
|
|
|
return observation;
|
|
|
|
|
}
|
|
|
|
|
assert!(
|
|
|
|
|
Instant::now() < deadline,
|
|
|
|
|
"timed out waiting for observation; observed: {:?}",
|
|
|
|
|
recorder.observations()
|
|
|
|
|
);
|
|
|
|
|
thread::sleep(Duration::from_millis(20));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn process_spec(node_id: u64, args: Vec<String>) -> NodeProvisionSpec {
|
|
|
|
|
NodeProvisionSpec {
|
|
|
|
|
run_id: 17,
|
|
|
|
|
node_id,
|
|
|
|
|
stage_index: Some(2),
|
|
|
|
|
image: "unused-for-local-process".to_owned(),
|
|
|
|
|
env: vec![("MVP_RUN_ID".to_owned(), "17".to_owned())],
|
|
|
|
|
args,
|
|
|
|
|
mounts: Vec::new(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
struct TempScript {
|
|
|
|
|
root: PathBuf,
|
|
|
|
|
path: PathBuf,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
impl TempScript {
|
|
|
|
|
fn new(name: &str, content: &str) -> Self {
|
|
|
|
|
let root = std::env::temp_dir().join(format!(
|
|
|
|
|
"mvp-local-process-plugin-{name}-{}-{}",
|
|
|
|
|
std::process::id(),
|
|
|
|
|
thread::current().name().unwrap_or("unnamed")
|
|
|
|
|
));
|
|
|
|
|
let _ = fs::remove_dir_all(&root);
|
|
|
|
|
fs::create_dir_all(&root).expect("create temp script directory");
|
|
|
|
|
let path = root.join("helper.sh");
|
|
|
|
|
fs::write(&path, content).expect("write local process helper");
|
|
|
|
|
let mut permissions = fs::metadata(&path)
|
|
|
|
|
.expect("stat local process helper")
|
|
|
|
|
.permissions();
|
|
|
|
|
permissions.set_mode(0o755);
|
|
|
|
|
fs::set_permissions(&path, permissions).expect("chmod local process helper");
|
|
|
|
|
Self { root, path }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
impl Drop for TempScript {
|
|
|
|
|
fn drop(&mut self) {
|
|
|
|
|
let _ = fs::remove_dir_all(&self.root);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
fn process_alive(pid: u32) -> bool {
|
|
|
|
|
unsafe {
|
|
|
|
|
if libc::kill(pid as i32, 0) == 0 {
|
|
|
|
|
true
|
|
|
|
|
} else {
|
|
|
|
|
std::io::Error::last_os_error().raw_os_error() != Some(libc::ESRCH)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-07-05 09:59:51 +00:00
|
|
|
|
|
|
|
|
fn spec_with_mounts(mounts: Vec<ProviderMount>) -> NodeProvisionSpec {
|
|
|
|
|
NodeProvisionSpec {
|
|
|
|
|
run_id: 17,
|
|
|
|
|
node_id: 23,
|
|
|
|
|
stage_index: Some(2),
|
|
|
|
|
image: "swactor-mvp-node:test".to_owned(),
|
|
|
|
|
env: vec![("MVP_RUN_ID".to_owned(), "17".to_owned())],
|
|
|
|
|
args: vec!["--serve".to_owned()],
|
|
|
|
|
mounts,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn node_provision_spec_serde_preserves_readonly_mounts() {
|
|
|
|
|
let spec = spec_with_mounts(vec![ProviderMount {
|
|
|
|
|
host_path: "/cache/models/model.gguf".to_owned(),
|
|
|
|
|
container_path: "/models/cached/model.gguf".to_owned(),
|
|
|
|
|
readonly: true,
|
|
|
|
|
}]);
|
|
|
|
|
|
|
|
|
|
let json = serde_json::to_string(&spec).expect("serialize mounted node spec");
|
|
|
|
|
let decoded: NodeProvisionSpec =
|
|
|
|
|
serde_json::from_str(&json).expect("deserialize mounted node spec");
|
|
|
|
|
|
|
|
|
|
assert_eq!(decoded, spec);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn node_provision_spec_serde_omits_empty_mounts_and_accepts_missing_mounts() {
|
|
|
|
|
let spec = spec_with_mounts(Vec::new());
|
|
|
|
|
|
|
|
|
|
let json = serde_json::to_value(&spec).expect("serialize unmounted node spec");
|
|
|
|
|
assert_eq!(json.get("mounts"), None);
|
|
|
|
|
|
|
|
|
|
let decoded: NodeProvisionSpec = serde_json::from_value(serde_json::json!({
|
|
|
|
|
"run_id": 17,
|
|
|
|
|
"node_id": 23,
|
|
|
|
|
"stage_index": 2,
|
|
|
|
|
"image": "swactor-mvp-node:test",
|
|
|
|
|
"env": [["MVP_RUN_ID", "17"]],
|
|
|
|
|
"args": ["--serve"]
|
|
|
|
|
}))
|
|
|
|
|
.expect("deserialize node spec written before mounts existed");
|
|
|
|
|
|
|
|
|
|
assert!(decoded.mounts.is_empty());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn docker_mount_arg_uses_bind_src_dst_and_readonly_flag() {
|
|
|
|
|
let mount = ProviderMount {
|
|
|
|
|
host_path: "/cache/models/model.gguf".to_owned(),
|
|
|
|
|
container_path: "/models/cached/model.gguf".to_owned(),
|
|
|
|
|
readonly: true,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
assert_eq!(
|
|
|
|
|
docker_mount_arg(&mount),
|
|
|
|
|
"type=bind,src=/cache/models/model.gguf,dst=/models/cached/model.gguf,readonly"
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
let writable_mount = ProviderMount {
|
|
|
|
|
readonly: false,
|
|
|
|
|
..mount
|
|
|
|
|
};
|
|
|
|
|
assert_eq!(
|
|
|
|
|
docker_mount_arg(&writable_mount),
|
|
|
|
|
"type=bind,src=/cache/models/model.gguf,dst=/models/cached/model.gguf"
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-07-12 06:14:34 +00:00
|
|
|
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
#[test]
|
|
|
|
|
fn local_process_plugin_observes_stdio_frames_and_graceful_shutdown() {
|
|
|
|
|
let helper = TempScript::new(
|
|
|
|
|
"graceful",
|
|
|
|
|
r#"#!/bin/sh
|
|
|
|
|
printf '%s\n' '{"mvp_stdio_event":1,"kind":"datastream_frame","channel":"mvp.node.bootstrap","payload":{"type":"TestFrame","status":"ready"}}'
|
|
|
|
|
printf '%s\n' 'local-process-stderr' >&2
|
|
|
|
|
while IFS= read -r line; do
|
|
|
|
|
if [ "$line" = shutdown ]; then
|
|
|
|
|
exit 0
|
|
|
|
|
fi
|
|
|
|
|
done
|
|
|
|
|
exit 0
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
let mut plugin = LocalProcessPlugin::new("/bin/sh");
|
|
|
|
|
let (recorder, sink) = recording_sink();
|
|
|
|
|
let spec = process_spec(23, vec![helper.path.to_string_lossy().to_string()]);
|
|
|
|
|
|
|
|
|
|
let handle = plugin
|
|
|
|
|
.start_node(spec, sink)
|
|
|
|
|
.expect("start local process node");
|
|
|
|
|
|
|
|
|
|
assert!(handle.provider_process_id.is_some());
|
|
|
|
|
wait_for_observation(&recorder, |observation| {
|
|
|
|
|
matches!(
|
|
|
|
|
observation,
|
|
|
|
|
PluginObservation::DatastreamFrame {
|
|
|
|
|
channel,
|
|
|
|
|
payload,
|
|
|
|
|
..
|
|
|
|
|
} if channel == "mvp.node.bootstrap"
|
|
|
|
|
&& payload.contains("\"TestFrame\"")
|
|
|
|
|
&& payload.contains("\"ready\"")
|
|
|
|
|
)
|
|
|
|
|
});
|
|
|
|
|
wait_for_observation(&recorder, |observation| {
|
|
|
|
|
matches!(
|
|
|
|
|
observation,
|
|
|
|
|
PluginObservation::StderrLine { line, .. } if line == "local-process-stderr"
|
|
|
|
|
)
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
plugin.stop_node(&handle).expect("stop local process node");
|
|
|
|
|
|
|
|
|
|
wait_for_observation(&recorder, |observation| {
|
|
|
|
|
matches!(
|
|
|
|
|
observation,
|
|
|
|
|
PluginObservation::Exited {
|
|
|
|
|
node_id: 23,
|
|
|
|
|
status: Some(0),
|
|
|
|
|
..
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[cfg(unix)]
|
|
|
|
|
#[test]
|
|
|
|
|
fn local_process_plugin_kills_and_reaps_unresponsive_child() {
|
|
|
|
|
let helper = TempScript::new(
|
|
|
|
|
"unresponsive",
|
|
|
|
|
r#"#!/bin/sh
|
|
|
|
|
while :; do
|
|
|
|
|
sleep 1
|
|
|
|
|
done
|
|
|
|
|
"#,
|
|
|
|
|
);
|
|
|
|
|
let mut plugin = LocalProcessPlugin::new("/bin/sh");
|
|
|
|
|
let (recorder, sink) = recording_sink();
|
|
|
|
|
let spec = process_spec(24, vec![helper.path.to_string_lossy().to_string()]);
|
|
|
|
|
let handle = plugin
|
|
|
|
|
.start_node(spec, sink)
|
|
|
|
|
.expect("start unresponsive local process node");
|
|
|
|
|
let pid = handle
|
|
|
|
|
.provider_process_id
|
|
|
|
|
.expect("local process handle exposes child pid");
|
|
|
|
|
|
|
|
|
|
plugin
|
|
|
|
|
.stop_node(&handle)
|
|
|
|
|
.expect("stop unresponsive local process node");
|
|
|
|
|
|
|
|
|
|
wait_for_observation(&recorder, |observation| {
|
|
|
|
|
matches!(
|
|
|
|
|
observation,
|
|
|
|
|
PluginObservation::Exited {
|
|
|
|
|
node_id: 24,
|
|
|
|
|
status,
|
|
|
|
|
..
|
|
|
|
|
} if *status != Some(0)
|
|
|
|
|
)
|
|
|
|
|
});
|
|
|
|
|
let deadline = Instant::now() + Duration::from_secs(1);
|
|
|
|
|
while process_alive(pid) && Instant::now() < deadline {
|
|
|
|
|
thread::sleep(Duration::from_millis(20));
|
|
|
|
|
}
|
|
|
|
|
assert!(
|
|
|
|
|
!process_alive(pid),
|
|
|
|
|
"local process child {pid} is still live"
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-07-05 09:59:51 +00:00
|
|
|
}
|