fix: enforce gateway single instance

This commit is contained in:
Peter Steinberger
2025-12-09 19:40:01 +00:00
parent 6329f60dff
commit 5df438fd2a
3 changed files with 38 additions and 0 deletions

View File

@@ -1,6 +1,7 @@
import Foundation
import OSLog
import Subprocess
import Network
#if canImport(Darwin)
import Darwin
#endif
@@ -153,6 +154,10 @@ final class GatewayProcessManager: ObservableObject {
self.appendLog("[gateway] starting: \(command.joined(separator: " ")) (cwd: \(cwd))\n")
do {
// Acquire the same UDS lock the CLI uses to guarantee a single instance.
let lockPath = FileManager.default.temporaryDirectory.appendingPathComponent("clawdis-gateway.lock").path
let listener = try self.acquireGatewayLock(path: lockPath)
let result = try await run(
.name(command.first ?? "clawdis"),
arguments: Arguments(Array(command.dropFirst())),
@@ -168,12 +173,31 @@ final class GatewayProcessManager: ObservableObject {
await err
}
// Release the lock after the process exits.
listener.cancel()
await self.handleTermination(status: result.terminationStatus)
} catch {
await self.handleError(error)
}
}
/// Minimal clone of the Node gateway lock: bind a UDS and return the listener.
private func acquireGatewayLock(path: String) throws -> NWListener {
// Remove stale socket if needed
try? FileManager.default.removeItem(atPath: path)
let params = NWParameters.tcp
params.allowLocalEndpointReuse = false
let endpoint = NWEndpoint.unix(path: path)
let listener = try NWListener(using: params, on: endpoint)
listener.newConnectionHandler = { connection in
// Any new connection indicates another starter; reject.
connection.cancel()
}
listener.start(queue: .global())
return listener
}
private func didStart(_ execution: Execution) {
self.execution = execution
self.stopping = false

View File

@@ -10,6 +10,7 @@ import { statusCommand } from "../commands/status.js";
import { callGateway, randomIdempotencyKey } from "../gateway/call.js";
import { startGatewayServer } from "../gateway/server.js";
import { danger, info, setVerbose } from "../globals.js";
import { GatewayLockError } from "../infra/gateway-lock.js";
import { loginWeb, logoutWeb } from "../provider-web.js";
import { runRpcLoop } from "../rpc/loop.js";
import { defaultRuntime } from "../runtime.js";
@@ -325,6 +326,11 @@ Examples:
try {
await startGatewayServer(port);
} catch (err) {
if (err instanceof GatewayLockError) {
defaultRuntime.error(`Gateway failed to start: ${err.message}`);
defaultRuntime.exit(1);
return;
}
defaultRuntime.error(`Gateway failed to start: ${String(err)}`);
defaultRuntime.exit(1);
}

View File

@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto";
import os from "node:os";
import { type WebSocket, WebSocketServer } from "ws";
import { GatewayLockError, acquireGatewayLock } from "../infra/gateway-lock.js";
import { createDefaultDeps } from "../cli/deps.js";
import { agentCommand } from "../commands/agent.js";
import { getHealthSnapshot } from "../commands/health.js";
@@ -102,6 +103,12 @@ function formatError(err: unknown): string {
}
export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
const releaseLock = await acquireGatewayLock().catch((err) => {
// Bubble known lock errors so callers can present a nice message.
if (err instanceof GatewayLockError) throw err;
throw new GatewayLockError(String(err));
});
const wss = new WebSocketServer({
port,
host: "127.0.0.1",
@@ -623,6 +630,7 @@ export async function startGatewayServer(port = 18789): Promise<GatewayServer> {
return {
close: async () => {
await releaseLock();
providerAbort.abort();
broadcast("shutdown", {
reason: "gateway stopping",