Repository navigation
Codex Desktop ephemeral thread summaries leak full MCP stacks via thread/unsubscribe #39783
Description
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_summariesenabled by the desktop feature overlay
Reproduction
-
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:[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"]
-
Fully quit and relaunch ChatGPT/Codex Desktop so the app-server starts from a clean baseline.
-
Find the app-server PID:
APP_SERVER_PID="$(pgrep -f 'ChatGPT.app/Contents/Resources/codex.*app-server' | head -n 1)" echo "$APP_SERVER_PID"
-
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.
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
-
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.
-
Correlate the process growth with the desktop log:
rg 'feature=thread_summary|unknown conversation' ~/Library/Logs/com.openai.codex
-
Wait after the visible turn is complete. The extra MCP process sets remain. Repeat step 5 and the count continues to rise.
-
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_summarygenerations 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:
- start with MCP servers disabled because they do not need tools, or
- 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
- 🩹 Codex app-server leaks MCP process stacks for subagents, causing linear memory growth on Linux #25015 — app-server MCP stacks retained for subagents; closest lifecycle/root-cause family
- Subagents leak stdio MCP helper trees in Codex App; xcodebuildmcp and chrome-devtools-mcp accumulate indefinitely #17574 — subagents leak stdio MCP helper trees
- MCP servers eagerly start per session, causing headed browser MCP processes to accumulate #21984 — MCP servers eagerly start per session
- [macOS App] Restored tasks fan out plugin MCP servers (~30x) and crash/reload the renderer #32942 — restored tasks fan out plugin MCP servers
- Windows Desktop: concurrent reasoning summaries correlate with ResizeObserver loops and UI freezes #33996 — concurrent reasoning summaries can amplify desktop resource use
- Codex.app GUI: MCP child processes not reaped after task completion — 1300+ zombies, 37GB memory leak #12491 — umbrella report for orphaned MCP/server processes
- Terminate stdio MCP servers on shutdown to avoid process leaks #19753 — added explicit MCP shutdown when a session actually shuts down; this path is not reached here
Activity
github-actions commentedon Aug 20, 2026
Potential duplicates detected. Please review them and close your issue if it is a duplicate.
Powered by Codex Action
EaglePPP commentedon Aug 22, 2026
#38825
left a bunch of Nodes and Gigabytes huge rust-analyzers is really annoying indeed.
yang199901 commentedon Sep 5, 2026
Windows reproduction:
thread_descriptionandthread_titleuse the same retained ephemeral-session pathI can reproduce the same lifecycle defect on Windows with a newer Desktop build. The trigger here is not limited to
thread_summary: Desktop-ownedthread_descriptionandthread_titlegenerations also create an unknown/ephemeral conversation and eagerly start the global stdio MCP stack.Environment
10.0.19045), x6426.901.4073.0node <redacted>/figma-console-mcp/dist/local.jscodex.exe app-serverSanitized log correlation (UTC)
The same run also contains this shape for
feature=thread_title: all configured MCP servers start for an unknown conversation, the title generation succeeds a few seconds later, and the unknown turn completes.Persistence / process evidence
At
00:27Z, approximately 29 minutes after the two description generations completed:node .../dist/local.jsprocesses, all direct children of the same app-server;9223-9242;23:58:29and23:58:36events were still alive and listening.After a later Desktop restart, the old process set was reaped, but accumulation started again. At
00:46Z, about 10 minutes into the new app-server lifetime, there were 7 loaded Codex tasks (4 active, 3 idle) versus 9 directlocal.jschildren, all listening on9223-9231.This MCP server already exits on stdin EOF/close/error and when its parent disappears. Neither condition occurs here: Codex keeps stdin open and the shared app-server parent remains alive, so server-side cleanup cannot infer that the ephemeral owner completed.
This looks like the same ownership gap described in the issue, but it broadens the affected Desktop-owned features from
thread_summaryto at leastthread_titleandthread_description, and confirms the behavior on Windows. A regression test should cover all three ephemeral-generation features and assert either that global MCP servers are never started or that the session/process count returns to baseline after completion.PracticalSwan commentedon Sep 5, 2026
Fresh Windows reproduction on the current Sep 5 Desktop build confirms the underlying ephemeral-thread MCP retention is still present.
Environment:
26.901.5003.0codex-cli 0.153.3Observed sequence:
thread/startand created thread01a07171-...(ID omitted here beyond the prefix).state_5.sqlite.Measured retained footprint for only the 19:00:55 generation:
Direct roots included
python.exe,blender-mcp.exe,node_repl.exe, severalnode.exe/cmd.exeMCP launchers,uvx.exe(Serena), Playwright, and NotebookLM.This build did not emit the older
ephemeral_generation_token_usage feature=thread_summarymarker in the Desktop log around this creation, so I cannot prove that this specific generation was triggered bythread_summary. I am adding this as current cross-platform evidence for the lifecycle/root-cause described here, not as proof of the exact feature owner.The important invariant still fails: a non-persisted one-shot/ephemeral thread can inherit a complete MCP manager and retain roughly 1 GiB after its visible purpose has ended. The fix should make ephemeral generations start tool/MCP-free unless explicitly required, and/or remove/shutdown the thread instead of merely unsubscribing it.
PracticalSwan commentedon Sep 5, 2026
Stronger current Windows reproduction of the ephemeral-thread MCP retention defect, now correlated with Codex review-summary activity.
Environment unchanged from my previous report:
26.901.5003.0codex-cli 0.153.3Sequence on a real persisted CSX4213 project thread:
01a07085-...at 19:46:27 local. It has both a rollout JSONL and a row instate_5.sqlite.against that project working tree.
3. At 19:46:39,
method=thread/startcompleted and a new thread01a0719b-...eagerly started the full MCP set.4. That second thread has no rollout JSONL and no row in
state_5.sqlite.5. Its MCP generation remains alive under the same app-server.
Measured retained footprint for the non-persisted 19:46:39 generation:
Direct roots include Blender, OCR Python MCP, Codex app tools, Codex Security, Node REPL / Computer Use, LSP, NotebookLM, Playwright, Serena and other MCP launchers.
This is much closer to identifying the trigger than my earlier report: the ephemeral
thread/startoccurs immediately afterrequestKind=review-summary source=review_modelactivity for the real project thread. I still cannot prove from the current logs that the private feature owner of01a0719b-...is specifically the review-summary generator, but the temporal/operation correlation is strong and reproducible.The lifecycle invariant still fails: an internal/non-persisted review-related thread inherits a complete MCP manager and retains roughly 1.1 GiB after creation. Internal review/summary generations should be tool-free unless explicitly required, or their MCP manager should be shut down as soon as the ephemeral generation completes.
0xdevalias commentedon Sep 22, 2026
Current 0.153.4 behavior adds a nuance to the unsubscribe finding here. I observed one completed non-window task fully unload its
node_repl/MCP fleet roughly 60 s afterthread/unsubscribe, but only because the caller was evidently the final owner. Four completed ordinary tasks and one completed automation returnedunsubscribedyet stayed loaded beyond the delay; a second call returnednotSubscribed, so a hidden owner was retaining them.I have not reproduced this report's exact
thread_summarypath. For that path, unsubscribe-and-wait is worth testing, but it should be described as conditional: verifythread/loaded/list, wait >=60 s, and confirm the exact task bundle exits. If it does not, killing individual MCP children is likely to respawn/break protocol state; subscriber diagnostics or an explicitthread/unloadRPC are needed.sharifhsn commentedon Oct 4, 2026
Update from macOS on Codex Desktop 26.930.31730 (build 12947), bundled CLI 0.160.0, macOS 26.6.2 arm64, 48 GiB RAM.
I tested backend cleanup separately from MCP startup. The isolated unsubscribe/idle cleanup path works on this build: all three test threads and their MCP processes were gone within 65 seconds. The remaining proposed change is to prevent unused MCP startup in tool-free summary/title helpers. This agrees with the conditional cleanup result in the earlier comment from @0xdevalias.
Observed Desktop footprint
On October 3, 2026:
RSS includes shared pages and does not measure unique physical memory. This inventory does not identify every owner as idle or as a summary helper. It establishes a large process footprint, not continuous growth or a permanent leak during this interval.
Isolated reproduction and control
The harness below starts temporary app-servers with a dummy local provider and one healthy stdio MCP probe. It copies no user configuration or authentication. Plugins and apps are disabled. Each case creates three ephemeral threads with the synthetic source label
thread_summary, verifies probe initialization, checksthread/loaded/list, and obtains successful unsubscribe responses. No model turn or MCP tool call is started.All test backends exited normally with code 0 and zero live probes. Cancellation after probe initialization also released the processes. An incorrect backend produced a nonzero exit status without success output.
The per-thread control changes only this request field:
{"config": {"mcp_servers.lifecycle_probe.enabled": false}}This tests one configured stdio server. It does not exercise actual Desktop summary generation, plugin-supplied servers, connected apps, additional subscribers, or cache disposal.
Source evidence and proposed change
The installed Desktop summary-generation helper disables plugins and most tool features. Its fresh-thread path disables
mcp_servers.codex_app, but does not disable the other configured MCP servers. Completion callsunsubscribeThread. The inspected asset is.vite/build/bootstrap-D3_zvIvQ.js, SHA-25635d486c724973490ad66921f6a451ac6aa59d14e31f977f0486ef9574976177e.The 0.160.0 session source creates a thread-owned MCP runtime. The connection source selects enabled servers. The release configuration source sets the default unload delay to 60 seconds, consistent with this test.
I propose an explicit tool-free policy for summary/title helper start and resume paths. Disable applicable configured MCP registrations before runtime startup, preserve plugin restrictions, and preserve app-tool allowlists for other callers that need them. Keep the parent chat's tools available. Do not infer tool policy from the analytics-only
threadSourcelabel.A regression test can run repeated helper generations with sentinel servers and require zero MCP starts. Cover global/project/plugin/executor registrations, cancellation, failure, and resume paths. If the client cannot express this policy for every registration source, the backend needs an explicit helper policy.
PR #50380 fixes a separate disconnect-during-required-startup race. The 0.160.0 release tag does not contain that commit. This harness does not test that race.
Runnable reproduction
Save the code below as
reproduce.py. Use Python 3.11+ on macOS or Linux:The default run takes approximately 75 seconds. The output file must not exist. Add
--hold-seconds 2to measure initial retention. Protocol or cleanup failures return a nonzero exit status. Successful output is a measurement, not a claim that the Desktop defect is fixed.reproduce.py — isolated backend and healthy MCP probe
sharifhsn commentedon Oct 4, 2026
Follow-up to the earlier 0.160.0 reproduction: I traced a separate completed-child retention path and added a two-client control.
The installed backend releases idle threads after the final unsubscribe.
It retains a subscribed thread and its MCP process past the idle deadline.
The release source automatically attaches clients to native child agents, and child completion does not dispose of the child runtime.
The live desktop contained repeated tool servers, idle chats, and process cohorts that matched completed children.
Those live ownership matches are an inference from timestamps, not a direct PID-to-thread ownership API.
This report distinguishes transient helper startup cost from retained child resources.
It does not establish an unbounded heap leak inside an individual process.
It also does not attribute every desktop descendant to MCP, since other chats continued active work.
Environment and source identity
codex-cli 0.160.0rust-v0.160.0, commita956835d020762cb2b570053af06f643a11c0eccdde5f8c5ae9b644c6882b3a48581ac317255d597The installed backend executable has SHA-256
6b582e8813ce7e8ed4c52814ee5cf230dba647bf2292df747a4003f2657ef201.The version string alone does not establish equivalence with every detail of the public source.
The controls below ran against that installed executable.
The live user configuration enables V2 agents and contains no root
thread_unload_delay_secsoverride.The observed desktop backend arguments also contained no override for that delay.
I did not query the desktop's effective per-thread configuration or exact subscriber list.
Live process measurements
The two snapshots cover the same desktop and shared app-server lifetime.
Times below are UTC.
The earlier local times were October 3 at 11:19:31 PM EDT and October 4 at 01:02:18 AM EDT.
The process count grew by 169, but RSS decreased between these snapshots.
This is an uncontrolled workload comparison, not evidence of monotonic RSS growth at a fixed workload.
Memory compression can reduce RSS without disposal of a process.
Summed RSS can count shared pages more than once, so it is not the unique physical memory footprint.
The tree also contains browsers, build processes, and other active work.
System Health reported warning-level memory pressure at 05:03:06 UTC, with zero swap and 18.63 GiB of compressed memory.
At 05:11:43 UTC, it reported normal pressure, zero swap, and 17.79 GiB of compressed memory.
This fluctuation does not establish a fix.
Direct-child executable metadata and runtime directory paths identify the groups below.
These are runtime process counts, not tool-call counts or tool-definition counts.
The later five groups contain 162 direct-child processes and 4.662 GiB of summed RSS.
Many other direct children use project directories, which do not identify their MCP server.
This table does not account for the entire app-server tree.
Owner classification and methodology
I used process metadata with PID, PPID, RSS, executable, and process start time.
I used bulk
lsofqueries for current directory metadata.I did not collect command arguments, environment values, credentials, prompts, or tool payloads.
The desktop log supplied structured MCP startup status events with thread ID, server name, status, and timestamp.
The local thread database supplied source and parent metadata for classification.
Bounded rollout tails supplied only
task_startedandtask_completeevent metadata.The inspected app logs contained 73 distinct thread owners with MCP startup records:
These are historical startup owners, not 73 simultaneously loaded sessions.
Of the 31 visible chats, the app reported 14 active, 11 idle, and 6 not loaded at the later snapshot.
I used persisted parent/source metadata to identify child agents, rather than chat titles.
For each direct app-server child, I compared its OS start time with logged MCP startup times.
A candidate matched when
process_started_at - mcp_start_event_atwas between -1 and +2 seconds.The OS start timestamp has one-second resolution, and the application event has finer resolution.
There was exactly one candidate owner for 232 of 399 direct children.
There were no candidates for 46, and multiple candidates for 121.
Concurrent startup bursts explain much of the ambiguity.
A unique timestamp candidate is still a correlation, not proof of exact ownership.
The logs did not supply a native process PID with its owner thread.
I did not use low CPU or a completed UI turn as proof that a process was safe to terminate.
Seven idle-chat cohorts matched 77 live direct-child processes and 1.886 GiB of summed RSS.
Their individual cohort counts were 12, 10, 10, 12, 10, 12, and 11.
I omitted private chat titles and thread IDs from this report.
Two completed-child examples had distinct startup cohorts:
The latest available lifecycle metadata showed completion for these children.
Other child cohorts overlap startup bursts, so I do not assign those processes to individual children.
I did not establish whether either child retained queued mailbox input or another reason to remain resident.
The measured cohorts support investigation of retention after completion, rather than immediate termination of every completed child.
Internal helpers and extension-host observations
Most unpersisted internal sessions did stop.
Eleven of fourteen had explicit
codex_core::session::handlersshutdown records with the messageShutting down Codex instance.The first-start-to-shutdown intervals were between 63.6 and 129.7 seconds.
The individual intervals, in seconds, are:
The remaining three lack sufficient shutdown evidence.
Missing shutdown records alone do not prove that a session survived.
The logs contained two extension-host creation records in this app session.
Read-only inspection of the installed renderer found a retirement callback that sends
thread/unsubscribeafter the old host's requests finish.The older five-minute cache-recreation issue in #43122 therefore needs separate version-specific validation.
This session's counts do not support that loop as the main explanation for dozens of tool-server copies.
Read-only inspection of the desktop helper still showed inherited MCP configuration for summary generation.
The helper applies plugin and tool-surface restrictions, but those restrictions do not clear every configured
mcp_serversregistration.The inspected structured-generation helper explicitly disables
mcp_servers.codex_app.Other configured servers can remain inherited.
That result supports the earlier startup-cost reproduction, not a claim that every retained owner is a summary helper.
The inspected bootstrap asset is
.vite/build/bootstrap-D3_zvIvQ.js.Its SHA-256 is
35d486c724973490ad66921f6a451ac6aa59d14e31f977f0486ef9574976177e.I did not modify installed vendor files or publish extracted desktop source.
Source-backed retention mechanism
The links below use the release commit
a956835d020762cb2b570053af06f643a11c0ecc.notify_thread_createdafter child admission.The default V2 session limit is four threads, with the parent included.
The effective child capacity subtracts the parent slot.
This is a per-session bound, not a global RAM budget across many parent chats.
This residency path does not evict a completed child merely because its turn ended.
The installed-release default unload delay is 60 seconds.
A smaller delay cannot release a thread that still has a subscription.
An initial test expectation that every ordinary root thread attaches all initialized clients was incorrect.
The two-client control below shows that a second connection alone does not retain a root thread.
The automatic notification path above applies to native child creation.
The explicit-subscription control confirms the unload guard, but does not independently reproduce native child completion.
Installed-binary controls
The earlier single-client harness starts synthetic summary-labelled threads with one tiny configured stdio MCP server.
It starts zero model turns and makes zero MCP tool calls.
mcp_servers.lifecycle_probe.enabled = falsethread_unload_delay_secs = 1All unsubscribes returned
unsubscribed.Each test backend exited with code zero through normal stdin closure, without a forced stop.
No probe survived backend shutdown.
The Python harness also passed its cancellation and invalid-executable checks.
The new two-client harness uses WebSocket transport and a one-second idle delay.
Both clients initialize, but the second client does not subscribe until the positive control.
The positive control uses a non-ephemeral thread and
thread/resumeto establish the second subscription.It first calls
thread/section/movewithsectionId: nullto materialize the turn-free thread.The earlier controls use ephemeral threads.
The test backend exits with code zero after the clients close, and no test probe survives cleanup.
The private original run and the portable version both produced this result.
The portable run started at 2026-10-04 05:43:49.707 UTC.
It uses a supplied executable path and
reproduce.pyin the same directory, with no personal path in the source.Its source and full result appear below.
Each harness uses a temporary
CODEX_HOMEand a local dummy model provider athttp://127.0.0.1:9/v1.Plugins and apps are disabled only in that temporary home.
The environment allowlist contains
PATH,HOME,TMPDIR,LANG, andLC_ALL.The harness copies no API key or proxy credential, and it starts no model request.
The TCP listener binds to loopback only.
These controls do not reproduce the complete desktop helper, real model generation, or native-agent completion.
Expected behavior and proposed fix areas
Tool-free summary and title helpers need zero inherited MCP startup.
The current per-thread disable demonstrates one applicable mechanism.
The policy needs coverage for user, project, plugin, executor-local, and host-supplied registrations.
It must preserve parent tools and explicit app-tool allowlists for callers that need them.
An explicit backend tool-free startup policy is another option if the client cannot enumerate every registration source.
Completed child agents and idle tool runtimes need a bounded resource deadline or a global resource budget.
The system must preserve child identity and persisted history for later resumption.
It must protect active turns, queued mailbox input, in-flight tool requests, and active approvals.
The present residency eviction already waits for shutdown before registry removal and preserves environment selections.
That path supplies useful constraints for any additional eviction policy.
The desktop needs explicit ownership of child subscriptions and a release point when an observer no longer needs a child.
Release of one client must preserve another client's interest.
A lower idle-delay value alone cannot solve subscriber retention.
Lazy runtime startup or catalog reuse can reduce the per-thread multiplier, but any shared runtime must preserve server state and per-thread authorization.
Useful regression coverage includes these cases:
PR #50380 addresses disconnect during MCP startup.
It merged on October 2, 2026, at 17:40:25 UTC with commit
1ab8c6ef28a8261aaddb528dd3875124acd1c91e.The inspected
rust-v0.160.0tag does not contain that commit.That race is relevant regression coverage, but these results do not establish that its fix resolves the live retention examples.
I did not run these controls against a build from current main.
Diagnostics to resolve the remaining uncertainty
The most useful additional event links each native MCP process PID to its owner thread, source, parent, and server registration.
A lifecycle snapshot can include subscriber count, active-turn state, queued-input count, residency state, idle deadline, and the reason that prevents unload.
Startup, retirement, shutdown completion, and PID exit need the same owner identifier.
An aggregate view can then separate active work, subscribed idle threads, resident completed children, and unowned processes.
These fields can provide useful evidence without prompts, credentials, or tool payloads.
The exact subscriber lists and mailbox states in this desktop remain unknown.
The source-backed mechanism is confirmed for the unload guard and supported by live timestamp correlations for completed children.
The test does not prove that every completed child must immediately shut down, or that every retained process is erroneous.
The desktop helper source is absent from the public checkout, so this report supplies analysis and reproductions rather than an installed-app patch.
No user configuration or live Codex process changed during these controls.
Related: #37870 for completed-subagent plugin retention, #30408 for the broader per-thread MCP multiplier, and #43122 for the older extension-host cache path.
Reproduction files and complete results
Save the three source files below in the same directory.
Use the installed backend executable for the supplied path.
These commands require Node with built-in WebSocket and Python 3, with no package installation.
The one-client default-delay run takes about 75 seconds.
The two-client run takes about 20 seconds.
The output files must not already exist.
The tests use isolated backends, not the live desktop.
Two-client subscription control: subscriber-probe.mjs
One-client lifecycle control and tiny MCP server: reproduce.py
Python harness cancellation and failure checks: test_reproduce.py
Complete portable two-client result: portable-results.json
{ "captured_at_utc": "2026-10-04T05:43:49.707Z", "backend_version": "codex-cli 0.160.0", "configured_idle_delay_seconds": 1, "model_turns_started": 0, "cases": [ { "name": "one_client_unsubscribes", "wait_seconds": 4, "loaded_threads": 0, "live_probes": 0 }, { "name": "second_client_connected_without_subscription", "second_client_explicit_subscribe_or_resume_requests": 0, "wait_seconds": 4, "loaded_threads": 0, "live_probes": 0 }, { "name": "second_subscribed_client_retains_thread", "second_client_explicit_subscribe_or_resume_requests": 1, "second_client_unsubscribe_status": "unsubscribed", "wait_seconds": 4, "loaded_threads": 1, "live_probes": 1 }, { "name": "last_client_unsubscribes", "wait_seconds": 4, "loaded_threads": 0, "live_probes": 0 } ], "test_server_exit": { "code": 0, "signal": null }, "surviving_test_probes": 0 }Complete earlier default-delay and per-thread-disable results: lifecycle-results.json
{ "description": "Per-thread MCP startup and post-unsubscribe retention measurement", "backend_version": "codex-cli 0.160.0", "captured_at_utc": "2026-10-04T03:52:13.715676+00:00", "platform": { "system": "Darwin", "machine": "arm64" }, "cases": [ { "case": "inherited_probe", "configured_idle_delay_seconds": null, "threads_started": 3, "checkpoints": [ { "threads_started": 1, "live_probes": 1 }, { "threads_started": 2, "live_probes": 2 }, { "threads_started": 3, "live_probes": 3 } ], "successful_unsubscribes": 3, "hold_seconds": 65, "loaded_threads_after_hold": 0, "initialized_probes": 3, "live_probes_after_hold": 0, "model_turns_started": 0, "forced_server_stop": false, "server_exit_code": 0, "surviving_probes_after_shutdown": 0 }, { "case": "probe_disabled_per_thread", "configured_idle_delay_seconds": null, "threads_started": 3, "checkpoints": [ { "threads_started": 1, "live_probes": 0 }, { "threads_started": 2, "live_probes": 0 }, { "threads_started": 3, "live_probes": 0 } ], "successful_unsubscribes": 3, "hold_seconds": 2, "loaded_threads_after_hold": 3, "initialized_probes": 0, "live_probes_after_hold": 0, "model_turns_started": 0, "forced_server_stop": false, "server_exit_code": 0, "surviving_probes_after_shutdown": 0 }, { "case": "short_idle_delay", "configured_idle_delay_seconds": 1, "threads_started": 3, "checkpoints": [ { "threads_started": 1, "live_probes": 1 }, { "threads_started": 2, "live_probes": 2 }, { "threads_started": 3, "live_probes": 3 } ], "successful_unsubscribes": 3, "hold_seconds": 5, "loaded_threads_after_hold": 0, "initialized_probes": 3, "live_probes_after_hold": 0, "model_turns_started": 0, "forced_server_stop": false, "server_exit_code": 0, "surviving_probes_after_shutdown": 0 } ], "scope": "Isolated backend API; no Desktop UI or model turns", "interpretation": "Post-unsubscribe retention must be compared with this backend's idle deadline" }Source and result checksums:
0xdevalias commentedon Oct 4, 2026
@sharifhsn If you're wanting to correlate the runtimes/MCP's/etc to their source threads better; it may actually be worth looking deeper at the ENV vars and similar passed to them.
A few related notes of learnings from one of my Codex threads:
0xdevalias commentedon Oct 4, 2026
Follow-up with a measured live-server cleanup and a correction to my earlier timing guidance.
On the existing running 0.153.4 server in macOS Desktop 26.903.71938 / build 8576, an exact unsubscribe of an obsolete internal MCP extension host returned
unsubscribed; that host and its app-tools/node_repl/Computer History processes disappeared after 1803.656 seconds, matching the tagged backend's fixed 30-minute idle/no-subscriber deadline. A 60-second wait is therefore insufficient for this running version. My earlier inference of a hidden owner from a short wait was too strong without establishing the running backend's deadline; this new experiment does not reconstruct every condition of the September observation.The full live-ownership audit and measured batch result are in #43122, which already covers the MCP host-query lifetime gap. After excluding all displayed tasks and currently observed MCP hosts, 44 reviewed obsolete/completed runtimes were unsubscribed and all unloaded after the same deadline. The batch exited 133 helpers, reduced summed Desktop footprint from 38.92 to 35.14 GiB, and preserved all 52 protected runtimes, window routes, cache owners and saved task/automation state. No detached survivors or new zombies were found.
We queried the already-running Desktop app-server, rather than launching a diagnostic app-server: guarded Electron Node inspection → CDP
Runtime.queryObjectsfor existing client instances → the client'ssendInternalRequestover its initialized stdio transport.thread/loaded/list, metadata-onlythread/read, andserver/diagnosticsprovided the actual registry, status/source and live gauges.thread/loaded/listlists runtimes, not subscribers. Exact unsubscribe releases this connection only; backend unloading still depends on absence of other subscribers and activity.This locally observed owner is
mcp_extension_host, not a reproduction ofthread_summary. Current public code includes #42320 (configurable unload delay, default 60 seconds) and #50380 (disconnect-during-required-MCP-startup listener fix), so reports should verify the running version/configuration before inferring permanent retention. Missing Desktop owner release and unnecessary MCP startup for tool-free generations remain separate questions.Essatt commentedon Oct 6, 2026
Another macOS data point, on current builds: ChatGPT Desktop 26.930.61225 (build 13232) with bundled codex-cli 0.160.1, macOS 27.0.1, 24 GiB RAM.
Impact (2026-10-05): one bundled app-server reached about 111 MCP child processes after about 3 hours of normal use with subagents. Swap was 23.4 of 24 GiB used and the load average was about 470. Terminal and other CLI startup took 2–3 minutes. Quitting the app reclaimed everything.
Measurement (2026-10-06). Read-only sampler;
logs_2.sqliteopened withmode=ro; no process killed.codex_mcp::connection_manager::tool_catalog). Only 44 have anop.dispatch.shutdownrecord, a gap of 21 threads.no subscribers and is idle: 0. Over the prior ~10 days: 1, against several hundred shutdown dispatches.config.toml, plus 41 node children (bundled computer-use, browser and plugin servers). Every one still had fd 0 open to the app-server. So stdin EOF never arrives, and a stdin- or parent-death watchdog on the server side can't help.Reading of the code (main):
thread_lifecycle.rswait_for_thread_shutdown→Op::Shutdown→mcp_runtime.shutdown()→ process-group terminate/kill.max(no_subscribers_since, inactive_since) + thread_unload_delay. It is never reached while any subscriber is retained, and that matches the 0 idle-unload lines above.thread_unload_delay_secstherefore can't help, and roleconfig_files can't shrink a subagent's MCP set (role_tests.rsassertsmcp_serversare inherited).Two asks that would fix it from our side:
Op::Shutdown(or drop the subscriber) when a subagent reaches a terminalAgentStatus.mcp_servers(e.g. a URL-only or empty set for worker roles).Happy to run a build with extra PID→thread logging if that helps.