From 1d8597fb7b7a3d4ce4293e4c4cb5263c48aa5bc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 17 Jul 2026 22:57:13 +0800 Subject: [PATCH] fix(server): bound late permission waits (#1019) --- .../__tests__/websocket-handler.test.ts | 33 +++++++++++++++++++ src/server/ws/handler.ts | 11 +++++++ 2 files changed, 44 insertions(+) diff --git a/src/server/__tests__/websocket-handler.test.ts b/src/server/__tests__/websocket-handler.test.ts index f2ff62ab..21d194c6 100644 --- a/src/server/__tests__/websocket-handler.test.ts +++ b/src/server/__tests__/websocket-handler.test.ts @@ -430,6 +430,39 @@ describe('WebSocket handler session isolation', () => { expect(stopSession).toHaveBeenCalledWith(sessionId) }) + it('starts the permission cleanup bound when the prompt arrives after disconnect', () => { + const sessionId = `late-permission-disconnect-${crypto.randomUUID()}` + const ws = makeClientSocket(sessionId) + const setTimeoutSpy = spyOn(globalThis, 'setTimeout').mockImplementation(() => 0 as any) + const pendingRequests = spyOn(conversationService, 'getPendingPermissionRequests') + .mockReturnValue([]) + let turnOutputCallback: ((cliMsg: any) => void) | null = null + spyOn(conversationService, 'onOutput').mockImplementation((_sid, callback) => { + turnOutputCallback = callback + }) + spyOn(conversationService, 'removeOutputCallback').mockImplementation(() => {}) + + handleWebSocket.open(ws) + __markActiveTurnForTests(sessionId) + setTimeoutSpy.mockClear() + handleWebSocket.close(ws, 1006, 'renderer closed before permission prompt') + + expect(setTimeoutSpy).not.toHaveBeenCalled() + pendingRequests.mockReturnValue([{ + requestId: 'late-request-1', + toolName: 'Bash', + input: { command: 'echo later' }, + }]) + ;(turnOutputCallback as ((cliMsg: any) => void) | null)?.({ + type: 'control_request', + request_id: 'late-request-1', + request: { subtype: 'can_use_tool', tool_name: 'Bash' }, + }) + + expect(setTimeoutSpy).toHaveBeenCalledTimes(1) + expect(setTimeoutSpy.mock.calls[0]?.[1]).toBe(30 * 60_000) + }) + it('does not forward prewarm startup status to a reconnecting client', async () => { const sessionId = `prewarm-reconnect-${crypto.randomUUID()}` const second = makeClientSocket(sessionId) diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index c9c27d86..b396527b 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -2298,6 +2298,17 @@ function watchTurnCompletionForCleanup(sessionId: string): void { cancelSessionDisconnectWatcher(sessionId) const onComplete = (cliMsg: any) => { + if ( + cliMsg?.type === 'control_request' && + cliMsg.request?.subtype === 'can_use_tool' && + !hasActiveClients(sessionId) + ) { + // The permission request may arrive after the renderer disconnected. + // ConversationService records it before notifying this callback, so the + // cleanup delay resolves to the bounded pending-permission window. + scheduleDisconnectCleanup(sessionId) + return + } if (cliMsg?.type !== 'result') return cancelSessionDisconnectWatcher(sessionId) // The turn finished while still unobserved — fall back to the idle timer.