mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
H5 远程访问的三处不稳定来源修复,让手机出门在外也能稳定连接、长任务不丢。 #767 令牌与端口固定: - 令牌明文持久化到 cc-haha/settings.json,重启后二维码/令牌随时可查、可复制; enable 复用现有令牌、disable 保留令牌,仅 regenerate 才轮换。手改 token 字段 即自定义固定令牌。完整令牌只经 local-trusted 面返回,远端 403。 - 新增可选固定端口 fixedPort,并在未配置时复用上次端口(desktop-server-state.json sticky,Electron/Tauri 双壳共享),反向代理/手机书签跨重启不失效;占用时回退随机。 #764 断连不杀正在运行的 CLI: - 客户端断开时若该会话仍在跑一轮任务,不再 30s 后强杀子进程,而是等任务跑完; 手机锁屏/切后台时长任务在后台跑完,重连即见结果。 - 空闲清理超时改为可配 disconnectGraceSeconds(H5 访问设置页,默认 30s), 经 disconnectGraceConfig 同步缓存供 close 处理读取。 server / Electron / Tauri / React 四层 + 五语言 i18n + 配套单测全部覆盖。
248 lines
9.2 KiB
TypeScript
248 lines
9.2 KiB
TypeScript
import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'
|
|
import type { ServerWebSocket } from 'bun'
|
|
import {
|
|
__markPrewarmPendingForTests,
|
|
__markActiveTurnForTests,
|
|
__resetWebSocketHandlerStateForTests,
|
|
closeSessionConnection,
|
|
getActiveSessionIds,
|
|
handleWebSocket,
|
|
type WebSocketData,
|
|
} from '../ws/handler.js'
|
|
import {
|
|
__resetDisconnectGraceMsForTests,
|
|
__setDisconnectGraceMsForTests,
|
|
} from '../ws/disconnectGraceConfig.js'
|
|
import { conversationService } from '../services/conversationService.js'
|
|
import { computerUseApprovalService } from '../services/computerUseApprovalService.js'
|
|
|
|
function makeClientSocket(sessionId: string) {
|
|
const sent: string[] = []
|
|
return {
|
|
data: {
|
|
sessionId,
|
|
connectedAt: Date.now(),
|
|
channel: 'client',
|
|
sdkToken: null,
|
|
serverPort: 0,
|
|
serverHost: '127.0.0.1',
|
|
},
|
|
send: mock((payload: string) => {
|
|
sent.push(payload)
|
|
}),
|
|
close: mock(() => {}),
|
|
sent,
|
|
} as unknown as ServerWebSocket<WebSocketData> & { sent: string[] }
|
|
}
|
|
|
|
describe('WebSocket handler session isolation', () => {
|
|
afterEach(() => {
|
|
__resetWebSocketHandlerStateForTests()
|
|
__resetDisconnectGraceMsForTests()
|
|
mock.restore()
|
|
})
|
|
|
|
it('ignores stale disconnects from an older socket for the same session', () => {
|
|
const sessionId = `duplicate-${crypto.randomUUID()}`
|
|
const first = makeClientSocket(sessionId)
|
|
const second = makeClientSocket(sessionId)
|
|
const clearCallbacks = spyOn(conversationService, 'clearOutputCallbacks')
|
|
const cancelComputerUse = spyOn(computerUseApprovalService, 'cancelSession')
|
|
|
|
handleWebSocket.open(first)
|
|
handleWebSocket.open(second)
|
|
clearCallbacks.mockClear()
|
|
cancelComputerUse.mockClear()
|
|
|
|
handleWebSocket.close(first, 1000, 'stale tab closed')
|
|
|
|
expect(getActiveSessionIds()).toContain(sessionId)
|
|
expect(clearCallbacks).not.toHaveBeenCalled()
|
|
expect(cancelComputerUse).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('closes and removes an active client socket when a session is deleted', () => {
|
|
const sessionId = `delete-${crypto.randomUUID()}`
|
|
const ws = makeClientSocket(sessionId)
|
|
const clearCallbacks = spyOn(conversationService, 'clearOutputCallbacks')
|
|
const cancelComputerUse = spyOn(computerUseApprovalService, 'cancelSession')
|
|
|
|
handleWebSocket.open(ws)
|
|
|
|
expect(closeSessionConnection(sessionId, 'session deleted')).toBe(true)
|
|
|
|
expect(getActiveSessionIds()).not.toContain(sessionId)
|
|
expect(ws.close).toHaveBeenCalledWith(1000, 'session deleted')
|
|
expect(clearCallbacks).toHaveBeenCalledWith(sessionId)
|
|
expect(cancelComputerUse).toHaveBeenCalledWith(sessionId)
|
|
})
|
|
|
|
it('replays pending permission requests when a client reconnects', () => {
|
|
const sessionId = `permission-reconnect-${crypto.randomUUID()}`
|
|
const ws = makeClientSocket(sessionId)
|
|
spyOn(conversationService, 'hasSession').mockReturnValue(true)
|
|
spyOn(conversationService, 'onOutput').mockImplementation(() => {})
|
|
spyOn(conversationService, 'removeOutputCallback').mockImplementation(() => {})
|
|
spyOn(conversationService, 'getPendingPermissionRequests').mockReturnValue([
|
|
{
|
|
requestId: 'request-ask-1',
|
|
toolName: 'AskUserQuestion',
|
|
toolUseId: 'tool-ask-1',
|
|
input: {
|
|
questions: [
|
|
{
|
|
header: 'Scope',
|
|
question: 'Which scope?',
|
|
options: [{ label: 'A', description: 'First' }, { label: 'B', description: 'Second' }],
|
|
},
|
|
],
|
|
},
|
|
description: 'Answer questions?',
|
|
},
|
|
])
|
|
|
|
handleWebSocket.open(ws)
|
|
|
|
expect(ws.sent.map((payload) => JSON.parse(payload))).toContainEqual({
|
|
type: 'permission_request',
|
|
requestId: 'request-ask-1',
|
|
toolName: 'AskUserQuestion',
|
|
toolUseId: 'tool-ask-1',
|
|
input: {
|
|
questions: [
|
|
{
|
|
header: 'Scope',
|
|
question: 'Which scope?',
|
|
options: [{ label: 'A', description: 'First' }, { label: 'B', description: 'Second' }],
|
|
},
|
|
],
|
|
},
|
|
description: 'Answer questions?',
|
|
})
|
|
})
|
|
|
|
it('keeps disconnected sessions alive longer while user input is pending', () => {
|
|
const sessionId = `permission-disconnect-${crypto.randomUUID()}`
|
|
const ws = makeClientSocket(sessionId)
|
|
const setTimeoutSpy = spyOn(globalThis, 'setTimeout').mockImplementation(() => 0 as any)
|
|
spyOn(conversationService, 'getPendingPermissionRequests').mockReturnValue([
|
|
{
|
|
requestId: 'request-ask-1',
|
|
toolName: 'AskUserQuestion',
|
|
toolUseId: 'tool-ask-1',
|
|
input: { questions: [] },
|
|
},
|
|
])
|
|
|
|
handleWebSocket.open(ws)
|
|
setTimeoutSpy.mockClear()
|
|
|
|
handleWebSocket.close(ws, 1006, 'renderer reconnecting')
|
|
|
|
expect(setTimeoutSpy).toHaveBeenCalled()
|
|
expect(setTimeoutSpy.mock.calls[0]?.[1]).toBeGreaterThan(30_000)
|
|
})
|
|
|
|
it('does not forward prewarm startup status to a reconnecting client', async () => {
|
|
const sessionId = `prewarm-reconnect-${crypto.randomUUID()}`
|
|
const second = makeClientSocket(sessionId)
|
|
let outputCallback: ((cliMsg: any) => void) | null = null
|
|
|
|
__markPrewarmPendingForTests(sessionId)
|
|
spyOn(conversationService, 'hasSession').mockReturnValue(true)
|
|
spyOn(conversationService, 'getRecentSdkMessages').mockReturnValue([])
|
|
spyOn(conversationService, 'onOutput').mockImplementation((_sid, callback) => {
|
|
outputCallback = callback
|
|
})
|
|
spyOn(conversationService, 'removeOutputCallback').mockImplementation(() => {})
|
|
spyOn(conversationService, 'clearOutputCallbacks').mockImplementation(() => {
|
|
outputCallback = null
|
|
})
|
|
|
|
handleWebSocket.open(second)
|
|
outputCallback?.({
|
|
type: 'stream_event',
|
|
event: { type: 'message_start' },
|
|
})
|
|
|
|
const secondMessages = second.sent.map((payload) => JSON.parse(payload))
|
|
expect(secondMessages).not.toContainEqual({ type: 'status', state: 'thinking' })
|
|
})
|
|
|
|
it('keeps a running session alive on disconnect and cleans up only after the turn finishes (issue #764)', () => {
|
|
const sessionId = `running-disconnect-${crypto.randomUUID()}`
|
|
const ws = makeClientSocket(sessionId)
|
|
const setTimeoutSpy = spyOn(globalThis, 'setTimeout')
|
|
const stopSession = spyOn(conversationService, 'stopSession').mockImplementation(() => {})
|
|
spyOn(conversationService, 'getPendingPermissionRequests').mockReturnValue([])
|
|
|
|
let turnCompleteCallback: ((cliMsg: any) => void) | null = null
|
|
spyOn(conversationService, 'onOutput').mockImplementation((_sid, cb) => {
|
|
turnCompleteCallback = cb
|
|
})
|
|
spyOn(conversationService, 'removeOutputCallback').mockImplementation(() => {})
|
|
|
|
handleWebSocket.open(ws)
|
|
__markActiveTurnForTests(sessionId)
|
|
setTimeoutSpy.mockClear()
|
|
|
|
// Last client disconnects while the turn is still running: no kill timer,
|
|
// just a turn-completion watcher.
|
|
handleWebSocket.close(ws, 1006, 'phone locked screen')
|
|
expect(setTimeoutSpy).not.toHaveBeenCalled()
|
|
expect(stopSession).not.toHaveBeenCalled()
|
|
expect(turnCompleteCallback).not.toBeNull()
|
|
|
|
// Turn finishes while still disconnected → now the idle grace timer starts.
|
|
turnCompleteCallback?.({ type: 'result', subtype: 'success' })
|
|
expect(setTimeoutSpy).toHaveBeenCalled()
|
|
// Timer body still hasn't run, so the process is not killed yet.
|
|
expect(stopSession).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('uses the configured disconnect grace period for an idle session', () => {
|
|
const sessionId = `idle-disconnect-${crypto.randomUUID()}`
|
|
const ws = makeClientSocket(sessionId)
|
|
__setDisconnectGraceMsForTests(120_000)
|
|
const setTimeoutSpy = spyOn(globalThis, 'setTimeout').mockImplementation(() => 0 as any)
|
|
spyOn(conversationService, 'getPendingPermissionRequests').mockReturnValue([])
|
|
|
|
handleWebSocket.open(ws)
|
|
setTimeoutSpy.mockClear()
|
|
|
|
handleWebSocket.close(ws, 1006, 'tab closed')
|
|
|
|
expect(setTimeoutSpy).toHaveBeenCalled()
|
|
expect(setTimeoutSpy.mock.calls[0]?.[1]).toBe(120_000)
|
|
})
|
|
|
|
it('does not start the idle timer if the client reconnects before the turn finishes', () => {
|
|
const sessionId = `reconnect-mid-turn-${crypto.randomUUID()}`
|
|
const ws = makeClientSocket(sessionId)
|
|
const reconnected = makeClientSocket(sessionId)
|
|
const setTimeoutSpy = spyOn(globalThis, 'setTimeout')
|
|
spyOn(conversationService, 'getPendingPermissionRequests').mockReturnValue([])
|
|
spyOn(conversationService, 'hasSession').mockReturnValue(true)
|
|
|
|
let turnCompleteCallback: ((cliMsg: any) => void) | null = null
|
|
spyOn(conversationService, 'onOutput').mockImplementation((_sid, cb) => {
|
|
turnCompleteCallback = cb
|
|
})
|
|
const removeOutputCallback = spyOn(conversationService, 'removeOutputCallback').mockImplementation(() => {})
|
|
|
|
handleWebSocket.open(ws)
|
|
__markActiveTurnForTests(sessionId)
|
|
handleWebSocket.close(ws, 1006, 'phone locked screen')
|
|
expect(turnCompleteCallback).not.toBeNull()
|
|
|
|
// Reconnect tears down the watcher before the turn completes.
|
|
handleWebSocket.open(reconnected)
|
|
expect(removeOutputCallback).toHaveBeenCalled()
|
|
setTimeoutSpy.mockClear()
|
|
|
|
// A late result must not schedule cleanup now that a client is back.
|
|
turnCompleteCallback?.({ type: 'result', subtype: 'success' })
|
|
expect(setTimeoutSpy).not.toHaveBeenCalled()
|
|
})
|
|
})
|