use super::process_io::{
read_bounded, ProcessGroup, ScriptDirectory, MAX_STDERR_BYTES, MAX_STDOUT_BYTES,
};
use super::types::{
parse_script_response, ScriptConfig, ScriptInput, ScriptLanguage, ScriptResponse,
};
use anyhow::{Context as AnyhowContext, Result};
use std::process::Stdio;
use std::time::Duration;
use tokio::io::AsyncWriteExt;
use tokio::process::{Child, Command};
use tracing::{debug, error, trace, warn};
pub const SCRIPT_TIMEOUT_SECS: u64 = 30;
pub const DEFAULT_SCRIPT_TIMEOUT: Duration = Duration::from_secs(SCRIPT_TIMEOUT_SECS);
const KILL_REAP_TIMEOUT: Duration = Duration::from_secs(5);
pub async fn execute_script_async(
config: &ScriptConfig,
input: &ScriptInput,
) -> Result<ScriptResponse> {
execute_script_with_timeout_async(config, input, DEFAULT_SCRIPT_TIMEOUT).await
}
pub async fn execute_script_with_timeout_async(
config: &ScriptConfig,
input: &ScriptInput,
timeout: Duration,
) -> Result<ScriptResponse> {
tokio::time::timeout(timeout, execute_script_inner(config, input, timeout))
.await
.map_err(|_| {
anyhow::anyhow!(
"script execution timed out after {:?} (process killed)",
timeout
)
})?
}
async fn execute_script_inner(
config: &ScriptConfig,
input: &ScriptInput,
timeout: Duration,
) -> Result<ScriptResponse> {
let code = config
.source
.get_code_async()
.await
.context("Failed to load script code")?;
let input_json = serde_json::to_string(&input).context("Failed to serialize input to JSON")?;
debug!(
"Executing {} script for context '{}' (timeout: {}s, input: {} bytes)",
config.language.as_str(),
input.event_type_id,
timeout.as_secs(),
input_json.len()
);
if tracing::enabled!(tracing::Level::TRACE) {
let input_json_pretty =
serde_json::to_string_pretty(&input).unwrap_or_else(|_| input_json.clone());
trace!("─────────────────────────────────────────────");
trace!("SCRIPT EXECUTION START");
trace!("Language: {}", config.language.as_str());
trace!("Context: {}", input.event_type_id);
trace!("Handles: {:?}", config.handles_contexts);
trace!("");
trace!("Script code:");
trace!("{}", code);
trace!("");
trace!("Script input (JSON):");
trace!("{}", input_json_pretty);
trace!("─────────────────────────────────────────────");
}
let (output, stderr) = match config.language {
ScriptLanguage::Python => execute_python(&code, &input_json, timeout).await?,
ScriptLanguage::JavaScript => execute_javascript(&code, &input_json, timeout).await?,
ScriptLanguage::Go => execute_go(&code, &input_json, timeout).await?,
ScriptLanguage::Perl => execute_perl(&code, &input_json, timeout).await?,
};
if tracing::enabled!(tracing::Level::TRACE) {
trace!("─────────────────────────────────────────────");
trace!("SCRIPT EXECUTION COMPLETE");
trace!("");
trace!("Script stdout:");
trace!("{}", output);
if !stderr.is_empty() {
trace!("");
trace!("Script stderr:");
trace!("{}", stderr);
}
trace!("─────────────────────────────────────────────");
}
let response = parse_script_response(&output).with_context(|| {
format!(
"Failed to parse script response as JSON object with actions array. Output was: {}",
output
)
})?;
debug!(
"Script executed successfully: {} actions",
response.actions.len()
);
Ok(response)
}
pub fn execute_script(config: &ScriptConfig, input: &ScriptInput) -> Result<ScriptResponse> {
execute_script_blocking_with_timeout(config, input, DEFAULT_SCRIPT_TIMEOUT)
}
pub fn execute_script_blocking_with_timeout(
config: &ScriptConfig,
input: &ScriptInput,
timeout: Duration,
) -> Result<ScriptResponse> {
let config = config.clone();
let input = input.clone();
std::thread::Builder::new()
.name("netget-script-sync".to_string())
.spawn(move || -> Result<ScriptResponse> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.context("Failed to build runtime for blocking script execution")?;
runtime.block_on(execute_script_with_timeout_async(&config, &input, timeout))
})
.context("Failed to spawn thread for blocking script execution")?
.join()
.map_err(|_| anyhow::anyhow!("Script execution thread panicked"))?
}
async fn execute_python(
code: &str,
input_json: &str,
timeout: Duration,
) -> Result<(String, String)> {
execute_with_command(
ScriptLanguage::Python,
"python3",
&["-c"],
Some(code),
input_json,
timeout,
)
.await
}
async fn execute_javascript(
code: &str,
input_json: &str,
timeout: Duration,
) -> Result<(String, String)> {
let wrapped_code = format!(
r#"
const fs = require('fs');
const inputJson = fs.readFileSync(0, 'utf-8');
const input = JSON.parse(inputJson);
// User's script begins here
(function() {{
{}
}})();
"#,
code
);
execute_with_command(
ScriptLanguage::JavaScript,
"node",
&["-e"],
Some(&wrapped_code),
input_json,
timeout,
)
.await
}
async fn execute_perl(code: &str, input_json: &str, timeout: Duration) -> Result<(String, String)> {
execute_with_command(
ScriptLanguage::Perl,
"perl",
&["-e"],
Some(code),
input_json,
timeout,
)
.await
}
async fn execute_go(code: &str, input_json: &str, timeout: Duration) -> Result<(String, String)> {
let directory = tokio::task::spawn_blocking(ScriptDirectory::new)
.await
.context("failed to create Go staging worker")??;
let script_path = directory.script_path();
let wrapped_code = format!(
r#"package main
import (
"encoding/json"
"fmt"
"io"
"os"
)
func main() {{
// Read JSON input from stdin
inputBytes, err := io.ReadAll(os.Stdin)
if err != nil {{
fmt.Fprintf(os.Stderr, "Error reading stdin: %v\n", err)
os.Exit(1)
}}
var input map[string]interface{{}}
if err := json.Unmarshal(inputBytes, &input); err != nil {{
fmt.Fprintf(os.Stderr, "Error parsing JSON: %v\n", err)
os.Exit(1)
}}
// User's script begins here
_ = input // Make input available to user code
{{
{}
}}
}}
"#,
code
);
tokio::fs::write(&script_path, wrapped_code.as_bytes())
.await
.with_context(|| format!("Failed to write Go script to {:?}", script_path))?;
let script_path_str = script_path.to_string_lossy().to_string();
let result = execute_with_command(
ScriptLanguage::Go,
"go",
&["run", &script_path_str],
None,
input_json,
timeout,
)
.await;
drop(directory);
result
}
async fn execute_with_command(
language: ScriptLanguage,
command: &str,
args: &[&str],
code: Option<&str>,
input_json: &str,
timeout: Duration,
) -> Result<(String, String)> {
let mut builder = Command::new(command);
builder.args(args);
if let Some(code) = code {
builder.arg(code);
}
builder
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
ProcessGroup::configure(&mut builder);
let mut child = builder
.spawn()
.map_err(|e| spawn_error(language, command, e))?;
let group = ProcessGroup::new(&child)?;
let mut stdin = child.stdin.take();
let stdout = child
.stdout
.take()
.context("Failed to capture script stdout")?;
let stderr = child
.stderr
.take()
.context("Failed to capture script stderr")?;
let input_bytes = input_json.as_bytes().to_vec();
let write_fut = async move {
if let Some(mut handle) = stdin.take() {
handle.write_all(&input_bytes).await?;
handle.flush().await?;
drop(handle);
}
Ok::<(), std::io::Error>(())
};
let stdout_fut = read_bounded(stdout, MAX_STDOUT_BYTES);
let stderr_fut = read_bounded(stderr, MAX_STDERR_BYTES);
let write_fut = async {
match write_fut.await {
Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
result => result,
}
};
let interaction = async { tokio::try_join!(write_fut, stdout_fut, stderr_fut, child.wait()) };
let joined = tokio::time::timeout(timeout, interaction).await;
let (_, stdout_bytes, stderr_bytes, status) = match joined {
Ok(Ok(results)) => results,
failed => {
group.kill();
let _ = child.start_kill();
reap(&mut child).await;
return match failed {
Ok(Err(error)) => Err(error).context("script input/output failed"),
Err(_) => Err(anyhow::anyhow!(
"{} script execution timed out after {:?} (process killed)",
language.as_str(),
timeout
)),
Ok(Ok(_)) => unreachable!(),
};
}
};
drop(group);
let stdout = String::from_utf8(stdout_bytes).context("Script stdout is not valid UTF-8")?;
let stderr = String::from_utf8_lossy(&stderr_bytes).to_string();
if !status.success() {
error!(
"{} script execution failed with exit code {:?}",
language.as_str(),
status.code()
);
error!("Script stderr: {}", stderr);
anyhow::bail!(
"{} script exited with non-zero status. stderr: {}",
language.as_str(),
stderr
);
}
if !stderr.is_empty() {
warn!("Script produced stderr output (but succeeded): {}", stderr);
}
Ok((stdout.trim().to_string(), stderr))
}
async fn reap(child: &mut Child) {
match tokio::time::timeout(KILL_REAP_TIMEOUT, child.wait()).await {
Ok(Ok(status)) => {
debug!("Killed script process reaped (status: {:?})", status);
}
Ok(Err(e)) => {
warn!("Failed to reap killed script process: {}", e);
}
Err(_) => {
warn!(
"Killed script process did not exit within {:?}; it may linger",
KILL_REAP_TIMEOUT
);
}
}
}
fn interpreter_requirement(language: ScriptLanguage) -> (&'static str, &'static str) {
match language {
ScriptLanguage::Python => (
"Python 3",
"install Python 3 (e.g. `brew install python3` or `apt install python3`)",
),
ScriptLanguage::JavaScript => (
"Node.js",
"install Node.js (e.g. `brew install node` or `apt install nodejs`)",
),
ScriptLanguage::Go => (
"the Go toolchain",
"install Go (e.g. `brew install go` or see https://go.dev/dl/)",
),
ScriptLanguage::Perl => (
"Perl",
"install Perl (e.g. `brew install perl` or `apt install perl`)",
),
}
}
fn spawn_error(language: ScriptLanguage, command: &str, err: std::io::Error) -> anyhow::Error {
let (requirement, remedy) = interpreter_requirement(language);
if err.kind() == std::io::ErrorKind::NotFound {
error!(
"Cannot run {} script: `{}` not found on PATH",
language.as_str(),
command
);
anyhow::anyhow!(
"Interpreter `{command}` was not found on PATH. \
The '{lang}' script handler requires {requirement}. \
To fix: {remedy}, or switch this event handler to a language whose \
interpreter is installed, or to an `llm`/`static` handler.",
command = command,
lang = language.as_str(),
requirement = requirement,
remedy = remedy,
)
} else {
anyhow::anyhow!(
"Failed to start interpreter `{command}` for the '{lang}' script handler \
(requires {requirement}): {err}",
command = command,
lang = language.as_str(),
requirement = requirement,
err = err,
)
}
}