diff --git a/src/main/daemon/client.test.ts b/src/main/daemon/client.test.ts index 9261a06ee4a..7ef2a3da11a 100644 --- a/src/main/daemon/client.test.ts +++ b/src/main/daemon/client.test.ts @@ -49,8 +49,10 @@ describe('DaemonClient', () => { function startMockDaemon(opts?: { onControlMessage?: (msg: unknown) => string | null + onHello?: (msg: HelloMessage) => void onStreamHello?: (msg: HelloMessage) => void rejectVersion?: boolean + suppressHelloResponse?: boolean }): Promise { return new Promise((resolve) => { server = createServer((socket) => { @@ -69,6 +71,10 @@ describe('DaemonClient', () => { if (msg.type === 'hello') { const hello = msg as HelloMessage + opts?.onHello?.(hello) + if (opts?.suppressHelloResponse) { + return + } if (opts?.rejectVersion) { socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Version mismatch' })) return @@ -130,6 +136,34 @@ describe('DaemonClient', () => { client = new DaemonClient({ socketPath, tokenPath }) await expect(client.ensureConnected()).rejects.toThrow() }) + + it('times out when the daemon never answers hello', async () => { + let resolveHello: () => void = () => {} + const helloReceived = new Promise((resolve) => { + resolveHello = resolve + }) + await startMockDaemon({ + suppressHelloResponse: true, + onHello: resolveHello + }) + + vi.useFakeTimers() + try { + client = new DaemonClient({ socketPath, tokenPath }) + const outcomePromise = client + .ensureConnected() + .then(() => 'connected') + .catch((error: Error) => error.message) + + await helloReceived + await vi.advanceTimersByTimeAsync(5000) + const outcome = await Promise.race([outcomePromise, Promise.resolve('pending')]) + + expect(outcome).toBe('Hello response timed out') + } finally { + vi.useRealTimers() + } + }) }) describe('RPC', () => { diff --git a/src/main/daemon/client.ts b/src/main/daemon/client.ts index 8f0b1a1ad24..72cbea13dd0 100644 --- a/src/main/daemon/client.ts +++ b/src/main/daemon/client.ts @@ -214,6 +214,29 @@ export class DaemonClient { } let buffer = '' + let settled = false + let timer: ReturnType | null = null + const cleanup = (): void => { + if (timer) { + clearTimeout(timer) + timer = null + } + socket.removeListener('data', onData) + socket.removeListener('error', onError) + socket.removeListener('close', onClose) + } + const finish = (error?: Error): void => { + if (settled) { + return + } + settled = true + cleanup() + if (error) { + reject(error) + return + } + resolve() + } const onData = (chunk: Buffer): void => { buffer += chunk.toString() const newlineIdx = buffer.indexOf('\n') @@ -221,23 +244,33 @@ export class DaemonClient { return } - socket.removeListener('data', onData) const line = buffer.slice(0, newlineIdx) try { const response = JSON.parse(line) as HelloResponse if (response.ok) { - resolve() + finish() } else { - reject( + finish( new DaemonProtocolError(addNodePtyRecoveryHint(response.error ?? 'Hello rejected')) ) } } catch { - reject(new DaemonProtocolError('Invalid hello response')) + finish(new DaemonProtocolError('Invalid hello response')) } } + const onError = (error: Error): void => finish(error) + const onClose = (): void => + finish(new DaemonProtocolError('Connection closed before hello response')) + timer = setTimeout(() => { + // Why: a stale daemon can accept the socket but never answer hello; + // without a handshake timeout, startup waits forever on ensureConnected(). + finish(new DaemonProtocolError('Hello response timed out')) + socket.destroy() + }, CONNECT_TIMEOUT_MS) socket.on('data', onData) + socket.on('error', onError) + socket.on('close', onClose) socket.write(encodeNdjson(hello)) }) }