diff --git a/apps/macos/Sources/Clawdis/GatewayProcessManager.swift b/apps/macos/Sources/Clawdis/GatewayProcessManager.swift index 6dd64ed29..376c89caf 100644 --- a/apps/macos/Sources/Clawdis/GatewayProcessManager.swift +++ b/apps/macos/Sources/Clawdis/GatewayProcessManager.swift @@ -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 diff --git a/src/cli/program.ts b/src/cli/program.ts index 2b2f0d963..301de269b 100644 --- a/src/cli/program.ts +++ b/src/cli/program.ts @@ -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); } diff --git a/src/gateway/server.ts b/src/gateway/server.ts index 96b99b818..bca925000 100644 --- a/src/gateway/server.ts +++ b/src/gateway/server.ts @@ -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 { + 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 { return { close: async () => { + await releaseLock(); providerAbort.abort(); broadcast("shutdown", { reason: "gateway stopping",