app-server: app/installed rebuilds the codex_apps MCP session per call, pinning ~120% of a core while idle
Nobody has claimed this yet.
- Dominant language
- Rust
- Stars
- 125k
- Forks
- 19.5k
- PR merge metrics
- PR metrics pending
Description
Summary
On a Linux host running codex app-server --listen unix:// for a Codex Desktop SSH-remote session, the app-server sat at 120–127% of one CPU core while completely idle (no active turn, no user present). The cause is that app/installed / app/list requests rebuild the entire codex_apps MCP session from scratch on every call, including a full system-CA-bundle re-parse and a fresh TLS handshake, and then drop the RunningService without close().
Restarting the app-server does not help — the Desktop client reconnects and resumes the same poll loop. Dropping the client connection (killing the codex app-server proxy relay) stops it immediately.
This is adjacent to #30606 but a different mechanism: no ambient-suggestion turns are involved here, and the hot path is MCP session construction rather than SQLite log churn.
Environment
- Remote: Debian, kernel 6.12.95, x86_64
codex-cli 0.144.0, vendored musl build (codex-linux-x64/vendor/x86_64-unknown-linux-musl/bin/codex)- Listener:
codex -c features.code_mode_host=true app-server --listen unix:// - Client: Codex Desktop over SSH remote,
app_server.client_name="Codex Desktop" - Enabled bundled plugins:
sites@openai-bundled,visualize@openai-bundled(these back thecodex_appsMCP server, 45 tools)
Observed behaviour
The client repeatedly issued only these requests — no others — from a single connection:
app/installed 0.40/s
app/list 0.10/s
mcpServerStatus/list 0.02/s
Each one drove a complete codex_apps rebuild cycle:
-
New HTTPS client built per call, re-reading and re-parsing the system CA bundle:
INFO codex_http_client::custom_ca transport_worker{name="StreamableHttpClientWorker"}: using system root certificates because no CA override environment variable was selected codex_ca_certificate_configured=false ssl_cert_file_configured=falseMeasured at 4.7/s.
-
No connection reuse — the whole pool is torn down each cycle:
TRACE hyper_util::client::legacy::pool pool dropped, dropping pooled (("https", chatgpt.com)) TRACE hyper_util::client::legacy::pool pool closed, canceling idle interval TRACE hyper_util::client::legacy::pool checkout waiting for idle connection: ("https", chatgpt.com) TRACE hyper_util::client::legacy::connect::http Http::connect; scheme=Some("https"), host=Some("chatgpt.com") -
Full
initialize+ListResources+ListToolsevery time, with a ~29.5 KBListResourcespayload serialized into a TRACE record:TRACE rmcp::service new{server_name=codex_apps}:start_server_task{server_name=codex_apps}:initialize:serve_inner: new event evt=PeerMessage(Response(JsonRpcResponse { ... ListResourcesResult { ... resources: [...] } })) -
664 KB tool-schema cache rewritten each cycle at
~/.codex/cache/codex_apps_tools/<hash>.json. -
RunningServicedropped withoutclose(), cancelling the transport:DEBUG rmcp::service RunningService dropped without explicit close(). The connection will be closed asynchronously. For guaranteed cleanup, call close() or cancel() before dropping. INFO rmcp::service ...:serve_inner: serve finished quit_reason=Cancelled INFO rmcp::service ...:serve_inner: task cancelled DEBUG rmcp::transport::worker worker quit with reason: Cancelled DEBUG rmcp::transport::streamable_http_client transport_worker{name="StreamableHttpClientWorker"}: cancelled -
The tool cache is never used. On every single pass:
TRACE codex_mcp::connection_manager list_all_tools{mcp_server_count=1}: waiting for MCP server tools while building tool list server_name=codex_apps has_cached_tools=true startup_complete=false TRACE codex_mcp::connection_manager list_all_tools{mcp_server_count=1}: listed MCP server tools while building tool list server_name=codex_apps tool_count=45startup_complete=falsenever latches true despitehas_cached_tools=true, so the fast path is dead code in this state.
Notably there were zero WARN and zero ERROR log entries throughout. The transport is not failing on the network — Codex cancels it itself, every cycle.
Measurements
Taken on the live process, with care not to attribute diagnostic overhead to the workload.
| Metric | Runaway | After dropping the client connection |
|---|---|---|
| app-server CPU | 120–127% of one core (sustained, 30 s windows) | 0.0–0.4% |
Log rows written to logs_2.sqlite |
21.4 rows/s | 0.0 rows/s |
| CA-bundle reloads | 4.7/s | 0 |
rchar |
3.0 MB/s (read_bytes = 0 — entirely page cache) |
~0 |
write_bytes |
0.5 MB/s | ~0 |
| RSS | 278 ↔ 355 MB sawtooth, ~12 s period | flat |
| Voluntary ctx switches | ~1,600/s per worker, ~13k/s across 8 workers | idle |
| CPU package temp | 82–87 °C | 56–58 °C |
Load was spread evenly across all 8 tokio-rt-worker threads (work stealing), which is why no single thread looked hot in top -H and why this is easy to misread as a runtime-level spin rather than a repeated workload. The sqlx-sqlite-worker threads were cold (~70 jiffies total), ruling out query execution as the hot path.
A healthy reconnected client costs a single ~5 s burst peaking at 237% during handshake, then settles to 0%. So client presence is not the problem — only the stuck poll loop is.
Reproduction
- Run
codex app-server --listen unix://on a Linux remote with the bundledsites/visualizeplugins enabled. - Connect Codex Desktop to it over SSH remote.
- Leave it idle. When the client enters the
app/installedpoll loop, the app-server pins ~120% of a core indefinitely. kill -TERMthecodex app-server proxyrelay → CPU drops to 0% immediately. Restarting the app-server instead does not help, because the client reconnects into the same loop.
Suggested fixes
- Reuse a pooled HTTPS client instead of constructing one per MCP session. Rebuilding the rustls root store from the system CA bundle 4.7 times a second is the single largest cost and is pure waste — the trust anchors do not change between calls.
- Latch
startup_completesohas_cached_tools=trueactually short-circuits the rebuild inlist_all_tools. As written, the cache is populated and then ignored. - Call
close()onRunningServicerather than dropping it —rmcpexplicitly warns about this, and the cancel/respawn churn is what multiplies the cost across the Tokio workers. - Consider rate-limiting or coalescing
app/installed/app/list, so a misbehaving client cannot amplify one poll into a full remote MCP handshake.
Side observation
~/.codex/logs_2.sqlite had grown to 1.76 GB with only 0.37 GB live data — 309,706 of 461,104 pages were freelist (~1.18 GB, 67%, reclaimable). Retention deletes rows but never VACUUMs, so the file only grows. Not the cause of the CPU issue, but it makes every log insert progressively more expensive and is worth a periodic VACUUM or auto_vacuum=INCREMENTAL.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start at the app/installed and app/list request handlers, then follow list_all_tools for the codex_apps MCP server, including startup_complete, the tool cache, and RunningService cleanup. Reproduce the Desktop polling loop with app-server and verify that repeated requests no longer rebuild and cancel the MCP session, while CPU and connection behavior remain stable.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- backend, performance
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 52/100