App-server アーキテクチャ
app-server は codex の JSON-RPC サーバーで、codex-core の ThreadManager、AuthManager、ConfigManager を IDE、Web、TUI が共有できる一つのプロセスに包む。外部には stdio / unix socket / websocket の三つの transport を露出し、内部では MessageProcessor がリクエストルーティングを行い、全ての RPC は同じ ClientRequest / ServerNotification プロトコルを通る。
責務
- 入口
run_main_with_transport_optionsが config、auth、otel、state_db を読み込み、transport acceptor と outbound router を起動し、最後にMessageProcessorを spawn して主 select ループに入る (codex-rs/app-server/src/lib.rs:449-460)。 - Transport 層は
stdio:///unix:///ws://IP:PORT/offをTransportEvent(ConnectionOpened / ConnectionClosed / IncomingMessage)に統一し、全接続が一つの event channel を共有する (codex-rs/app-server-transport/src/transport/mod.rs:73-78)。 MessageProcessor::process_requestがJSONRPCRequestをClientRequestにデシリアライズし、Initialize/ 初期化済みの分岐でディスパッチする。初期化済みリクエストはdispatch_initialized_client_request経由で対応する processor に入る (codex-rs/app-server/src/message_processor.rs:518-570)。- 各初期化済みリクエストは
serialization_scopeで排队するか spawn するかを決める:RequestSerializationQueuesが同 scope リクエストを直列化(例:同 thread の turn 操作)し、scope をまたいで初めて並発する (codex-rs/app-server/src/message_processor.rs:846-861)。 - 出力は
OutgoingEnvelope::ToConnection/Broadcastで送る。Broadcast はinitialized済み接続にだけ送り、experimental 通知はexperimental_apiを有効にした接続にだけ送る (codex-rs/app-server/src/transport.rs:198-237)。
設計動機
app-server が独立 crate なのは、IDE(VSCode、JetBrains)、TUI、リモートペアリング app が同じプロトコルを共有するが実行環境が異なるからだ:IDE は stdio、リモートペアリングは websocket、daemon モードは unix socket + pid ファイルが必要。transport を app-server-transport crate に、プロトコルを app-server-protocol crate に切り出し、server crate は「どう JSON-RPC を ThreadManager にルーティングするか」だけを気にする。こうすれば新 transport を追加しても主ロジックを触らなくてよい。
ConnectionSessionState が Mutex ではなく OnceLock<InitializedConnectionSessionState> を使うのは、initialize が一回限りの操作で、一度設定したら変更できず以降のリクエストは read-only だからだ。これで「初期化済み接続」のロックオーバーヘッドを避ける。experimental_api_enabled / opted_out_notification_methods はどちらも atomic / RwLock で読む。
リクエストルーティングが単一の match ではなく processor struct の集合なのは、各 processor(ThreadRequestProcessor、TurnRequestProcessor、ConfigRequestProcessor など)が自身の state を持ち、handle_initialized_client_request は match でディスパッチするだけだからだ。新メソッドを追加する時に中心コードを触らず、processor 内部のテストも書きやすい。
serialization_scope は面白い設計だ:同 thread の turn 操作は直列化しなければならない(さもないと二つの Op::UserInput が乱れる)が、異なる thread のリクエストは並発できる。RequestSerializationQueues は (connection_id, scope) を key に分け、同 key リクエストは排队し、key を跨げば直接 tokio::spawn する。
outbound ルーティングの try_send + キュー満杯で disconnect は、遅いクライアントが server を引きずり込むのを防ぐためだ:websocket の遅い接続はキューが満杯になったら直接 kick し、stdio/stdin には断流保護がないため send().await で待機する。
主要ファイル
codex-rs/app-server/src/main.rs:19-60 — AppServerArgs コマンドライン。--listen が transport をパースし、--session-source が vscode / cli / mcp を区別。codex-rs/app-server/src/lib.rs:449-540 — run_main_with_transport_options。config、auth、otel を読み込む入口。codex-rs/app-server/src/lib.rs:849-990 — MessageProcessor の spawn と主 select ループ。transport event と outbound envelope を処理。codex-rs/app-server/src/message_processor.rs:224-302 — MessageProcessor::new。ThreadManager、ThreadStateManager、SkillsWatcher、各 processor を作成。codex-rs/app-server/src/message_processor.rs:799-862 — dispatch_initialized_client_request。serialization scope のディスパッチロジック。codex-rs/app-server/src/message_processor.rs:864-1000 — handle_initialized_client_request の大きな match。ClientRequest を各 processor にルーティング。codex-rs/app-server/src/transport.rs:134-172 — send_message_to_connection。遅い接続はキュー満杯で disconnect。codex-rs/app-server-transport/src/transport/mod.rs:73-158 — AppServerTransport 列挙型と from_listen_url のパース。codex-rs/app-server-protocol/src/rpc.rs:34-88 — JSONRPCMessage / JSONRPCRequest / JSONRPCError。プロトコルの基本型(コメントには真の JSON-RPC 2.0 ではなく、jsonrpc フィールドを要求しないと明記)。codex-rs/app-server/src/request_processors/initialize_processor.rs:44-101 — InitializeRequestProcessor::initialize。接続の experimental_api、attestation、originator などのグローバル身分を設定。主 select は transport event と outbound envelope の二択で、shutdown 信号ガード付き。transport 分岐は IncomingMessage を processor.process_request に渡し、outbound 分岐は route_outgoing_envelope でルーティングする:
// 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 はリクエストを QueuedInitializedRequest に変換してから排队するか spawn するかを決める。同 scope は必ず直列化し、scope を跨いで初めて並発する——例えば二つの connection がそれぞれ turn を送っても互いにブロックしない:
// 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 は指向送信と broadcast を区別する:broadcast 時は初期化済みの全接続を走査するが、experimental notification は experimental_api を有効化した接続にだけ送る。opted_out_notification_methods でクライアントはメソッド名単位で配信停止できる:
// 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();
// ...
}データフロー
境界と失敗
- 遅い接続は kick される:websocket 接続の outbound キューが満杯になると
try_sendがFullを返し、直接disconnect_connectionする。遅いクライアントが server を引きずり込むのを防ぐため。stdio には disconnect_sender がない場合だけsend().awaitでブロックする (codex-rs/app-server/src/transport.rs:154-172)。 - 未初期化接続は Initialize のみ受け付ける:
dispatch_initialized_client_requestの最初の行がsession.initialized()を検査し、未初期化ならinvalid_request("Not initialized")を返す (codex-rs/app-server/src/message_processor.rs:806-808)。 - experimental リクエストはゲートされる:
experimental_reason()を持つリクエストは experimental_api 未有効の接続で拒否され、experimental_required_messageを返す (codex-rs/app-server/src/message_processor.rs:810-814)。 - shutdown は turn の後処理を待つ:
ShutdownState::updateがrunning_turn_countとconnections.len()を見て、両方が 0 になった時だけFinishする。タイムアウト付きで待ち死にを避ける (codex-rs/app-server/src/lib.rs:877-892)。 - transport
offも合法:acceptor を一つも起動せず remote control だけを走らせる。headless シーンで daemon がリモート codex を制御するためのパスだ (codex-rs/app-server/src/lib.rs:712-740)。
まとめ
app-server は codex のプロトコル層だ:transport は多形、processor は各々が自分の担当を持ち、serialization scope が同 thread の直列化を保証する。ConnectionSessionState は OnceLock で「一度初期化したら不変」というセマンティクスを表す。さらに下を見るには、リモートペアリングと daemon ライフサイクルは app-server-daemon crate、クライアント封装は app-server-client にある。MCP ツール呼び出しがどうこれ経由で core に入るかは MCP 統合 を参照。