use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use async_trait::async_trait;
use serde_json::Value;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::Mutex;
use crate::runs::handler::{JobContext, JobError, JobHandler, JobOutput};
use crate::runs::model::JobId;
#[derive(Clone, Debug, Default)]
pub enum OutputParser {
#[default]
OutputResultSentinel,
LastJsonLine,
None,
}
#[derive(Clone, Debug)]
pub enum ArgTpl {
Literal(String),
FromInput(String),
}
impl ArgTpl {
pub fn lit<S: Into<String>>(s: S) -> Self {
Self::Literal(s.into())
}
pub fn input<S: Into<String>>(path: S) -> Self {
Self::FromInput(path.into())
}
}
#[derive(Clone, Debug)]
pub enum EnvTpl {
Literal(String),
FromInput(String),
}
pub struct SubprocessHandler {
kind: String,
binary: PathBuf,
args: Vec<ArgTpl>,
env: HashMap<String, EnvTpl>,
timeout: Option<Duration>,
parser: OutputParser,
pids: Arc<Mutex<HashMap<JobId, u32>>>,
}
impl SubprocessHandler {
pub fn new<S: Into<String>>(kind: S, binary: impl Into<PathBuf>) -> Self {
Self {
kind: kind.into(),
binary: binary.into(),
args: Vec::new(),
env: HashMap::new(),
timeout: None,
parser: OutputParser::default(),
pids: Arc::new(Mutex::new(HashMap::new())),
}
}
pub fn arg(mut self, tpl: ArgTpl) -> Self {
self.args.push(tpl);
self
}
pub fn arg_lit<S: Into<String>>(self, s: S) -> Self {
self.arg(ArgTpl::lit(s))
}
pub fn arg_input<S: Into<String>>(self, path: S) -> Self {
self.arg(ArgTpl::input(path))
}
pub fn env<K: Into<String>>(mut self, key: K, tpl: EnvTpl) -> Self {
self.env.insert(key.into(), tpl);
self
}
pub fn timeout_dur(mut self, d: Duration) -> Self {
self.timeout = Some(d);
self
}
pub fn parser(mut self, p: OutputParser) -> Self {
self.parser = p;
self
}
}
const OUTPUT_RESULT_SENTINEL: &str = "OUTPUT_RESULT:";
fn resolve_path(value: &Value, path: &str) -> String {
let mut cur = value;
for seg in path.split('.') {
cur = cur.get(seg).unwrap_or(&Value::Null);
}
match cur {
Value::Null => String::new(),
Value::String(s) => s.clone(),
other => other.to_string(),
}
}
fn render_arg(tpl: &ArgTpl, inputs: &Value) -> String {
match tpl {
ArgTpl::Literal(s) => s.clone(),
ArgTpl::FromInput(p) => resolve_path(inputs, p),
}
}
fn render_env(tpl: &EnvTpl, inputs: &Value) -> String {
match tpl {
EnvTpl::Literal(s) => s.clone(),
EnvTpl::FromInput(p) => resolve_path(inputs, p),
}
}
#[async_trait]
impl JobHandler for SubprocessHandler {
fn kind(&self) -> &str {
&self.kind
}
fn timeout(&self) -> Option<Duration> {
self.timeout
}
async fn run(&self, ctx: JobContext) -> Result<JobOutput, JobError> {
let argv: Vec<String> = self
.args
.iter()
.map(|t| render_arg(t, &ctx.inputs))
.collect();
let env: Vec<(String, String)> = self
.env
.iter()
.map(|(k, t)| (k.clone(), render_env(t, &ctx.inputs)))
.collect();
let mut cmd = Command::new(&self.binary);
cmd.args(&argv);
for (k, v) in &env {
cmd.env(k, v);
}
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
let mut child = cmd.spawn().map_err(JobError::Io)?;
if let Some(pid) = child.id() {
self.pids.lock().await.insert(ctx.job_id, pid);
if let Err(e) = ctx.store.set_pid(ctx.job_id, pid).await {
ctx.log.warn(format!("failed to persist pid: {e}")).await;
}
}
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let log = ctx.log.clone();
let parser = self.parser.clone();
let stream_handle = tokio::spawn(async move {
let mut combined_stdout = String::new();
if let (Some(stdout), Some(stderr)) = (stdout, stderr) {
let mut so = BufReader::new(stdout).lines();
let mut se = BufReader::new(stderr).lines();
loop {
tokio::select! {
line = so.next_line() => match line {
Ok(Some(l)) => {
combined_stdout.push_str(&l);
combined_stdout.push('\n');
log.info(l).await;
}
Ok(None) => break,
Err(e) => {
log.warn(format!("stdout read error: {e}")).await;
break;
}
},
line = se.next_line() => match line {
Ok(Some(l)) => log.warn(format!("[stderr] {l}")).await,
Ok(None) => {}
Err(e) => log.warn(format!("stderr read error: {e}")).await,
}
}
}
while let Ok(Some(l)) = se.next_line().await {
log.warn(format!("[stderr] {l}")).await;
}
}
(combined_stdout, parser)
});
let exit_status = match child.wait().await {
Ok(s) => s,
Err(e) => {
self.pids.lock().await.remove(&ctx.job_id);
return Err(JobError::Io(e));
}
};
let (combined_stdout, parser) = stream_handle.await.unwrap_or_default();
self.pids.lock().await.remove(&ctx.job_id);
if !exit_status.success() {
let code = exit_status.code();
return Err(JobError::Failed(format!(
"subprocess exited with status {:?}",
code
)));
}
let value: Value = match parser {
OutputParser::OutputResultSentinel => {
if let Some(path) = combined_stdout
.lines()
.rev()
.find_map(|l| l.strip_prefix(OUTPUT_RESULT_SENTINEL))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
{
serde_json::json!({ "output_file": path })
} else {
serde_json::json!({})
}
}
OutputParser::LastJsonLine => {
let line = combined_stdout
.lines()
.rev()
.find(|l| !l.trim().is_empty())
.unwrap_or("")
.trim();
if line.is_empty() {
Value::Null
} else {
serde_json::from_str(line)
.map_err(|e| JobError::Failed(format!("LastJsonLine parse failed: {e}")))?
}
}
OutputParser::None => serde_json::json!({}),
};
Ok(JobOutput::new(value))
}
async fn cancel(&self, job_id: JobId) -> Result<(), JobError> {
let pid = self.pids.lock().await.get(&job_id).copied();
if let Some(pid) = pid {
#[cfg(unix)]
{
use nix::sys::signal::{kill, Signal};
use nix::unistd::Pid;
let target = Pid::from_raw(pid as i32);
if let Err(e) = kill(target, Signal::SIGTERM) {
tracing::warn!(?e, %job_id, %pid, "SIGTERM failed; trying SIGKILL");
let _ = kill(target, Signal::SIGKILL);
}
}
#[cfg(not(unix))]
{
tracing::warn!(%job_id, %pid, "SubprocessHandler::cancel is no-op on non-Unix");
}
self.pids.lock().await.remove(&job_id);
}
Ok(())
}
}