Repository navigation
Windows/WSL2: Desktop retains one eager MCP stack per opened chat and replays history during navigation #32154
Description
What version of the Codex App are you using (From "About Codex" dialog)?
Codex App 26.707.31123 (Windows MSIX package 26.707.3563.0).
Bundled WSL app-server: codex-cli 0.144.0-alpha.4, release commit 049586f.
What subscription do you have?
Pro.
What platform is your computer?
Microsoft Windows NT 10.0.22631.0 x64
WSL2: Ubuntu 24.04
The Desktop app launches its bundled app-server inside WSL2 and uses a WSL-resident CODEX_HOME. The configuration has 10 enabled user-configured stdio MCP servers, 3 enabled HTTP MCP servers, plus app-managed MCP helpers.
What issue are you seeing?
Switching across existing local chats, without submitting a prompt or invoking a tool, causes an immediate Desktop/UI stall and a large WSL CPU burst. Opening each previously unloaded distinct chat starts one complete per-thread MCP process generation. Earlier generations remain owned by the live app-server. In parallel, the local Desktop resume path reads/replays persisted history despite using excludeTurns=true.
In a controlled five-switch run across five distinct existing chats:
- the Desktop emitted exactly 5
thread/readand 5thread/resumerequests; - 4
thread/turns/listrequests were also observed; - no
thread/unsubscriberequest occurred; - 5 complete MCP process generations appeared approximately 2.3-5.3 seconds after their corresponding switches;
- earlier generations were not reaped during the capture;
- the result was 43 direct MCP children, 89 total descendants, and approximately 5.5 GiB aggregate RSS.
Reopening a thread that is already loaded is not claimed to create another manager. The deterministic observation is one new MCP generation per newly loaded distinct chat.
This report overlaps with #30408 and #30428, but adds a current-build Windows/WSL2 reproduction with direct UI-click -> RPC -> process-generation correlation and a three-way A/B control.
Controlled A/B results
CPU percentages use 100% = one logical processor. Desktop CPU is aggregated across the Codex/ChatGPT process family. WSL CPU is the aggregate vmmemWSL counter and can exceed 100% across cores.
| Experiment | Switches | Desktop peak CPU | WSL peak CPU | Maximum sampler gap | Retained MCP processes | Retained MCP-tree RSS |
|---|---|---|---|---|---|---|
| Full MCP configuration | 6 | 519.1% | 2111.9% | 868.5 ms | 54 direct / 114 descendants | 4.24 GiB |
| All user stdio MCP disabled | 4 | 495.4% | 702.6% | 757.8 ms | 8 direct / 8 descendants | 0.19 GiB |
shell_snapshot=false, full MCP restored |
5 | 435.2% | 1260.7% | 872.3 ms | 43 direct / 89 descendants | 5.5 GiB |
Disabling user stdio MCP reduced retained MCP RSS by about 96% and the WSL CPU peak by about 67%, but the Desktop CPU peak and visible UI/mouse stall remained. This separates two components:
- eager per-thread MCP startup and retention causes most of the WSL process/RSS/CPU amplification;
- the Desktop chat-navigation path still performs full persisted-history reads/replay plus main/renderer/RPC/Git work even without user stdio MCP.
During the final full-MCP run:
- Electron main RSS grew from approximately 291 MiB to 1.35 GiB after four switches;
- Electron main external/IPC memory grew from approximately 11 MiB to 1.99 GiB;
- renderer RSS grew by approximately 143.5 MiB;
- 49 Git worker invocations occurred within 30 seconds;
- one
thread/resumetook 6.019 seconds; - the external 250 ms sampler itself was delayed by up to 872 ms during the stall.
Controls
- A separate focus-triggered plugin reconciliation problem was removed before these captures with
browser_use_external=false. No plugin reconciliation failure occurred during the chat-switch captures. - One shell snapshot was previously generated per switch. Each snapshot was approximately 9.3 MiB. Setting
shell_snapshot=falseprevented all new snapshots, but did not stop MCP generation or the UI/WSL stalls. - The stdio-disabled run left HTTP MCP and app-managed helpers available, so this was not an empty-config test.
Source-level investigation
The bundled backend release provides a plausible lifecycle explanation:
- A newly spawned/resumed session eagerly constructs a new
McpConnectionManager, before any MCP tool is requested. Manager initialization then starts the configured clients through the per-server startup path. ThreadManager::spawn_thread_with_sourcereuses an already-running thread, but otherwise callsCodex::spawn, which creates the new per-session manager above.thread/unsubscriberemoves only the connection subscription; it does not shut down the session.- The backend intentionally uses a 30-minute idle unload delay, after which it finally calls
shutdown_and_wait. - Inspection of the packaged Desktop client shows an inactive-owner policy that can retain four inactive chats for up to one hour, in addition to the active chat, before requesting
thread/unsubscribe. The five-switch trace did not reach an unsubscribe boundary. - On a running resume,
resume_running_threadrequests persisted history unconditionally, even when the client suppliedexcludeTurns=true. - The local Desktop client sends
excludeTurns=true, but then performs a separatethread/turns/listrequest rather than usinginitialTurnsPage.thread/turns/listreconstructs the requested view by replaying the rollout. This puts history work back onto each navigation path and adds another round trip.
These relevant lifecycle and history paths were still present on main at 6138909 on 2026-07-10.
PR #19753 added explicit MCP teardown for session shutdown, channel-close fallback, refresh, and connector probing. This reproduction appears to be a remaining lifecycle/ownership gap rather than evidence that the teardown implementation itself is universally broken: ordinary Desktop navigation retains the sessions, so the shutdown boundary that #19753 fixed is not reached.
What steps can reproduce the bug?
- On Windows, configure Codex Desktop to run the bundled app-server in WSL2 with multiple stdio MCP servers enabled.
- Fully restart Codex Desktop and wait until CPU is idle.
- Do not submit prompts and do not invoke tools.
- Open five or six distinct existing chats from the sidebar, waiting a few seconds between selections.
- Record
thread/read,thread/resume,thread/turns/list, andthread/unsubscribeRPC counts. - After each switch, inspect descendants of the bundled WSL app-server.
- Observe one new full MCP generation for each newly loaded chat and no corresponding teardown during the navigation window.
- Repeat with all user-configured stdio MCP servers disabled. WSL load and retained RSS drop sharply, while the Desktop stall remains.
What is the expected behavior?
Displaying an idle existing chat should not eagerly start a complete MCP runtime or repeatedly replay its full persisted rollout. Chat navigation should remain responsive, and the number of stdio MCP process trees should remain bounded independently of how many chats are viewed.
If a session is retained as a warm cache entry, its expensive MCP and Git runtime resources should be suspended or shared rather than retained for the entire thread cache lifetime.
Additional information
Potential fix directions
Near-term containment:
- Make MCP initialization lazy.
thread/read,thread/resume, and read-only chat rendering should not start stdio servers. Initialize them atturn/start, explicit MCP status/tool access, or another real capability boundary. - Have Desktop send
thread/unsubscribefor the previous idle chat. Active/background turns should remain subscribed until they finish. - Split thread caching from MCP runtime ownership. On last unsubscribe or when a Desktop chat becomes inactive and idle, shut down its MCP manager immediately while retaining lightweight thread state. Reinitialize MCP only if a turn or MCP operation actually needs it.
- Lower or resource-bound the Desktop inactive-thread cache. A fixed count of four inactive threads is not safe when each thread can own many stdio process trees. Account for configured stdio server count/RSS, or unsubscribe idle chats immediately.
- Avoid persisted-history loading in
resume_running_threadwhenexcludeTurns=trueand noinitialTurnsPagewas requested. Conceptually, only load history when!exclude_turns || initial_turns_page.is_some(). - For local Desktop, request
initialTurnsPageas part ofthread/resumeand remove the immediate separatethread/turns/listround trip, as enabled by feat(app-server): include turns page on thread resume #23534. - Cancel superseded resume/hydration work when the user navigates again. Coalesce and cache Git probes by normalized workspace root and keep them off the UI-critical path.
Architectural options:
- Add a workspace/project-scoped MCP pool keyed by normalized workspace root plus an effective MCP config, environment, auth, permission, and elicitation-routing fingerprint, as proposed in Codex Desktop should use a project-scoped MCP process pool instead of starting MCP per session #20883. Use leases/reference counts and an idle timeout. Servers that are not safe to share can remain session-scoped or opt out.
- Separate a lightweight thread-event subscription from a fully running
Session. The Desktop could render metadata and a recent turns page, then promote the thread to a live session only when interaction begins. - Keep the existing 30-minute thread cache if useful, but detach process-owning resources such as MCP transports from cached zero-subscriber sessions.
- Back
thread/turns/listwith indexed/projected turns or cache reconstructed history with correct invalidation on append, rollback, and compaction, rather than replaying the full rollout for every page. - Expose diagnostics for active MCP manager count, owning thread, creation reason, stdio child count, last subscriber, pending teardown reason/duration, history read/replay count, and Git probe fan-out.
Suggested regression coverage:
- Add an app-server integration test with a stub stdio MCP server that records its PID. Resume several distinct stored threads on one persistent connection, follow the Desktop unsubscribe/navigation pattern, and assert a bounded child-process count.
- Add paused-time coverage for the inactive-thread and 30-minute unload policies, asserting that MCP children are shut down even if lightweight thread state remains cached.
- Assert that an active turn is not interrupted when its view becomes inactive, preserving the motivation behind app-server: Only unload threads which were unused for some time #17398.
- Add a rapid-navigation cancellation test and assert that a superseded
thread/resumecannot leave another initialized MCP manager behind. - Add a read-only resume test asserting that no stdio process is launched until a turn or MCP operation requires it.
- Add tests showing that
excludeTurns=truewithoutinitialTurnsPageperforms no persisted-history read, and that resume withinitialTurnsPageperforms exactly one read and returns the same data asthread/turns/list. - Add a large-rollout benchmark that bounds wall time, CPU, response bytes, and history replay count for repeated navigation.
Related issues and changes
- MCP server processes leak: per-thread processes never cleaned up (9+ GB RSS) #30408: per-thread MCP process retention and multi-GiB RSS.
- Windows app stutters when switching chats or opening Settings #30428: Windows chat-switch UI/mouse stutter and Git worker fan-out.
- [Codex app] Opening existing local conversations stalls 10–45s in thread/resume #32068: current-build
thread/resumestalls. - Codex Desktop should use a project-scoped MCP process pool instead of starting MCP per session #20883: project-scoped MCP pool proposal.
- app-server: Only unload threads which were unused for some time #17398: idle thread retention policy.
- feat(app-server): include turns page on thread resume #23534:
initialTurnsPagesupport for resume. - fix: stdio MCP child process leak causing unbounded memory growth #19469 and MCP child processes leak when McpConnectionManager is replaced #18881: earlier MCP manager/process lifecycle reports.
- Terminate stdio MCP servers on shutdown to avoid process leaks #19753: explicit MCP teardown on session shutdown.
Evidence and privacy boundary
I retained a privacy-sanitized 352-sample CPU/RSS time series and relative chat-switch event markers. Raw captures can be provided through a private channel if maintainers need them.
Activity
github-actions commentedon Jul 10, 2026
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
OrionArchitekton commentedon Jul 12, 2026
I found and locally validated a narrower VS Code prewarm lifecycle path that
contributes to retained per-thread MCP stacks.
Environment:
26.5707.41301codex-cli 0.144.0-alpha.4The packaged extension tracks
prewarmThreadStartresponses in aprewarmedThreadsmap with a ten-minute TTL. The current expiry callback onlydeletes the thread ID from that client-side map; it does not send
thread/unsubscribe. The app-server therefore retains the subscription afterthe client discards the identifier needed to release it.
An isolated A/B canary first confirmed that explicit
thread/unsubscribeworks in both the bundled alpha and stable CLI: each emitted
notLoadedandthread/closedat approximately 1,800 seconds and had zero MCP residue afterminute 31.
I then applied a local hash-pinned client experiment that preserves the
ten-minute TTL but sends
thread/unsubscribeonly when an invisible,unconsumed prewarm expires. Consumed/visible and explicitly cleared entries
retain their previous behavior.
After a controlled extension-host reload, four MCP roots created at minutes
16.55-16.79 disappeared by minute 60, immediately after their expected
10-minute prewarm plus 30-minute server-unload deadlines at minutes
56.55-56.79. No legacy launcher or orphan process was involved. This is direct
runtime evidence that expiry-triggered unsubscribe closes the abandoned
prewarm path.
A separate cost remains: visible or retained loaded threads continue to own
one MCP runtime each. That likely needs upstream lazy MCP initialization,
resource-aware inactive-thread eviction, or separation of cached thread state
from MCP runtime ownership rather than a local reaper.
Suggested regression coverage:
thread/unsubscriberequest for that thread ID.by expiry.
stdio MCP child exits with no residue.
This complements the eager per-chat retention evidence already captured in
this issue and closes the client path that loses ownership of abandoned
prewarms.
HyperSpAceBoy commentedon Jul 13, 2026
Current Windows build still multiplies MCP stacks per task — including disabled plugin servers
Environment:
A live process-tree inventory across 16 restored/opened tasks found exactly one root per task for each of four local MCP servers:
enabled = falseenabled = falseenabled = falseThat is approximately 2.48 GiB for these four root sets alone, before counting other possible wrappers or unrelated Desktop state. Creation times spread from 16:07:43 through 16:14:54 as tasks/windows were restored or opened.
Controls in the same inventory:
The exact 16× count and staggered creation times are consistent with one eager stack per task, not one machine/workspace service. More seriously, the current startup path spawned three plugin-provided servers despite explicit plugin-level and server-level disable overrides already present on disk.
This is not acceptable lifecycle behavior for a desktop development tool. Users should not have to choose between disabling useful capabilities, manually killing exact PIDs, or accepting multi-gigabyte background duplication just because they viewed multiple chats.
Required fix boundaries:
The latest Desktop update did not resolve this failure path.
Kerbz-dev commentedon Jul 20, 2026
Native Windows confirmation (no WSL)
I can confirm the same retained per-thread MCP process pattern on native Windows.
Environment: Codex Desktop 26.715.4045.0, bundled app-server/CLI 0.144.0, Windows 11 x64.
After a clean reboot, one live
codex.exeapp-server accumulated:node_repl.exe/ Computer Use processesThese were not orphaned processes left behind by a dead app-server. They remained children of the live app-server, and their start times appeared in separate batches as chats/tasks were loaded.
The practical impact can become much larger. In an earlier buildup on the same machine, the Codex tree reached 365 processes and 11.31 GB of private memory, including 52 Blender MCP roots and 52 per-thread
node_replroots. Removing only the verified stale Codex-owned trees reduced it to 7 processes and 2.80 GB, while total Windows commit fell by 11.66 GB. (The machine had 61.85 GB total commit before cleanup, but I am not attributing all of that to Codex.)The current source is consistent with the mechanism described in this issue: a loaded session owns the MCP service, session startup constructs and initializes its MCP connection manager, and unloading is blocked while the thread has subscribers or active work (predicate).
The subscriber rule itself makes sense: a subscribed client needs live thread events. The problem is that the process-owning MCP runtime currently shares that lifetime. This native result supports the original report's fix direction: retain lightweight thread/history state for navigation, but do not retain or eagerly recreate a full per-thread MCP process stack until that thread actually needs MCP capabilities.
berkyuo2-cpu commentedon Aug 14, 2026
Native Windows current-build confirmation: one eager MCP cohort per loaded task
Reporter: @berkyuo2-cpu
Affected ChatGPT/Codex profile: berky1905
Environment:
26.803.10989.0codex-cli 0.144.110.0.19045(not WSL)I observed the same per-loaded-task eager MCP allocation pattern on native Windows.
Before containment
codex.exeapp-server working set: approximately 7.2–7.5 GiB.node.exeprocesses with approximately 7 GiB aggregate working set.Family-wide cleanup is unsafe here: command lines do not contain an authoritative owning thread ID, and another loaded task had used the Google Play MCP. Killing by process name could sever another task's stdio transport.
Containment A/B
I disabled the unused heavy MCP entries in user/plugin configuration and fully restarted the app-server so the startup configuration was refreshed.
node.exe: 122 -> 8This strongly corroborates the eager per-loaded-task allocation/retention mechanism in this issue on the current native-Windows build.
Suggested fix boundary:
(server, owning task/thread, PID tree, last call, teardown state)diagnostics so exact cleanup is possible without process-name heuristics.Private paths, credentials, MCP environment values, project names, and task/session IDs are intentionally omitted.