Skip to main content

codex_app_server/
in_process.rs

1//! In-process app-server runtime host for local embedders.
2//!
3//! This module runs the existing [`MessageProcessor`] and outbound routing logic
4//! on Tokio tasks, but replaces socket/stdio transports with bounded in-memory
5//! channels. The intent is to preserve app-server semantics while avoiding a
6//! process boundary for CLI surfaces that run in the same process.
7//!
8//! # Lifecycle
9//!
10//! 1. Construct runtime state with [`InProcessStartArgs`].
11//! 2. Call [`start`], which performs the `initialize` / `initialized` handshake
12//!    internally and returns a ready-to-use [`InProcessClientHandle`].
13//! 3. Send requests via [`InProcessClientHandle::request`], notifications via
14//!    [`InProcessClientHandle::notify`], and consume events via
15//!    [`InProcessClientHandle::next_event`].
16//! 4. Terminate with [`InProcessClientHandle::shutdown`].
17//!
18//! # Transport model
19//!
20//! The runtime is transport-local but not protocol-free. Incoming requests are
21//! typed [`ClientRequest`] values, yet responses still come back through the
22//! same JSON-RPC result envelope that `MessageProcessor` uses for stdio and
23//! websocket transports. This keeps in-process behavior aligned with
24//! app-server rather than creating a second execution contract.
25//!
26//! # Backpressure
27//!
28//! Command submission uses `try_send` and can return `WouldBlock`, while event
29//! fanout may drop notifications under saturation. Server requests are never
30//! silently abandoned: if they cannot be queued they are failed back into
31//! `MessageProcessor` with overload or internal errors so approval flows do
32//! not hang indefinitely.
33//!
34//! # Relationship to `codex-app-server-client`
35//!
36//! This module provides the low-level runtime handle ([`InProcessClientHandle`]).
37//! Higher-level callers (TUI, exec) should go through `codex-app-server-client`,
38//! which wraps this module behind a worker task with async request/response
39//! helpers, surface-specific startup policy, and bounded shutdown.
40
41use std::collections::HashMap;
42use std::collections::HashSet;
43use std::collections::hash_map::Entry;
44use std::io::Error as IoError;
45use std::io::ErrorKind;
46use std::io::Result as IoResult;
47use std::sync::Arc;
48use std::sync::RwLock;
49use std::sync::atomic::AtomicBool;
50use std::sync::atomic::Ordering;
51use std::time::Duration;
52
53use crate::analytics_utils::analytics_events_client_from_config;
54use crate::config_manager::ConfigManager;
55use crate::error_code::OVERLOADED_ERROR_CODE;
56use crate::error_code::internal_error;
57use crate::error_code::invalid_request;
58use crate::message_processor::ConnectionSessionState;
59use crate::message_processor::MessageProcessor;
60use crate::message_processor::MessageProcessorArgs;
61use crate::outgoing_message::ConnectionId;
62use crate::outgoing_message::OutgoingEnvelope;
63use crate::outgoing_message::OutgoingMessage;
64use crate::outgoing_message::OutgoingMessageSender;
65use crate::outgoing_message::QueuedOutgoingMessage;
66use crate::transport::CHANNEL_CAPACITY;
67use crate::transport::OutboundConnectionState;
68use crate::transport::route_outgoing_envelope;
69use codex_analytics::AppServerRpcTransport;
70use codex_app_server_protocol::ClientNotification;
71use codex_app_server_protocol::ClientRequest;
72use codex_app_server_protocol::ConfigWarningNotification;
73use codex_app_server_protocol::InitializeParams;
74use codex_app_server_protocol::JSONRPCErrorError;
75use codex_app_server_protocol::RequestId;
76use codex_app_server_protocol::Result;
77use codex_app_server_protocol::ServerNotification;
78use codex_app_server_protocol::ServerRequest;
79use codex_arg0::Arg0DispatchPaths;
80use codex_config::CloudConfigBundleLoader;
81use codex_config::LoaderOverrides;
82use codex_config::ThreadConfigLoader;
83use codex_core::check_execpolicy_for_warnings;
84use codex_core::config::Config;
85use codex_core::resolve_installation_id;
86use codex_exec_server::EnvironmentManager;
87use codex_feedback::CodexFeedback;
88use codex_login::AuthManager;
89use codex_protocol::protocol::SessionSource;
90pub use codex_rollout::StateDbHandle;
91pub use codex_state::log_db::LogDbLayer;
92use tokio::sync::mpsc;
93use tokio::sync::oneshot;
94use tokio::time::timeout;
95use toml::Value as TomlValue;
96use tracing::warn;
97
98const IN_PROCESS_CONNECTION_ID: ConnectionId = ConnectionId(0);
99const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5);
100/// Default bounded channel capacity for in-process runtime queues.
101pub const DEFAULT_IN_PROCESS_CHANNEL_CAPACITY: usize = CHANNEL_CAPACITY;
102
103type PendingClientRequestResponse = std::result::Result<Result, JSONRPCErrorError>;
104
105fn server_notification_requires_delivery(notification: &ServerNotification) -> bool {
106    matches!(
107        notification,
108        ServerNotification::TurnCompleted(_)
109            | ServerNotification::ThreadSettingsUpdated(_)
110            | ServerNotification::ExternalAgentConfigImportCompleted(_)
111    )
112}
113
114/// Input needed to start an in-process app-server runtime.
115///
116/// These fields mirror the pieces of ambient process state that stdio and
117/// websocket transports normally assemble before `MessageProcessor` starts.
118#[derive(Clone)]
119pub struct InProcessStartArgs {
120    /// Resolved argv0 dispatch paths used by command execution internals.
121    pub arg0_paths: Arg0DispatchPaths,
122    /// Shared base config used to initialize core components.
123    pub config: Arc<Config>,
124    /// CLI config overrides that are already parsed into TOML values.
125    pub cli_overrides: Vec<(String, TomlValue)>,
126    /// Loader override knobs used by config API paths.
127    pub loader_overrides: LoaderOverrides,
128    /// Whether config API paths should reject unknown config fields.
129    pub strict_config: bool,
130    /// Preloaded cloud config bundle provider.
131    pub cloud_config_bundle: CloudConfigBundleLoader,
132    /// Loader used to fetch typed thread config sources before a thread starts.
133    pub thread_config_loader: Arc<dyn ThreadConfigLoader>,
134    /// Feedback sink used by app-server/core telemetry and logs.
135    pub feedback: CodexFeedback,
136    /// SQLite tracing layer used to flush recently emitted logs before feedback upload.
137    pub log_db: Option<LogDbLayer>,
138    /// Process-wide SQLite state handle shared with embedded app-server consumers.
139    pub state_db: Option<StateDbHandle>,
140    /// Environment manager used by core execution and filesystem operations.
141    pub environment_manager: Arc<EnvironmentManager>,
142    /// Startup warnings emitted after initialize succeeds.
143    pub config_warnings: Vec<ConfigWarningNotification>,
144    /// Session source stamped into thread/session metadata.
145    pub session_source: SessionSource,
146    /// Whether auth loading should honor the `CODEX_API_KEY` environment variable.
147    pub enable_codex_api_key_env: bool,
148    /// Initialize params used for initial handshake.
149    pub initialize: InitializeParams,
150    /// Capacity used for all runtime queues (clamped to at least 1).
151    pub channel_capacity: usize,
152}
153
154/// Event emitted from the app-server to the in-process client.
155///
156/// [`Lagged`](Self::Lagged) is a transport health marker, not an application
157/// event — it signals that the consumer fell behind and some events were dropped.
158#[derive(Debug, Clone)]
159pub enum InProcessServerEvent {
160    /// Server request that requires client response/rejection.
161    ServerRequest(ServerRequest),
162    /// App-server notification directed to the embedded client.
163    ServerNotification(ServerNotification),
164    /// Indicates one or more events were dropped due to backpressure.
165    Lagged { skipped: usize },
166}
167
168/// Internal message sent from [`InProcessClientHandle`] methods to the runtime task.
169///
170/// Requests carry a oneshot sender for the response; notifications and server-request
171/// replies are fire-and-forget from the caller's perspective (transport errors are
172/// caught by `try_send` on the outer channel).
173enum InProcessClientMessage {
174    Request {
175        request: Box<ClientRequest>,
176        response_tx: oneshot::Sender<PendingClientRequestResponse>,
177    },
178    Notification {
179        notification: ClientNotification,
180    },
181    ServerRequestResponse {
182        request_id: RequestId,
183        result: Result,
184    },
185    ServerRequestError {
186        request_id: RequestId,
187        error: JSONRPCErrorError,
188    },
189    Shutdown {
190        done_tx: oneshot::Sender<()>,
191    },
192}
193
194enum ProcessorCommand {
195    Request(Box<ClientRequest>),
196    Notification(ClientNotification),
197}
198
199#[derive(Clone)]
200pub struct InProcessClientSender {
201    client_tx: mpsc::Sender<InProcessClientMessage>,
202}
203
204impl InProcessClientSender {
205    pub async fn request(&self, request: ClientRequest) -> IoResult<PendingClientRequestResponse> {
206        let (response_tx, response_rx) = oneshot::channel();
207        self.try_send_client_message(InProcessClientMessage::Request {
208            request: Box::new(request),
209            response_tx,
210        })?;
211        response_rx.await.map_err(|err| {
212            IoError::new(
213                ErrorKind::BrokenPipe,
214                format!("in-process request response channel closed: {err}"),
215            )
216        })
217    }
218
219    pub fn notify(&self, notification: ClientNotification) -> IoResult<()> {
220        self.try_send_client_message(InProcessClientMessage::Notification { notification })
221    }
222
223    pub fn respond_to_server_request(&self, request_id: RequestId, result: Result) -> IoResult<()> {
224        self.try_send_client_message(InProcessClientMessage::ServerRequestResponse {
225            request_id,
226            result,
227        })
228    }
229
230    pub fn fail_server_request(
231        &self,
232        request_id: RequestId,
233        error: JSONRPCErrorError,
234    ) -> IoResult<()> {
235        self.try_send_client_message(InProcessClientMessage::ServerRequestError {
236            request_id,
237            error,
238        })
239    }
240
241    fn try_send_client_message(&self, message: InProcessClientMessage) -> IoResult<()> {
242        match self.client_tx.try_send(message) {
243            Ok(()) => Ok(()),
244            Err(mpsc::error::TrySendError::Full(_)) => Err(IoError::new(
245                ErrorKind::WouldBlock,
246                "in-process app-server client queue is full",
247            )),
248            Err(mpsc::error::TrySendError::Closed(_)) => Err(IoError::new(
249                ErrorKind::BrokenPipe,
250                "in-process app-server runtime is closed",
251            )),
252        }
253    }
254}
255
256/// Handle used by an in-process client to call app-server and consume events.
257///
258/// This is the low-level runtime handle. Higher-level callers should usually go
259/// through `codex-app-server-client`, which adds worker-task buffering,
260/// request/response helpers, and surface-specific startup policy.
261pub struct InProcessClientHandle {
262    client: InProcessClientSender,
263    event_rx: mpsc::Receiver<InProcessServerEvent>,
264    runtime_handle: tokio::task::JoinHandle<()>,
265    #[cfg(test)]
266    _test_codex_home: Option<tempfile::TempDir>,
267}
268
269impl InProcessClientHandle {
270    /// Sends a typed client request into the in-process runtime.
271    ///
272    /// The returned value is a transport-level `IoResult` containing either a
273    /// JSON-RPC success payload or JSON-RPC error payload. Callers must keep
274    /// request IDs unique among concurrent requests; reusing an in-flight ID
275    /// produces an `INVALID_REQUEST` response and can make request routing
276    /// ambiguous in the caller.
277    pub async fn request(&self, request: ClientRequest) -> IoResult<PendingClientRequestResponse> {
278        self.client.request(request).await
279    }
280
281    /// Sends a typed client notification into the in-process runtime.
282    ///
283    /// Notifications do not have an application-level response. Transport
284    /// errors indicate queue saturation or closed runtime.
285    pub fn notify(&self, notification: ClientNotification) -> IoResult<()> {
286        self.client.notify(notification)
287    }
288
289    /// Resolves a pending [`ServerRequest`](InProcessServerEvent::ServerRequest).
290    ///
291    /// This should be used only with request IDs received from the current
292    /// runtime event stream; sending arbitrary IDs has no effect on app-server
293    /// state and can mask a stuck approval flow in the caller.
294    pub fn respond_to_server_request(&self, request_id: RequestId, result: Result) -> IoResult<()> {
295        self.client.respond_to_server_request(request_id, result)
296    }
297
298    /// Rejects a pending [`ServerRequest`](InProcessServerEvent::ServerRequest).
299    ///
300    /// Use this when the embedder cannot satisfy a server request; leaving
301    /// requests unanswered can stall turn progress.
302    pub fn fail_server_request(
303        &self,
304        request_id: RequestId,
305        error: JSONRPCErrorError,
306    ) -> IoResult<()> {
307        self.client.fail_server_request(request_id, error)
308    }
309
310    /// Receives the next server event from the in-process runtime.
311    ///
312    /// Returns `None` when the runtime task exits and no more events are
313    /// available.
314    pub async fn next_event(&mut self) -> Option<InProcessServerEvent> {
315        self.event_rx.recv().await
316    }
317
318    /// Requests runtime shutdown and waits for worker termination.
319    ///
320    /// Shutdown is bounded by internal timeouts and may abort background tasks
321    /// if graceful drain does not complete in time.
322    pub async fn shutdown(self) -> IoResult<()> {
323        let mut runtime_handle = self.runtime_handle;
324        let (done_tx, done_rx) = oneshot::channel();
325
326        if self
327            .client
328            .client_tx
329            .send(InProcessClientMessage::Shutdown { done_tx })
330            .await
331            .is_ok()
332        {
333            let _ = timeout(SHUTDOWN_TIMEOUT, done_rx).await;
334        }
335
336        if let Err(_elapsed) = timeout(SHUTDOWN_TIMEOUT, &mut runtime_handle).await {
337            runtime_handle.abort();
338            let _ = runtime_handle.await;
339        }
340        Ok(())
341    }
342
343    pub fn sender(&self) -> InProcessClientSender {
344        self.client.clone()
345    }
346}
347
348/// Starts an in-process app-server runtime and performs initialize handshake.
349///
350/// This function sends `initialize` followed by `initialized` before returning
351/// the handle, so callers receive a ready-to-use runtime. If initialize fails,
352/// the runtime is shut down and an `InvalidData` error is returned.
353pub async fn start(mut args: InProcessStartArgs) -> IoResult<InProcessClientHandle> {
354    if let Ok(Some(err)) = check_execpolicy_for_warnings(&args.config.config_layer_stack).await {
355        let (path, range) = crate::exec_policy_warning_location(&err);
356        args.config_warnings.push(ConfigWarningNotification {
357            summary: "Error parsing rules; custom rules not applied.".to_string(),
358            details: Some(err.to_string()),
359            path,
360            range,
361        });
362    }
363    let initialize = args.initialize.clone();
364    let client = start_uninitialized(args).await?;
365
366    let initialize_response = client
367        .request(ClientRequest::Initialize {
368            request_id: RequestId::Integer(0),
369            params: initialize,
370        })
371        .await?;
372    if let Err(error) = initialize_response {
373        let _ = client.shutdown().await;
374        return Err(IoError::new(
375            ErrorKind::InvalidData,
376            format!("in-process initialize failed: {}", error.message),
377        ));
378    }
379    client.notify(ClientNotification::Initialized)?;
380
381    Ok(client)
382}
383
384async fn start_uninitialized(args: InProcessStartArgs) -> IoResult<InProcessClientHandle> {
385    let channel_capacity = args.channel_capacity.max(1);
386    let installation_id = resolve_installation_id(&args.config.codex_home).await?;
387    let (client_tx, mut client_rx) = mpsc::channel::<InProcessClientMessage>(channel_capacity);
388    let (event_tx, event_rx) = mpsc::channel::<InProcessServerEvent>(channel_capacity);
389
390    let runtime_handle = tokio::spawn(async move {
391        let (outgoing_tx, mut outgoing_rx) = mpsc::channel::<OutgoingEnvelope>(channel_capacity);
392        let auth_manager =
393            AuthManager::shared_from_config(args.config.as_ref(), args.enable_codex_api_key_env)
394                .await;
395        let analytics_events_client =
396            analytics_events_client_from_config(Arc::clone(&auth_manager), args.config.as_ref());
397        let outgoing_message_sender = Arc::new(OutgoingMessageSender::new(
398            outgoing_tx,
399            analytics_events_client.clone(),
400        ));
401
402        let (writer_tx, mut writer_rx) = mpsc::channel::<QueuedOutgoingMessage>(channel_capacity);
403        let outbound_initialized = Arc::new(AtomicBool::new(false));
404        let outbound_experimental_api_enabled = Arc::new(AtomicBool::new(false));
405        let outbound_opted_out_notification_methods = Arc::new(RwLock::new(HashSet::new()));
406
407        let mut outbound_connections = HashMap::<ConnectionId, OutboundConnectionState>::new();
408        outbound_connections.insert(
409            IN_PROCESS_CONNECTION_ID,
410            OutboundConnectionState::new(
411                writer_tx,
412                Arc::clone(&outbound_initialized),
413                Arc::clone(&outbound_experimental_api_enabled),
414                Arc::clone(&outbound_opted_out_notification_methods),
415                /*disconnect_sender*/ None,
416            ),
417        );
418        let mut outbound_handle = tokio::spawn(async move {
419            while let Some(envelope) = outgoing_rx.recv().await {
420                route_outgoing_envelope(&mut outbound_connections, envelope).await;
421            }
422        });
423
424        let processor_outgoing = Arc::clone(&outgoing_message_sender);
425        let config_manager = ConfigManager::new(
426            args.config.codex_home.to_path_buf(),
427            args.cli_overrides,
428            args.loader_overrides,
429            args.strict_config,
430            args.cloud_config_bundle,
431            args.arg0_paths.clone(),
432            args.thread_config_loader,
433        );
434        let (processor_tx, mut processor_rx) = mpsc::channel::<ProcessorCommand>(channel_capacity);
435        let mut processor_handle = tokio::spawn(async move {
436            let processor = Arc::new(MessageProcessor::new(MessageProcessorArgs {
437                outgoing: Arc::clone(&processor_outgoing),
438                analytics_events_client,
439                arg0_paths: args.arg0_paths,
440                config: args.config,
441                config_manager,
442                environment_manager: args.environment_manager,
443                feedback: args.feedback,
444                log_db: args.log_db,
445                state_db: args.state_db,
446                config_warnings: args.config_warnings,
447                session_source: args.session_source,
448                auth_manager,
449                installation_id,
450                rpc_transport: AppServerRpcTransport::InProcess,
451                remote_control_handle: None,
452                plugin_startup_tasks: crate::PluginStartupTasks::Start,
453            }));
454            let mut thread_created_rx = processor.thread_created_receiver();
455            let session = Arc::new(ConnectionSessionState::new());
456            let mut listen_for_threads = true;
457
458            loop {
459                tokio::select! {
460                    command = processor_rx.recv() => {
461                        match command {
462                            Some(ProcessorCommand::Request(request)) => {
463                                let was_initialized = session.initialized();
464                                processor
465                                    .process_client_request(
466                                        IN_PROCESS_CONNECTION_ID,
467                                        *request,
468                                        Arc::clone(&session),
469                                        &outbound_initialized,
470                                    )
471                                    .await;
472                                let opted_out_notification_methods_snapshot =
473                                    session.opted_out_notification_methods();
474                                let experimental_api_enabled =
475                                    session.experimental_api_enabled();
476                                let is_initialized = session.initialized();
477                                if let Ok(mut opted_out_notification_methods) =
478                                    outbound_opted_out_notification_methods.write()
479                                {
480                                    *opted_out_notification_methods =
481                                        opted_out_notification_methods_snapshot;
482                                } else {
483                                    warn!("failed to update outbound opted-out notifications");
484                                }
485                                outbound_experimental_api_enabled.store(
486                                    experimental_api_enabled,
487                                    Ordering::Release,
488                                );
489                                if !was_initialized && is_initialized {
490                                    processor.send_initialize_notifications().await;
491                                }
492                            }
493                            Some(ProcessorCommand::Notification(notification)) => {
494                                processor.process_client_notification(notification).await;
495                            }
496                            None => {
497                                break;
498                            }
499                        }
500                    }
501                    created = thread_created_rx.recv(), if listen_for_threads => {
502                        match created {
503                            Ok(thread_id) => {
504                                let connection_ids = if session.initialized() {
505                                    vec![IN_PROCESS_CONNECTION_ID]
506                                } else {
507                                    Vec::<ConnectionId>::new()
508                                };
509                                processor
510                                    .try_attach_thread_listener(thread_id, connection_ids)
511                                    .await;
512                            }
513                            Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {
514                                warn!("thread_created receiver lagged; skipping resync");
515                            }
516                            Err(tokio::sync::broadcast::error::RecvError::Closed) => {
517                                listen_for_threads = false;
518                            }
519                        }
520                    }
521                }
522            }
523
524            processor.clear_runtime_references();
525            processor.cancel_active_login().await;
526            processor
527                .connection_closed(IN_PROCESS_CONNECTION_ID, &session)
528                .await;
529            processor.clear_all_thread_listeners().await;
530            processor.drain_background_tasks().await;
531            processor.shutdown_threads().await;
532        });
533        let mut pending_request_responses =
534            HashMap::<RequestId, oneshot::Sender<PendingClientRequestResponse>>::new();
535        let mut shutdown_ack = None;
536
537        loop {
538            tokio::select! {
539                message = client_rx.recv() => {
540                    match message {
541                        Some(InProcessClientMessage::Request { request, response_tx }) => {
542                            let request = *request;
543                            let request_id = request.id().clone();
544                            match pending_request_responses.entry(request_id.clone()) {
545                                Entry::Vacant(entry) => {
546                                    entry.insert(response_tx);
547                                }
548                                Entry::Occupied(_) => {
549                                    let _ = response_tx.send(Err(invalid_request(format!(
550                                        "duplicate request id: {request_id:?}"
551                                    ))));
552                                    continue;
553                                }
554                            }
555
556                            match processor_tx.try_send(ProcessorCommand::Request(Box::new(request))) {
557                                Ok(()) => {}
558                                Err(mpsc::error::TrySendError::Full(_)) => {
559                                    if let Some(response_tx) =
560                                        pending_request_responses.remove(&request_id)
561                                    {
562                                        let _ = response_tx.send(Err(JSONRPCErrorError {
563                                            code: OVERLOADED_ERROR_CODE,
564                                            message: "in-process app-server request queue is full"
565                                                .to_string(),
566                                            data: None,
567                                        }));
568                                    }
569                                }
570                                Err(mpsc::error::TrySendError::Closed(_)) => {
571                                    if let Some(response_tx) =
572                                        pending_request_responses.remove(&request_id)
573                                    {
574                                        let _ = response_tx.send(Err(internal_error(
575                                            "in-process app-server request processor is closed",
576                                        )));
577                                    }
578                                    break;
579                                }
580                            }
581                        }
582                        Some(InProcessClientMessage::Notification { notification }) => {
583                            match processor_tx.try_send(ProcessorCommand::Notification(notification)) {
584                                Ok(()) => {}
585                                Err(mpsc::error::TrySendError::Full(_)) => {
586                                    warn!("dropping in-process client notification (queue full)");
587                                }
588                                Err(mpsc::error::TrySendError::Closed(_)) => {
589                                    break;
590                                }
591                            }
592                        }
593                        Some(InProcessClientMessage::ServerRequestResponse { request_id, result }) => {
594                            outgoing_message_sender
595                                .notify_client_response(request_id, result)
596                                .await;
597                        }
598                        Some(InProcessClientMessage::ServerRequestError { request_id, error }) => {
599                            outgoing_message_sender
600                                .notify_client_error(request_id, error)
601                                .await;
602                        }
603                        Some(InProcessClientMessage::Shutdown { done_tx }) => {
604                            shutdown_ack = Some(done_tx);
605                            break;
606                        }
607                        None => {
608                            break;
609                        }
610                    }
611                }
612                queued_message = writer_rx.recv() => {
613                    let Some(queued_message) = queued_message else {
614                        break;
615                    };
616                    let outgoing_message = queued_message.message;
617                    match outgoing_message {
618                        OutgoingMessage::Response(response) => {
619                            if let Some(response_tx) = pending_request_responses.remove(&response.id) {
620                                let _ = response_tx.send(Ok(response.result));
621                            } else {
622                                warn!(
623                                    request_id = ?response.id,
624                                    "dropping unmatched in-process response"
625                                );
626                            }
627                        }
628                        OutgoingMessage::Error(error) => {
629                            if let Some(response_tx) = pending_request_responses.remove(&error.id) {
630                                let _ = response_tx.send(Err(error.error));
631                            } else {
632                                warn!(
633                                    request_id = ?error.id,
634                                    "dropping unmatched in-process error response"
635                                );
636                            }
637                        }
638                        OutgoingMessage::Request(request) => {
639                            // Send directly to avoid cloning; on failure the
640                            // original value is returned inside the error.
641                            if let Err(send_error) = event_tx
642                                .try_send(InProcessServerEvent::ServerRequest(request))
643                            {
644                                let (error, inner) = match send_error {
645                                    mpsc::error::TrySendError::Full(inner) => (
646                                        JSONRPCErrorError {
647                                            code: OVERLOADED_ERROR_CODE,
648                                            message:
649                                                "in-process server request queue is full".to_string(),
650                                            data: None,
651                                        },
652                                        inner,
653                                    ),
654                                    mpsc::error::TrySendError::Closed(inner) => (
655                                        internal_error(
656                                            "in-process server request consumer is closed",
657                                        ),
658                                        inner,
659                                    ),
660                                };
661                                let request_id = match inner {
662                                    InProcessServerEvent::ServerRequest(req) => req.id().clone(),
663                                    _ => unreachable!("we just sent a ServerRequest variant"),
664                                };
665                                outgoing_message_sender
666                                    .notify_client_error(request_id, error)
667                                    .await;
668                            }
669                        }
670                        OutgoingMessage::AppServerNotification(envelope) => {
671                            let notification = envelope.notification;
672                            if server_notification_requires_delivery(&notification) {
673                                if event_tx
674                                    .send(InProcessServerEvent::ServerNotification(notification))
675                                    .await
676                                    .is_err()
677                                {
678                                    break;
679                                }
680                            } else if let Err(send_error) =
681                                event_tx.try_send(InProcessServerEvent::ServerNotification(notification))
682                            {
683                                match send_error {
684                                    mpsc::error::TrySendError::Full(_) => {
685                                        warn!("dropping in-process server notification (queue full)");
686                                    }
687                                    mpsc::error::TrySendError::Closed(_) => {
688                                        break;
689                                    }
690                                }
691                            }
692                        }
693                    }
694                    if let Some(write_complete_tx) = queued_message.write_complete_tx {
695                        let _ = write_complete_tx.send(());
696                    }
697                }
698            }
699        }
700
701        drop(writer_rx);
702        drop(processor_tx);
703        outgoing_message_sender
704            .cancel_all_requests(Some(internal_error(
705                "in-process app-server runtime is shutting down",
706            )))
707            .await;
708        // Drop the runtime's last sender before awaiting the router task so
709        // `outgoing_rx.recv()` can observe channel closure and exit cleanly.
710        drop(outgoing_message_sender);
711        for (_, response_tx) in pending_request_responses {
712            let _ = response_tx.send(Err(internal_error(
713                "in-process app-server runtime is shutting down",
714            )));
715        }
716
717        if let Err(_elapsed) = timeout(SHUTDOWN_TIMEOUT, &mut processor_handle).await {
718            processor_handle.abort();
719            let _ = processor_handle.await;
720        }
721        if let Err(_elapsed) = timeout(SHUTDOWN_TIMEOUT, &mut outbound_handle).await {
722            outbound_handle.abort();
723            let _ = outbound_handle.await;
724        }
725
726        if let Some(done_tx) = shutdown_ack {
727            let _ = done_tx.send(());
728        }
729    });
730
731    Ok(InProcessClientHandle {
732        client: InProcessClientSender { client_tx },
733        event_rx,
734        runtime_handle,
735        #[cfg(test)]
736        _test_codex_home: None,
737    })
738}
739
740#[cfg(test)]
741mod tests {
742    use super::*;
743    use codex_app_server_protocol::ClientInfo;
744    use codex_app_server_protocol::ConfigRequirementsReadResponse;
745    use codex_app_server_protocol::ExternalAgentConfigImportCompletedNotification;
746    use codex_app_server_protocol::SessionSource as ApiSessionSource;
747    use codex_app_server_protocol::ThreadStartParams;
748    use codex_app_server_protocol::ThreadStartResponse;
749    use codex_app_server_protocol::Turn;
750    use codex_app_server_protocol::TurnCompletedNotification;
751    use codex_app_server_protocol::TurnItemsView;
752    use codex_app_server_protocol::TurnStatus;
753    use codex_core::config::ConfigBuilder;
754    use pretty_assertions::assert_eq;
755    use std::path::Path;
756    use tempfile::TempDir;
757
758    async fn build_test_config(codex_home: &Path) -> Config {
759        match ConfigBuilder::default()
760            .codex_home(codex_home.to_path_buf())
761            .build()
762            .await
763        {
764            Ok(config) => config,
765            Err(_) => Config::load_default_with_cli_overrides_for_codex_home(
766                codex_home.to_path_buf(),
767                Vec::new(),
768            )
769            .await
770            .expect("default config should load"),
771        }
772    }
773
774    async fn start_test_client_with_capacity(
775        session_source: SessionSource,
776        channel_capacity: usize,
777    ) -> InProcessClientHandle {
778        let codex_home = TempDir::new().expect("temp dir");
779        let config = Arc::new(build_test_config(codex_home.path()).await);
780        let state_db = codex_rollout::state_db::try_init(config.as_ref())
781            .await
782            .expect("state db should initialize for in-process test");
783        let args = InProcessStartArgs {
784            arg0_paths: Arg0DispatchPaths::default(),
785            config,
786            cli_overrides: Vec::new(),
787            loader_overrides: LoaderOverrides::default(),
788            strict_config: false,
789            cloud_config_bundle: CloudConfigBundleLoader::default(),
790            thread_config_loader: Arc::new(codex_config::NoopThreadConfigLoader),
791            feedback: CodexFeedback::new(),
792            log_db: None,
793            state_db: Some(state_db),
794            environment_manager: Arc::new(EnvironmentManager::default_for_tests()),
795            config_warnings: Vec::new(),
796            session_source,
797            enable_codex_api_key_env: false,
798            initialize: InitializeParams {
799                client_info: ClientInfo {
800                    name: "codex-in-process-test".to_string(),
801                    title: None,
802                    version: "0.0.0".to_string(),
803                },
804                capabilities: None,
805            },
806            channel_capacity,
807        };
808        let mut client = start(args).await.expect("in-process runtime should start");
809        client._test_codex_home = Some(codex_home);
810        client
811    }
812
813    async fn start_test_client(session_source: SessionSource) -> InProcessClientHandle {
814        start_test_client_with_capacity(session_source, DEFAULT_IN_PROCESS_CHANNEL_CAPACITY).await
815    }
816
817    #[tokio::test]
818    async fn in_process_start_initializes_and_handles_typed_v2_request() {
819        let client = start_test_client(SessionSource::Cli).await;
820        let response = client
821            .request(ClientRequest::ConfigRequirementsRead {
822                request_id: RequestId::Integer(1),
823                params: None,
824            })
825            .await
826            .expect("request transport should work")
827            .expect("request should succeed");
828        assert!(response.is_object());
829
830        let _parsed: ConfigRequirementsReadResponse =
831            serde_json::from_value(response).expect("response should match v2 schema");
832        client
833            .shutdown()
834            .await
835            .expect("in-process runtime should shutdown cleanly");
836    }
837
838    #[tokio::test]
839    async fn in_process_start_uses_requested_session_source_for_thread_start() {
840        for (requested_source, expected_source) in [
841            (SessionSource::Cli, ApiSessionSource::Cli),
842            (SessionSource::Exec, ApiSessionSource::Exec),
843        ] {
844            let client = start_test_client(requested_source).await;
845            let response = client
846                .request(ClientRequest::ThreadStart {
847                    request_id: RequestId::Integer(2),
848                    params: ThreadStartParams {
849                        ephemeral: Some(true),
850                        ..ThreadStartParams::default()
851                    },
852                })
853                .await
854                .expect("request transport should work")
855                .expect("thread/start should succeed");
856            let parsed: ThreadStartResponse =
857                serde_json::from_value(response).expect("thread/start response should parse");
858            assert_eq!(parsed.thread.source, expected_source);
859            client
860                .shutdown()
861                .await
862                .expect("in-process runtime should shutdown cleanly");
863        }
864    }
865
866    #[tokio::test]
867    async fn in_process_start_clamps_zero_channel_capacity() {
868        let client =
869            start_test_client_with_capacity(SessionSource::Cli, /*channel_capacity*/ 0).await;
870        let response = loop {
871            match client
872                .request(ClientRequest::ConfigRequirementsRead {
873                    request_id: RequestId::Integer(4),
874                    params: None,
875                })
876                .await
877            {
878                Ok(response) => break response.expect("request should succeed"),
879                Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
880                    tokio::task::yield_now().await;
881                }
882                Err(err) => panic!("request transport should work: {err}"),
883            }
884        };
885        let _parsed: ConfigRequirementsReadResponse =
886            serde_json::from_value(response).expect("response should match v2 schema");
887        client
888            .shutdown()
889            .await
890            .expect("in-process runtime should shutdown cleanly");
891    }
892
893    #[test]
894    fn guaranteed_delivery_helpers_cover_terminal_server_notifications() {
895        assert!(server_notification_requires_delivery(
896            &ServerNotification::TurnCompleted(TurnCompletedNotification {
897                thread_id: "thread-1".to_string(),
898                turn: Turn {
899                    id: "turn-1".to_string(),
900                    items: Vec::new(),
901                    items_view: TurnItemsView::NotLoaded,
902                    status: TurnStatus::Completed,
903                    error: None,
904                    started_at: None,
905                    completed_at: Some(0),
906                    duration_ms: None,
907                },
908            })
909        ));
910        assert!(server_notification_requires_delivery(
911            &ServerNotification::ExternalAgentConfigImportCompleted(
912                ExternalAgentConfigImportCompletedNotification {
913                    import_id: "import".to_string(),
914                    item_type_results: Vec::new(),
915                },
916            )
917        ));
918    }
919}