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)