Skip to main content

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}