From 38477a98454c97af106e1cf56ec1e7d8f5682201 Mon Sep 17 00:00:00 2001 From: r0ck37c0d3r Date: Tue, 20 Oct 2026 21:15:28 +0200 Subject: [PATCH] Stop the detached blitz-iphone MCP server when its client exits 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. --- .../ProjectAgentConfigServiceTests.swift | 36 +++++++++++++++++++ .../project/ProjectAgentConfigService.swift | 31 ++++++++++++++-- 2 files changed, 65 insertions(+), 2 deletions(-) diff --git a/Tests/blitz_tests/ProjectAgentConfigServiceTests.swift b/Tests/blitz_tests/ProjectAgentConfigServiceTests.swift index 5c0432b..d74b4f5 100644 --- a/Tests/blitz_tests/ProjectAgentConfigServiceTests.swift +++ b/Tests/blitz_tests/ProjectAgentConfigServiceTests.swift @@ -30,3 +30,39 @@ import Testing let blitzIphone = try #require(servers["blitz-iphone"] as? [String: Any]) #expect(blitzIphone["command"] as? String == launcherPath) } + +@Test func detachedLauncherStopsServerWhenClientExits() throws { + let tempDirectory = FileManager.default.temporaryDirectory + .appendingPathComponent("blitz-detached-launcher-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: tempDirectory) } + + ProjectAgentConfigService(baseDirectory: tempDirectory).ensureMCPConfig(in: tempDirectory) + let launcherPath = BlitzPaths.bin.appendingPathComponent("blitz-detached-mcp").path + let pidFile = tempDirectory.appendingPathComponent("server.pid") + + // The shell stands in for the MCP client: it starts the launcher with a stand-in server, + // then exits without signalling it, as a killed or crashed agent does. + let client = Process() + client.executableURL = URL(fileURLWithPath: "/bin/sh") + client.arguments = [ + "-c", + "\"$0\" /bin/sh -c 'echo $$ > \"$0\"; exec /bin/sleep 60' \"$1\" & sleep 1", + launcherPath, + pidFile.path, + ] + client.standardOutput = FileHandle.nullDevice + client.standardError = FileHandle.nullDevice + try client.run() + client.waitUntilExit() + + let pidText = try String(contentsOf: pidFile, encoding: .utf8) + let serverPID = try #require(pid_t(pidText.trimmingCharacters(in: .whitespacesAndNewlines))) + defer { kill(serverPID, SIGKILL) } + + let deadline = Date().addingTimeInterval(10) + while kill(serverPID, 0) == 0 && Date() < deadline { + Thread.sleep(forTimeInterval: 0.2) + } + #expect(kill(serverPID, 0) != 0, "server outlived its MCP client") +} diff --git a/src/services/project/ProjectAgentConfigService.swift b/src/services/project/ProjectAgentConfigService.swift index ff31e30..681c3d3 100644 --- a/src/services/project/ProjectAgentConfigService.swift +++ b/src/services/project/ProjectAgentConfigService.swift @@ -51,12 +51,31 @@ struct ProjectAgentConfigService { import os import signal import sys + import time + + def stop_server(child_pid: int) -> None: + # The child is a session leader (setsid), so its pid is also its process group: + # this stops the server and anything it spawned. + try: + os.killpg(child_pid, signal.SIGTERM) + except ProcessLookupError: + return + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if os.waitpid(child_pid, os.WNOHANG)[0] == child_pid: + return + time.sleep(0.1) + try: + os.killpg(child_pid, signal.SIGKILL) + except ProcessLookupError: + pass def main() -> int: if len(sys.argv) < 2: print("usage: blitz-detached-mcp [args...]", file=sys.stderr) return 64 + client_pid = os.getppid() child_pid = os.fork() if child_pid == 0: os.setsid() @@ -71,12 +90,20 @@ struct ProjectAgentConfigService { for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP): signal.signal(sig, forward) + # The MCP client can exit without signalling us (killed, crashed, terminal closed). + # The detached server never sees that, so watch for being reparented and stop it + # rather than leave it running with its viewer port open. while True: try: - _, status = os.waitpid(child_pid, 0) - break + pid, status = os.waitpid(child_pid, os.WNOHANG) except InterruptedError: continue + if pid == child_pid: + break + if os.getppid() != client_pid: + stop_server(child_pid) + return 0 + time.sleep(1) if os.WIFEXITED(status): return os.WEXITSTATUS(status)