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