app-server websocket disconnect leaves orphaned MCP-backed threads loaded

Resolved 💬 4 comments Opened Apr 3, 2026 by kmatsunami Closed Apr 3, 2026
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

Summary

When the last app-server websocket connection drops without sending thread/unsubscribe, the loaded thread remains in memory and its MCP child process stays alive.

The root cause appears to be that connection_closed removes connection bookkeeping but does not unload threads that now have zero subscribers. thread/unsubscribe already has the correct unload path, but disconnects do not go through it.

Impact

In the desktop app this lets MCP-backed threads accumulate under the long-lived app-server process. Over time that leaves large numbers of retained biz-cosense-mcp-server, playwright-mcp, and wrapper processes alive after their owning connections are gone.

Reproduction

A deterministic regression test can reproduce this with a real stdio MCP child process:

  1. Start app-server in websocket mode with a stdio MCP server configured.
  2. Connect websocket client A and initialize it.
  3. Start a thread so the MCP stdio child process is launched.
  4. Verify the thread appears in thread/loaded/list and the MCP child process is alive.
  5. Close websocket client A without sending thread/unsubscribe.
  6. Reconnect as websocket client B and inspect thread/loaded/list.

Expected

  • The last disconnect should unload the orphaned thread.
  • The MCP stdio child process should exit.
  • A subsequent thread/loaded/list should be empty.

Actual

  • The thread remains loaded after the last websocket disconnect.
  • The MCP stdio child process stays alive.
  • The new regression test times out waiting for the child process to exit.

Evidence

Before the fix, this targeted test fails:

mise exec rust@1.93.0 -- cargo test -p codex-app-server websocket_disconnect_unloads_thread_and_its_mcp_process -- --nocapture

Failure mode:

  • wait_for_process_exit(&pid) times out after websocket close.
  • Reconnecting still shows the thread in thread/loaded/list.

Acceptance Criteria

  • connection_closed unloads threads whose last subscriber disappeared.
  • The disconnect path reuses the same thread teardown logic already used by thread/unsubscribe.
  • A regression test covers websocket disconnect cleanup with a real stdio MCP child process and passes.
  • Existing thread_unsubscribe tests continue to pass.

View original on GitHub ↗

4 Comments

github-actions[bot] contributor · 4 months ago

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

  • #16256
  • #14950
  • #15379
  • #16271

Powered by Codex Action

kmatsunami · 4 months ago

Tested fix is available here:

I could not open a PR because openai/codex is configured with pull_request_creation_policy: collaborators_only.

Verified locally:

  • mise exec rust@1.93.0 -- cargo test -p codex-app-server websocket_disconnect_unloads_thread_and_its_mcp_process -- --nocapture
  • mise exec rust@1.93.0 -- cargo test -p codex-app-server removing_last_connection_returns_orphaned_thread -- --nocapture
  • mise exec rust@1.93.0 -- cargo test -p codex-app-server thread_unsubscribe -- --nocapture
kmatsunami · 4 months ago

I checked the duplicate suggestions.

Closest related issues:

  • #14950: same broad Codex App symptom on macOS (stale MCP helper process trees)
  • #16256: same broad MCP lifecycle problem family, but focused on CLI suspend / Ctrl+Z

I think this issue is still worth keeping open because it narrows one concrete app-server cause with a deterministic repro and tested fix:

  • when the last websocket client disconnects without thread/unsubscribe, connection_closed drops connection bookkeeping but does not unload the thread that now has zero subscribers
  • that leaves the thread loaded and keeps its MCP stdio child process alive
  • the fix branch here adds a regression test for that exact websocket-disconnect path and routes it through the existing thread teardown logic

So my read is:

  • #14950 is likely the broader user-facing symptom tracker for the desktop app
  • this issue documents one specific root cause / fixable path under that umbrella

Fix branch and compare are here if useful for triage:

etraut-openai contributor · 4 months ago

Closing in favor of #14950.