use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
use tokio::sync::Semaphore;
use tracing::Instrument;
use crate::ast::{Command, Redirect, Value};
use crate::dispatch::CommandDispatcher;
use crate::duration::parse_duration;
use crate::interpreter::ExecResult;
use crate::tools::{ExecContext, ToolRegistry};
use super::pipeline::{apply_redirects, PipelineRunner};
#[derive(Debug, Clone)]
pub struct ScatterOptions {
pub var_name: String,
pub limit: usize,
pub timeout: Option<Duration>,
}
#[derive(Debug, Clone, Default)]
pub struct GatherOptions {
pub lines: bool,
pub json: bool,
}
impl Default for ScatterOptions {
fn default() -> Self {
Self {
var_name: "ITEM".to_string(),
limit: 8,
timeout: None,
}
}
}
#[derive(Debug, Clone)]
pub struct ScatterItem {
pub json: serde_json::Value,
pub label: String,
}
impl ScatterItem {
fn new(json: serde_json::Value) -> Self {
let full = match &json {
serde_json::Value::String(s) => s.clone(),
other => other.to_string(),
};
let label = if full.chars().count() > 64 {
let head: String = full.chars().take(64).collect();
format!("{head}...")
} else {
full
};
Self { json, label }
}
fn from_text_line(line: &str) -> Self {
Self::new(serde_json::Value::String(line.to_string()))
}
}
#[derive(Debug, Clone)]
pub struct ScatterResult {
pub item: ScatterItem,
pub result: ExecResult,
pub timed_out: bool,
}
pub struct ScatterGatherRunner {
tools: Arc<ToolRegistry>,
sequential_dispatcher: Arc<dyn CommandDispatcher>,
}
impl ScatterGatherRunner {
pub fn new(
tools: Arc<ToolRegistry>,
dispatcher: Arc<dyn CommandDispatcher>,
) -> Self {
Self { tools, sequential_dispatcher: dispatcher }
}
#[tracing::instrument(level = "info", skip(self, pre_scatter, scatter_opts, parallel, gather_opts, post_gather, ctx), fields(item_count = tracing::field::Empty, parallelism = scatter_opts.limit))]
#[allow(clippy::too_many_arguments)]
pub async fn run(
&self,
pre_scatter: &[Command],
scatter_opts: ScatterOptions,
parallel: &[Command],
gather_opts: GatherOptions,
gather_redirects: &[Redirect],
post_gather: &[Command],
ctx: &mut ExecContext,
) -> ExecResult {
let runner = PipelineRunner::new(self.tools.clone());
let (text, data) = if pre_scatter.is_empty() {
let data = ctx.take_stdin_data();
let text = match ctx.read_stdin_to_text().await {
Ok(s) => s.unwrap_or_default(),
Err(e) => return ExecResult::failure(2, format!("scatter: {e}")),
};
(text, data)
} else {
let mut result = runner.run_sequential(pre_scatter, ctx, &*self.sequential_dispatcher).await;
crate::output_limit::apply_spill_contract(&mut result, &ctx.output_limit).await;
if !result.ok() {
return result;
}
(result.text_out().into_owned(), result.data)
};
let items = match extract_items(data.as_ref(), &text) {
Ok(items) => items,
Err(msg) => return ExecResult::failure(1, msg),
};
if items.is_empty() {
return ExecResult::success("");
}
tracing::Span::current().record("item_count", items.len());
let results = self
.run_parallel(&items, &scatter_opts, parallel, ctx)
.await;
let gathered = gather_results(&results, &gather_opts);
let gathered = apply_redirects(gathered, gather_redirects, ctx, &*self.sequential_dispatcher).await;
if post_gather.is_empty() || gathered.code != 0 {
gathered
} else {
ctx.set_stdin_with_data(
gathered.text_out().into_owned(),
gathered.data.clone(),
);
runner.run_sequential(post_gather, ctx, &*self.sequential_dispatcher).await
}
}
#[tracing::instrument(level = "debug", skip(self, items, opts, commands, base_ctx), fields(worker_count = items.len()))]
async fn run_parallel(
&self,
items: &[ScatterItem],
opts: &ScatterOptions,
commands: &[Command],
base_ctx: &ExecContext,
) -> Vec<ScatterResult> {
let semaphore = Arc::new(Semaphore::new(opts.limit));
let tools = self.tools.clone();
let var_name = opts.var_name.clone();
let mut handles = Vec::with_capacity(items.len());
for item in items.iter().cloned() {
let permit = semaphore.clone().acquire_owned().await;
let tools = tools.clone();
let worker_dispatcher = self.sequential_dispatcher.fork_attached().await;
let commands = commands.to_vec();
let parent_token = base_ctx.cancel.clone();
let worker_token = parent_token.child_token();
let mut worker_ctx = base_ctx.child_for_pipeline();
worker_ctx.scope.set(
&var_name,
crate::interpreter::json_to_value_no_envelope(item.json.clone()),
);
worker_ctx.cancel = worker_token.clone();
let timed_out_flag = Arc::new(AtomicBool::new(false));
let timer_handle: Option<tokio::task::JoinHandle<()>> = opts.timeout.map(|d| {
let cancel = worker_token.clone();
let flag = timed_out_flag.clone();
tokio::spawn(async move {
tokio::time::sleep(d).await;
flag.store(true, Ordering::SeqCst);
cancel.cancel();
})
});
let timed_out_check = timed_out_flag.clone();
let worker_span = tracing::debug_span!("scatter_worker", item = %item.label);
let handle = tokio::spawn(crate::telemetry::bind_current_context(async move {
let _permit = permit; let mut worker_ctx = worker_ctx;
let runner = PipelineRunner::new(tools);
let mut result =
runner.run_sequential(&commands, &mut worker_ctx, &*worker_dispatcher).await;
let genuinely_completed = result.ok();
crate::output_limit::apply_spill_contract(&mut result, &worker_ctx.output_limit).await;
if let Some(h) = timer_handle {
h.abort();
}
let timed_out = timed_out_check.load(Ordering::SeqCst) && !genuinely_completed;
ScatterResult { item, result, timed_out }
}.instrument(worker_span)));
handles.push(handle);
}
let mut results = Vec::with_capacity(handles.len());
for handle in handles {
match handle.await {
Ok(result) => results.push(result),
Err(e) => {
results.push(ScatterResult {
item: ScatterItem::new(serde_json::Value::String(
"<worker panicked>".to_string(),
)),
result: ExecResult::failure(1, format!("Task panicked: {}", e)),
timed_out: false,
});
}
}
}
results
}
}
pub fn extract_items(data: Option<&Value>, text: &str) -> Result<Vec<ScatterItem>, String> {
match data {
Some(Value::Json(serde_json::Value::Array(arr))) => {
let mut items = Vec::with_capacity(arr.len());
for (i, elem) in arr.iter().enumerate() {
if elem.is_null() {
return Err(format!(
"scatter: item {i} is null — filter nulls out first, \
e.g. jq 'map(select(. != null))'"
));
}
items.push(ScatterItem::new(elem.clone()));
}
return Ok(items);
}
Some(Value::String(s)) => {
return Ok(vec![ScatterItem::new(serde_json::Value::String(s.clone()))])
}
Some(Value::Int(i)) => return Ok(vec![ScatterItem::new(serde_json::json!(i))]),
Some(Value::Float(f)) => return Ok(vec![ScatterItem::new(serde_json::json!(f))]),
Some(Value::Bool(b)) => return Ok(vec![ScatterItem::new(serde_json::json!(b))]),
Some(Value::Null) => {
return Err("scatter: input is null — nothing to fan out".to_string())
}
Some(Value::Json(serde_json::Value::Object(map))) => {
let hint = map
.iter()
.find(|(_, v)| v.is_array())
.map(|(k, _)| format!(" (did you mean jq '.{k}'?)"))
.unwrap_or_default();
return Err(format!(
"scatter: input is a single object, not an array — select the array to \
fan out over{hint}"
));
}
Some(Value::Json(serde_json::Value::Null)) => {
return Err("scatter: input is null — nothing to fan out".to_string())
}
Some(Value::Json(json)) => return Ok(vec![ScatterItem::new(json.clone())]),
Some(Value::Bytes(b)) => {
return Err(format!(
"scatter: input is binary ({} bytes) — decode it to text or JSON first",
b.len()
))
}
None => {}
}
let trimmed = text.trim_end_matches(['\n', '\r']);
if trimmed.is_empty() {
return Ok(vec![]);
}
Ok(trimmed
.split('\n')
.map(|line| line.trim_end_matches('\r'))
.filter(|line| !line.is_empty())
.map(ScatterItem::from_text_line)
.collect())
}
fn strip_one_trailing_newline(s: &str) -> &str {
let s = s.strip_suffix('\n').unwrap_or(s);
s.strip_suffix('\r').unwrap_or(s)
}
fn result_row(i: usize, r: &ScatterResult) -> serde_json::Value {
let mut ok = r.result.ok() && !r.timed_out;
let mut code = if r.timed_out { 124 } else { r.result.code };
let (out_text, err_text) = match r.result.try_text_out() {
Ok(text) => (
strip_one_trailing_newline(&text).to_string(),
strip_one_trailing_newline(&r.result.err).to_string(),
),
Err(e) => {
ok = false;
if code == 0 {
code = 1;
}
(
String::new(),
format!(
"binary worker output not representable as text ({} bytes) — \
encode it in the worker (base64/xxd)",
e.len
),
)
}
};
let mut row = serde_json::Map::new();
row.insert("i".into(), serde_json::json!(i));
row.insert("item".into(), r.item.json.clone());
row.insert("ok".into(), serde_json::json!(ok));
row.insert("code".into(), serde_json::json!(code));
row.insert("out".into(), serde_json::json!(out_text));
row.insert("err".into(), serde_json::json!(err_text));
if let Some(data) = &r.result.data {
row.insert("data".into(), kaish_types::value_to_json(data));
}
if r.timed_out {
row.insert("timed_out".into(), serde_json::json!(true));
}
serde_json::Value::Object(row)
}
fn gather_results(results: &[ScatterResult], opts: &GatherOptions) -> ExecResult {
let is_unrepresentable = |r: &ScatterResult| r.result.try_text_out().is_err();
let failed: Vec<&ScatterResult> = results
.iter()
.filter(|r| !r.result.ok() || r.timed_out || is_unrepresentable(r))
.collect();
let code = if failed.is_empty() { 0 } else { 123 };
let err = if failed.is_empty() {
String::new()
} else {
let names = failed
.iter()
.map(|r| {
if is_unrepresentable(r) {
format!("{} (binary output not representable as text)", r.item.label)
} else {
r.item.label.clone()
}
})
.collect::<Vec<_>>()
.join(", ");
format!("gather: {} of {} worker(s) failed: {names}", failed.len(), results.len())
};
if opts.lines {
if !failed.is_empty() {
return ExecResult::failure(code, format!("{err} (drop --lines to get per-worker rows)"));
}
let text = results
.iter()
.map(|r| strip_one_trailing_newline(&r.result.text_out()).to_string())
.collect::<Vec<_>>()
.join("\n");
return ExecResult::success(text);
}
let rows: Vec<serde_json::Value> =
results.iter().enumerate().map(|(i, r)| result_row(i, r)).collect();
let text = if opts.json {
serde_json::to_string_pretty(&rows).unwrap_or_default()
} else {
rows.iter().map(|row| row.to_string()).collect::<Vec<_>>().join("\n")
};
let array = serde_json::Value::Array(rows);
let mut result = ExecResult::from_parts(code, text, err, Some(Value::Json(array)));
result.data_is_value = true;
result
}
fn describe_value(v: &Value) -> String {
match v {
Value::Null => "null".to_string(),
Value::Bool(b) => b.to_string(),
Value::Int(n) => n.to_string(),
Value::Float(f) => f.to_string(),
Value::String(s) => format!("{s:?}"),
Value::Json(j) => j.to_string(),
Value::Bytes(b) => format!("<{} bytes>", b.len()),
}
}
pub fn parse_scatter_options(args: &crate::tools::ToolArgs) -> Result<ScatterOptions, String> {
let mut opts = ScatterOptions::default();
match args.named.get("as") {
None => {}
Some(Value::String(name)) => {
crate::name::validate(name)
.map_err(|bad| format!("scatter --as: `{name}': {bad}"))?;
opts.var_name = name.clone();
}
Some(other) => {
return Err(format!(
"scatter --as: expected a variable name, got {}",
describe_value(other)
))
}
}
match args.named.get("limit") {
None => {}
Some(Value::Int(n)) => opts.limit = clamp_scatter_limit(*n),
Some(Value::String(s)) => match s.trim().parse::<i64>() {
Ok(n) => opts.limit = clamp_scatter_limit(n),
Err(_) => {
return Err(format!(
"scatter --limit: expected a positive integer, got {}",
describe_value(&Value::String(s.clone()))
))
}
},
Some(other) => {
return Err(format!(
"scatter --limit: expected a positive integer, got {}",
describe_value(other)
))
}
}
match args.named.get("timeout") {
None => {}
Some(Value::String(s)) => match parse_duration(s) {
Some(d) => opts.timeout = Some(d),
None => {
return Err(format!(
"scatter --timeout: invalid duration {} (try: 30, 5s, 500ms, 2m, 1h)",
describe_value(&Value::String(s.clone()))
))
}
},
Some(Value::Int(n)) if *n >= 0 => opts.timeout = Some(Duration::from_secs(*n as u64)),
Some(other) => {
return Err(format!(
"scatter --timeout: expected a non-negative duration, got {}",
describe_value(other)
))
}
}
Ok(opts)
}
fn clamp_scatter_limit(requested: i64) -> usize {
let clamped = requested.clamp(1, SCATTER_LIMIT_MAX as i64);
if requested > SCATTER_LIMIT_MAX as i64 {
tracing::warn!(
target: "kaish::scatter",
requested = requested,
ceiling = SCATTER_LIMIT_MAX,
"scatter limit clamped to ceiling"
);
}
clamped as usize
}
pub const SCATTER_LIMIT_MAX: usize = 10_000;
pub fn parse_gather_options(args: &crate::tools::ToolArgs) -> Result<GatherOptions, String> {
let mut opts = GatherOptions::default();
if args.has_flag("lines") {
opts.lines = true;
}
if args.has_flag("json") {
opts.json = true;
}
Ok(opts)
}
#[cfg(test)]
mod tests {
use super::*;
fn labels(items: &[ScatterItem]) -> Vec<String> {
items.iter().map(|i| i.label.clone()).collect()
}
fn item(s: &str) -> ScatterItem {
ScatterItem::new(serde_json::Value::String(s.to_string()))
}
#[test]
fn test_extract_items_structured_json_array() {
let data = Value::Json(serde_json::json!(["a", "b", "c"]));
let items = extract_items(Some(&data), "").unwrap();
assert_eq!(labels(&items), vec!["a", "b", "c"]);
}
#[test]
fn test_extract_items_structured_mixed_types_stay_typed() {
let data = Value::Json(serde_json::json!([1, "1", true, {"id": 7}]));
let items = extract_items(Some(&data), "").unwrap();
assert_eq!(items[0].json, serde_json::json!(1));
assert_eq!(items[1].json, serde_json::json!("1"));
assert_ne!(items[0].json, items[1].json, "1 and \"1\" must not conflate");
assert_eq!(items[2].json, serde_json::json!(true));
assert_eq!(items[3].json, serde_json::json!({"id": 7}));
}
#[test]
fn test_extract_items_null_element_is_loud() {
let data = Value::Json(serde_json::json!(["a", null, "c"]));
let err = extract_items(Some(&data), "").unwrap_err();
assert!(err.contains("null"), "should name the problem: {err}");
assert!(err.contains("item 1"), "should name the position: {err}");
}
#[test]
fn test_extract_items_single_object_is_loud_with_hint() {
let data = Value::Json(serde_json::json!({"jobs": [1, 2]}));
let err = extract_items(Some(&data), "").unwrap_err();
assert!(err.contains("single object"), "{err}");
assert!(err.contains("jq '.jobs'"), "should hint the array key: {err}");
}
#[test]
fn test_extract_items_binary_is_loud() {
let data = Value::Bytes(vec![0, 1, 2]);
let err = extract_items(Some(&data), "").unwrap_err();
assert!(err.contains("binary"), "{err}");
}
#[test]
fn test_extract_items_structured_string() {
let data = Value::String("single".into());
let items = extract_items(Some(&data), "").unwrap();
assert_eq!(labels(&items), vec!["single"]);
}
#[test]
fn test_extract_items_single_line_text() {
let items = extract_items(None, "hello").unwrap();
assert_eq!(labels(&items), vec!["hello"]);
}
#[test]
fn test_extract_items_empty() {
let items = extract_items(None, "").unwrap();
assert!(items.is_empty());
}
#[test]
fn test_extract_items_multiline_fans_out_per_line() {
let items = extract_items(None, "one\ntwo\nthree").unwrap();
assert_eq!(labels(&items), vec!["one", "two", "three"]);
}
#[test]
fn test_extract_items_trailing_newline_no_phantom_item() {
let items = extract_items(None, "one\ntwo\n").unwrap();
assert_eq!(labels(&items), vec!["one", "two"]);
}
#[test]
fn test_extract_items_crlf_per_line() {
let items = extract_items(None, "one\r\ntwo\r\n").unwrap();
assert_eq!(labels(&items), vec!["one", "two"]);
}
#[test]
fn test_extract_items_blank_lines_skipped() {
let items = extract_items(None, "a\n\nb").unwrap();
assert_eq!(labels(&items), vec!["a", "b"]);
}
#[test]
fn test_extract_items_whitespace_within_line_not_split() {
let items = extract_items(None, "a b\nc d").unwrap();
assert_eq!(labels(&items), vec!["a b", "c d"]);
}
#[test]
fn test_extract_items_only_newlines_is_empty() {
let items = extract_items(None, "\n\n").unwrap();
assert!(items.is_empty());
}
#[test]
fn test_extract_items_structured_overrides_text() {
let data = Value::Json(serde_json::json!(["x", "y"]));
let items = extract_items(Some(&data), "ignored\ntext").unwrap();
assert_eq!(labels(&items), vec!["x", "y"]);
}
#[test]
fn test_item_label_truncates_on_char_boundary() {
let long: String = "é".repeat(100);
let it = ScatterItem::new(serde_json::Value::String(long));
assert!(it.label.ends_with("..."));
assert_eq!(it.label.chars().count(), 67);
}
#[test]
fn test_gather_results_jsonl_rows_carry_everything() {
let results = vec![
ScatterResult {
item: item("a"),
result: ExecResult::success("result_a\n"),
timed_out: false,
},
ScatterResult {
item: item("b"),
result: ExecResult::failure(7, "boom\n"),
timed_out: false,
},
];
let out = gather_results(&results, &GatherOptions::default());
assert_eq!(out.code, 123, "any failure → 123 (A′)");
let rows: Vec<serde_json::Value> = out
.text_out()
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(rows.len(), 2, "every worker gets a row, failures included");
assert_eq!(rows[0]["i"], 0);
assert_eq!(rows[0]["item"], "a");
assert_eq!(rows[0]["ok"], true);
assert_eq!(rows[0]["out"], "result_a", "trailing newline stripped");
assert_eq!(rows[0]["err"], "", "err always present");
assert!(rows[0].get("timed_out").is_none(), "omit-false");
assert!(rows[0].get("data").is_none(), "omit-empty");
assert_eq!(rows[1]["i"], 1);
assert_eq!(rows[1]["ok"], false);
assert_eq!(rows[1]["code"], 7);
assert_eq!(rows[1]["err"], "boom");
assert!(matches!(out.data, Some(Value::Json(serde_json::Value::Array(_)))));
}
#[test]
fn test_gather_results_all_ok_is_zero() {
let results = vec![ScatterResult {
item: item("a"),
result: ExecResult::success("x"),
timed_out: false,
}];
let out = gather_results(&results, &GatherOptions::default());
assert_eq!(out.code, 0);
assert!(out.err.is_empty());
}
#[test]
fn test_gather_results_timeout_row_is_124() {
let results = vec![ScatterResult {
item: item("slow"),
result: ExecResult::failure(1, "cancelled"),
timed_out: true,
}];
let out = gather_results(&results, &GatherOptions::default());
assert_eq!(out.code, 123);
let row: serde_json::Value = serde_json::from_str(out.text_out().lines().next().unwrap()).unwrap();
assert_eq!(row["code"], 124, "timeout reports the timeout(1) code");
assert_eq!(row["ok"], false);
assert_eq!(row["timed_out"], true);
}
#[test]
fn test_gather_results_typed_record_item_in_row() {
let results = vec![ScatterResult {
item: ScatterItem::new(serde_json::json!({"id": 3, "host": "web1"})),
result: ExecResult::success("ok"),
timed_out: false,
}];
let out = gather_results(&results, &GatherOptions::default());
let row: serde_json::Value = serde_json::from_str(out.text_out().lines().next().unwrap()).unwrap();
assert_eq!(row["item"]["id"], 3, "row item is the TYPED value, not a string");
}
#[test]
fn test_gather_results_worker_data_rides_the_row() {
let mut r = ExecResult::success("text");
r.data = Some(Value::Json(serde_json::json!({"k": 1})));
let results = vec![ScatterResult { item: item("a"), result: r, timed_out: false }];
let out = gather_results(&results, &GatherOptions::default());
let row: serde_json::Value = serde_json::from_str(out.text_out().lines().next().unwrap()).unwrap();
assert_eq!(row["data"]["k"], 1, "worker .data lands typed in the row");
assert_eq!(row["out"], "text", "out stays alongside data");
}
#[test]
fn test_gather_results_spilled_worker_counts_as_failed() {
let mut spilled = ExecResult::success("truncated preview");
spilled.did_spill = true;
spilled.original_code = Some(0);
spilled.code = 3;
let results = vec![
ScatterResult { item: item("a"), result: spilled, timed_out: false },
ScatterResult { item: item("b"), result: ExecResult::success("clean"), timed_out: false },
];
let out = gather_results(&results, &GatherOptions::default());
assert_eq!(
out.code, 123,
"a spilled worker (code remapped to 3) must count as failed, not silently succeed"
);
let rows: Vec<serde_json::Value> = out
.text_out()
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
assert_eq!(rows[0]["ok"], false, "spilled row must read ok:false: {:?}", rows[0]);
assert_eq!(rows[0]["code"], 3, "spilled row must carry the remapped exit 3: {:?}", rows[0]);
assert_eq!(rows[1]["ok"], true, "the clean worker's own row is unaffected: {:?}", rows[1]);
}
#[test]
fn test_gather_results_lines_happy_path() {
let results = vec![
ScatterResult { item: item("a"), result: ExecResult::success("result_a\n"), timed_out: false },
ScatterResult { item: item("b"), result: ExecResult::success("result_b"), timed_out: false },
];
let out = gather_results(&results, &GatherOptions { lines: true, ..Default::default() });
assert_eq!(out.code, 0);
assert_eq!(&*out.text_out(), "result_a\nresult_b");
}
#[test]
fn test_gather_results_lines_hard_errors_on_any_failure() {
let results = vec![
ScatterResult { item: item("a"), result: ExecResult::success("good"), timed_out: false },
ScatterResult { item: item("b"), result: ExecResult::failure(1, "boom"), timed_out: false },
];
let out = gather_results(&results, &GatherOptions { lines: true, ..Default::default() });
assert_eq!(out.code, 123);
assert!(out.text_out().is_empty(), "no partial text on --lines failure");
assert!(out.err.contains("b"), "names the failed item: {}", out.err);
}
#[test]
fn test_parse_scatter_options() {
use crate::tools::ToolArgs;
let mut args = ToolArgs::new();
args.named.insert("as".to_string(), Value::String("URL".to_string()));
args.named.insert("limit".to_string(), Value::Int(4));
let opts = parse_scatter_options(&args).unwrap();
assert_eq!(opts.var_name, "URL");
assert_eq!(opts.limit, 4);
}
#[test]
fn test_parse_gather_options() {
use crate::tools::ToolArgs;
let mut args = ToolArgs::new();
args.flags.insert("lines".to_string());
let opts = parse_gather_options(&args).unwrap();
assert!(opts.lines);
assert!(!parse_gather_options(&ToolArgs::new()).unwrap().lines, "default is JSONL");
}
#[test]
fn scatter_limit_clamps_to_ceiling() {
use crate::tools::ToolArgs;
let mut args = ToolArgs::new();
args.named.insert("limit".to_string(), Value::Int(999_999));
let opts = parse_scatter_options(&args).unwrap();
assert_eq!(opts.limit, SCATTER_LIMIT_MAX);
}
#[test]
fn scatter_limit_raises_zero_to_one() {
use crate::tools::ToolArgs;
let mut args = ToolArgs::new();
args.named.insert("limit".to_string(), Value::Int(0));
let opts = parse_scatter_options(&args).unwrap();
assert_eq!(opts.limit, 1);
}
#[test]
fn scatter_limit_raises_negative_to_one() {
use crate::tools::ToolArgs;
let mut args = ToolArgs::new();
args.named.insert("limit".to_string(), Value::Int(-42));
let opts = parse_scatter_options(&args).unwrap();
assert_eq!(opts.limit, 1);
}
#[test]
fn scatter_limit_preserves_valid_values() {
use crate::tools::ToolArgs;
let mut args = ToolArgs::new();
args.named.insert("limit".to_string(), Value::Int(500));
let opts = parse_scatter_options(&args).unwrap();
assert_eq!(opts.limit, 500);
}
#[test]
fn scatter_limit_wrong_type_is_loud_error() {
use crate::tools::ToolArgs;
let mut args = ToolArgs::new();
args.named.insert("limit".to_string(), Value::String("five".to_string()));
let err = parse_scatter_options(&args).unwrap_err();
assert!(err.contains("--limit"), "{err}");
assert!(err.contains("five"), "{err}");
}
#[test]
fn scatter_limit_bool_is_loud_error() {
use crate::tools::ToolArgs;
let mut args = ToolArgs::new();
args.named.insert("limit".to_string(), Value::Bool(true));
let err = parse_scatter_options(&args).unwrap_err();
assert!(err.contains("--limit"), "{err}");
}
#[test]
fn scatter_limit_numeric_string_coerces() {
use crate::tools::ToolArgs;
let mut args = ToolArgs::new();
args.named.insert("limit".to_string(), Value::String("5".to_string()));
let opts = parse_scatter_options(&args).unwrap();
assert_eq!(opts.limit, 5);
}
#[test]
fn scatter_as_wrong_type_is_loud_error() {
use crate::tools::ToolArgs;
let mut args = ToolArgs::new();
args.named.insert("as".to_string(), Value::Int(42));
let err = parse_scatter_options(&args).unwrap_err();
assert!(err.contains("--as"), "{err}");
assert!(err.contains("42"), "{err}");
}
#[test]
fn scatter_timeout_negative_int_is_loud_error() {
use crate::tools::ToolArgs;
let mut args = ToolArgs::new();
args.named.insert("timeout".to_string(), Value::Int(-5));
let err = parse_scatter_options(&args).unwrap_err();
assert!(err.contains("--timeout"), "{err}");
}
#[test]
fn scatter_timeout_unparseable_string_is_loud_error() {
use crate::tools::ToolArgs;
let mut args = ToolArgs::new();
args.named.insert("timeout".to_string(), Value::String("banana".to_string()));
let err = parse_scatter_options(&args).unwrap_err();
assert!(err.contains("--timeout"), "{err}");
assert!(err.contains("banana"), "{err}");
}
#[test]
fn scatter_timeout_valid_duration_string_parses() {
use crate::tools::ToolArgs;
let mut args = ToolArgs::new();
args.named.insert("timeout".to_string(), Value::String("5s".to_string()));
let opts = parse_scatter_options(&args).unwrap();
assert_eq!(opts.timeout, Some(Duration::from_secs(5)));
}
#[test]
fn scatter_timeout_nonnegative_int_is_seconds() {
use crate::tools::ToolArgs;
let mut args = ToolArgs::new();
args.named.insert("timeout".to_string(), Value::Int(30));
let opts = parse_scatter_options(&args).unwrap();
assert_eq!(opts.timeout, Some(Duration::from_secs(30)));
}
fn binary_result(invalid_utf8: Vec<u8>) -> ExecResult {
ExecResult::success_bytes(invalid_utf8)
}
#[test]
fn gather_row_goes_loud_not_lossy_on_binary_out() {
let results = vec![ScatterResult {
item: item("bin"),
result: binary_result(vec![0xFF, 0xFE, 0x00, 0x01]),
timed_out: false,
}];
let out = gather_results(&results, &GatherOptions::default());
assert_eq!(out.code, 123, "a binary row flips the overall exit code too");
let row: serde_json::Value =
serde_json::from_str(out.text_out().lines().next().unwrap()).unwrap();
assert_eq!(row["ok"], false, "binary output must not be silently ok:true");
assert_ne!(row["code"], 0, "must carry a nonzero code");
assert!(row["out"].as_str().unwrap().is_empty(), "no lossy text in out");
let err_text = row["err"].as_str().unwrap();
assert!(err_text.contains("binary"), "{err_text}");
assert!(!err_text.contains('\u{FFFD}'), "must not carry U+FFFD: {err_text}");
}
#[test]
fn gather_lines_hard_errors_on_binary_out() {
let results = vec![
ScatterResult { item: item("a"), result: ExecResult::success("good"), timed_out: false },
ScatterResult {
item: item("bin"),
result: binary_result(vec![0xFF, 0xFE]),
timed_out: false,
},
];
let out = gather_results(&results, &GatherOptions { lines: true, ..Default::default() });
assert_eq!(out.code, 123);
assert!(out.text_out().is_empty(), "no partial/lossy text on binary --lines failure");
assert!(!out.err.contains('\u{FFFD}'), "must not carry U+FFFD: {}", out.err);
assert!(out.err.contains("binary") || out.err.contains("bin"), "{}", out.err);
}
fn ctx_with_memory_fs() -> ExecContext {
use crate::vfs::{MemoryFs, VfsRouter};
use std::sync::Arc;
let mut vfs = VfsRouter::new();
vfs.mount("/", MemoryFs::new());
ExecContext::new(Arc::new(vfs))
}
#[test]
fn worker_ctx_inherits_parent_watchdog() {
use crate::watchdog::Watchdog;
use std::sync::Arc;
let mut parent = ctx_with_memory_fs();
parent.watchdog = Some(Arc::new(Watchdog::new(Duration::from_secs(30))));
let worker_ctx = parent.child_for_pipeline();
assert!(
worker_ctx.watchdog.is_some(),
"worker must carry the parent's script watchdog, not None"
);
let from_scratch =
ExecContext::with_backend_and_scope(parent.backend.clone(), parent.scope.clone());
assert!(
from_scratch.watchdog.is_none(),
"the abandoned from-scratch path is exactly why the worker lost its watchdog"
);
}
#[tokio::test]
async fn worker_spills_over_the_shared_output_limit() {
use crate::output_limit::{apply_spill_contract, OutputLimitConfig};
let mut cfg = OutputLimitConfig::agent().in_memory();
cfg.set_limit(Some(64));
let mut parent = ctx_with_memory_fs();
parent.output_limit = cfg;
let worker_ctx = parent.child_for_pipeline();
assert!(worker_ctx.output_limit.is_enabled(), "budget must reach the worker");
let mut result = ExecResult::success("x".repeat(4096));
assert!(worker_ctx.output_limit.is_enabled());
apply_spill_contract(&mut result, &worker_ctx.output_limit).await;
assert!(result.did_spill, "worker output over the limit must spill, not stay resident");
assert_eq!(
result.code, 3,
"a spilled worker must exit 3 so gather's ok()-based aggregation counts it as failed"
);
assert_eq!(result.original_code, Some(0), "the worker's own clean exit is preserved");
assert!(
result.text_out().len() < 4096,
"spilled output must be truncated, not the full payload: {} bytes",
result.text_out().len()
);
}
#[tokio::test(flavor = "current_thread", start_paused = true)]
async fn worker_completing_at_timeout_boundary_is_not_misclassified() {
use crate::ast::{Arg, Expr};
use crate::dispatch::BackendDispatcher;
use crate::tools::register_builtins;
use crate::vfs::{MemoryFs, VfsRouter};
let mut registry = ToolRegistry::new();
register_builtins(&mut registry);
let tools = Arc::new(registry);
let dispatcher: Arc<dyn CommandDispatcher> =
Arc::new(BackendDispatcher::new(tools.clone()));
let runner = ScatterGatherRunner::new(tools.clone(), dispatcher);
let commands = vec![Command {
name: "sleep".to_string(),
args: vec![Arg::Positional(Expr::Literal(Value::String("0.02".to_string())))],
redirects: vec![],
}];
let opts = ScatterOptions {
timeout: Some(Duration::from_millis(20)),
..ScatterOptions::default()
};
let mut false_positives = 0;
let mut genuine_timeouts = 0;
let mut clean_success = 0;
let iterations = 300;
for _ in 0..iterations {
let mut vfs = VfsRouter::new();
vfs.mount("/", MemoryFs::new());
let ctx = ExecContext::with_vfs_and_tools(Arc::new(vfs), tools.clone());
let items = vec![item("x")];
let results = runner.run_parallel(&items, &opts, &commands, &ctx).await;
assert_eq!(results.len(), 1);
let r = &results[0];
match (r.timed_out, r.result.ok()) {
(true, true) => false_positives += 1,
(true, false) => genuine_timeouts += 1,
(false, _) => clean_success += 1,
}
}
eprintln!(
"worker_completing_at_timeout_boundary: {false_positives} false-positive(s), \
{genuine_timeouts} genuine timeout(s), {clean_success} clean success(es) out of \
{iterations} iterations"
);
assert!(
genuine_timeouts > 0 && clean_success > 0,
"the tie never formed (genuine_timeouts={genuine_timeouts}, \
clean_success={clean_success}) — this test needs the race to actually occur to \
mean anything; check the tied durations still create a real contest"
);
assert_eq!(
false_positives, 0,
"GH #132: a worker whose operation genuinely completed (result.ok()) must never \
be reported timed_out — completion should win the tie"
);
}
}