Skip to main content

codex_app_server_client/
client.rs

1mod dispatch;
2
3use std::collections::HashMap;
4use std::sync::atomic::{AtomicI64, Ordering};
5use std::sync::{Arc, Mutex, MutexGuard};
6use std::time::{Duration, Instant};
7
8use tokio::process::Child;
9use tokio::sync::{mpsc, oneshot};
10use tokio_util::sync::CancellationToken;
11
12use crate::protocol::{ClientRequest, RequestId, ServerNotification, ServerRequest};
13use tokio::io::AsyncWriteExt as _;
14
15use crate::transport::{self, AsyncBufRead, AsyncWrite, OutgoingReply};
16use crate::{Error, Result};
17
18/// Default timeout for `CodexAppServerClient::call_request` (and therefore
19/// every generated per-method wrapper). Override with
20/// [`CodexAppServerClient::with_call_timeout`]. This bounds one request/response
21/// round trip - it has nothing to do with how long a turn takes to finish
22/// generating, since that streams via [`crate::Event::Notification`] instead
23/// of blocking the request that started it.
24pub const DEFAULT_CALL_TIMEOUT: Duration = Duration::from_secs(120);
25
26/// Timeout for a single outgoing line write. A `write_all`/`flush` to a healthy
27/// pipe or socket completes in microseconds; if it hasn't completed in this
28/// long the peer is almost certainly stalled (backpressure with nobody
29/// reading), and we'd rather tear the connection down than hang forever with
30/// an ever-growing outgoing queue and no diagnostics. Tearing the connection
31/// down always fails every *future* call immediately (`write_tx.send`
32/// starts failing once the writer task exits). For [`CodexAppServerClient::spawn`]
33/// it also fails every *pending* call promptly: killing the child closes its
34/// stdout, the reader sees EOF, and that's what actually clears the pending
35/// map. For [`CodexAppServerClient::connect_streams`]/[`CodexAppServerClient::connect_unix`]
36/// there's no child to kill, so a write-stall alone doesn't touch the reader
37/// task or the pending map - already-in-flight calls there still rely on
38/// their own `call_timeout` instead.
39const WRITE_TIMEOUT: Duration = Duration::from_secs(30);
40
41/// Upper bound on how long the forwarding task spawned for one incoming
42/// server->client request (see [`PendingServerRequest`]) will wait for a
43/// reply before giving up on its own. This is a backstop for the case where a
44/// caller holds a `PendingServerRequest` forever without ever dropping or
45/// responding to it (e.g. stored in a collection and forgotten). Dropping one
46/// (deliberately, via cancellation, or via a panic) always sends a fallback
47/// error through its own `Drop` impl first, so this timeout only covers the
48/// "never even dropped" case. Generous because these are often
49/// human-in-the-loop approval/elicitation flows that can legitimately take a
50/// while.
51const PENDING_SERVER_REQUEST_TIMEOUT: Duration = Duration::from_secs(600);
52
53/// Default capacity of the internal channel from the reader task to
54/// [`EventStream`], used by [`CodexAppServerClient::spawn`],
55/// [`CodexAppServerClient::connect_streams`], and
56/// [`CodexAppServerClient::connect_unix`]. Bounded (rather than unbounded)
57/// so a stalled or absent consumer grows memory by a fixed amount, not
58/// without bound, if events keep arriving faster than [`EventStream::recv`]
59/// is called - see that type's doc comment for the drop policy once this
60/// fills up.
61///
62/// Override per-connection with
63/// [`CodexAppServerClient::spawn_with_events_capacity`],
64/// [`CodexAppServerClient::connect_streams_with_events_capacity`], or
65/// [`CodexAppServerClient::connect_unix_with_events_capacity`] - REST/SSE
66/// consumers reading events over a network are exactly the "slow consumer"
67/// case this bound protects against, so `crate::rest::RestLimits` threads
68/// its own per-session override through
69/// [`crate::SessionOptions::with_events_capacity`] instead of hardcoding
70/// this default for every session it spawns.
71pub const DEFAULT_EVENTS_CHANNEL_CAPACITY: usize = 1024;
72
73/// Capacity of the internal channel from callers (and the reader task's own
74/// reply-forwarding) to the writer task. Bounded for the same reason as
75/// [`DEFAULT_EVENTS_CHANNEL_CAPACITY`]: without a cap, a caller issuing many
76/// concurrent requests while the peer write is slow (anywhere up to
77/// [`WRITE_TIMEOUT`] before the connection gets torn down) could grow this
78/// queue by an unbounded number of serialized-but-unwritten lines. A full
79/// queue surfaces as [`Error::TransportClosed`] to the caller that tried to
80/// enqueue onto it - not perfectly accurate wording for "backed up" versus
81/// "closed," but by the time this queue is actually full the connection is
82/// in comparably serious trouble, and treating it as unusable is reasonable
83/// without adding a dedicated error variant for a narrow, rare case.
84const WRITE_CHANNEL_CAPACITY: usize = 1024;
85
86type PendingSender = oneshot::Sender<std::result::Result<serde_json::Value, Error>>;
87type PendingMap = Arc<Mutex<HashMap<i64, PendingSender>>>;
88
89/// Locks `pending`, recovering from mutex poisoning rather than panicking a
90/// second time. A panic while this lock is held elsewhere in the crate can't
91/// leave the underlying `HashMap` in a logically-broken state - every
92/// operation on it (`insert`/`remove`/`clear`) either fully completes or
93/// doesn't start - so recovering the guard and continuing is safe, and it
94/// avoids turning one unrelated bug into a crate-wide cascade of
95/// poisoned-lock panics on every subsequent call.
96fn lock_pending(pending: &PendingMap) -> MutexGuard<'_, HashMap<i64, PendingSender>> {
97    pending
98        .lock()
99        .unwrap_or_else(|poisoned| poisoned.into_inner())
100}
101
102/// An event pushed from the app-server connection. Deliberately *not*
103/// `#[non_exhaustive]` (contrast [`Error`], which explains its own opposite
104/// choice) - this models a closed, stable three-way split of "shape of thing
105/// the app-server can send," not an open-ended taxonomy, so callers matching
106/// on it exhaustively (as this crate's own code does everywhere) get a
107/// compile error if a variant is ever added, rather than silently ignoring
108/// the new case via a wildcard arm.
109#[derive(Debug)]
110pub enum Event {
111    /// A fire-and-forget server notification (`turn/completed`, `item/started`, etc.).
112    Notification(ServerNotification),
113    /// A request the app-server expects a reply to (approvals, elicitation, etc.).
114    Request(PendingServerRequest),
115    /// The transport closed (EOF or the child process exited). No further
116    /// events will be produced; the [`EventStream`] is exhausted.
117    Closed,
118}
119
120/// Receives [`Event`]s from one app-server connection.
121///
122/// Own exactly one `EventStream` per connection and keep draining it (even if
123/// you only care about requests, not notifications). The channel between the
124/// reader task and this stream is bounded (see
125/// `DEFAULT_EVENTS_CHANNEL_CAPACITY`, overridable per-connection via
126/// [`CodexAppServerClient::spawn_with_events_capacity`] and friends): a slow
127/// consumer just grows a fixed-size backlog, but a consumer that stops
128/// draining entirely will eventually cause the reader task to drop events
129/// once that backlog fills. Drop policy when full:
130/// - [`Event::Notification`]: dropped and logged - fire-and-forget by design.
131/// - [`Event::Request`]: **not** silently dropped - this crate sends a
132///   fallback error reply on the app-server's behalf first (so it isn't left
133///   hanging), then drops the event.
134/// - [`Event::Closed`]: dropped and logged, but harmless - once the reader
135///   task ends it drops its sender, so [`Self::recv`] still observes the
136///   connection closing (as `None`) even without the explicit event.
137pub struct EventStream {
138    rx: mpsc::Receiver<Event>,
139}
140
141impl EventStream {
142    pub async fn recv(&mut self) -> Option<Event> {
143        self.rx.recv().await
144    }
145}
146
147/// A server->client request the app-server sent us (approvals, elicitation,
148/// etc.), paired with a one-shot reply channel. Obtain these from
149/// [`EventStream::recv`] as an [`Event::Request`].
150///
151/// Every `PendingServerRequest` gets exactly one reply, no matter what
152/// happens to it: call [`Self::respond`] or [`Self::respond_error`] to send
153/// your own, or just let it drop - deliberately, via cancellation, or via a
154/// panic unwinding through it - and its [`Drop`] impl sends a generic
155/// fallback error on your behalf. The app-server is never left permanently
156/// unanswered either way. Prefer responding explicitly and promptly when you
157/// can; the fallback exists so a bug, an unhandled case, or a task
158/// abandoning this value doesn't turn into a silent hang.
159pub struct PendingServerRequest {
160    pub request: ServerRequest,
161    reply_tx: Option<oneshot::Sender<OutgoingReply>>,
162    reply_deadline: Instant,
163}
164
165impl std::fmt::Debug for PendingServerRequest {
166    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
167        f.debug_struct("PendingServerRequest")
168            .field("request", &self.request)
169            .finish_non_exhaustive()
170    }
171}
172
173impl PendingServerRequest {
174    #[cfg(all(test, feature = "rest"))]
175    pub(crate) fn for_test(request: ServerRequest) -> Self {
176        let (reply_tx, _reply_rx) = oneshot::channel::<OutgoingReply>();
177        Self {
178            request,
179            reply_tx: Some(reply_tx),
180            reply_deadline: Instant::now() + PENDING_SERVER_REQUEST_TIMEOUT,
181        }
182    }
183
184    /// The `RequestId` the app-server expects echoed back in the reply.
185    pub fn id(&self) -> &RequestId {
186        self.request.id()
187    }
188
189    /// The wire method name, e.g. `"execCommandApproval"`.
190    pub fn method_name(&self) -> &'static str {
191        self.request.method_name()
192    }
193
194    /// The name of the `crate::protocol` type [`Self::respond`]'s `result`
195    /// must serialize to for this specific request, e.g.
196    /// `"ExecCommandApprovalResponse"`. Prefer this over guessing from
197    /// [`Self::method_name`] - the naming convention (`PascalCase(method) +
198    /// "Response"`) doesn't hold for every method (e.g.
199    /// `"item/tool/call"` expects `DynamicToolCallResponse`, not
200    /// `ItemToolCallResponse`).
201    pub fn expected_response_type_name(&self) -> &'static str {
202        self.request.expected_response_type_name()
203    }
204
205    /// Absolute deadline after which the internal forwarding task no longer
206    /// accepts a reply for this request.
207    pub fn reply_deadline(&self) -> Instant {
208        self.reply_deadline
209    }
210
211    /// Send a successful reply. `result` must serialize to the response
212    /// shape the app-server expects for this specific method - see
213    /// [`Self::expected_response_type_name`] for exactly which
214    /// `crate::protocol` type that is. `Err` only means `result` failed to
215    /// serialize or the forwarding task already stopped accepting replies.
216    pub fn respond(mut self, result: impl serde::Serialize) -> Result<()> {
217        let id = self.id().clone();
218        let value = serde_json::to_value(result)?;
219        self.send_reply(OutgoingReply::Result { id, result: value })
220    }
221
222    /// Send an error reply.
223    pub fn respond_error(
224        mut self,
225        code: i64,
226        message: impl Into<String>,
227        data: Option<serde_json::Value>,
228    ) -> Result<()> {
229        let id = self.id().clone();
230        self.send_reply(OutgoingReply::Error {
231            id,
232            code,
233            message: message.into(),
234            data,
235        })
236    }
237
238    /// Takes `reply_tx` (leaving `None` so [`Drop`] becomes a no-op) and
239    /// sends `reply` through it, logging - rather than silently discarding -
240    /// the case where the forwarding task already gave up and dropped its
241    /// receiving end.
242    fn send_reply(&mut self, reply: OutgoingReply) -> Result<()> {
243        let Some(tx) = self.reply_tx.take() else {
244            return Err(Error::TransportClosed);
245        };
246        if tx.send(reply).is_err() {
247            tracing::debug!(
248                method = self.method_name(),
249                "reply channel already closed (the forwarding task must have already given up, \
250                 e.g. its own timeout fired) - this reply was accepted but could not be delivered"
251            );
252            return Err(Error::TransportClosed);
253        }
254        Ok(())
255    }
256}
257
258impl Drop for PendingServerRequest {
259    fn drop(&mut self) {
260        // `respond`/`respond_error` already took `reply_tx` (leaving `None`)
261        // if either was called - only a bare drop (deliberate, cancelled, or
262        // unwinding through a panic) reaches this with `Some` still set.
263        let Some(tx) = self.reply_tx.take() else {
264            return;
265        };
266        let id = self.request.id().clone();
267        let _ = tx.send(OutgoingReply::Error {
268            id,
269            code: -32000,
270            message: "codex-app-server-client: PendingServerRequest was dropped without a response"
271                .to_string(),
272            data: None,
273        });
274    }
275}
276
277/// Drop signal decoupled from any internal task's channel clones. Only
278/// [`CodexAppServerClient`] holds (a clone of) the `Arc` wrapping this; the
279/// reader task and per-request reply-forwarding tasks intentionally do *not*
280/// hold *this* handle (though the reader task holds its own [`CancellationToken`]
281/// clone directly - see `connect` - so it can also trigger shutdown itself on
282/// EOF/error, not just observe it), so the connection's lifetime tracks "the
283/// caller still holds a client handle, or the connection is known to be dead"
284/// rather than "some internal task still happens to hold a sender."
285struct ShutdownOnDrop(CancellationToken);
286
287impl Drop for ShutdownOnDrop {
288    fn drop(&mut self) {
289        self.0.cancel();
290    }
291}
292
293/// Async client for the Codex app-server v2 JSON-RPC protocol.
294///
295/// Cheap to `Clone`; every clone shares the same underlying connection and
296/// pending-request table. Construct one with [`Self::spawn`] (launches
297/// `codex app-server` as a child process, the common case), [`Self::connect_streams`]
298/// (bring your own duplex byte stream), or [`Self::connect_unix`] (a Unix
299/// socket to an already-running `codex app-server daemon`).
300///
301/// You must complete the `initialize` / `initialized` handshake before calling
302/// any other method - the app-server rejects everything else on a fresh
303/// connection with a `"Not initialized"` error. See [`Self::initialize`] and
304/// [`Self::send_initialized`].
305#[derive(Clone)]
306pub struct CodexAppServerClient {
307    write_tx: mpsc::Sender<String>,
308    pending: PendingMap,
309    next_id: Arc<AtomicI64>,
310    call_timeout: Duration,
311    _lifetime: Arc<ShutdownOnDrop>,
312}
313
314impl CodexAppServerClient {
315    /// Spawns `command app-server` (default `command = "codex"`) with stdio
316    /// piped and connects to it over the stdio JSONL transport.
317    ///
318    /// `extra_args` are appended after `app-server`, e.g.
319    /// `["--enable".into(), "some_feature".into()]` or
320    /// `["-c".into(), r#"model="gpt-5.4""#.into()]`.
321    ///
322    /// The child process is killed (`kill_on_drop`) once every clone of the
323    /// returned client has been dropped - dropping the last clone always
324    /// terminates the child, whether or not it's currently mid-write. It's
325    /// also reaped promptly and automatically the moment the connection is
326    /// otherwise known to be dead: a transport read error, clean EOF (e.g.
327    /// the child crashed or exited), or a write that exceeds the internal
328    /// stall timeout - none of these require the caller to drop the client or
329    /// issue another call first.
330    pub fn spawn(command: &str, extra_args: &[String]) -> Result<(Self, EventStream)> {
331        Self::spawn_with_events_capacity(command, extra_args, DEFAULT_EVENTS_CHANNEL_CAPACITY)
332    }
333
334    /// Same as [`Self::spawn`], but with an explicit capacity for the
335    /// internal event channel instead of [`DEFAULT_EVENTS_CHANNEL_CAPACITY`].
336    /// See [`EventStream`]'s doc comment for what this bounds and the drop
337    /// policy once it fills up.
338    ///
339    /// # Errors
340    ///
341    /// Returns [`Error::Io`] if `events_capacity` is `0` -
342    /// `tokio::sync::mpsc::channel` panics on a zero capacity, and that
343    /// panic's message doesn't mention which knob caused it (useful context
344    /// this crate has and a bare `unwrap` deep inside tokio wouldn't
345    /// surface), so it's rejected here instead with a message that does.
346    pub fn spawn_with_events_capacity(
347        command: &str,
348        extra_args: &[String],
349        events_capacity: usize,
350    ) -> Result<(Self, EventStream)> {
351        Self::validate_events_capacity(events_capacity)?;
352        let (stdin, reader, child) = transport::spawn_app_server(command, extra_args)?;
353        Ok(Self::connect(reader, stdin, Some(child), events_capacity))
354    }
355
356    /// Connects to an already-open duplex stream (e.g. any
357    /// `AsyncBufRead + AsyncWrite` pair) speaking the same NDJSON JSON-RPC
358    /// protocol. See also [`Self::connect_unix`] for the common case of a
359    /// `codex app-server daemon`'s Unix domain socket.
360    pub fn connect_streams<R, W>(reader: R, writer: W) -> (Self, EventStream)
361    where
362        R: AsyncBufRead + Unpin + Send + 'static,
363        W: AsyncWrite + Unpin + Send + 'static,
364    {
365        Self::connect(reader, writer, None, DEFAULT_EVENTS_CHANNEL_CAPACITY)
366    }
367
368    /// Same as [`Self::connect_streams`], but with an explicit capacity for
369    /// the internal event channel instead of
370    /// [`DEFAULT_EVENTS_CHANNEL_CAPACITY`]. See
371    /// [`Self::spawn_with_events_capacity`] for the zero-capacity error case
372    /// (identical here).
373    pub fn connect_streams_with_events_capacity<R, W>(
374        reader: R,
375        writer: W,
376        events_capacity: usize,
377    ) -> Result<(Self, EventStream)>
378    where
379        R: AsyncBufRead + Unpin + Send + 'static,
380        W: AsyncWrite + Unpin + Send + 'static,
381    {
382        Self::validate_events_capacity(events_capacity)?;
383        Ok(Self::connect(reader, writer, None, events_capacity))
384    }
385
386    /// Connects to a `codex app-server` (or `codex app-server daemon`)
387    /// listening on a Unix domain socket (`--listen unix://PATH`).
388    #[cfg(unix)]
389    pub async fn connect_unix(path: impl AsRef<std::path::Path>) -> Result<(Self, EventStream)> {
390        Self::connect_unix_with_events_capacity(path, DEFAULT_EVENTS_CHANNEL_CAPACITY).await
391    }
392
393    /// Same as [`Self::connect_unix`], but with an explicit capacity for the
394    /// internal event channel instead of [`DEFAULT_EVENTS_CHANNEL_CAPACITY`].
395    /// See [`Self::spawn_with_events_capacity`] for the zero-capacity error
396    /// case (identical here).
397    #[cfg(unix)]
398    pub async fn connect_unix_with_events_capacity(
399        path: impl AsRef<std::path::Path>,
400        events_capacity: usize,
401    ) -> Result<(Self, EventStream)> {
402        Self::validate_events_capacity(events_capacity)?;
403        let stream = tokio::net::UnixStream::connect(path).await?;
404        let (writer, reader) = transport::split_unix_stream(stream);
405        Ok(Self::connect(reader, writer, None, events_capacity))
406    }
407
408    /// Rejects a zero `events_capacity` before it can reach
409    /// `tokio::sync::mpsc::channel` (which panics on capacity `0` with a
410    /// message that has no idea it's about an event channel, let alone which
411    /// constructor or env var supplied the bad value). Called by every
412    /// `*_with_events_capacity` constructor; the capacity-less constructors
413    /// never call this because [`DEFAULT_EVENTS_CHANNEL_CAPACITY`] is a
414    /// compile-time constant that is never zero.
415    fn validate_events_capacity(events_capacity: usize) -> Result<()> {
416        if events_capacity == 0 {
417            return Err(Error::Io(std::io::Error::new(
418                std::io::ErrorKind::InvalidInput,
419                "events_capacity must be greater than zero",
420            )));
421        }
422        Ok(())
423    }
424
425    /// Returns a client that applies `timeout` to every request/response call
426    /// instead of [`DEFAULT_CALL_TIMEOUT`]. Cloning preserves the timeout;
427    /// mixing timeouts across clones of the same connection is fine (it's a
428    /// per-call setting checked in `Self::call_request`, not a property of
429    /// the shared connection state).
430    pub fn with_call_timeout(mut self, timeout: Duration) -> Self {
431        self.call_timeout = timeout;
432        self
433    }
434
435    /// `events_capacity` must be non-zero - every caller reaching this is a
436    /// `*_with_events_capacity` constructor (see [`Self::validate_events_capacity`])
437    /// or one of the capacity-less constructors passing the never-zero
438    /// [`DEFAULT_EVENTS_CHANNEL_CAPACITY`], so this does not re-validate.
439    fn connect<R, W>(
440        mut reader: R,
441        mut writer: W,
442        child: Option<Child>,
443        events_capacity: usize,
444    ) -> (Self, EventStream)
445    where
446        R: AsyncBufRead + Unpin + Send + 'static,
447        W: AsyncWrite + Unpin + Send + 'static,
448    {
449        debug_assert!(
450            events_capacity > 0,
451            "events_capacity must be validated (see Self::validate_events_capacity) before \
452             reaching `connect` - tokio::sync::mpsc::channel panics on a capacity of 0"
453        );
454        let (write_tx, mut write_rx) = mpsc::channel::<String>(WRITE_CHANNEL_CAPACITY);
455        let (events_tx, events_rx) = mpsc::channel::<Event>(events_capacity);
456        let pending: PendingMap = Arc::new(Mutex::new(HashMap::new()));
457        let cancel = CancellationToken::new();
458
459        // Writer task: owns the write half + (if spawned) the child process,
460        // so the process stays alive exactly as long as this task is running.
461        // Exits (and so kills the child) on: cancellation - triggered either by
462        // the last CodexAppServerClient clone being dropped (`ShutdownOnDrop`)
463        // *or* by the reader task detecting the connection is dead (EOF/error,
464        // see below) - a write that exceeds WRITE_TIMEOUT (peer stalled), a
465        // write I/O error, or every sender having been dropped (fallback -
466        // should be unreachable in practice since the reader task and
467        // forwarding tasks are careful not to hold long-lived clones past
468        // their own task's natural lifetime, but a real fallback rather than
469        // an assumption).
470        let writer_cancel = cancel.clone();
471        tokio::spawn(async move {
472            let _child = child; // kept alive here; dropped (kill_on_drop) when this task ends
473            loop {
474                tokio::select! {
475                    biased;
476                    _ = writer_cancel.cancelled() => break,
477                    maybe_line = write_rx.recv() => {
478                        let Some(line) = maybe_line else { break };
479                        match tokio::time::timeout(WRITE_TIMEOUT, transport::write_line(&mut writer, &line)).await {
480                            Ok(Ok(())) => {}
481                            Ok(Err(err)) => {
482                                tracing::warn!(error = %err, "app-server transport write error; closing connection");
483                                break;
484                            }
485                            Err(_elapsed) => {
486                                tracing::warn!(timeout = ?WRITE_TIMEOUT, "app-server write stalled past the timeout; closing connection");
487                                break;
488                            }
489                        }
490                    }
491                }
492            }
493            // Explicitly shut down the write half rather than relying on drop
494            // alone: for a spawned child, this closes its stdin, giving it a
495            // chance to notice and exit cleanly before `kill_on_drop` resorts
496            // to a hard kill; for split-stream transports (`connect_streams`,
497            // `connect_unix`), a bare drop doesn't reliably signal EOF to the
498            // peer when a `ReadHalf` derived from the same underlying stream
499            // is still alive elsewhere (as it is here, in the reader task).
500            let _ = writer.shutdown().await;
501        });
502
503        // Reader task: the only place incoming lines are parsed and dispatched.
504        // It needs its own `write_tx` clone to send replies (error responses
505        // to undecodable requests, forwarded PendingServerRequest replies),
506        // so it necessarily holds one for its whole lifetime - that's fine
507        // now, because the writer task's shutdown no longer depends on every
508        // `write_tx` clone being dropped (see `ShutdownOnDrop`'s doc comment
509        // and the `select!` above); it depends on `cancel`.
510        let pending_reader = pending.clone();
511        let write_tx_for_reader = write_tx.clone();
512        let reader_cancel = cancel.clone();
513        tokio::spawn(async move {
514            // Guarantees the three cleanup steps below run on *every* exit
515            // from the loop - clean EOF, a transport error, cancellation, or
516            // a panic unwinding out of `dispatch_incoming_line` - rather than
517            // only the two normal `break` paths the loop was originally
518            // written to reach. Without this, a panic here would silently
519            // orphan the connection: pending calls never resolve (each rides
520            // out its own timeout instead), and the writer task (and, for a
521            // spawned child, the process) is never reaped, because nothing
522            // else calls `reader_cancel.cancel()` on this task's behalf. Owns
523            // cheap clones (`Arc`/`Sender`/`CancellationToken`) rather than
524            // borrowing so it has no lifetime relationship to the rest of
525            // this function's locals to reason about.
526            struct ReaderCleanup {
527                pending: PendingMap,
528                events_tx: mpsc::Sender<Event>,
529                reader_cancel: CancellationToken,
530            }
531            impl Drop for ReaderCleanup {
532                fn drop(&mut self) {
533                    lock_pending(&self.pending).clear(); // drops senders -> pending calls see TransportClosed
534                    if let Err(err) = self.events_tx.try_send(Event::Closed) {
535                        // Harmless: dropping `events_tx` (this task ending)
536                        // still makes `EventStream::recv` observe closure as
537                        // `None`.
538                        tracing::debug!(
539                            ?err,
540                            "event channel full/closed while sending Event::Closed"
541                        );
542                    }
543                    // Proactively tear down the writer task (and reap a
544                    // spawned child) the moment we know the connection is
545                    // dead, rather than waiting for the caller to notice
546                    // `Event::Closed` and drop the client, or for the
547                    // writer's own next write attempt to fail.
548                    self.reader_cancel.cancel();
549                }
550            }
551            let _cleanup = ReaderCleanup {
552                pending: pending_reader.clone(),
553                events_tx: events_tx.clone(),
554                reader_cancel: reader_cancel.clone(),
555            };
556
557            let mut buf = String::new();
558            loop {
559                // Raced against `reader_cancel` (not just relied on via
560                // EOF/error) so a caller-initiated shutdown - the last client
561                // clone dropping - terminates this task promptly even when
562                // the peer never notices the writer's half-close and so never
563                // sends EOF back (a real risk for `connect_streams`/
564                // `connect_unix`, which - unlike a spawned child - have no
565                // `kill_on_drop` to force the issue).
566                tokio::select! {
567                    biased;
568                    _ = reader_cancel.cancelled() => break,
569                    result = transport::read_line(&mut reader, &mut buf) => {
570                        match result {
571                            Ok(0) => break, // EOF
572                            Ok(_) => {
573                                let line = buf.trim();
574                                if line.is_empty() {
575                                    continue;
576                                }
577                                dispatch::dispatch_incoming_line(
578                                    line,
579                                    &pending_reader,
580                                    &events_tx,
581                                    &write_tx_for_reader,
582                                    &reader_cancel,
583                                );
584                            }
585                            Err(err) => {
586                                tracing::warn!(error = %err, "app-server transport read error");
587                                break;
588                            }
589                        }
590                    }
591                }
592            }
593        });
594
595        let client = CodexAppServerClient {
596            write_tx,
597            pending,
598            next_id: Arc::new(AtomicI64::new(0)),
599            call_timeout: DEFAULT_CALL_TIMEOUT,
600            _lifetime: Arc::new(ShutdownOnDrop(cancel)),
601        };
602        (client, EventStream { rx: events_rx })
603    }
604
605    /// Issues one typed request and awaits its response as a raw JSON value,
606    /// bounded by [`Self::call_timeout`] (see [`Self::with_call_timeout`]).
607    /// Used by the generated per-method wrapper functions
608    /// (`thread_start`, `turn_start`, ...); most callers should use those
609    /// instead of this directly.
610    pub(crate) async fn call_request(
611        &self,
612        build: impl FnOnce(RequestId) -> ClientRequest,
613    ) -> Result<serde_json::Value> {
614        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
615        let request = build(RequestId::Int64(id));
616        let line = serde_json::to_string(&request)?;
617        self.call_serialized_request(id, line).await
618    }
619
620    /// Issues one raw JSON-RPC method call and returns its raw `result` value.
621    ///
622    /// This is the escape hatch for bridges and generated surfaces that need
623    /// to call app-server methods dynamically. Prefer the typed generated
624    /// wrappers when the method is known at compile time.
625    pub async fn call_raw_method(
626        &self,
627        method: impl Into<String>,
628        params: serde_json::Value,
629    ) -> Result<serde_json::Value> {
630        let id = self.next_id.fetch_add(1, Ordering::Relaxed);
631        let mut request = serde_json::Map::new();
632        request.insert("id".to_owned(), serde_json::Value::from(id));
633        request.insert(
634            "method".to_owned(),
635            serde_json::Value::String(method.into()),
636        );
637        if !params.is_null() {
638            request.insert("params".to_owned(), params);
639        }
640        let line = serde_json::to_string(&request)?;
641        self.call_serialized_request(id, line).await
642    }
643
644    async fn call_serialized_request(&self, id: i64, line: String) -> Result<serde_json::Value> {
645        let (tx, rx) = oneshot::channel();
646        lock_pending(&self.pending).insert(id, tx);
647
648        // Removes this id's map entry when this guard drops, which happens
649        // whether `call_request` returns normally, is cancelled (e.g. wrapped
650        // in `tokio::time::timeout` or a `select!` branch that loses), or
651        // panics. Without this, a cancelled call whose response never arrives
652        // (or arrives after the caller already gave up) leaks its map entry
653        // and `oneshot::Sender` for the life of the connection. Redundant
654        // (and harmless - `HashMap::remove` on a missing key is a no-op) on
655        // the normal-completion path, where `dispatch_incoming_line` already
656        // removed the entry.
657        struct RemoveOnDrop<'a> {
658            pending: &'a PendingMap,
659            id: i64,
660        }
661        impl Drop for RemoveOnDrop<'_> {
662            fn drop(&mut self) {
663                lock_pending(self.pending).remove(&self.id);
664            }
665        }
666        let _guard = RemoveOnDrop {
667            pending: &self.pending,
668            id,
669        };
670
671        if self.write_tx.try_send(line).is_err() {
672            return Err(Error::TransportClosed);
673        }
674
675        match tokio::time::timeout(self.call_timeout, rx).await {
676            Ok(Ok(result)) => result,
677            Ok(Err(_recv_error)) => Err(Error::TransportClosed),
678            Err(_elapsed) => Err(Error::Timeout {
679                after: self.call_timeout,
680            }),
681        }
682    }
683
684    /// Sends the `initialized` notification. Call this exactly once,
685    /// immediately after [`Self::initialize`] succeeds - the app-server
686    /// rejects every other method until it's received.
687    pub fn send_initialized(&self) -> Result<()> {
688        let line = serde_json::to_string(&serde_json::json!({ "method": "initialized" }))?;
689        self.write_tx
690            .try_send(line)
691            .map_err(|_| Error::TransportClosed)
692    }
693}
694
695include!(concat!(env!("OUT_DIR"), "/methods_generated.rs"));
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700    use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
701
702    /// Regression test: `expected_response_type_name()` must resolve to the
703    /// *actual* response type even for methods where the naive
704    /// `PascalCase(method) + "Response"` convention is wrong -
705    /// `item/tool/call` is one of the 6 (of 11) server-request methods that
706    /// need the `RESPONSE_OVERRIDES` table in
707    /// `xtask/src/codex_schema/naming.rs` (it expects `DynamicToolCallResponse`,
708    /// not the naive `ItemToolCallResponse`).
709    #[tokio::test]
710    async fn expected_response_type_name_resolves_irregular_names_correctly() {
711        let (reply_tx, _reply_rx) = oneshot::channel::<OutgoingReply>();
712        let pending = PendingServerRequest {
713            request: ServerRequest::ItemToolCall {
714                id: RequestId::Int64(1),
715                params: crate::protocol::DynamicToolCallParams {
716                    arguments: serde_json::json!({}),
717                    call_id: "call-1".into(),
718                    namespace: None,
719                    thread_id: "thr_test".into(),
720                    tool: "some_tool".into(),
721                    turn_id: "turn_1".into(),
722                },
723            },
724            reply_tx: Some(reply_tx),
725            reply_deadline: Instant::now() + PENDING_SERVER_REQUEST_TIMEOUT,
726        };
727        assert_eq!(
728            pending.expected_response_type_name(),
729            "DynamicToolCallResponse"
730        );
731    }
732
733    /// `PendingServerRequest::respond()` must serialize `result` and put it
734    /// on the wire as `{"id": ..., "result": ...}` - the exact shape the
735    /// app-server expects for a reply to one of its requests. Constructs the
736    /// pair directly (bypassing the full dispatch/connection machinery,
737    /// which is exercised by other tests) to check the wire format in
738    /// isolation.
739    #[tokio::test]
740    async fn respond_puts_the_correct_shape_on_the_wire() {
741        let (reply_tx, reply_rx) = oneshot::channel::<OutgoingReply>();
742        let pending = PendingServerRequest {
743            request: ServerRequest::CurrentTimeRead {
744                id: RequestId::Int64(7),
745                params: crate::protocol::CurrentTimeReadParams {
746                    thread_id: "thr_test".into(),
747                },
748            },
749            reply_tx: Some(reply_tx),
750            reply_deadline: Instant::now() + PENDING_SERVER_REQUEST_TIMEOUT,
751        };
752        assert_eq!(pending.method_name(), "currentTime/read");
753        assert_eq!(pending.id(), &RequestId::Int64(7));
754
755        pending
756            .respond(serde_json::json!({ "currentTimeAt": 12345 }))
757            .expect("respond should succeed for a plain serializable value");
758
759        let reply = reply_rx
760            .await
761            .expect("forwarding channel should receive the reply");
762        let line = reply
763            .into_line()
764            .expect("reply should serialize to a wire line");
765        let parsed: serde_json::Value = serde_json::from_str(&line).unwrap();
766        assert_eq!(
767            parsed,
768            serde_json::json!({ "id": 7, "result": { "currentTimeAt": 12345 } })
769        );
770    }
771
772    /// Same as above but for the error-reply path.
773    #[tokio::test]
774    async fn respond_error_puts_the_correct_shape_on_the_wire() {
775        let (reply_tx, reply_rx) = oneshot::channel::<OutgoingReply>();
776        let pending = PendingServerRequest {
777            request: ServerRequest::CurrentTimeRead {
778                id: RequestId::String("req-1".into()),
779                params: crate::protocol::CurrentTimeReadParams {
780                    thread_id: "thr_test".into(),
781                },
782            },
783            reply_tx: Some(reply_tx),
784            reply_deadline: Instant::now() + PENDING_SERVER_REQUEST_TIMEOUT,
785        };
786
787        pending.respond_error(-32000, "denied", None).unwrap();
788
789        let reply = reply_rx
790            .await
791            .expect("forwarding channel should receive the reply");
792        let line = reply
793            .into_line()
794            .expect("reply should serialize to a wire line");
795        let parsed: serde_json::Value = serde_json::from_str(&line).unwrap();
796        assert_eq!(
797            parsed,
798            serde_json::json!({ "id": "req-1", "error": { "code": -32000, "message": "denied", "data": null } })
799        );
800    }
801
802    /// Regression test: `PendingServerRequest`'s `Drop` impl must send a
803    /// fallback error reply when a value is dropped without ever calling
804    /// `.respond()`/`.respond_error()` - the whole point of moving from a
805    /// bare `oneshot::Sender` field to `Option<...>` + `Drop`. Constructs the
806    /// pair directly (bypassing the full dispatch/connection machinery) to
807    /// check the `Drop`-triggered wire shape in isolation.
808    #[tokio::test]
809    async fn dropping_a_pending_server_request_without_responding_sends_a_fallback_error() {
810        let (reply_tx, reply_rx) = oneshot::channel::<OutgoingReply>();
811        let pending = PendingServerRequest {
812            request: ServerRequest::CurrentTimeRead {
813                id: RequestId::Int64(42),
814                params: crate::protocol::CurrentTimeReadParams {
815                    thread_id: "thr_test".into(),
816                },
817            },
818            reply_tx: Some(reply_tx),
819            reply_deadline: Instant::now() + PENDING_SERVER_REQUEST_TIMEOUT,
820        };
821
822        drop(pending); // no .respond()/.respond_error() call
823
824        let reply = reply_rx
825            .await
826            .expect("Drop must send a fallback reply, not just drop the sender silently");
827        let line = reply
828            .into_line()
829            .expect("fallback reply should serialize to a wire line");
830        let parsed: serde_json::Value = serde_json::from_str(&line).unwrap();
831        assert_eq!(parsed["id"], 42);
832        assert_eq!(parsed["error"]["code"], -32000);
833        assert!(
834            parsed["error"]["message"]
835                .as_str()
836                .unwrap()
837                .contains("dropped without a response"),
838            "unexpected fallback message: {parsed}"
839        );
840    }
841
842    /// `connect_unix` must complete a real handshake over an actual Unix
843    /// domain socket - the only transport constructor not otherwise exercised
844    /// by the `connect_streams`-based tests in this module (or by the live
845    /// `tests/smoke.rs`, which only spawns a child over stdio).
846    #[cfg(unix)]
847    #[tokio::test]
848    async fn connect_unix_completes_a_round_trip() {
849        let dir = tempfile::tempdir().unwrap();
850        let socket_path = dir.path().join("codex-app-server-client-test.sock");
851        let listener = tokio::net::UnixListener::bind(&socket_path).unwrap();
852
853        let accept_task = tokio::spawn(async move {
854            let (stream, _addr) = listener.accept().await.unwrap();
855            let (read_half, mut write_half) = stream.into_split();
856            let mut reader = BufReader::new(read_half);
857            let mut line = String::new();
858            reader.read_line(&mut line).await.unwrap();
859            let request: serde_json::Value = serde_json::from_str(&line).unwrap();
860            assert_eq!(request["method"], "memory/reset");
861            let id = request["id"].clone();
862            // MemoryResetResponse wraps a JSON object (`serde_json::Map`), not `null`.
863            let response = serde_json::json!({ "id": id, "result": {} });
864            write_half
865                .write_all(format!("{response}\n").as_bytes())
866                .await
867                .unwrap();
868        });
869
870        let (client, _events) = CodexAppServerClient::connect_unix(&socket_path)
871            .await
872            .expect("connect_unix should dial the listening socket");
873        client
874            .memory_reset()
875            .await
876            .expect("round trip over the Unix socket should succeed");
877
878        accept_task.await.unwrap();
879    }
880
881    /// Regression test: dropping a `oneshot::Sender` wakes its paired
882    /// `Receiver` immediately with an error - it does not require the
883    /// `Receiver`'s side of a `tokio::time::timeout` to elapse. This is the
884    /// exact mechanism `PendingServerRequest`'s forwarding task relies on to
885    /// resolve promptly (not after `PENDING_SERVER_REQUEST_TIMEOUT`) when a
886    /// caller drops a `PendingServerRequest` without responding.
887    #[tokio::test]
888    async fn dropping_a_oneshot_sender_resolves_the_receiver_immediately_not_via_timeout() {
889        let (tx, rx) = oneshot::channel::<()>();
890        drop(tx);
891        let result = tokio::time::timeout(Duration::from_secs(600), rx).await;
892        assert!(
893            matches!(result, Ok(Err(_))),
894            "dropping the sender should resolve the receiver immediately with an error, \
895             not by waiting out the 600s timeout - got {result:?}"
896        );
897    }
898
899    /// Regression test: dropping a `PendingServerRequest` without responding
900    /// must not wedge the connection - the reader must keep dispatching
901    /// subsequent server->client requests normally afterward. Uses a real
902    /// `currentTime/read` wire line (the simplest `ServerRequest` variant -
903    /// `CurrentTimeReadParams` has exactly one required field) written
904    /// directly into the duplex to exercise the actual `dispatch_incoming_line`
905    /// path, not a hand-rolled shortcut.
906    #[tokio::test]
907    async fn an_abandoned_pending_server_request_does_not_wedge_the_connection() {
908        let (client_io, mut server_io) = tokio::io::duplex(4096);
909        let (client_read, client_write) = tokio::io::split(client_io);
910        let (_client, mut events) =
911            CodexAppServerClient::connect_streams(BufReader::new(client_read), client_write);
912
913        let send_current_time_read = |id: i64| {
914            format!(
915                r#"{{"method":"currentTime/read","id":{id},"params":{{"threadId":"thr_test"}}}}"#
916            )
917        };
918
919        server_io
920            .write_all(format!("{}\n", send_current_time_read(1)).as_bytes())
921            .await
922            .unwrap();
923        let first = tokio::time::timeout(Duration::from_secs(5), events.recv())
924            .await
925            .expect("first event within timeout")
926            .expect("event stream still open");
927        let Event::Request(pending) = first else {
928            panic!("expected Event::Request, got something else");
929        };
930        drop(pending); // abandoned without .respond()/.respond_error()
931
932        server_io
933            .write_all(format!("{}\n", send_current_time_read(2)).as_bytes())
934            .await
935            .unwrap();
936        let second = tokio::time::timeout(Duration::from_secs(5), events.recv())
937            .await
938            .expect("second event within timeout - connection must not be wedged by the abandoned request")
939            .expect("event stream still open");
940        assert!(
941            matches!(second, Event::Request(_)),
942            "expected a second Event::Request after abandoning the first, got something else"
943        );
944    }
945
946    /// Regression test: when `EventStream` is dropped, an incoming
947    /// server->client request must still get a JSON-RPC error reply on the
948    /// wire - never silently dropped - exercising the `events_tx.try_send`
949    /// failure branch in `dispatch::dispatch_incoming_line` (the behavioral
950    /// change this session's channel-bounding fix was about; with the
951    /// previous unbounded channel this branch could never be reached at
952    /// all).
953    #[tokio::test]
954    async fn a_server_request_gets_a_fallback_error_reply_when_the_event_stream_is_dropped() {
955        let (client_io, mut server_io) = tokio::io::duplex(4096);
956        let (client_read, client_write) = tokio::io::split(client_io);
957        let (_client, events) =
958            CodexAppServerClient::connect_streams(BufReader::new(client_read), client_write);
959        drop(events); // closes the events_tx receiver -> try_send returns Closed
960
961        server_io
962            .write_all(br#"{"method":"currentTime/read","id":9,"params":{"threadId":"thr_test"}}"#)
963            .await
964            .unwrap();
965        server_io.write_all(b"\n").await.unwrap();
966
967        let mut buf = [0u8; 4096];
968        let n = tokio::time::timeout(Duration::from_secs(5), server_io.read(&mut buf))
969            .await
970            .expect("should get a reply promptly, not a hang")
971            .unwrap();
972        let reply: serde_json::Value = serde_json::from_slice(&buf[..n]).unwrap();
973        assert_eq!(reply["id"], 9);
974        assert_eq!(reply["error"]["code"], -32000);
975    }
976
977    /// Regression test: `turn/completed` is the terminal event
978    /// `wait_for_turn_completed` needs in order to stop waiting. Ordinary
979    /// notifications can still be dropped under backpressure, but this one
980    /// must survive a full event channel.
981    #[tokio::test]
982    async fn turn_completed_notification_is_delivered_when_the_event_channel_is_full() {
983        let (client_io, mut server_io) = tokio::io::duplex(1024 * 1024);
984        let (client_read, client_write) = tokio::io::split(client_io);
985        let (_client, mut events) =
986            CodexAppServerClient::connect_streams(BufReader::new(client_read), client_write);
987
988        for index in 0..DEFAULT_EVENTS_CHANNEL_CAPACITY {
989            let delta = serde_json::json!({
990                "method": "item/agentMessage/delta",
991                "params": {
992                    "threadId": "thread-1",
993                    "turnId": "turn-1",
994                    "itemId": format!("item-{index}"),
995                    "delta": "x"
996                }
997            });
998            server_io
999                .write_all(format!("{delta}\n").as_bytes())
1000                .await
1001                .unwrap();
1002        }
1003
1004        tokio::time::sleep(Duration::from_millis(100)).await;
1005
1006        let completed = serde_json::json!({
1007            "method": "turn/completed",
1008            "params": {
1009                "threadId": "thread-1",
1010                "turn": {
1011                    "id": "turn-1",
1012                    "items": [],
1013                    "status": "completed"
1014                }
1015            }
1016        });
1017        server_io
1018            .write_all(format!("{completed}\n").as_bytes())
1019            .await
1020            .unwrap();
1021
1022        for _ in 0..=DEFAULT_EVENTS_CHANNEL_CAPACITY {
1023            let event = tokio::time::timeout(Duration::from_secs(5), events.recv())
1024                .await
1025                .expect("event stream should not stall")
1026                .expect("event stream should remain open");
1027            if matches!(
1028                event,
1029                Event::Notification(ServerNotification::TurnCompleted(_))
1030            ) {
1031                return;
1032            }
1033        }
1034
1035        panic!("turn/completed was not delivered after draining the full event channel");
1036    }
1037
1038    /// Regression test: a zero `events_capacity` must be rejected with a
1039    /// crate-owned error - not left to panic inside
1040    /// `tokio::sync::mpsc::channel`, whose message ("mpsc bounded channel
1041    /// requires buffer > 0") says nothing about which knob or env var caused
1042    /// it. Exercised through `connect_streams_with_events_capacity` since it
1043    /// needs no real process or socket.
1044    #[tokio::test]
1045    async fn connect_streams_with_events_capacity_rejects_a_zero_capacity_cleanly() {
1046        let (client_io, _server_io) = tokio::io::duplex(4096);
1047        let (client_read, client_write) = tokio::io::split(client_io);
1048
1049        let result = CodexAppServerClient::connect_streams_with_events_capacity(
1050            BufReader::new(client_read),
1051            client_write,
1052            0,
1053        );
1054        let error = match result {
1055            Ok(_) => panic!("a zero capacity must be rejected rather than panicking inside tokio"),
1056            Err(error) => error,
1057        };
1058
1059        assert!(
1060            matches!(error, Error::Io(ref io_err) if io_err.kind() == std::io::ErrorKind::InvalidInput),
1061            "unexpected error variant for a rejected zero capacity: {error:?}"
1062        );
1063    }
1064
1065    /// Regression test: `*_with_events_capacity` must actually bound the
1066    /// channel to the requested size, not silently keep using
1067    /// `DEFAULT_EVENTS_CHANNEL_CAPACITY` - otherwise the REST layer's
1068    /// `events_channel_capacity` limit (see `crate::rest::RestLimits`) would
1069    /// be a knob nothing reads. Floods a small-capacity channel with far more
1070    /// notifications than it can hold *before* this test starts draining, so
1071    /// only notifications that fit within the requested capacity can survive;
1072    /// if the capacity argument were ignored in favor of the (much larger)
1073    /// default, every flooded notification would arrive.
1074    #[tokio::test]
1075    async fn connect_streams_with_events_capacity_bounds_the_channel_to_the_requested_size() {
1076        const CUSTOM_CAPACITY: usize = 4;
1077        const NOTIFICATIONS_SENT: usize = CUSTOM_CAPACITY + 20;
1078
1079        let (client_io, mut server_io) = tokio::io::duplex(1024 * 1024);
1080        let (client_read, client_write) = tokio::io::split(client_io);
1081        let (_client, mut events) = CodexAppServerClient::connect_streams_with_events_capacity(
1082            BufReader::new(client_read),
1083            client_write,
1084            CUSTOM_CAPACITY,
1085        )
1086        .expect("a nonzero capacity must be accepted");
1087
1088        for index in 0..NOTIFICATIONS_SENT {
1089            let delta = serde_json::json!({
1090                "method": "item/agentMessage/delta",
1091                "params": {
1092                    "threadId": "thread-1",
1093                    "turnId": "turn-1",
1094                    "itemId": format!("item-{index}"),
1095                    "delta": "x"
1096                }
1097            });
1098            server_io
1099                .write_all(format!("{delta}\n").as_bytes())
1100                .await
1101                .unwrap();
1102        }
1103
1104        // Give the reader task time to parse and `try_send` every line before
1105        // this test starts draining - the point is to observe how many
1106        // survive a channel that's still full, not to race the reader task.
1107        tokio::time::sleep(Duration::from_millis(100)).await;
1108
1109        let mut delivered = 0usize;
1110        while tokio::time::timeout(Duration::from_millis(50), events.recv())
1111            .await
1112            .ok()
1113            .flatten()
1114            .is_some()
1115        {
1116            delivered += 1;
1117        }
1118
1119        assert!(
1120            delivered <= CUSTOM_CAPACITY,
1121            "a capacity-{CUSTOM_CAPACITY} channel should have dropped most of the \
1122             {NOTIFICATIONS_SENT} notifications flooded in before draining started, but \
1123             {delivered} were delivered - the requested capacity is not actually bounding \
1124             the channel (it looks like DEFAULT_EVENTS_CHANNEL_CAPACITY is still in effect)"
1125        );
1126        assert!(
1127            delivered > 0,
1128            "at least some notifications should get through"
1129        );
1130    }
1131
1132    /// Regression test: the reader task detecting a dead connection (EOF from
1133    /// the peer, here simulated by dropping the peer side entirely) must
1134    /// proactively reap the writer task - not just when the caller eventually
1135    /// drops the client. Observed via `send_initialized` failing with
1136    /// `TransportClosed` once the writer task's channel receiver has dropped,
1137    /// which only happens once that task has actually exited.
1138    #[tokio::test]
1139    async fn reader_detected_disconnect_promptly_reaps_the_writer_task() {
1140        let (client_io, server_io) = tokio::io::duplex(4096);
1141        let (client_read, client_write) = tokio::io::split(client_io);
1142        let (client, mut events) =
1143            CodexAppServerClient::connect_streams(BufReader::new(client_read), client_write);
1144
1145        drop(server_io); // simulate the peer disconnecting - client never dropped
1146
1147        let event = tokio::time::timeout(Duration::from_secs(5), events.recv()).await;
1148        assert!(
1149            matches!(event, Ok(Some(Event::Closed))),
1150            "expected Event::Closed promptly after peer disconnect, got {event:?}"
1151        );
1152
1153        let reaped = tokio::time::timeout(Duration::from_secs(5), async {
1154            loop {
1155                if matches!(client.send_initialized(), Err(Error::TransportClosed)) {
1156                    return;
1157                }
1158                tokio::time::sleep(Duration::from_millis(5)).await;
1159            }
1160        })
1161        .await;
1162        assert!(
1163            reaped.is_ok(),
1164            "writer task should be reaped shortly after the reader detects disconnect, \
1165             without needing the client itself to be dropped"
1166        );
1167    }
1168
1169    /// Regression test: dropping every `CodexAppServerClient` clone must
1170    /// terminate the writer task (and, for a spawned child, kill it) even
1171    /// though the reader task independently holds its own `write_tx` clone
1172    /// for its whole lifetime. Verified indirectly via `connect_streams`: the
1173    /// writer task drops its write half on shutdown, which the peer observes
1174    /// as EOF.
1175    #[tokio::test]
1176    async fn dropping_the_last_client_terminates_the_writer_and_closes_the_transport() {
1177        let (client_io, mut server_io) = tokio::io::duplex(4096);
1178        let (client_read, client_write) = tokio::io::split(client_io);
1179        let (client, _events) =
1180            CodexAppServerClient::connect_streams(BufReader::new(client_read), client_write);
1181
1182        drop(client);
1183
1184        let mut buf = [0u8; 8];
1185        let result = tokio::time::timeout(Duration::from_secs(5), server_io.read(&mut buf)).await;
1186        assert!(
1187            matches!(result, Ok(Ok(0))),
1188            "expected EOF on the peer side after dropping the last client handle, got {result:?}"
1189        );
1190    }
1191
1192    /// Regression test: for `connect_streams`/`connect_unix` (unlike
1193    /// `spawn()`, which can force the issue via `kill_on_drop`), dropping
1194    /// every `CodexAppServerClient` clone must terminate the reader task
1195    /// promptly even when the peer never sends EOF and never reacts to the
1196    /// writer's half-close. `_server_io` is kept alive but never read,
1197    /// written, or dropped - an uncooperative peer that will never produce
1198    /// EOF on its own. Before the reader task raced on `reader_cancel` (not
1199    /// just EOF/errors), this would hang forever instead of observing
1200    /// `Event::Closed`.
1201    #[tokio::test]
1202    async fn dropping_the_last_client_terminates_the_reader_even_without_peer_eof() {
1203        let (client_io, _server_io) = tokio::io::duplex(4096);
1204        let (client_read, client_write) = tokio::io::split(client_io);
1205        let (client, mut events) =
1206            CodexAppServerClient::connect_streams(BufReader::new(client_read), client_write);
1207
1208        drop(client);
1209
1210        let event = tokio::time::timeout(Duration::from_secs(5), events.recv()).await;
1211        assert!(
1212            matches!(event, Ok(Some(Event::Closed))),
1213            "expected the reader task to notice cancellation and emit Event::Closed \
1214             even without peer EOF, got {event:?}"
1215        );
1216    }
1217
1218    /// Regression test: cancelling an in-flight call (here, via an outer
1219    /// `tokio::time::timeout` that drops the `call_request` future before it
1220    /// resolves) must not leak its entry in the pending-request map.
1221    #[tokio::test]
1222    async fn a_cancelled_call_does_not_leak_its_pending_map_entry() {
1223        let (client_io, _server_io) = tokio::io::duplex(4096);
1224        // `_server_io` is kept alive but never reads or writes, so no response
1225        // ever arrives and `call_request`'s own (much longer) timeout won't
1226        // fire either - only the outer cancellation below will.
1227        let (client_read, client_write) = tokio::io::split(client_io);
1228        let (client, _events) =
1229            CodexAppServerClient::connect_streams(BufReader::new(client_read), client_write);
1230
1231        let result = tokio::time::timeout(
1232            Duration::from_millis(50),
1233            client.call_request(|id| ClientRequest::MemoryReset { id, params: () }),
1234        )
1235        .await;
1236        assert!(
1237            result.is_err(),
1238            "expected the outer timeout to cancel call_request before any response arrives"
1239        );
1240        assert!(
1241            client.pending.lock().unwrap().is_empty(),
1242            "pending map must be empty once the caller gave up on the call"
1243        );
1244    }
1245}