Desktop: app-server death mid-turn leaves every thread permanently unsendable (latestTurnStatus=interrupted + markedStreaming=true, zero turn/start afterwards)

Open 💬 3 comments Opened Aug 26, 2026 by hjxccc
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

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, rmcp 3.0.0
  • MCP server: third-party agentkey plugin — {"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

  1. Configure an OAuth-backed Streamable HTTP MCP server and authenticate successfully.
  2. Let the access token expire, or revoke it server-side, so the endpoint answers 401 with WWW-Authenticate: Bearer resource_metadata=....
  3. Start a turn in Codex Desktop that reaches the MCP layer.
  4. Observe the app-server process exit, rather than an MCP-scoped tool error.
  5. Relaunch Codex. The thread resumes as interrupted / markedStreaming and the composer accepts no input, in any thread.

Expected behaviour

  1. AuthRequired should 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.
  2. When the app-server dies mid-turn, the orphaned turn should be finalized as failed on resume, and the composer must unlock. A thread whose last turn is interrupted should be immediately writable.
  3. 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

View original on GitHub ↗

3 Comments

github-actions[bot] contributor · 2 days ago

Potential duplicates detected. Please review them and close your issue if it is a duplicate.

  • #40332

Powered by Codex Action

hjxccc · 2 days ago

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 AuthRequired kills the app-server process is not supported by the evidence I have. Splitting it into what holds and what does not:

Holds up:

  • An expired OAuth token on a Streamable HTTP MCP server produces StreamableHttpError::AuthRequired, and rmcp's worker treats this as fatal — worker quit with fatal: Transport channel closed, when AuthRequired(...) is logged by rmcp::transport::worker itself. The MCP server is dead for the remainder of the session with no re-auth prompt.
  • The Desktop-side stuck state is fully reproducible and independent of the cause of death: any app-server death mid-turn leaves the rollout without a turn-completion record, and every subsequent resume reports latestTurnStatus=interrupted and markedStreaming=true, with zero turn/start events afterwards in any thread.

Does not hold up:

  • The last hop, "fatal MCP worker → process exit", is unproven. The two observed exit codes — 1073807364 (DBG_TERMINATE_PROCESS) and 4294967295 (-1) — are signatures of external termination, not of a Rust panic (which would be 101) or a clean exit. I could not find any process::exit on an MCP error path in codex-rs/app-server/src/. The reason string attached to app_server_connection.closed appears 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.
  • Disclosure: the final crash in my logs is confounded — I had manually terminated the previous app-server process ~30s before the replacement process logged the AuthRequired fatal. The earlier 1073807364 crash 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:

codex-rs/codex-mcp/src/rmcp_client.rs:609
    impl From<anyhow::Error> for StartupOutcomeError

So the "needs authentication" classification is wired only into the startup path. When a token expires mid-session, the runtime AuthRequired is 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:171 also (correctly) marks AuthRequired non-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 AuthRequired on 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.

hjxccc · 2 days ago

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 rmcp 3.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:883 sits inside the initialize block (startup_request, bootstrap_headers, expect_initialized). So worker 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 via is_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:43 reacts to AuthRequired by calling try_refresh_or_reauth() and replaying the request once, returning AuthRequired only when the refresh failed or produced the same token. So an AuthRequired surfacing 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:

maybe_resume_success latestTurnStatus=interrupted markedStreaming=true turnCount=1

After that there are zero turn/start events 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 editing config.toml to 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.