aprender_mcp/tools/subprocess.rs
1//! Shared subprocess wrapper for M2/M3 tools.
2//!
3//! Every M2 tool spawns `apr <subcommand> [...args] --json` and passes stdout
4//! through to the MCP client verbatim. Non-zero exit maps to `isError: true`
5//! with stderr attached. This module centralizes that pattern so each tool is
6//! a thin definition + a list of CLI args.
7//!
8//! M3 (FALSIFY-MCP-006) adds [`run_apr_cancellable`], which polls a
9//! [`std::sync::mpsc::Receiver`] between `try_wait` checks and escalates to
10//! SIGTERM → (grace window) → SIGKILL on the spawned subprocess when a
11//! cancellation is signalled. The non-cancellable [`run_apr`] is kept as a
12//! thin wrapper for tools that don't support cancellation yet.
13//!
14//! #2418: the failure path used to pick *either* stderr *or* stdout — stdout
15//! only when stderr was empty. `apr qa` writes its JSON gate report to stdout
16//! and a one-line summary to stderr, so every failing QA run (the tool's
17//! primary use case) reached the client as a single line with the >3 KB
18//! report thrown away. [`failure_result`] now keeps the summary as the first
19//! content block and attaches the report as a second one, so a failing gate
20//! is as inspectable as a passing one.
21
22use crate::apr_bin::apr_binary;
23use crate::types::{ContentBlock, ToolCallResult};
24use std::ffi::OsStr;
25use std::io::{BufRead, BufReader, Read};
26use std::process::{Command, Stdio};
27use std::sync::mpsc::{Receiver, TryRecvError};
28use std::time::{Duration, Instant};
29
30/// Default grace window between SIGTERM and SIGKILL for cancelled calls.
31///
32/// Per `docs/specifications/apr-mcp-server-spec.md`:
33/// > `notifications/cancelled` from client → kill the spawned `apr`
34/// > subprocess with SIGTERM (30s grace) → SIGKILL.
35pub const CANCEL_GRACE_MS: u64 = 30_000;
36
37/// Poll interval when waiting for subprocess exit / cancel signal.
38const POLL_INTERVAL: Duration = Duration::from_millis(10);
39
40/// Render one argv element so the echoed command can actually be re-run.
41///
42/// `format!("apr {}", args.join(" "))` produced `--prompt What is 2+2?`, which
43/// is a different command from the one that ran (#2403). Anything outside the
44/// POSIX-safe set is single-quoted, with embedded quotes escaped the shell way.
45fn quote_arg(arg: &str) -> String {
46 let safe = !arg.is_empty()
47 && arg
48 .bytes()
49 .all(|b| b.is_ascii_alphanumeric() || b"@%+=:,./-_".contains(&b));
50 if safe {
51 arg.to_string()
52 } else {
53 format!("'{}'", arg.replace('\'', r"'\''"))
54 }
55}
56
57/// Build the `isError` result for a subprocess that exited non-zero.
58///
59/// The first content block is the one-line summary the client already relied
60/// on. When the command wrote to BOTH streams the stdout payload is attached
61/// as a second block instead of being discarded (#2418).
62fn failure_result(cmd_display: &str, code: i32, stdout: &str, stderr: &str) -> ToolCallResult {
63 let summary = if stderr.trim().is_empty() {
64 stdout.to_string()
65 } else {
66 stderr.to_string()
67 };
68 let mut content = vec![ContentBlock::text(format!(
69 "`{cmd_display}` failed (exit {code}): {summary}"
70 ))];
71 if !stderr.trim().is_empty() && !stdout.trim().is_empty() {
72 content.push(ContentBlock::text(stdout.to_string()));
73 }
74 ToolCallResult {
75 content,
76 is_error: Some(true),
77 }
78}
79
80/// Spawn `apr <args...>` and wait synchronously. Shorthand for the
81/// non-cancellable path used by every tool except `apr.run`.
82///
83/// - Successful exit with non-empty stdout → `success(stdout)`
84/// - Successful exit with empty stdout → `error("apr ... produced no output")`
85/// - Non-zero exit → `error("apr ... failed (exit N): <stderr-or-stdout>")`
86/// - Spawn failure → `error("Failed to spawn apr ...: <io-err>")`
87#[must_use]
88pub fn run_apr(args: &[&str]) -> ToolCallResult {
89 run_program(apr_binary(), args)
90}
91
92/// Generic-over-program variant of [`run_apr`]. [`run_apr`] binds `program`
93/// to [`crate::apr_bin::apr_binary`] — the running `apr` executable — so the
94/// version the user launched is the version that answers.
95#[must_use]
96pub fn run_program<P: AsRef<OsStr>>(program: P, args: &[&str]) -> ToolCallResult {
97 let program = program.as_ref();
98 let cmd_display = display_cmd(program, args);
99 let output = match Command::new(program).args(args).output() {
100 Ok(o) => o,
101 Err(e) => {
102 return ToolCallResult::error(format!("Failed to spawn `{cmd_display}`: {e}"));
103 }
104 };
105
106 let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
107 let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
108
109 if output.status.success() {
110 if stdout.trim().is_empty() {
111 ToolCallResult::error(format!("`{cmd_display}` produced no output"))
112 } else {
113 ToolCallResult::success(stdout)
114 }
115 } else {
116 let code = output.status.code().unwrap_or(-1);
117 failure_result(&cmd_display, code, &stdout, &stderr)
118 }
119}
120
121/// Render `program args...` for user-facing error messages, quoted so the echoed
122/// command can actually be re-run.
123///
124/// This used to be `args.join(" ")`, which is the #2403 defect: `--prompt What is
125/// 2+2?` is a DIFFERENT command from the one that ran, and a user copying it out
126/// of an error message gets a different failure than the one being reported.
127///
128/// The merge that produced this file kept the `OsStr` signature (every call site
129/// passes `apr_binary()`, an OsStr) but had dropped the quoting, leaving
130/// `quote_arg` orphaned — clippy's dead-code error is what surfaced the lost fix.
131fn display_cmd(program: &OsStr, args: &[&str]) -> String {
132 let mut out = quote_arg(&program.to_string_lossy());
133 for a in args {
134 out.push(' ');
135 out.push_str("e_arg(a));
136 }
137 out
138}
139
140/// Spawn `apr <args...>` cancellable via `cancel_rx`.
141///
142/// On receipt of any value on `cancel_rx`, the subprocess is sent SIGTERM.
143/// If it hasn't exited within `grace_ms` milliseconds, SIGKILL is sent. The
144/// returned `ToolCallResult` carries whatever stdout was captured up to the
145/// point of cancellation and has `is_error: Some(true)` with a message that
146/// starts with `"Cancelled:"`.
147///
148/// Non-Unix targets do NOT support signalling; this function falls back to
149/// `child.kill()` (equivalent to SIGKILL on Windows).
150#[must_use]
151pub fn run_apr_cancellable(
152 args: &[&str],
153 cancel_rx: &Receiver<()>,
154 grace_ms: u64,
155) -> ToolCallResult {
156 spawn_cancellable(apr_binary(), args, cancel_rx, grace_ms)
157}
158
159/// Generic over the binary. `run_apr_cancellable` binds `program` to
160/// [`crate::apr_bin::apr_binary`] — the running `apr` executable — which is
161/// what production code should use.
162#[must_use]
163pub fn spawn_cancellable<P: AsRef<OsStr>>(
164 program: P,
165 args: &[&str],
166 cancel_rx: &Receiver<()>,
167 grace_ms: u64,
168) -> ToolCallResult {
169 let program = program.as_ref();
170 let cmd_display = display_cmd(program, args);
171
172 let mut child = match Command::new(program)
173 .args(args)
174 .stdout(Stdio::piped())
175 .stderr(Stdio::piped())
176 .spawn()
177 {
178 Ok(c) => c,
179 Err(e) => {
180 return ToolCallResult::error(format!("Failed to spawn `{cmd_display}`: {e}"));
181 }
182 };
183
184 let pid = child.id();
185
186 // Poll loop: check if the child exited, then check for a cancel signal.
187 // Sleep `POLL_INTERVAL` between iterations. This keeps cancel latency
188 // under ~10ms while the subprocess is alive.
189 let wait_status = loop {
190 match child.try_wait() {
191 Ok(Some(status)) => break Ok(status),
192 Ok(None) => {}
193 Err(e) => {
194 return ToolCallResult::error(format!("Failed to poll `{cmd_display}`: {e}"));
195 }
196 }
197
198 match cancel_rx.try_recv() {
199 Ok(()) => break Err(CancelReason::Signalled),
200 Err(TryRecvError::Empty) => {}
201 Err(TryRecvError::Disconnected) => {
202 // Sender dropped without a cancel — treat as "no cancellation
203 // will ever come" and just wait for natural exit.
204 }
205 }
206
207 std::thread::sleep(POLL_INTERVAL);
208 };
209
210 match wait_status {
211 Ok(status) => {
212 // Natural exit: drain pipes and map to success/error.
213 let stdout = drain(&mut child.stdout.take());
214 let stderr = drain(&mut child.stderr.take());
215 if status.success() {
216 if stdout.trim().is_empty() {
217 ToolCallResult::error(format!("`{cmd_display}` produced no output"))
218 } else {
219 ToolCallResult::success(stdout)
220 }
221 } else {
222 let code = status.code().unwrap_or(-1);
223 failure_result(&cmd_display, code, &stdout, &stderr)
224 }
225 }
226 Err(CancelReason::Signalled) => {
227 // SIGTERM, grace window, then SIGKILL.
228 send_sigterm(pid);
229 let deadline = Instant::now() + Duration::from_millis(grace_ms);
230 let mut escalated = false;
231 loop {
232 match child.try_wait() {
233 Ok(Some(_)) => break,
234 Ok(None) => {}
235 Err(_) => break,
236 }
237 if Instant::now() >= deadline {
238 if !escalated {
239 // Best-effort kill (SIGKILL on Unix, TerminateProcess
240 // on Windows). Ignore errors — the process may have
241 // exited between try_wait and here.
242 let _ = child.kill();
243 escalated = true;
244 } else {
245 // Even SIGKILL hasn't reaped it — give up after a
246 // short extra window to avoid hanging the main
247 // thread forever. In practice SIGKILL is immediate.
248 break;
249 }
250 }
251 std::thread::sleep(POLL_INTERVAL);
252 }
253 // Reap if still alive (keeps us from leaking a zombie).
254 let _ = child.wait();
255
256 let stdout = drain(&mut child.stdout.take());
257 let preview = truncate_for_preview(&stdout);
258 ToolCallResult::error(format!(
259 "Cancelled: `{cmd_display}` terminated by notifications/cancelled; partial stdout: {preview}"
260 ))
261 }
262 }
263}
264
265enum CancelReason {
266 Signalled,
267}
268
269fn drain<R: Read>(reader: &mut Option<R>) -> String {
270 let mut buf = String::new();
271 if let Some(r) = reader.as_mut() {
272 let _ = r.read_to_string(&mut buf);
273 }
274 buf
275}
276
277fn truncate_for_preview(s: &str) -> String {
278 const MAX: usize = 512;
279 if s.len() <= MAX {
280 s.to_string()
281 } else {
282 let truncated: String = s.chars().take(MAX).collect();
283 format!("{truncated}… (truncated)")
284 }
285}
286
287#[cfg(unix)]
288fn send_sigterm(pid: u32) {
289 use nix::sys::signal::{kill, Signal};
290 use nix::unistd::Pid;
291
292 // Cast is safe: u32 → i32 for PIDs in the valid pid_t range. Values
293 // above i32::MAX would be invalid PIDs on every Unix we support, so
294 // saturating is fine — `kill` will just fail with EINVAL and we move
295 // on to the SIGKILL branch.
296 #[allow(clippy::cast_possible_wrap)]
297 let raw = pid as i32;
298 let _ = kill(Pid::from_raw(raw), Signal::SIGTERM);
299}
300
301#[cfg(not(unix))]
302fn send_sigterm(_pid: u32) {
303 // Windows has no SIGTERM; we skip straight to the SIGKILL equivalent
304 // (`child.kill()`) in the escalation path above.
305}
306
307/// Spawn `apr <args...>` and stream stdout line-by-line to `on_line`.
308///
309/// FALSIFY-MCP-PROGRESS-001: this is the streaming variant used by tools that
310/// emit `notifications/progress` (currently `apr.finetune`). Each line of
311/// stdout (as written by `apr <cmd> --json`) is passed to `on_line`
312/// synchronously before the next `read_line` — the caller is responsible for
313/// emitting the notification.
314///
315/// Returns a `ToolCallResult` whose body is the concatenated stdout (the same
316/// shape as [`run_apr`] would have produced), so callers can keep the
317/// existing "final payload" semantics while layering progress on top.
318#[must_use]
319pub fn run_apr_streaming<F>(args: &[&str], on_line: F) -> ToolCallResult
320where
321 F: FnMut(&str),
322{
323 spawn_streaming(apr_binary(), args, on_line)
324}
325
326/// Generic-over-program variant of [`run_apr_streaming`]. Production callers
327/// pass [`crate::apr_bin::apr_binary`]; tests use it to inject a mock.
328#[must_use]
329pub fn spawn_streaming<P: AsRef<OsStr>, F>(
330 program: P,
331 args: &[&str],
332 mut on_line: F,
333) -> ToolCallResult
334where
335 F: FnMut(&str),
336{
337 let program = program.as_ref();
338 let cmd_display = display_cmd(program, args);
339
340 let mut child = match Command::new(program)
341 .args(args)
342 .stdout(Stdio::piped())
343 .stderr(Stdio::piped())
344 .spawn()
345 {
346 Ok(c) => c,
347 Err(e) => {
348 return ToolCallResult::error(format!("Failed to spawn `{cmd_display}`: {e}"));
349 }
350 };
351
352 // Take stdout so we can wrap it in a BufReader. Leaving stderr attached
353 // to the child means we can drain it after wait() for the error path.
354 let stdout_pipe = match child.stdout.take() {
355 Some(p) => p,
356 None => {
357 let _ = child.wait();
358 return ToolCallResult::error(format!("Failed to capture stdout of `{cmd_display}`"));
359 }
360 };
361
362 let mut accumulated = String::new();
363 let reader = BufReader::new(stdout_pipe);
364 for line in reader.lines() {
365 match line {
366 Ok(text) => {
367 on_line(&text);
368 accumulated.push_str(&text);
369 accumulated.push('\n');
370 }
371 Err(e) => {
372 // Best-effort: surface the read error but still try to reap
373 // the child so we don't leak a zombie.
374 let _ = child.wait();
375 return ToolCallResult::error(format!(
376 "Failed to read stdout of `{cmd_display}`: {e}"
377 ));
378 }
379 }
380 }
381
382 // stdout closed → subprocess is either exited or about to. Wait for the
383 // exit status so we can map success/failure correctly.
384 let status = match child.wait() {
385 Ok(s) => s,
386 Err(e) => {
387 return ToolCallResult::error(format!("Failed to reap `{cmd_display}`: {e}"));
388 }
389 };
390
391 let stderr = drain(&mut child.stderr.take());
392
393 if status.success() {
394 if accumulated.trim().is_empty() {
395 ToolCallResult::error(format!("`{cmd_display}` produced no output"))
396 } else {
397 ToolCallResult::success(accumulated)
398 }
399 } else {
400 let code = status.code().unwrap_or(-1);
401 let detail = if stderr.trim().is_empty() {
402 accumulated
403 } else {
404 stderr
405 };
406 ToolCallResult::error(format!("`{cmd_display}` failed (exit {code}): {detail}"))
407 }
408}
409
410#[cfg(test)]
411#[allow(clippy::disallowed_methods)] // test helpers
412mod tests {
413 use super::*;
414 use std::sync::mpsc;
415 use std::thread;
416
417 /// #2418 — a failing `apr qa` writes its JSON gate report to stdout and a
418 /// one-line summary to stderr. The report is the whole point of the tool;
419 /// it must survive the failure path, not be replaced by the summary.
420 #[test]
421 fn failure_keeps_the_stdout_report_when_stderr_also_spoke() {
422 let report = r#"{"passed":false,"gates":[{"name":"ollama_parity","passed":false}]}"#;
423 let result = failure_result(
424 "apr qa m.gguf --json",
425 5,
426 report,
427 "error: Validation failed",
428 );
429
430 assert_eq!(result.is_error, Some(true));
431 let whole: String = result
432 .content
433 .iter()
434 .map(|b| b.text.as_str())
435 .collect::<Vec<_>>()
436 .join("\n");
437 assert!(
438 whole.contains("ollama_parity"),
439 "gate report must reach the client, got: {whole}"
440 );
441 assert!(
442 whole.contains("failed (exit 5)"),
443 "summary line must survive too, got: {whole}"
444 );
445 }
446
447 /// End-to-end through a real subprocess that writes to BOTH streams and
448 /// exits non-zero — the exact shape of a failing `apr qa`.
449 ///
450 /// The report is asserted on the SECOND content block specifically. The
451 /// first block echoes the command, and the command here IS the script
452 /// text, so an `any()` over all blocks passed even with the report
453 /// discarded — that is how the first draft of this test survived its own
454 /// mutation check.
455 #[test]
456 fn cancellable_failure_carries_both_streams() {
457 let (_tx, rx) = mpsc::channel::<()>();
458 let result = spawn_cancellable(
459 "sh",
460 &[
461 "-c",
462 "printf '{\"gates\":\"REP\"}\\nORT\\n'; echo SUMMARY >&2; exit 5",
463 ],
464 &rx,
465 CANCEL_GRACE_MS,
466 );
467 assert_eq!(result.is_error, Some(true));
468 assert!(
469 result.content[0].text.contains("SUMMARY"),
470 "stderr dropped: {}",
471 result.content[0].text
472 );
473 assert_eq!(
474 result.content.len(),
475 2,
476 "stdout report dropped, only got: {:?}",
477 result.content
478 );
479 assert!(
480 result.content[1].text.contains("{\"gates\":\"REP\"}\nORT"),
481 "stdout report mangled: {}",
482 result.content[1].text
483 );
484 }
485
486 /// A failure with nothing on stderr still reports stdout in the summary,
487 /// and does not emit a redundant duplicate block.
488 #[test]
489 fn failure_with_empty_stderr_reports_stdout_once() {
490 let result = failure_result("apr qa m.gguf", 1, "only-stdout", " \n");
491 assert_eq!(result.content.len(), 1);
492 assert!(result.content[0].text.contains("only-stdout"));
493 }
494
495 /// #2403 (secondary) — the echoed reproduction command must be
496 /// copy-pasteable. `--prompt What is 2+2?` is a different command from the
497 /// one that ran.
498 #[test]
499 fn echoed_command_is_shell_quoted() {
500 let cmd = display_cmd(
501 OsStr::new("apr"),
502 &["run", "m.gguf", "--prompt", "What is 2+2?"],
503 );
504 assert_eq!(cmd, "apr run m.gguf --prompt 'What is 2+2?'");
505 }
506
507 /// Quoting must be idempotent for safe argv elements (no needless noise)
508 /// and must survive an embedded single quote.
509 #[test]
510 fn quoting_leaves_safe_args_alone_and_escapes_quotes() {
511 assert_eq!(quote_arg("--max-tokens"), "--max-tokens");
512 assert_eq!(
513 quote_arg("/home/noah/models/a.gguf"),
514 "/home/noah/models/a.gguf"
515 );
516 assert_eq!(quote_arg(""), "''");
517 assert_eq!(quote_arg("it's"), r"'it'\''s'");
518 }
519
520 /// Spawning `apr` with an unrecognised subcommand yields a tool error
521 /// (non-zero exit), not a panic.
522 #[test]
523 fn spawn_failure_maps_to_tool_error() {
524 let result = run_apr(&["this-subcommand-does-not-exist"]);
525 assert_eq!(result.is_error, Some(true));
526 }
527
528 /// FALSIFIER (#2384): `run_apr` must execute the *resolved* `apr` binary,
529 /// not a hard-coded `Command::new("apr")` that the OS looks up on `$PATH`.
530 ///
531 /// The field defect: `apr mcp` shipped in 0.63.0 returned results produced
532 /// by a 0.60.0 binary that happened to be first on `$PATH`, while
533 /// `apr.version` kept answering 0.63.0.
534 ///
535 /// Here we designate a specific binary via `$APR_BIN` and assert its
536 /// marker output comes back as the tool payload. Before the fix, `run_apr`
537 /// ignored resolution entirely and this returned whatever `apr` `$PATH`
538 /// produced — never the marker.
539 ///
540 /// The shim mirrors the real CLI's exit semantics (unknown subcommand →
541 /// exit 2) so it is compatible with `spawn_failure_maps_to_tool_error`
542 /// should the two overlap while `$APR_BIN` is set.
543 #[test]
544 #[cfg(unix)]
545 fn falsify_2384_run_apr_executes_the_resolved_binary() {
546 // Unique per process: a fixed path lets two concurrent runs of this
547 // test binary delete each other's shim mid-flight.
548 let dir =
549 std::env::temp_dir().join(format!("aprender-mcp-2384-run-apr-{}", std::process::id()));
550 let _ = std::fs::remove_dir_all(&dir);
551 std::fs::create_dir_all(&dir).expect("mkdir scratch");
552 let shim = dir.join("apr");
553 // #4020: the shim is written by a child `sh`, never through a write
554 // fd in THIS process. Another test thread that forks while such an fd
555 // is open hands it to its child until that child's exec (O_CLOEXEC
556 // closes it at exec, not at fork), and our exec of the shim then fails
557 // with ETXTBSY. A child's fds are not inherited by our later forks, so
558 // once `sh` exits no writable fd to the shim exists anywhere. The exec
559 // goes through `run_apr` itself, so a retry in the test cannot help.
560 let script = "#!/bin/sh\n\
561 if [ \"$1\" = \"validate\" ]; then\n\
562 \x20 echo '{\"marker\":\"APR-BIN-RESOLVED-SHIM\"}'\n\
563 \x20 exit 0\n\
564 fi\n\
565 exit 2\n";
566 let wrote = Command::new("sh")
567 .args(["-c", "printf '%s' \"$1\" > \"$0\" && chmod 755 \"$0\""])
568 .arg(&shim)
569 .arg(script)
570 .status()
571 .expect("spawn sh to write the shim");
572 assert!(wrote.success(), "sh failed to write the shim: {wrote}");
573
574 // Edition 2021 — `set_var` is safe here.
575 std::env::set_var(crate::apr_bin::APR_BIN_ENV, &shim);
576 let result = run_apr(&["validate", "/dev/null", "--json"]);
577 std::env::remove_var(crate::apr_bin::APR_BIN_ENV);
578
579 assert!(
580 result.is_error.is_none(),
581 "resolved shim should succeed, got: {}",
582 result.content[0].text
583 );
584 assert!(
585 result.content[0].text.contains("APR-BIN-RESOLVED-SHIM"),
586 "run_apr must execute the resolved binary; got: {}",
587 result.content[0].text
588 );
589 }
590
591 /// Cancellable path: a never-firing receiver lets the subprocess run to
592 /// natural completion, producing identical behaviour to `run_apr`.
593 #[test]
594 fn cancellable_natural_exit_matches_run_apr() {
595 let (_tx, rx) = mpsc::channel::<()>();
596 let result = spawn_cancellable("echo", &["hello"], &rx, CANCEL_GRACE_MS);
597 assert!(result.is_error.is_none(), "echo should succeed");
598 assert!(result.content[0].text.contains("hello"));
599 }
600
601 /// Cancellable path: a disconnected receiver (sender dropped) is
602 /// equivalent to "no cancellation will arrive" — behaviour should not
603 /// change vs the never-firing channel.
604 #[test]
605 fn cancellable_disconnected_channel_is_noop() {
606 let (tx, rx) = mpsc::channel::<()>();
607 drop(tx);
608 let result = spawn_cancellable("echo", &["world"], &rx, CANCEL_GRACE_MS);
609 assert!(result.is_error.is_none());
610 assert!(result.content[0].text.contains("world"));
611 }
612
613 /// Spawning a missing binary returns a spawn error without panic.
614 #[test]
615 fn cancellable_spawn_failure_maps_to_error() {
616 let (_tx, rx) = mpsc::channel::<()>();
617 let result = spawn_cancellable(
618 "/this/binary/does/not/exist/apr-mcp-test",
619 &[],
620 &rx,
621 CANCEL_GRACE_MS,
622 );
623 assert_eq!(result.is_error, Some(true));
624 assert!(result.content[0].text.contains("Failed to spawn"));
625 }
626
627 /// FALSIFY-MCP-PROGRESS-001 (unit): `spawn_streaming` fires the callback
628 /// once per stdout line before returning the aggregated payload.
629 #[test]
630 fn streaming_invokes_callback_per_line() {
631 let lines = std::sync::Mutex::new(Vec::<String>::new());
632 let result = spawn_streaming("printf", &["line1\nline2\nline3\n"], |line| {
633 lines
634 .lock()
635 .expect("test mutex not poisoned")
636 .push(line.to_string());
637 });
638 assert!(result.is_error.is_none(), "printf should succeed");
639
640 let captured = lines.lock().expect("mutex").clone();
641 assert_eq!(captured, vec!["line1", "line2", "line3"]);
642 assert!(result.content[0].text.contains("line1"));
643 assert!(result.content[0].text.contains("line3"));
644 }
645
646 /// Spawn failure in the streaming path returns a tool error without
647 /// invoking the callback.
648 #[test]
649 fn streaming_spawn_failure_does_not_call_callback() {
650 let called = std::sync::Mutex::new(false);
651 let result = spawn_streaming(
652 "/this/binary/does/not/exist/apr-mcp-streaming-test",
653 &[],
654 |_| {
655 *called.lock().expect("mutex") = true;
656 },
657 );
658 assert_eq!(result.is_error, Some(true));
659 assert!(!*called.lock().expect("mutex"));
660 assert!(result.content[0].text.contains("Failed to spawn"));
661 }
662
663 /// Streaming path: non-zero exit surfaces as a tool error.
664 #[test]
665 #[cfg(unix)]
666 fn streaming_nonzero_exit_is_error() {
667 let result = spawn_streaming("sh", &["-c", "echo partial; exit 3"], |_| {});
668 assert_eq!(result.is_error, Some(true));
669 assert!(
670 result.content[0].text.contains("exit 3"),
671 "message should include exit code: {}",
672 result.content[0].text
673 );
674 }
675
676 /// FALSIFY-MCP-006 (unit-level): sending a cancel signal to a
677 /// long-running `sleep 60` subprocess returns within the grace window
678 /// (SIGTERM is immediate for `sleep`, so we see natural reap well
679 /// before the SIGKILL escalation).
680 #[test]
681 #[cfg(unix)]
682 fn cancellable_stops_long_running_subprocess_within_grace() {
683 let (tx, rx) = mpsc::channel::<()>();
684
685 // Fire the cancel shortly after spawn to give the subprocess time
686 // to get into its sleep syscall.
687 let handle = thread::spawn(move || {
688 thread::sleep(Duration::from_millis(100));
689 let _ = tx.send(());
690 });
691
692 let t0 = Instant::now();
693 // Grace of 2s: if SIGTERM fails for any reason we still fall back
694 // to SIGKILL well before the test's own timeout.
695 let result = spawn_cancellable("sleep", &["60"], &rx, 2_000);
696 let elapsed = t0.elapsed();
697
698 handle.join().expect("cancel-sender thread joins");
699
700 assert_eq!(result.is_error, Some(true), "cancelled calls are errors");
701 assert!(
702 result.content[0].text.starts_with("Cancelled:"),
703 "message should indicate cancellation, got: {}",
704 result.content[0].text
705 );
706 // 100ms fire + ~immediate SIGTERM response + cleanup — well under
707 // the 2s grace + 200ms test slack the spec calls for.
708 assert!(
709 elapsed < Duration::from_millis(2_500),
710 "cancel should finish within grace + slack, took {elapsed:?}"
711 );
712 // And it must finish meaningfully faster than the underlying
713 // `sleep 60` would have — this is the real falsification.
714 assert!(
715 elapsed < Duration::from_secs(5),
716 "cancelled call must return far before sleep 60's natural exit"
717 );
718 }
719}