Skip to content

Codex Desktop ephemeral thread summaries leak full MCP stacks via thread/unsubscribe #39783

Open

Description

@NathanNorman

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_summaries enabled by the desktop feature overlay

Reproduction

  1. 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"]
  2. Fully quit and relaunch ChatGPT/Codex Desktop so the app-server starts from a clean baseline.

  3. Find the app-server PID:

    APP_SERVER_PID="$(pgrep -f 'ChatGPT.app/Contents/Resources/codex.*app-server' | head -n 1)"
    echo "$APP_SERVER_PID"
  4. 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
  5. 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.

  6. Correlate the process growth with the desktop log:

    rg 'feature=thread_summary|unknown conversation' ~/Library/Logs/com.openai.codex
  7. Wait after the visible turn is complete. The extra MCP process sets remain. Repeat step 5 and the count continues to rise.

  8. 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_summary generations 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:

  1. start with MCP servers disabled because they do not need tools, or
  2. 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

Activity

  1. added
    bugSomething isn't working
    appIssues related to the Codex desktop app
    mcpIssues related to the use of model context protocol (MCP) servers
    app-serverIssues involving app server protocol or interfaces
    on Aug 20, 2026
  2. EaglePPP commented on Aug 22, 2026

    @EaglePPP

    #38825
    left a bunch of Nodes and Gigabytes huge rust-analyzers is really annoying indeed.

  3. yang199901 commented on Sep 5, 2026

    @yang199901

    Windows reproduction: thread_description and thread_title use the same retained ephemeral-session path

    I can reproduce the same lifecycle defect on Windows with a newer Desktop build. The trigger here is not limited to thread_summary: Desktop-owned thread_description and thread_title generations also create an unknown/ephemeral conversation and eagerly start the global stdio MCP stack.

    Environment

    • Windows 10 Pro 22H2 (10.0.19045), x64
    • Codex Desktop 26.901.4073.0
    • Global stdio MCP entry launching node <redacted>/figma-console-mcp/dist/local.js
    • All observed MCP processes were direct children of one long-lived codex.exe app-server

    Sanitized log correlation (UTC)

    23:58:29.687 mcp_server_startup_status_updated server=figma_console status=starting threadId=<ephemeral-A>
    23:58:29.701 Received turn/started for unknown conversation conversationId=<ephemeral-A>
    23:58:30.127 mcp_server_startup_status_updated server=figma_console status=ready threadId=<ephemeral-A>
    23:58:37.611 ephemeral_generation_token_usage feature=thread_description status=success
    23:58:37.611 Received turn/completed for unknown conversation conversationId=<ephemeral-A>
    
    23:58:36.875 mcp_server_startup_status_updated server=figma_console status=starting threadId=<ephemeral-B>
    23:58:36.886 Received turn/started for unknown conversation conversationId=<ephemeral-B>
    23:58:37.346 mcp_server_startup_status_updated server=figma_console status=ready threadId=<ephemeral-B>
    23:58:45.323 ephemeral_generation_token_usage feature=thread_description status=success
    23:58:45.323 Received turn/completed for unknown conversation conversationId=<ephemeral-B>
    

    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:

    • the app-server thread list showed 8 loaded Codex tasks (2 active, 6 idle);
    • Windows still showed 20 node .../dist/local.js processes, all direct children of the same app-server;
    • all 20 independently held listening ports 9223-9242;
    • the two processes born at the 23:58:29 and 23:58:36 events 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 direct local.js children, all listening on 9223-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_summary to at least thread_title and thread_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.

  4. PracticalSwan commented on Sep 5, 2026

    @PracticalSwan

    Fresh Windows reproduction on the current Sep 5 Desktop build confirms the underlying ephemeral-thread MCP retention is still present.

    Environment:

    • Windows 11 x64, build 26200
    • Codex Desktop package 26.901.5003.0
    • app-server codex-cli 0.153.3
    • app-server PID remained long-lived for the measurement

    Observed sequence:

    1. A normal persisted thread initialized its MCP stack at 19:00:44 local.
    2. At 19:00:55 Desktop issued another thread/start and created thread 01a07171-... (ID omitted here beyond the prefix).
    3. That second thread eagerly started the same full MCP set: local Python MCPs, Blender MCP, Node REPL, Computer Use, Codex app tools, Codex Security, LSP, Playwright, NotebookLM, Serena, and others. Authentication-failed HTTP MCPs were also retried later.
    4. The second thread is not persisted: there is no rollout JSONL filename containing its ID and no matching row in state_5.sqlite.
    5. More than ten minutes later, its MCP process set was still alive under the same app-server. At 19:10:28 the same ephemeral thread even retried failed MCP startup for XcodeBuildMCP / OAuth MCPs.

    Measured retained footprint for only the 19:00:55 generation:

    direct MCP/launcher roots: 13
    live processes including descendants: 29
    combined working set: ~1085.7 MiB
    combined private memory: ~961.8 MiB
    

    Direct roots included python.exe, blender-mcp.exe, node_repl.exe, several node.exe/cmd.exe MCP launchers, uvx.exe (Serena), Playwright, and NotebookLM.

    This build did not emit the older ephemeral_generation_token_usage feature=thread_summary marker in the Desktop log around this creation, so I cannot prove that this specific generation was triggered by thread_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.

  5. PracticalSwan commented on Sep 5, 2026

    @PracticalSwan

    Stronger current Windows reproduction of the ephemeral-thread MCP retention defect, now correlated with Codex review-summary activity.

    Environment unchanged from my previous report:

    • Windows 11 x64
    • Codex Desktop 26.901.5003.0
    • app-server codex-cli 0.153.3

    Sequence on a real persisted CSX4213 project thread:

    1. Desktop resumed persisted thread 01a07085-... at 19:46:27 local. It has both a rollout JSONL and a row in state_5.sqlite.
    2. Immediately afterward, Desktop emitted several canceled Git operations with:
    requestKind=review-summary
    source=review_model
    

    against that project working tree.
    3. At 19:46:39, method=thread/start completed and a new thread 01a0719b-... 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 MCP/launcher roots: 13
    live processes including descendants: 29
    combined working set: ~1116.4 MiB
    combined private memory: ~990.9 MiB
    

    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/start occurs immediately after requestKind=review-summary source=review_model activity for the real project thread. I still cannot prove from the current logs that the private feature owner of 01a0719b-... 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.

  6. 0xdevalias commented on Sep 22, 2026

    @0xdevalias

    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 after thread/unsubscribe, but only because the caller was evidently the final owner. Four completed ordinary tasks and one completed automation returned unsubscribed yet stayed loaded beyond the delay; a second call returned notSubscribed, so a hidden owner was retaining them.

    I have not reproduced this report's exact thread_summary path. For that path, unsubscribe-and-wait is worth testing, but it should be described as conditional: verify thread/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 explicit thread/unload RPC are needed.

  7. sharifhsn commented on Oct 4, 2026

    @sharifhsn

    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:

    • At 11:19:31 PM EDT, the Desktop process tree contained 442 processes / 28.16 GiB summed RSS. The app-server subtree contained 411 processes / 21.15 GiB summed RSS.
    • The main renderer used 1.65 GiB RSS. The app-server process itself used 1.92 GiB RSS.
    • Direct tool-server groups included 25 code-review servers, 25 Codex app-tool servers, 24 System Health servers, and 23 Computer History clients.
    • At 11:30:40 PM EDT, the Desktop tree contained 439 processes / 28.60 GiB summed RSS. The count was approximately stable during this interval.
    • The Desktop log recorded MCP startup for 40 distinct sessions since sign-in. That is a historical count, not a simultaneous loaded-thread count.

    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, checks thread/loaded/list, and obtains successful unsubscribe responses. No model turn or MCP tool call is started.

    Case Probe starts Wait after unsubscribe Loaded threads Live probes
    Inherited configuration, initial observation 3 2 seconds 3 3
    Inherited configuration, longer observation 3 65 seconds 0 0
    Per-thread probe disable 0 2 seconds 3 0
    One-second idle delay 3 5 seconds 0 0

    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 calls unsubscribeThread. The inspected asset is .vite/build/bootstrap-D3_zvIvQ.js, SHA-256 35d486c724973490ad66921f6a451ac6aa59d14e31f977f0486ef9574976177e.

    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 threadSource label.

    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:

    python3 reproduce.py --codex /absolute/path/to/codex --output results.json

    The default run takes approximately 75 seconds. The output file must not exist. Add --hold-seconds 2 to 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
    #!/usr/bin/env python3
    """Measure MCP ownership in isolated, turn-free Codex app-server sessions."""
    
    import argparse
    import asyncio
    import contextlib
    from datetime import datetime, timezone
    import json
    import os
    from pathlib import Path
    import platform
    import signal
    import subprocess
    import sys
    import tempfile
    import time
    
    
    def probe(directory):
        """Serve a tiny MCP catalog and record process lifecycle events locally."""
        events = Path(directory) / f"{os.getpid()}.jsonl"
    
        def record(event):
            with events.open("a", encoding="utf-8") as stream:
                stream.write(json.dumps({"event": event}) + "\n")
    
        def stop(_signum, _frame):
            raise SystemExit(0)
    
        signal.signal(signal.SIGTERM, stop)
        record("started")
        try:
            for line in sys.stdin:
                request = json.loads(line)
                if "id" not in request:
                    continue
                method = request.get("method")
                if method == "initialize":
                    record("initialized")
                    result = {
                        "protocolVersion": request["params"]["protocolVersion"],
                        "capabilities": {"tools": {}},
                        "serverInfo": {"name": "lifecycle-probe", "version": "1.0"},
                    }
                elif method == "tools/list":
                    result = {
                        "tools": [
                            {
                                "name": "sentinel",
                                "description": "Return a fixed diagnostic value.",
                                "inputSchema": {"type": "object", "properties": {}},
                                "annotations": {"readOnlyHint": True},
                            }
                        ]
                    }
                elif method in ("resources/list", "resources/templates/list"):
                    result = {
                        "resources"
                        if method == "resources/list"
                        else "resourceTemplates": []
                    }
                elif method == "ping":
                    result = {}
                else:
                    response = {
                        "jsonrpc": "2.0",
                        "id": request["id"],
                        "error": {
                            "code": -32601,
                            "message": "Method not available in probe",
                        },
                    }
                    print(json.dumps(response), flush=True)
                    continue
                print(
                    json.dumps({"jsonrpc": "2.0", "id": request["id"], "result": result}),
                    flush=True,
                )
        finally:
            record("exited")
    
    
    class RpcClient:
        def __init__(self, process):
            self.process = process
            self.sequence = 0
            self.pending = {}
            self.reader = asyncio.create_task(self.read_messages())
    
        async def read_messages(self):
            try:
                async for line in self.process.stdout:
                    message = json.loads(line)
                    request_id = message.get("id")
                    if "method" in message and request_id is not None:
                        self.send(
                            {
                                "id": request_id,
                                "error": {
                                    "code": -32601,
                                    "message": "No interactive requests in test",
                                },
                            }
                        )
                    elif request_id in self.pending:
                        self.pending.pop(request_id).set_result(message)
            finally:
                for future in self.pending.values():
                    if not future.done():
                        future.set_exception(
                            RuntimeError("Test app-server closed its output")
                        )
                self.pending.clear()
    
        def send(self, message):
            self.process.stdin.write((json.dumps(message) + "\n").encode())
    
        async def request(self, method, params):
            self.sequence += 1
            request_id = self.sequence
            future = asyncio.get_running_loop().create_future()
            self.pending[request_id] = future
            self.send({"id": request_id, "method": method, "params": params})
            try:
                response = await asyncio.wait_for(future, timeout=30)
            finally:
                self.pending.pop(request_id, None)
            if "error" in response:
                # Do not print backend errors: they can include local paths.
                raise RuntimeError(f"{method} failed with code {response['error']['code']}")
            return response["result"]
    
        async def close(self):
            forced = False
            self.process.stdin.close()
            try:
                await asyncio.wait_for(self.process.wait(), timeout=15)
            except TimeoutError:
                forced = True
                self.process.terminate()
                try:
                    await asyncio.wait_for(self.process.wait(), timeout=5)
                except TimeoutError:
                    self.process.kill()
                    await self.process.wait()
            await self.reader
            return {
                "forced_server_stop": forced,
                "server_exit_code": self.process.returncode,
            }
    
    
    def probe_inventory(directory):
        initialized = 0
        live = []
        for path in sorted(directory.glob("*.jsonl")):
            events = [json.loads(line)["event"] for line in path.read_text().splitlines()]
            initialized += "initialized" in events
            pid = int(path.stem)
            try:
                os.kill(pid, 0)
            except ProcessLookupError:
                continue
            live.append(pid)
        return initialized, live
    
    
    async def wait_for_probes(directory, count):
        deadline = time.monotonic() + 15
        while time.monotonic() < deadline:
            initialized, live = probe_inventory(directory)
            if initialized == count and len(live) == count:
                return
            await asyncio.sleep(0.1)
        raise RuntimeError("MCP probe count did not reach the expected value")
    
    
    async def loaded_ids(client):
        ids = []
        cursor = None
        while True:
            params = {"limit": 100}
            if cursor is not None:
                params["cursor"] = cursor
            page = await client.request("thread/loaded/list", params)
            ids.extend(page["data"])
            cursor = page.get("nextCursor")
            if cursor is None:
                return ids
    
    
    def config_text(probe_directory, idle_delay=None):
        # JSON strings are valid TOML basic strings for these absolute paths.
        arguments = [str(Path(__file__).resolve()), "--probe", str(probe_directory)]
        delay = "" if idle_delay is None else f"thread_unload_delay_secs = {idle_delay}\n"
        return f"""{delay}model = "diagnostic-model"
    model_provider = "diagnostic"
    cli_auth_credentials_store = "file"
    approval_policy = "never"
    sandbox_mode = "read-only"
    
    [features]
    plugins = false
    apps = false
    
    [model_providers.diagnostic]
    name = "Isolated diagnostic provider"
    base_url = "http://127.0.0.1:9/v1"
    wire_api = "responses"
    requires_openai_auth = false
    
    [mcp_servers.lifecycle_probe]
    command = {json.dumps(sys.executable)}
    args = {json.dumps(arguments)}
    startup_timeout_sec = 15
    required = true
    """
    
    
    async def run_case(codex, count, hold_seconds, disable_probe, idle_delay=None):
        with tempfile.TemporaryDirectory(prefix="codex-mcp-lifecycle-") as temporary:
            root = Path(temporary)
            codex_home = root / "codex-home"
            codex_home.mkdir()
            directory = root / "probe-events"
            directory.mkdir()
            (codex_home / "config.toml").write_text(config_text(directory, idle_delay))
            # Do not copy API keys, proxy credentials, or the user's Codex settings.
            environment = {
                key: os.environ[key]
                for key in ("PATH", "HOME", "TMPDIR", "LANG", "LC_ALL")
                if key in os.environ
            }
            environment["CODEX_HOME"] = str(codex_home)
            process = await asyncio.create_subprocess_exec(
                str(codex),
                "app-server",
                cwd=root,
                env=environment,
                stdin=asyncio.subprocess.PIPE,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.DEVNULL,
            )
            client = RpcClient(process)
            result = {}
            try:
                await client.request(
                    "initialize",
                    {
                        "clientInfo": {"name": "mcp_lifecycle_repro", "version": "1.0"},
                        "capabilities": {"experimentalApi": True},
                    },
                )
                client.send({"method": "initialized", "params": {}})
                ids = []
                checkpoints = []
                for _ in range(count):
                    params = {
                        "ephemeral": True,
                        "threadSource": "thread_summary",
                        "cwd": str(root),
                        "baseInstructions": "Diagnostic session. No model turn will be started.",
                    }
                    if disable_probe:
                        params["config"] = {"mcp_servers.lifecycle_probe.enabled": False}
                    started = await client.request("thread/start", params)
                    ids.append(started["thread"]["id"])
                    if not started["thread"]["ephemeral"]:
                        raise RuntimeError("The backend did not create an ephemeral thread")
                    expected = 0 if disable_probe else len(ids)
                    await wait_for_probes(directory, expected)
                    checkpoints.append(
                        {"threads_started": len(ids), "live_probes": expected}
                    )
                if sorted(await loaded_ids(client)) != sorted(ids):
                    raise RuntimeError(
                        "Loaded thread inventory did not match the test threads"
                    )
                statuses = []
                for thread_id in ids:
                    reply = await client.request(
                        "thread/unsubscribe", {"threadId": thread_id}
                    )
                    statuses.append(reply["status"])
                if statuses != ["unsubscribed"] * count:
                    raise RuntimeError(
                        "The backend did not confirm all unsubscribe requests"
                    )
                await asyncio.sleep(hold_seconds)
                loaded = await loaded_ids(client)
                initialized, live = probe_inventory(directory)
                if not set(loaded).issubset(ids):
                    raise RuntimeError("Unexpected loaded thread in isolated backend")
                if len(live) != (0 if disable_probe else len(loaded)):
                    raise RuntimeError(
                        "Live probe count did not match loaded thread ownership"
                    )
                if idle_delay is not None and loaded:
                    raise RuntimeError(
                        "Configured idle deadline did not release the test threads"
                    )
                result = {
                    "case": "short_idle_delay"
                    if idle_delay is not None
                    else "probe_disabled_per_thread"
                    if disable_probe
                    else "inherited_probe",
                    "configured_idle_delay_seconds": idle_delay,
                    "threads_started": count,
                    "checkpoints": checkpoints,
                    "successful_unsubscribes": len(statuses),
                    "hold_seconds": hold_seconds,
                    "loaded_threads_after_hold": len(loaded),
                    "initialized_probes": initialized,
                    "live_probes_after_hold": len(live),
                    "model_turns_started": 0,
                }
            finally:
                result.update(await client.close())
                deadline = time.monotonic() + 5
                while probe_inventory(directory)[1] and time.monotonic() < deadline:
                    await asyncio.sleep(0.1)
                survivors = probe_inventory(directory)[1]
                result["surviving_probes_after_shutdown"] = len(survivors)
                # These PIDs came from this test's private probe directory.
                for pid in survivors:
                    with contextlib.suppress(ProcessLookupError):
                        os.kill(pid, signal.SIGTERM)
                if (
                    survivors
                    or result["forced_server_stop"]
                    or result["server_exit_code"] != 0
                ):
                    raise RuntimeError("Test app-server did not shut down cleanly")
            return result
    
    
    async def reproduce(arguments):
        cases = []
        for disable_probe, hold_seconds, idle_delay in (
            (False, arguments.hold_seconds, None),
            (True, 2, None),
            (False, 5, 1),
        ):
            cases.append(
                await run_case(
                    arguments.codex,
                    arguments.threads,
                    hold_seconds,
                    disable_probe,
                    idle_delay,
                )
            )
        version = subprocess.check_output(
            [str(arguments.codex), "--version"], text=True, timeout=10
        ).strip()
        result = {
            "description": "Per-thread MCP startup and post-unsubscribe retention measurement",
            "backend_version": version,
            "captured_at_utc": datetime.now(timezone.utc).isoformat(),
            "platform": {"system": platform.system(), "machine": platform.machine()},
            "cases": cases,
            "scope": "Isolated backend API; no Desktop UI or model turns",
            "interpretation": "Post-unsubscribe retention must be compared with this backend's idle deadline",
        }
        encoded = json.dumps(result, indent=2) + "\n"
        if arguments.output:
            with arguments.output.open("x", encoding="utf-8") as stream:
                stream.write(encoded)
        print(encoded, end="")
    
    
    def main():
        parser = argparse.ArgumentParser(description=__doc__)
        parser.add_argument(
            "--codex", type=Path, help="Absolute path to a Codex executable"
        )
        parser.add_argument("--threads", type=int, default=3)
        parser.add_argument("--hold-seconds", type=float, default=65)
        parser.add_argument("--output", type=Path)
        parser.add_argument("--probe", type=Path, help=argparse.SUPPRESS)
        arguments = parser.parse_args()
        if arguments.probe:
            probe(arguments.probe)
            return
        if os.name != "posix":
            parser.error("This process-liveness check requires macOS or Linux")
        if (
            not arguments.codex
            or not arguments.codex.is_absolute()
            or not arguments.codex.is_file()
        ):
            parser.error("--codex must name an existing absolute executable path")
        if not 1 <= arguments.threads <= 20:
            parser.error("--threads must be between 1 and 20")
        if not 0 <= arguments.hold_seconds <= 1900:
            parser.error("--hold-seconds must be between 0 and 1900")
        if arguments.output and arguments.output.exists():
            parser.error("The output file must not already exist")
        asyncio.run(reproduce(arguments))
    
    
    if __name__ == "__main__":
        main()
  8. sharifhsn commented on Oct 4, 2026

    @sharifhsn

    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

    Item Value
    Desktop 26.930.31730, build 12947
    Bundled backend codex-cli 0.160.0
    macOS 26.6.2, build 25G83
    Architecture arm64
    Physical RAM 48 GiB, 51,539,607,552 bytes
    Node for the two-client probe v26.10.0, built-in WebSocket
    Python for the tiny MCP probe 3.14.7
    Public release source rust-v0.160.0, commit a956835d020762cb2b570053af06f643a11c0ecc
    Previously inspected upstream main dde5f8c5ae9b644c6882b3a48581ac317255d597

    The 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_secs override.
    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.

    Measurement 2026-10-04 03:19:31 UTC 2026-10-04 05:02:18 UTC
    Desktop and descendant processes 442 611
    Sum of desktop-tree RSS 28.1621 GiB 25.45 GiB
    Shared app-server and descendant processes 411 568
    Sum of app-server-tree RSS 21.1482 GiB 18.22 GiB
    App-server RSS alone 1.923 GiB 1.42 GiB
    Main renderer RSS 1.6489 GiB 1.81 GiB
    Direct app-server children Not recorded in this summary 399

    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.

    Runtime group Earlier processes Later processes Later sum of RSS
    Local code-review tools 25 34 1.442 GiB
    Codex app tools 25 32 1.187 GiB
    System Health 24 34 0.516 GiB
    Computer History 23 34 0.959 GiB
    Typst XY Studio Not recorded in this summary 28 0.558 GiB

    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 lsof queries 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_started and task_complete event metadata.

    The inspected app logs contained 73 distinct thread owners with MCP startup records:

    Owner class Historical owner count
    Visible chats 31
    Native child agents 12
    Unpersisted internal sessions with unknown subtype 14
    MCP extension-host sessions 2
    Other persisted owners 4

    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_at was 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:

    Anonymous child Last completion event Process snapshot Elapsed time Matched direct children Cohort RSS Parent state
    Child A 2026-10-04 04:20:33.659 UTC 2026-10-04 05:02:18.224 UTC 41.7 minutes 7 0.145 GiB Active
    Child B 2026-10-04 04:55:53.212 UTC 2026-10-04 05:02:18.224 UTC 6.4 minutes 6 0.133 GiB Idle

    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::handlers shutdown records with the message Shutting down Codex instance.
    The first-start-to-shutdown intervals were between 63.6 and 129.7 seconds.
    The individual intervals, in seconds, are:

    67.6, 129.7, 64.3, 64.4, 64.9, 65.1, 129.2, 64.4, 63.6, 125.4, 63.8
    

    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/unsubscribe after 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_servers registration.
    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.

    1. Native child creation notification calls notify_thread_created after child admission.
    2. App-server client attachment collects every initialized connection for that notification.
    3. Thread listener attachment subscribes those connections to the child thread.
    4. Idle unload condition requires zero subscribers and no active turn before it sets a deadline.
    5. Child completion delivery sends the result without disposal of the child session.
    6. V2 resident reservation evicts an eligible child when another reservation reaches capacity.
    7. Resident eligibility requires a completed, errored, or interrupted child with no active turn and no queued mailbox item.

    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.

    Single-client case Probe starts Wait after unsubscribe Loaded threads Live probes
    Inherited configuration, short hold 3 2 seconds 3 3
    Inherited configuration, default delay 3 65 seconds 0 0
    Per-thread mcp_servers.lifecycle_probe.enabled = false 0 2 seconds 3 0
    thread_unload_delay_secs = 1 3 5 seconds 0 0

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

    State after four seconds Loaded threads Live MCP probes
    Only subscriber unsubscribes 0 0
    Second client connects without subscription 0 0
    Second client subscribes, then first client unsubscribes 1 1
    Final subscriber unsubscribes 0 0

    The positive control uses a non-ephemeral thread and thread/resume to establish the second subscription.
    It first calls thread/section/move with sectionId: null to 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.py in the same directory, with no personal path in the source.
    Its source and full result appear below.

    Each harness uses a temporary CODEX_HOME and a local dummy model provider at http://127.0.0.1:9/v1.
    Plugins and apps are disabled only in that temporary home.
    The environment allowlist contains PATH, HOME, TMPDIR, LANG, and LC_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:

    • Repeated tool-free helper generations with a sentinel MCP server: zero unnecessary starts.
    • Final-unsubscribe cleanup at the default and short delays: return to a bounded process baseline.
    • A second connected client without a subscription: no accidental retention of an ordinary root thread.
    • Two subscribed clients: preserve the runtime until the final unsubscribe.
    • Native child completion below the resident-capacity limit: enforce the selected idle or resource budget.
    • Many parent chats: enforce the intended global resource bound, rather than only each parent's child count.
    • A queued message or active tool request during eviction: preserve delivery and avoid duplicate work.
    • Resumption of an evicted child through its parent: preserve identity, context, and environment selections.
    • Error, cancellation, startup failure, and connection loss: wait for shutdown and verify MCP process exit.

    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.0 tag 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.

    python3 reproduce.py --codex /absolute/path/to/codex --threads 3 --hold-seconds 65 --output lifecycle-results.json
    python3 test_reproduce.py --codex /absolute/path/to/codex
    node subscriber-probe.mjs /absolute/path/to/codex subscriber-results.json

    The output files must not already exist.
    The tests use isolated backends, not the live desktop.

    Two-client subscription control: subscriber-probe.mjs
    // Measure subscriptions in an isolated backend without model turns.
    import assert from 'node:assert/strict';
    import { spawn, execFileSync } from 'node:child_process';
    import { mkdtemp, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises';
    import net from 'node:net';
    import os from 'node:os';
    import path from 'node:path';
    import { fileURLToPath } from 'node:url';
    import { setTimeout as delay } from 'node:timers/promises';
    
    const codex = process.argv[2];
    if (!codex || !path.isAbsolute(codex)) throw new Error('Supply an absolute path to the Codex executable');
    const probeScript = fileURLToPath(new URL('./reproduce.py', import.meta.url));
    const python = execFileSync('python3', ['-c', 'import sys; print(sys.executable)'], { encoding: 'utf8' }).trim();
    const root = await mkdtemp(path.join(os.tmpdir(), 'codex-subscriptions-'));
    const home = path.join(root, 'home');
    const events = path.join(root, 'events');
    await mkdir(home);
    await mkdir(events);
    await writeFile(path.join(home, 'config.toml'), `thread_unload_delay_secs = 1
    model = "diagnostic-model"
    model_provider = "diagnostic"
    cli_auth_credentials_store = "file"
    approval_policy = "never"
    sandbox_mode = "read-only"
    [features]
    plugins = false
    apps = false
    [model_providers.diagnostic]
    name = "Isolated diagnostic provider"
    base_url = "http://127.0.0.1:9/v1"
    wire_api = "responses"
    requires_openai_auth = false
    [mcp_servers.lifecycle_probe]
    command = ${JSON.stringify(python)}
    args = ${JSON.stringify([probeScript, '--probe', events])}
    startup_timeout_sec = 15
    required = true
    `, { mode: 0o600 });
    
    const allocator = net.createServer();
    await new Promise((resolve, reject) => {
      allocator.once('error', reject);
      allocator.listen(0, '127.0.0.1', resolve);
    });
    const port = allocator.address().port;
    await new Promise(resolve => allocator.close(resolve));
    const environment = Object.fromEntries(
      ['PATH', 'HOME', 'TMPDIR', 'LANG', 'LC_ALL']
        .filter(key => process.env[key] !== undefined)
        .map(key => [key, process.env[key]])
    );
    environment.CODEX_HOME = home;
    const server = spawn(codex, ['app-server', '--listen', `ws://127.0.0.1:${port}`], {
      cwd: root, env: environment, stdio: ['ignore', 'ignore', 'ignore'],
    });
    let serverExited = false;
    const serverExit = new Promise(resolve => {
      server.once('error', error => resolve({ error: error.code }));
      server.once('exit', (code, signal) => {
        serverExited = true;
        resolve({ code, signal });
      });
    });
    const clients = [];
    const result = {
      captured_at_utc: new Date().toISOString(),
      backend_version: execFileSync(codex, ['--version'], { encoding: 'utf8' }).trim(),
      configured_idle_delay_seconds: 1,
      model_turns_started: 0,
      cases: [],
    };
    
    async function connect() {
      const deadline = Date.now() + 15000;
      let socket;
      while (Date.now() < deadline) {
        socket = new WebSocket(`ws://127.0.0.1:${port}`);
        const opened = await new Promise(resolve => {
          socket.addEventListener('open', () => resolve(true), { once: true });
          socket.addEventListener('error', () => resolve(false), { once: true });
        });
        if (opened) break;
        if (serverExited) throw new Error('Isolated backend exited before connection');
        await delay(100);
      }
      if (socket.readyState !== WebSocket.OPEN) throw new Error('Connection deadline expired');
      let sequence = 0;
      const pending = new Map();
      const notifications = [];
      socket.addEventListener('message', event => {
        const message = JSON.parse(event.data);
        if (message.method && message.id !== undefined) {
          socket.send(JSON.stringify({ id: message.id, error: { code: -32601, message: 'No interactive requests in probe' } }));
        } else if (message.id !== undefined) {
          const entry = pending.get(message.id);
          if (!entry) return;
          pending.delete(message.id);
          clearTimeout(entry.timeout);
          if (message.error) {
            const safeMessage = String(message.error.message).replaceAll(root, '<test-directory>')
              .replaceAll(/[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}/gi, '<test-thread>');
            entry.reject(new Error(`${entry.method} failed with code ${message.error.code}: ${safeMessage}`));
          }
          else entry.resolve(message.result);
        } else if (message.method) {
          notifications.push(message);
        }
      });
      socket.addEventListener('close', () => {
        for (const entry of pending.values()) {
          clearTimeout(entry.timeout);
          entry.reject(new Error('Probe connection closed'));
        }
        pending.clear();
      });
      const client = {
        socket,
        notifications,
        request(method, params) {
          const id = ++sequence;
          return new Promise((resolve, reject) => {
            const timeout = setTimeout(() => {
              pending.delete(id);
              reject(new Error(`${method} deadline expired`));
            }, 15000);
            pending.set(id, { resolve, reject, timeout, method });
            socket.send(JSON.stringify({ id, method, params }));
          });
        },
      };
      clients.push(client);
      await client.request('initialize', {
        clientInfo: { name: 'subscription_probe', version: '1.0' },
        capabilities: { experimentalApi: true },
      });
      socket.send(JSON.stringify({ method: 'initialized', params: {} }));
      return client;
    }
    
    async function inventory() {
      let initialized = 0;
      const live = [];
      for (const file of await readdir(events)) {
        if (!file.endsWith('.jsonl')) continue;
        const records = (await readFile(path.join(events, file), 'utf8'))
          .trim().split('\n').filter(Boolean).map(line => JSON.parse(line).event);
        initialized += Number(records.includes('initialized'));
        const pid = Number(path.basename(file, '.jsonl'));
        try { process.kill(pid, 0); live.push(pid); }
        catch (error) { if (error.code !== 'ESRCH') throw error; }
      }
      return { initialized, live };
    }
    
    async function startThread(client, expectedProbeCount, ephemeral = true) {
      const started = await client.request('thread/start', {
        ephemeral, threadSource: 'thread_summary', cwd: root,
        baseInstructions: 'Diagnostic session. No model turn will be started.',
      });
      assert.equal(started.thread.ephemeral, ephemeral);
      const deadline = Date.now() + 15000;
      while (Date.now() < deadline) {
        if ((await inventory()).initialized === expectedProbeCount) return started.thread.id;
        await delay(100);
      }
      throw new Error('Probe initialization deadline expired');
    }
    
    async function checkpoint(client) {
      const loaded = await client.request('thread/loaded/list', { limit: 100 });
      assert.equal(loaded.nextCursor, null);
      return { loaded_threads: loaded.data.length, live_probes: (await inventory()).live.length };
    }
    
    try {
      const first = await connect();
      const singleId = await startThread(first, 1);
      assert.equal((await first.request('thread/unsubscribe', { threadId: singleId })).status, 'unsubscribed');
      await delay(4000);
      const single = await checkpoint(first);
      assert.deepEqual(single, { loaded_threads: 0, live_probes: 0 });
      result.cases.push({ name: 'one_client_unsubscribes', wait_seconds: 4, ...single });
    
      const second = await connect();
      const rootId = await startThread(first, 2);
      assert.equal((await first.request('thread/unsubscribe', { threadId: rootId })).status, 'unsubscribed');
      await delay(4000);
      const connected = await checkpoint(first);
      assert.deepEqual(connected, { loaded_threads: 0, live_probes: 0 });
      result.cases.push({
        name: 'second_client_connected_without_subscription',
        second_client_explicit_subscribe_or_resume_requests: 0,
        wait_seconds: 4, ...connected,
      });
    
      const sharedId = await startThread(first, 3, false);
      // Materialize this test thread before another client resumes it.
      await first.request('thread/section/move', { threadId: sharedId, sectionId: null });
      const resumed = await second.request('thread/resume', { threadId: sharedId, excludeTurns: true });
      assert.equal(resumed.thread.id, sharedId);
      assert.equal((await first.request('thread/unsubscribe', { threadId: sharedId })).status, 'unsubscribed');
      await delay(4000);
      const retained = await checkpoint(first);
      assert.deepEqual(retained, { loaded_threads: 1, live_probes: 1 });
      const secondUnsubscribe = await second.request('thread/unsubscribe', { threadId: sharedId });
      assert.equal(secondUnsubscribe.status, 'unsubscribed');
      result.cases.push({
        name: 'second_subscribed_client_retains_thread',
        second_client_explicit_subscribe_or_resume_requests: 1,
        second_client_unsubscribe_status: secondUnsubscribe.status,
        wait_seconds: 4, ...retained,
      });
      await delay(4000);
      const released = await checkpoint(first);
      assert.deepEqual(released, { loaded_threads: 0, live_probes: 0 });
      result.cases.push({ name: 'last_client_unsubscribes', wait_seconds: 4, ...released });
    } finally {
      for (const client of clients) client.socket.close();
      await delay(250);
      if (!serverExited) server.kill('SIGTERM');
      let outcome = await Promise.race([serverExit, delay(5000).then(() => null)]);
      if (!outcome) {
        server.kill('SIGKILL');
        outcome = await serverExit;
      }
      result.test_server_exit = outcome;
      const deadline = Date.now() + 5000;
      while ((await inventory()).live.length && Date.now() < deadline) await delay(100);
      const survivors = (await inventory()).live;
      result.surviving_test_probes = survivors.length;
      for (const pid of survivors) process.kill(pid, 'SIGTERM');
      await rm(root, { recursive: true, force: true });
      assert.equal(survivors.length, 0);
    }
    if (process.argv[3]) await writeFile(process.argv[3], `${JSON.stringify(result, null, 2)}\n`, { mode: 0o600, flag: 'wx' });
    console.log(JSON.stringify(result, null, 2));
    One-client lifecycle control and tiny MCP server: reproduce.py
    #!/usr/bin/env python3
    """Measure MCP ownership in isolated, turn-free Codex app-server sessions."""
    
    import argparse
    import asyncio
    import contextlib
    from datetime import datetime, timezone
    import json
    import os
    from pathlib import Path
    import platform
    import signal
    import subprocess
    import sys
    import tempfile
    import time
    
    
    def probe(directory):
        """Serve a tiny MCP catalog and record process lifecycle events locally."""
        events = Path(directory) / f"{os.getpid()}.jsonl"
    
        def record(event):
            with events.open("a", encoding="utf-8") as stream:
                stream.write(json.dumps({"event": event}) + "\n")
    
        def stop(_signum, _frame):
            raise SystemExit(0)
    
        signal.signal(signal.SIGTERM, stop)
        record("started")
        try:
            for line in sys.stdin:
                request = json.loads(line)
                if "id" not in request:
                    continue
                method = request.get("method")
                if method == "initialize":
                    record("initialized")
                    result = {
                        "protocolVersion": request["params"]["protocolVersion"],
                        "capabilities": {"tools": {}},
                        "serverInfo": {"name": "lifecycle-probe", "version": "1.0"},
                    }
                elif method == "tools/list":
                    result = {
                        "tools": [
                            {
                                "name": "sentinel",
                                "description": "Return a fixed diagnostic value.",
                                "inputSchema": {"type": "object", "properties": {}},
                                "annotations": {"readOnlyHint": True},
                            }
                        ]
                    }
                elif method in ("resources/list", "resources/templates/list"):
                    result = {
                        "resources"
                        if method == "resources/list"
                        else "resourceTemplates": []
                    }
                elif method == "ping":
                    result = {}
                else:
                    response = {
                        "jsonrpc": "2.0",
                        "id": request["id"],
                        "error": {
                            "code": -32601,
                            "message": "Method not available in probe",
                        },
                    }
                    print(json.dumps(response), flush=True)
                    continue
                print(
                    json.dumps({"jsonrpc": "2.0", "id": request["id"], "result": result}),
                    flush=True,
                )
        finally:
            record("exited")
    
    
    class RpcClient:
        def __init__(self, process):
            self.process = process
            self.sequence = 0
            self.pending = {}
            self.reader = asyncio.create_task(self.read_messages())
    
        async def read_messages(self):
            try:
                async for line in self.process.stdout:
                    message = json.loads(line)
                    request_id = message.get("id")
                    if "method" in message and request_id is not None:
                        self.send(
                            {
                                "id": request_id,
                                "error": {
                                    "code": -32601,
                                    "message": "No interactive requests in test",
                                },
                            }
                        )
                    elif request_id in self.pending:
                        self.pending.pop(request_id).set_result(message)
            finally:
                for future in self.pending.values():
                    if not future.done():
                        future.set_exception(
                            RuntimeError("Test app-server closed its output")
                        )
                self.pending.clear()
    
        def send(self, message):
            self.process.stdin.write((json.dumps(message) + "\n").encode())
    
        async def request(self, method, params):
            self.sequence += 1
            request_id = self.sequence
            future = asyncio.get_running_loop().create_future()
            self.pending[request_id] = future
            self.send({"id": request_id, "method": method, "params": params})
            try:
                response = await asyncio.wait_for(future, timeout=30)
            finally:
                self.pending.pop(request_id, None)
            if "error" in response:
                # Do not print backend errors: they can include local paths.
                raise RuntimeError(f"{method} failed with code {response['error']['code']}")
            return response["result"]
    
        async def close(self):
            forced = False
            self.process.stdin.close()
            try:
                await asyncio.wait_for(self.process.wait(), timeout=15)
            except TimeoutError:
                forced = True
                self.process.terminate()
                try:
                    await asyncio.wait_for(self.process.wait(), timeout=5)
                except TimeoutError:
                    self.process.kill()
                    await self.process.wait()
            await self.reader
            return {
                "forced_server_stop": forced,
                "server_exit_code": self.process.returncode,
            }
    
    
    def probe_inventory(directory):
        initialized = 0
        live = []
        for path in sorted(directory.glob("*.jsonl")):
            events = [json.loads(line)["event"] for line in path.read_text().splitlines()]
            initialized += "initialized" in events
            pid = int(path.stem)
            try:
                os.kill(pid, 0)
            except ProcessLookupError:
                continue
            live.append(pid)
        return initialized, live
    
    
    async def wait_for_probes(directory, count):
        deadline = time.monotonic() + 15
        while time.monotonic() < deadline:
            initialized, live = probe_inventory(directory)
            if initialized == count and len(live) == count:
                return
            await asyncio.sleep(0.1)
        raise RuntimeError("MCP probe count did not reach the expected value")
    
    
    async def loaded_ids(client):
        ids = []
        cursor = None
        while True:
            params = {"limit": 100}
            if cursor is not None:
                params["cursor"] = cursor
            page = await client.request("thread/loaded/list", params)
            ids.extend(page["data"])
            cursor = page.get("nextCursor")
            if cursor is None:
                return ids
    
    
    def config_text(probe_directory, idle_delay=None):
        # JSON strings are valid TOML basic strings for these absolute paths.
        arguments = [str(Path(__file__).resolve()), "--probe", str(probe_directory)]
        delay = "" if idle_delay is None else f"thread_unload_delay_secs = {idle_delay}\n"
        return f"""{delay}model = "diagnostic-model"
    model_provider = "diagnostic"
    cli_auth_credentials_store = "file"
    approval_policy = "never"
    sandbox_mode = "read-only"
    
    [features]
    plugins = false
    apps = false
    
    [model_providers.diagnostic]
    name = "Isolated diagnostic provider"
    base_url = "http://127.0.0.1:9/v1"
    wire_api = "responses"
    requires_openai_auth = false
    
    [mcp_servers.lifecycle_probe]
    command = {json.dumps(sys.executable)}
    args = {json.dumps(arguments)}
    startup_timeout_sec = 15
    required = true
    """
    
    
    async def run_case(codex, count, hold_seconds, disable_probe, idle_delay=None):
        with tempfile.TemporaryDirectory(prefix="codex-mcp-lifecycle-") as temporary:
            root = Path(temporary)
            codex_home = root / "codex-home"
            codex_home.mkdir()
            directory = root / "probe-events"
            directory.mkdir()
            (codex_home / "config.toml").write_text(config_text(directory, idle_delay))
            # Do not copy API keys, proxy credentials, or the user's Codex settings.
            environment = {
                key: os.environ[key]
                for key in ("PATH", "HOME", "TMPDIR", "LANG", "LC_ALL")
                if key in os.environ
            }
            environment["CODEX_HOME"] = str(codex_home)
            process = await asyncio.create_subprocess_exec(
                str(codex),
                "app-server",
                cwd=root,
                env=environment,
                stdin=asyncio.subprocess.PIPE,
                stdout=asyncio.subprocess.PIPE,
                stderr=asyncio.subprocess.DEVNULL,
            )
            client = RpcClient(process)
            result = {}
            try:
                await client.request(
                    "initialize",
                    {
                        "clientInfo": {"name": "mcp_lifecycle_repro", "version": "1.0"},
                        "capabilities": {"experimentalApi": True},
                    },
                )
                client.send({"method": "initialized", "params": {}})
                ids = []
                checkpoints = []
                for _ in range(count):
                    params = {
                        "ephemeral": True,
                        "threadSource": "thread_summary",
                        "cwd": str(root),
                        "baseInstructions": "Diagnostic session. No model turn will be started.",
                    }
                    if disable_probe:
                        params["config"] = {"mcp_servers.lifecycle_probe.enabled": False}
                    started = await client.request("thread/start", params)
                    ids.append(started["thread"]["id"])
                    if not started["thread"]["ephemeral"]:
                        raise RuntimeError("The backend did not create an ephemeral thread")
                    expected = 0 if disable_probe else len(ids)
                    await wait_for_probes(directory, expected)
                    checkpoints.append(
                        {"threads_started": len(ids), "live_probes": expected}
                    )
                if sorted(await loaded_ids(client)) != sorted(ids):
                    raise RuntimeError(
                        "Loaded thread inventory did not match the test threads"
                    )
                statuses = []
                for thread_id in ids:
                    reply = await client.request(
                        "thread/unsubscribe", {"threadId": thread_id}
                    )
                    statuses.append(reply["status"])
                if statuses != ["unsubscribed"] * count:
                    raise RuntimeError(
                        "The backend did not confirm all unsubscribe requests"
                    )
                await asyncio.sleep(hold_seconds)
                loaded = await loaded_ids(client)
                initialized, live = probe_inventory(directory)
                if not set(loaded).issubset(ids):
                    raise RuntimeError("Unexpected loaded thread in isolated backend")
                if len(live) != (0 if disable_probe else len(loaded)):
                    raise RuntimeError(
                        "Live probe count did not match loaded thread ownership"
                    )
                if idle_delay is not None and loaded:
                    raise RuntimeError(
                        "Configured idle deadline did not release the test threads"
                    )
                result = {
                    "case": "short_idle_delay"
                    if idle_delay is not None
                    else "probe_disabled_per_thread"
                    if disable_probe
                    else "inherited_probe",
                    "configured_idle_delay_seconds": idle_delay,
                    "threads_started": count,
                    "checkpoints": checkpoints,
                    "successful_unsubscribes": len(statuses),
                    "hold_seconds": hold_seconds,
                    "loaded_threads_after_hold": len(loaded),
                    "initialized_probes": initialized,
                    "live_probes_after_hold": len(live),
                    "model_turns_started": 0,
                }
            finally:
                result.update(await client.close())
                deadline = time.monotonic() + 5
                while probe_inventory(directory)[1] and time.monotonic() < deadline:
                    await asyncio.sleep(0.1)
                survivors = probe_inventory(directory)[1]
                result["surviving_probes_after_shutdown"] = len(survivors)
                # These PIDs came from this test's private probe directory.
                for pid in survivors:
                    with contextlib.suppress(ProcessLookupError):
                        os.kill(pid, signal.SIGTERM)
                if (
                    survivors
                    or result["forced_server_stop"]
                    or result["server_exit_code"] != 0
                ):
                    raise RuntimeError("Test app-server did not shut down cleanly")
            return result
    
    
    async def reproduce(arguments):
        cases = []
        for disable_probe, hold_seconds, idle_delay in (
            (False, arguments.hold_seconds, None),
            (True, 2, None),
            (False, 5, 1),
        ):
            cases.append(
                await run_case(
                    arguments.codex,
                    arguments.threads,
                    hold_seconds,
                    disable_probe,
                    idle_delay,
                )
            )
        version = subprocess.check_output(
            [str(arguments.codex), "--version"], text=True, timeout=10
        ).strip()
        result = {
            "description": "Per-thread MCP startup and post-unsubscribe retention measurement",
            "backend_version": version,
            "captured_at_utc": datetime.now(timezone.utc).isoformat(),
            "platform": {"system": platform.system(), "machine": platform.machine()},
            "cases": cases,
            "scope": "Isolated backend API; no Desktop UI or model turns",
            "interpretation": "Post-unsubscribe retention must be compared with this backend's idle deadline",
        }
        encoded = json.dumps(result, indent=2) + "\n"
        if arguments.output:
            with arguments.output.open("x", encoding="utf-8") as stream:
                stream.write(encoded)
        print(encoded, end="")
    
    
    def main():
        parser = argparse.ArgumentParser(description=__doc__)
        parser.add_argument(
            "--codex", type=Path, help="Absolute path to a Codex executable"
        )
        parser.add_argument("--threads", type=int, default=3)
        parser.add_argument("--hold-seconds", type=float, default=65)
        parser.add_argument("--output", type=Path)
        parser.add_argument("--probe", type=Path, help=argparse.SUPPRESS)
        arguments = parser.parse_args()
        if arguments.probe:
            probe(arguments.probe)
            return
        if os.name != "posix":
            parser.error("This process-liveness check requires macOS or Linux")
        if (
            not arguments.codex
            or not arguments.codex.is_absolute()
            or not arguments.codex.is_file()
        ):
            parser.error("--codex must name an existing absolute executable path")
        if not 1 <= arguments.threads <= 20:
            parser.error("--threads must be between 1 and 20")
        if not 0 <= arguments.hold_seconds <= 1900:
            parser.error("--hold-seconds must be between 0 and 1900")
        if arguments.output and arguments.output.exists():
            parser.error("The output file must not already exist")
        asyncio.run(reproduce(arguments))
    
    
    if __name__ == "__main__":
        main()
    Python harness cancellation and failure checks: test_reproduce.py
    """Check harness cancellation and failure against an actual Codex executable."""
    
    import argparse
    import asyncio
    import os
    from pathlib import Path
    import subprocess
    import sys
    import tempfile
    import time
    import unittest
    
    import reproduce
    
    
    CODEX_BINARY = None
    
    
    class HarnessTests(unittest.IsolatedAsyncioTestCase):
        async def test_cancellation_releases_initialized_probes(self):
            with tempfile.TemporaryDirectory(prefix="codex-repro-test-") as directory:
                original_tempdir = tempfile.tempdir
                tempfile.tempdir = directory
                task = asyncio.create_task(reproduce.run_case(CODEX_BINARY, 3, 65, False))
                try:
                    pids = []
                    deadline = time.monotonic() + 30
                    while time.monotonic() < deadline:
                        for probe_directory in Path(directory).glob("*/probe-events"):
                            initialized, pids = reproduce.probe_inventory(probe_directory)
                            if initialized == 3 and len(pids) == 3:
                                break
                        if len(pids) == 3:
                            break
                        if task.done():
                            await task
                            self.fail("The test completed before cancellation")
                        await asyncio.sleep(0.1)
                    self.assertEqual(len(pids), 3)
                    task.cancel()
                    with self.assertRaises(asyncio.CancelledError):
                        await task
                    for pid in pids:
                        with self.assertRaises(ProcessLookupError):
                            os.kill(pid, 0)
                    self.assertEqual(list(Path(directory).iterdir()), [])
                finally:
                    if not task.done():
                        task.cancel()
                        try:
                            await task
                        except asyncio.CancelledError:
                            pass
                    tempfile.tempdir = original_tempdir
    
        def test_wrong_backend_exits_without_success_output(self):
            completed = subprocess.run(
                [
                    sys.executable,
                    str(Path(reproduce.__file__).resolve()),
                    "--codex",
                    "/usr/bin/true",
                ],
                capture_output=True,
                text=True,
                timeout=30,
            )
            self.assertNotEqual(completed.returncode, 0)
            self.assertEqual(completed.stdout, "")
    
    
    if __name__ == "__main__":
        parser = argparse.ArgumentParser(description=__doc__)
        parser.add_argument("--codex", type=Path, required=True)
        arguments = parser.parse_args()
        CODEX_BINARY = arguments.codex
        if not CODEX_BINARY.is_absolute() or not CODEX_BINARY.is_file():
            parser.error("--codex must name an existing absolute executable path")
        unittest.main(argv=[sys.argv[0]], verbosity=2)
    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:

    5d2574fcd5d8b5810cd07c2101d54210f5a851282a0a10cac03248520eb625a0  subscriber-probe.mjs
    6f178c54c7161547ded802928cb7d994fb3c4c2ecc244fcd3d726192789f6301  reproduce.py
    28f9cbd1e5ad5179947a7959759ff8fc0879d537accfcde6c91357642202f6b0  test_reproduce.py
    dd16d33b180bb4f99caec22148f2515dbfac8bdee98ba320e0da673e47e653d9  portable-results.json
    68be770858d736c4b81ab655b9840371a741ea881e8b125f42c060d4177556bd  lifecycle-results.json
    
  9. 0xdevalias commented on Oct 4, 2026

    @0xdevalias

    I did not collect command arguments, environment values, credentials, prompts, or tool payloads.

    The most useful additional event links each native MCP process PID to its owner thread, source, parent, and server registration.

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

    There’s a useful distinction:

    • task-launched subprocesses expose CODEX_THREAD_ID and CODEX_SESSION_ID, but the app-server’s direct MCP and REPL helpers generally do not.
    • The UUID in CODEX_APP_TOOLS_PIPE_PATH is shared across many helpers, so it doesn’t identify a thread.
    • REPL working directories provide another clue.
    • Open rollout files and thread-writer locks give stronger evidence than inherited environment variables: they show which threads a nested app-server actually hosts.
    • This also exposes an attribution trap: a server’s CODEX_THREAD_ID can name the task that launched it, while its open rollout files identify the successor task it runs.
    • I found another useful clue in the app-tools code: it receives thread and turn IDs in each tool request’s metadata, rather than in its launch environment.
  10. 0xdevalias commented on Oct 4, 2026

    @0xdevalias

    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.queryObjects for existing client instances → the client's sendInternalRequest over its initialized stdio transport. thread/loaded/list, metadata-only thread/read, and server/diagnostics provided the actual registry, status/source and live gauges. thread/loaded/list lists 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 of thread_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.

  11. Essatt commented on Oct 6, 2026

    @Essatt

    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.sqlite opened with mode=ro; no process killed.

    • Last 24 h: 65 distinct thread_ids built an MCP tool catalog (codex_mcp::connection_manager::tool_catalog). Only 44 have an op.dispatch.shutdown record, a gap of 21 threads.
    • In the same window, log lines matching no subscribers and is idle: 0. Over the prior ~10 days: 1, against several hundred shutdown dispatches.
    • About an hour after an app relaunch, the app-server already had 74 descendants: 4 each of the 6 stdio servers in 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):

    • Idle unload does tear down the pool: thread_lifecycle.rs wait_for_thread_shutdown → Op::Shutdown → mcp_runtime.shutdown() → process-group terminate/kill.
    • But the unload target is 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.
    • In our data the leaked set looks like threads that never lose their last subscriber, rather than threads whose shutdown fails.
    • Lowering thread_unload_delay_secs therefore can't help, and role config_files can't shrink a subagent's MCP set (role_tests.rs asserts mcp_servers are inherited).

    Two asks that would fix it from our side:

    1. Send Op::Shutdown (or drop the subscriber) when a subagent reaches a terminal AgentStatus.
    2. Let agent roles opt out of, or narrow, the inherited 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.

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) serversperformance

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions