mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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:
parent
ddcfa5faae
commit
d3b2f868a9
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
39
src/server/__tests__/server-shutdown.test.ts
Normal file
39
src/server/__tests__/server-shutdown.test.ts
Normal 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
|
||||
}
|
||||
})
|
||||
@ -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) {
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user