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}