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}