Skip to content

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

Open

Description

@ARJ999

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

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
    
  2. 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.
  3. Inspect ps --ppid <app-server-pid> — the npm exec chrome-devtools-mcp wrapper is alive.
  4. 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.
  5. 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

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

Activity

  1. added
    bugSomething isn't working
    CLIIssues related to the Codex CLI
    app-serverIssues involving app server protocol or interfaces
    mcpIssues related to the use of model context protocol (MCP) servers
    on May 24, 2026
  2. github-actions commented on May 24, 2026

    @github-actions
    Contributor

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

    Powered by Codex Action

  3. jshaofa-ui commented on May 24, 2026

    @jshaofa-ui

    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
  4. yui-stingray commented on May 24, 2026

    @yui-stingray

    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.

  5. w2jmoe commented on May 27, 2026

    @w2jmoe

    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.

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

    CLIIssues related to the Codex CLIapp-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