Skip to content

App-server broker retains thread subscriptions after task clients disconnect #706

Open

Description

@thossullivan

Summary

The app-server broker shares one upstream connection across plugin tasks.

Each task starts or resumes a thread, which subscribes that upstream connection.

The downstream task socket closes after completion.

The broker clears routing state but never sends thread/unsubscribe.

Completed threads and their MCP runtimes remain loaded until unsubscribe or app-server shutdown.

Observed impact

  • Fresh tasks accumulate MCP runtimes inside one broker session.
  • One app-server retained 11 Basic Memory runtimes.
  • Other app-servers retained seven, five, and five runtimes.
  • Basic Memory only made the generic broker defect visible.
  • The proposed fix contains no Basic Memory dependency.

Reproduction

  1. Configure any stdio MCP server in Codex.
  2. Start Claude Code with the Codex plugin.
  3. Run several fresh Codex tasks in one workspace.
  4. Let every task finish normally.
  5. Inspect the app-server process tree.

Observed result

  • Each fresh task creates another thread and MCP runtime.
  • The downstream task client disconnects.
  • The upstream thread subscription remains active.
  • Completed tasks therefore accumulate MCP processes.

Expected behavior

The broker should track thread ownership for each downstream socket.

It should send thread/unsubscribe after the final owner disconnects.

Cause

thread/start subscribes the shared upstream connection to thread events.

The broker does not associate that subscription with its downstream owner.

Socket closure therefore removes routing state without removing the subscription.

The final unsubscribe starts Codex's documented 30-minute inactivity period.

Codex unloads the thread and MCP runtime after that period.

App-server documentation

Proposed fix

  • Track subscribed threads for each downstream socket.
  • Preserve subscriptions while another downstream owner remains.
  • Cover started, resumed, forked, review, and automatically created child threads.
  • Unsubscribe after normal or abrupt final-owner disconnection.
  • Log upstream unsubscribe failures.
  • Add broker ownership regression tests.

Real-process verification

Tested with Codex CLI 0.150.1 and Basic Memory.

Case Probe after client closure Meaning MCP process result
Control broker unsubscribed The broker retained the subscription Exited approximately 30 minutes after the probe unsubscribed
Patched broker notSubscribed The broker had already unsubscribed Exited approximately 30 minutes after client closure

Both cases started a real Basic Memory wrapper and Python server.

Every isolated test descendant exited during cleanup.

Related work

Those changes bound session lifetime.

This issue addresses per-task thread retention inside a running broker.

