(
exitCode = 0,
reason: ExitReason = 'other',
options?: {
getAppState?: () => AppState
setAppState?: (f: (prev: AppState) => AppState) => void
/** Printed to stderr after alt-screen exit, before forceExit. */
finalMessage?: string
},
)
| 389 | |
| 390 | // Graceful shutdown function that drains the event loop |
| 391 | export async function gracefulShutdown( |
| 392 | exitCode = 0, |
| 393 | reason: ExitReason = 'other', |
| 394 | options?: { |
| 395 | getAppState?: () => AppState |
| 396 | setAppState?: (f: (prev: AppState) => AppState) => void |
| 397 | /** Printed to stderr after alt-screen exit, before forceExit. */ |
| 398 | finalMessage?: string |
| 399 | }, |
| 400 | ): Promise<void> { |
| 401 | if (shutdownInProgress) { |
| 402 | return |
| 403 | } |
| 404 | shutdownInProgress = true |
| 405 | |
| 406 | // Resolve the SessionEnd hook budget before arming the failsafe so the |
| 407 | // failsafe can scale with it. Without this, a user-configured 10s hook |
| 408 | // budget is silently truncated by the 5s failsafe (gh-32712 follow-up). |
| 409 | const { executeSessionEndHooks, getSessionEndHookTimeoutMs } = await import( |
| 410 | './hooks.js' |
| 411 | ) |
| 412 | const sessionEndTimeoutMs = getSessionEndHookTimeoutMs() |
| 413 | |
| 414 | // Failsafe: guarantee process exits even if cleanup hangs (e.g., MCP connections). |
| 415 | // Runs cleanupTerminalModes first so a hung cleanup doesn't leave the terminal dirty. |
| 416 | // Budget = max(5s, hook budget + 3.5s headroom for cleanup + analytics flush). |
| 417 | failsafeTimer = setTimeout( |
| 418 | code => { |
| 419 | cleanupTerminalModes() |
| 420 | printResumeHint() |
| 421 | forceExit(code) |
| 422 | }, |
| 423 | Math.max(5000, sessionEndTimeoutMs + 3500), |
| 424 | exitCode, |
| 425 | ) |
| 426 | failsafeTimer.unref() |
| 427 | |
| 428 | // Set the exit code that will be used when process naturally exits |
| 429 | process.exitCode = exitCode |
| 430 | |
| 431 | // Exit alt screen and print resume hint FIRST, before any async operations. |
| 432 | // This ensures the hint is visible even if the process is killed during |
| 433 | // cleanup (e.g., SIGKILL during macOS reboot). Without this, the resume |
| 434 | // hint would only appear after cleanup functions, hooks, and analytics |
| 435 | // flush — which can take several seconds. |
| 436 | cleanupTerminalModes() |
| 437 | printResumeHint() |
| 438 | |
| 439 | // Flush session data first — this is the most critical cleanup. If the |
| 440 | // terminal is dead (SIGHUP, SSH disconnect), hooks and analytics may hang |
| 441 | // on I/O to a dead TTY or unreachable network, eating into the |
| 442 | // failsafe budget. Session persistence must complete before anything else. |
| 443 | let cleanupTimeoutId: ReturnType<typeof setTimeout> | undefined |
| 444 | try { |
| 445 | const cleanupPromise = (async () => { |
| 446 | try { |
| 447 | await runCleanupFunctions() |
| 448 | } catch { |
no test coverage detected