Skip to content

App-server Architecture

源码版本rust-v0.145.0

app-server is codex's JSON-RPC server, wrapping codex-core's ThreadManager, AuthManager, and ConfigManager into a process that IDE, Web, and TUI can all share. It exposes three transports externally — stdio / unix socket / websocket — and uses a MessageProcessor internally for request routing. All RPC goes through the same ClientRequest / ServerNotification protocol.

Responsibilities

  1. The entry run_main_with_transport_options loads config, auth, otel, state_db, starts the transport acceptor and outbound router, and finally spawns MessageProcessor into the main select loop (codex-rs/app-server/src/lib.rs:449-460).
  2. The transport layer unifies stdio:// / unix:// / ws://IP:PORT / off into TransportEvent (ConnectionOpened / ConnectionClosed / IncomingMessage); all connections share one event channel (codex-rs/app-server-transport/src/transport/mod.rs:73-78).
  3. MessageProcessor::process_request deserializes a JSONRPCRequest into a ClientRequest, dispatching by Initialize / already-initialized branches; already-initialized requests go through dispatch_initialized_client_request to the corresponding processor (codex-rs/app-server/src/message_processor.rs:518-570).
  4. Each initialized request is queued or spawned by serialization_scope: RequestSerializationQueues ensures same-scope requests are serial (e.g. turn operations on the same thread); only cross-scope requests run concurrently (codex-rs/app-server/src/message_processor.rs:846-861).
  5. Outbound goes through OutgoingEnvelope::ToConnection / Broadcast; broadcast only sends to already-initialized connections; experimental notifications only go to connections that opted into experimental_api (codex-rs/app-server/src/transport.rs:198-237).

Design motivation

app-server is its own crate because IDE (VSCode, JetBrains), TUI, and the remote-pair app all share the same protocol but run in different environments: IDE runs stdio, remote-pair goes websocket, daemon mode needs unix socket + pid file. Transport is abstracted into the app-server-transport crate and the protocol into app-server-protocol; the server crate only cares about "how to route JSON-RPC to ThreadManager", so adding a new transport does not touch the main logic.

ConnectionSessionState uses OnceLock<InitializedConnectionSessionState> rather than Mutex because initialize is a one-shot operation — once set, it cannot be modified; subsequent requests only read. This avoids lock overhead for "already initialized connections"; experimental_api_enabled / opted_out_notification_methods are all read via atomic / RwLock.

Request routing uses a set of processor structs rather than a single match: each processor (ThreadRequestProcessor, TurnRequestProcessor, ConfigRequestProcessor, etc.) holds its own state; handle_initialized_client_request only does match dispatch. Adding a new method does not touch central code, and each processor is easier to test on its own.

serialization_scope is an interesting design: turn operations on the same thread must be serial (otherwise two Op::UserInputs would race), but requests on different threads can run concurrently. RequestSerializationQueues keys by (connection_id, scope); same-key requests queue, cross-key requests tokio::spawn directly.

Outbound routing's try_send + queue-full-then-disconnect prevents slow clients from stalling the server: when a slow websocket's queue is full, it gets kicked directly; stdio/stdin only uses send().await because there is no flow-control disconnect.

Key files

codex-rs/app-server/src/main.rs:19-60 — the AppServerArgs CLI; --listen parses the transport; --session-source distinguishes vscode / cli / mcp.codex-rs/app-server/src/lib.rs:449-540run_main_with_transport_options, the entry that loads config, auth, otel.codex-rs/app-server/src/lib.rs:849-990MessageProcessor spawn and the main select loop, handling transport events and outbound envelopes.codex-rs/app-server/src/message_processor.rs:224-302MessageProcessor::new, creating ThreadManager, ThreadStateManager, SkillsWatcher, and each processor.codex-rs/app-server/src/message_processor.rs:799-862dispatch_initialized_client_request, serialization-scope dispatch logic.codex-rs/app-server/src/message_processor.rs:864-1000handle_initialized_client_request big match, routing ClientRequest to each processor.codex-rs/app-server/src/transport.rs:134-172send_message_to_connection, disconnecting slow connections on a full queue.codex-rs/app-server-transport/src/transport/mod.rs:73-158 — the AppServerTransport enum and from_listen_url parser.codex-rs/app-server-protocol/src/rpc.rs:34-88JSONRPCMessage / JSONRPCRequest / JSONRPCError, the protocol base types (the comments explicitly note this is not true JSON-RPC 2.0; the jsonrpc field is not required).codex-rs/app-server/src/request_processors/initialize_processor.rs:44-101InitializeRequestProcessor::initialize, setting the connection's experimental_api, attestation, originator, and other global identity.

