cli_agents/adapters/mod.rs
1mod claude;
2mod codex;
3mod gemini;
4
5pub use claude::ClaudeAdapter;
6pub use codex::CodexAdapter;
7pub use gemini::GeminiAdapter;
8
9use crate::error::{Error, Result};
10use crate::events::StreamEvent;
11use crate::types::{CliName, RunOptions, RunResult};
12#[cfg(windows)]
13use process_wrap::tokio::CreationFlags;
14#[cfg(windows)]
15use process_wrap::tokio::JobObject;
16#[cfg(windows)]
17use windows::Win32::System::Threading::CREATE_NO_WINDOW;
18#[cfg(unix)]
19use process_wrap::tokio::ProcessGroup;
20use process_wrap::tokio::TokioChildWrapper;
21use process_wrap::tokio::TokioCommandWrap;
22use std::collections::HashMap;
23use tokio::io::{AsyncBufReadExt, BufReader};
24use tracing::{debug, warn};
25
26/// Trait implemented by each CLI adapter.
27pub trait CliAdapter: Send + Sync {
28 fn name(&self) -> CliName;
29
30 fn run(
31 &self,
32 opts: &RunOptions,
33 emit: &(dyn Fn(StreamEvent) + Send + Sync),
34 cancel: tokio_util::sync::CancellationToken,
35 ) -> impl std::future::Future<Output = crate::error::Result<RunResult>> + Send;
36}
37
38/// Get the adapter for a given CLI.
39pub(crate) fn get_adapter(cli: CliName) -> Box<dyn CliAdapterBoxed> {
40 match cli {
41 CliName::Claude => Box::new(ClaudeAdapter),
42 CliName::Codex => Box::new(CodexAdapter),
43 CliName::Gemini => Box::new(GeminiAdapter),
44 }
45}
46
47/// Object-safe version of [`CliAdapter`] for dynamic dispatch.
48///
49/// Needed because `CliAdapter::run` uses RPITIT (`impl Future`), which makes
50/// the trait non-object-safe. This wrapper boxes the future for `dyn` dispatch.
51/// The blanket impl below bridges the two automatically.
52#[allow(dead_code)]
53pub(crate) trait CliAdapterBoxed: Send + Sync {
54 fn name(&self) -> CliName;
55
56 fn run_boxed<'a>(
57 &'a self,
58 opts: &'a RunOptions,
59 emit: &'a (dyn Fn(StreamEvent) + Send + Sync),
60 cancel: tokio_util::sync::CancellationToken,
61 ) -> std::pin::Pin<
62 Box<dyn std::future::Future<Output = crate::error::Result<RunResult>> + Send + 'a>,
63 >;
64}
65
66impl<T: CliAdapter> CliAdapterBoxed for T {
67 fn name(&self) -> CliName {
68 CliAdapter::name(self)
69 }
70
71 fn run_boxed<'a>(
72 &'a self,
73 opts: &'a RunOptions,
74 emit: &'a (dyn Fn(StreamEvent) + Send + Sync),
75 cancel: tokio_util::sync::CancellationToken,
76 ) -> std::pin::Pin<
77 Box<dyn std::future::Future<Output = crate::error::Result<RunResult>> + Send + 'a>,
78 > {
79 Box::pin(self.run(opts, emit, cancel))
80 }
81}
82
83// ── Shared subprocess infrastructure ──
84
85/// Outcome of a spawned CLI process.
86pub(crate) enum SpawnOutcome {
87 /// Process reached a terminal state on its own (exited, or was signalled).
88 Done {
89 /// The process's exit status, or `None` when a SIGNAL ended it instead.
90 ///
91 /// A signalled process genuinely has no exit code. Substituting one
92 /// makes an out-of-memory kill indistinguishable from an agent that
93 /// cleanly decided it had failed, and [`RunResult::exit_code`] is an
94 /// `Option` precisely so a caller can tell the two apart.
95 exit_code: Option<i32>,
96 /// The signal that terminated the process, when one did. Unix only;
97 /// always `None` elsewhere.
98 signal: Option<i32>,
99 stderr: Option<String>,
100 /// How many stdout lines exceeded `max_bytes` and were dropped.
101 ///
102 /// A dropped line is a lost EVENT, not a lost run — adapters surface
103 /// this as a warning so the loss is never silent.
104 dropped_lines: u64,
105 },
106 /// Process was cancelled via the cancellation token.
107 Cancelled,
108}
109
110/// Parameters for [`spawn_and_stream`].
111pub(crate) struct SpawnParams<'a> {
112 pub cli_label: &'a str,
113 pub binary: &'a str,
114 pub args: &'a [String],
115 pub extra_env: &'a HashMap<String, String>,
116 /// Keys to remove from the inherited parent env before applying `extra_env`.
117 /// Used to prevent leaks like `ANTHROPIC_API_KEY` overriding subscription auth.
118 pub strip_env: &'a [&'static str],
119 pub cwd: &'a str,
120 pub max_bytes: usize,
121 pub cancel: &'a tokio_util::sync::CancellationToken,
122}
123
124/// Spawn a CLI subprocess and stream its stdout line-by-line.
125///
126/// Handles the boilerplate shared across all adapters: process spawning,
127/// stdout buffering with size limits, stderr collection, and cancellation.
128/// Does **not** clone the parent process environment — `Command` inherits it
129/// automatically; only `extra_env` entries are added.
130pub(crate) async fn spawn_and_stream(
131 params: SpawnParams<'_>,
132 mut on_line: impl FnMut(&str) + Send,
133) -> Result<SpawnOutcome> {
134 let SpawnParams {
135 cli_label,
136 binary,
137 args,
138 extra_env,
139 strip_env,
140 cwd,
141 max_bytes,
142 cancel,
143 } = params;
144 debug!(cli = cli_label, binary = %binary, args = ?args, "spawning CLI");
145
146 // ── The child owns a KILL GROUP, on every platform ──
147 //
148 // Cancelling a run has to take the whole tree, not just the process we
149 // spawned: `claude` is a launcher, and the work happens in node processes
150 // below it. Killing only the parent orphans those — they keep running, keep
151 // holding the model session, and keep writing to a pipe nobody reads.
152 //
153 // This used to be `pre_exec(setpgid)` plus `libc::killpg(SIGKILL)`, which is
154 // correct on unix and does not exist on Windows — where the equivalent is a
155 // Job Object, a completely different mechanism with the same purpose.
156 // `process-wrap` is that difference, already written and tested: the unix
157 // arm is the same process-group call, and the Windows arm assigns the child
158 // to a job that dies with it.
159 let mut wrap = TokioCommandWrap::with_new(binary, |cmd| {
160 cmd.args(args);
161 for key in strip_env {
162 cmd.env_remove(key);
163 }
164 cmd.envs(extra_env)
165 .current_dir(cwd)
166 .stdin(std::process::Stdio::null())
167 .stdout(std::process::Stdio::piped())
168 .stderr(std::process::Stdio::piped())
169 .kill_on_drop(true);
170 // NOTE: CREATE_NO_WINDOW is NOT set here. See the wrap() calls below —
171 // it MUST go through process-wrap's `CreationFlags` wrapper, or the
172 // `JobObject` wrapper silently overwrites it.
173 });
174
175 // ── NO CONSOLE WINDOW, AND IT HAS TO GO THROUGH process-wrap ──
176 //
177 // Windows gives every console-subsystem child its own console window unless
178 // the parent passes CREATE_NO_WINDOW at CreateProcess. `claude`, `codex` and
179 // `gemini` are console programs, so a GUI app embedding this crate flashes a
180 // black terminal on every run — reported against a Tauri app, over the
181 // user's editor.
182 //
183 // THE FIRST FIX (0.2.15) WAS SILENTLY CLOBBERED. It set
184 // `cmd.creation_flags(CREATE_NO_WINDOW)` in the closure above. But the
185 // `JobObject` wrapper's pre_spawn does `command.creation_flags(CREATE_SUSPENDED)`
186 // (process-wrap 8.2.1, std/job_object.rs), and `creation_flags` REPLACES,
187 // it does not OR — so the window flag was overwritten by the time the child
188 // spawned. process-wrap reads CREATE_NO_WINDOW back ONLY from its own
189 // `CreationFlags` wrapper (`core.get_wrap::<CreationFlags>()`), never from
190 // the raw command, and ORs it into CREATE_SUSPENDED. Its docs say exactly
191 // this: "the only way to use creation flags and the JobObject wrapper
192 // together," and "CreationFlags must come first."
193 //
194 // So the flag is a WRAPPER, ordered before JobObject. Windows-only; on unix
195 // ProcessGroup carries the tree-kill and there is no console to hide.
196 #[cfg(windows)]
197 wrap.wrap(CreationFlags(CREATE_NO_WINDOW));
198 #[cfg(unix)]
199 wrap.wrap(ProcessGroup::leader());
200 #[cfg(windows)]
201 wrap.wrap(JobObject);
202
203 let mut child = wrap
204 .spawn()
205 .map_err(|e| Error::Process(format!("failed to spawn {cli_label}: {e}")))?;
206
207 let stdout = child.stdout().take().expect("stdout piped");
208 let stderr = child.stderr().take().expect("stderr piped");
209
210 // stderr is retained as a bounded TAIL. It exists so a failed run can be
211 // explained (`extract_error_message` reads it), and the parting words are
212 // at the end — a chatty CLI logging megabytes must not be held in full
213 // for that. Chunked reads, not lines: a single unterminated line cannot
214 // grow the buffer past the bound either.
215 let stderr_handle = tokio::spawn(async move {
216 use tokio::io::AsyncReadExt;
217 let mut stderr = stderr;
218 let mut buf: Vec<u8> = Vec::new();
219 let mut chunk = [0u8; 8192];
220 loop {
221 match stderr.read(&mut chunk).await {
222 Ok(0) | Err(_) => break,
223 Ok(n) => {
224 buf.extend_from_slice(&chunk[..n]);
225 if buf.len() > STDERR_TAIL_BYTES * 2 {
226 buf.drain(..buf.len() - STDERR_TAIL_BYTES);
227 }
228 }
229 }
230 }
231 String::from_utf8_lossy(&buf).into_owned()
232 });
233
234 // `max_bytes` bounds what a single LINE may retain — not cumulative
235 // throughput. Every line is handed to `on_line` and released, so the
236 // total streamed volume never lives in memory; a cumulative cap here
237 // used to KILL a healthy long run at the 10MB mark and discard the
238 // whole turn it was carrying. A line that will not fit is consumed to
239 // its newline, dropped, and counted; the run continues.
240 let mut reader = BufReader::new(stdout);
241 let mut line_buf: Vec<u8> = Vec::new();
242 let mut dropped_lines: u64 = 0;
243
244 loop {
245 line_buf.clear();
246 tokio::select! {
247 result = read_line_capped(&mut reader, &mut line_buf, max_bytes) => {
248 match result {
249 Ok(CappedLine::Eof) => break,
250 Ok(CappedLine::Line { dropped: true }) => {
251 dropped_lines += 1;
252 warn!(cli = cli_label, max_bytes, "dropped a stdout line larger than the retention cap");
253 }
254 Ok(CappedLine::Line { dropped: false }) => {
255 on_line(String::from_utf8_lossy(&line_buf).trim());
256 }
257 Err(e) => {
258 warn!(cli = cli_label, error = %e, "error reading stdout");
259 break;
260 }
261 }
262 }
263 _ = cancel.cancelled() => {
264 kill_process_group(&mut child).await;
265 return Ok(SpawnOutcome::Cancelled);
266 }
267 }
268 }
269
270 let status = Box::into_pin(child.wait()).await.map_err(Error::Io)?;
271 // `code()` is `None` for a signalled process. This used to be
272 // `.unwrap_or(1)`, which reported a SIGKILL as a clean `exit 1` and left
273 // callers with no way to recover the difference — the exact ambiguity that
274 // sent a downstream app hunting for an error message a killed process never
275 // wrote. Report what actually happened and let the caller decide.
276 let exit_code = status.code();
277 #[cfg(unix)]
278 let signal = std::os::unix::process::ExitStatusExt::signal(&status);
279 #[cfg(not(unix))]
280 let signal: Option<i32> = None;
281 let stderr_text = stderr_handle.await.unwrap_or_default();
282
283 Ok(SpawnOutcome::Done {
284 exit_code,
285 signal,
286 stderr: if stderr_text.is_empty() {
287 None
288 } else {
289 Some(stderr_text)
290 },
291 dropped_lines,
292 })
293}
294
295/// How much stderr is retained (as a tail — see the reader above).
296const STDERR_TAIL_BYTES: usize = 64 * 1024;
297
298/// Outcome of one capped line read.
299enum CappedLine {
300 /// A complete line is in the buffer — unless `dropped`, in which case the
301 /// line exceeded the cap, the buffer is empty, and the line is gone.
302 Line { dropped: bool },
303 /// End of stream.
304 Eof,
305}
306
307/// Read one `\n`-terminated line, retaining at most `cap` bytes of it.
308///
309/// A line that will not fit is not truncated-and-delivered — a cut JSONL
310/// event is garbage to every parser downstream — it is consumed to its
311/// newline, discarded, and reported as `dropped`. Memory stays bounded by
312/// `cap` plus the reader's own buffer, no matter what the child writes.
313async fn read_line_capped<R: tokio::io::AsyncBufRead + Unpin>(
314 reader: &mut R,
315 buf: &mut Vec<u8>,
316 cap: usize,
317) -> std::io::Result<CappedLine> {
318 let mut dropped = false;
319 loop {
320 let (consumed, line_complete) = {
321 let available = reader.fill_buf().await?;
322 if available.is_empty() {
323 // EOF — a final unterminated line still counts as a line.
324 return Ok(if buf.is_empty() && !dropped {
325 CappedLine::Eof
326 } else {
327 CappedLine::Line { dropped }
328 });
329 }
330 match available.iter().position(|&b| b == b'\n') {
331 Some(newline) => {
332 if !dropped {
333 if buf.len() + newline <= cap {
334 buf.extend_from_slice(&available[..newline]);
335 } else {
336 dropped = true;
337 buf.clear();
338 }
339 }
340 (newline + 1, true)
341 }
342 None => {
343 let n = available.len();
344 if !dropped {
345 if buf.len() + n <= cap {
346 buf.extend_from_slice(available);
347 } else {
348 dropped = true;
349 buf.clear();
350 }
351 }
352 (n, false)
353 }
354 }
355 };
356 reader.consume(consumed);
357 if line_complete {
358 return Ok(CappedLine::Line { dropped });
359 }
360 }
361}
362
363/// The loss a dropped line represents must reach the consumer, not just the
364/// log — every adapter calls this after its spawn completes.
365pub(crate) fn warn_dropped_lines(dropped_lines: u64, max_bytes: usize, emit: &dyn Fn(StreamEvent)) {
366 if dropped_lines > 0 {
367 emit(StreamEvent::Error {
368 message: format!(
369 "{dropped_lines} output line(s) exceeded the {max_bytes}-byte retention cap and were dropped"
370 ),
371 severity: Some(crate::events::Severity::Warning),
372 });
373 }
374}
375
376/// A sentence for a process that died without writing one.
377///
378/// A signalled CLI usually produces NO stderr and no result event — there was
379/// no chance to. Without this the only fact reaching the user is a number, and
380/// the most common case by far (the OS reclaiming memory) reads as an
381/// unexplained failure.
382pub(crate) fn describe_signal(signal: Option<i32>) -> Option<String> {
383 let sig = signal?;
384 Some(match sig {
385 2 => "The agent was interrupted (SIGINT).".to_string(),
386 6 => "The agent aborted (SIGABRT).".to_string(),
387 9 => "The agent was killed (SIGKILL), most often by the system reclaiming memory."
388 .to_string(),
389 11 => "The agent crashed (SIGSEGV).".to_string(),
390 15 => "The agent was terminated (SIGTERM).".to_string(),
391 other => format!("The agent was terminated by signal {other}."),
392 })
393}
394
395/// Extract a user-friendly error message from CLI stderr.
396/// When an agent fails with no text output, this provides something
397/// meaningful to show the user instead of a blank response.
398pub(crate) fn extract_error_message(stderr: Option<&str>) -> Option<String> {
399 let stderr = stderr?;
400 // Find the most informative error line.
401 let msg = stderr
402 .lines()
403 .filter(|l| !l.is_empty())
404 .find(|l| {
405 let lower = l.to_lowercase();
406 lower.contains("error")
407 || lower.contains("limit")
408 || lower.contains("failed")
409 || lower.contains("denied")
410 || lower.contains("unauthorized")
411 })
412 .or_else(|| stderr.lines().rfind(|l| !l.is_empty()));
413 msg.map(|s| s.trim().to_string())
414}
415
416/// Kill the child AND everything it spawned.
417///
418/// `TokioChildWrapper::kill` dispatches to whichever group mechanism was wrapped
419/// on at spawn — the process group on unix, the Job Object on Windows — so the
420/// `#[cfg]` that used to live here is gone. It returns a boxed future, hence the
421/// pin.
422async fn kill_process_group(child: &mut Box<dyn TokioChildWrapper>) {
423 let _ = Box::into_pin(child.kill()).await;
424}
425
426#[cfg(test)]
427mod tests {
428 use super::*;
429
430 /// CANCELLING TAKES THE WHOLE TREE, not just the process we spawned.
431 ///
432 /// This is the contract `setpgid`/`killpg` existed to provide, and it had no
433 /// test — so the swap to `process-wrap` would have been unverifiable, and so
434 /// would any future change to it. It matters because `claude` is a
435 /// launcher: the work runs in node processes underneath. Killing only the
436 /// parent leaves those alive, holding a model session, writing to a pipe
437 /// nobody is reading.
438 ///
439 /// HOW IT PROVES IT WITHOUT TIMING GAMES: the shell writes a marker file,
440 /// spawns a grandchild that would DELETE that file after a delay, then
441 /// sleeps. Cancel immediately. If the group died, the grandchild never runs
442 /// and the marker survives. If only the parent died, the orphan wakes up and
443 /// removes it. The assertion is on a filesystem fact, not on a pid still
444 /// being enumerable, which is what makes it honest on both platforms.
445 ///
446 /// Unix-only for now: it needs a shell that can background a process, and
447 /// the Windows equivalent (`cmd /c start`) has different semantics worth
448 /// writing deliberately rather than transliterating. The Job Object path is
449 /// exercised by CI compiling this file for Windows; that it KILLS the tree
450 /// there is not yet proved. Marked plainly rather than assumed.
451 #[cfg(unix)]
452 #[tokio::test]
453 async fn cancelling_kills_the_grandchild_not_just_the_child() {
454 let dir = tempfile::tempdir().unwrap();
455 let marker = dir.path().join("survivor");
456 std::fs::write(&marker, "alive").unwrap();
457
458 // Grandchild removes the marker after 3s; parent then sleeps 10s.
459 let script = format!("(sleep 3; rm -f '{}') & sleep 10", marker.display());
460 let args = vec!["-c".to_string(), script];
461 let cancel = tokio_util::sync::CancellationToken::new();
462
463 let token = cancel.clone();
464 tokio::spawn(async move {
465 tokio::time::sleep(std::time::Duration::from_millis(300)).await;
466 token.cancel();
467 });
468
469 let outcome = spawn_and_stream(
470 SpawnParams {
471 cli_label: "test",
472 binary: "sh",
473 args: &args,
474 extra_env: &HashMap::new(),
475 strip_env: &[],
476 cwd: dir.path().to_str().unwrap(),
477 max_bytes: 1024,
478 cancel: &cancel,
479 },
480 |_: &str| {},
481 )
482 .await
483 .expect("spawn");
484
485 assert!(
486 matches!(outcome, SpawnOutcome::Cancelled),
487 "run was cancelled"
488 );
489
490 // Past when the grandchild would have deleted it, had it survived.
491 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
492 assert!(
493 marker.exists(),
494 "the grandchild outlived cancellation and deleted the marker — the kill did not reach the process group"
495 );
496 }
497
498 /// A process that ends by SIGNAL has no exit code, and says so.
499 ///
500 /// THE REGRESSION THIS PINS. `spawn_and_stream` used to finish with
501 /// `status.code().unwrap_or(1)`, so a killed process was reported as a
502 /// clean `exit 1`. Downstream that is unrecoverable: an out-of-memory kill
503 /// and an agent that decided it had failed become the same fact, and a
504 /// consumer looking for the reason searches a stderr the process never got
505 /// to write. `sh -c 'kill -9 $$'` reproduces it without timing games — the
506 /// shell signals itself, so the outcome is deterministic.
507 #[cfg(unix)]
508 #[tokio::test]
509 async fn a_signalled_process_reports_the_signal_not_a_fabricated_exit_code() {
510 let args = vec!["-c".to_string(), "kill -9 $$".to_string()];
511 let cancel = tokio_util::sync::CancellationToken::new();
512 let outcome = spawn_and_stream(
513 SpawnParams {
514 cli_label: "test",
515 binary: "sh",
516 args: &args,
517 extra_env: &HashMap::new(),
518 strip_env: &[],
519 cwd: ".",
520 max_bytes: 1024,
521 cancel: &cancel,
522 },
523 |_| {},
524 )
525 .await
526 .expect("spawn should succeed");
527
528 match outcome {
529 SpawnOutcome::Done {
530 exit_code, signal, ..
531 } => {
532 assert_eq!(exit_code, None, "a signalled process has no exit code");
533 assert_eq!(signal, Some(9), "SIGKILL should be reported as itself");
534 }
535 SpawnOutcome::Cancelled => panic!("nothing cancelled this run"),
536 }
537 }
538
539 /// An ordinary non-zero exit still reports its code — the change above must
540 /// not turn every failure into `None`.
541 #[tokio::test]
542 async fn a_normal_exit_still_reports_its_code() {
543 let args = vec!["-c".to_string(), "exit 3".to_string()];
544 let cancel = tokio_util::sync::CancellationToken::new();
545 let outcome = spawn_and_stream(
546 SpawnParams {
547 cli_label: "test",
548 binary: "sh",
549 args: &args,
550 extra_env: &HashMap::new(),
551 strip_env: &[],
552 cwd: ".",
553 max_bytes: 1024,
554 cancel: &cancel,
555 },
556 |_| {},
557 )
558 .await
559 .expect("spawn should succeed");
560
561 match outcome {
562 SpawnOutcome::Done {
563 exit_code, signal, ..
564 } => {
565 assert_eq!(exit_code, Some(3));
566 assert_eq!(signal, None, "an ordinary exit was not signalled");
567 }
568 SpawnOutcome::Cancelled => panic!("nothing cancelled this run"),
569 }
570 }
571
572 /// The user-facing sentence for the case that writes no stderr at all.
573 #[test]
574 fn describe_signal_names_the_common_kills() {
575 assert!(describe_signal(Some(9)).unwrap().contains("SIGKILL"));
576 assert!(describe_signal(Some(9)).unwrap().contains("memory"));
577 assert!(describe_signal(Some(15)).unwrap().contains("SIGTERM"));
578 assert!(describe_signal(Some(42)).unwrap().contains("42"));
579 assert_eq!(describe_signal(None), None);
580 }
581
582 /// A LONG RUN IS NOT AN ERROR. `max_bytes` used to count cumulative
583 /// throughput and KILL the process when the total crossed it — but every
584 /// line is handed to `on_line` and dropped, so the total was never held in
585 /// memory at all. A 30-minute agent run streaming tens of MB of tool
586 /// events died at the 10MB mark with its entire turn discarded, which is
587 /// how CueFrame's Director lost long turns in production. The cap bounds
588 /// what a single line may RETAIN; the run itself must complete.
589 #[cfg(unix)]
590 #[tokio::test]
591 async fn total_output_beyond_max_bytes_streams_through_and_completes() {
592 // 200 lines × ~100 bytes ≈ 20 KB through a 1 KB cap.
593 let script = "i=0; while [ $i -lt 200 ]; do printf '%0100d\\n' $i; i=$((i+1)); done";
594 let args = vec!["-c".to_string(), script.to_string()];
595 let cancel = tokio_util::sync::CancellationToken::new();
596 let mut lines = 0u32;
597 let outcome = spawn_and_stream(
598 SpawnParams {
599 cli_label: "test",
600 binary: "sh",
601 args: &args,
602 extra_env: &HashMap::new(),
603 strip_env: &[],
604 cwd: ".",
605 max_bytes: 1024,
606 cancel: &cancel,
607 },
608 |_| lines += 1,
609 )
610 .await
611 .expect("a large-but-line-bounded run must not be an error");
612
613 match outcome {
614 SpawnOutcome::Done { exit_code, .. } => assert_eq!(exit_code, Some(0)),
615 SpawnOutcome::Cancelled => panic!("nothing cancelled this run"),
616 }
617 assert_eq!(lines, 200, "every line was streamed through");
618 }
619
620 /// One oversized line loses ITSELF, not the run. The line that cannot be
621 /// retained within `max_bytes` is dropped (a truncated JSON event would be
622 /// garbage anyway); the lines after it still arrive and the process still
623 /// reports its own exit.
624 #[cfg(unix)]
625 #[tokio::test]
626 async fn an_oversized_line_is_dropped_and_the_run_continues() {
627 let script = "echo before; printf '%05000d\\n' 7; echo after";
628 let args = vec!["-c".to_string(), script.to_string()];
629 let cancel = tokio_util::sync::CancellationToken::new();
630 let mut seen: Vec<String> = Vec::new();
631 let outcome = spawn_and_stream(
632 SpawnParams {
633 cli_label: "test",
634 binary: "sh",
635 args: &args,
636 extra_env: &HashMap::new(),
637 strip_env: &[],
638 cwd: ".",
639 max_bytes: 1024,
640 cancel: &cancel,
641 },
642 |l| seen.push(l.to_string()),
643 )
644 .await
645 .expect("an oversized line must not abort the run");
646
647 assert_eq!(seen, vec!["before".to_string(), "after".to_string()]);
648 match outcome {
649 SpawnOutcome::Done {
650 exit_code,
651 dropped_lines,
652 ..
653 } => {
654 assert_eq!(exit_code, Some(0));
655 assert_eq!(dropped_lines, 1, "the loss is counted, never silent");
656 }
657 SpawnOutcome::Cancelled => panic!("nothing cancelled this run"),
658 }
659 }
660
661 /// stderr retention is a TAIL, not the whole stream. It exists so
662 /// `extract_error_message` has the CLI's parting words; a chatty process
663 /// logging megabytes to stderr must not be held in full for that.
664 #[cfg(unix)]
665 #[tokio::test]
666 async fn stderr_retains_a_bounded_tail() {
667 // ~1 MB of filler, then the line that matters, all on stderr.
668 let script = "i=0; while [ $i -lt 10000 ]; do printf '%0100d\\n' $i 1>&2; i=$((i+1)); done; \
669 echo 'Error: the last words' 1>&2; exit 1";
670 let args = vec!["-c".to_string(), script.to_string()];
671 let cancel = tokio_util::sync::CancellationToken::new();
672 let outcome = spawn_and_stream(
673 SpawnParams {
674 cli_label: "test",
675 binary: "sh",
676 args: &args,
677 extra_env: &HashMap::new(),
678 strip_env: &[],
679 cwd: ".",
680 max_bytes: 1024,
681 cancel: &cancel,
682 },
683 |_| {},
684 )
685 .await
686 .expect("spawn should succeed");
687
688 match outcome {
689 SpawnOutcome::Done { stderr, .. } => {
690 let stderr = stderr.expect("stderr was written");
691 assert!(
692 stderr.len() <= 256 * 1024,
693 "stderr retention must be bounded, got {} bytes",
694 stderr.len()
695 );
696 assert!(
697 stderr.contains("the last words"),
698 "the tail is the part that explains the failure"
699 );
700 }
701 SpawnOutcome::Cancelled => panic!("nothing cancelled this run"),
702 }
703 }
704}