Skip to main content

forge_sdk/transport/
process.rs

1//! `tokio::process` wrapping of the `claude` binary.
2//!
3//! ## I/O architecture
4//!
5//! The subprocess's stdin and stdout each run inside a dedicated
6//! tokio task; the [`Subprocess`] surface talks to them over mpsc
7//! channels. Two properties fall out:
8//!
9//! - **Cancel-safe reads.** [`tokio::io::AsyncBufReadExt::read_line`]
10//!   is documented as not cancel-safe, but [`mpsc::Receiver::recv`]
11//!   is. Driving stdout through a reader task lets callers
12//!   `tokio::select!` over [`Subprocess::read_line`] without losing
13//!   already-consumed bytes.
14//! - **Concurrent writes.** Cloning the writer-side mpsc is cheap
15//!   and `Send + 'static`. The `Subprocess::clone_writer` helper
16//!   hands out a clonable writer backed by the same writer task,
17//!   so detached control-request dispatch can write concurrently
18//!   with the reader without serialising on `&mut self`.
19
20use std::process::Stdio;
21use std::sync::Arc;
22
23use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
24use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command};
25use tokio::sync::{mpsc, oneshot};
26use tokio::task::JoinHandle;
27use tracing::{debug, warn};
28
29use crate::Error;
30use crate::argv::build_args;
31use crate::options::{Options, WireTee};
32
33/// Default upper bound on `close()` wait-for-exit. After this elapses,
34/// the child is SIGKILL'd. 5s is generous for a CLI that's draining
35/// in-flight turns; tests that want to verify the kill path drive
36/// against a process that ignores stdin EOF. Public so a consumer
37/// timing a wait over a session's teardown can derive it from this
38/// budget rather than guess.
39pub const CLOSE_WAIT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
40
41/// Name what actually failed. A spawn that cannot find the program and
42/// one that cannot enter `current_dir` both surface as `ENOENT`, so the
43/// cwd is re-checked here - this is the only point that holds it.
44fn spawn_error(err: std::io::Error, options: &Options) -> Error {
45    if err.kind() != std::io::ErrorKind::NotFound {
46        return Error::Io(err);
47    }
48    match options.cwd.as_deref() {
49        Some(cwd) if !cwd.is_dir() => {
50            Error::CwdNotFound { path: cwd.to_string_lossy().into_owned() }
51        }
52        _ => Error::CliNotFound { binary: options.binary.clone() },
53    }
54}
55
56/// Run `<binary> --version` synchronously and return the stdout.
57///
58/// # Errors
59///
60/// [`Error::CliNotFound`] when the binary isn't on PATH; [`Error::Io`]
61/// on any other spawn/wait failure; [`Error::Process`] when the version
62/// probe exits non-zero.
63pub fn query_cli_version(binary: &str) -> Result<String, Error> {
64    let output =
65        std::process::Command::new(binary).arg("--version").output().map_err(|e| {
66            match e.kind() {
67                std::io::ErrorKind::NotFound => Error::CliNotFound { binary: binary.to_string() },
68                _ => Error::Io(e),
69            }
70        })?;
71    if !output.status.success() {
72        return Err(Error::Process {
73            exit_code: output.status.code(),
74            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
75        });
76    }
77    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
78}
79
80/// Check that the reported `claude` version is at least `min_version`.
81/// Compares the parsed (major, minor, patch) components numerically.
82///
83/// # Errors
84///
85/// [`Error::Connection`] when the reported version is below the minimum
86/// or can't be parsed.
87pub fn check_cli_version(reported: &str, min_version: &str) -> Result<(), Error> {
88    // `reported` is typically like `2.1.116 (anthropic)` or `claude 2.1.116`.
89    let token = reported
90        .split_whitespace()
91        .find(|t| t.chars().next().is_some_and(|c| c.is_ascii_digit()))
92        .ok_or_else(|| Error::Connection {
93            reason: format!("could not parse claude version from: {reported}"),
94        })?;
95    let reported_triple = parse_semver_triple(token).ok_or_else(|| Error::Connection {
96        reason: format!("could not parse semver triple from: {token}"),
97    })?;
98    let min_triple = parse_semver_triple(min_version).ok_or_else(|| Error::Connection {
99        reason: format!("could not parse minimum semver triple from: {min_version}"),
100    })?;
101    if reported_triple < min_triple {
102        return Err(Error::Connection {
103            reason: format!("claude CLI version {reported} below minimum required {min_version}"),
104        });
105    }
106    Ok(())
107}
108
109/// Parse `"<major>.<minor>.<patch>"` (any trailing non-numeric suffix
110/// ignored). Missing minor/patch default to 0.
111fn parse_semver_triple(s: &str) -> Option<(u32, u32, u32)> {
112    let mut parts = s.split('.');
113    let major: u32 = parts.next()?.parse().ok()?;
114    let minor: u32 = parts.next().and_then(|p| p.parse().ok()).unwrap_or(0);
115    let patch_str = parts.next().unwrap_or("0");
116    // Strip any non-digit suffix (e.g. "117-rc1" or "117 (build)").
117    let patch_digits: String = patch_str.chars().take_while(char::is_ascii_digit).collect();
118    let patch: u32 = if patch_digits.is_empty() { 0 } else { patch_digits.parse().ok()? };
119    Some((major, minor, patch))
120}
121
122/// Every flag [`build_args`] can emit. A user-defined `extra_args`
123/// name is not covered, except where it collides with one of these -
124/// forge routes `--effort` that way, so the collision is the common
125/// case rather than a corner.
126const LOGGABLE_FLAGS: [&str; 16] = [
127    "output-format",
128    "verbose",
129    "system-prompt",
130    "system-prompt-file",
131    "append-system-prompt",
132    "allowedTools",
133    "max-turns",
134    "model",
135    "permission-prompt-tool",
136    "permission-mode",
137    "resume",
138    "session-id",
139    "mcp-config",
140    "plugin-dir",
141    "effort",
142    "input-format",
143];
144
145/// The recognised flag names in an argv, and a count of the `--`
146/// tokens that are neither recognised nor a declared `extra_args`
147/// name.
148///
149/// Matching against [`LOGGABLE_FLAGS`] rather than on a `--` prefix is
150/// what stops a value reaching the log when it happens to look like a
151/// flag - `--mcp-config` alone carries every external MCP server's
152/// `env` and `headers` verbatim.
153///
154/// The count exists to make a silent omission loud, so it reads zero
155/// on every shape forge builds today. `extra_args` names are excluded
156/// because their own count reports them, which leaves this number
157/// meaning "a `--` token nothing accounts for". A repeated recognised
158/// flag also raises it - no production path emits one.
159fn flag_names(
160    args: &[String],
161    extra_args: &std::collections::HashMap<String, Option<String>>,
162) -> (String, usize) {
163    let mut names: Vec<&str> = Vec::new();
164    let mut dropped = 0;
165    for token in args {
166        let Some(name) = token.strip_prefix("--") else { continue };
167        let name = name.split_once('=').map_or(name, |(before, _)| before);
168        if LOGGABLE_FLAGS.contains(&name) && !names.contains(&name) {
169            names.push(name);
170        } else if !extra_args.contains_key(name) {
171            dropped += 1;
172        }
173    }
174    (names.join(" "), dropped)
175}
176
177/// How long one write to the CLI's stdin, and its ack, may take before the
178/// write fails.
179///
180/// **A write had no bound.** A child that stopped reading its stdin - a
181/// wedged CLI, a busy one, a full pipe - left the writer task parked in
182/// `write_all` and every caller waiting on its ack for ever. The hook
183/// callback that writes a control response is one of those callers, so a
184/// backed-up child hung the server silently: no error, no panic, no reap.
185/// Thirty seconds is longer than a pipe write to a reading child ever takes
186/// and far shorter than the 26.7 minutes the 2026-11-09 freeze went
187/// unnoticed.
188pub(crate) const STDIN_WRITE_BOUND: std::time::Duration = std::time::Duration::from_secs(30);
189
190/// A stdin write, or its ack, that outlived [`STDIN_WRITE_BOUND`].
191fn write_stalled(bound: std::time::Duration, what: &str) -> Error {
192    Error::Io(std::io::Error::new(
193        std::io::ErrorKind::TimedOut,
194        format!("the CLI stopped reading stdin: {what} did not complete within {bound:?}"),
195    ))
196}
197
198/// Why the writer task's stdin is gone, kept beside it so a later write names
199/// the cause rather than blaming a call that never ran.
200#[derive(Clone, Copy, Debug)]
201enum Closed {
202    /// `end_input` dropped it on purpose.
203    EndInput,
204    /// A write outlived [`STDIN_WRITE_BOUND`], so a partial line may be on
205    /// the wire.
206    Stalled,
207    /// A write failed at the pipe.
208    Failed,
209}
210
211impl Closed {
212    fn why(self) -> &'static str {
213        match self {
214            Self::EndInput => "stdin already closed (end_input)",
215            Self::Stalled => "stdin was closed because a write outlived its bound",
216            Self::Failed => "stdin was closed because a write failed",
217        }
218    }
219}
220
221/// The error a write gets once the stream is closed, naming why it closed.
222fn closed_stdin(reason: Option<Closed>) -> Error {
223    Error::Io(std::io::Error::new(
224        std::io::ErrorKind::BrokenPipe,
225        reason.map_or("stdin already closed", Closed::why),
226    ))
227}
228
229/// Outcome of one writer-task operation. Sent back over a oneshot the
230/// caller provides.
231type IoAck = Result<(), Error>;
232
233/// A write-side command dispatched to the writer task.
234#[derive(Debug)]
235enum WriterCmd {
236    /// Append `line` to stdin and flush.
237    Write(String, oneshot::Sender<IoAck>),
238    /// Drop stdin so the CLI sees EOF.
239    EndInput(oneshot::Sender<IoAck>),
240}
241
242/// A live `claude` subprocess driven over channels.
243///
244/// Spawned via [`Subprocess::spawn`]. Every read goes through the
245/// reader task → mpsc; every write goes through the writer task ←
246/// mpsc. Drop hits `kill_on_drop` cleanup; [`close`](Self::close)
247/// gives a graceful exit path with a 5s timeout before SIGKILL.
248pub struct Subprocess {
249    /// Outbound channel into the writer task. Cloned by
250    /// [`clone_writer`](Self::clone_writer) so external
251    /// dispatchers can write without contending on `&mut self`.
252    writer_tx: mpsc::UnboundedSender<WriterCmd>,
253    /// How long one write to stdin may take. The shipping bound is
254    /// [`STDIN_WRITE_BOUND`]; a test injects a shorter one so it can watch
255    /// the bound fire without waiting thirty seconds out.
256    ///
257    /// **A caller waits twice this** ([`Subprocess::ack_wait`]), because the
258    /// task's verdict is the authoritative one for a line it dequeued: a
259    /// writer that started a line and timed out reports it before the caller
260    /// gives up, so no line is written after its caller was told it failed.
261    /// A line still queued behind two slow *successful* writes can outlast
262    /// even that wait; its caller is told the wait's own bound, not the
263    /// writer's.
264    write_bound: std::time::Duration,
265    /// Inbound channel from the reader task. Single-consumer.
266    reader_rx: mpsc::UnboundedReceiver<Result<Option<String>, Error>>,
267    /// Writer task handle. [`close`](Self::close) aborts this so the
268    /// child sees stdin EOF promptly even when external writer clones
269    /// still hold `writer_tx` clones - without the abort the writer
270    /// task would wait for every clone to drop and the child wouldn't
271    /// see EOF on stdin until then.
272    writer_task: Option<JoinHandle<()>>,
273    /// Stderr drain task - best-effort logged. Joined during
274    /// [`close`](Self::close), which takes its returned tail for
275    /// [`Error::Process`].
276    stderr_task: Option<JoinHandle<String>>,
277    /// The child handle. [`close`](Self::close) waits on it (with
278    /// timeout) and SIGKILLs on hang.
279    child: Option<Child>,
280    /// The child's process group, when this transport put it in one of
281    /// its own. `None` means no group was claimed and teardown must not
282    /// signal one.
283    pgid: Option<u32>,
284    /// The claude session this child carries, as the container the reader
285    /// and the dispatcher fill. **Shared, not copied**: `/new` swaps the
286    /// occupant without swapping the process, so a label read at spawn names
287    /// a session the record is not about - and the long-lived session is
288    /// exactly where a stall happens.
289    session: Arc<parking_lot::RwLock<String>>,
290    /// Idempotency guard for [`close`](Self::close).
291    closed: bool,
292}
293
294impl std::fmt::Debug for Subprocess {
295    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
296        f.debug_struct("Subprocess")
297            .field("child", &self.child.is_some())
298            .field("closed", &self.closed)
299            .finish_non_exhaustive()
300    }
301}
302
303impl Subprocess {
304    /// Spawn the `claude` binary with stream-json flags.
305    ///
306    /// # Errors
307    ///
308    /// - [`Error::CliNotFound`] when the binary isn't on PATH or the given path
309    ///   doesn't exist.
310    /// - [`Error::CwdNotFound`] when `cwd` names a directory that isn't there.
311    /// - [`Error::Io`] for other spawn failures.
312    pub async fn spawn(options: &Options) -> Result<Self, Error> {
313        // Optional CLI-version guard. Runs `<binary> --version` once.
314        // The caller asked for a floor - if the probe fails, surface it
315        // rather than silently skipping (they won't know the check was
316        // bypassed otherwise).
317        if let Some(min) = &options.minimum_cli_version {
318            // `claude --version` is a fork+exec; wrap in spawn_blocking
319            // so the tokio worker thread isn't parked during the probe.
320            let binary = options.binary.clone();
321            let reported = tokio::task::spawn_blocking(move || query_cli_version(&binary))
322                .await
323                .map_err(|e| Error::Connection {
324                    reason: format!("version probe join failed: {e}"),
325                })?
326                .map_err(|e| Error::Connection {
327                    reason: format!("minimum_cli_version set but --version probe failed: {e}"),
328                })?;
329            check_cli_version(&reported, min)?;
330        }
331        let args = build_args(options)?;
332        // Seeded with what the caller knows now; the reader and the
333        // dispatcher keep writing it as the occupant changes.
334        let session = Arc::new(parking_lot::RwLock::new(
335            options.session_id.clone().or_else(|| options.resume.clone()).unwrap_or_default(),
336        ));
337        let mut cmd = Command::new(&options.binary);
338        cmd.args(&args);
339        if let Some(cwd) = &options.cwd {
340            cmd.current_dir(cwd);
341        }
342
343        // Env setup. Order matters because later writes win:
344        //
345        // 1. Start from parent env, filtering out `CLAUDECODE` so SDK-
346        //    spawned subprocesses don't think they're nested inside a
347        //    Claude Code parent (upstream issue #573).
348        // 2. Clear any inherited `CLAUDE_CODE_ENTRYPOINT` so the child
349        //    self-classifies instead of reporting a stamp leaked from
350        //    the parent shell (e.g. running forge inside a Claude Code
351        //    session that itself set `sdk-rs`). Callers that genuinely
352        //    want to preset the entrypoint can still do so via
353        //    `options.env` - those writes happen after this removal.
354        // 3. Let `options.env` override anything above, EXCEPT
355        //    `CLAUDE_AGENT_SDK_VERSION` - that one we always stamp last.
356        // 4. Stamp `CLAUDE_AGENT_SDK_VERSION` as the final write.
357        // 5. Set `PWD` to the chosen cwd when present.
358        cmd.env_remove("CLAUDECODE");
359        cmd.env_remove("CLAUDE_CODE_ENTRYPOINT");
360        for (k, v) in &options.env {
361            cmd.env(k, v);
362        }
363        cmd.env("CLAUDE_AGENT_SDK_VERSION", env!("CARGO_PKG_VERSION"));
364        if let Some(cwd) = &options.cwd {
365            cmd.env("PWD", cwd);
366        }
367
368        cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
369        cmd.kill_on_drop(true);
370        // Its own process group, so teardown can take down whatever the
371        // session started: a descendant otherwise outlives the child's
372        // death.
373        #[cfg(unix)]
374        cmd.process_group(0);
375
376        let (flags, flags_dropped) = flag_names(&args, &options.extra_args);
377        debug!(
378            binary = %options.binary,
379            cwd = ?options.cwd,
380            %flags,
381            flags_dropped,
382            extra_args_len = options.extra_args.len(),
383            "spawning claude subprocess"
384        );
385        let mut child = cmd.spawn().map_err(|e| spawn_error(e, options))?;
386        // `process_group(0)` makes the child its own group leader, so the
387        // group id is its pid. Kept rather than re-read in `close`: a
388        // completed wait reaps the child and drops the id.
389        #[cfg(unix)]
390        let pgid = child.id();
391        #[cfg(not(unix))]
392        let pgid: Option<u32> = None;
393
394        let stdin = child
395            .stdin
396            .take()
397            .ok_or_else(|| Error::Connection { reason: "stdin pipe missing".into() })?;
398        let stdout = child
399            .stdout
400            .take()
401            .ok_or_else(|| Error::Connection { reason: "stdout pipe missing".into() })?;
402        let stderr = child
403            .stderr
404            .take()
405            .ok_or_else(|| Error::Connection { reason: "stderr pipe missing".into() })?;
406
407        let stderr_task = tokio::spawn(tracing::Instrument::instrument(
408            drain_stderr(stderr),
409            tracing::info_span!("forge_sdk::stderr_reader"),
410        ));
411
412        let (reader_tx, reader_rx) = mpsc::unbounded_channel();
413        spawn_reader_task(stdout, options.tee_inbound.clone(), reader_tx);
414
415        let (writer_tx, writer_rx) = mpsc::unbounded_channel();
416        let writer_task = spawn_writer_task(
417            stdin,
418            options.tee_outbound.clone(),
419            writer_rx,
420            STDIN_WRITE_BOUND,
421            session.clone(),
422        );
423
424        Ok(Self {
425            writer_tx,
426            write_bound: STDIN_WRITE_BOUND,
427            reader_rx,
428            writer_task: Some(writer_task),
429            stderr_task: Some(stderr_task),
430            child: Some(child),
431            pgid,
432            session,
433            closed: false,
434        })
435    }
436
437    /// Read one line (without the trailing `\n`) from the subprocess stdout.
438    ///
439    /// Operating-system PID of the spawned `claude` child, when one is
440    /// still attached. Returns `None` after [`close`](Self::close) or
441    /// when the child has already been reaped.
442    ///
443    /// Used by the Inspector pane's PROCESSES section to anchor an
444    /// OS-level walk of the descendant tree (the `forge-agent`
445    /// crate's `env::processes` scanner - not linked here because the
446    /// dependency direction forbids forge-sdk from naming forge-agent
447    /// types). The PID is stable for the lifetime of the subprocess
448    /// so the scanner can cache its snapshot across polls keyed off
449    /// this value.
450    pub fn child_pid(&self) -> Option<u32> {
451        self.child.as_ref().and_then(tokio::process::Child::id)
452    }
453
454    /// The session this child carries, as the container the reader and the
455    /// dispatcher fill: a consumer that records a problem about this child
456    /// reads it at the failure rather than at the spawn, because `/new` swaps
457    /// the occupant.
458    pub(crate) fn session_id(&self) -> Arc<parking_lot::RwLock<String>> {
459        Arc::clone(&self.session)
460    }
461
462    /// Returns `Ok(None)` at end-of-stream. Cancel-safe.
463    ///
464    /// # Errors
465    ///
466    /// [`Error::Io`] on read failure.
467    pub async fn read_line(&mut self) -> Result<Option<String>, Error> {
468        // Reader task signals EOF by either sending `Ok(None)` or by
469        // closing the channel - both collapse to `Ok(None)` here.
470        self.reader_rx.recv().await.unwrap_or(Ok(None))
471    }
472
473    /// How long a caller waits for a write's ack: **twice the writer's own
474    /// bound**, so the task's verdict lands first for a line it dequeued and
475    /// no line is written after its caller was told it failed.
476    fn ack_wait(&self) -> std::time::Duration {
477        self.write_bound * 2
478    }
479
480    /// Write one line of stream-json to the subprocess stdin.
481    ///
482    /// The caller is responsible for including the trailing `\n` - this
483    /// matches the contract of
484    /// [`codec::encode_user_prompt`](super::codec::encode_user_prompt).
485    ///
486    /// # Errors
487    ///
488    /// [`Error::Io`] on write failure, including writes issued after
489    /// [`end_input`](Self::end_input) or [`close`](Self::close).
490    pub async fn write_line(&mut self, line: &str) -> Result<(), Error> {
491        let (ack_tx, ack_rx) = oneshot::channel();
492        self.writer_tx.send(WriterCmd::Write(line.to_owned(), ack_tx)).map_err(|_| {
493            Error::Io(std::io::Error::new(
494                std::io::ErrorKind::BrokenPipe,
495                "Subprocess writer task gone",
496            ))
497        })?;
498        match tokio::time::timeout(self.ack_wait(), ack_rx).await {
499            Ok(Ok(result)) => result,
500            Ok(Err(_)) => Err(Error::Io(std::io::Error::new(
501                std::io::ErrorKind::BrokenPipe,
502                "Subprocess writer task dropped ack",
503            ))),
504            Err(_) => {
505                Err(write_stalled(self.ack_wait(), "the caller's wait for a stdin write's ack"))
506            }
507        }
508    }
509
510    /// Close the subprocess's stdin. Safe to call multiple times.
511    /// Subsequent [`write_line`](Self::write_line) calls will fail
512    /// with a `BrokenPipe` `Io` error.
513    ///
514    /// # Errors
515    ///
516    /// [`Error::Io`] on flush failure.
517    pub async fn end_input(&mut self) -> Result<(), Error> {
518        let (ack_tx, ack_rx) = oneshot::channel();
519        // Writer task already gone (e.g. previous end_input + drop) →
520        // treat as no-op, matching the old direct-stdin behaviour.
521        if self.writer_tx.send(WriterCmd::EndInput(ack_tx)).is_err() {
522            return Ok(());
523        }
524        match tokio::time::timeout(self.ack_wait(), ack_rx).await {
525            Ok(Ok(result)) => result,
526            Ok(Err(_)) => {
527                warn!("Subprocess::end_input: ack channel dropped");
528                Ok(())
529            }
530            Err(_) => {
531                Err(write_stalled(self.ack_wait(), "the caller's wait for the end-input ack"))
532            }
533        }
534    }
535
536    /// Hand out a clonable [`SharedWriter`] backed by the same writer
537    /// task. Multiple clones can write concurrently; the writer task
538    /// serialises onto the child's stdin in arrival order. Used by
539    /// detached control-request dispatch so a slow callback can't
540    /// block the reader / command loop.
541    pub(crate) fn clone_writer(&self) -> Arc<SharedWriter> {
542        Arc::new(SharedWriter { writer_tx: self.writer_tx.clone(), bound: self.write_bound })
543    }
544
545    /// Graceful shutdown: close stdin, wait for the subprocess to
546    /// exit (5s timeout, SIGKILL fallback), SIGKILL whatever it left
547    /// in its process group, drain the stderr task. Idempotent -
548    /// subsequent calls are no-ops.
549    ///
550    /// # Errors
551    ///
552    /// [`Error::Process`] when the subprocess exits non-zero or hits
553    /// the SIGKILL fallback; [`Error::Io`] on wait failure.
554    pub async fn close(&mut self) -> Result<(), Error> {
555        if self.closed {
556            return Ok(());
557        }
558        self.closed = true;
559
560        // Drop our held writer_tx and abort the writer task. The abort
561        // forces stdin closure even if external `SharedWriter` clones
562        // (handed out via `clone_writer`) still hold writer_tx
563        // clones - without the abort the writer task would wait for
564        // every clone to drop. In-flight write_line acks on cloned
565        // writers will resolve with the ack-channel-dropped error,
566        // which is correct semantics for a closed transport.
567        let (closed_tx, closed_rx) = mpsc::unbounded_channel();
568        self.writer_tx = closed_tx;
569        // Drop the placeholder receiver immediately so any concurrent
570        // SharedWriter clone trying to send on `writer_tx` fails fast
571        // with BrokenPipe rather than queuing into a dead channel and
572        // hanging for the duration of the close grace period.
573        drop(closed_rx);
574        if let Some(handle) = self.writer_task.take() {
575            handle.abort();
576            // Symmetric drain with the stderr task below. abort() then
577            // await - the future returns JoinError::Cancelled which is
578            // expected; surface panic JoinErrors at debug so the
579            // tokio default panic handler isn't the only path that
580            // notices.
581            if let Err(e) = handle.await
582                && !e.is_cancelled()
583            {
584                debug!(error = %e, "writer task ended abnormally");
585            }
586        }
587
588        // Wait for child exit, with a SIGKILL timeout so a stuck CLI
589        // doesn't pin the disconnect path forever.
590        if let Some(mut child) = self.child.take() {
591            let waited = tokio::time::timeout(CLOSE_WAIT_TIMEOUT, child.wait()).await;
592            // Whatever the child left running in its group goes down with
593            // it: a descendant that survived the child is not the
594            // session's to leave behind. An empty group is the ordinary
595            // answer and stays quiet; anything else leaves processes
596            // forge meant to kill, so it is forge's own problem.
597            if let Some(pgid) = self.pgid
598                && let Some(error) = kill_group(pgid)
599            {
600                warn!(
601                    target: "forge_sdk",
602                    event_name = "subprocess_group_signal_failed",
603                    session = %self.session.read().as_str(),
604                    pgid,
605                    error = %error,
606                    "the session child's process group could not be signalled; \
607                     a descendant may outlive the session"
608                );
609            }
610            if waited.is_err() {
611                warn!("Subprocess::close timed out waiting for child; sending SIGKILL");
612                if let Err(e) = child.kill().await {
613                    warn!(error = %e, "Subprocess::close: kill() failed");
614                }
615            }
616            // Release the child before joining, so `kill_on_drop` still
617            // fires on the wait-failed path.
618            drop(child);
619            // Join before building the error rather than reading a shared
620            // buffer at construction, which would race the last line.
621            let stderr = label_stderr_tail(&self.join_stderr_tail().await);
622            match waited {
623                Ok(Ok(status)) if status.success() => Ok(()),
624                Ok(Ok(status)) => Err(Error::Process { exit_code: status.code(), stderr }),
625                Ok(Err(e)) => Err(Error::Io(e)),
626                Err(_elapsed) => {
627                    let mut reason = String::from("close timeout - child killed");
628                    if !stderr.is_empty() {
629                        reason.push('\n');
630                        reason.push_str(&stderr);
631                    }
632                    Err(Error::Process { exit_code: None, stderr: reason })
633                }
634            }
635        } else {
636            self.join_stderr_tail().await;
637            Ok(())
638        }
639    }
640
641    /// Join the stderr drain task and take its retained tail. Errors
642    /// are best-effort - it's a logging sink, so a dead task costs the
643    /// tail rather than the close.
644    async fn join_stderr_tail(&mut self) -> String {
645        let Some(task) = self.stderr_task.take() else { return String::new() };
646        match task.await {
647            Ok(tail) => tail,
648            Err(e) => {
649                debug!(error = %e, "stderr drain task ended abnormally");
650                String::new()
651            }
652        }
653    }
654}
655
656/// Cloneable writer half of [`Subprocess`] - pushes onto the writer
657/// task's mpsc. Multiple clones can write concurrently; the writer
658/// task serialises onto the child's stdin in arrival order.
659///
660/// Returned by [`Subprocess::clone_writer`]. Used by detached
661/// `control_request` dispatch.
662#[derive(Debug, Clone)]
663pub(crate) struct SharedWriter {
664    writer_tx: mpsc::UnboundedSender<WriterCmd>,
665    /// The writer task's own bound, so this side can derive the wait that
666    /// outlasts it (see [`SharedWriter::ack_wait`]).
667    bound: std::time::Duration,
668}
669
670impl SharedWriter {
671    /// How long a caller waits for a write's ack: **twice the writer's own
672    /// bound**, so the task's verdict lands first for a line it dequeued and
673    /// no line is written after its caller was told it failed.
674    fn ack_wait(&self) -> std::time::Duration {
675        self.bound * 2
676    }
677}
678
679#[cfg(test)]
680impl SharedWriter {
681    /// Test stub: written lines land on the returned receiver; every
682    /// write is acked `Ok`.
683    pub(crate) fn test_stub() -> (Self, mpsc::UnboundedReceiver<String>) {
684        let (writer_tx, mut cmd_rx) = mpsc::unbounded_channel();
685        let (line_tx, line_rx) = mpsc::unbounded_channel();
686        tokio::spawn(async move {
687            while let Some(cmd) = cmd_rx.recv().await {
688                match cmd {
689                    WriterCmd::Write(line, ack) => {
690                        let _ = line_tx.send(line);
691                        let _ = ack.send(Ok(()));
692                    }
693                    WriterCmd::EndInput(ack) => {
694                        let _ = ack.send(Ok(()));
695                    }
696                }
697            }
698        });
699        (Self { writer_tx, bound: STDIN_WRITE_BOUND }, line_rx)
700    }
701}
702
703impl SharedWriter {
704    /// Write one line of stream-json to the transport. Caller supplies
705    /// the trailing `\n`.
706    ///
707    /// # Errors
708    ///
709    /// [`Error::Io`] on write failure or after the transport has
710    /// closed its write half.
711    pub(crate) async fn write_line(&self, line: &str) -> Result<(), Error> {
712        let (ack_tx, ack_rx) = oneshot::channel();
713        self.writer_tx.send(WriterCmd::Write(line.to_owned(), ack_tx)).map_err(|_| {
714            Error::Io(std::io::Error::new(std::io::ErrorKind::BrokenPipe, "SharedWriter task gone"))
715        })?;
716        // **The ack can be as slow as the write ahead of it**, and a writer
717        // parked on a full pipe never sends one: an unbounded wait here hangs
718        // the caller with the task still alive, which is the shape that went
719        // unnoticed for 26.7 minutes.
720        match tokio::time::timeout(self.ack_wait(), ack_rx).await {
721            Ok(Ok(result)) => result,
722            Ok(Err(_)) => Err(Error::Io(std::io::Error::new(
723                std::io::ErrorKind::BrokenPipe,
724                "SharedWriter task dropped ack",
725            ))),
726            Err(_) => {
727                Err(write_stalled(self.ack_wait(), "the caller's wait for a stdin write's ack"))
728            }
729        }
730    }
731
732    /// Close the write half so the remote sees EOF on stdin. Idempotent.
733    ///
734    /// # Errors
735    ///
736    /// [`Error::Io`] on flush failure.
737    pub(crate) async fn end_input(&self) -> Result<(), Error> {
738        let (ack_tx, ack_rx) = oneshot::channel();
739        // Writer task already exited (e.g. previous end_input or close)
740        // → treat as no-op, matching `Subprocess::end_input`.
741        if self.writer_tx.send(WriterCmd::EndInput(ack_tx)).is_err() {
742            return Ok(());
743        }
744        // The same bound as a write: a command queued behind a stalled write
745        // is an ack that never comes.
746        match tokio::time::timeout(self.ack_wait(), ack_rx).await {
747            Ok(Ok(result)) => result,
748            Ok(Err(_)) => {
749                warn!("SharedWriter::end_input: ack channel dropped");
750                Ok(())
751            }
752            Err(_) => {
753                Err(write_stalled(self.ack_wait(), "the caller's wait for the end-input ack"))
754            }
755        }
756    }
757}
758
759fn spawn_reader_task(
760    stdout: ChildStdout,
761    tee: Option<WireTee>,
762    tx: mpsc::UnboundedSender<Result<Option<String>, Error>>,
763) {
764    use tracing::Instrument;
765    let span = tracing::info_span!("forge_sdk::stdout_reader");
766    tokio::spawn(
767        async move {
768            let mut reader = BufReader::new(stdout);
769            let mut buf = String::new();
770            loop {
771                buf.clear();
772                match reader.read_line(&mut buf).await {
773                    Ok(0) => {
774                        // EOF - signal end-of-stream and exit.
775                        let _ = tx.send(Ok(None));
776                        break;
777                    }
778                    Ok(_) => {
779                        let mut line = std::mem::take(&mut buf);
780                        while matches!(line.chars().last(), Some('\n' | '\r')) {
781                            line.pop();
782                        }
783                        if let Some(cb) = tee.as_ref() {
784                            cb(&line);
785                        }
786                        if tx.send(Ok(Some(line))).is_err() {
787                            // Receiver gone - caller dropped the transport.
788                            break;
789                        }
790                    }
791                    Err(e) => {
792                        if tx.send(Err(Error::Io(e))).is_err() {
793                            tracing::warn!(
794                                target: "forge_sdk::transport",
795                                "subprocess stdout I/O error after caller dropped reader"
796                            );
797                        }
798                        break;
799                    }
800                }
801            }
802        }
803        .instrument(span),
804    );
805}
806
807/// Write one line and flush it, as the writer task's unit of work.
808async fn write_one(stdin: &mut ChildStdin, line: &[u8]) -> std::io::Result<()> {
809    stdin.write_all(line).await?;
810    stdin.flush().await
811}
812
813fn spawn_writer_task(
814    stdin: ChildStdin,
815    tee: Option<WireTee>,
816    mut rx: mpsc::UnboundedReceiver<WriterCmd>,
817    write_bound: std::time::Duration,
818    session: Arc<parking_lot::RwLock<String>>,
819) -> JoinHandle<()> {
820    use tracing::Instrument;
821    let span = tracing::info_span!("forge_sdk::stdin_writer");
822    tokio::spawn(
823        async move {
824            let mut stdin = Some(stdin);
825            // Kept beside the handle so a write after the closure names the
826            // cause. Without it every later failure blamed `end_input`, a call
827            // that never ran.
828            let mut closed: Option<Closed> = None;
829            while let Some(cmd) = rx.recv().await {
830                match cmd {
831                    WriterCmd::Write(line, ack) => {
832                        if let Some(cb) = tee.as_ref() {
833                            cb(line.trim_end_matches('\n'));
834                        }
835                        let result = if let Some(s) = stdin.as_mut() {
836                            match tokio::time::timeout(write_bound, write_one(s, line.as_bytes()))
837                                .await
838                            {
839                                Ok(Ok(())) => Ok(()),
840                                Ok(Err(e)) => {
841                                    closed = Some(Closed::Failed);
842                                    Err(Error::Io(e))
843                                }
844                                Err(_) => {
845                                    // **The one record a stall gets**, and the
846                                    // only one that can name the session: the
847                                    // CLI stopped reading and this task is
848                                    // parked in a write no caller can see.
849                                    tracing::warn!(
850                                        target: "forge_sdk",
851                                        event_name = "stdin_write_stalled",
852                                        session = %session.read().as_str(),
853                                        bound_ms = write_bound.as_millis(),
854                                        "the CLI stopped reading stdin: a write did not complete \
855                                         within the bound, so the stream is closed",
856                                    );
857                                    closed = Some(Closed::Stalled);
858                                    Err(write_stalled(write_bound, "a stdin write"))
859                                }
860                            }
861                        } else {
862                            Err(closed_stdin(closed))
863                        };
864                        if closed.is_some() {
865                            // **A closed stream stays closed.** A write that
866                            // timed out may have left a partial line on the
867                            // wire, and one that failed left a broken pipe;
868                            // either way another line cannot follow. Dropping
869                            // stdin also lets the child see EOF instead of
870                            // waiting for bytes that will not come.
871                            stdin = None;
872                        }
873                        let _ = ack.send(result);
874                    }
875                    WriterCmd::EndInput(ack) => {
876                        // **A stall keeps its reason**: an end-input after a
877                        // stalled write must not relabel the stream as a
878                        // deliberate close.
879                        if closed.is_none() {
880                            closed = Some(Closed::EndInput);
881                        }
882                        drop(stdin.take());
883                        let _ = ack.send(Ok(()));
884                    }
885                }
886            }
887        }
888        .instrument(span),
889    )
890}
891
892/// Prefix the retained tail so it reads as the last thing the CLI
893/// wrote rather than as the cause. Nothing here can tell whether it is
894/// causal: a session whose only stderr was a startup warning attaches
895/// that warning to an exit hours later.
896fn label_stderr_tail(tail: &str) -> String {
897    if tail.is_empty() { String::new() } else { format!("last stderr from the CLI: {tail}") }
898}
899
900/// Upper bound on the drained stderr retained for [`Error::Process`].
901/// Large enough for a CLI failure message and its context, small
902/// enough that a chatty child can't pin unbounded memory for the life
903/// of the process.
904const STDERR_TAIL_CAP: usize = 8 * 1024;
905
906/// The most recent drained stderr lines, capped at [`STDERR_TAIL_CAP`]
907/// bytes. Oldest lines are dropped first: a failing CLI puts its
908/// reason last.
909#[derive(Default)]
910struct StderrTail {
911    lines: std::collections::VecDeque<String>,
912    bytes: usize,
913}
914
915impl StderrTail {
916    fn push(&mut self, line: &str) {
917        // Keep the end of an over-cap line, for the same reason we evict
918        // oldest-first.
919        let mut start = line.len().saturating_sub(STDERR_TAIL_CAP);
920        while !line.is_char_boundary(start) {
921            start += 1;
922        }
923        let kept = &line[start..];
924        self.bytes += kept.len() + 1;
925        self.lines.push_back(kept.to_owned());
926        // Never evict the newest line: on a one-line overflow it is the
927        // whole message.
928        while self.bytes > STDERR_TAIL_CAP && self.lines.len() > 1 {
929            let Some(dropped) = self.lines.pop_front() else { break };
930            self.bytes -= dropped.len() + 1;
931        }
932    }
933
934    fn into_string(self) -> String {
935        Vec::from(self.lines).join("\n")
936    }
937}
938
939/// SIGKILL every process left in `pgid`, reporting a failure a caller
940/// should tell someone about.
941///
942/// `None` covers the ordinary answers: the signal landed, or the group
943/// was already empty (`ESRCH`), which is what a clean exit leaves.
944#[cfg(unix)]
945pub(crate) fn kill_group(pgid: u32) -> Option<std::io::Error> {
946    use rustix::io::Errno;
947    use rustix::process::{Pid, Signal, kill_process_group};
948
949    let Ok(raw) = i32::try_from(pgid) else {
950        debug!(pgid, "process group id does not fit in i32; nothing signalled");
951        return None;
952    };
953    let Some(pgid) = Pid::from_raw(raw) else {
954        debug!(raw, "process group id names no process; nothing signalled");
955        return None;
956    };
957    match kill_process_group(pgid, Signal::KILL) {
958        Ok(()) | Err(Errno::SRCH) => None,
959        Err(error) => Some(std::io::Error::from_raw_os_error(error.raw_os_error())),
960    }
961}
962
963#[cfg(not(unix))]
964fn kill_group(_pgid: u32) -> Option<std::io::Error> {
965    None
966}
967
968/// Background drain for the subprocess stderr pipe. Reads lines as UTF-8
969/// (lossy on invalid bytes) and logs each, so the pipe never blocks.
970///
971/// Returns the tail of what it drained, which [`Subprocess::close`]
972/// attaches to [`Error::Process`] on a non-zero exit.
973async fn drain_stderr(stderr: ChildStderr) -> String {
974    let mut reader = BufReader::new(stderr);
975    let mut raw = Vec::new();
976    let mut tail = StderrTail::default();
977    loop {
978        raw.clear();
979        // Bytes, not `read_line`: that returns InvalidData on non-UTF-8
980        // and would end the drain at the first bad byte, losing every
981        // line after it.
982        let n = match reader.read_until(b'\n', &mut raw).await {
983            Ok(n) => n,
984            Err(e) => {
985                debug!(?e, "stderr read failed");
986                break;
987            }
988        };
989        if n == 0 {
990            break;
991        }
992        let mut line = String::from_utf8_lossy(&raw).into_owned();
993        while matches!(line.chars().last(), Some('\n' | '\r')) {
994            line.pop();
995        }
996        tail.push(&line);
997        if line.starts_with("ERROR") || line.starts_with("Error") {
998            tracing::warn!(target: "forge_sdk::stderr", line = %line, "claude stderr");
999        } else {
1000            tracing::info!(target: "forge_sdk::stderr", line = %line, "claude stderr");
1001        }
1002    }
1003    tail.into_string()
1004}
1005
1006#[cfg(test)]
1007mod tests {
1008
1009    use super::*;
1010    use std::time::Duration;
1011
1012    /// Every field of every record, formatted, in one buffer.
1013    #[derive(Clone, Default)]
1014    struct FieldCapture(Arc<std::sync::Mutex<String>>);
1015
1016    impl FieldCapture {
1017        fn text(&self) -> String {
1018            self.0.lock().expect("capture").clone()
1019        }
1020    }
1021
1022    struct AllFields<'a>(&'a mut String);
1023
1024    impl tracing::field::Visit for AllFields<'_> {
1025        // `record_str` forwards here, so every field arrives through
1026        // this one arm however the macro wrote it.
1027        fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
1028            use std::fmt::Write;
1029            let _ = write!(self.0, " {}={value:?}", field.name());
1030        }
1031    }
1032
1033    impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> for FieldCapture {
1034        fn on_event(
1035            &self,
1036            event: &tracing::Event<'_>,
1037            _ctx: tracing_subscriber::layer::Context<'_, S>,
1038        ) {
1039            let mut buf = self.0.lock().expect("capture");
1040            buf.push_str(event.metadata().target());
1041            event.record(&mut AllFields(&mut buf));
1042            buf.push('\n');
1043        }
1044    }
1045
1046    /// An `Options` carrying a sentinel on every surface that reaches
1047    /// argv or the child env with a value: the env map, all three
1048    /// external MCP transports, `extra_args`, and the system prompt.
1049    /// `Preset { append }` is the shape forge builds.
1050    fn options_with_secrets() -> Options {
1051        let mut options = crate::OptionsBuilder::new()
1052            // Never spawns. The record under test is written before
1053            // `cmd.spawn()`, so a missing binary still exercises it.
1054            .binary("/nonexistent/claude-for-this-test")
1055            .model("claude-test")
1056            .resume("session-abc")
1057            .system_prompt(crate::SystemPromptKind::preset_append(
1058                "sentinel-append-prompt-must-never-be-logged",
1059            ))
1060            .env("ANTHROPIC_AUTH_TOKEN", "sentinel-auth-must-never-be-logged")
1061            .env("AIRMAIL_TOKEN", "sentinel-airmail-must-never-be-logged")
1062            .extra_arg("some-flag", Some("sentinel-extra-arg-must-never-be-logged".to_owned()))
1063            // A value shaped like a flag. Anything keying on a `--`
1064            // prefix rather than on a known name renders this one.
1065            .extra_arg("odd-flag", Some("--sentinel-dashed-must-never-be-logged".to_owned()))
1066            .build();
1067        // The probe is a separate fork+exec that would fail first and
1068        // never reach the record under test.
1069        options.minimum_cli_version = None;
1070        options.external_mcp_servers.insert(
1071            "billing-http".to_owned(),
1072            forge_primitives::McpServerConfig::Http {
1073                url: "https://mcp.example.com".to_owned(),
1074                headers: std::collections::HashMap::from([(
1075                    "Authorization".to_owned(),
1076                    "Bearer sentinel-http-header-must-never-be-logged".to_owned(),
1077                )]),
1078            },
1079        );
1080        options.external_mcp_servers.insert(
1081            "billing-sse".to_owned(),
1082            forge_primitives::McpServerConfig::Sse {
1083                url: "https://sse.example.com".to_owned(),
1084                headers: std::collections::HashMap::from([(
1085                    "Authorization".to_owned(),
1086                    "Bearer sentinel-sse-header-must-never-be-logged".to_owned(),
1087                )]),
1088            },
1089        );
1090        options.external_mcp_servers.insert(
1091            "billing-stdio".to_owned(),
1092            forge_primitives::McpServerConfig::Stdio {
1093                command: "/bin/true".to_owned(),
1094                args: Vec::new(),
1095                env: std::collections::HashMap::from([(
1096                    "STDIO_MCP_TOKEN".to_owned(),
1097                    "sentinel-stdio-env-must-never-be-logged".to_owned(),
1098                )]),
1099            },
1100        );
1101        options
1102    }
1103
1104    /// Every declared secret, read back off the `Options` rather than
1105    /// restated, so a sentinel added to the fixture is asserted
1106    /// without touching the assertion.
1107    fn declared_secrets(options: &Options) -> Vec<String> {
1108        let mut secrets: Vec<String> = options.env.values().cloned().collect();
1109        secrets.extend(options.extra_args.values().flatten().cloned());
1110        for cfg in options.external_mcp_servers.values() {
1111            match cfg {
1112                forge_primitives::McpServerConfig::Stdio { env, .. } => {
1113                    secrets.extend(env.values().cloned());
1114                }
1115                forge_primitives::McpServerConfig::Sse { headers, .. }
1116                | forge_primitives::McpServerConfig::Http { headers, .. } => {
1117                    secrets.extend(headers.values().cloned());
1118                }
1119            }
1120        }
1121        if let Some(crate::SystemPromptKind::Preset { append: Some(text), .. }) =
1122            &options.system_prompt
1123        {
1124            secrets.push(text.clone());
1125        }
1126        secrets
1127    }
1128
1129    /// A spawn that cannot enter its `cwd` and one that cannot find the
1130    /// binary both surface as `ENOENT`, so the error has to say which.
1131    /// The mirror case pins the other half: a genuinely missing binary
1132    /// still reports as one, so the cwd arm cannot swallow it.
1133    #[tokio::test]
1134    async fn spawn_names_a_missing_cwd_apart_from_a_missing_binary() {
1135        let missing_cwd = std::env::temp_dir().join("forge-sdk-no-such-working-dir");
1136        assert!(!missing_cwd.exists(), "fixture precondition: {missing_cwd:?} must not exist");
1137        let missing_cwd = missing_cwd.to_string_lossy().into_owned();
1138        let options = Options {
1139            binary: "claude".to_owned(),
1140            cwd: Some(missing_cwd.clone().into()),
1141            minimum_cli_version: None,
1142            ..Options::default()
1143        };
1144        let err = Subprocess::spawn(&options).await.expect_err("a missing cwd must fail the spawn");
1145        assert!(
1146            matches!(&err, Error::CwdNotFound { path } if *path == missing_cwd),
1147            "a spawn that could not enter its working directory reported {err:?} rather than \
1148             naming that directory"
1149        );
1150
1151        let options = Options {
1152            binary: "forge-no-such-binary-on-path".to_owned(),
1153            cwd: Some(std::env::temp_dir()),
1154            minimum_cli_version: None,
1155            ..Options::default()
1156        };
1157        let err =
1158            Subprocess::spawn(&options).await.expect_err("a missing binary must fail the spawn");
1159        assert!(
1160            matches!(err, Error::CliNotFound { .. }),
1161            "a binary that is not on PATH reported {err:?} rather than CliNotFound"
1162        );
1163    }
1164
1165    /// The spawn record must not render a credential. Two carriers
1166    /// reach it independently: the child environment, and argv.
1167    #[test]
1168    fn spawn_record_renders_no_declared_secret() {
1169        use tracing_subscriber::layer::SubscriberExt;
1170
1171        let options = options_with_secrets();
1172        let capture = FieldCapture::default();
1173        let subscriber = tracing_subscriber::registry()
1174            .with(capture.clone())
1175            .with(tracing_subscriber::filter::LevelFilter::TRACE);
1176        let result = tracing::subscriber::with_default(subscriber, || {
1177            tokio::runtime::Builder::new_current_thread()
1178                .enable_all()
1179                .build()
1180                .expect("runtime")
1181                .block_on(Subprocess::spawn(&options))
1182        });
1183        assert!(
1184            matches!(result, Err(Error::CliNotFound { .. })),
1185            "fixture binary must not exist, got {:?}",
1186            result.as_ref().err()
1187        );
1188
1189        let logged = capture.text();
1190        assert!(
1191            logged.contains("spawning claude subprocess"),
1192            "the record under test never fired, so nothing was proved: {logged:?}",
1193        );
1194
1195        // Without a floor the loop below is vacuous: emptying the
1196        // fixture leaves zero secrets and a green test.
1197        let secrets = declared_secrets(&options);
1198        assert_eq!(secrets.len(), 8, "fixture lost a sentinel: {secrets:?}");
1199
1200        let argv = build_args(&options).expect("fixture argv").join("\u{1}");
1201        for secret in &secrets {
1202            // A sentinel reaching neither argv nor the child env would
1203            // prove nothing by being absent from the record.
1204            assert!(
1205                argv.contains(secret) || options.env.values().any(|v| v == secret),
1206                "sentinel {secret:?} reaches neither argv nor the child env",
1207            );
1208            // `--sentinel-…` renders with its dashes stripped, so
1209            // searching for the raw value alone would miss the leak.
1210            // Pairs with the dashed `extra_args` sentinel: drop either and prefix-widening
1211            // stops being covered while this test stays green.
1212            let bare = secret.trim_start_matches('-');
1213            assert!(
1214                !logged.contains(secret) && !logged.contains(bare),
1215                "spawn record leaked {secret:?}: {logged:?}",
1216            );
1217        }
1218    }
1219
1220    fn spawn_sleep_subprocess(secs: u64) -> Subprocess {
1221        spawn_sleep_subprocess_with_bound(secs, STDIN_WRITE_BOUND)
1222    }
1223
1224    fn spawn_sleep_subprocess_with_bound(secs: u64, write_bound: Duration) -> Subprocess {
1225        let mut cmd = Command::new("/bin/sleep");
1226        cmd.arg(secs.to_string());
1227        spawn_test_subprocess_with_bound(cmd, write_bound)
1228    }
1229
1230    fn spawn_test_subprocess(cmd: Command) -> Subprocess {
1231        spawn_test_subprocess_with_bound(cmd, STDIN_WRITE_BOUND)
1232    }
1233
1234    fn spawn_test_subprocess_with_bound(mut cmd: Command, write_bound: Duration) -> Subprocess {
1235        cmd.stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
1236        cmd.kill_on_drop(true);
1237        let mut child = cmd.spawn().expect("spawn test child");
1238        let stdin = child.stdin.take().expect("stdin");
1239        let stdout = child.stdout.take().expect("stdout");
1240        let stderr = child.stderr.take().expect("stderr");
1241
1242        let stderr_task = tokio::spawn(drain_stderr(stderr));
1243        let (reader_tx, reader_rx) = mpsc::unbounded_channel();
1244        spawn_reader_task(stdout, None, reader_tx);
1245        let (writer_tx, writer_rx) = mpsc::unbounded_channel();
1246        let session = Arc::new(parking_lot::RwLock::new(String::new()));
1247        let writer_task =
1248            spawn_writer_task(stdin, None, writer_rx, write_bound, Arc::clone(&session));
1249
1250        Subprocess {
1251            writer_tx,
1252            write_bound,
1253            reader_rx,
1254            writer_task: Some(writer_task),
1255            stderr_task: Some(stderr_task),
1256            child: Some(child),
1257            pgid: None,
1258            session,
1259            closed: false,
1260        }
1261    }
1262
1263    /// `close()` must SIGKILL a subprocess that ignores stdin EOF
1264    /// rather than hang on `wait()`. Bound documented at 5s; allow ~1s
1265    /// slack for tokio scheduling.
1266    #[tokio::test]
1267    async fn close_kills_unresponsive_child_within_5s() {
1268        let mut sub = spawn_sleep_subprocess(30);
1269
1270        let start = tokio::time::Instant::now();
1271        let result = sub.close().await;
1272        let elapsed = start.elapsed();
1273
1274        // The documented bound is 5s; widen the assert to 30s for
1275        // CI-load tolerance - flagging a one-time-blip scheduler
1276        // delay as a regression would be noise.
1277        assert!(elapsed <= Duration::from_secs(30), "close() took {elapsed:?}, expected <= 30s");
1278        assert!(
1279            elapsed >= Duration::from_secs(5),
1280            "close() returned in {elapsed:?}, expected >= 5s (close timeout fired)"
1281        );
1282        match result {
1283            Err(Error::Process { stderr, .. }) => {
1284                assert!(
1285                    stderr.contains("close timeout"),
1286                    "expected stderr to mention close timeout, got: {stderr}"
1287                );
1288            }
1289            other => panic!("expected Process error from kill, got {other:?}"),
1290        }
1291    }
1292
1293    /// **A child that stops reading stdin must fail the write, not hang it.**
1294    /// The writer task parked in `write_all` on a full pipe, so no ack ever
1295    /// came and every caller behind it waited for ever. The hook callback
1296    /// that writes a control response is one of those callers: the server
1297    /// went silent for 26.7 minutes on 2026-11-09 with no error and no reap.
1298    ///
1299    /// The clock is paused so the wait is measured, not raced: the writer's
1300    /// own bound is the only timer that can fire, so the verdict is the
1301    /// writer's and the elapsed time is exact.
1302    #[tokio::test(start_paused = true)]
1303    async fn a_child_that_stops_reading_stdin_fails_the_write_within_the_bound() {
1304        // `sleep` inherits the pipe and never reads it, so the buffer fills
1305        // and the next write has nowhere to go.
1306        let sub = spawn_sleep_subprocess_with_bound(30, Duration::from_millis(300));
1307        let writer = sub.clone_writer();
1308        // Past any pipe buffer, so `write_all` cannot finish in one go.
1309        let line = format!("{}\n", "x".repeat(1 << 20));
1310
1311        let started = tokio::time::Instant::now();
1312        let outcome = tokio::time::timeout(Duration::from_secs(5), writer.write_line(&line))
1313            .await
1314            .expect("the write did not fail within its bound");
1315        let waited = started.elapsed();
1316        let err = outcome.expect_err("a child that stopped reading stdin must fail the write");
1317        let Error::Io(io) = err else { panic!("expected an I/O error, got {err:?}") };
1318        assert_eq!(io.kind(), std::io::ErrorKind::TimedOut, "the bound is what failed it: {io}");
1319        assert!(
1320            waited >= Duration::from_millis(300),
1321            "the writer's own bound is what fired: {waited:?}"
1322        );
1323        // **The writer's verdict, not the caller's.** With the write's bound
1324        // widened, the caller's ack wait is what answers, and its message
1325        // names the wait instead: this assertion is what tells them apart.
1326        assert!(
1327            io.to_string().contains("a stdin write did not complete"),
1328            "the writer's own failure must be the answer, not the caller's wait: {io}"
1329        );
1330
1331        // **The writer task itself gave up, not only the caller.** A partial
1332        // line may be on the wire, so it dropped stdin: the next write
1333        // answers at once with a broken pipe rather than queueing for ever
1334        // behind the write that never finished.
1335        let again = tokio::time::timeout(Duration::from_secs(5), writer.write_line("more\n"))
1336            .await
1337            .expect("a write after a stalled one must answer, not hang");
1338        let err = again.expect_err("a stalled stream must not accept another line");
1339        let Error::Io(io) = err else { panic!("expected an I/O error, got {err:?}") };
1340        assert_eq!(io.kind(), std::io::ErrorKind::BrokenPipe, "the task did not drop stdin: {io}");
1341        assert!(
1342            io.to_string().contains("outlived its bound"),
1343            "a later write must name the stall, not a call that never ran: {io}"
1344        );
1345    }
1346
1347    /// The other half of the same hole: the writer task is alive (or gone),
1348    /// but the command sits behind a write that never finishes, so its ack
1349    /// never arrives. **All four ack waits are bounded**, and each used to
1350    /// wait for ever: the two writes and the two end-inputs. The paused clock
1351    /// makes the caller's deadline measurable rather than raced.
1352    #[tokio::test(start_paused = true)]
1353    async fn every_wait_on_a_writer_that_never_acks_fails_within_its_bound() {
1354        /// Answers how long the wait took and what it said, so the caller's
1355        /// deadline can be measured rather than assumed.
1356        async fn fails_within<F>(what: &str, fut: F) -> (Duration, String)
1357        where
1358            F: std::future::Future<Output = Result<(), Error>>,
1359        {
1360            let started = tokio::time::Instant::now();
1361            let outcome = tokio::time::timeout(Duration::from_secs(5), fut).await;
1362            let outcome =
1363                outcome.unwrap_or_else(|_| panic!("{what} did not fail within its bound"));
1364            let waited = started.elapsed();
1365            let err = match outcome {
1366                Ok(()) => panic!("{what} answered Ok from a writer that never acks"),
1367                Err(err) => err,
1368            };
1369            let Error::Io(io) = err else { panic!("{what}: expected an I/O error, got {err:?}") };
1370            assert_eq!(io.kind(), std::io::ErrorKind::TimedOut, "{what}: {io}");
1371            (waited, io.to_string())
1372        }
1373
1374        // A writer queue nothing drains: the command is accepted, and the ack
1375        // it would answer with never comes.
1376        let (writer_tx, queue) = mpsc::unbounded_channel();
1377        let bound = Duration::from_millis(100);
1378        let (_reader_tx, reader_rx) = mpsc::unbounded_channel();
1379        let mut subprocess = Subprocess {
1380            writer_tx: writer_tx.clone(),
1381            write_bound: bound,
1382            reader_rx,
1383            writer_task: None,
1384            stderr_task: None,
1385            child: None,
1386            pgid: None,
1387            session: Arc::new(parking_lot::RwLock::new(String::new())),
1388            closed: false,
1389        };
1390        let shared = SharedWriter { writer_tx, bound };
1391
1392        // **The caller waits twice the writer's bound**, measured: at the
1393        // writer's own bound it would have given up first, losing the task's
1394        // verdict - and the record would name a wait it did not take.
1395        let (waited, why) =
1396            fails_within("Subprocess::write_line", subprocess.write_line("line\n")).await;
1397        assert!(waited >= bound * 2, "a caller waits twice the writer's bound: {waited:?}");
1398        assert!(
1399            why.contains(&format!("{:?}", bound * 2)),
1400            "the record names the wait it fired at: {why}"
1401        );
1402
1403        fails_within("Subprocess::end_input", subprocess.end_input()).await;
1404        fails_within("SharedWriter::write_line", shared.write_line("line\n")).await;
1405        fails_within("SharedWriter::end_input", shared.end_input()).await;
1406        drop(queue);
1407    }
1408
1409    /// **Each close reason names itself**, and none reads as a deliberate
1410    /// close: a later write must never blame a call that did not run. A typo
1411    /// in either string would otherwise ship with every other test green.
1412    #[test]
1413    fn a_closed_stream_names_why_it_closed() {
1414        for (reason, needle) in [
1415            (Closed::EndInput, "end_input"),
1416            (Closed::Stalled, "outlived its bound"),
1417            (Closed::Failed, "a write failed"),
1418        ] {
1419            let Error::Io(io) = closed_stdin(Some(reason)) else {
1420                panic!("expected an I/O error for {reason:?}")
1421            };
1422            assert_eq!(io.kind(), std::io::ErrorKind::BrokenPipe, "{reason:?}: {io}");
1423            assert!(io.to_string().contains(needle), "{reason:?}: {io}");
1424        }
1425        let Error::Io(io) = closed_stdin(None) else { panic!("expected an I/O error") };
1426        assert!(io.to_string().contains("already closed"), "{io}");
1427    }
1428
1429    /// **A stall names the session the process carries now**, not the one it
1430    /// was spawned with: `/new` swaps the occupant under a long-lived child,
1431    /// and that is exactly the session a freeze happens in. The record also
1432    /// carries the bound it fired at.
1433    ///
1434    /// Synchronous like the spawn-record test: it builds its own runtime, so
1435    /// the capture subscriber is the thread's default for the whole run.
1436    #[test]
1437    fn a_stdin_stall_records_the_current_session_and_its_bound() {
1438        use tracing_subscriber::layer::SubscriberExt;
1439
1440        let capture = FieldCapture::default();
1441        let subscriber = tracing_subscriber::registry()
1442            .with(capture.clone())
1443            .with(tracing_subscriber::filter::LevelFilter::TRACE);
1444        tracing::subscriber::with_default(subscriber, || {
1445            tokio::runtime::Builder::new_current_thread()
1446                .enable_all()
1447                .build()
1448                .expect("runtime")
1449                .block_on(async {
1450                    let sub = spawn_sleep_subprocess_with_bound(30, Duration::from_millis(100));
1451                    // The session this child carries *after* a `/new`.
1452                    *sub.session_id().write() = "session-after-new".to_owned();
1453                    let writer = sub.clone_writer();
1454                    let line = format!("{}\n", "x".repeat(1 << 20));
1455                    let _ = tokio::time::timeout(Duration::from_secs(5), writer.write_line(&line))
1456                        .await;
1457                });
1458        });
1459
1460        let logged = capture.text();
1461        assert!(
1462            logged.contains("stdin_write_stalled"),
1463            "the stall's record never fired, so nothing was proved: {logged:?}",
1464        );
1465        assert!(
1466            logged.contains("session-after-new"),
1467            "the record must name the session the process carries now: {logged:?}",
1468        );
1469        assert!(
1470            logged.contains("bound_ms=100"),
1471            "the record must carry the bound it fired at: {logged:?}"
1472        );
1473    }
1474
1475    /// An empty group answers `ESRCH`, and that is not forge's problem:
1476    /// mapping it to a report would warn on every clean exit.
1477    #[cfg(unix)]
1478    #[test]
1479    fn kill_group_treats_an_empty_group_as_nothing_to_report() {
1480        // Above any kernel's pid ceiling but inside `i32`, so the call
1481        // reaches the kernel and fails there.
1482        assert!(kill_group(2_000_000_000).is_none(), "an empty group is not a failure");
1483    }
1484
1485    /// A signal that lands is no failure either, and it reaches the whole
1486    /// group the child leads.
1487    #[cfg(unix)]
1488    #[tokio::test]
1489    async fn kill_group_signals_a_live_group_without_reporting() {
1490        let mut command = Command::new("/bin/sleep");
1491        command.arg("30").process_group(0);
1492        let mut child = command.spawn().expect("a child in its own group");
1493        let pid = child.id().expect("a live child has a pid");
1494
1495        assert!(kill_group(pid).is_none(), "a landed group kill is not a failure");
1496
1497        let status = child.wait().await.expect("the child is reaped");
1498        assert!(!status.success(), "the group signal killed the child: {status}");
1499    }
1500
1501    #[test]
1502    fn stderr_tail_keeps_the_last_lines_within_cap() {
1503        let mut tail = StderrTail::default();
1504        for i in 0..20_000 {
1505            tail.push(&format!("line {i}"));
1506        }
1507        let out = tail.into_string();
1508        assert!(out.len() <= STDERR_TAIL_CAP, "tail grew to {} bytes", out.len());
1509        assert!(out.contains("line 19999"), "tail dropped the newest line");
1510        assert!(!out.contains("line 0\n"), "tail kept the oldest line");
1511
1512        let mut tail = StderrTail::default();
1513        tail.push(&format!("{}REASON_AT_END", "x".repeat(STDERR_TAIL_CAP * 2)));
1514        let out = tail.into_string();
1515        assert!(out.len() <= STDERR_TAIL_CAP, "tail grew to {} bytes", out.len());
1516        assert!(!out.is_empty(), "an oversized sole line was evicted to nothing");
1517        assert!(out.ends_with("REASON_AT_END"), "kept the head of an over-cap line, not the end");
1518    }
1519
1520    #[tokio::test]
1521    async fn close_reports_drained_stderr_on_nonzero_exit() {
1522        let mut cmd = Command::new("/bin/sh");
1523        cmd.arg("-c").arg("echo 'boom: unknown flag --nope' >&2; exit 3");
1524
1525        match spawn_test_subprocess(cmd).close().await {
1526            Err(Error::Process { exit_code, stderr }) => {
1527                assert_eq!(exit_code, Some(3));
1528                assert!(
1529                    stderr.contains("boom: unknown flag --nope"),
1530                    "expected drained stderr in the error, got: {stderr:?}"
1531                );
1532                assert!(
1533                    stderr.starts_with("last stderr from the CLI:"),
1534                    "tail must be labelled, not presented as the cause: {stderr:?}"
1535                );
1536            }
1537            other => panic!("expected Process error, got {other:?}"),
1538        }
1539    }
1540
1541    /// stderr is inherited by everything `claude` spawns, so a stray
1542    /// non-UTF-8 byte is realistic. It must not cost the drain the rest
1543    /// of the stream - the reason arrives last, after the bad byte.
1544    #[tokio::test]
1545    async fn close_reports_stderr_past_invalid_utf8() {
1546        let mut cmd = Command::new("/bin/sh");
1547        cmd.arg("-c").arg(
1548            "printf 'early-line\\n\\377\\376bad\\n' >&2; \
1549             printf 'after-bad\\n' >&2; \
1550             printf 'THE REAL REASON' >&2; \
1551             exit 9",
1552        );
1553
1554        match spawn_test_subprocess(cmd).close().await {
1555            Err(Error::Process { exit_code, stderr }) => {
1556                assert_eq!(exit_code, Some(9));
1557                assert!(
1558                    stderr.contains("after-bad"),
1559                    "drain died at the invalid byte, losing the rest: {stderr:?}"
1560                );
1561                assert!(
1562                    stderr.contains("THE REAL REASON"),
1563                    "final line without a trailing newline was dropped: {stderr:?}"
1564                );
1565            }
1566            other => panic!("expected Process error, got {other:?}"),
1567        }
1568    }
1569}