The main select is a two-way choice between transport events and outbound envelopes, with a shutdown signal guard. The transport branch hands IncomingMessage to processor.process_request; the outbound branch calls route_outgoing_envelope to route:

rust
// app-server/src/lib.rs:877-892 — 主循环 shutdown 判定
let exit_reason = loop {
    let running_turn_count = { *running_turn_count_rx.borrow() };
    if matches!(
        shutdown_state.update(running_turn_count, connections.len()),
        ShutdownAction::Finish
    ) {
        transport_shutdown_token.cancel();
        let _ = outbound_control_tx
            .send(OutboundControlEvent::DisconnectAll)
            .await;
        break "shutdown_requested";
    }
    tokio::select! { /* ... */ }
};

dispatch_initialized_client_request turns the request into a QueuedInitializedRequest and then decides to queue or spawn. Same-scope must be serial; only cross-scope runs concurrently — for example, two connections each sending a turn don't block each other:

rust
// message_processor.rs:851-860 — scope 排队 vs 直接 spawn
if let Some(scope) = serialization_scope {
    let (key, access) = RequestSerializationQueueKey::from_scope(connection_id, scope);
    self.request_serialization_queues
        .enqueue(key, access, request)
        .await;
} else {
    tokio::spawn(async move { request.run().await; });
}

route_outgoing_envelope distinguishes targeted send from broadcast: on broadcast it iterates all already-initialized connections, but experimental notifications only go to connections with experimental_api on; opted_out_notification_methods lets clients opt out by method name:

rust
// app-server/src/transport.rs:212-236 — Broadcast 路由
OutgoingEnvelope::Broadcast { message } => {
    let target_connections: Vec<ConnectionId> = connections
        .iter()
        .filter_map(|(connection_id, connection_state)| {
            if connection_state.initialized.load(Ordering::Acquire)
                && !should_skip_notification_for_connection(connection_state, &message)
            {
                Some(*connection_id)
            } else {
                None
            }
        })
        .collect();
    // ...
}

Data flow

Boundaries and failures

  • Slow connections get kicked: when a websocket connection's outbound queue is full, try_send returns Full, and it directly calls disconnect_connection, preventing a slow client from stalling the server; stdio only uses send().await blocking when there is no disconnect_sender (codex-rs/app-server/src/transport.rs:154-172).
  • Uninitialized connections only accept Initialize: the first line of dispatch_initialized_client_request checks session.initialized(); if not initialized, it returns invalid_request("Not initialized") directly (codex-rs/app-server/src/message_processor.rs:806-808).
  • Experimental requests are gated: requests with experimental_reason() are rejected on connections that haven't enabled experimental_api, returning experimental_required_message (codex-rs/app-server/src/message_processor.rs:810-814).
  • Shutdown waits for turn wind-down: ShutdownState::update looks at running_turn_count and connections.len(); only when both are 0 does it Finish; a timeout avoids waiting forever (codex-rs/app-server/src/lib.rs:877-892).
  • transport off is also valid: no acceptor is started; only remote control runs — this is the path for headless scenarios where a daemon controls remote codex (codex-rs/app-server/src/lib.rs:712-740).

Summary

app-server is codex's protocol layer: multiple transport forms, each processor managing its own area, serialization scope ensures same-thread serial. ConnectionSessionState uses OnceLock to express "initialized once, immutable" semantics. To go further, remote pairing and daemon lifecycle live in the app-server-daemon crate; client wrappers in app-server-client; for how MCP tool calls go through this into core, see MCP integration.