Recover from daemon hello timeout (#1179)

This commit is contained in:
Jinwoo Hong
2026-05-14 01:55:21 -07:00
committed by GitHub
parent 543d9a6c30
commit 63bd56c770
3 changed files with 27 additions and 3 deletions
+11
View File
@@ -51,6 +51,7 @@ describe('DaemonClient', () => {
onControlMessage?: (msg: unknown) => string | null
onStreamHello?: (msg: HelloMessage) => void
rejectVersion?: boolean
ignoreHello?: boolean
}): Promise<void> {
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', () => {
+12
View File
@@ -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))
})
+4 -3
View File
@@ -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'
}