fix: guard prewarm resume shutdown paths (#611)

Prevent desktop prewarm launches from inheriting interrupted-turn resume state, while leaving explicit user-message startup unchanged. Also stop server-side background schedulers during shutdown so app quit cannot leave scheduled task runners alive.

Tested:

- bun test src/server/__tests__/conversation-service.test.ts

- bun test src/server/__tests__/conversations.test.ts -t "prewarm"

- bun test src/server/__tests__/conversations.test.ts -t "permission"

- bun test src/server/__tests__/diagnostics-service.test.ts -t "keeps fatal startup errors visible on stderr while recording diagnostics"

- bun test desktop/electron/services/windows.test.ts desktop/electron/services/tray.test.ts desktop/electron/services/sidecarManager.test.ts src/server/__tests__/server-shutdown.test.ts

- bun run check:server
This commit is contained in:
程序员阿江(Relakkes) 2026-06-09 21:44:25 +08:00
parent ddcfa5faae
commit d3b2f868a9
7 changed files with 88 additions and 10 deletions

View File

@ -21,6 +21,7 @@ describe('ConversationService', () => {
let originalProviderManagedByHost: string | undefined
let originalDiagnosticsFile: string | undefined
let originalAttributionHeader: string | undefined
let originalResumeInterruptedTurn: string | undefined
let originalHome: string | undefined
let originalPath: string | undefined
let originalShell: string | undefined
@ -39,6 +40,7 @@ describe('ConversationService', () => {
originalProviderManagedByHost = process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST
originalDiagnosticsFile = process.env.CLAUDE_CODE_DIAGNOSTICS_FILE
originalAttributionHeader = process.env.CLAUDE_CODE_ATTRIBUTION_HEADER
originalResumeInterruptedTurn = process.env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN
originalHome = process.env.HOME
originalPath = process.env.PATH
originalShell = process.env.SHELL
@ -57,6 +59,7 @@ describe('ConversationService', () => {
delete process.env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST
delete process.env.CLAUDE_CODE_DIAGNOSTICS_FILE
delete process.env.CLAUDE_CODE_ATTRIBUTION_HEADER
delete process.env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN
process.env.CC_HAHA_DISABLE_TERMINAL_SHELL_ENV = '1'
resetTerminalShellEnvironmentCacheForTests()
})
@ -92,6 +95,9 @@ describe('ConversationService', () => {
if (originalAttributionHeader === undefined) delete process.env.CLAUDE_CODE_ATTRIBUTION_HEADER
else process.env.CLAUDE_CODE_ATTRIBUTION_HEADER = originalAttributionHeader
if (originalResumeInterruptedTurn === undefined) delete process.env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN
else process.env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN = originalResumeInterruptedTurn
if (originalHome === undefined) delete process.env.HOME
else process.env.HOME = originalHome
@ -597,6 +603,18 @@ describe('ConversationService', () => {
expect(env.CC_HAHA_DESKTOP_AWAIT_MCP_TIMEOUT_MS).toBe('5000')
})
test('buildChildEnv disables inherited interrupted-turn resume for prewarm launches', async () => {
process.env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN = '1'
const service = new ConversationService() as any
const env = (await service.buildChildEnv(
'/tmp',
'ws://127.0.0.1:3456/sdk/test-session?token=test-token',
{ resumeInterruptedTurn: false },
)) as Record<string, string>
expect(env.CLAUDE_CODE_RESUME_INTERRUPTED_TURN).toBeUndefined()
})
test('buildChildEnv enables stream idle watchdog for desktop CLI sessions', async () => {
const service = new ConversationService() as any
const env = (await service.buildChildEnv(

View File

@ -1875,7 +1875,7 @@ describe('WebSocket Chat Integration', () => {
const { sessionId } = await createRes.json() as { sessionId: string }
const originalStartSession = conversationService.startSession.bind(conversationService)
const startCalls: Array<{ sessionId: string }> = []
const startCalls: Array<{ sessionId: string; options?: Record<string, unknown> }> = []
conversationService.startSession = (async function patchedStartSession(
sid: string,
@ -1883,7 +1883,7 @@ describe('WebSocket Chat Integration', () => {
sdkUrl: string,
options?: { permissionMode?: string; model?: string; effort?: string; thinking?: 'enabled' | 'adaptive' | 'disabled'; providerId?: string | null },
) {
startCalls.push({ sessionId: sid })
startCalls.push({ sessionId: sid, options: options as Record<string, unknown> | undefined })
return originalStartSession(sid, workDir, sdkUrl, options)
}) as typeof conversationService.startSession
@ -1972,6 +1972,8 @@ describe('WebSocket Chat Integration', () => {
await completion
expect(startCalls).toHaveLength(1)
expect(startCalls[0]!.sessionId).toBe(sessionId)
expect(startCalls[0]!.options?.resumeInterruptedTurn).toBe(false)
expect(messages.some((msg) => msg.type === 'content_delta')).toBe(true)
expect(messages.some((msg) => msg.type === 'message_complete')).toBe(true)
expect(messages.some((msg) => msg.type === 'error')).toBe(false)

View File

@ -0,0 +1,39 @@
import { expect, test } from 'bun:test'
import { stopServerRuntimeForShutdown } from '../index.js'
import { conversationService } from '../services/conversationService.js'
import { cronScheduler } from '../services/cronScheduler.js'
import { teamWatcher } from '../services/teamWatcher.js'
test('server shutdown stops background schedulers before waiting for CLI sessions', async () => {
const calls: string[] = []
const originalTeamStop = teamWatcher.stop.bind(teamWatcher)
const originalCronStop = cronScheduler.stop.bind(cronScheduler)
const originalGetActiveSessions = conversationService.getActiveSessions.bind(conversationService)
const originalStopAllSessionsAndWait = conversationService.stopAllSessionsAndWait.bind(conversationService)
try {
teamWatcher.stop = (() => {
calls.push('teamWatcher.stop')
}) as typeof teamWatcher.stop
cronScheduler.stop = (() => {
calls.push('cronScheduler.stop')
}) as typeof cronScheduler.stop
conversationService.getActiveSessions = (() => ['active-session']) as typeof conversationService.getActiveSessions
conversationService.stopAllSessionsAndWait = (async () => {
calls.push('conversationService.stopAllSessionsAndWait')
}) as typeof conversationService.stopAllSessionsAndWait
await stopServerRuntimeForShutdown({ waitForCli: true })
expect(calls).toEqual([
'teamWatcher.stop',
'cronScheduler.stop',
'conversationService.stopAllSessionsAndWait',
])
} finally {
teamWatcher.stop = originalTeamStop
cronScheduler.stop = originalCronStop
conversationService.getActiveSessions = originalGetActiveSessions
conversationService.stopAllSessionsAndWait = originalStopAllSessionsAndWait
}
})

View File

@ -463,20 +463,29 @@ export function startServer(port = PORT, host = HOST) {
let shutdownInProgress: Promise<void> | null = null
function cleanupAllSessions() {
export async function stopServerRuntimeForShutdown(
options: { waitForCli?: boolean } = {},
): Promise<void> {
teamWatcher.stop()
cronScheduler.stop()
const active = conversationService.getActiveSessions()
if (active.length > 0) {
console.log(`[Server] Shutting down — killing ${active.length} CLI subprocess(es)`)
conversationService.stopAllSessions()
if (options.waitForCli === false) {
conversationService.stopAllSessions()
} else {
await conversationService.stopAllSessionsAndWait()
}
}
}
function cleanupAllSessions() {
void stopServerRuntimeForShutdown({ waitForCli: false })
}
async function cleanupAllSessionsAndWait() {
const active = conversationService.getActiveSessions()
if (active.length > 0) {
console.log(`[Server] Shutting down — killing ${active.length} CLI subprocess(es)`)
await conversationService.stopAllSessionsAndWait()
}
await stopServerRuntimeForShutdown({ waitForCli: true })
}
function shutdownAndExit(signal: 'SIGTERM' | 'SIGINT', exitCode: number) {

View File

@ -121,6 +121,7 @@ type SessionStartOptions = {
effort?: string
thinking?: 'enabled' | 'adaptive' | 'disabled'
providerId?: string | null
resumeInterruptedTurn?: boolean
}
export class ConversationStartupError extends Error {
@ -1042,6 +1043,9 @@ export class ConversationService {
const cleanEnv = await getProcessEnvWithTerminalShellEnvironment()
delete cleanEnv.CLAUDE_CODE_OAUTH_TOKEN
if (options?.resumeInterruptedTurn === false) {
delete cleanEnv.CLAUDE_CODE_RESUME_INTERRUPTED_TURN
}
if (this.shouldStripInheritedProviderEnv(options?.providerId)) {
for (const key of PROVIDER_ENV_KEYS) {
delete cleanEnv[key]

View File

@ -368,6 +368,9 @@ export class CronScheduler {
/** Stop the scheduler and kill any running task processes. */
stop(): void {
const wasRunning = this.intervalId !== null || this.runningTasks.size > 0
if (!wasRunning) return
if (this.intervalId) {
clearInterval(this.intervalId)
this.intervalId = null

View File

@ -1310,12 +1310,15 @@ async function ensureCliSessionStarted(
const workDir = await resolveSessionWorkDir(sessionId)
lastResolvedStartupWorkDirs.set(sessionId, workDir)
const runtimeSettings = await getRuntimeSettings(sessionId)
const startupSettings = reason === 'prewarm_session'
? { ...runtimeSettings, resumeInterruptedTurn: false }
: runtimeSettings
const sdkUrl =
`ws://${ws.data.serverHost}:${ws.data.serverPort}/sdk/${sessionId}` +
`?token=${encodeURIComponent(crypto.randomUUID())}`
await sendRepositoryStartupStatus(ws, sessionId, reason)
console.log(`[WS] Starting CLI for ${sessionId} due to ${reason}`)
await conversationService.startSession(sessionId, workDir, sdkUrl, runtimeSettings)
await conversationService.startSession(sessionId, workDir, sdkUrl, startupSettings)
})()
sessionStartupPromises.set(sessionId, startup)