Windows/WSL2: Desktop retains one eager MCP stack per opened chat and replays history during navigation

Open 💬 4 comments Opened Jul 10, 2026 by 4erdenko

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/read and 5 thread/resume requests;
  • 4 thread/turns/list requests were also observed;
  • no thread/unsubscribe request 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:

  1. eager per-thread MCP startup and retention causes most of the WSL process/RSS/CPU amplification;
  2. 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/resume took 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=false prevented 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:

  1. 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.
  2. ThreadManager::spawn_thread_with_source reuses an already-running thread, but otherwise calls Codex::spawn, which creates the new per-session manager above.
  3. thread/unsubscribe removes only the connection subscription; it does not shut down the session.
  4. The backend intentionally uses a 30-minute idle unload delay, after which it finally calls shutdown_and_wait.
  5. 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.
  6. On a running resume, resume_running_thread requests persisted history unconditionally, even when the client supplied excludeTurns=true.
  7. The local Desktop client sends excludeTurns=true, but then performs a separate thread/turns/list request rather than using initialTurnsPage. thread/turns/list reconstructs 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?

  1. On Windows, configure Codex Desktop to run the bundled app-server in WSL2 with multiple stdio MCP servers enabled.
  2. Fully restart Codex Desktop and wait until CPU is idle.
  3. Do not submit prompts and do not invoke tools.
  4. Open five or six distinct existing chats from the sidebar, waiting a few seconds between selections.
  5. Record thread/read, thread/resume, thread/turns/list, and thread/unsubscribe RPC counts.
  6. After each switch, inspect descendants of the bundled WSL app-server.
  7. Observe one new full MCP generation for each newly loaded chat and no corresponding teardown during the navigation window.
  8. 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:

  1. Make MCP initialization lazy. thread/read, thread/resume, and read-only chat rendering should not start stdio servers. Initialize them at turn/start, explicit MCP status/tool access, or another real capability boundary.
  2. Have Desktop send thread/unsubscribe for the previous idle chat. Active/background turns should remain subscribed until they finish.
  3. 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.
  4. 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.
  5. Avoid persisted-history loading in resume_running_thread when excludeTurns=true and no initialTurnsPage was requested. Conceptually, only load history when !exclude_turns || initial_turns_page.is_some().
  6. For local Desktop, request initialTurnsPage as part of thread/resume and remove the immediate separate thread/turns/list round trip, as enabled by #23534.
  7. 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:

  1. 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 #20883. Use leases/reference counts and an idle timeout. Servers that are not safe to share can remain session-scoped or opt out.
  2. 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.
  3. Keep the existing 30-minute thread cache if useful, but detach process-owning resources such as MCP transports from cached zero-subscriber sessions.
  4. Back thread/turns/list with indexed/projected turns or cache reconstructed history with correct invalidation on append, rollback, and compaction, rather than replaying the full rollout for every page.
  5. 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:

  1. 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.
  2. 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.
  3. Assert that an active turn is not interrupted when its view becomes inactive, preserving the motivation behind #17398.
  4. Add a rapid-navigation cancellation test and assert that a superseded thread/resume cannot leave another initialized MCP manager behind.
  5. Add a read-only resume test asserting that no stdio process is launched until a turn or MCP operation requires it.
  6. Add tests showing that excludeTurns=true without initialTurnsPage performs no persisted-history read, and that resume with initialTurnsPage performs exactly one read and returns the same data as thread/turns/list.
  7. Add a large-rollout benchmark that bounds wall time, CPU, response bytes, and history replay count for repeated navigation.
Related issues and changes
  • #30408: per-thread MCP process retention and multi-GiB RSS.
  • #30428: Windows chat-switch UI/mouse stutter and Git worker fan-out.
  • #32068: current-build thread/resume stalls.
  • #20883: project-scoped MCP pool proposal.
  • #17398: idle thread retention policy.
  • #23534: initialTurnsPage support for resume.
  • #19469 and #18881: earlier MCP manager/process lifecycle reports.
  • #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.

View original on GitHub ↗

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