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

Open 💬 5 comments Opened Jul 10, 2026 by 4erdenko
💡 Likely answer: A maintainer (github-actions[bot], contributor) responded on this thread — see the highlighted reply below.

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 ↗

5 Comments

github-actions[bot] contributor · 1 month ago

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

  • #31499
  • #30753

Powered by Codex Action

OrionArchitekton · 1 month ago

I found and locally validated a narrower VS Code prewarm lifecycle path that
contributes to retained per-thread MCP stacks.

Environment:

  • OpenAI VS Code extension 26.5707.41301
  • bundled codex-cli 0.144.0-alpha.4
  • local stdio MCP server with approximately 103 MiB RSS per thread

The packaged extension tracks prewarmThreadStart responses in a
prewarmedThreads map with a ten-minute TTL. The current expiry callback only
deletes the thread ID from that client-side map; it does not send
thread/unsubscribe. The app-server therefore retains the subscription after
the client discards the identifier needed to release it.

An isolated A/B canary first confirmed that explicit thread/unsubscribe
works in both the bundled alpha and stable CLI: each emitted notLoaded and
thread/closed at approximately 1,800 seconds and had zero MCP residue after
minute 31.

I then applied a local hash-pinned client experiment that preserves the
ten-minute TTL but sends thread/unsubscribe only 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:

  1. Start a prewarmed thread and never consume it.
  2. Advance the client prewarm clock past ten minutes.
  3. Assert exactly one thread/unsubscribe request for that thread ID.
  4. Assert consumed/visible and explicitly cleared entries are not unsubscribed

by expiry.

  1. Advance the app-server idle clock thirty minutes and assert the associated

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 · 1 month ago

Current Windows build still multiplies MCP stacks per task — including disabled plugin servers

Environment:

  • Codex Desktop package: 26.707.8479.0
  • bundled app-server: 0.144.2
  • Windows 11 x64
  • verification: 2026-07-13, after a full Desktop/package restart

A live process-tree inventory across 16 restored/opened tasks found exactly one root per task for each of four local MCP servers:

| Server | Effective intent on disk | Roots | Working set |
|---|---:|---:|---:|
| Codex Security | plugin and server override set to enabled = false | 16 | 838.9 MiB |
| Creative Production | plugin and server override set to enabled = false | 16 | 862.6 MiB |
| Data Analytics | plugin and server override set to enabled = false | 16 | 674.5 MiB |
| node_repl | enabled capability, expected bounded/shared/lazy | 16 | 158.5 MiB |

That 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:

  • legacy Blender stdio roots: 0
  • shared Blender listener: 0
  • Sites helper: 0

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:

  1. Do not start stdio MCP servers during passive thread read/resume/navigation.
  2. Honor effective disabled configuration before any process spawn.
  3. Lazy-start at an actual MCP/tool-use boundary.
  4. Share safe servers by an effective config/auth/environment fingerprint, or tear down zero-subscriber managers promptly.
  5. Add diagnostics exposing owning thread, creation reason, subscriber count, and teardown deadline.
  6. Add a Windows regression test that opens/resumes many stored threads and asserts bounded child-process count.

The latest Desktop update did not resolve this failure path.

Kerbz-dev · 1 month ago

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.exe app-server accumulated:

  • 8 ElevenLabs MCP stacks
  • 8 shadcn MCP stacks
  • 9 node_repl.exe / Computer Use processes
  • roughly 90 descendant processes and 3.9 GB of private memory

These 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_repl roots. 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 · 13 days ago

Native Windows current-build confirmation: one eager MCP cohort per loaded task

Reporter: @berkyuo2-cpu
Affected ChatGPT/Codex profile: berky1905

Environment:

  • Codex Desktop AppX: 26.803.10989.0
  • codex-cli 0.144.1
  • Native Windows x64 10.0.19045 (not WSL)
  • 31.85 GiB physical RAM

I observed the same per-loaded-task eager MCP allocation pattern on native Windows.

Before containment
  • codex.exe app-server working set: approximately 7.2–7.5 GiB.
  • 122 node.exe processes with approximately 7 GiB aggregate working set.
  • 11 held task/thread writer locks.
  • Each new held lock was followed about 2–6 seconds later by one complete repeated MCP cohort: Google Play, Ads, Firecrawl, Firebase, Xcode, and bundled plugin MCP helpers.
  • Ten of the eleven loaded rollouts contained no actual calls to the five heavy external MCP families during the inspected work, so their startup was not demand-driven.
  • In an 8-second activity sample, the duplicated heavy MCP families had zero observed CPU delta. Their process existence and birth-time correlation, not idle CPU, establishes the eager allocation pattern.
  • When one old task's writer lock disappeared naturally, only that task's matching MCP cohort disappeared. This confirms that teardown can work when the real session shutdown boundary is reached.

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 -> 8
  • aggregate Node working set: roughly 7 GiB -> 183 MiB
  • Firecrawl, Firebase, Google Play, Google Ads/AdMob, and Xcode MCP families did not return.

This strongly corroborates the eager per-loaded-task allocation/retention mechanism in this issue on the current native-Windows build.

Suggested fix boundary:

  1. Start local stdio MCP servers lazily on first real capability/tool use, not on task load, history display, or subagent creation.
  2. Detach/shut down process-owning MCP runtime state when the last subscriber/active turn is gone, while retaining lightweight resumable task metadata.
  3. If a server can be shared, use owner-scoped leases/reference counts and an in-flight call counter; one task must never globally disable or terminate a server still leased by another task.
  4. Expose (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.