app-server websocket disconnect leaves orphaned MCP-backed threads loaded
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:
- Start app-server in websocket mode with a stdio MCP server configured.
- Connect websocket client A and initialize it.
- Start a thread so the MCP stdio child process is launched.
- Verify the thread appears in
thread/loaded/listand the MCP child process is alive. - Close websocket client A without sending
thread/unsubscribe. - 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/listshould 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_closedunloads 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_unsubscribetests continue to pass.
This issue has 4 comments on GitHub. Read the full discussion on GitHub ↗