app-server thread/start can hang in mcp_manager_init after shell snapshot and poison later starts
Summary
On Codex CLI 0.144.3 (app-server over a Unix socket), thread/start can remain pending indefinitely after the shell snapshot succeeds. The request is stuck in the aggregate session_init.mcp_manager_init await. A pending initialization can then contaminate later thread/start requests on the same long-lived app-server daemon.
This is intermittent and concurrency-correlated in production, but a controlled isolated reproduction deterministically reaches the same post-snapshot/no-return boundary by using a local streamable-HTTP MCP endpoint that accepts initialize and never completes it.
Environment: macOS 26.5.2 (25F84), arm64; Codex CLI 0.144.3.
Production evidence (UTC)
2026-07-14T03:22:30Z: the affected thread logsShell snapshot successfully created.- No later MCP activity or thread row is emitted before teardown at
03:52:30Z; teardown reportshad_active_turn=false. - A relaunch at
03:35:35Zalso wedges while the first request is still pending server-side on the same daemon (two distinct connection ids). This is the contagion signal: the original client retiring did not clear the poisoned startup family. - Other thread starts succeeded at
07:04Z,07:21Z, and07:41Z; another strike occurred at09:24Z. The strikes clustered around three simultaneous Codex turns and repeated full gates, and the daemon was clean by evening. - Thirteen
codex_models_managerrecords across the surrounding two days reportfailed to refresh available models: timeout waiting for child process to exit. This is sibling evidence for the child spawn/wait family, not proof that model refresh caused this incident. - Backend MCP connections to the ChatGPT backend MCP and the developer documentation MCP failed fast earlier in the period. No auth values, request bodies, or transcript content are included here.
Controlled reproduction
The reproduction does not touch the Desktop daemon. It creates a disposable CODEX_HOME, SQLite root, Unix socket, and app-server process with RUST_LOG=trace.
- Configure one required local streamable-HTTP MCP server with a long startup timeout.
- Have the local peer answer OAuth discovery with 404, accept each JSON-RPC
initializePOST, and intentionally never send a response. - Start one
thread/start. Once the peer has acceptedinitialize, start two morethread/startrequests and 12 short-lived child processes (all 12 children exit 0). - Bound the harness at 3 seconds.
Deterministic result:
{
"version": "codex-cli 0.144.3",
"isolated": true,
"liveDesktopDaemonTouched": false,
"concurrentThreadStarts": 3,
"childSpawns": 12,
"mcpInitializePostsHeldOpen": 3,
"threadStartResults": ["deadline", "deadline", "deadline"],
"traceBoundary": [
"session_init.mcp_manager_init: new",
"streamable HTTP initialize accepted",
"Shell snapshot successfully created",
"no session_init.mcp_manager_init close before deadline"
]
}
Await attribution
For this controlled reproduction, the exact blocked await is McpConnectionManager::new(...).await, instrumented as session_init.mcp_manager_init in codex-rs/core/src/session/session.rs at tag rust-v0.144.3. The held child is the streamable-HTTP MCP initialize, not a stdio child-stdout handshake. The trace never reaches the later required-server validation await.
The production log has the same post-snapshot/no-progress boundary, but it is too sparse to name which built-in backend MCP child (or another child within the aggregate constructor) was pending. I do not want to over-attribute the production event beyond mcp_manager_init.
Expected behavior
- Every MCP initialization child should have a bounded deadline that unwinds
thread/startwith a typed error. - Cancelling/disconnecting a
thread/startclient should cancel or retire its pending session-initialization work. - One hung initialization should not block or poison later thread starts on the shared daemon.
- The trace should identify the specific MCP child and phase that prevented the aggregate constructor from returning.
Downstream guard already shipped
The provider adapter now wraps the real thread/start boundary with a configurable 15-second deadline and durable per-socket identity marker. It returns stable thread_start_timeout for the first deadline and thread_start_wedge for a subsequent attempt against the same unchanged socket. It never blindly retries. An explicit operator canary can recover only after proving a launchd-owned daemon's PID, socket inode, socket owner, and version/health, then allows one re-probe. This contains the operational impact but does not fix the app-server leak/contagion upstream.
Possibly related but distinct: #32799 reports a bounded 60-second OAuth secrets-store lock on Windows. This report is an unbounded macOS app-server startup stall and is deterministically reproduced with a held streamable-HTTP MCP initialize.
This issue has 1 comment on GitHub. Read the full discussion on GitHub ↗