Skip to main content

forge_sdk/
client.rs

1//! Public [`Client`] - the entry point consumers hold.
2//!
3//! `Client` is a cheap-clone handle (`Arc`-backed) over a background
4//! reader task that owns the `claude` subprocess. All methods take
5//! `&self`, so the same `Client` can be passed by reference into
6//! multiple tasks. The events stream is returned alongside the Client
7//! at spawn time as a [`ClientEvents`] receiver - single-consumer,
8//! independent of the writer-side handle.
9//!
10//! Internally:
11//! - The reader task pulls lines from the subprocess, decodes them,
12//!   pushes regular [`Message`]s onto an mpsc that backs the returned
13//!   [`ClientEvents`], dispatches inbound `control_request`s on
14//!   detached tasks, and routes outbound `control_response`s to
15//!   per-request oneshots so [`Client::send_control`] callers can
16//!   `await` their typed reply.
17//! - The writer half is a clonable
18//!   [`SharedWriter`](crate::transport::process::SharedWriter) cloned
19//!   from [`Subprocess`]; outbound
20//!   writes go through it without contending on `&mut self`.
21
22pub(crate) mod control_dispatch;
23mod control_send;
24pub(crate) mod runtime;
25
26pub(crate) use control_dispatch::ControlDispatchHandle;
27
28use std::collections::{HashMap, VecDeque};
29use std::sync::Arc;
30use std::sync::atomic::{AtomicBool, Ordering};
31
32use tokio::sync::{Mutex, mpsc, oneshot};
33use tokio::task::JoinHandle;
34use tracing::debug;
35
36use crate::Error;
37use crate::client::runtime::{PendingControls, SharedSessionId, excerpt, spawn_reader_task};
38use crate::mcp::orchestration::McpHosts;
39use crate::options::Options;
40use crate::transport::codec::{DecodedLine, decode_dispatch};
41use crate::transport::process::Subprocess;
42use forge_primitives::Message;
43
44/// Stream of regular [`Message`]s produced by the reader task.
45/// Returned alongside [`Client`] from [`Client::spawn`]. Single-consumer;
46/// the writer-side [`Client`] is `Clone` and can move freely between
47/// tasks while one task owns the receiver.
48pub type ClientEvents = mpsc::UnboundedReceiver<Result<Message, Error>>;
49
50/// Removes a `pending_controls` entry when the awaiting future is
51/// dropped without a response having been routed - the drop guard
52/// `send_control` parks in its future.
53struct PendingGuard {
54    pending: PendingControls,
55    request_id: String,
56    defused: bool,
57}
58impl Drop for PendingGuard {
59    fn drop(&mut self) {
60        if self.defused {
61            return;
62        }
63        match self.pending.try_lock() {
64            Ok(mut pending) => {
65                pending.remove(&self.request_id);
66            }
67            // The mutex is momentarily held (the reader routing a
68            // response); the removal must still land, so finish it on a
69            // task once the holder releases.
70            Err(_) if tokio::runtime::Handle::try_current().is_ok() => {
71                let pending = Arc::clone(&self.pending);
72                let request_id = self.request_id.clone();
73                tokio::spawn(async move {
74                    pending.lock().await.remove(&request_id);
75                });
76            }
77            Err(_) => {
78                tracing::debug!(
79                    target: crate::logging::targets::BRIDGE_LIFECYCLE,
80                    request_id = %self.request_id,
81                    "pending_controls mutex contended outside a runtime; entry removal skipped",
82                );
83            }
84        }
85    }
86}
87
88/// Budget for the `initialize` handshake inside [`Client::spawn`]. A
89/// wedged CLI (alive, stdout open, never answering) would otherwise
90/// park the caller forever with the child leaked.
91///
92/// Published because a caller that waits on a spawn is waiting on this
93/// budget: a wait shorter than it fails a machine that is merely slow,
94/// so a test derives its own wait from this number rather than
95/// hand-picking one under it.
96pub const INIT_HANDSHAKE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
97
98/// An active `claude` binary subprocess.
99///
100/// Construct via [`spawn`](Self::spawn). The init handshake
101/// (`initialize` `control_request` + drained response) runs inside
102/// `spawn`; the `system/init` frame is consumed and its session id
103/// cached on [`session_id()`](Self::session_id). The returned
104/// [`ClientEvents`] receiver yields the clean conversational stream
105/// starting with hook/assistant/user frames.
106///
107/// `Client` is `Clone`. All clones share the same underlying
108/// subprocess, reader task, and pending-control map. Cloning is
109/// cheap (an `Arc` increment); pass `Client` by value or reference
110/// freely.
111#[derive(Clone)]
112pub struct Client {
113    inner: Arc<ClientInner>,
114}
115
116struct ClientInner {
117    /// Cloned writer (mpsc-backed via the transport's writer task).
118    writer: std::sync::Arc<crate::transport::process::SharedWriter>,
119    /// PID of the spawned `claude` child captured at spawn time.
120    /// Stable for the lifetime of the subprocess; consumers (e.g. the
121    /// Inspector pane's OS-level process walker) cache snapshots
122    /// keyed off this value. `None` when the transport couldn't
123    /// report a PID (in practice never for a live subprocess, but
124    /// the optional makes the test stubs and post-disconnect path
125    /// total).
126    claude_pid: Option<u32>,
127    /// Cached response from the `initialize` `control_request` -
128    /// populated during spawn and never mutated afterwards.
129    initialization_result: Option<serde_json::Value>,
130    /// Captured `system/init` payload (`model`, `tools`, `mcp_servers`,
131    /// `slash_commands`, …). The CLI emits this once after init and the
132    /// SDK strips it from the user-visible `Message` stream; cache it
133    /// here so `forge-agent` can answer footer / slash queries without
134    /// re-running the handshake.
135    cached_init_data: Option<serde_json::Value>,
136    /// Captured session id. The reader task updates it as messages
137    /// arrive; consumers read the current value via
138    /// [`Client::session_id`].
139    session_id: SharedSessionId,
140    /// In-flight outbound `control_request`s waiting on responses.
141    /// The reader task routes incoming `control_response`s here.
142    pending_controls: PendingControls,
143    /// Reader task handle. Awaited on [`Client::disconnect`].
144    reader_task: Mutex<Option<JoinHandle<()>>>,
145    /// Shutdown signal for the reader task. `take()`'d on the first
146    /// [`Client::disconnect`] call; subsequent calls are no-ops.
147    shutdown_tx: Mutex<Option<oneshot::Sender<()>>>,
148    /// Set by [`Client::disconnect`] before the
149    /// shutdown signal, so the stream's end can be read as "told to
150    /// stop" rather than pipe death.
151    disconnect_requested: AtomicBool,
152}
153
154impl std::fmt::Debug for Client {
155    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
156        f.debug_struct("Client")
157            .field("session_id", &"<shared>")
158            .field(
159                "initialization_result",
160                &self.inner.initialization_result.as_ref().map(|_| "<cached>"),
161            )
162            .finish_non_exhaustive()
163    }
164}
165
166impl Client {
167    /// Spawn `claude` with the given options and run the init handshake.
168    ///
169    /// Wire-recording for conformance baselines is configured via
170    /// [`Options::tee_inbound`](crate::Options) /
171    /// [`Options::tee_outbound`](crate::Options) callbacks - the
172    /// `forge-test-harness` crate drives this.
173    ///
174    /// # Errors
175    ///
176    /// Any [`Error`] variant; see field docs.
177    pub async fn spawn(options: Options) -> Result<(Self, ClientEvents), Error> {
178        let mut sub = Subprocess::spawn(&options).await?;
179        let writer = sub.clone_writer();
180        // Capture the PID before the reader task consumes `sub`. It
181        // doesn't change for the subprocess's lifetime so caching
182        // once is correct.
183        let claude_pid = sub.child_pid();
184        // **The transport's own container, not a second one**: the writer
185        // task names the session in a stall's record, and `/new` swaps the
186        // occupant of this same handle, so both sides read the current id.
187        let session_id = sub.session_id();
188        let mcp_hosts =
189            McpHosts::new(options.mcp_servers.clone(), options.external_mcp_servers.clone());
190        let hook_registry = options.hooks.mint_registry();
191        let hook_payload = hook_registry.to_initialize_payload();
192        let hook_callbacks = hook_registry.by_id;
193
194        // Build the dispatch handle now so the init loop can route
195        // interleaved `control_request`s through it.
196        let dispatch = ControlDispatchHandle::new(
197            writer.clone(),
198            options.can_use_tool.clone(),
199            options.auto_approve_tool.clone(),
200            mcp_hosts,
201            hook_callbacks,
202            session_id.clone(),
203        );
204
205        // Build the initialize control_request body. the CLI keeps
206        // the same field-inclusion rules:
207        // `hooks` always present (null when empty), `excludeDynamicSections`
208        // only when explicitly set.
209        let exclude_dynamic_sections = match &options.system_prompt {
210            Some(crate::options::SystemPromptKind::Preset {
211                exclude_dynamic_sections: Some(v),
212                ..
213            }) => Some(*v),
214            _ => None,
215        };
216        let hooks_field = if hook_payload.as_object().is_some_and(serde_json::Map::is_empty) {
217            serde_json::Value::Null
218        } else {
219            hook_payload
220        };
221        let mut init_body = serde_json::Map::new();
222        init_body.insert("subtype".into(), serde_json::Value::String("initialize".into()));
223        init_body.insert("hooks".into(), hooks_field);
224        if let Some(flag) = exclude_dynamic_sections {
225            init_body.insert("excludeDynamicSections".into(), serde_json::Value::Bool(flag));
226        }
227        let init_request_id = crate::request_id::next();
228        let init_envelope = serde_json::json!({
229            "type": "control_request",
230            "request_id": init_request_id,
231            "request": serde_json::Value::Object(init_body),
232        });
233        let mut init_line = serde_json::to_string(&init_envelope)
234            .map_err(|e| Error::encode("initialize body", e))?;
235        init_line.push('\n');
236
237        // **Send initialize FIRST.** The CLI in stream-json interactive
238        // mode is driven entirely by stdin: it will not emit `system/init`
239        // until BOTH (a) an `initialize` control_request is received AND
240        // (b) a user message arrives. It DOES emit a `control_response` to
241        // the initialize right after hooks complete - that gives us the
242        // server-info payload (commands, agents, models, account, pid)
243        // without needing to drive a conversation yet.
244        sub.write_line(&init_line).await?;
245
246        let mut pre_init_messages: VecDeque<Message> = VecDeque::new();
247        let mut cached_init_data: Option<serde_json::Value> = None;
248        let mut line_number: u64 = 0;
249        let initialization_result = tokio::time::timeout(INIT_HANDSHAKE_TIMEOUT, async {
250            let response: Option<serde_json::Value> = loop {
251                line_number += 1;
252                let line = sub.read_line().await?.ok_or_else(|| Error::Connection {
253                    reason: "transport closed stdout before initialize control_response".into(),
254                })?;
255                let value: serde_json::Value = serde_json::from_str(&line)
256                    .map_err(|source| Error::JsonDecode { line: line_number, source })?;
257                let ty = value.get("type").and_then(serde_json::Value::as_str).unwrap_or("");
258                match ty {
259                    "control_response" => {
260                        let resp_request_id = value
261                            .pointer("/response/request_id")
262                            .and_then(serde_json::Value::as_str);
263                        if resp_request_id != Some(&init_request_id) {
264                            return Err(Error::message_parse(format!(
265                                "init: control_response request_id mismatch \
266                                     (expected {init_request_id:?}, got {resp_request_id:?}); \
267                                     raw line: {}",
268                                line.trim_end()
269                            )));
270                        }
271                        let resp_subtype =
272                            value.pointer("/response/subtype").and_then(serde_json::Value::as_str);
273                        if resp_subtype == Some("success") {
274                            let body = value
275                                .pointer("/response/response")
276                                .cloned()
277                                .unwrap_or(serde_json::Value::Null);
278                            break match body {
279                                serde_json::Value::Null => None,
280                                v => Some(v),
281                            };
282                        }
283                        let err_msg = value
284                            .pointer("/response/error")
285                            .and_then(serde_json::Value::as_str)
286                            .map_or_else(
287                                || {
288                                    format!(
289                                        "no `error` string field; full response: {}",
290                                        value.pointer("/response").map_or_else(
291                                            || "<missing>".to_string(),
292                                            ToString::to_string,
293                                        )
294                                    )
295                                },
296                                ToString::to_string,
297                            );
298                        return Err(Error::message_parse(format!("initialize failed: {err_msg}")));
299                    }
300                    "control_request" => {
301                        // Interleaved CLI → SDK request during init -
302                        // most commonly an MCP `mcp_message` bootstrap.
303                        // Dispatch synchronously through the dispatch
304                        // handle (the reader task isn't running yet, so
305                        // there's no concurrent reader to worry about).
306                        let req: crate::control::ControlRequest = serde_json::from_value(value)
307                            .map_err(|e| {
308                                Error::message_parse(format!(
309                                    "line {line_number}: control_request decode: {e}"
310                                ))
311                            })?;
312                        dispatch.dispatch(req).await?;
313                    }
314                    "control_cancel_request" => {
315                        tracing::debug!(
316                            line_number,
317                            "control_cancel_request during init; nothing live to cancel"
318                        );
319                    }
320                    _ => match decode_dispatch(&line, line_number) {
321                        DecodedLine::Message(msg) => {
322                            debug!(line_number, "buffering pre-init frame for caller");
323                            // Capture session id off pre-init messages so
324                            // callers reading session_id() right after
325                            // spawn see a real value when the CLI happens
326                            // to emit init early.
327                            if let Some(id) = msg.session_id()
328                                && !id.is_empty()
329                            {
330                                let mut current = session_id.write();
331                                if current.is_empty() {
332                                    *current = id.to_string();
333                                }
334                            }
335                            // Drop `system/init` from the pre-init buffer -
336                            // the CLI consumes it inside `query._fetch_init`
337                            // and never surfaces it to callers; we mirror.
338                            // Cache its `data` so `forge-agent` can read
339                            // model / mcp / slash-command info off it.
340                            if let Message::System { ref subtype, ref data, .. } = *msg
341                                && subtype == "init"
342                            {
343                                cached_init_data = Some(data.clone());
344                            } else {
345                                pre_init_messages.push_back(*msg);
346                            }
347                        }
348                        DecodedLine::Unknown { type_str, raw } => {
349                            tracing::warn!(
350                                type = %type_str,
351                                raw = %raw,
352                                line = line_number,
353                                "unknown top-level type during init - buffering as Message::Unknown"
354                            );
355                            pre_init_messages.push_back(Message::Unknown { type_str, raw });
356                        }
357                        DecodedLine::Malformed { line: line_no, reason } => {
358                            // Pre-init frames stay strict: unparsable
359                            // output before initialize completes is a
360                            // broken spawn, not a degradable stream.
361                            return Err(Error::message_parse(format!(
362                                "line {line_no}: {reason}; raw line: {}",
363                                excerpt(&line, 160)
364                            )));
365                        }
366                        other => {
367                            debug!(
368                                line_number,
369                                ?other,
370                                "unexpected DecodedLine during init fallthrough; ignoring"
371                            );
372                        }
373                    },
374                }
375            };
376            Ok(response)
377        })
378        .await;
379        let initialization_result = match initialization_result {
380            Ok(result) => result?,
381            Err(_) => {
382                return Err(Error::Connection {
383                    reason: format!(
384                        "initialize handshake timed out after {}s; the CLI never answered the \
385                         initialize control_request",
386                        INIT_HANDSHAKE_TIMEOUT.as_secs()
387                    ),
388                });
389            }
390        };
391        debug!("client init handshake complete");
392
393        let pending_controls: PendingControls = Arc::new(Mutex::new(HashMap::new()));
394        let (events_tx, events_rx) = tokio::sync::mpsc::unbounded_channel();
395        let (shutdown_tx, shutdown_rx) = oneshot::channel();
396
397        let reader_task = spawn_reader_task(
398            sub,
399            dispatch,
400            pending_controls.clone(),
401            events_tx,
402            pre_init_messages.into_iter().collect(),
403            shutdown_rx,
404        );
405
406        let inner = Arc::new(ClientInner {
407            writer,
408            claude_pid,
409            initialization_result,
410            cached_init_data,
411            session_id,
412            pending_controls,
413            reader_task: Mutex::new(Some(reader_task)),
414            shutdown_tx: Mutex::new(Some(shutdown_tx)),
415            disconnect_requested: AtomicBool::new(false),
416        });
417
418        Ok((Self { inner }, events_rx))
419    }
420
421    /// Cached response from the `initialize` `control_request` - holds
422    /// the CLI's server capabilities, available commands, and output
423    /// styles. Returns `None` when the CLI didn't attach a body to its
424    /// initialize response.
425    pub fn get_server_info(&self) -> Option<&serde_json::Value> {
426        self.inner.initialization_result.as_ref()
427    }
428
429    /// OS-level PID of the spawned `claude` child, captured at spawn
430    /// time. Stable for the lifetime of this `Client`. Used by
431    /// downstream consumers (e.g. `forge_agent::env::processes`) to
432    /// walk the child process tree for the Inspector pane's
433    /// PROCESSES section.
434    ///
435    /// Returns `None` only when the transport couldn't report a PID
436    /// at spawn - never happens for a real subprocess on supported
437    /// platforms, but the `Option` keeps the test-stub path
438    /// expressible.
439    pub fn claude_pid(&self) -> Option<u32> {
440        self.inner.claude_pid
441    }
442
443    /// SIGKILL the child's process group now, without the reader-task drain
444    /// [`disconnect`](Self::disconnect) performs.
445    ///
446    /// For a session that has no task to run its exit path: dropping the
447    /// handle alone kills only the direct child and leaves whatever the CLI
448    /// already started behind.
449    pub fn kill_process_group(&self) {
450        let Some(pgid) = self.inner.claude_pid else { return };
451        if let Some(error) = crate::transport::process::kill_group(pgid) {
452            tracing::warn!(
453                target: "forge_sdk",
454                event_name = "subprocess_group_signal_failed",
455                session = %self.session_id(),
456                pgid,
457                error = %error,
458                "the session child's process group could not be signalled; \
459                 a descendant may outlive the session"
460            );
461        }
462    }
463
464    /// Captured `system/init` payload - the CLI's first session-scoped
465    /// frame, carrying `model`, `tools`, `mcp_servers`, `slash_commands`,
466    /// `agents`, `skills`, etc. Stripped from the user-visible `Message`
467    /// stream during init; cached here for callers that need the
468    /// initial session context (e.g. forge-agent's status snapshot +
469    /// slash autocomplete).
470    pub fn initial_session_data(&self) -> Option<&serde_json::Value> {
471        self.inner.cached_init_data.as_ref()
472    }
473
474    /// The session id captured from the init message. Returns an empty
475    /// string until the first session-scoped frame arrives.
476    pub fn session_id(&self) -> String {
477        self.inner.session_id.read().clone()
478    }
479
480    /// Read the bare `apiKeySource` field from the cached
481    /// `system/init` payload, returning a partial
482    /// [`forge_primitives::AccountInfo`] (auth source only - no
483    /// email/org/subscription). Returns `None` when the payload is
484    /// absent (init not yet arrived), the field is missing, or the
485    /// value is empty / `"none"`.
486    pub fn account_info_from_init(&self) -> Option<forge_primitives::AccountInfo> {
487        let data = self.inner.cached_init_data.as_ref()?;
488        let api_key_source = data
489            .get("apiKeySource")
490            .and_then(serde_json::Value::as_str)
491            .map(str::to_owned)
492            .filter(|s| !s.is_empty() && s != "none")?;
493        Some(forge_primitives::AccountInfo {
494            api_key_source: Some(api_key_source.clone()),
495            token_source: Some(api_key_source),
496            ..forge_primitives::AccountInfo::default()
497        })
498    }
499
500    /// Send a user prompt as a stream-json user turn.
501    ///
502    /// Mints the prompt's own id and returns it: the id is stamped on the
503    /// frame, and the CLI echoes it on that prompt's `command_lifecycle`
504    /// frames, so a caller that keeps it can key its own row to them.
505    ///
506    /// # Errors
507    ///
508    /// [`Error::Io`] on pipe write failure.
509    pub async fn send_user_message(&self, prompt: &str) -> Result<String, Error> {
510        let uuid = crate::request_id::next_prompt_id();
511        self.send_user_message_under(prompt, &uuid).await?;
512        Ok(uuid)
513    }
514
515    /// Send a user prompt under an id the caller minted.
516    ///
517    /// Use this when the id must be known before the write - a dispatcher
518    /// that draws the prompt's row and reconciles against the lifecycle
519    /// frames by id. Mint with
520    /// [`crate::request_id::next_prompt_id`] and never reuse one: the CLI
521    /// drops a repeated id's prompt silently.
522    ///
523    /// # Errors
524    ///
525    /// [`Error::Io`] on pipe write failure;
526    /// [`Error::MessageParse`] on JSON serialization failure.
527    pub async fn send_user_message_under(&self, prompt: &str, uuid: &str) -> Result<(), Error> {
528        let session_id = self.session_id();
529        let line = crate::transport::codec::encode_user_prompt(prompt, &session_id, uuid)?;
530        self.inner.writer.write_line(&line).await
531    }
532
533    /// Send a user prompt with structured content blocks (text +
534    /// image, etc.) as a stream-json user turn. Use this when the
535    /// prompt is multi-modal; for plain text use
536    /// [`send_user_message`](Self::send_user_message).
537    ///
538    /// `content` is forwarded verbatim as the message body's
539    /// `content` field - callers must build CLI-compatible block
540    /// objects (e.g. `{"type":"text","text":"..."}`,
541    /// `{"type":"image","source":{"type":"base64","media_type":"image/png","data":"..."}}`).
542    ///
543    /// Mints and returns the prompt's id, as [`Self::send_user_message`] does.
544    ///
545    /// # Errors
546    ///
547    /// [`Error::Io`] on pipe write failure;
548    /// [`Error::MessageParse`] on JSON serialization failure.
549    pub async fn send_user_message_with_content(
550        &self,
551        content: &[serde_json::Value],
552    ) -> Result<String, Error> {
553        let uuid = crate::request_id::next_prompt_id();
554        self.send_user_message_with_content_under(content, &uuid).await?;
555        Ok(uuid)
556    }
557
558    /// Send structured content under an id the caller minted, as
559    /// [`Self::send_user_message_under`] does.
560    ///
561    /// # Errors
562    ///
563    /// [`Error::Io`] on pipe write failure;
564    /// [`Error::MessageParse`] on JSON serialization failure.
565    pub async fn send_user_message_with_content_under(
566        &self,
567        content: &[serde_json::Value],
568        uuid: &str,
569    ) -> Result<(), Error> {
570        let session_id = self.session_id();
571        let line =
572            crate::transport::codec::encode_user_prompt_with_content(content, &session_id, uuid)?;
573        self.inner.writer.write_line(&line).await
574    }
575
576    /// Close the stdin side of the transport while keeping the client
577    /// alive for reading. Signals the CLI that no more user messages
578    /// are coming; the CLI drains in-flight turns, emits any final
579    /// frames, and closes stdout. The events receiver returns `None`
580    /// on EOF afterwards.
581    ///
582    /// # Errors
583    ///
584    /// [`Error::Io`] on write failure.
585    pub async fn end_input(&self) -> Result<(), Error> {
586        self.inner.writer.end_input().await
587    }
588
589    /// Graceful shutdown. Signals the reader task to stop, waits for
590    /// it to drain and close the subprocess. Idempotent - subsequent
591    /// calls (on this clone or any other) are no-ops.
592    ///
593    /// # Errors
594    ///
595    /// None today; the inner reader-task close is best-effort. Kept
596    /// `Result` for forward-compat.
597    pub async fn disconnect(&self) -> Result<(), Error> {
598        // The flag goes up BEFORE the signal: a consumer watching the
599        // event stream end must be able to tell "told to stop" (a
600        // session swap or teardown, expected) from pipe death (a real
601        // failure) without any new event field.
602        self.inner.disconnect_requested.store(true, Ordering::Release);
603        if let Some(tx) = self.inner.shutdown_tx.lock().await.take() {
604            let _ = tx.send(());
605        }
606        if let Some(handle) = self.inner.reader_task.lock().await.take()
607            && let Err(join_err) = handle.await
608        {
609            tracing::error!(
610                target: "forge_sdk",
611                error = %join_err,
612                "reader task panicked during disconnect"
613            );
614        }
615        Ok(())
616    }
617
618    /// Whether [`disconnect`](Self::disconnect) has been called on any
619    /// clone of this client. Lets a consumer distinguish an expected
620    /// stream end (session swap, teardown) from the subprocess dying
621    /// on its own.
622    pub fn disconnect_requested(&self) -> bool {
623        self.inner.disconnect_requested.load(Ordering::Acquire)
624    }
625
626    /// Internal: outbound `control_request` issuer. Inserts a oneshot
627    /// into `pending_controls`, writes the envelope via the cloned
628    /// writer, and awaits the reader task's routed response.
629    ///
630    /// If the awaiting future is dropped - a caller's timeout fired, or
631    /// the task died - the oneshot sender would otherwise sit in
632    /// `pending_controls` forever, one leak per dropped call (the
633    /// footer's context-usage poll being the unbounded one). The
634    /// `PendingGuard` returned in the future removes the entry whenever
635    /// the future is dropped without a response having been routed; a
636    /// late response then finds no entry, which the reader task already
637    /// handles.
638    pub(crate) async fn send_control(
639        &self,
640        subtype: &str,
641        extra: serde_json::Value,
642    ) -> Result<serde_json::Value, Error> {
643        let request_id = crate::request_id::next();
644        let mut request_body = serde_json::Map::new();
645        request_body.insert("subtype".into(), serde_json::Value::String(subtype.to_string()));
646        if let serde_json::Value::Object(extra_map) = extra {
647            for (k, v) in extra_map {
648                request_body.insert(k, v);
649            }
650        }
651        let envelope = serde_json::json!({
652            "type": "control_request",
653            "request_id": request_id,
654            "request": serde_json::Value::Object(request_body),
655        });
656        let mut line = serde_json::to_string(&envelope)
657            .map_err(|e| Error::encode("control_request envelope", e))?;
658        line.push('\n');
659
660        let (resp_tx, resp_rx) = oneshot::channel();
661        {
662            let mut pending = self.inner.pending_controls.lock().await;
663            pending.insert(request_id.clone(), resp_tx);
664        }
665        let mut guard = PendingGuard {
666            pending: Arc::clone(&self.inner.pending_controls),
667            request_id: request_id.clone(),
668            defused: false,
669        };
670        if let Err(e) = self.inner.writer.write_line(&line).await {
671            // Remove the pending entry on write failure so the EOF
672            // drain doesn't fire on a request that never went out.
673            let mut pending = self.inner.pending_controls.lock().await;
674            pending.remove(&request_id);
675            guard.defused = true;
676            return Err(e);
677        }
678        let outcome = match resp_rx.await {
679            Ok(outcome) => outcome,
680            Err(_) => Err(Error::Connection {
681                reason: format!("subprocess closed before {subtype} response"),
682            }),
683        };
684        guard.defused = true;
685        outcome
686    }
687}
688
689#[cfg(test)]
690mod tests {
691    use super::{Client, PendingGuard};
692    use crate::client::runtime::PendingControls;
693    use crate::options::OptionsBuilder;
694    use std::collections::HashMap;
695    use std::sync::Arc;
696    use tokio::sync::Mutex;
697
698    fn control_mock_binary() -> String {
699        concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/mock_claude_control.sh").to_owned()
700    }
701
702    /// A control request whose awaiting future is dropped (the bridge's
703    /// 60s timeout firing) must not leave its `pending_controls` entry
704    /// behind - a wedged CLI would otherwise accumulate one entry per
705    /// context-usage poll, unbounded.
706    #[tokio::test]
707    async fn dropped_control_response_future_drains_the_pending_entry() {
708        let opts = OptionsBuilder::new()
709            .binary(control_mock_binary())
710            .env("FORGED_MOCK_SKIP_SUBTYPE", "get_context_usage")
711            .build();
712        let (client, _events) = Client::spawn(opts).await.expect("spawn");
713
714        let _ =
715            tokio::time::timeout(std::time::Duration::from_millis(200), client.get_context_usage())
716                .await;
717
718        assert!(
719            client.inner.pending_controls.lock().await.is_empty(),
720            "the dropped request's pending entry must be removed"
721        );
722        client.disconnect().await.expect("disconnect");
723    }
724
725    /// The reader task can hold `pending_controls` at the instant a
726    /// dropped future's guard runs; the removal must still land (via a
727    /// spawned retry) rather than silently stranding a dead sender.
728    #[tokio::test]
729    async fn pending_guard_removal_survives_a_contended_mutex() {
730        let pending: PendingControls = Arc::new(Mutex::new(HashMap::new()));
731        let (tx, _rx) = tokio::sync::oneshot::channel::<crate::client::runtime::ControlOutcome>();
732        pending.lock().await.insert("req-1".to_owned(), tx);
733
734        let guard = PendingGuard {
735            pending: Arc::clone(&pending),
736            request_id: "req-1".to_owned(),
737            defused: false,
738        };
739        // Hold the map across the drop so the guard's synchronous
740        // try_lock loses the race.
741        let held = pending.lock().await;
742        drop(guard);
743        drop(held);
744
745        tokio::time::timeout(std::time::Duration::from_secs(1), async {
746            while !pending.lock().await.is_empty() {
747                tokio::task::yield_now().await;
748            }
749        })
750        .await
751        .expect("removal retried after the lost race");
752    }
753}