Repository navigation
Stop the detached blitz-iphone MCP server when its client exits - #31
Open
r0ck37c0d3r wants to merge 1 commit into
Open
r0ck37c0d3r wants to merge 1 commit into
r0ck37c0d3r wants to merge 1 commit into
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Each agent session that ends without signalling
blitz-detached-mcpleaks one@blitzdev/iphone-mcpserver. 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,
lsofshowed 50iphone-mcpprocesses listening on*:5150–*:5200. Each answeredGET /with the "blitz-iphone-mcp viewer" page, reachable from any device on the same Wi-Fi.blitz-detached-mcpwrapperlaunchd(PID 1): the original client was goneapp-serverprocessesclaudeprocessesEach leaked pair was a
blitz-detached-mcpwrapper adopted by launchd plus itsnode …/iphone-mcpchild. Nothing would ever stop them short of a reboot.Root cause
Two behaviours combine.
The launcher doesn't notice its client leaving.
blitz-detached-mcpforks and the child runsos.setsid()beforeexecing the server. The parent installs SIGTERM/SIGINT/SIGHUP forwarders and blocks inos.waitpid(child_pid, 0). That covers a client that shuts down politely by signalling the launcher. Other exits are not covered: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.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-mcp0.1.18 does not: I started it with</dev/nulland 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:
stop_serversends 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.128 + signalfor a signalled server.ensureDetachedMCPLauncher()already rewrites the script each time it runs, so existing installs pick up the new launcher after updating Blitz.Alternatives considered
select()for readability can't tell EOF from pending data without reading.kqueuewithEVFILT_PROC/NOTE_EXITon 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-secondgetppid()is effectively free and easy to reason about; I'm happy to switch if you prefer.PR_SET_PDEATHSIG. Linux only.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.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 thenpxfallback with an older package version.Edge cases
execand the launcher's firstgetppid(). 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.WNOHANGreap handles it on the next tick, and the launcher returns its exit status as before.Testing
I extracted the generated launcher script from the Swift source and ran it directly:
sh) starts the launcher with a stand-in server, then exits normallykill -9iphone-mcp0.1.18 under the launcher, client exitsnodeboth gone within 4 sThis also adds
detachedLauncherStopsServerWhenClientExitstoProjectAgentConfigServiceTests. It drives the same orphaned-client scenario through the launcher thatensureMCPConfigwrites.The test target doesn't compile on
master, independent of this change. 9628996 added initializer arguments (ASCApp.relationships,iconURL,iconAssetToken) without updatingASCProjectLifecycleTests,ASCScreenshotsLocaleRegressionTests,ASCVersionSelectionTestsorDashboardSummaryStoreTests, which gives 14 errors. The new test has therefore not run underswift 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:
Suggested follow-ups in
blitzdotdev/iPhone-mcpdist/viewer/server.jscallslisten(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.