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

Resolved 💬 4 comments Opened Apr 3, 2026 by kmatsunami Closed Apr 3, 2026

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 ↗

This issue has 4 comments on GitHub. Read the full discussion on GitHub ↗