Skip to content

Stop the detached blitz-iphone MCP server when its client exits - #31

Open
r0ck37c0d3r wants to merge 1 commit into
blitzdotdev:masterfrom
r0ck37c0d3r:master
Open

r0ck37c0d3r wants to merge 1 commit into
blitzdotdev:masterfrom
r0ck37c0d3r:master

Conversation

@r0ck37c0d3r

Copy link
Copy Markdown

Summary

Each agent session that ends without signalling blitz-detached-mcp leaks one @blitzdev/iphone-mcp server. Every leaked copy keeps its viewer port open on all network interfaces. This PR makes the launcher notice when its MCP client has gone and stop the server.

What I observed

On a Mac that runs several Claude Code and Codex sessions per project, lsof showed 50 iphone-mcp processes listening on *:5150–*:5200. Each answered GET / with the "blitz-iphone-mcp viewer" page, reachable from any device on the same Wi-Fi.

Count Parent of the blitz-detached-mcp wrapper Started
Leaked 46 launchd (PID 1): the original client was gone 1 Sep – 14 Oct
Leaked through leaked parents 4 orphaned Codex app-server processes 1–13 Oct
Legitimate 3 live claude processes that day

Each leaked pair was a blitz-detached-mcp wrapper adopted by launchd plus its node …/iphone-mcp child. Nothing would ever stop them short of a reboot.

Root cause

Two behaviours combine.

  1. The launcher doesn't notice its client leaving. blitz-detached-mcp forks and the child runs os.setsid() before execing the server. The parent installs SIGTERM/SIGINT/SIGHUP forwarders and blocks in os.waitpid(child_pid, 0). That covers a client that shuts down politely by signalling the launcher. Other exits are not covered:

    • The client is killed with SIGKILL, or crashes.
    • Its terminal or tab closes and the hang-up isn't delivered to the launcher.
    • A wrapper above it (such as the Codex plugin's app-server broker) dies first.

    In all of these the launcher is reparented to launchd and waits forever. Because of setsid(), the server is outside the client's process group and session, so nothing aimed at the client's group reaches it either.

  2. The server doesn't exit when its stdin closes. Stdio MCP servers normally exit on stdin EOF, and that alone would have prevented the leak. iphone-mcp 0.1.18 does not: I started it with </dev/null and it was still running 6 seconds later. Its HTTP viewer keeps Node's event loop alive.

The fix

The launcher records its parent PID (the MCP client) before forking. Its wait loop becomes a poll:

while True:
    pid, status = os.waitpid(child_pid, os.WNOHANG)
    if pid == child_pid:
        break                      # server exited: pass its status through, as before
    if os.getppid() != client_pid:
        stop_server(child_pid)     # client is gone: we've been reparented
        return 0
    time.sleep(1)

stop_server sends SIGTERM to the server's process group. The child is a session leader, so its PID is its PGID, and the signal also reaches any helpers the server spawned that stayed in its group. It then waits up to 5 seconds for the server to exit and reaps it, and sends SIGKILL to the group if it's still alive.

Unchanged:

  • setsid(). I couldn't find why it was added: it arrived in 01ec953 with the embedded terminal work, presumably to keep the server out of the PTY's job-control signals. So I kept it.
  • Forwarding of SIGTERM, SIGINT and SIGHUP from the launcher to the server.
  • Exit-code passthrough, including 128 + signal for a signalled server.
  • The Swift side. ensureDetachedMCPLauncher() already rewrites the script each time it runs, so existing installs pick up the new launcher after updating Blitz.

Alternatives considered

  • Have the launcher watch stdin for EOF. The launcher shares stdin with the server. Reading it would take bytes meant for the server, and select() for readability can't tell EOF from pending data without reading.
  • kqueue with EVFILT_PROC/NOTE_EXIT on the client PID. This would react the moment the client exits, with no polling. It's more code and needs care when the client is already gone by the time the watch is registered. A once-a-second getppid() is effectively free and easy to reason about; I'm happy to switch if you prefer.
  • PR_SET_PDEATHSIG. Linux only.
  • Drop setsid(). That might let the server receive the client's process-group signals. But it doesn't help when the client is SIGKILLed, which signals no one, and it could undo whatever the detach was added for.
  • Fix only iphone-mcp. Worth doing too (see below), but the launcher is the process that knows its client is gone. Fixing it also covers a server that is wedged, or one started through the npx fallback with an older package version.

Edge cases

  • Client exits between exec and the launcher's first getppid(). The launcher records PID 1 as its client and doesn't detect the exit. That window is microseconds wide, and no MCP client runs under launchd directly.
  • Server exits on its own. The WNOHANG reap handles it on the next tick, and the launcher returns its exit status as before.
  • Shutdown delay. The server stops within about 1 second of the client disappearing, plus up to 5 seconds of grace for SIGTERM.

Testing

I extracted the generated launcher script from the Swift source and ran it directly:

Scenario Result
Client (sh) starts the launcher with a stand-in server, then exits normally server alive while client ran; gone < 1 s after
Same, but the client is killed with kill -9 server alive while client ran; gone < 1 s after
Real iphone-mcp 0.1.18 under the launcher, client exits launcher and node both gone within 4 s
Server exits with status 7 launcher exits 7
SIGTERM sent to the launcher while its client is alive forwarded; launcher exits 143

This also adds detachedLauncherStopsServerWhenClientExits to ProjectAgentConfigServiceTests. It drives the same orphaned-client scenario through the launcher that ensureMCPConfig writes.

The test target doesn't compile on master, independent of this change. 9628996 added initializer arguments (ASCApp.relationships, iconURL, iconAssetToken) without updating ASCProjectLifecycleTests, ASCScreenshotsLocaleRegressionTests, ASCVersionSelectionTests or DashboardSummaryStoreTests, which gives 14 errors. The new test has therefore not run under swift test. The app target builds with this change. I kept those test fixes out of this PR so it stays focused.

Cleaning up servers already leaked

Updating Blitz doesn't stop servers leaked by the old launcher. This stops only the wrappers launchd has adopted, together with their servers:

for p in $(ps -axo pid=,ppid=,command= | awk '$2==1 && /blitz-detached-mcp/ {print $1}'); do
  kill $(pgrep -P "$p") "$p"
done

Suggested follow-ups in blitzdotdev/iPhone-mcp

  1. Bind the viewer to loopback. dist/viewer/server.js calls listen(p, …) with no host, so the viewer is reachable from the local network. Guest Wi-Fi, IoT devices and shared office networks can all see it. listen(p, "127.0.0.1", …) would keep it local. I didn't test what the viewer exposes beyond its landing page.
  2. Exit on stdin EOF, like other stdio MCP servers. That would make the server safe however it's launched.

blitz-detached-mcp starts the server in its own session (setsid) and then
blocks in waitpid. When the MCP client goes away without signalling the
launcher (agent killed, crashed, terminal closed), the launcher is reparented
to launchd and the server never notices: @blitzdev/iphone-mcp does not exit
on stdin EOF. Each such session leaks one server, and each keeps its viewer
port open; one machine had 46 of them.

The launcher now polls for its own reparenting once a second and, when the
client is gone, sends SIGTERM to the server's process group (the server is a
session leader), escalating to SIGKILL after 5 seconds. Signal forwarding and
exit-code passthrough are unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant