codex-cli app-server (Linux) leaks chrome-devtools-mcp child trees on JSON-RPC transport close

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

What version of the Codex CLI are you using?

codex-cli 0.132.0 (@openai/codex@0.132.0, npm-installed at /usr/lib/node_modules/@openai/codex/)

What subscription do you have?

Pro

What platform is your computer?

Linux 6.8.0-117-generic x86_64 (Ubuntu 24.04, Hostinger VPS, 32 GB RAM, 8 vCPU)

What issue are you seeing?

codex app-server --listen unix://… does not reap chrome-devtools-mcp (stdio MCP) child process trees when the JSON-RPC transport closes. Each orphan tree is npm exec → sh -c → chrome-devtools-mcp ≈ 270 MB RSS. They accumulate indefinitely until OOM or until external intervention.

Related but distinct issues (different surface, same defect class):

  • #17574 — Codex App on macOS / subagent spawn pattern
  • #17832 — Playwright MCP regression after #16895
  • #14548 — Codex spawning too many MCP instances
  • #21008 — Codex Desktop launchd leak
  • #21984 — Eager MCP start per session

This report is specifically about codex-cli's app-server mode on Linux under the claude-bridge.sh peer-agent pattern (Claude Code invoking codex via Unix-socket JSON-RPC), which none of the above cover.

Observed accumulation

Steady-state on my VPS over a ~3-hour window:

