Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions Tests/blitz_tests/ProjectAgentConfigServiceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
31 changes: 29 additions & 2 deletions src/services/project/ProjectAgentConfigService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command> [args...]", file=sys.stderr)
return 64

client_pid = os.getppid()
child_pid = os.fork()
if child_pid == 0:
os.setsid()
Expand All @@ -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)
Expand Down