App-server Architecture
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
- The entry
run_main_with_transport_optionsloads config, auth, otel, state_db, starts the transport acceptor and outbound router, and finally spawnsMessageProcessorinto the main select loop (codex-rs/app-server/src/lib.rs:449-460). - The transport layer unifies
stdio:///unix:///ws://IP:PORT/offintoTransportEvent(ConnectionOpened / ConnectionClosed / IncomingMessage); all connections share one event channel (codex-rs/app-server-transport/src/transport/mod.rs:73-78). MessageProcessor::process_requestdeserializes aJSONRPCRequestinto aClientRequest, dispatching byInitialize/ already-initialized branches; already-initialized requests go throughdispatch_initialized_client_requestto the corresponding processor (codex-rs/app-server/src/message_processor.rs:518-570).- Each initialized request is queued or spawned by
serialization_scope:RequestSerializationQueuesensures 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). - Outbound goes through
OutgoingEnvelope::ToConnection/Broadcast; broadcast only sends to already-initializedconnections; experimental notifications only go to connections that opted intoexperimental_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-540 — run_main_with_transport_options, the entry that loads config, auth, otel.codex-rs/app-server/src/lib.rs:849-990 — MessageProcessor spawn and the main select loop, handling transport events and outbound envelopes.codex-rs/app-server/src/message_processor.rs:224-302 — MessageProcessor::new, creating ThreadManager, ThreadStateManager, SkillsWatcher, and each processor.codex-rs/app-server/src/message_processor.rs:799-862 — dispatch_initialized_client_request, serialization-scope dispatch logic.codex-rs/app-server/src/message_processor.rs:864-1000 — handle_initialized_client_request big match, routing ClientRequest to each processor.codex-rs/app-server/src/transport.rs:134-172 — send_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-88 — JSONRPCMessage / 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-101 — InitializeRequestProcessor::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:
// 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:
// 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:
// 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_sendreturnsFull, and it directly callsdisconnect_connection, preventing a slow client from stalling the server; stdio only usessend().awaitblocking when there is nodisconnect_sender(codex-rs/app-server/src/transport.rs:154-172). - Uninitialized connections only accept Initialize: the first line of
dispatch_initialized_client_requestcheckssession.initialized(); if not initialized, it returnsinvalid_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, returningexperimental_required_message(codex-rs/app-server/src/message_processor.rs:810-814). - Shutdown waits for turn wind-down:
ShutdownState::updatelooks atrunning_turn_countandconnections.len(); only when both are 0 does itFinish; a timeout avoids waiting forever (codex-rs/app-server/src/lib.rs:877-892). - transport
offis 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.