//! Shared Process Executor //! //! Provides common subprocess execution infrastructure used by all runtime //! implementations. Handles streaming stdout/stderr capture, bounded log //! collection, timeout management, stdin parameter delivery, and //! output format parsing. //! //! ## Cancellation Support //! //! When a `CancellationToken` is provided, the executor monitors it alongside //! the running process. On cancellation: //! 1. SIGTERM is sent to the process immediately //! 2. After a 5-second grace period, SIGKILL is sent as a last resort use super::{BoundedLogWriter, ExecutionResult, OutputFormat, RuntimeResult}; use std::collections::HashMap; use std::io; use std::path::Path; use std::time::Instant; use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; use tokio::process::Command; use tokio::time::timeout; use tokio_util::sync::CancellationToken; use tracing::{debug, info, warn}; /// Execute a subprocess command with streaming output capture. /// /// This is the core execution function used by all runtime implementations. /// It handles: /// - Spawning the process with piped I/O /// - Writing parameters (with secrets merged in) to stdin /// - Streaming stdout/stderr with bounded log collection /// - Timeout management /// - Output format parsing (JSON, YAML, JSONL, text) /// /// # Arguments /// * `cmd` - Pre-configured `Command` (interpreter, args, env vars, working dir already set) /// * `secrets` - Deprecated/unused — secrets are now merged into parameters by the caller /// * `parameters_stdin` - Optional parameter data (including secrets) to write to stdin /// * `timeout_secs` - Optional execution timeout in seconds /// * `max_stdout_bytes` - Maximum stdout size before truncation /// * `max_stderr_bytes` - Maximum stderr size before truncation /// * `output_format` - How to parse stdout (Text, Json, Yaml, Jsonl) pub async fn execute_streaming( cmd: Command, _secrets: &HashMap, parameters_stdin: Option<&str>, timeout_secs: Option, max_stdout_bytes: usize, max_stderr_bytes: usize, output_format: OutputFormat, ) -> RuntimeResult { execute_streaming_cancellable( cmd, _secrets, parameters_stdin, timeout_secs, max_stdout_bytes, max_stderr_bytes, output_format, None, ) .await } /// Execute a subprocess command with streaming output capture and optional cancellation. /// /// This is the core execution function used by all runtime implementations. /// It handles: /// - Spawning the process with piped I/O /// - Writing parameters (with secrets merged in) to stdin /// - Streaming stdout/stderr with bounded log collection /// - Timeout management /// - Prompt cancellation via SIGTERM → SIGKILL escalation /// - Output format parsing (JSON, YAML, JSONL, text) /// /// # Arguments /// * `cmd` - Pre-configured `Command` (interpreter, args, env vars, working dir already set) /// * `secrets` - Deprecated/unused — secrets are now merged into parameters by the caller /// * `parameters_stdin` - Optional parameter data (including secrets) to write to stdin /// * `timeout_secs` - Optional execution timeout in seconds /// * `max_stdout_bytes` - Maximum stdout size before truncation /// * `max_stderr_bytes` - Maximum stderr size before truncation /// * `output_format` - How to parse stdout (Text, Json, Yaml, Jsonl) /// * `cancel_token` - Optional cancellation token for graceful process termination #[allow(clippy::too_many_arguments)] pub async fn execute_streaming_cancellable( mut cmd: Command, _secrets: &HashMap, parameters_stdin: Option<&str>, timeout_secs: Option, max_stdout_bytes: usize, max_stderr_bytes: usize, output_format: OutputFormat, cancel_token: Option, ) -> RuntimeResult { let start = Instant::now(); configure_child_process(&mut cmd)?; // Spawn process with piped I/O let mut child = cmd .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) .spawn()?; // Write to stdin - parameters (with secrets already merged in by the caller). // If this fails, the process has already started, so we continue and capture output. let stdin_write_error = if let Some(mut stdin) = child.stdin.take() { let mut error = None; // Write parameters to stdin as a single JSON line. // Secrets are merged into the parameters map by the caller, so the // action reads everything with a single readline(). if let Some(params_data) = parameters_stdin { if let Err(e) = stdin.write_all(params_data.as_bytes()).await { error = Some(format!("Failed to write parameters to stdin: {}", e)); } else if let Err(e) = stdin.write_all(b"\n").await { error = Some(format!("Failed to write newline to stdin: {}", e)); } } drop(stdin); error } else { None }; // Create bounded writers let mut stdout_writer = BoundedLogWriter::new_stdout(max_stdout_bytes); let mut stderr_writer = BoundedLogWriter::new_stderr(max_stderr_bytes); // Take stdout and stderr streams let stdout = child.stdout.take().expect("stdout not captured"); let stderr = child.stderr.take().expect("stderr not captured"); // Create buffered readers let mut stdout_reader = BufReader::new(stdout); let mut stderr_reader = BufReader::new(stderr); // Stream both outputs concurrently let stdout_task = async { let mut line = Vec::new(); loop { line.clear(); match stdout_reader.read_until(b'\n', &mut line).await { Ok(0) => break, // EOF Ok(_) => { if stdout_writer.write_all(&line).await.is_err() { break; } } Err(_) => break, } } stdout_writer }; let stderr_task = async { let mut line = Vec::new(); loop { line.clear(); match stderr_reader.read_until(b'\n', &mut line).await { Ok(0) => break, // EOF Ok(_) => { if stderr_writer.write_all(&line).await.is_err() { break; } } Err(_) => break, } } stderr_writer }; // Determine the process ID for signal-based cancellation. // Must be read before we move `child` into the wait future. let child_pid = child.id(); // Build the wait future that handles timeout, cancellation, and normal completion. // // The result is a tuple: (exit_status, was_cancelled, was_timed_out) let wait_future = async { match (cancel_token.as_ref(), timeout_secs) { (Some(token), Some(timeout_secs)) => { tokio::select! { result = child.wait() => (result, false, false), _ = token.cancelled() => { if let Some(pid) = child_pid { terminate_process(pid, "cancel"); } (wait_for_terminated_child(&mut child).await, true, false) } _ = tokio::time::sleep(std::time::Duration::from_secs(timeout_secs)) => { if let Some(pid) = child_pid { warn!("Process timed out after {} seconds, terminating", timeout_secs); terminate_process(pid, "timeout"); } (wait_for_terminated_child(&mut child).await, false, true) } } } (Some(token), None) => { tokio::select! { result = child.wait() => (result, false, false), _ = token.cancelled() => { if let Some(pid) = child_pid { terminate_process(pid, "cancel"); } (wait_for_terminated_child(&mut child).await, true, false) } } } (None, Some(timeout_secs)) => { tokio::select! { result = child.wait() => (result, false, false), _ = tokio::time::sleep(std::time::Duration::from_secs(timeout_secs)) => { if let Some(pid) = child_pid { warn!("Process timed out after {} seconds, terminating", timeout_secs); terminate_process(pid, "timeout"); } (wait_for_terminated_child(&mut child).await, false, true) } } } (None, None) => (child.wait().await, false, false), } }; // Wait for both streams and the process let (stdout_writer, stderr_writer, (wait_result, was_cancelled, was_timed_out)) = tokio::join!(stdout_task, stderr_task, wait_future); let duration_ms = start.elapsed().as_millis() as u64; // Get results from bounded writers let stdout_result = stdout_writer.into_result(); let stderr_result = stderr_writer.into_result(); // Handle process wait result let (exit_code, process_error) = match wait_result { Ok(status) => (status.code().unwrap_or(-1), None), Err(e) => { warn!("Process wait failed but captured output: {}", e); (-1, Some(format!("Process wait failed: {}", e))) } }; if was_timed_out { return Ok(ExecutionResult { exit_code: -1, stdout: stdout_result.content.clone(), stderr: stderr_result.content.clone(), result: None, duration_ms, error: Some(format!( "Execution timed out after {} seconds", timeout_secs.unwrap() )), stdout_truncated: stdout_result.truncated, stderr_truncated: stderr_result.truncated, stdout_bytes_truncated: stdout_result.bytes_truncated, stderr_bytes_truncated: stderr_result.bytes_truncated, }); } // If the process was cancelled, return a specific result if was_cancelled { return Ok(ExecutionResult { exit_code, stdout: stdout_result.content.clone(), stderr: stderr_result.content.clone(), result: None, duration_ms, error: Some("Execution cancelled by user".to_string()), stdout_truncated: stdout_result.truncated, stderr_truncated: stderr_result.truncated, stdout_bytes_truncated: stdout_result.bytes_truncated, stderr_bytes_truncated: stderr_result.bytes_truncated, }); } debug!( "Process execution completed: exit_code={}, duration={}ms, stdout_truncated={}, stderr_truncated={}", exit_code, duration_ms, stdout_result.truncated, stderr_result.truncated ); // Parse result from stdout based on output_format let result = if exit_code == 0 && !stdout_result.content.trim().is_empty() { parse_output(&stdout_result.content, output_format) } else { None }; // Determine error message let error = if let Some(proc_err) = process_error { Some(proc_err) } else if let Some(stdin_err) = stdin_write_error { // Ignore broken pipe errors for fast-exiting successful actions. // These occur when the process exits before we finish writing secrets to stdin. let is_broken_pipe = stdin_err.contains("Broken pipe") || stdin_err.contains("os error 32"); let is_fast_exit = duration_ms < 500; let is_success = exit_code == 0; if is_broken_pipe && is_fast_exit && is_success { debug!( "Ignoring broken pipe error for fast-exiting successful action ({}ms)", duration_ms ); None } else { Some(stdin_err) } } else if exit_code != 0 { Some(if stderr_result.content.is_empty() { format!("Command exited with code {}", exit_code) } else { // Use last line of stderr as error, or full stderr if short if stderr_result.content.lines().count() > 5 { stderr_result .content .lines() .last() .unwrap_or("") .to_string() } else { stderr_result.content.clone() } }) } else { None }; Ok(ExecutionResult { exit_code, // Only populate stdout if result wasn't parsed (avoid duplication) stdout: if result.is_some() { String::new() } else { stdout_result.content.clone() }, stderr: stderr_result.content.clone(), result, duration_ms, error, stdout_truncated: stdout_result.truncated, stderr_truncated: stderr_result.truncated, stdout_bytes_truncated: stdout_result.bytes_truncated, stderr_bytes_truncated: stderr_result.bytes_truncated, }) } /// Parse stdout content according to the specified output format. fn configure_child_process(cmd: &mut Command) -> io::Result<()> { #[cfg(unix)] { // Run each action in its own process group so cancellation and timeout // can terminate shell wrappers and any children they spawned. unsafe { cmd.pre_exec(|| { if libc::setpgid(0, 0) == -1 { Err(io::Error::last_os_error()) } else { Ok(()) } }); } } Ok(()) } async fn wait_for_terminated_child( child: &mut tokio::process::Child, ) -> io::Result { match timeout(std::time::Duration::from_secs(5), child.wait()).await { Ok(status) => status, Err(_) => { warn!("Process did not exit after SIGTERM + 5s, sending SIGKILL"); if let Some(pid) = child.id() { kill_process_group_or_process(pid, libc::SIGKILL); } child.wait().await } } } fn terminate_process(pid: u32, reason: &str) { info!("Sending SIGTERM to {} process group {}", reason, pid); kill_process_group_or_process(pid, libc::SIGTERM); } fn kill_process_group_or_process(pid: u32, signal: i32) { #[cfg(unix)] { // Negative PID targets the process group created with setpgid(0, 0). let pgid = -(pid as i32); // Safety: we only signal processes we spawned. let rc = unsafe { libc::kill(pgid, signal) }; if rc == 0 { return; } let err = io::Error::last_os_error(); warn!( "Failed to signal process group {} with signal {}: {}. Falling back to PID {}", pid, signal, err, pid ); } // Safety: fallback to the direct child PID if the process group signal fails // or on non-Unix targets where process groups are unavailable. unsafe { libc::kill(pid as i32, signal); } } fn parse_output(stdout: &str, format: OutputFormat) -> Option { let trimmed = stdout.trim(); if trimmed.is_empty() { return None; } match format { OutputFormat::Text => { // No parsing - text output is captured in stdout field None } OutputFormat::Json => { // Try to parse full stdout as JSON first (handles multi-line JSON), // then fall back to last line only (for scripts that log before output) serde_json::from_str(trimmed).ok().or_else(|| { trimmed .lines() .last() .and_then(|line| serde_json::from_str(line).ok()) }) } OutputFormat::Yaml => { // Try to parse stdout as YAML serde_yaml_ng::from_str(trimmed).ok() } OutputFormat::Jsonl => { // Parse each line as JSON and collect into array let mut items = Vec::new(); for line in trimmed.lines() { if let Ok(value) = serde_json::from_str::(line) { items.push(value); } } if items.is_empty() { None } else { Some(serde_json::Value::Array(items)) } } } } /// Build a `Command` for executing an action script with the given interpreter. /// /// This configures the command with: /// - The interpreter binary and any additional args /// - The action file path as the final argument /// - Environment variables from the execution context /// - Working directory (pack directory) /// /// # Arguments /// * `interpreter` - Path to the interpreter binary /// * `interpreter_args` - Additional args before the action file /// * `action_file` - Path to the action script file /// * `working_dir` - Working directory for the process (typically the pack dir) /// * `env_vars` - Environment variables to set pub fn build_action_command( interpreter: &Path, interpreter_args: &[String], action_file: &Path, working_dir: Option<&Path>, env_vars: &HashMap, ) -> Command { let mut cmd = Command::new(interpreter); // Add interpreter args (e.g., "-u" for unbuffered Python) for arg in interpreter_args { cmd.arg(arg); } // Add the action file as the last argument cmd.arg(action_file); // Set working directory if let Some(dir) = working_dir { if dir.exists() { cmd.current_dir(dir); } } // Set environment variables for (key, value) in env_vars { cmd.env(key, value); } cmd } /// Build a `Command` for executing inline code with the given interpreter. /// /// This is used for ad-hoc/inline actions where code is passed as a string /// rather than a file path. /// /// # Arguments /// * `interpreter` - Path to the interpreter binary /// * `code` - The inline code to execute /// * `env_vars` - Environment variables to set pub fn build_inline_command( interpreter: &Path, code: &str, env_vars: &HashMap, ) -> Command { let mut cmd = Command::new(interpreter); // Pass code via -c flag (works for bash, python, etc.) cmd.arg("-c").arg(code); // Set environment variables for (key, value) in env_vars { cmd.env(key, value); } cmd } #[cfg(test)] mod tests { use super::*; use tempfile::NamedTempFile; use tokio::fs; use tokio::time::{sleep, Duration}; #[test] fn test_parse_output_text() { let result = parse_output("hello world", OutputFormat::Text); assert!(result.is_none()); } #[test] fn test_parse_output_json() { let result = parse_output(r#"{"key": "value"}"#, OutputFormat::Json); assert!(result.is_some()); assert_eq!(result.unwrap()["key"], "value"); } #[test] fn test_parse_output_json_with_log_prefix() { let result = parse_output( "some log line\nanother log\n{\"key\": \"value\"}", OutputFormat::Json, ); assert!(result.is_some()); assert_eq!(result.unwrap()["key"], "value"); } #[test] fn test_parse_output_jsonl() { let result = parse_output("{\"a\": 1}\n{\"b\": 2}\n{\"c\": 3}", OutputFormat::Jsonl); assert!(result.is_some()); let arr = result.unwrap(); assert_eq!(arr.as_array().unwrap().len(), 3); } #[test] fn test_parse_output_yaml() { let result = parse_output("key: value\nother: 42", OutputFormat::Yaml); assert!(result.is_some()); let val = result.unwrap(); assert_eq!(val["key"], "value"); assert_eq!(val["other"], 42); } #[test] fn test_parse_output_empty() { assert!(parse_output("", OutputFormat::Json).is_none()); assert!(parse_output(" ", OutputFormat::Yaml).is_none()); assert!(parse_output("\n", OutputFormat::Jsonl).is_none()); } #[tokio::test] async fn test_execute_streaming_simple() { let mut cmd = Command::new("/bin/echo"); cmd.arg("hello world"); let result = execute_streaming( cmd, &HashMap::new(), None, Some(10), 1024 * 1024, 1024 * 1024, OutputFormat::Text, ) .await .unwrap(); assert_eq!(result.exit_code, 0); assert!(result.stdout.contains("hello world")); assert!(result.error.is_none()); } #[tokio::test] async fn test_execute_streaming_json_output() { let mut cmd = Command::new("/bin/bash"); cmd.arg("-c").arg(r#"echo '{"status": "ok", "count": 42}'"#); let result = execute_streaming( cmd, &HashMap::new(), None, Some(10), 1024 * 1024, 1024 * 1024, OutputFormat::Json, ) .await .unwrap(); assert_eq!(result.exit_code, 0); assert!(result.result.is_some()); let parsed = result.result.unwrap(); assert_eq!(parsed["status"], "ok"); assert_eq!(parsed["count"], 42); } #[tokio::test] async fn test_execute_streaming_failure() { let mut cmd = Command::new("/bin/bash"); cmd.arg("-c").arg("echo 'error msg' >&2; exit 1"); let result = execute_streaming( cmd, &HashMap::new(), None, Some(10), 1024 * 1024, 1024 * 1024, OutputFormat::Text, ) .await .unwrap(); assert_eq!(result.exit_code, 1); assert!(result.error.is_some()); assert!(result.stderr.contains("error msg")); } #[tokio::test] async fn test_build_action_command() { let interpreter = Path::new("/usr/bin/python3"); let args = vec!["-u".to_string()]; let action_file = Path::new("/opt/attune/packs/mypack/actions/hello.py"); let mut env = HashMap::new(); env.insert("ATTUNE_EXEC_ID".to_string(), "123".to_string()); let cmd = build_action_command(interpreter, &args, action_file, None, &env); // We can't easily inspect Command internals, but at least verify it builds without panic let _ = cmd; } #[tokio::test] async fn test_execute_streaming_cancellation_kills_shell_child_process() { let script = NamedTempFile::new().unwrap(); fs::write( script.path(), "#!/bin/sh\nsleep 30\nprintf 'unexpected completion\\n'\n", ) .await .unwrap(); let mut perms = fs::metadata(script.path()).await.unwrap().permissions(); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; perms.set_mode(0o755); } fs::set_permissions(script.path(), perms).await.unwrap(); let cancel_token = CancellationToken::new(); let trigger = cancel_token.clone(); tokio::spawn(async move { sleep(Duration::from_millis(200)).await; trigger.cancel(); }); let mut cmd = Command::new("/bin/sh"); cmd.arg(script.path()); let result = execute_streaming_cancellable( cmd, &HashMap::new(), None, Some(60), 1024 * 1024, 1024 * 1024, OutputFormat::Text, Some(cancel_token), ) .await .unwrap(); assert!(result .error .as_deref() .is_some_and(|e| e.contains("cancelled"))); assert!( result.duration_ms < 5_000, "expected prompt cancellation, got {}ms", result.duration_ms ); assert!(!result.stdout.contains("unexpected completion")); } #[tokio::test] async fn test_execute_streaming_timeout_terminates_process() { let mut cmd = Command::new("/bin/sh"); cmd.arg("-c").arg("sleep 30"); let result = execute_streaming( cmd, &HashMap::new(), None, Some(1), 1024 * 1024, 1024 * 1024, OutputFormat::Text, ) .await .unwrap(); assert_eq!(result.exit_code, -1); assert!(result .error .as_deref() .is_some_and(|e| e.contains("timed out after 1 seconds"))); assert!( result.duration_ms < 7_000, "expected timeout termination, got {}ms", result.duration_ms ); } }