fix: time out daemon hello handshakes

This commit is contained in:
Neil
2026-05-30 08:18:46 -07:00
parent 26a65588d8
commit e11af2fc93
2 changed files with 71 additions and 4 deletions
+34
View File
@@ -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<void> {
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<void>((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', () => {
+37 -4
View File
@@ -214,6 +214,29 @@ export class DaemonClient {
}
let buffer = ''
let settled = false
let timer: ReturnType<typeof setTimeout> | 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))
})
}