kaish_kernel/dispatch.rs
1//! Command dispatch — the single execution path for all commands.
2//!
3//! The `CommandDispatcher` trait defines how a single command is resolved and
4//! executed. The Kernel implements this trait with the full dispatch chain:
5//! user tools → builtins → .kai scripts → external commands → backend tools.
6//!
7//! `PipelineRunner` calls `dispatcher.dispatch()` for each command in a
8//! pipeline, handling I/O routing (stdin piping, redirects) around each call.
9//!
10//! ```text
11//! Stmt::Command ──┐
12//! ├──▶ execute_pipeline() ──▶ PipelineRunner::run(dispatcher, commands, ctx)
13//! Stmt::Pipeline ──┘ │
14//! for each command:
15//! dispatcher.dispatch(cmd, ctx)
16//! │
17//! ┌─────┼──────────────┐
18//! │ │ │
19//! user_tools builtins .kai scripts
20//! external cmds
21//! backend tools
22//! ```
23
24use std::sync::Arc;
25
26use anyhow::Result;
27use async_trait::async_trait;
28
29use crate::ast::{Command, Expr, Value};
30use crate::interpreter::ExecResult;
31use crate::tools::ExecContext;
32
33// The following imports are only used by the test-only `BackendDispatcher`.
34#[cfg(test)]
35use crate::ast::Arg;
36#[cfg(test)]
37use crate::backend::BackendError;
38#[cfg(test)]
39use crate::interpreter::apply_output_format;
40#[cfg(test)]
41use crate::scheduler::build_tool_args;
42#[cfg(test)]
43use crate::tools::{GlobalFlags, ToolRegistry};
44#[cfg(all(test, feature = "subprocess"))]
45use crate::tools::{resolve_in_path, virtual_cwd_error};
46
47/// Position of a command within a pipeline.
48///
49/// Used by external command execution to decide stdio inheritance:
50/// - `Only` or `Last` in interactive mode → inherit terminal
51/// - `First` or `Middle` → always capture
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
53pub enum PipelinePosition {
54 /// Single command, no pipe.
55 #[default]
56 Only,
57 /// First command in a pipeline (no stdin from pipe).
58 First,
59 /// Middle of a pipeline (piped stdin, piped stdout).
60 Middle,
61 /// Last command in a pipeline (piped stdin, final output).
62 Last,
63}
64
65/// Trait for dispatching a single command through the full resolution chain.
66///
67/// Implementations handle argument parsing, tool lookup, and execution.
68/// The pipeline runner handles I/O routing (stdin, redirects, piping).
69#[async_trait]
70pub trait CommandDispatcher: Send + Sync {
71 /// Dispatch a single command for execution.
72 ///
73 /// The `ctx` provides stdin (from pipe or redirect), scope, and backend.
74 /// Implementations should handle schema-aware argument parsing and
75 /// output format extraction internally.
76 async fn dispatch(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult>;
77
78 /// Evaluate an expression through the full async chain.
79 ///
80 /// Unlike the runner's sync `eval_simple_expr`, this can run command
81 /// substitution (`$(...)`) because it has access to pipeline execution.
82 /// Used for redirect targets and heredoc bodies so `cat < $(cmd)`,
83 /// `echo x > $(cmd)`, and `$(...)` inside heredoc bodies work. The `ctx`
84 /// carries scope/cwd/backend for dispatchers that evaluate against it;
85 /// stateful dispatchers (Kernel) snapshot their own session state and
86 /// only let command output escape (side effects like `cd` do not).
87 async fn eval_expr(&self, expr: &Expr, ctx: &ExecContext) -> Result<Value>;
88
89 /// Fork the dispatcher for concurrent execution (detached).
90 ///
91 /// Returns a subsidiary dispatcher with independent mutable state, safe
92 /// to run concurrently with the parent and other forks without data
93 /// races on shared scope/cwd/aliases. Used by background `&` jobs,
94 /// where the fork must survive parent cancellation.
95 ///
96 /// For stateful dispatchers (e.g. Kernel) this snapshots per-session
97 /// state into a fresh instance. Stateless dispatchers may clone.
98 async fn fork(&self) -> Arc<dyn CommandDispatcher>;
99
100 /// Fork the dispatcher for concurrent execution (attached to parent cancel).
101 ///
102 /// Like [`Self::fork`] but the fork's cancellation token is a *child* of
103 /// the parent's. Cancelling the parent (timeout, Ctrl-C, embedder
104 /// `Kernel::cancel`) cascades into the fork, which then kills its own
105 /// external children via the usual SIGTERM/SIGKILL discipline.
106 ///
107 /// Used for foreground concurrency: scatter workers, concurrent pipeline
108 /// stages, command substitution. Default implementation delegates to
109 /// [`Self::fork`] for stateless dispatchers that don't track cancellation.
110 async fn fork_attached(&self) -> Arc<dyn CommandDispatcher> {
111 self.fork().await
112 }
113}
114
115/// Minimal stateless dispatcher used by pipeline/runner unit tests.
116///
117/// Production code uses `Kernel` (via `Kernel::fork` for concurrent contexts).
118/// This test-only dispatcher routes directly through `backend.call_tool()` so
119/// the pipeline runner can be exercised without spinning up a full Kernel.
120///
121/// Limitations (intentional — these are test-only constraints):
122/// - No user-defined tools
123/// - No .kai script resolution
124/// - No async argument evaluation (command substitution in args won't work)
125#[cfg(test)]
126pub(crate) struct BackendDispatcher {
127 tools: Arc<ToolRegistry>,
128}
129
130#[cfg(test)]
131impl BackendDispatcher {
132 /// Create a new backend dispatcher with the given tool registry.
133 pub(crate) fn new(tools: Arc<ToolRegistry>) -> Self {
134 Self { tools }
135 }
136
137 /// Try to execute an external command (PATH lookup + process spawn).
138 ///
139 /// Used as fallback when no builtin/backend tool matches. Returns None if
140 /// the command is not found in PATH. Always captures stdout/stderr (never
141 /// inherits terminal — pipeline stages don't need interactive I/O).
142 #[cfg(not(feature = "subprocess"))]
143 async fn try_external(
144 &self,
145 _name: &str,
146 _args: &[Arg],
147 _ctx: &mut ExecContext,
148 ) -> Option<ExecResult> {
149 None
150 }
151
152 /// Try to execute an external command (PATH lookup + process spawn).
153 #[cfg(feature = "subprocess")]
154 async fn try_external(
155 &self,
156 name: &str,
157 args: &[Arg],
158 ctx: &mut ExecContext,
159 ) -> Option<ExecResult> {
160 if !ctx.allow_external_commands {
161 return None;
162 }
163
164 // Real filesystem location of the shell's cwd, if any. A `None` real
165 // path means the cwd is virtual (a CoW overlay, an in-memory VFS
166 // mount, …) — there's nowhere for a child OS process to run. Don't
167 // bail out here: a bare command name that isn't in PATH at all is a
168 // genuine "not found" regardless of cwd. Once the command actually
169 // resolves, `real_cwd` is checked again below and the honest reason
170 // is given then — kept in sync with kernel.rs::try_execute_external
171 // (issue #181).
172 let real_cwd = ctx.backend.resolve_real_path(&ctx.cwd);
173
174 // Resolve command: absolute/relative path or PATH lookup
175 let executable = if name.contains('/') {
176 // Resolve relative paths (./script, ../bin/tool) against the shell's cwd
177 let resolved = if std::path::Path::new(name).is_absolute() {
178 std::path::PathBuf::from(name)
179 } else {
180 match &real_cwd {
181 Some(real_cwd) => real_cwd.join(name),
182 // Can't resolve a relative path without a real cwd to
183 // join against, so we can't even tell whether it would
184 // exist — name the actual blocker.
185 None => return Some(virtual_cwd_error(name, &ctx.cwd)),
186 }
187 };
188 if resolved.exists() {
189 resolved.to_string_lossy().into_owned()
190 } else {
191 return Some(ExecResult::failure(127, format!("{}: No such file or directory", name)));
192 }
193 } else {
194 // PATH from scope only — never OS env (keeps this test-only spawn
195 // site in sync with kernel.rs::try_execute_external).
196 let path_var = ctx.scope.get("PATH")
197 .map(crate::interpreter::value_to_string)
198 .unwrap_or_default();
199 resolve_in_path(name, &path_var)?
200 };
201
202 // The executable resolved — found in PATH, or a path that exists —
203 // but there's still nowhere to run it without a real cwd.
204 let real_cwd = match real_cwd {
205 Some(p) => p,
206 None => return Some(virtual_cwd_error(name, &ctx.cwd)),
207 };
208
209 // Build flat argv from args. A for-loop (not filter_map) so the
210 // Decision D collection-argv guard can short-circuit the whole spawn
211 // — kept in sync with the production build in kernel.rs::build_args_flat.
212 let mut argv: Vec<String> = Vec::new();
213 for arg in args {
214 match arg {
215 Arg::Positional(expr) => match expr {
216 Expr::Literal(Value::String(s)) => argv.push(s.clone()),
217 Expr::Literal(Value::Int(i)) => argv.push(i.to_string()),
218 Expr::Literal(Value::Float(f)) => argv.push(f.to_string()),
219 Expr::VarRef(path) => {
220 if let Ok(v) = ctx.scope.resolve_path(path) {
221 if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &v) {
222 return Some(ExecResult::failure(1, msg));
223 }
224 // Text sink: binary goes loud (kept in sync with
225 // kernel.rs::build_args_flat).
226 match crate::interpreter::value_to_text_sink(&v) {
227 Ok(s) => argv.push(s),
228 Err(e) => return Some(ExecResult::failure(1, e.to_string())),
229 }
230 }
231 }
232 // Remaining literal types (Bool/Json/Null/Bytes) — kept in
233 // sync with the production build_args_flat, which resolves
234 // every positional through value_to_text_sink (binary loud).
235 Expr::Literal(other) => match crate::interpreter::value_to_text_sink(other) {
236 Ok(s) => argv.push(s),
237 Err(e) => return Some(ExecResult::failure(1, e.to_string())),
238 },
239 _ => {}
240 },
241 Arg::ShortFlag(f) => argv.push(format!("-{f}")),
242 Arg::LongFlag(f) => argv.push(format!("--{f}")),
243 Arg::Named { key, value } => match value {
244 Expr::Literal(Value::String(s)) => argv.push(format!("--{key}={s}")),
245 _ => argv.push(format!("--{key}=")),
246 },
247 Arg::WordAssign { key, value } => match value {
248 Expr::Literal(Value::String(s)) => argv.push(format!("{key}={s}")),
249 _ => argv.push(format!("{key}=")),
250 },
251 Arg::DoubleDash => argv.push("--".to_string()),
252 }
253 }
254
255 // Check for streaming pipes
256 let has_pipe_stdin = ctx.pipe_stdin.is_some();
257 let has_buffered_stdin = ctx.stdin.is_some();
258
259 // Spawn process
260 use tokio::process::Command;
261 use tokio::io::{AsyncReadExt, AsyncWriteExt};
262
263 let mut cmd = Command::new(&executable);
264 cmd.args(&argv);
265 cmd.current_dir(&real_cwd);
266 cmd.kill_on_drop(true);
267
268 // Hermetic env: child sees only kaish's exported vars, not the kaish
269 // process's OS env. Frontends that want OS-env passthrough (REPL, MCP)
270 // populate it via KernelConfig::initial_vars at construction.
271 cmd.env_clear();
272 let exported = ctx.scope.exported_vars();
273 // A structured value can't cross the process boundary; refuse rather than
274 // silently JSON-serialize it into the child's environment. Kept in sync
275 // with the production spawn site in kernel.rs::try_execute_external.
276 if let Some(msg) = crate::interpreter::structured_export_error(&exported) {
277 return Some(ExecResult::failure(1, msg));
278 }
279 for (var_name, value) in exported {
280 // Binary can't cross the process boundary as an env var value
281 // either — loud, not the `[binary: N bytes]` placeholder (kept in
282 // sync with the production spawn site).
283 match crate::interpreter::value_to_text_sink_named(
284 &value,
285 "an exported environment variable value",
286 ) {
287 Ok(s) => {
288 cmd.env(var_name, s);
289 }
290 Err(e) => return Some(ExecResult::failure(1, e.to_string())),
291 }
292 }
293
294 // Stdin: pipe_stdin or buffered bytes or inherit (interactive) or null
295 cmd.stdin(if has_pipe_stdin || has_buffered_stdin {
296 std::process::Stdio::piped()
297 } else if ctx.interactive && matches!(ctx.pipeline_position, PipelinePosition::First | PipelinePosition::Only) {
298 std::process::Stdio::inherit()
299 } else {
300 std::process::Stdio::null()
301 });
302 cmd.stdout(std::process::Stdio::piped());
303 cmd.stderr(std::process::Stdio::piped());
304
305 // On Unix, always put the child in its own process group so a
306 // cancel can `killpg` the whole tree (the child plus any
307 // grandchildren) — matching the production spawn site
308 // (kernel.rs::try_execute_external) exactly. Without this, `killpg`
309 // targets a group nobody is actually in (an ESRCH no-op), and a
310 // grandchild spawned by the child survives cancellation — the exact
311 // gap GH #133 item 4 closes. This dispatcher has no job-control
312 // terminal integration (no `terminal_state`), so unlike production
313 // there is no signal-handler restoration to gate here.
314 #[cfg(unix)]
315 {
316 // SAFETY: setpgid is async-signal-safe per POSIX; safe to call
317 // between fork and exec.
318 #[allow(unsafe_code)]
319 unsafe {
320 cmd.pre_exec(|| {
321 nix::unistd::setpgid(nix::unistd::Pid::from_raw(0), nix::unistd::Pid::from_raw(0))
322 .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
323 Ok(())
324 });
325 }
326 }
327
328 let mut child = match cmd.spawn() {
329 Ok(c) => c,
330 Err(e) => return Some(ExecResult::failure(127, format!("{}: {}", name, e))),
331 };
332 // Open a pidfd (Linux) for race-free direct-child kill via wait_or_kill.
333 let kill_target = crate::pidfd::KillTarget::from_child(&child);
334
335 // Stream stdin: copy pipe_stdin → child stdin in chunks (bounded memory)
336 let stdin_task: Option<tokio::task::JoinHandle<()>> = if let Some(mut pipe_in) = ctx.pipe_stdin.take() {
337 child.stdin.take().map(|mut child_stdin| {
338 tokio::spawn(async move {
339 let mut buf = [0u8; 8192];
340 loop {
341 match pipe_in.read(&mut buf).await {
342 Ok(0) => break, // EOF
343 Ok(n) => {
344 if child_stdin.write_all(&buf[..n]).await.is_err() {
345 break; // child closed stdin
346 }
347 }
348 Err(_) => break,
349 }
350 }
351 // Drop child_stdin signals EOF to child
352 })
353 })
354 } else if let Some(data) = ctx.stdin.take() {
355 // Buffered stdin bytes written from a DETACHED task, not inline:
356 // an inline write deadlocks once the stdin pipe fills before the
357 // output drain below has spawned (mirrors the kernel.rs fix; keeps
358 // the two spawn sites in sync). Drop signals EOF; a broken pipe
359 // (child closed stdin early) is fine.
360 child.stdin.take().map(|mut child_stdin| {
361 tokio::spawn(async move {
362 let _ = child_stdin.write_all(&data).await;
363 })
364 })
365 } else {
366 None
367 };
368
369 // Capture stdout via the spill-aware collector, regardless of whether
370 // this is a pipeline stage (`ctx.pipe_stdout` set) or the last/only
371 // stage. This intentionally does NOT special-case `ctx.pipe_stdout`
372 // — production's `try_execute_external` never touches that field at
373 // all; a middle/first pipeline stage's forwarding to the next stage
374 // is entirely `PipelineRunner::run_pipeline`'s job (pipeline.rs),
375 // which reads `stage_ctx.pipe_stdout` (still `Some`, untouched here)
376 // after `dispatch()` returns and forwards `result.out` itself.
377 //
378 // Before this fix, this dispatcher special-cased `pipe_stdout` and
379 // streamed the child's stdout straight through in 8KB chunks — full
380 // fidelity, no cap. Production has no such fast path: every external
381 // stage's stdout is captured here first, then forwarded by the
382 // runner, so a >10MB intermediate stage silently loses its head in
383 // production (the runner's forward goes through the SAME capture,
384 // still true after this fix — see GH #133 item 2 for the capture
385 // primitive itself). Losing the pipe_stdout special case is what lets
386 // a test reproduce that production bug class at all (GH #133 item 3).
387 let Some(child_stdout) = child.stdout.take() else {
388 return Some(ExecResult::failure(1, "internal: stdout not available"));
389 };
390 let Some(mut child_stderr) = child.stderr.take() else {
391 return Some(ExecResult::failure(1, "internal: stderr not available"));
392 };
393
394 // Capture stdout into a fixed 10MB tail-evicting ring (`BoundedStream`
395 // + `drain_to_stream`) — the SAME capture primitive the production
396 // spawn site uses (kernel.rs::try_execute_external), not the
397 // limit-aware `spill_aware_collect` this used to call. Production
398 // does not spill-check an external command's own capture inline
399 // against `ctx.output_limit`; the pipeline-level post-hoc
400 // `spill_if_needed` (`Kernel::execute_pipeline`) is what applies that
401 // afterward, and `did_spill` is left `false` here for THAT reason — a
402 // caller wanting the limit-aware post-hoc behavior applies it
403 // separately, same as the real pipeline path (GH #133 item 2).
404 // Independently, `did_spill` CAN still end up `true` below: if the
405 // ring itself overflows (unconditionally, regardless of
406 // `ctx.output_limit`), that's the GH #191 loud-overflow signal, not
407 // the limit-aware spill this comment is about.
408 let stdout_stream = Arc::new(crate::scheduler::BoundedStream::new(
409 crate::scheduler::DEFAULT_STREAM_MAX_SIZE,
410 ));
411 let stdout_clone = stdout_stream.clone();
412 let stdout_task = tokio::spawn(async move {
413 crate::scheduler::drain_to_stream(child_stdout, stdout_clone).await;
414 });
415
416 // Stderr streaming is intentionally left as-is (live to
417 // `ctx.stderr` when present, else buffered) — production instead
418 // caps stderr into its own 10MB ring with no live streaming. That
419 // divergence is out of scope for this PR; see GH #133 follow-ups.
420 let stderr_stream_handle = ctx.stderr.clone();
421 let stderr_task = tokio::spawn(async move {
422 let mut buf = Vec::new();
423 let mut chunk = [0u8; 8192];
424 loop {
425 match child_stderr.read(&mut chunk).await {
426 Ok(0) => break,
427 Ok(n) => {
428 if let Some(ref stream) = stderr_stream_handle {
429 stream.write(&chunk[..n]);
430 } else {
431 buf.extend_from_slice(&chunk[..n]);
432 }
433 }
434 Err(_) => break,
435 }
436 }
437 if stderr_stream_handle.is_some() {
438 String::new()
439 } else {
440 String::from_utf8_lossy(&buf).into_owned()
441 }
442 });
443
444 let cancel = ctx.cancel.clone();
445 // Mirror production's cancel-aware drain handling: spawn the
446 // drains concurrently with the wait (not after collection
447 // completes) so a cancel can actually interrupt a still-running,
448 // still-silent child instead of blocking until it produces EOF.
449 let cancelled_before_wait = cancel.is_cancelled();
450 let status = crate::kernel::wait_or_kill(
451 &mut child,
452 kill_target.as_ref(),
453 &cancel,
454 std::time::Duration::from_secs(2),
455 ).await;
456 if let Some(task) = stdin_task { task.abort(); }
457 let mut stderr = if cancelled_before_wait || cancel.is_cancelled() {
458 // The child's pipes are gone; late output is lost but
459 // predictable death beats partial capture (same tradeoff
460 // production makes).
461 stdout_task.abort();
462 stderr_task.abort();
463 String::new()
464 } else {
465 let _ = stdout_task.await;
466 stderr_task.await.unwrap_or_default()
467 };
468
469 // Signal-death mapping (128+signal, e.g. SIGKILL→137) must match
470 // the production spawn site exactly — kept in sync via the shared
471 // `exit_code_from_status` helper (GH #133 item 1). A `wait_or_kill`
472 // I/O error (not a signal death) falls back to 1, same as before.
473 let code = match status {
474 Ok(s) => crate::kernel::exit_code_from_status(&s),
475 Err(_) => 1,
476 };
477 let stdout = stdout_stream.read().await;
478 // stdout came back as raw bytes: text if valid UTF-8, else a Bytes
479 // result (so `curl url`, `curl url > file.bin`, etc. keep binary intact).
480 let mut result = ExecResult::success_text_or_bytes(stdout).with_code(code);
481
482 // Mirror production's overflow signaling (GH #191) for the piece this
483 // twin actually shares with `kernel.rs::try_execute_external`: the
484 // stdout `BoundedStream` ring. Stderr here is captured differently
485 // from production (live-streamed to `ctx.stderr` when set, else an
486 // unbounded `Vec` — see the comment above `stderr_stream_handle`,
487 // GH #133 follow-up), so there is no stderr `BoundedStream` overflow
488 // to mirror; only the stdout side applies. `did_spill` stays `false`
489 // otherwise, matching `output_limit_is_not_applied_inline_matching_production`
490 // below — this is the fixed-ring overflow signal, not the
491 // limit-aware post-hoc spill Kernel::execute_pipeline applies.
492 if stdout_stream.has_overflowed().await {
493 let stats = stdout_stream.stats().await;
494 stderr = format!("{}{stderr}", stats.overflow_marker("stdout"));
495 result.did_spill = true;
496 }
497 result.err = stderr;
498 Some(result)
499 }
500}
501
502#[cfg(test)]
503#[async_trait]
504impl CommandDispatcher for BackendDispatcher {
505 async fn dispatch(&self, cmd: &Command, ctx: &mut ExecContext) -> Result<ExecResult> {
506 // Handle built-in true/false
507 match cmd.name.as_str() {
508 "true" => return Ok(ExecResult::success("")),
509 "false" => return Ok(ExecResult::failure(1, "")),
510 _ => {}
511 }
512
513 // Build tool args through the reduced sync evaluator (no command
514 // substitution) — see `SyncEvalSource` in `scheduler::pipeline`.
515 // A bad/subscripted collection access is a genuine PathError here too —
516 // propagate it via `?` rather than swallowing, same as the production
517 // Kernel::dispatch_command's `execute_command(..).await?`.
518 let schema = self.tools.get(&cmd.name).map(|t| t.schema());
519 let tool_args = build_tool_args(&cmd.args, ctx, schema.as_ref())
520 .await
521 .map_err(|e| anyhow::anyhow!(e))?;
522
523 // Honor --json before the tool runs so a parse failure inside the
524 // builtin doesn't drop the format on the floor. See kernel.rs for the
525 // matching call in the production path.
526 GlobalFlags::apply_from_args(&tool_args, ctx);
527
528 // Execute via backend
529 let backend = ctx.backend.clone();
530 let result = match backend.call_tool(&cmd.name, tool_args, ctx).await {
531 // Route through the same `From<ToolResult> for ExecResult` the
532 // production dispatch path uses (kernel.rs) rather than
533 // hand-rolling the field-by-field copy: the old inline version
534 // wrapped `data` unconditionally as `Value::Json`, which skipped
535 // `json_to_value_no_envelope`'s scalar-unwrap (`Value::Int`/
536 // `Value::String`/…) and silently dropped `did_spill`/
537 // `original_code` — a divergence this test-only dispatcher must
538 // not have from the real path (GH #93 item 4).
539 Ok(tool_result) => ExecResult::from(tool_result),
540 Err(BackendError::ToolNotFound(_)) => {
541 // Fall back to external command execution
542 match self.try_external(&cmd.name, &cmd.args, ctx).await {
543 Some(result) => result,
544 None => ExecResult::failure(127, format!("command not found: {}", cmd.name)),
545 }
546 }
547 Err(e) => ExecResult::failure(127, e.to_string()),
548 };
549
550 // Migrated builtins parse --json via the GlobalFlags flatten and
551 // write ctx.output_format. The kernel just applies it.
552 let result = match ctx.output_format {
553 Some(format) => apply_output_format(result, format),
554 None => result,
555 };
556
557 Ok(result)
558 }
559
560 /// Sync-only evaluation (no command substitution) — matches this
561 /// test dispatcher's documented "no async argument evaluation" limit.
562 async fn eval_expr(&self, expr: &Expr, ctx: &ExecContext) -> Result<Value> {
563 crate::scheduler::pipeline::eval_simple_expr(expr, ctx)
564 .map_err(|e| anyhow::anyhow!(e))?
565 .ok_or_else(|| anyhow::anyhow!("cannot evaluate expression in test dispatcher"))
566 }
567
568 /// BackendDispatcher is stateless, so a fork is just a clone.
569 async fn fork(&self) -> Arc<dyn CommandDispatcher> {
570 Arc::new(Self { tools: Arc::clone(&self.tools) })
571 }
572}
573
574/// Tests that spawn real external processes through `try_external`, to catch
575/// behavioral drift from the production spawn site (`kernel.rs::try_execute_external`)
576/// — GH #133. Unlike the `BackendDispatcher` tests in `scheduler::pipeline`,
577/// which exercise builtins over a `MemoryFs` (virtual cwd, so `try_external`
578/// never spawns), these give the dispatcher a real tempdir cwd + PATH so the
579/// external fallback actually runs a child process.
580#[cfg(all(test, feature = "subprocess"))]
581mod external_process_tests {
582 // Test-fixture helpers (not `#[test]` bodies themselves), so the
583 // workspace's usual allow-in-tests clippy.toml carve-out doesn't cover
584 // them — see CLAUDE.md's "clap builtin gotchas" / test-code conventions.
585 #![allow(clippy::unwrap_used, clippy::expect_used)]
586 use super::*;
587 use crate::ast::{Arg, Command, Expr, Value};
588 use crate::tools::{ExecContext, ToolRegistry};
589 use crate::vfs::{LocalFs, VfsRouter};
590
591 /// A `BackendDispatcher` + `ExecContext` rooted at a real tempdir, with an
592 /// empty tool registry (every command name falls through to
593 /// `try_external`, exactly like a real external command with no matching
594 /// builtin/user tool) and PATH seeded from the test process's own OS env.
595 /// Reading OS env here is fixture code, not kaish's hermetic runtime — see
596 /// CLAUDE.md and `external_command_tests.rs::repl_kernel`.
597 fn real_cwd_dispatcher() -> (BackendDispatcher, ExecContext, tempfile::TempDir) {
598 let dir = tempfile::tempdir().expect("tempdir");
599 let mut vfs = VfsRouter::new();
600 vfs.mount("/", LocalFs::new(dir.path().to_path_buf()));
601 let tools = Arc::new(ToolRegistry::new());
602 let mut ctx = ExecContext::with_vfs_and_tools(Arc::new(vfs), tools.clone());
603 // Exported (not just set): try_external's own PATH lookup reads
604 // ctx.scope directly, but the CHILD process only inherits exported
605 // vars (cmd.env_clear() + exported_vars()) — a script that shells
606 // out further (`sh -c "yes | head"`) needs PATH in ITS env too,
607 // not just kaish's resolver.
608 ctx.scope.set_exported(
609 "PATH",
610 Value::String(std::env::var("PATH").unwrap_or_default()),
611 );
612 let dispatcher = BackendDispatcher::new(tools);
613 (dispatcher, ctx, dir)
614 }
615
616 /// `sh -c <script>` as a `Command`, matching how the parser would build it
617 /// from `sh -c 'script'` (a short flag, then a positional literal).
618 fn sh_cmd(script: &str) -> Command {
619 Command {
620 name: "sh".to_string(),
621 args: vec![
622 Arg::ShortFlag("c".to_string()),
623 Arg::Positional(Expr::Literal(Value::String(script.to_string()))),
624 ],
625 redirects: vec![],
626 }
627 }
628
629 /// GH #133 item 1: production maps a signal-killed child to `128 + signal`
630 /// (SIGKILL -> 137); the twin used to hardcode `code().unwrap_or(1)` -> 1,
631 /// so a cancel/timeout test run through this dispatcher observed an exit
632 /// code production never actually produces. Fails at `code == 1` pre-fix.
633 #[tokio::test]
634 async fn signal_killed_child_maps_to_128_plus_signal() {
635 let (dispatcher, mut ctx, _dir) = real_cwd_dispatcher();
636 let cmd = sh_cmd("kill -KILL $$");
637 let result = dispatcher.dispatch(&cmd, &mut ctx).await.expect("dispatch");
638 assert_eq!(
639 result.code, 137,
640 "SIGKILL should map to 128+9=137 (production's mapping), got {}",
641 result.code
642 );
643 }
644
645 /// GH #133 item 2: the twin used to call the limit-aware
646 /// `spill_aware_collect` in its non-pipe capture branch, applying
647 /// `ctx.output_limit` inline and setting `did_spill` itself. Production's
648 /// `try_execute_external` never spill-checks its own capture that way —
649 /// spill is a pipeline-level, post-hoc step (`Kernel::execute_pipeline`
650 /// calls `spill_if_needed` AFTER the dispatcher returns). So even with a
651 /// tiny `output_limit` configured, `try_external` itself must return the
652 /// full (up to the 10MB ring) captured output with `did_spill == false`.
653 /// Pre-fix, the twin truncated inline and set `did_spill = true` here.
654 #[tokio::test]
655 async fn output_limit_is_not_applied_inline_matching_production() {
656 let (dispatcher, mut ctx, _dir) = real_cwd_dispatcher();
657 // A tiny in-memory limit (no disk spill file — CLAUDE.md: no real
658 // system paths in tests) — if try_external still spill-checked
659 // inline (the bug), this would trigger truncation right here.
660 ctx.output_limit = crate::output_limit::OutputLimitConfig::agent().in_memory();
661 ctx.output_limit.set_limit(Some(64));
662
663 let cmd = sh_cmd("yes x | head -c 1000");
664 let result = dispatcher.dispatch(&cmd, &mut ctx).await.expect("dispatch");
665
666 assert_eq!(result.code, 0, "err: {}", result.err);
667 assert_eq!(
668 result.text_out().len(),
669 1000,
670 "try_external must return the full captured output — production \
671 defers spill to the post-hoc pipeline step, not its own capture; \
672 got {} bytes: {:?}",
673 result.text_out().len(),
674 result.text_out()
675 );
676 assert!(
677 !result.did_spill,
678 "try_external itself must not set did_spill — that's \
679 Kernel::execute_pipeline's post-hoc spill_if_needed's job, \
680 matching production"
681 );
682 }
683
684 /// GH #133 item 3: before this fix, `try_external` special-cased
685 /// `ctx.pipe_stdout` — taking it out of the context and hand-streaming
686 /// the child's stdout straight into it in 8KB chunks, bypassing the
687 /// capture logic a non-pipeline external goes through, and always
688 /// returning an empty `result.out` ("output was streamed to pipe").
689 /// Production's `try_execute_external` has no such special case: it never
690 /// reads or writes `ctx.pipe_stdout` at all — `PipelineRunner::run_pipeline`
691 /// (pipeline.rs) is solely responsible for reading a stage's captured
692 /// `result.out` back out and forwarding it to the next stage.
693 #[tokio::test]
694 async fn pipeline_stage_leaves_pipe_stdout_for_the_runner_to_forward() {
695 let (dispatcher, mut ctx, _dir) = real_cwd_dispatcher();
696
697 // Simulate what PipelineRunner::run_pipeline wires onto a first/middle
698 // stage's ctx before calling dispatch(): a pipe_stdout the runner
699 // expects to read back out afterward.
700 let (writer, reader) = crate::scheduler::pipe_stream_default();
701 ctx.pipe_stdout = Some(writer);
702
703 // Drain the reader concurrently — a full-fidelity writer (the old
704 // special case) would otherwise still work here for a small payload,
705 // but this also lets the pipe close out cleanly either way.
706 let drain = tokio::spawn(async move {
707 use tokio::io::AsyncReadExt;
708 let mut reader = reader;
709 let mut buf = Vec::new();
710 let _ = reader.read_to_end(&mut buf).await;
711 buf
712 });
713
714 let cmd = sh_cmd("echo hello");
715 // A generous but bounded timeout: a real hang here (e.g. an
716 // accidental deadlock reintroduced by a future edit) should fail
717 // loud and fast in CI, not stall the suite indefinitely.
718 let result = tokio::time::timeout(
719 std::time::Duration::from_secs(15),
720 dispatcher.dispatch(&cmd, &mut ctx),
721 )
722 .await
723 .expect("dispatch timed out")
724 .expect("dispatch");
725
726 assert!(
727 ctx.pipe_stdout.is_some(),
728 "try_external must leave ctx.pipe_stdout untouched — forwarding \
729 to the next stage is PipelineRunner's job, matching production, \
730 which never reads or writes this field at all"
731 );
732
733 // Drop the writer now (the runner would take it back out and, after
734 // forwarding, let it go) so the reader sees EOF and `drain` actually
735 // completes — nothing else in this test closes the pipe, since
736 // try_external no longer touches it at all post-fix.
737 drop(ctx.pipe_stdout.take());
738 let _ = drain.await;
739
740 assert!(
741 result.text_out().contains("hello"),
742 "try_external must capture and return stdout the same way for a \
743 pipeline stage as a non-pipeline call (not force it empty \
744 because a pipe was attached) — got: {:?}",
745 result.text_out()
746 );
747 }
748
749 /// GH #133 item 3, large-payload consequence: before this fix, a pipeline
750 /// stage's stdout went through the hand-rolled full-fidelity streamer,
751 /// which ignored any size cap entirely and forwarded byte-for-byte no
752 /// matter the size — an intermediate stage had NO cap at all, of any
753 /// kind. Post-fix, every stage (pipe or not) goes through the same
754 /// capture path a non-pipeline external uses.
755 ///
756 /// Updated for GH #133 item 2 (landed since this test was written): the
757 /// shared capture path now caps via an *unconditional* ~10MB
758 /// `BoundedStream` ring regardless of `ctx.output_limit` configuration —
759 /// production never spill-checks its own capture inline against
760 /// `ctx.output_limit`, deferring THAT to the pipeline-level, post-hoc
761 /// `spill_if_needed`. So `ctx.output_limit` is configured below only to
762 /// prove it's inert here (matching item 2's contract) — it plays no part
763 /// in why this payload gets capped.
764 ///
765 /// Updated again for GH #191: the fixed ring overflowing IS now loud on
766 /// its own terms, independent of `ctx.output_limit`. `did_spill` flips to
767 /// `true` (this dispatcher calling `dispatch()` directly, not through
768 /// `Kernel::execute_pipeline`, is exactly why `code` stays `0` here — the
769 /// exit-3 remap lives in that caller, not in `try_external` itself), and
770 /// stderr carries a truncation marker. Stdout still comes back as a
771 /// clean, marker-free tail — the marker is never prepended into stdout
772 /// (which may be binary), only into stderr. This test still pins the
773 /// piece item 3 alone is responsible for: a pipeline stage is no longer
774 /// special-cased into a no-cap-of-any-kind fast path.
775 #[tokio::test]
776 async fn oversized_pipeline_stage_output_is_no_longer_forwarded_losslessly() {
777 let (dispatcher, mut ctx, _dir) = real_cwd_dispatcher();
778
779 ctx.output_limit = crate::output_limit::OutputLimitConfig::agent().in_memory();
780 ctx.output_limit.set_limit(Some(1024)); // tiny vs. the >10MB payload below
781
782 let (writer, reader) = crate::scheduler::pipe_stream_default();
783 ctx.pipe_stdout = Some(writer);
784
785 // Drain the pipe concurrently — a full-fidelity writer would
786 // otherwise block on the 64KB pipe capacity well before finishing an
787 // 11MB write, deadlocking the test.
788 let drain = tokio::spawn(async move {
789 use tokio::io::AsyncReadExt;
790 let mut reader = reader;
791 let mut buf = Vec::new();
792 let _ = reader.read_to_end(&mut buf).await;
793 buf
794 });
795
796 let cmd = sh_cmd("yes x | head -c 11000000");
797 // A generous but bounded timeout: a real hang here should fail loud
798 // and fast in CI, not stall the suite indefinitely.
799 let result = tokio::time::timeout(
800 std::time::Duration::from_secs(15),
801 dispatcher.dispatch(&cmd, &mut ctx),
802 )
803 .await
804 .expect("dispatch timed out")
805 .expect("dispatch");
806
807 // Drop the writer (try_external no longer touches it post-fix, so
808 // nothing else will) so the reader sees EOF and `drain` completes.
809 drop(ctx.pipe_stdout.take());
810 let _ = drain.await;
811
812 // The exit-3 remap lives in `Kernel::execute_pipeline` (`if
813 // result.did_spill { code = 3 }`), which this test never calls —
814 // it drives `dispatcher.dispatch()` directly. So `code` stays the
815 // child's own exit status (0) even though `did_spill` is now `true`.
816 assert_eq!(result.code, 0, "err: {}", result.err);
817 assert!(
818 result.text_out().len() < 11_000_000,
819 "an oversized (~11MB) pipeline stage's output must now be capped, \
820 not forwarded byte-for-byte losslessly — the pre-fix special \
821 case ignored any cap entirely; post-fix it goes through the same \
822 capped capture (the unconditional ~10MB ring) a non-pipeline \
823 external uses. got {} bytes",
824 result.text_out().len()
825 );
826 assert!(
827 !result.text_out().contains("truncated"),
828 "the loud-overflow marker (GH #191) must never contaminate stdout \
829 — it belongs in stderr only, since stdout may be binary: got {:?}",
830 &result.text_out()[..result.text_out().len().min(80)]
831 );
832 assert!(
833 result.did_spill,
834 "the fixed ~10MB ring overflowing must set did_spill (GH #191) so \
835 a real `Kernel::execute_pipeline` caller remaps to exit 3 — this \
836 is independent of ctx.output_limit's own spill_if_needed, which \
837 stays out of scope for try_external as before"
838 );
839 assert!(
840 result.err.contains("stdout truncated"),
841 "stderr must carry the loud overflow marker (GH #191): {}",
842 result.err
843 );
844 }
845
846 /// GH #133 item 4: production always puts the spawned child in its own
847 /// process group (`setpgid(0,0)` in `pre_exec`) so a cancel's `killpg`
848 /// reaches the whole tree — the direct child AND any grandchildren it
849 /// spawns. Pre-fix, this dispatcher never called `setpgid`, so `killpg`
850 /// targeted a process group nobody was actually in (an ESRCH no-op): a
851 /// grandchild survived cancellation even though the direct child died.
852 /// Any existing test asserting "grandchild cleanup" against this
853 /// dispatcher was passing trivially, verifying nothing real.
854 ///
855 /// # Why this test checks the structural fact, not an end-to-end kill
856 ///
857 /// The most faithful reproduction of the issue would background a
858 /// grandchild (`sleep N &`), cancel mid-flight, and assert the
859 /// grandchild dies too — pinning the exact "existing test passes
860 /// trivially" symptom. That reproduction turned out to be **blocked by a
861 /// separate, pre-existing ordering issue** in this dispatcher, not
862 /// introduced by this PR: `try_external`'s output collection used to run
863 /// to completion BEFORE `wait_or_kill` was even called, so cancellation
864 /// had no observable effect until the child's stdout closed on its own —
865 /// which, for a `sh -c '... & wait'` script producing no stdout, only
866 /// happened once the whole script finished naturally. GH #133 item 2 (PR
867 /// #152, already landed on main alongside this fix) restructured
868 /// collection to run *concurrently* with `wait_or_kill`, matching
869 /// production — an end-to-end grandchild-kill test is now meaningful and
870 /// fast, and remains a natural follow-up. Until then, this test pins the
871 /// concrete, fast, unconfounded consequence of *this* PR's diff: the
872 /// spawned child's own pgid equals its own pid, i.e. `setpgid(0, 0)` in
873 /// `pre_exec` actually took effect. `ps -p $$` runs and exits almost
874 /// immediately, producing no stdout for kaish to block draining — so the
875 /// ordering issue above never enters into it either way.
876 #[cfg(unix)]
877 #[tokio::test]
878 async fn spawned_child_becomes_its_own_process_group_leader() {
879 let tmp = tempfile::tempdir().expect("tempdir");
880 let out_file = tmp.path().join("pgid_info");
881
882 let (dispatcher, mut ctx, _dir) = real_cwd_dispatcher();
883
884 // `$$` is the running shell's own PID; `ps -o pid=,pgid= -p $$`
885 // reports that shell's pid and process-group id. If setpgid(0,0)
886 // took effect in pre_exec (before `ps` even execs), the two must be
887 // equal. Redirected straight to a file — sh's own captured stdout
888 // (what kaish pipes) stays empty, so collection returns immediately.
889 let script = format!("ps -o pid=,pgid= -p $$ > {}", out_file.display());
890 let cmd = sh_cmd(&script);
891
892 let result = tokio::time::timeout(
893 std::time::Duration::from_secs(10),
894 dispatcher.dispatch(&cmd, &mut ctx),
895 )
896 .await
897 .expect("dispatch timed out")
898 .expect("dispatch");
899 assert_eq!(result.code, 0, "err: {}", result.err);
900
901 let contents = std::fs::read_to_string(&out_file).expect("read pgid info");
902 let mut fields = contents.split_whitespace();
903 let pid: i32 = fields.next().expect("pid field").parse().expect("pid parse");
904 let pgid: i32 = fields.next().expect("pgid field").parse().expect("pgid parse");
905
906 assert_eq!(
907 pid, pgid,
908 "the spawned child's pgid must equal its own pid — setpgid(0,0) \
909 in pre_exec should make it its own process-group leader (so a \
910 later killpg reaches it and any of its own children), matching \
911 production (kernel.rs::try_execute_external); got pid={pid} \
912 pgid={pgid}"
913 );
914 }
915}