Skip to content

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

Open

Description

@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 feat(app-server): include turns page on thread resume #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 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.
  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 app-server: Only unload threads which were unused for some time #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

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

  1. added
    bugSomething isn't working
    appIssues related to the Codex desktop app
    windows-osIssues related to Codex on Windows systems
    mcpIssues related to the use of model context protocol (MCP) servers
    app-serverIssues involving app server protocol or interfaces
    on Jul 10, 2026
  2. OrionArchitekton commented on Jul 12, 2026

    @OrionArchitekton

    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.
    5. 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.

  3. HyperSpAceBoy commented on Jul 13, 2026

    @HyperSpAceBoy

    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.

  4. Kerbz-dev commented on Jul 20, 2026

    @Kerbz-dev

    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.

  5. berkyuo2-cpu commented on Aug 14, 2026

    @berkyuo2-cpu

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    appIssues related to the Codex desktop appapp-serverIssues involving app server protocol or interfacesbugSomething isn't workingmcpIssues related to the use of model context protocol (MCP) serversperformancewindows-osIssues related to Codex on Windows systems

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions