Codex Desktop ephemeral thread summaries leak full MCP stacks via thread/unsubscribe

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

Summary

Codex Desktop's internal concurrent reasoning-summary path creates an ephemeral thread for each thread_summary generation. That thread inherits and eagerly starts the user's complete global stdio MCP configuration, even though summarization does not use MCP tools. When generation finishes, the desktop client calls thread/unsubscribe; it does not remove or shut down the ephemeral thread.

The app-server implementation of thread/unsubscribe only removes the connection's subscription. It intentionally leaves the thread/session loaded, so the session-owned MCP runtime and all of its processes remain alive. Repeated summaries therefore produce repeated complete MCP stacks and unbounded process/RSS growth under one long-lived app-server.

This is a distinct trigger within the broader MCP lifecycle defect: no user-created subagent, restored task, or MCP call is required. The desktop's own summary helper generates the leaked sessions.

Environment

  • macOS 26.6.2 (25G83), arm64
  • ChatGPT/Codex Desktop 26.814.41407
  • Bundled codex-cli 0.148.0-alpha.15
  • App-server command: codex -c features.code_mode_host=true app-server --analytics-default-enabled
  • Several globally configured local stdio MCP servers
  • concurrent_reasoning_summaries enabled by the desktop feature overlay

Reproduction

  1. Configure several global stdio MCP servers in ~/.codex/config.toml. The server identities do not matter; using 3 or more makes the growth obvious. For example:

```toml
[mcp_servers.example_a]
command = "node"
args = ["/absolute/path/to/server-a.js"]

[mcp_servers.example_b]
command = "python3"
args = ["/absolute/path/to/server-b.py"]

[mcp_servers.example_c]
command = "node"
args = ["/absolute/path/to/server-c.js"]
```

  1. Fully quit and relaunch ChatGPT/Codex Desktop so the app-server starts from a clean baseline.
  1. Find the app-server PID:

``bash
APP_SERVER_PID="$(pgrep -f 'ChatGPT.app/Contents/Resources/codex.*app-server' | head -n 1)"
echo "$APP_SERVER_PID"
``

  1. In another terminal, monitor its direct children. Each summary thread adds approximately one direct launcher/root per configured stdio MCP server; wrappers may add transitive descendants.

``bash
while true; do
date
ps -axo ppid=,rss=,command= |
awk -v p="$APP_SERVER_PID" '$1 == p { n++; rss += $2 } END { printf "direct children=%d direct-child RSS=%.1f MiB\n", n, rss/1024 }'
sleep 2
done
``

  1. Start or continue a Codex task and generate multiple turns. Realtime voice makes the issue reproduce quickly because short utterance/turn boundaries frequently trigger concurrent reasoning summaries, but voice is not required if the feature generates summaries during normal turns.
  1. Correlate the process growth with the desktop log:

``bash
rg 'feature=thread_summary|unknown conversation' ~/Library/Logs/com.openai.codex
``

  1. Wait after the visible turn is complete. The extra MCP process sets remain. Repeat step 5 and the count continues to rise.
  1. Fully quit ChatGPT/Codex Desktop. The retained processes disappear. Relaunching resets the count, but subsequent summary generations reproduce the growth.

Observed result

On one app-server lifetime:

  • clean post-restart baseline: 9 descendants / about 236 MiB RSS
  • after normal task and realtime-voice summary activity: 449 MCP-related processes / about 10.97 GiB RSS
  • desktop log: 116 successful feature=thread_summary generations in roughly 31 minutes
  • only 4 user-visible active tasks, but roughly 133 retained complete MCP bundles

Process birth times aligned with thread_summary generation and unknown conversation events. Killing only the stale MCP descendants reclaimed the memory, but new summary generations immediately created new complete sets. The transient thread IDs were absent from the persistent thread database and rollout files, consistent with in-memory ephemeral threads that cannot be closed from the UI.

Representative sanitized log shape:

ephemeral_generation_token_usage feature=thread_summary ... status=success
Received turn/started for unknown conversation conversationId=<ephemeral-id>

Expected result

Internal summarization/title-generation threads should either:

  1. start with MCP servers disabled because they do not need tools, or
  2. reliably remove/shut down their session and MCP runtime when generation finishes.

Repeated summaries should return to a bounded process and memory baseline.

Root-cause evidence

Inspection of the packaged desktop code shows the generic ephemeral-generation helper doing the equivalent of:

const thread = await client.startThread({ ephemeral: true, ...options });
try {
  return await runStructuredTurn(thread);
} finally {
  client.unsubscribeThread(thread).catch(() => {});
}

The thread_summary feature calls this helper. Its request overrides disable apps/plugins/tool surfaces, but do not clear mcp_servers, so global stdio MCP configuration is inherited by every ephemeral summary session.

In the exact bundled backend source, thread_unsubscribe_response_inner only calls unsubscribe_connection_from_thread. It does not remove the thread or wait for shutdown.

The actual removal path, prepare_thread_for_removal, calls thread_manager.remove_thread and wait_for_thread_shutdown. Session shutdown in turn calls mcp_runtime.shutdown().

This also explains why process cleanup works when the app-server or a real session is shut down: the MCP runtime has a functioning shutdown path, but the ephemeral summary caller never reaches it.

Suggested fix and regression test

  • Make desktop-owned summary/title ephemeral generations explicitly start without inherited MCP servers.
  • Treat completion of a one-shot ephemeral generation as an ownership boundary: remove/shut down the thread, rather than only unsubscribing the connection.
  • Add a test that runs many ephemeral structured generations with a sentinel stdio MCP server and asserts that live sessions/processes return to baseline.
  • Consider reporting the owning thread/feature for each MCP runtime so retained internal sessions can be diagnosed without OS-level process tracing.

Workarounds

  • Fully quitting the app resets the retained in-memory sessions and reaps their MCP stacks.
  • Avoiding realtime voice reduces the dominant trigger rate in this reproduction.
  • Disabling heavyweight global MCP servers limits the impact but removes their functionality.
  • Killing child processes alone is temporary; the next summary generation creates another full set.

Related

  • #25015 — app-server MCP stacks retained for subagents; closest lifecycle/root-cause family
  • #17574 — subagents leak stdio MCP helper trees
  • #21984 — MCP servers eagerly start per session
  • #32942 — restored tasks fan out plugin MCP servers
  • #33996 — concurrent reasoning summaries can amplify desktop resource use
  • #12491 — umbrella report for orphaned MCP/server processes
  • #19753 — added explicit MCP shutdown when a session actually shuts down; this path is not reached here

View original on GitHub ↗

2 Comments

github-actions[bot] contributor · 7 days ago

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

  • #38825
  • #38754
  • #38981

Powered by Codex Action

EaglePPP · 5 days ago

https://github.com/openai/codex/issues/38825
left a bunch of Nodes and Gigabytes huge rust-analyzers is really annoying indeed.