From 63bd56c77041101acb4064acf4982253e89100ff Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Thu, 14 May 2026 04:55:21 -0400 Subject: [PATCH] Recover from daemon hello timeout (#1179) --- src/main/daemon/client.test.ts | 11 +++++++++++ src/main/daemon/client.ts | 12 ++++++++++++ src/main/daemon/daemon-pty-adapter.ts | 7 ++++--- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/src/main/daemon/client.test.ts b/src/main/daemon/client.test.ts index ea3ff88bfee..7df7d51738e 100644 --- a/src/main/daemon/client.test.ts +++ b/src/main/daemon/client.test.ts @@ -51,6 +51,7 @@ describe('DaemonClient', () => { onControlMessage?: (msg: unknown) => string | null onStreamHello?: (msg: HelloMessage) => void rejectVersion?: boolean + ignoreHello?: boolean }): Promise { return new Promise((resolve) => { server = createServer((socket) => { @@ -69,6 +70,9 @@ describe('DaemonClient', () => { if (msg.type === 'hello') { const hello = msg as HelloMessage + if (opts?.ignoreHello) { + return + } if (opts?.rejectVersion) { socket.write(encodeNdjson({ type: 'hello', ok: false, error: 'Version mismatch' })) return @@ -112,6 +116,13 @@ describe('DaemonClient', () => { client = new DaemonClient({ socketPath, tokenPath }) await expect(client.ensureConnected()).rejects.toThrow() }) + + it('rejects when the daemon accepts a socket but never answers hello', async () => { + await startMockDaemon({ ignoreHello: true }) + + client = new DaemonClient({ socketPath, tokenPath, handshakeTimeoutMs: 10 }) + await expect(client.ensureConnected()).rejects.toThrow('Hello timed out') + }) }) describe('RPC', () => { diff --git a/src/main/daemon/client.ts b/src/main/daemon/client.ts index 007afd5ee68..f68b48098d0 100644 --- a/src/main/daemon/client.ts +++ b/src/main/daemon/client.ts @@ -7,12 +7,14 @@ import type { HelloMessage, HelloResponse, RpcResponse, DaemonEvent } from './ty import { addNodePtyRecoveryHint } from './node-pty-error-hints' const CONNECT_TIMEOUT_MS = 5000 +const HELLO_TIMEOUT_MS = 5000 const REQUEST_TIMEOUT_MS = 30000 export type DaemonClientOptions = { socketPath: string tokenPath: string protocolVersion?: number + handshakeTimeoutMs?: number } type PendingRequest = { @@ -25,6 +27,7 @@ export class DaemonClient { private socketPath: string private tokenPath: string private protocolVersion: number + private handshakeTimeoutMs: number private clientId = randomUUID() private controlSocket: Socket | null = null @@ -50,6 +53,7 @@ export class DaemonClient { this.socketPath = opts.socketPath this.tokenPath = opts.tokenPath this.protocolVersion = opts.protocolVersion ?? PROTOCOL_VERSION + this.handshakeTimeoutMs = opts.handshakeTimeoutMs ?? HELLO_TIMEOUT_MS } isConnected(): boolean { @@ -215,6 +219,7 @@ export class DaemonClient { } socket.removeListener('data', onData) + clearTimeout(timer) const line = buffer.slice(0, newlineIdx) try { const response = JSON.parse(line) as HelloResponse @@ -230,6 +235,13 @@ export class DaemonClient { } } + // Why: a wedged daemon can accept the socket but never answer hello, + // leaving terminal spawn permanently pending with a blank renderer pane. + const timer = setTimeout(() => { + socket.removeListener('data', onData) + reject(new DaemonProtocolError('Hello timed out')) + }, this.handshakeTimeoutMs) + socket.on('data', onData) socket.write(encodeNdjson(hello)) }) diff --git a/src/main/daemon/daemon-pty-adapter.ts b/src/main/daemon/daemon-pty-adapter.ts index 0570519c1fd..db25971ed8f 100644 --- a/src/main/daemon/daemon-pty-adapter.ts +++ b/src/main/daemon/daemon-pty-adapter.ts @@ -660,8 +660,9 @@ export class DaemonPtyAdapter implements IPtyProvider { // unreachable (daemon died). Checking syscall avoids false positives from // token-file ENOENT (readFileSync), which has no syscall or syscall='open'. // "Connection lost" / "Not connected" mean the daemon died while we had an -// active or stale connection. All indicate the daemon is gone and a respawn -// should be attempted. +// active or stale connection. "Hello timed out" means the daemon accepted +// the socket but did not complete the protocol handshake, which is also a +// wedged-daemon state. All indicate a respawn should be attempted. function isDaemonGoneError(err: unknown): boolean { if (!(err instanceof Error)) { return false @@ -671,5 +672,5 @@ function isDaemonGoneError(err: unknown): boolean { return true } const msg = err.message - return msg === 'Connection lost' || msg === 'Not connected' + return msg === 'Connection lost' || msg === 'Not connected' || msg === 'Hello timed out' }