Skip to content

App-server アーキテクチャ

源码版本rust-v0.145.0

app-server は codex の JSON-RPC サーバーで、codex-coreThreadManagerAuthManagerConfigManager を IDE、Web、TUI が共有できる一つのプロセスに包む。外部には stdio / unix socket / websocket の三つの transport を露出し、内部では MessageProcessor がリクエストルーティングを行い、全ての RPC は同じ ClientRequest / ServerNotification プロトコルを通る。

責務

  1. 入口 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)。
  2. Transport 層は stdio:// / unix:// / ws://IP:PORT / offTransportEvent(ConnectionOpened / ConnectionClosed / IncomingMessage)に統一し、全接続が一つの event channel を共有する (codex-rs/app-server-transport/src/transport/mod.rs:73-78)。
  3. MessageProcessor::process_requestJSONRPCRequestClientRequest にデシリアライズし、Initialize / 初期化済みの分岐でディスパッチする。初期化済みリクエストは dispatch_initialized_client_request 経由で対応する processor に入る (codex-rs/app-server/src/message_processor.rs:518-570)。
  4. 各初期化済みリクエストは serialization_scope で排队するか spawn するかを決める:RequestSerializationQueues が同 scope リクエストを直列化(例:同 thread の turn 操作)し、scope をまたいで初めて並発する (codex-rs/app-server/src/message_processor.rs:846-861)。
  5. 出力は 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 を追加しても主ロジックを触らなくてよい。

ConnectionSessionStateMutex ではなく OnceLock<InitializedConnectionSessionState> を使うのは、initialize が一回限りの操作で、一度設定したら変更できず以降のリクエストは read-only だからだ。これで「初期化済み接続」のロックオーバーヘッドを避ける。experimental_api_enabled / opted_out_notification_methods はどちらも atomic / RwLock で読む。

リクエストルーティングが単一の match ではなく processor struct の集合なのは、各 processor(ThreadRequestProcessorTurnRequestProcessorConfigRequestProcessor など)が自身の 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-60AppServerArgs コマンドライン。--listen が transport をパースし、--session-source が vscode / cli / mcp を区別。codex-rs/app-server/src/lib.rs:449-540run_main_with_transport_options。config、auth、otel を読み込む入口。codex-rs/app-server/src/lib.rs:849-990MessageProcessor の spawn と主 select ループ。transport event と outbound envelope を処理。codex-rs/app-server/src/message_processor.rs:224-302MessageProcessor::newThreadManagerThreadStateManagerSkillsWatcher、各 processor を作成。codex-rs/app-server/src/message_processor.rs:799-862dispatch_initialized_client_request。serialization scope のディスパッチロジック。codex-rs/app-server/src/message_processor.rs:864-1000handle_initialized_client_request の大きな match。ClientRequest を各 processor にルーティング。codex-rs/app-server/src/transport.rs:134-172send_message_to_connection。遅い接続はキュー満杯で disconnect。codex-rs/app-server-transport/src/transport/mod.rs:73-158AppServerTransport 列挙型と from_listen_url のパース。codex-rs/app-server-protocol/src/rpc.rs:34-88JSONRPCMessage / JSONRPCRequest / JSONRPCError。プロトコルの基本型(コメントには真の JSON-RPC 2.0 ではなく、jsonrpc フィールドを要求しないと明記)。codex-rs/app-server/src/request_processors/initialize_processor.rs:44-101InitializeRequestProcessor::initialize。接続の experimental_api、attestation、originator などのグローバル身分を設定。

主 select は transport event と outbound envelope の二択で、shutdown 信号ガード付き。transport 分岐は IncomingMessageprocessor.process_request に渡し、outbound 分岐は route_outgoing_envelope でルーティングする:

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 はリクエストを QueuedInitializedRequest に変換してから排队するか spawn するかを決める。同 scope は必ず直列化し、scope を跨いで初めて並発する——例えば二つの connection がそれぞれ turn を送っても互いにブロックしない:

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 は指向送信と broadcast を区別する:broadcast 時は初期化済みの全接続を走査するが、experimental notification は experimental_api を有効化した接続にだけ送る。opted_out_notification_methods でクライアントはメソッド名単位で配信停止できる:

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();
    // ...
}

データフロー

境界と失敗

  • 遅い接続は kick される:websocket 接続の outbound キューが満杯になると try_sendFull を返し、直接 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::updaterunning_turn_countconnections.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 の直列化を保証する。ConnectionSessionStateOnceLock で「一度初期化したら不変」というセマンティクスを表す。さらに下を見るには、リモートペアリングと daemon ライフサイクルは app-server-daemon crate、クライアント封装は app-server-client にある。MCP ツール呼び出しがどうこれ経由で core に入るかは MCP 統合 を参照。