codex app-server PID: 1754 (single instance, uptime ~8 h)
chrome-devtools-mcp wrappers under PID 1754: 4
ages: 11974s, 1236s, 1081s, 1056s   (only the 11974s could plausibly be "active";
                                      the other 3 were spawned during one-shot
                                      `codex exec` runs via codex-cli's bridge mode)
combined RSS: ~1.1 GB

Process tree (one of the four):

1115984  ppid=1754      npm exec chrome-devtools-mcp@latest --no-usage-statistics --no-performance-crux --headless
1116129  ppid=1115984   sh -c chrome-devtools-mcp --no-usage-statistics --no-performance-crux --headless
1116130  ppid=1116129   chrome-devtools-mcp

The wrapper PID's parent is the codex app-server daemon. The MCP server never received a graceful close-on-shutdown signal — once the originating JSON-RPC channel closed (single tool call completed), the wrapper + child remained alive.

Steps to reproduce (deterministic)

  1. Start codex app-server:

``
codex app-server --listen unix:///tmp/codex-app-server.sock
``

  1. From another agent (e.g. Claude Code) over the same socket, fire a tools/call for a chrome-devtools-mcp tool method (we use the bridge wrapper claude-bridge.sh):

``
/usr/local/bin/codex exec -p advisory -C /tmp -s read-only --skip-git-repo-check --ephemeral --json --output-last-message …
`
The bridge spawns a fresh codex worker which talks to the long-lived
app-server` via the socket.

  1. Inspect ps --ppid <app-server-pid> — the npm exec chrome-devtools-mcp wrapper is alive.
  2. The bridge worker exits, the JSON-RPC channel for the chrome MCP closes from the client side, BUT the wrapper remains alive under app-server.
  3. Repeat. Each invocation adds one orphan.

Expected behavior

When the JSON-RPC transport to a stdio MCP child closes (or when its consuming session shuts down), app-server should:

  1. Send SIGTERM to the wrapper's process group.
  2. Wait a bounded grace (e.g. 5 s).
  3. SIGKILL the process group's surviving descendants (collected before SIGTERM, so reparented chromium survives only briefly).

Actual behavior

Wrapper + descendants stay alive indefinitely. Memory leak scales linearly with tool-call count.

Workaround (containment, not a fix)

I deployed a per-host cron reaper that:

  • snapshots codex app-server PIDs + /proc/<pid>/stat[22] start times,
  • enumerates npm exec chrome-devtools-mcp wrappers parented to the snapshot set,
  • keeps the youngest per app-server plus any wrapper younger than 10 min (grace for in-flight calls),
  • SIGTERMs the rest deepest-first; 5 s; SIGKILLs survivors from the SAVED descendant tree.

Source: happy to paste, but it should not be the fix — app-server knows the exact moment a transport closes; a host-side reaper is guessing.

Suggested fix locations

(I have not read the codex-cli source yet, but the surface area implied by the symptom:)

  • The MCP transport-close handler for stdio servers should track child PIDs and tear the process group down explicitly. node:child_process spawn({ detached: false }) puts children in the parent's PG; process.kill(-pgid, 'SIGTERM') works. For detached: true, the spawn site must store pid and call kill(-pid, …) on transport close.
  • A short post-SIGTERM grace plus SIGKILL covers chromium's slow shutdown.
  • Belt-and-suspenders: on app-server shutdown (SIGTERM to itself), iterate all known MCP child PGs and kill them, since a normal exit() of the daemon does not cascade through.

Cross-references

  • #17574 (Codex App / macOS / subagent): same defect class, different platform.
  • #21008 (Codex Desktop / launchd): same defect class on macOS Desktop.
  • #16895 (the prior Playwright MCP fix) and #17832 (regression after it): suggests a shared lifecycle bug that the Playwright-specific fix didn't generalize to other stdio MCPs.

Environment

  • OS: Ubuntu 24.04, kernel 6.8.0-117-generic
  • Node: bundled with codex-cli (@openai/codex-linux-x64)
  • chrome-devtools-mcp version: latest (npm exec chrome-devtools-mcp@latest)
  • Invocation: codex app-server --listen unix:// + peer-agent bridge over the socket
  • Concurrent CCD (Claude Code) sessions on the same host (7 active) — their own MCP children are correctly scoped and do not leak, only the codex app-server side does

View original on GitHub ↗

4 Comments

github-actions[bot] contributor · 3 months ago

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

  • #22992

Powered by Codex Action

jshaofa-ui · 3 months ago

Technical Proposal: Fix MCP Child Process Leak on Transport Close

Service Match: A. MCP 集成服务
Timeline: 2-3 days
Competition: 1 comment

Problem Analysis

When JSON-RPC transport closes, the app-server fails to clean up child processes spawned by chrome-devtools-mcp, causing resource leaks and port conflicts.

Proposed Fix

impl JsonRpcTransport {
    async fn close(&mut self) -> Result<()> {
        // 1. Cancel all in-flight requests
        self.cancel_in_flight().await;
        
        // 2. Get list of MCP server child processes
        let mcp_children = self.mcp_registry.get_all_children().await;
        
        // 3. Gracefully terminate each MCP server
        for (server_id, child) in mcp_children {
            if let Err(e) = self.terminate_mcp_server(&server_id, child).await {
                tracing::warn!(server_id, error = ?e, "Failed to terminate MCP server");
            }
        }
        
        // 4. Clean up registry
        self.mcp_registry.clear().await;
        
        // 5. Close transport
        self.inner.close().await
    }
    
    async fn terminate_mcp_server(&self, server_id: &str, child: &mut Child) -> Result<()> {
        let _ = child.start_kill(); // SIGTERM
        match tokio::time::timeout(Duration::from_secs(5), child.wait()).await {
            Ok(Ok(_)) => Ok(()),
            Ok(Err(e)) => Err(e.into()),
            Err(_) => {
                let _ = child.kill(); // SIGKILL after timeout
                child.wait().await.map_err(Into::into)
            }
        }
    }
}

Deliverables

  • Transport close handler with MCP server cleanup
  • Graceful termination with timeout and SIGKILL fallback
  • Unit tests for process lifecycle
  • Integration test verifying no leaked processes
yui-stingray · 3 months ago

One concrete layer to inspect is the stdio MCP launcher rather than only the app-server connection gate. On current main (9f42c89):

  • codex-rs/app-server/src/lib.rs:902-914 handles TransportEvent::ConnectionClosed by removing the connection state, notifying outbound control, and calling processor.connection_closed(...); this is not itself the direct MCP child cleanup site.
  • That call currently goes through codex-rs/app-server/src/message_processor.rs:709-723: it shuts down the per-connection RPC gate and clears outgoing/fs/exec/thread bookkeeping. The thread side (codex-rs/app-server/src/request_processors/thread_processor.rs:2396-2408) removes the connection subscription and only finalizes stale bookkeeping if the thread was already removed; I do not see it directly submitting a thread/session shutdown there.
  • The stdio MCP path starts at codex-rs/codex-mcp/src/rmcp_client.rs:575-590, then the shared transport close path calls self.process.terminate().await in codex-rs/rmcp-client/src/stdio_server_launcher.rs:128-133.
  • On Unix, the local launcher puts the stdio server wrapper in its own process group (codex-rs/rmcp-client/src/stdio_server_launcher.rs:262-263), and the process-handle Drop path also terminates if close did not already run (:397-405). The executor-backed transport has a similar close-time terminate at codex-rs/rmcp-client/src/executor_process_transport.rs:159-162.
  • Existing cleanup coverage in codex-rs/rmcp-client/tests/process_group_cleanup.rs:98-130 and :133+ checks drop(client) / client.shutdown(), but I do not see a regression test for the app-server JSON-RPC connection-close path described in this report.

So if this reproduces with chrome-devtools-mcp, the gap to verify may be whether app-server JSON-RPC connection close actually reaches the RmcpClient/stdio transport close or drop path for the session that owns the MCP server. The process-group cleanup primitive appears to exist; the uncertain part is whether this app-server close path drives it.

w2jmoe · 3 months ago

This is a really good write-up 👀

The interesting part is that the leak is not coming from model execution itself, but from transport/session lifecycle boundaries.

Once the JSON-RPC channel closes, the runtime seems to lose ownership of the MCP child tree, so the process survives outside the original execution context.

That’s a pretty nasty failure mode for long-running agent systems because the leak scales with tool-call count, not just session duration.

The “youngest-per-server + grace window” reaper workaround is also clever 😊
It basically recreates a missing lifecycle manager externally.

Feels like MCP runtimes may eventually need explicit “capability ownership” tracking:
which session spawned which process tree, who is responsible for teardown, and what happens during transport interruption / reconnect / partial failure.

Otherwise these orphaned execution surfaces become surprisingly hard to reason about once multiple agents/tools/sessions coexist.