diff --git a/packages/nuxt-cli/src/commands/dev.ts b/packages/nuxt-cli/src/commands/dev.ts index 4fff5400f..30508b65c 100644 --- a/packages/nuxt-cli/src/commands/dev.ts +++ b/packages/nuxt-cli/src/commands/dev.ts @@ -19,7 +19,7 @@ import { ForkPool } from '../dev/pool' import { preflight } from '../dev/preflight' import { formatRestartReason } from '../dev/reason' import { devShortcutContext } from '../dev/shortcut-context' -import { SUPERVISOR_SHUTDOWN_TIMEOUT_MS } from '../dev/shutdown' +import { adoptShutdown, SUPERVISOR_SHUTDOWN_TIMEOUT_MS } from '../dev/shutdown' import { formatTakeoverRefusal, takeOverDevServer } from '../dev/takeover' import { beginDevUI, setupDevUI, teardownDevUI } from '../dev/tui/controller' import { replaceCwdArg } from '../utils/args' @@ -236,6 +236,7 @@ const command = defineCommand({ provide({ clearCaches }) const startingUI = ui ? await setupDevUI(shortcutContext, { ...uiOptions, enabled: true }) : undefined setupSignalHandlers(() => shortcutContext.close()) + adoptShutdown() // Evaluating the dev server's graph blocks the loop; let the panel answer // anything already typed first. diff --git a/packages/nuxt-cli/src/dev/shutdown.ts b/packages/nuxt-cli/src/dev/shutdown.ts index 5f6412942..24361dd7b 100644 --- a/packages/nuxt-cli/src/dev/shutdown.ts +++ b/packages/nuxt-cli/src/dev/shutdown.ts @@ -13,3 +13,21 @@ export const SUPERVISOR_SHUTDOWN_TIMEOUT_MS = 15_000 /** How long a signalled process has to disappear before we give up on it. */ export const FORCE_KILL_TIMEOUT_MS = 2000 + +let adopted = false + +/** Whether the dev command has taken interrupts over from the panel's stop-gap. */ +export function isShutdownAdopted(): boolean { + return adopted +} + +/** + * Hand interrupts to the command's own handlers. + * + * The panel is on screen long before the command can shut a dev server down, + * and listening for `SIGINT` at all suppresses the default exit, so until this + * is called the session ends the process itself. + */ +export function adoptShutdown(): void { + adopted = true +} diff --git a/packages/nuxt-cli/src/dev/tui/index.ts b/packages/nuxt-cli/src/dev/tui/index.ts index feb028fc8..98d6cf686 100644 --- a/packages/nuxt-cli/src/dev/tui/index.ts +++ b/packages/nuxt-cli/src/dev/tui/index.ts @@ -23,6 +23,7 @@ import { MUTED, paint } from '../../utils/terminal-theme' import { checkForUpdate, isUpdateCheckEnabled, releaseNotesUrl } from '../../utils/update-check' import { openBrowser } from '../listen' import { setupShortcuts } from '../shortcuts' +import { isShutdownAdopted } from '../shutdown' import { NOOP_CONTROLLER } from './controller' import { isBoxedNotice, normaliseMessage } from './events' import { HelpOverlay } from './help-overlay' @@ -360,6 +361,9 @@ export function setupDevUI(context: ShortcutContext, options: DevUIOptions = {}) const quit = () => { session.teardown({ keep: true }) + if (!isShutdownAdopted()) { + process.exit(130) + } process.emit('SIGINT' as any) } diff --git a/packages/nuxt-cli/src/dev/tui/session.ts b/packages/nuxt-cli/src/dev/tui/session.ts index bd176449f..1d8b6886b 100644 --- a/packages/nuxt-cli/src/dev/tui/session.ts +++ b/packages/nuxt-cli/src/dev/tui/session.ts @@ -16,6 +16,7 @@ import { READY_MESSAGE } from '../../utils/progress-snapshot' import { startupElapsedMs } from '../../utils/startup-clock' import { registerTerminalHost } from '../../utils/terminal-host' import { currentRequest, isServingRequest } from '../serving-state' +import { isShutdownAdopted } from '../shutdown' import { queryBackground } from './background' import { DevEventLog, isBoxedNotice, normaliseMessage, noteRoute } from './events' import { createPanelState, renderPanelState } from './first-frame' @@ -535,6 +536,12 @@ export function beginDevUI(options: PanelStartOptions & { start?: PanelStart } = surface.externalOutput = 'capture' const onSignal = () => teardown({ keep: true }) + const onInterrupt = (code: number) => () => { + teardown({ keep: true }) + if (!isShutdownAdopted()) { + process.exit(code) + } + } // Merely listening for SIGHUP suppresses its default exit, and nothing else in // `nuxt dev` handles it, so a closed terminal would leave the server running // headless. 129 is the exit code the default disposition would have produced. @@ -554,8 +561,8 @@ export function beginDevUI(options: PanelStartOptions & { start?: PanelStart } = } handlers = [ ['exit', onSignal], - ['SIGINT', onSignal], - ['SIGTERM', onSignal], + ['SIGINT', onInterrupt(130)], + ['SIGTERM', onInterrupt(143)], ['SIGHUP', onHangup], // Raw mode and a pinned panel would otherwise outlive the crash: node // prints the error and exits without unwinding through `exit` first. diff --git a/packages/nuxt-cli/test/unit/dev-tui.spec.ts b/packages/nuxt-cli/test/unit/dev-tui.spec.ts index cd2f08fbb..7aef7114c 100644 --- a/packages/nuxt-cli/test/unit/dev-tui.spec.ts +++ b/packages/nuxt-cli/test/unit/dev-tui.spec.ts @@ -10,6 +10,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createRequest, currentRequest, isServingRequest, runWithRequest } from '../../src/dev/serving-state' import { deferShortcutContext } from '../../src/dev/shortcut-context' +import { adoptShutdown } from '../../src/dev/shutdown' import { DevEventLog, noteRoute } from '../../src/dev/tui/events' import { HelpOverlay } from '../../src/dev/tui/help-overlay' import { beginDevUI, setupDevUI } from '../../src/dev/tui/index' @@ -2572,6 +2573,56 @@ describe('dev ui teardown', () => { }) }) + it('should end the process when an interrupt arrives before the command is listening', async () => { + await withTerminal(({ session }) => { + const detach = attachKeys(() => {}) + session.onTeardown(detach) + + // `mockRestore` clears the call history, so the assertion comes first. + const exit = vi.spyOn(process, 'exit').mockImplementation((() => {}) as never) + try { + process.emit('SIGINT') + expect(exit).toHaveBeenCalledWith(130) + } + finally { + exit.mockRestore() + } + }) + }) + + it('should end the process with the SIGTERM status when terminated before the command is listening', async () => { + await withTerminal(({ session }) => { + const detach = attachKeys(() => {}) + session.onTeardown(detach) + + const exit = vi.spyOn(process, 'exit').mockImplementation((() => {}) as never) + try { + process.emit('SIGTERM') + expect(exit).toHaveBeenCalledWith(143) + } + finally { + exit.mockRestore() + } + }) + }) + + it('should leave the exit to the command once it has taken shutdown over', async () => { + await withTerminal(({ session }) => { + const detach = attachKeys(() => {}) + session.onTeardown(detach) + adoptShutdown() + + const exit = vi.spyOn(process, 'exit').mockImplementation((() => {}) as never) + try { + process.emit('SIGINT') + expect(exit).not.toHaveBeenCalled() + } + finally { + exit.mockRestore() + } + }) + }) + it('leaves the alternate buffer when an overlay is open as it exits', async () => { await withTerminal(({ session, written }) => { const overlay = new LogOverlay(session.events, chunk => session.surface.writeRaw(chunk), () => {})