Activity

  1. dot-agi commented on Sep 11, 2026

    @dot-agi

    Corroborating this at much larger scale, plus one correction on the unload delay.

    Observed (macOS 26.6, plugin 1.0.6, codex-cli 0.154.0): a single broker for one worktree had been alive for 2d 17h — its owning sessions were SIGKILLed by macOS jetsam, so SessionEnd never ran. Over ~94 codex sessions (rescues + Stop-hook review gates) it accumulated 1,218 processes under one codex app-server:

    517 × npm exec mcp-remote http://localhost:900X/sse
    517 × node …/mcp-remote
     92 × node_repl
     91 × /opt/homebrew/bin/claude      (each a full Claude Code instance)
      1 × codex-code-mode-host
    

    i.e. one full [mcp_servers.*] set per thread, never released. A jetsam snapshot attributed 60.4 GB of footprint to the 957 node processes and 12.3 GB to 80 claude processes; swap was 63.9 / 64.5 GB, and the ~1,000 V8 address spaces pushed kernel page tables (VM_KERN_MEMORY_PTE) to 11.6 GB of wired memory. SIGTERM to the broker released everything (1,969 → 632 processes, swap 62.7 → 15.8 GB).

    The task path and the review path leak the same way — a review holds two threads (the thread/start one plus reviewThreadId from review/start).

    Correction on the 30-minute figure: in codex rust-v0.154.0, thread_unload_delay_secs defaults to 60 s (codex-rs/core/src/config/mod.rs: cfg.thread_unload_delay_secs.unwrap_or(60); the config schema says "Seconds a thread must have no subscribers and no activity before app-server unloads it. Defaults to 60"). So a correct thread/unsubscribe releases the MCP runtime about a minute after the client disconnects. thread/archive is not a substitute for the ephemeral threads used by reviews and non-persisted tasks: it reads the thread from the persisted store and errors for ephemeral: true threads.

    Verified #707 fixes it (details on the PR): A/B against the real codex app-server 0.154.0 — thread/start spawns the 14-process MCP set; after the downstream client disconnects, the unpatched broker still holds all 14 after 150 s, while the patched broker releases them at ~61 s.

  2. youngjin39 commented on Oct 8, 2026

    @youngjin39

    Still reproduces on plugin 1.0.6 + codex-cli 0.159.2 (macOS). Besides memory, a project-scoped stdio MCP server that keeps a SQLite database open was left running 113x under one broker, which tripped that project's open-handle safety gate.

    Workaround until #707 ships, without patching the plugin: the broker forwards any JSON-RPC request from a socket client to its app-server (and answers "Shared Codex broker is busy" while a task runs). An external script can connect to the broker's unix socket (--endpoint unix:... in its argv), initialize, thread/loaded/list, thread/read each id, and send thread/unsubscribe for threads whose status.type is idle and whose recencyAt/updatedAt is old; the app-server unloads them ~60 s later and their MCP servers exit. For a broker whose workspace has no Claude session left (#791, #108), broker/shutdown stops the broker, its app-server and every MCP server cleanly. Verified on a self-started 1.0.6 broker: 6 MCP children -> 0, no orphans. Caveat: an unsubscribed ephemeral thread cannot be resumed.

  3. omarha2023 commented on Oct 8, 2026

    @omarha2023

    Another occurrence, with a measurement that supports the fix in #707.

    Environment: plugin openai-codex/codex 1.0.6, codex-cli 0.161.0, macOS (arm64).

    Observed: every codex-companion.mjs task job opens a thread on the shared broker's codex app-server. Each thread spawns its own set of the user-configured MCP servers, and the set stays alive after the job ends. A failed turn adds a set too. Three minimal jobs added three sets, and none of them exited.

    Controlled check: after the jobs finished, I sent the RPC through the broker endpoint myself (CodexAppServerClient.connect + request):

    • thread/unsubscribe {threadId} returned {"status":"unsubscribed"}. That thread's MCP set exited 30 to 60 s later.
    • thread/archive {threadId} returned {}. That thread's MCP set exited within 30 s.
    • Threads that got neither call kept their sets until the app-server exited.

    thread/loaded/list returns the threads still holding sets, so a reaper can find them. Unsubscribing after each job, as #707 does, is enough, and it keeps the thread resumable.

  4. viktor7ornst commented on Oct 20, 2026

    @viktor7ornst

    Another data point, this time severe enough to crash the Claude desktop app — please consider merging #707.

    Environment: plugin openai-codex/codex 1.0.2 (the reports above show the same on 1.0.6), codex-cli 0.163.0, Claude Code 2.1.303–2.1.304 (desktop app + CLI), macOS on Apple silicon (10 cores). Many Claude Code sessions run in parallel and use the plugin for reviews/rescues throughout the day.

    Observed after ~1.5 days:

    • 25 brokers (app-server-broker.mjs), all idle (0 % CPU), each holding a codex app-server.
    • 3,124 processes in total, of which 1,221 were node; per broker the same set kept repeating: npm exec xcodebuildmcp@latest mcp, npm exec @playwright/mcp@latest, Codex Computer Use client, ChatGPT node_repl, a meetings MCP and other user-configured MCP servers.
    • The worst broker was 33 h old and held 553 processes (~55 finished task/review threads × ~10 MCP processes each).
    • Load average reached ~870, memory ran out, and the Claude desktop app crashed.

    Mitigation: terminating the 17 brokers older than 3 h (SIGTERM to the broker, then its remaining tree) ended 1,863 processes; nothing else was affected and the plugin started fresh brokers on demand. Until #707 ships we run an hourly local reaper that terminates brokers older than 3 h whose process tree used no CPU in the last hour.

    This matches the root cause described here: completed threads are never unsubscribed, so their MCP runtimes stay loaded for the lifetime of the app-server. With several busy sessions the leak grows by roughly ten processes per review and takes the machine down within a day or two, so a release with #707 (or an idle timeout per #543/#108) would be very welcome.

  5. QQism commented on Nov 5, 2026

    @QQism

    Confirming on plugin 1.0.6 with Codex CLI 0.166. One long-lived Claude Code session's broker had loaded 19 task threads over about two days, each with its own set of MCP server processes (about 10 GB RSS in total).

    A client-side workaround works: calling thread/unsubscribe for each thread a task started (the task thread, plus the review thread and any sub-agent threads collected in the turn state) at the end of runAppServerTurn and runAppServerReview in scripts/lib/codex.mjs. In a repro through the broker, two tasks left 20 MCP processes behind without it. With it, all of them were released once the unload delay passed. Setting thread_unload_delay_secs in ~/.codex/config.toml shortens the default 30-minute delay.

    Tracking ownership per downstream socket in the broker, as proposed above, would also cover tasks that crash before they reach the end.

  6. gabssolutions2022-pixel commented on Nov 5, 2026

    @gabssolutions2022-pixel

    Another data point on Windows that matches this issue, with the stop review gate as the trigger.

    Setup

    • Plugin codex 1.0.6 (latest release), codex-cli 0.166.0, Windows 11.
    • stopReviewGate: true in one workspace only. Three stdio MCP servers from ~/.codex/config.toml start per thread: Context7, Playwright, and MarkItDown. That is four processes per thread, because MarkItDown runs as two.

    Observed

    • One broker-owned codex app-server held 144 MCP server processes after about 17 hours in one Claude Code session.
    • The plugin state.json for that workspace lists 36 completed task jobs in that window, all in status completed.
    • Each job's createdAt matches, to the minute, the start time of one new set of MCP servers under the app-server.
    • No set was released while the broker lived. Jobs from an earlier broker left no processes, because that broker had shut down.

    Impact

    • The stop gate runs a task on every Claude Code stop, so a long session in a workspace with the gate on grows by one MCP set per turn.
    • The held processes also lock native files such as Pillow .pyd, which blocks uv sync of the MCP environments until the broker exits.

    Workaround

    • Sending broker/shutdown to the broker endpoint, the same path SessionEnd uses, releases all of them. The next task starts a fresh broker.

    Unsubscribing completed task threads, as proposed in #707, would fix this case.

  7. rugobal commented on Nov 12, 2026

    @rugobal

    Additional Linux confirmation on plugin 1.0.6, with measured cleanup results.

    Environment

    • Arch/Omarchy, Linux 7.2.15, x86_64, 91.93 GiB usable RAM, approximately six days of uptime.
    • OpenAI Codex plugin 1.0.6; installed marketplace checkout db52e28f4d9ded852ab3942cea316258ae4ef346.
    • Broker app-servers used Codex CLI 0.166.0–0.167.2 (mostly 0.167.0).
    • Per-thread stdio helpers included MongoDB MCP, Tauri MCP, computer-use REPL, and Node REPL.

    Before cleanup

    • 47 persistent app-server-broker.mjs serve trees, containing 1,357 processes.
    • 166 loaded threads: 165 idle, one systemError. All matched saved terminal job records: 198 completed jobs and one failed job across those workspaces.
    • No downstream task client was connected during verification. The most recent recorded completion in every tree was over five minutes old.
    • Their descendant trees included 166 MongoDB MCP servers, 166 Tauri MCP servers, 166 computer-use REPL servers, and 332 node_repl processes, plus wrappers and sandbox/code-mode helpers.
    • Combined process PSS: 26.92 GiB, plus 18.16 GiB SwapPss. PSS was used to avoid counting shared pages repeatedly.
    • The largest single broker retained 50 idle review threads / 353 processes, with 7.24 GiB PSS and 3.38 GiB SwapPss, despite all 50 job outputs already being saved.

    Cleanup and results
    After checking saved job receipts, idle/error thread status, and absence of downstream clients, we backed up the completed job records and invoked the existing broker/shutdown RPC for each eligible broker. All 47 shut down, and all 1,357 original processes exited; no surviving descendants were found. Other running Codex/Claude sessions and their helpers were left running.

    System measurement Before After
    RAM used (MemTotal - MemAvailable) 63.57 GiB 30.29 GiB
    RAM available 28.36 GiB 61.64 GiB
    Logical swap used 21.34 GiB 3.19 GiB
    zram physical allocation 5.54 GiB 1.78 GiB

    That recovered 33.28 GiB of available RAM, with 18.16 GiB less logical swap. Swap was zram-backed; the logical swap figures should not be added to resident RAM as physical usage.

    The installed code matches the lifecycle problem described here: downstream client closure clears broker socket ownership, while the shared upstream app-server remains alive without a matching thread/unsubscribe. Review execution also uses ephemeral threads, so persisted job results were checked before disposing of those runtimes.

    This provides a Linux data point for the subscription cleanup proposed in #707, alongside the broker-lifetime problems tracked in #543. A released fix covering completed/failed task and review threads, including abrupt client disconnects, would prevent this accumulation.

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions