Desktop: app-server death mid-turn leaves every thread permanently unsendable (latestTurnStatus=interrupted + markedStreaming=true, zero turn/start afterwards)
Summary
When an OAuth-backed Streamable HTTP MCP server returns 401 with a WWW-Authenticate: Bearer resource_metadata=... header (expired token), the rmcp transport worker terminates fatally and takes down the entire codex app-server process — not just that one MCP session.
On Codex Desktop the blast radius is total: after the process dies mid-turn, the rollout is left without a turn-completion record, and on every subsequent launch the renderer resumes the thread with latestTurnStatus=interrupted and markedStreaming=true. The composer then believes a turn is permanently in flight and submits nothing. New conversations cannot be started either. Restarting the app does not help, because the state is re-derived from the truncated rollout on each resume. The only recovery is hand-editing ~/.codex/config.toml to disable the offending plugin.
This looks like the missing trigger requested in #36863, which observed the identical worker quit with fatal: Transport channel closed symptom but concluded that the 429/503 path in rmcp 3.0.0 was not sufficient to explain a fatal worker shutdown, and asked for the "concurrent or subsequent event". Here the trigger is AuthRequired, not a transient 5xx.
Worth stressing: the offending server is an optional third-party plugin. A single non-official MCP server with an expired token should not be able to brick the whole client.
Environment
- Codex Desktop
26.820.7780.0(MSIX), Windows 11 build 26100 - bundled
codex-cli 0.149.0,rmcp3.0.0 - MCP server: third-party
agentkeyplugin —{"type": "http", "url": "https://api.agentkey.app/v1/mcp"}, OAuth-backed, access token expired
Log evidence
Fatal worker exit escalating to full process death:
app_server_connection.closed code=4294967295 connectionId=1 transport=stdio
reason="{\"level\":\"ERROR\",\"fields\":{\"message\":\"worker quit with fatal:
Transport channel closed, when AuthRequired(AuthRequiredError {
www_authenticate_header: \\\"Bearer resource_metadata=\\\\\\\"https://api.agentkey.app/.well-known/oauth-protected-resource\\\\\\\"\\\"
})\"},\"target\":\"rmcp::transport::worker\"}"
error [AppServerConnection] Codex CLI process exited classifiedAsExpected=false code=4294967295
error [AppServerConnection] fatal_error_broadcasted initialized=false listenerCount=2 pendingRequestCount=0
info [electron-message-handler] remote_connections.manager_state_set nextState=error
The same crash had already fired twice earlier the same morning, surfacing with a different exit code when the watchdog terminated the dying process:
error [AppServerConnection] Codex CLI process exited classifiedAsExpected=false code=1073807364
(0x40010004 = DBG_TERMINATE_PROCESS.)
Resulting stuck state (the user-visible bug)
The in-flight turn is never finalized. The rollout JSONL ends mid-turn on a custom_tool_call_output, with no turn-completion and no interrupt record. On the next launch:
maybe_resume_success conversationId=<redacted> latestTurnId=<redacted>
latestTurnStatus=interrupted markedStreaming=true turnCount=1
hasLatestTurnParams=false hasCurrentPermissions=false hasLatestThreadSettings=false
With markedStreaming=true the composer treats a turn as in flight. Across the whole following app session there are zero turn/start events despite repeated send attempts — in that thread and in unrelated threads. Esc / stop does not clear it. Restarting the app does not clear it.
Reproduction
- Configure an OAuth-backed Streamable HTTP MCP server and authenticate successfully.
- Let the access token expire, or revoke it server-side, so the endpoint answers
401withWWW-Authenticate: Bearer resource_metadata=.... - Start a turn in Codex Desktop that reaches the MCP layer.
- Observe the app-server process exit, rather than an MCP-scoped tool error.
- Relaunch Codex. The thread resumes as
interrupted/markedStreamingand the composer accepts no input, in any thread.
Expected behaviour
AuthRequiredshould surface as a per-server re-auth prompt, or at worst as a failed tool call scoped to that server. It must never terminate the app-server process. One optional MCP server should be isolated from the rest of the client.- When the app-server dies mid-turn, the orphaned turn should be finalized as
failedon resume, and the composer must unlock. A thread whose last turn isinterruptedshould be immediately writable. - Starting a new conversation must never be blocked by another thread's stale streaming state.
Workaround
Set enabled = false for the offending plugin in ~/.codex/config.toml and restart. There is no in-app recovery path, because the UI itself is unresponsive by the time the user notices.
Related
#36863 (same symptom, trigger unidentified), #27165, #33403, #35006
3 Comments
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
Correction to the original report — I over-claimed the causal chain, and want to fix that before anyone spends time on it.
After reading the source with the tree checked out, the claim in the title and summary that
AuthRequiredkills the app-server process is not supported by the evidence I have. Splitting it into what holds and what does not:Holds up:
StreamableHttpError::AuthRequired, and rmcp's worker treats this as fatal —worker quit with fatal: Transport channel closed, when AuthRequired(...)is logged byrmcp::transport::workeritself. The MCP server is dead for the remainder of the session with no re-auth prompt.latestTurnStatus=interruptedandmarkedStreaming=true, with zeroturn/startevents afterwards in any thread.Does not hold up:
1073807364(DBG_TERMINATE_PROCESS) and4294967295(-1) — are signatures of external termination, not of a Rust panic (which would be101) or a clean exit. I could not find anyprocess::exiton an MCP error path incodex-rs/app-server/src/. Thereasonstring attached toapp_server_connection.closedappears to be the most recent stderr ERROR line rather than a causally linked event; in my capture it is timestamped 83 seconds before the close.AuthRequiredfatal. The earlier1073807364crash predates any intervention on my part and is clean, but I can no longer claim a controlled repro of the process death.What I think the actionable defect actually is
is_authentication_required_error()has exactly one non-test caller in the tree:So the "needs authentication" classification is wired only into the startup path. When a token expires mid-session, the runtime
AuthRequiredis never classified, so it surfaces neither as a re-auth affordance nor as a scoped tool-call failure — the server simply goes silent until a restart.streamable_http_retry.rs:171also (correctly) marksAuthRequirednon-retryable, which means nothing else picks it up either.That gap is small, well-scoped, and looks like the missing piece #36863 is after. I'd like to send a PR that classifies
AuthRequiredon the running-session path and surfaces re-auth instead of a silently dead transport. Happy to retitle or close this issue in favour of a tighter one if maintainers prefer — and happy to be told the process-death angle is a separate concern.Retracting the diagnosis in my previous comment. Do not act on it — I had it wrong, and I want that on the record before it costs anyone time.
I wrote a patch against this theory and had it adversarially reviewed against the actual
rmcp3.1.3 sources before opening a PR. Two load-bearing claims failed:1. The fatal I quoted is on the bootstrap path, not a mid-session request.
rmcp-3.1.3/src/transport/streamable_http_client.rs:883sits inside the initialize block (startup_request,bootstrap_headers,expect_initialized). Soworker quit with fatal: Transport channel closed, when AuthRequired(...)in my logs was emitted while establishing the MCP session, not while a live one expired. The startup path already classifies this viais_authentication_required_error. My "classified only on the startup path / mid-session expiry is unhandled" framing does not describe what my logs actually captured.For completeness: once a worker is genuinely dead, later requests surface as
ServiceError::TransportClosed(rmcp-3.1.3/src/service.rs:904), which is a different variant from the one I was reasoning about.2. rmcp already does the recovery I was proposing to add.
rmcp-3.1.3/src/transport/common/auth/streamable_http_client.rs:43reacts toAuthRequiredby callingtry_refresh_or_reauth()and replaying the request once, returningAuthRequiredonly when the refresh failed or produced the same token. So anAuthRequiredsurfacing to Codex already means refresh was unavailable or rejected. Adding a transport rebuild on top would mostly repeat a failed sequence — and worse, because Codex persists refreshed credentials only after a successful operation (rmcp-client/src/rmcp_client.rs:802), a rebuild after a rotating refresh token had already been consumed in memory could reload the stale predecessor from disk and force a spurious reauthorization. That is a regression, not a fix.What I still think is real, and is the only thing I'd like to leave open here
The Desktop-side stuck state, which does not depend on any of the above being true. Whatever kills the app-server mid-turn, the rollout is left with no turn-completion record, and every subsequent resume reports:
After that there are zero
turn/startevents for the remainder of the app session — in that thread and in unrelated threads — so no message can be sent and no new conversation can be started.Esc/stop does not clear it, and restarting the app does not either, because the state is re-derived from the truncated rollout on every resume. The only recovery I found was editingconfig.tomlto disable the MCP server that was failing at startup.I've retitled the issue to that scope. Apologies for the noise on the transport theory — happy for a maintainer to close this if the Desktop half is already tracked elsewhere.