mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Prevent desktop turns from hanging after CLI exits
A desktop session can pass the startup grace window and then lose its CLI subprocess before a result arrives. Previously that path only deleted the active process record, leaving the WebSocket client in an active turn with no terminal message. The process-exit handler now emits a synthetic CLI error result through the existing translation path so the UI receives both an error and message_complete. The mock SDK fixture can reproduce post-startup exits, and the integration test locks the terminal event behavior. Constraint: The frontend already relies on result is_error translation to emit error plus message_complete. Rejected: Add a separate WebSocket process-exit message type | it would duplicate existing result-error handling and require another frontend state path. Confidence: high Scope-risk: narrow Directive: Keep process-exit failures on the same result-error path unless the client protocol gains a dedicated terminal error event. Tested: bun test src/server/__tests__/conversations.test.ts Tested: cd desktop && bun run lint Tested: agent-browser against http://127.0.0.1:1421 with mock CLI late exit; page showed CLI process exited unexpectedly and composer became editable again
This commit is contained in:
parent
1631c449f6
commit
664c6ebd9f
@ -235,6 +235,29 @@ describe('WebSocket Chat Integration', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function withMockExitAfterFirstUser<T>(
|
||||
delayMs: number | undefined,
|
||||
callback: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const previousDelay = process.env.MOCK_SDK_EXIT_AFTER_FIRST_USER_MS
|
||||
|
||||
if (delayMs && delayMs > 0) {
|
||||
process.env.MOCK_SDK_EXIT_AFTER_FIRST_USER_MS = String(delayMs)
|
||||
} else {
|
||||
delete process.env.MOCK_SDK_EXIT_AFTER_FIRST_USER_MS
|
||||
}
|
||||
|
||||
try {
|
||||
return await callback()
|
||||
} finally {
|
||||
if (previousDelay === undefined) {
|
||||
delete process.env.MOCK_SDK_EXIT_AFTER_FIRST_USER_MS
|
||||
} else {
|
||||
process.env.MOCK_SDK_EXIT_AFTER_FIRST_USER_MS = previousDelay
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runTurn(sessionId: string, content: string, allowError = false): Promise<any[]> {
|
||||
const messages: any[] = []
|
||||
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
||||
@ -277,6 +300,39 @@ describe('WebSocket Chat Integration', () => {
|
||||
return messages
|
||||
}
|
||||
|
||||
async function runTurnUntilComplete(sessionId: string, content: string): Promise<any[]> {
|
||||
const messages: any[] = []
|
||||
const ws = new WebSocket(`${wsUrl}/ws/${sessionId}`)
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = setTimeout(() => {
|
||||
ws.close()
|
||||
reject(new Error(`Timed out waiting for terminal event for session ${sessionId}`))
|
||||
}, 10000)
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
const msg = JSON.parse(e.data as string)
|
||||
messages.push(msg)
|
||||
if (msg.type === 'connected') {
|
||||
ws.send(JSON.stringify({ type: 'user_message', content }))
|
||||
}
|
||||
if (msg.type === 'message_complete') {
|
||||
clearTimeout(timeout)
|
||||
ws.close()
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
clearTimeout(timeout)
|
||||
ws.close()
|
||||
reject(new Error(`WebSocket error for session ${sessionId}`))
|
||||
}
|
||||
})
|
||||
|
||||
return messages
|
||||
}
|
||||
|
||||
async function waitUntil(
|
||||
predicate: () => boolean | Promise<boolean>,
|
||||
label: string,
|
||||
@ -434,6 +490,24 @@ describe('WebSocket Chat Integration', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('should complete the client turn when the CLI exits after startup', async () => {
|
||||
const messages = await withMockExitAfterFirstUser(50, () =>
|
||||
runTurnUntilComplete(`chat-late-exit-${crypto.randomUUID()}`, 'trigger late exit'),
|
||||
)
|
||||
|
||||
expect(
|
||||
messages.some(
|
||||
(m) =>
|
||||
m.type === 'error' &&
|
||||
m.code === 'CLI_ERROR' &&
|
||||
typeof m.message === 'string' &&
|
||||
m.message.includes('CLI process exited unexpectedly'),
|
||||
),
|
||||
).toBe(true)
|
||||
expect(messages.some((m) => m.type === 'message_complete')).toBe(true)
|
||||
expect(messages.at(-1)?.type).toBe('message_complete')
|
||||
}, 15_000)
|
||||
|
||||
it('should handle permission_response without error', async () => {
|
||||
const messages: any[] = []
|
||||
const ws = new WebSocket(`${wsUrl}/ws/chat-test-4`)
|
||||
|
||||
@ -26,7 +26,10 @@ const sdkUrl = getArg('--sdk-url')
|
||||
const sessionId = getArg('--session-id') || crypto.randomUUID()
|
||||
const initMode = process.env.MOCK_SDK_INIT_MODE || 'on_open'
|
||||
const streamDelayMs = Number(process.env.MOCK_SDK_STREAM_DELAY_MS || '0')
|
||||
const exitAfterOpenMs = Number(process.env.MOCK_SDK_EXIT_AFTER_OPEN_MS || '0')
|
||||
const exitAfterFirstUserMs = Number(process.env.MOCK_SDK_EXIT_AFTER_FIRST_USER_MS || '0')
|
||||
let initSent = false
|
||||
let firstUserExitScheduled = false
|
||||
|
||||
if (!sdkUrl) {
|
||||
console.error('Missing --sdk-url')
|
||||
@ -51,6 +54,9 @@ ws.addEventListener('open', () => {
|
||||
if (initMode !== 'on_first_user') {
|
||||
sendInit()
|
||||
}
|
||||
if (exitAfterOpenMs > 0) {
|
||||
setTimeout(() => process.exit(1), exitAfterOpenMs)
|
||||
}
|
||||
})
|
||||
|
||||
ws.addEventListener('message', (event) => {
|
||||
@ -63,6 +69,11 @@ ws.addEventListener('message', (event) => {
|
||||
|
||||
if (parsed.type === 'user') {
|
||||
sendInit()
|
||||
if (exitAfterFirstUserMs > 0 && !firstUserExitScheduled) {
|
||||
firstUserExitScheduled = true
|
||||
setTimeout(() => process.exit(1), exitAfterFirstUserMs)
|
||||
continue
|
||||
}
|
||||
const text = extractUserText(parsed)
|
||||
emit(ws, {
|
||||
type: 'stream_event',
|
||||
|
||||
@ -477,6 +477,17 @@ export class ConversationService {
|
||||
|
||||
const activeSession = this.sessions.get(sessionId)
|
||||
if (activeSession?.proc === proc) {
|
||||
const exitError = this.buildRuntimeExitMessage(sessionId, code)
|
||||
for (const cb of activeSession.outputCallbacks) {
|
||||
cb({
|
||||
type: 'result',
|
||||
subtype: 'error',
|
||||
is_error: true,
|
||||
result: exitError,
|
||||
usage: { input_tokens: 0, output_tokens: 0 },
|
||||
session_id: sessionId,
|
||||
})
|
||||
}
|
||||
this.sessions.delete(sessionId)
|
||||
}
|
||||
}
|
||||
@ -783,6 +794,26 @@ export class ConversationService {
|
||||
)
|
||||
}
|
||||
|
||||
private buildRuntimeExitMessage(sessionId: string, exitCode: number): string {
|
||||
const session = this.sessions.get(sessionId)
|
||||
const stderrText = session?.stderrLines.join('\n').trim() ?? ''
|
||||
const recentMessages = session?.sdkMessages ?? []
|
||||
const resultMessage = [...recentMessages]
|
||||
.reverse()
|
||||
.find((msg) => msg?.type === 'result' && msg.is_error)
|
||||
const authStatus = [...recentMessages]
|
||||
.reverse()
|
||||
.find((msg) => msg?.type === 'auth_status')
|
||||
const detail =
|
||||
this.extractStartupDetail(resultMessage) ||
|
||||
this.extractStartupDetail(authStatus) ||
|
||||
stderrText
|
||||
|
||||
return detail
|
||||
? `CLI process exited unexpectedly (code ${exitCode}): ${detail}`
|
||||
: `CLI process exited unexpectedly with code ${exitCode}.`
|
||||
}
|
||||
|
||||
private extractStartupDetail(message: any): string {
|
||||
if (!message) return ''
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user