Skip to main content

claude_wrapper/
streaming.rs

1//! NDJSON streaming of `claude` events.
2//!
3//! [`stream_query`] (and its blocking peer `stream_query_sync`) run a
4//! query in `stream-json` mode and hand each decoded event to a
5//! caller-supplied callback as it arrives, rather than buffering the
6//! whole run. Requires the `json` feature.
7
8#[cfg(feature = "json")]
9use std::collections::VecDeque;
10#[cfg(feature = "json")]
11use std::time::Duration;
12
13#[cfg(all(feature = "json", feature = "async"))]
14use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
15#[cfg(all(feature = "json", feature = "async"))]
16use tokio::process::{ChildStderr, Command};
17#[cfg(feature = "json")]
18use tracing::{debug, warn};
19
20#[cfg(feature = "json")]
21use crate::Claude;
22#[cfg(feature = "json")]
23use crate::error::{Error, Result};
24#[cfg(feature = "json")]
25use crate::exec::CommandOutput;
26
27#[cfg(feature = "json")]
28const STREAM_DIAGNOSTIC_MAX_BYTES: usize = 16 * 1024;
29
30/// Bounded stdout retained only for lines that are not stream events.
31#[cfg(feature = "json")]
32#[derive(Default)]
33struct ParseFailureDiagnostics {
34    lines: VecDeque<String>,
35    bytes: usize,
36}
37
38#[cfg(feature = "json")]
39impl ParseFailureDiagnostics {
40    fn push(&mut self, line: &str) {
41        // Reserve one byte per line for the separator added by
42        // `into_string`. A single oversized line keeps its prefix,
43        // where CLI diagnostics normally put the error category.
44        let max_line_bytes = STREAM_DIAGNOSTIC_MAX_BYTES - 1;
45        let line = if line.len() > max_line_bytes {
46            let mut end = max_line_bytes;
47            while !line.is_char_boundary(end) {
48                end -= 1;
49            }
50            &line[..end]
51        } else {
52            line
53        };
54        let line_bytes = line.len() + 1;
55
56        while self.bytes + line_bytes > STREAM_DIAGNOSTIC_MAX_BYTES {
57            let Some(removed) = self.lines.pop_front() else {
58                break;
59            };
60            self.bytes -= removed.len() + 1;
61        }
62
63        self.lines.push_back(line.to_string());
64        self.bytes += line_bytes;
65    }
66
67    fn into_string(self) -> String {
68        let mut output = String::with_capacity(self.bytes);
69        for line in self.lines {
70            output.push_str(&line);
71            output.push('\n');
72        }
73        output.pop();
74        output
75    }
76}
77
78/// A single line from `--output-format stream-json` output.
79///
80/// Each line is an NDJSON object. The structure varies by message type,
81/// so we provide the raw JSON value and convenience accessors.
82#[cfg(feature = "json")]
83#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
84pub struct StreamEvent {
85    /// The raw JSON object for this event.
86    #[serde(flatten)]
87    pub data: serde_json::Value,
88}
89
90#[cfg(feature = "json")]
91impl StreamEvent {
92    /// Get the event type, if present.
93    pub fn event_type(&self) -> Option<&str> {
94        self.data.get("type").and_then(|v| v.as_str())
95    }
96
97    /// Get the message role, if present.
98    pub fn role(&self) -> Option<&str> {
99        self.data.get("role").and_then(|v| v.as_str())
100    }
101
102    /// Check if this is the final result message.
103    pub fn is_result(&self) -> bool {
104        self.event_type() == Some("result")
105    }
106
107    /// Extract the result text from a result event.
108    pub fn result_text(&self) -> Option<&str> {
109        self.data.get("result").and_then(|v| v.as_str())
110    }
111
112    /// Get the session ID if present.
113    pub fn session_id(&self) -> Option<&str> {
114        self.data.get("session_id").and_then(|v| v.as_str())
115    }
116
117    /// Get the cost in USD if present (usually on result events).
118    ///
119    /// Prefers `total_cost_usd` (the CLI's primary key) and falls back
120    /// to the legacy `cost_usd` alias.
121    pub fn cost_usd(&self) -> Option<f64> {
122        self.data
123            .get("total_cost_usd")
124            .or_else(|| self.data.get("cost_usd"))
125            .and_then(|v| v.as_f64())
126    }
127
128    /// Decode a partial-message event into a typed view.
129    ///
130    /// Returns `Some` when the event is one of the content-block lifecycle
131    /// events surfaced by [`QueryCommand::include_partial_messages`] -- start,
132    /// delta, or stop. Returns `None` for any other event (system, assistant,
133    /// result, message-level stream events, etc).
134    ///
135    /// The CLI wraps each raw streaming event as
136    /// `{"type":"stream_event","event":{...}}`; this accessor unwraps that
137    /// envelope. Unknown block types and unknown delta types fall through to
138    /// [`BlockType::Other`] / [`BlockDelta::Other`] rather than erroring, so
139    /// future content-block kinds remain accessible (just untyped).
140    ///
141    /// # Example
142    ///
143    /// Pull incremental thinking text out of a partial-message event:
144    ///
145    /// ```
146    /// use claude_wrapper::streaming::{BlockDelta, PartialMessageEvent, StreamEvent};
147    /// use serde_json::json;
148    ///
149    /// let event: StreamEvent = serde_json::from_value(json!({
150    ///     "type": "stream_event",
151    ///     "event": {
152    ///         "type": "content_block_delta",
153    ///         "index": 0,
154    ///         "delta": { "type": "thinking_delta", "thinking": "Let me think..." }
155    ///     },
156    ///     "session_id": "abc"
157    /// })).unwrap();
158    ///
159    /// match event.partial_message() {
160    ///     Some(PartialMessageEvent::BlockDelta { delta: BlockDelta::Thinking(t), .. }) => {
161    ///         assert_eq!(t, "Let me think...");
162    ///     }
163    ///     _ => unreachable!(),
164    /// }
165    /// ```
166    ///
167    /// [`QueryCommand::include_partial_messages`]: crate::QueryCommand::include_partial_messages
168    pub fn partial_message(&self) -> Option<PartialMessageEvent> {
169        let event = if self.event_type() == Some("stream_event") {
170            self.data.get("event")?
171        } else {
172            &self.data
173        };
174
175        let inner_type = event.get("type")?.as_str()?;
176        let index = event.get("index").and_then(serde_json::Value::as_u64)?;
177        let index = u32::try_from(index).ok()?;
178
179        match inner_type {
180            "content_block_start" => {
181                let block_type = parse_block_type(event.get("content_block")?);
182                Some(PartialMessageEvent::BlockStart { index, block_type })
183            }
184            "content_block_delta" => {
185                let delta = parse_block_delta(event.get("delta")?);
186                Some(PartialMessageEvent::BlockDelta { index, delta })
187            }
188            "content_block_stop" => Some(PartialMessageEvent::BlockStop { index }),
189            _ => None,
190        }
191    }
192}
193
194/// A decoded partial-message event from a streaming `claude` call.
195///
196/// Surfaced by [`StreamEvent::partial_message`] when `--include-partial-messages`
197/// is set. The three variants correspond to the Anthropic streaming content-block
198/// lifecycle: a block starts, gets one or more deltas, then stops.
199#[cfg(feature = "json")]
200#[derive(Debug, Clone, PartialEq, Eq)]
201pub enum PartialMessageEvent {
202    /// A new content block is starting. `block_type` says what kind.
203    BlockStart {
204        /// Position of this block within the assistant message.
205        index: u32,
206        /// What kind of block is starting (text, thinking, tool use, ...).
207        block_type: BlockType,
208    },
209    /// Incremental content for an in-progress block.
210    BlockDelta {
211        /// Index of the block this delta applies to (matches a prior [`BlockStart`]).
212        ///
213        /// [`BlockStart`]: PartialMessageEvent::BlockStart
214        index: u32,
215        /// The incremental payload.
216        delta: BlockDelta,
217    },
218    /// The block at `index` is complete.
219    BlockStop {
220        /// Index of the block that just finished.
221        index: u32,
222    },
223}
224
225/// The kind of content block reported by a [`PartialMessageEvent::BlockStart`].
226///
227/// Mirrors the `content_block.type` field from the Anthropic streaming API.
228/// New block kinds added upstream surface as [`BlockType::Other`] -- callers
229/// can still recover the type name from the carried string.
230#[cfg(feature = "json")]
231#[derive(Debug, Clone, PartialEq, Eq)]
232pub enum BlockType {
233    /// Regular assistant text -- followed by `text_delta` deltas.
234    Text,
235    /// Extended-thinking block -- followed by `thinking_delta` deltas.
236    Thinking,
237    /// A tool invocation -- followed by `input_json_delta` deltas streaming the JSON input.
238    ToolUse {
239        /// Tool-call id, used to correlate the eventual tool result.
240        id: String,
241        /// Name of the tool being called.
242        name: String,
243    },
244    /// Any block type not yet modelled. Carries the raw `type` string.
245    Other(String),
246}
247
248/// The incremental payload carried by a [`PartialMessageEvent::BlockDelta`].
249///
250/// Mirrors the `delta.type` field from the Anthropic streaming API.
251/// Less-common delta kinds (signature, citations, compaction, ...) collapse to
252/// [`BlockDelta::Other`]; callers that need them can fall back to
253/// [`StreamEvent::data`].
254#[cfg(feature = "json")]
255#[derive(Debug, Clone, PartialEq, Eq)]
256pub enum BlockDelta {
257    /// Chunk of assistant text.
258    Text(String),
259    /// Chunk of extended-thinking text.
260    Thinking(String),
261    /// Chunk of streaming tool-input JSON. Concatenate across deltas to
262    /// reconstruct the full input -- individual chunks are not standalone JSON.
263    InputJson(String),
264    /// Any delta type not modelled above (e.g. `signature_delta`,
265    /// `citations_delta`). Read from [`StreamEvent::data`] for the raw payload.
266    Other,
267}
268
269#[cfg(feature = "json")]
270fn parse_block_type(content_block: &serde_json::Value) -> BlockType {
271    let Some(ty) = content_block
272        .get("type")
273        .and_then(serde_json::Value::as_str)
274    else {
275        return BlockType::Other(String::new());
276    };
277    match ty {
278        "text" => BlockType::Text,
279        "thinking" => BlockType::Thinking,
280        "tool_use" => {
281            let id = content_block
282                .get("id")
283                .and_then(serde_json::Value::as_str)
284                .unwrap_or("")
285                .to_string();
286            let name = content_block
287                .get("name")
288                .and_then(serde_json::Value::as_str)
289                .unwrap_or("")
290                .to_string();
291            BlockType::ToolUse { id, name }
292        }
293        other => BlockType::Other(other.to_string()),
294    }
295}
296
297#[cfg(feature = "json")]
298fn parse_block_delta(delta: &serde_json::Value) -> BlockDelta {
299    let Some(ty) = delta.get("type").and_then(serde_json::Value::as_str) else {
300        return BlockDelta::Other;
301    };
302    match ty {
303        "text_delta" => delta
304            .get("text")
305            .and_then(serde_json::Value::as_str)
306            .map(|s| BlockDelta::Text(s.to_string()))
307            .unwrap_or(BlockDelta::Other),
308        "thinking_delta" => delta
309            .get("thinking")
310            .and_then(serde_json::Value::as_str)
311            .map(|s| BlockDelta::Thinking(s.to_string()))
312            .unwrap_or(BlockDelta::Other),
313        "input_json_delta" => delta
314            .get("partial_json")
315            .and_then(serde_json::Value::as_str)
316            .map(|s| BlockDelta::InputJson(s.to_string()))
317            .unwrap_or(BlockDelta::Other),
318        _ => BlockDelta::Other,
319    }
320}
321
322/// Execute a command with streaming output, calling a handler for each NDJSON line.
323///
324/// This spawns the claude process and reads stdout line-by-line, parsing each
325/// as a JSON event and passing it to the handler. Useful for progress tracking
326/// and real-time output processing.
327///
328/// Dropping the returned future mid-flight kills the spawned `claude`
329/// process and, on Unix, its whole process group (SIGKILL): an
330/// abandoned run does not keep executing in the background, and the
331/// subprocesses it spawned for tool use die with it. Events already
332/// dispatched to the handler are not rolled back.
333///
334/// # Example
335///
336/// ```no_run
337/// use claude_wrapper::{Claude, QueryCommand, OutputFormat};
338/// use claude_wrapper::streaming::{StreamEvent, stream_query};
339///
340/// # async fn example() -> claude_wrapper::Result<()> {
341/// let claude = Claude::builder().build()?;
342///
343/// let cmd = QueryCommand::new("explain quicksort")
344///     .output_format(OutputFormat::StreamJson);
345///
346/// let output = stream_query(&claude, &cmd, |event: StreamEvent| {
347///     if let Some(t) = event.event_type() {
348///         println!("[{t}] {:?}", event.data);
349///     }
350/// }).await?;
351/// # Ok(())
352/// # }
353/// ```
354#[cfg(all(feature = "json", feature = "async"))]
355pub async fn stream_query<F>(
356    claude: &Claude,
357    cmd: &crate::command::query::QueryCommand,
358    handler: F,
359) -> Result<CommandOutput>
360where
361    F: FnMut(StreamEvent),
362{
363    stream_query_impl(claude, cmd, handler, claude.timeout).await
364}
365
366/// Unified streaming implementation with optional timeout.
367///
368/// Reads stderr concurrently in a background task so a chatty child
369/// cannot deadlock by filling the stderr pipe buffer, and so any
370/// captured stderr is available even on timeout or IO error.
371///
372/// On timeout, the child is killed and reaped (`kill().await` sends
373/// SIGKILL and waits), and whatever stderr was produced is logged at
374/// warn level. The returned `Error::Timeout` does not carry partial
375/// output -- streamed stdout events were already dispatched to the
376/// handler as they arrived.
377#[cfg(all(feature = "json", feature = "async"))]
378async fn stream_query_impl<F>(
379    claude: &Claude,
380    cmd: &crate::command::query::QueryCommand,
381    mut handler: F,
382    timeout: Option<Duration>,
383) -> Result<CommandOutput>
384where
385    F: FnMut(StreamEvent),
386{
387    use crate::command::ClaudeCommand;
388
389    let args = cmd.args();
390
391    let mut command_args = Vec::new();
392    command_args.extend(claude.global_args.clone());
393    command_args.extend(args);
394
395    // A stream has three outcomes and the failure one is the easy one
396    // to miss, so `outcome` is recorded on every exit path: completed,
397    // failed, or timeout. `events` counts what the handler actually
398    // saw, which is the difference between "produced nothing" and
399    // "never started".
400    let span = tracing::debug_span!(
401        "claude.stream",
402        command = crate::exec::span_command(&command_args),
403        binary = %claude.binary.display(),
404        cwd = claude.working_dir.as_deref().map(|d| d.display().to_string()),
405        timeout_secs = timeout.map(|t| t.as_secs()),
406        outcome = tracing::field::Empty,
407        events = tracing::field::Empty,
408        exit_code = tracing::field::Empty,
409        duration_ms = tracing::field::Empty,
410    );
411    let _enter = span.enter();
412    let started = std::time::Instant::now();
413    let mut event_count: u64 = 0;
414
415    debug!(
416        binary = %claude.binary.display(),
417        args = ?command_args,
418        timeout = ?timeout,
419        "streaming claude command"
420    );
421
422    let mut cmd = Command::new(&claude.binary);
423    cmd.args(&command_args)
424        .stdout(std::process::Stdio::piped())
425        .stderr(std::process::Stdio::piped())
426        .stdin(std::process::Stdio::null())
427        // Dropping the in-flight future must kill the child, not leave
428        // the CLI running unattended (see the `exec` module docs).
429        .kill_on_drop(true);
430    crate::exec::apply_child_environment(cmd.as_std_mut(), claude.clear_env, &claude.env);
431    // Own process group (Unix) so cancellation can signal the whole
432    // tree, not just the direct child (see exec::GroupKillGuard). Opt
433    // out via ClaudeBuilder::process_group.
434    crate::exec::apply_process_group(&mut cmd, claude.process_group);
435
436    if let Some(ref dir) = claude.working_dir {
437        cmd.current_dir(dir);
438    }
439
440    let mut child = cmd.spawn().map_err(|e| Error::Io {
441        message: format!("failed to spawn claude: {e}"),
442        source: e,
443        working_dir: claude.working_dir.clone(),
444    })?;
445    let mut group =
446        crate::exec::arm_and_notify(claude.process_group, child.id(), claude.on_spawn.as_ref());
447
448    let stdout = child.stdout.take().expect("stdout was piped");
449    let mut stderr = child.stderr.take().expect("stderr was piped");
450
451    let mut reader = BufReader::new(stdout).lines();
452
453    // Run stdout line reading and stderr draining concurrently so a
454    // chatty child can't deadlock by filling the stderr pipe buffer.
455    // tokio::join! polls both futures on the same task (no tokio::spawn
456    // needed, so we avoid pulling in the `rt` feature).
457    let drain = drain_stderr(&mut stderr);
458    // Wrap the caller's handler so the span can report how many events
459    // were dispatched without the handler needing to care.
460    let mut counting_handler = |event: StreamEvent| {
461        event_count += 1;
462        handler(event);
463    };
464    let read_future = read_lines(
465        &mut reader,
466        &mut counting_handler,
467        claude.working_dir.clone(),
468    );
469    let combined = async {
470        let (line_result, stderr_str) = tokio::join!(read_future, drain);
471        (line_result, stderr_str)
472    };
473
474    let (line_result, stderr_str) = match timeout {
475        Some(d) => match tokio::time::timeout(d, combined).await {
476            Ok(pair) => pair,
477            Err(_) => {
478                // Timeout: take down the whole group, honoring the
479                // optional SIGTERM grace, then kill+reap the direct
480                // child, and try to drain whatever stderr remains.
481                // The group kill takes down subprocesses that could
482                // otherwise hold our pipe fds open; the capped drain
483                // below stays as a backstop.
484                crate::exec::kill_group_with_grace(&mut group, claude.kill_grace).await;
485                let _ = child.kill().await;
486                let drain_budget = Duration::from_millis(200);
487                let stderr_str = tokio::time::timeout(drain_budget, drain_stderr(&mut stderr))
488                    .await
489                    .unwrap_or_default();
490                if !stderr_str.is_empty() {
491                    warn!(stderr = %stderr_str, "stderr from timed-out streaming process");
492                }
493                span.record("outcome", "timeout");
494                span.record("events", event_count);
495                span.record("duration_ms", started.elapsed().as_millis() as u64);
496                return Err(Error::Timeout {
497                    timeout_seconds: d.as_secs(),
498                });
499            }
500        },
501        None => combined.await,
502    };
503
504    // If reading lines failed partway through (IO error, not timeout),
505    // clean up the child (and its group) before returning.
506    let stdout_diagnostics = match line_result {
507        Ok(diagnostics) => diagnostics.into_string(),
508        Err(e) => {
509            group.kill_now();
510            let _ = child.kill().await;
511            return Err(e);
512        }
513    };
514
515    let status = child.wait().await.map_err(|e| Error::Io {
516        message: "failed to wait for claude process".to_string(),
517        source: e,
518        working_dir: claude.working_dir.clone(),
519    })?;
520    group.disarm();
521
522    let exit_code = status.code().unwrap_or(-1);
523
524    span.record("events", event_count);
525    span.record("exit_code", exit_code);
526    span.record("duration_ms", started.elapsed().as_millis() as u64);
527
528    if !status.success() {
529        span.record("outcome", "failed");
530        return Err(Error::from_command_failure(
531            format!("{} {}", claude.binary.display(), command_args.join(" ")),
532            exit_code,
533            stdout_diagnostics,
534            stderr_str,
535            claude.working_dir.clone(),
536        ));
537    }
538
539    span.record("outcome", "completed");
540    Ok(CommandOutput {
541        stdout: String::new(), // already consumed via streaming
542        stderr: stderr_str,
543        exit_code,
544        success: true,
545    })
546}
547
548#[cfg(all(feature = "json", feature = "async"))]
549async fn drain_stderr(stderr: &mut ChildStderr) -> String {
550    let mut buf = Vec::new();
551    let _ = stderr.read_to_end(&mut buf).await;
552    String::from_utf8_lossy(&buf).into_owned()
553}
554
555#[cfg(all(feature = "json", feature = "async"))]
556async fn read_lines<F>(
557    reader: &mut tokio::io::Lines<BufReader<tokio::process::ChildStdout>>,
558    handler: &mut F,
559    working_dir: Option<std::path::PathBuf>,
560) -> Result<ParseFailureDiagnostics>
561where
562    F: FnMut(StreamEvent),
563{
564    let mut diagnostics = ParseFailureDiagnostics::default();
565    while let Some(line) = reader.next_line().await.map_err(|e| Error::Io {
566        message: "failed to read stdout line".to_string(),
567        source: e,
568        working_dir: working_dir.clone(),
569    })? {
570        if line.trim().is_empty() {
571            continue;
572        }
573        match serde_json::from_str::<StreamEvent>(&line) {
574            Ok(event) => handler(event),
575            Err(e) => {
576                debug!(line = %line, error = %e, "failed to parse stream event, skipping");
577                diagnostics.push(&line);
578            }
579        }
580    }
581
582    Ok(diagnostics)
583}
584
585// ---------- sync streaming ----------
586
587/// Blocking mirror of [`stream_query`]. Reads NDJSON lines from the
588/// child's stdout on a worker thread, dispatches each parsed event
589/// to `handler` on the caller's thread, and drains stderr on a
590/// separate worker thread so the child can't deadlock on a full pipe.
591///
592/// Requires both `sync` and `json` features.
593///
594/// The handler is invoked on the caller's thread — no `Send` bound —
595/// so it can capture non-`Send` state. If a timeout is configured on
596/// the [`Claude`] client, the child's whole process group (Unix) is
597/// SIGKILLed and the child reaped once the deadline passes; partial
598/// events already dispatched to the handler are not rolled back.
599///
600/// # Example
601///
602/// ```no_run
603/// # #[cfg(all(feature = "sync", feature = "json"))]
604/// # {
605/// use claude_wrapper::{Claude, OutputFormat, QueryCommand};
606/// use claude_wrapper::streaming::{StreamEvent, stream_query_sync};
607///
608/// # fn example() -> claude_wrapper::Result<()> {
609/// let claude = Claude::builder().build()?;
610/// let cmd = QueryCommand::new("explain quicksort")
611///     .output_format(OutputFormat::StreamJson);
612///
613/// stream_query_sync(&claude, &cmd, |event: StreamEvent| {
614///     if let Some(t) = event.event_type() {
615///         println!("[{t}] {:?}", event.data);
616///     }
617/// })?;
618/// # Ok(())
619/// # }
620/// # }
621/// ```
622#[cfg(all(feature = "sync", feature = "json"))]
623pub fn stream_query_sync<F>(
624    claude: &Claude,
625    cmd: &crate::command::query::QueryCommand,
626    mut handler: F,
627) -> Result<CommandOutput>
628where
629    F: FnMut(StreamEvent),
630{
631    use std::io::{BufRead as _, Read as _};
632    use std::process::{Command as StdCommand, Stdio};
633    use std::sync::mpsc;
634    use std::thread;
635    use std::time::Instant;
636
637    use crate::command::ClaudeCommand;
638
639    let args = cmd.args();
640    let mut command_args = Vec::new();
641    command_args.extend(claude.global_args.clone());
642    command_args.extend(args);
643
644    debug!(
645        binary = %claude.binary.display(),
646        args = ?command_args,
647        timeout = ?claude.timeout,
648        "streaming claude command (sync)"
649    );
650
651    let mut cmd_builder = StdCommand::new(&claude.binary);
652    cmd_builder
653        .args(&command_args)
654        .stdin(Stdio::null())
655        .stdout(Stdio::piped())
656        .stderr(Stdio::piped());
657    crate::exec::apply_child_environment(&mut cmd_builder, claude.clear_env, &claude.env);
658    // Own process group (Unix) so a kill can signal the whole tree,
659    // not just the direct child (see exec::GroupKillGuard). Opt out
660    // via ClaudeBuilder::process_group.
661    crate::exec::apply_process_group_sync(&mut cmd_builder, claude.process_group);
662
663    if let Some(ref dir) = claude.working_dir {
664        cmd_builder.current_dir(dir);
665    }
666
667    let mut child = cmd_builder.spawn().map_err(|e| Error::Io {
668        message: format!("failed to spawn claude: {e}"),
669        source: e,
670        working_dir: claude.working_dir.clone(),
671    })?;
672    let mut group = crate::exec::arm_and_notify(
673        claude.process_group,
674        Some(child.id()),
675        claude.on_spawn.as_ref(),
676    );
677
678    let stdout = child.stdout.take().expect("stdout was piped");
679    let stderr = child.stderr.take().expect("stderr was piped");
680
681    // Reader thread: parse NDJSON lines and push StreamEvents through
682    // the channel. Handler runs on the caller's thread so it doesn't
683    // need Send. Bubbles IO errors out via the thread's return value.
684    let (tx, rx) = mpsc::channel::<StreamEvent>();
685    let reader_wd = claude.working_dir.clone();
686    let reader_thread = thread::spawn(move || -> Result<ParseFailureDiagnostics> {
687        let reader = std::io::BufReader::new(stdout);
688        let mut diagnostics = ParseFailureDiagnostics::default();
689        for line_res in reader.lines() {
690            let line = line_res.map_err(|e| Error::Io {
691                message: "failed to read stdout line".to_string(),
692                source: e,
693                working_dir: reader_wd.clone(),
694            })?;
695            if line.trim().is_empty() {
696                continue;
697            }
698            match serde_json::from_str::<StreamEvent>(&line) {
699                Ok(event) => {
700                    if tx.send(event).is_err() {
701                        // Receiver gone — main thread has bailed out.
702                        return Ok(diagnostics);
703                    }
704                }
705                Err(e) => {
706                    debug!(line = %line, error = %e, "failed to parse stream event, skipping");
707                    diagnostics.push(&line);
708                }
709            }
710        }
711        Ok(diagnostics)
712    });
713
714    let stderr_thread = thread::spawn(move || -> String {
715        let mut buf = Vec::new();
716        let mut stderr = stderr;
717        let _ = stderr.read_to_end(&mut buf);
718        String::from_utf8_lossy(&buf).into_owned()
719    });
720
721    // Main loop: dispatch events on the caller's thread, honouring the
722    // configured timeout. Break on disconnect (reader done) or timeout.
723    let deadline = claude.timeout.map(|d| Instant::now() + d);
724    let mut timed_out = false;
725
726    loop {
727        let recv_result = match deadline {
728            Some(d) => {
729                let now = Instant::now();
730                if now >= d {
731                    timed_out = true;
732                    break;
733                }
734                rx.recv_timeout(d - now)
735            }
736            None => rx.recv().map_err(|_| mpsc::RecvTimeoutError::Disconnected),
737        };
738
739        match recv_result {
740            Ok(event) => handler(event),
741            Err(mpsc::RecvTimeoutError::Timeout) => {
742                timed_out = true;
743                break;
744            }
745            Err(mpsc::RecvTimeoutError::Disconnected) => break,
746        }
747    }
748
749    if timed_out {
750        // Take down the whole group first, honoring the optional
751        // SIGTERM grace, so grandchildren holding our pipe fds die
752        // too, then kill+reap the direct child.
753        crate::exec::kill_group_with_grace_sync(&mut group, claude.kill_grace);
754        let _ = child.kill();
755        let _ = child.wait();
756        // Both worker threads can block indefinitely if an orphaned
757        // grandchild inherited our pipe fds and keeps the write end
758        // open (e.g. a `bash` script whose `sleep` subprocess outlives
759        // the SIGKILLed shell). Cap the joins so the timeout error
760        // still returns promptly; any thread that misses the deadline
761        // leaks its JoinHandle, which is acceptable for this edge.
762        let budget = Duration::from_millis(200);
763        let stderr_str = join_with_budget(stderr_thread, budget).unwrap_or_default();
764        let _ = join_with_budget(reader_thread, budget);
765        if !stderr_str.is_empty() {
766            warn!(stderr = %stderr_str, "stderr from timed-out streaming process");
767        }
768        return Err(Error::Timeout {
769            timeout_seconds: claude.timeout.map(|d| d.as_secs()).unwrap_or_default(),
770        });
771    }
772
773    // Normal completion: collect reader result (may carry IO error).
774    let reader_result = reader_thread
775        .join()
776        .unwrap_or_else(|_| Ok(ParseFailureDiagnostics::default()));
777    let stdout_diagnostics = match reader_result {
778        Ok(diagnostics) => diagnostics.into_string(),
779        Err(e) => {
780            group.kill_now();
781            let _ = child.kill();
782            let _ = child.wait();
783            let _ = stderr_thread.join();
784            return Err(e);
785        }
786    };
787
788    let status = child.wait().map_err(|e| Error::Io {
789        message: "failed to wait for claude process".to_string(),
790        source: e,
791        working_dir: claude.working_dir.clone(),
792    })?;
793    group.disarm();
794    let stderr_str = stderr_thread.join().unwrap_or_default();
795    let exit_code = status.code().unwrap_or(-1);
796
797    if !status.success() {
798        return Err(Error::from_command_failure(
799            format!("{} {}", claude.binary.display(), command_args.join(" ")),
800            exit_code,
801            stdout_diagnostics,
802            stderr_str,
803            claude.working_dir.clone(),
804        ));
805    }
806
807    Ok(CommandOutput {
808        stdout: String::new(),
809        stderr: stderr_str,
810        exit_code,
811        success: true,
812    })
813}
814
815/// Join a worker thread with a time budget. Returns `Some(value)` if
816/// the thread finished in time, `None` if the deadline passed first.
817/// A missed deadline leaks the `JoinHandle`; the thread completes
818/// eventually and its value is dropped.
819#[cfg(all(feature = "sync", feature = "json"))]
820fn join_with_budget<T: Send + 'static>(
821    handle: std::thread::JoinHandle<T>,
822    budget: Duration,
823) -> Option<T> {
824    use std::sync::mpsc;
825    use std::thread;
826
827    let (tx, rx) = mpsc::channel::<T>();
828    thread::spawn(move || {
829        if let Ok(v) = handle.join() {
830            let _ = tx.send(v);
831        }
832    });
833    rx.recv_timeout(budget).ok()
834}
835
836#[cfg(all(test, feature = "json"))]
837mod tests {
838    use super::*;
839    use serde_json::json;
840
841    fn parse(v: serde_json::Value) -> StreamEvent {
842        serde_json::from_value(v).expect("valid StreamEvent")
843    }
844
845    fn wrap(inner: serde_json::Value) -> StreamEvent {
846        parse(json!({
847            "type": "stream_event",
848            "event": inner,
849            "session_id": "sess-1",
850            "parent_tool_use_id": null,
851            "uuid": "11111111-1111-1111-1111-111111111111"
852        }))
853    }
854
855    #[test]
856    fn parse_failure_diagnostics_are_bounded_and_keep_recent_lines() {
857        let mut diagnostics = ParseFailureDiagnostics::default();
858        diagnostics.push(&"x".repeat(STREAM_DIAGNOSTIC_MAX_BYTES));
859        diagnostics.push("Not authenticated. Run `claude login`.");
860
861        let output = diagnostics.into_string();
862        assert!(output.len() <= STREAM_DIAGNOSTIC_MAX_BYTES);
863        assert_eq!(output, "Not authenticated. Run `claude login`.");
864    }
865
866    #[test]
867    fn partial_message_text_block_lifecycle() {
868        let start = wrap(json!({
869            "type": "content_block_start",
870            "index": 0,
871            "content_block": { "type": "text", "text": "" }
872        }));
873        assert_eq!(
874            start.partial_message(),
875            Some(PartialMessageEvent::BlockStart {
876                index: 0,
877                block_type: BlockType::Text,
878            })
879        );
880
881        let delta = wrap(json!({
882            "type": "content_block_delta",
883            "index": 0,
884            "delta": { "type": "text_delta", "text": "Hello" }
885        }));
886        assert_eq!(
887            delta.partial_message(),
888            Some(PartialMessageEvent::BlockDelta {
889                index: 0,
890                delta: BlockDelta::Text("Hello".into()),
891            })
892        );
893
894        let stop = wrap(json!({ "type": "content_block_stop", "index": 0 }));
895        assert_eq!(
896            stop.partial_message(),
897            Some(PartialMessageEvent::BlockStop { index: 0 })
898        );
899    }
900
901    #[test]
902    fn partial_message_thinking_block_lifecycle() {
903        let start = wrap(json!({
904            "type": "content_block_start",
905            "index": 1,
906            "content_block": { "type": "thinking", "thinking": "", "signature": "" }
907        }));
908        assert_eq!(
909            start.partial_message(),
910            Some(PartialMessageEvent::BlockStart {
911                index: 1,
912                block_type: BlockType::Thinking,
913            })
914        );
915
916        let delta = wrap(json!({
917            "type": "content_block_delta",
918            "index": 1,
919            "delta": { "type": "thinking_delta", "thinking": "weighing options" }
920        }));
921        assert_eq!(
922            delta.partial_message(),
923            Some(PartialMessageEvent::BlockDelta {
924                index: 1,
925                delta: BlockDelta::Thinking("weighing options".into()),
926            })
927        );
928
929        let stop = wrap(json!({ "type": "content_block_stop", "index": 1 }));
930        assert_eq!(
931            stop.partial_message(),
932            Some(PartialMessageEvent::BlockStop { index: 1 })
933        );
934    }
935
936    #[test]
937    fn partial_message_tool_use_block_carries_id_and_name() {
938        let start = wrap(json!({
939            "type": "content_block_start",
940            "index": 2,
941            "content_block": {
942                "type": "tool_use",
943                "id": "toolu_abc",
944                "name": "Bash",
945                "input": {}
946            }
947        }));
948        assert_eq!(
949            start.partial_message(),
950            Some(PartialMessageEvent::BlockStart {
951                index: 2,
952                block_type: BlockType::ToolUse {
953                    id: "toolu_abc".into(),
954                    name: "Bash".into(),
955                },
956            })
957        );
958
959        let delta = wrap(json!({
960            "type": "content_block_delta",
961            "index": 2,
962            "delta": { "type": "input_json_delta", "partial_json": "{\"cmd\":" }
963        }));
964        assert_eq!(
965            delta.partial_message(),
966            Some(PartialMessageEvent::BlockDelta {
967                index: 2,
968                delta: BlockDelta::InputJson("{\"cmd\":".into()),
969            })
970        );
971    }
972
973    #[test]
974    fn partial_message_unknown_kinds_fall_through_to_other() {
975        let unknown_block = wrap(json!({
976            "type": "content_block_start",
977            "index": 3,
978            "content_block": { "type": "redacted_thinking", "data": "..." }
979        }));
980        assert_eq!(
981            unknown_block.partial_message(),
982            Some(PartialMessageEvent::BlockStart {
983                index: 3,
984                block_type: BlockType::Other("redacted_thinking".into()),
985            })
986        );
987
988        let unknown_delta = wrap(json!({
989            "type": "content_block_delta",
990            "index": 3,
991            "delta": { "type": "signature_delta", "signature": "sig" }
992        }));
993        assert_eq!(
994            unknown_delta.partial_message(),
995            Some(PartialMessageEvent::BlockDelta {
996                index: 3,
997                delta: BlockDelta::Other,
998            })
999        );
1000    }
1001
1002    #[test]
1003    fn partial_message_returns_none_for_non_partial_events() {
1004        let result = parse(json!({
1005            "type": "result",
1006            "result": "done",
1007            "session_id": "sess-1",
1008            "total_cost_usd": 0.01
1009        }));
1010        assert!(result.partial_message().is_none());
1011
1012        let assistant = parse(json!({
1013            "type": "assistant",
1014            "message": { "role": "assistant", "content": [] },
1015            "session_id": "sess-1"
1016        }));
1017        assert!(assistant.partial_message().is_none());
1018
1019        let message_start = wrap(json!({
1020            "type": "message_start",
1021            "message": { "id": "msg_1", "role": "assistant", "content": [] }
1022        }));
1023        assert!(message_start.partial_message().is_none());
1024    }
1025
1026    #[test]
1027    fn partial_message_accepts_unwrapped_event() {
1028        let raw = parse(json!({
1029            "type": "content_block_delta",
1030            "index": 0,
1031            "delta": { "type": "text_delta", "text": "hi" }
1032        }));
1033        assert_eq!(
1034            raw.partial_message(),
1035            Some(PartialMessageEvent::BlockDelta {
1036                index: 0,
1037                delta: BlockDelta::Text("hi".into()),
1038            })
1039        );
1040    }
1041}