//! `exec`: Durable's `NodeExecutionEnv.exec` semantics (docs/semantics.md), output streamed as `output` events. use crate::decode::StreamDecoder; use crate::errors::Failure; use crate::frame::{EVENT, Frame}; use crate::output::Output; use crate::sys; use crate::window::{Pending, Window}; use serde_json::{Map, Value, json}; use std::collections::HashSet; use std::fs::{File, OpenOptions}; use std::io::Write; use std::path::Path; use std::process::{Child, Command, ExitStatus, Stdio}; use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; use std::sync::mpsc::{self, RecvTimeoutError, Sender}; use std::sync::{Arc, Mutex}; use std::thread::{self, JoinHandle}; use std::time::{Duration, Instant}; const EXIT_STDIO_GRACE: Duration = Duration::from_millis(100); /// How often a delivery waiting for the link to drain looks again. const UNSENT_POLL: Duration = Duration::from_millis(20); /// Commands this daemon started and has not reaped; killed when the connection ends. pub type Groups = Arc>>; pub enum Message { Data(usize, Vec), Eof(usize), Exit(ExitStatus), WaitFailed(String), /// Abort: the command settles as `aborted`. Cancel, /// Kill without aborting, as `cleanup()` does: the command settles with the killed process's status. Kill, } /// Cancellation of one request, registered before the request runs so a cancel that arrives first is not lost. #[derive(Default)] pub struct Control { pub aborted: AtomicBool, pub killed: AtomicBool, sender: Mutex>>, } impl Control { pub fn cancel(&self, kill: bool) { let sender = self.sender.lock().unwrap(); if kill { self.killed.store(true, Ordering::SeqCst); } else { self.aborted.store(true, Ordering::SeqCst); } if let Some(sender) = sender.as_ref() { let _ = sender.send(if kill { Message::Kill } else { Message::Cancel }); } } pub fn attach(&self, sender: Sender) { *self.sender.lock().unwrap() = Some(sender); } } pub struct ExecRequest { pub command: Option, pub argv: Option>, pub cwd: String, pub env: Map, pub inherit_env: bool, pub shell_path: Option, pub timeout: Option, pub spill: Option<(u64, u64)>, pub window: Option, } impl ExecRequest { pub fn from_json(json: &Value) -> Result { let strings = |value: &Value| -> Option> { value .as_array()? .iter() .map(|item| item.as_str().map(str::to_string)) .collect() }; Ok(ExecRequest { command: json["command"].as_str().map(str::to_string), argv: strings(&json["argv"]), cwd: json["cwd"] .as_str() .ok_or_else(|| Failure::new("EINVAL", "exec needs cwd"))? .to_string(), env: json["env"].as_object().cloned().unwrap_or_default(), inherit_env: json["inheritEnv"].as_bool().unwrap_or(true), shell_path: json["shellPath"].as_str().map(str::to_string), timeout: json["timeoutMs"] .as_f64() .map(|ms| Duration::from_secs_f64(ms / 1000.0)), spill: json["spill"].as_object().and_then(|spill| { Some(( spill.get("afterBytes")?.as_u64()?, spill.get("afterLines")?.as_u64()?, )) }), window: Window::from_json(&json["window"]), }) } } pub fn kill_group(pid: u32) { sys::kill_tree(pid); } fn random_uuid() -> String { let mut bytes = sys::random_bytes(); bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; let hex: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); format!( "{}-{}-{}-{}-{}", &hex[0..8], &hex[8..12], &hex[12..16], &hex[16..20], &hex[20..32] ) } /// Spill of the complete raw output, started once it crosses either threshold, like Node's. struct Spill { after_bytes: u64, after_lines: u64, seen_bytes: u64, seen_newlines: u64, prefix: Vec>, file: Option, path: Option, failed: Option, } impl Spill { fn push(&mut self, chunk: &[u8], tmpdir: &str) { if self.failed.is_some() || chunk.is_empty() { return; } if self.file.is_none() { self.seen_bytes += chunk.len() as u64; self.seen_newlines += chunk.iter().filter(|byte| **byte == b'\n').count() as u64; let lines = self.seen_newlines + u64::from(chunk.last() != Some(&b'\n')); if self.seen_bytes <= self.after_bytes && lines <= self.after_lines { self.prefix.push(chunk.to_vec()); return; } if let Err(error) = self.start(tmpdir) { self.failed = Some(error); return; } } if let Some(file) = &mut self.file && let Err(error) = file.write_all(chunk) { self.failed = Some(error.to_string()); } } /// `createTempFile({ prefix: "pi-output-", suffix: ".log" })`: a fresh `tmp-` directory holding the file. fn start(&mut self, tmpdir: &str) -> Result<(), String> { let prefix = Path::new(tmpdir) .join("tmp-") .to_string_lossy() .into_owned(); let directory = sys::mkdtemp(&prefix).map_err(|error| error.to_string())?; let path = Path::new(&directory) .join(format!("pi-output-{}.log", random_uuid())) .to_string_lossy() .into_owned(); let mut file = OpenOptions::new() .append(true) .create(true) .open(&path) .map_err(|error| error.to_string())?; for chunk in self.prefix.drain(..) { file.write_all(&chunk).map_err(|error| error.to_string())?; } self.file = Some(file); self.path = Some(path); Ok(()) } } fn spawn( request: &ExecRequest, program: &str, args: &[String], piped_stdin: bool, ) -> std::io::Result { let mut command = Command::new(program); command .current_dir(&request.cwd) .stdin(if piped_stdin { Stdio::piped() } else { Stdio::null() }) .stdout(Stdio::piped()) .stderr(Stdio::piped()); sys::configure(&mut command, args, &request.env, request.inherit_env); let child = command.spawn()?; sys::after_spawn(&child); Ok(child) } /// Read one pipe on its own thread. Without an output window, reading waits while too much output is unsent. fn spawn_reader( stream: usize, pipe: impl sys::Pipe + Send + 'static, sender: Sender, stop: Arc, output: Option>, ) -> JoinHandle<()> { thread::spawn(move || { sys::read_pipe(pipe, &stop, |bytes| { let delivered = sender.send(Message::Data(stream, bytes.to_vec())).is_ok(); if let Some(output) = &output { output.wait_for_room(&stop); } delivered }); let _ = sender.send(Message::Eof(stream)); }) } fn output_frame(id: u32, stream: &str, text: String, skipped: Option) -> Frame { let mut json = json!({ "kind": "output", "stream": stream }); if let Some(skipped) = skipped { json["skipped"] = skipped; } Frame::with_payload(EVENT, id, json, text.into_bytes()) } /// Run one command, sending output events for request `id`, until it settles. pub fn run( id: u32, request: ExecRequest, tmpdir: &str, output: &Arc, control: &Control, groups: &Groups, ) -> Result { let (sender, receiver) = mpsc::channel(); control.attach(sender.clone()); if control.aborted.load(Ordering::SeqCst) { return Err(Failure::new("aborted", "aborted")); } // A string runs through the shell (as its last argument, or on stdin for legacy WSL bash); argv runs directly. let (program, args, stdin_command) = match (&request.command, &request.argv) { (Some(command), _) => { let shell = sys::shell_config(request.shell_path.as_deref())?; let mut args = shell.args; if shell.command_on_stdin { (shell.program, args, Some(command.clone())) } else { args.push(command.clone()); (shell.program, args, None) } } (None, Some(argv)) => match argv.split_first() { Some((first, rest)) => ( sys::resolve_program(first, &request.cwd, &request.env)?, rest.to_vec(), None, ), None => return Err(Failure::new("spawn_error", "Empty argv: no program to run")), }, (None, None) => return Err(Failure::new("EINVAL", "exec needs command or argv")), }; if !Path::new(&request.cwd).exists() { return Err(Failure::new( "spawn_error", format!( "Working directory does not exist: {}\nCannot execute bash commands.", request.cwd ), )); } let mut child = spawn(&request, &program, &args, stdin_command.is_some()) .map_err(|error| Failure::new("spawn_error", error.to_string()))?; if let (Some(command), Some(mut stdin)) = (stdin_command, child.stdin.take()) { let _ = stdin.write_all(command.as_bytes()); } let pid = child.id(); groups.lock().unwrap().insert(pid); let deadline = request.timeout.map(|timeout| Instant::now() + timeout); let stop = Arc::new(AtomicBool::new(false)); // Without a window, output is not coalesced; reading then waits for the link. let gate = if request.window.is_none() { Some(output.clone()) } else { None }; let readers = [ spawn_reader( 0, child.stdout.take().unwrap(), sender.clone(), stop.clone(), gate.clone(), ), spawn_reader( 1, child.stderr.take().unwrap(), sender.clone(), stop.clone(), gate, ), ]; let waiter = sender.clone(); thread::spawn(move || { let _ = waiter.send(match child.wait() { Ok(status) => Message::Exit(status), Err(error) => Message::WaitFailed(error.to_string()), }); }); drop(sender); let mut decoders = [StreamDecoder::new(), StreamDecoder::new()]; let mut spill = request.spill.map(|(after_bytes, after_lines)| Spill { after_bytes, after_lines, seen_bytes: 0, seen_newlines: 0, prefix: Vec::new(), file: None, path: None, failed: None, }); let mut pending = request.window.map(Pending::new); let unsent = Arc::new(AtomicUsize::new(0)); let names = ["stdout", "stderr"]; let emit = |pending: &mut Option, stream: usize, text: String| { if text.is_empty() { return; } match pending { Some(pending) => pending.push(names[stream], text), None => output.bulk(output_frame(id, names[stream], text, None), None), } }; let deliver = |pending: &mut Pending| { for event in pending.take() { output.bulk( output_frame(id, event.stream, event.text, event.skipped), Some(unsent.clone()), ); } }; let mut ended = [false, false]; let mut status: Option> = None; let mut idle_until: Option = None; let mut timed_out = false; let mut aborted = false; let mut killed = false; let kill = |killed: &mut bool| { if !*killed { *killed = true; kill_group(pid); } }; if control.killed.load(Ordering::SeqCst) { kill(&mut killed); } loop { let now = Instant::now(); // Node's timer stays armed until the command settles, including the grace period after exit. if !timed_out && deadline.is_some_and(|deadline| now >= deadline) { timed_out = true; kill(&mut killed); } if status.is_some() && ended[0] && ended[1] { break; } if idle_until.is_some_and(|idle| now >= idle) { // A descendant may hold the pipes open after the process exited; stop waiting for it. break; } if let Some(pending) = &mut pending && !pending.is_empty() && now >= pending.next_send() && unsent.load(Ordering::SeqCst) == 0 { deliver(pending); } let mut wait = Duration::from_secs(3600); if let Some(deadline) = deadline.filter(|_| !timed_out) { wait = wait.min(deadline.saturating_duration_since(now)); } if let Some(idle) = idle_until { wait = wait.min(idle.saturating_duration_since(now)); } if let Some(pending) = pending.as_ref().filter(|pending| !pending.is_empty()) { wait = wait.min(if unsent.load(Ordering::SeqCst) > 0 { UNSENT_POLL } else { pending.next_send().saturating_duration_since(now) }); } match receiver.recv_timeout(wait) { Ok(Message::Data(stream, bytes)) => { emit(&mut pending, stream, decoders[stream].decode(&bytes, false)); if let Some(spill) = &mut spill { spill.push(&bytes, tmpdir); if spill.failed.is_some() { kill(&mut killed); } } if status.is_some() { idle_until = Some(Instant::now() + EXIT_STDIO_GRACE); } } Ok(Message::Eof(stream)) => ended[stream] = true, Ok(Message::Exit(exit)) => { status = Some(Ok(exit)); idle_until = Some(Instant::now() + EXIT_STDIO_GRACE); } Ok(Message::WaitFailed(error)) => { status = Some(Err(error)); idle_until = Some(Instant::now()); } Ok(Message::Cancel) => { aborted = true; kill(&mut killed); } Ok(Message::Kill) => kill(&mut killed), Err(RecvTimeoutError::Timeout) => {} Err(RecvTimeoutError::Disconnected) => break, } } // Like Node destroying its streams: stop reading, so the pipes close even if a descendant still holds them. stop.store(true, Ordering::SeqCst); for reader in &readers { sys::interrupt_reader(reader); } groups.lock().unwrap().remove(&pid); emit(&mut pending, 0, decoders[0].decode(&[], true)); emit(&mut pending, 1, decoders[1].decode(&[], true)); if let Some(pending) = &mut pending && !pending.is_empty() { deliver(pending); } let spill_path = spill.as_ref().and_then(|spill| spill.path.clone()); let with_spill = |failure: Failure| match &spill_path { Some(path) => failure.extra(json!({ "spillPath": path })), None => failure, }; if timed_out { return Err(with_spill(Failure::new("timeout", "timeout"))); } if aborted { return Err(with_spill(Failure::new("aborted", "aborted"))); } if let Some(error) = spill.as_ref().and_then(|spill| spill.failed.clone()) { return Err(Failure::new( "unknown", format!("Failed to preserve complete shell output: {error}"), )); } let status = match status { Some(Ok(status)) => status, Some(Err(error)) => return Err(Failure::new("spawn_error", error)), None => { return Err(Failure::new( "spawn_error", "process did not report its exit", )); } }; let mut result = json!({ "exitCode": sys::exit_code(status) }); if let Some(path) = spill_path { result["spillPath"] = json!(path); } Ok(result) }