From b355af8333188050b4aad5e7812b6f169dc2fa5b Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 21 Sep 2026 20:01:32 +0000 Subject: [PATCH 1/2] fix(dev): end the process when ctrl-c arrives early --- packages/nuxt-cli/src/commands/dev.ts | 3 +- packages/nuxt-cli/src/dev/shutdown.ts | 18 +++++++++++ packages/nuxt-cli/src/dev/tui/index.ts | 4 +++ packages/nuxt-cli/src/dev/tui/session.ts | 11 +++++-- packages/nuxt-cli/test/unit/dev-tui.spec.ts | 35 +++++++++++++++++++++ 5 files changed, 68 insertions(+), 3 deletions(-) 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..c6111ec63 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 = () => { + teardown({ keep: true }) + if (!isShutdownAdopted()) { + process.exit(130) + } + } // 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], + ['SIGTERM', onInterrupt], ['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..4e3f7925b 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,40 @@ 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 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), () => {}) From df582ff3789454ddd167e248b889db3f9d17c80c Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sat, 26 Sep 2026 13:08:13 +0000 Subject: [PATCH 2/2] fix(dev): exit with sigterm status on early termination --- packages/nuxt-cli/src/dev/tui/session.ts | 8 ++++---- packages/nuxt-cli/test/unit/dev-tui.spec.ts | 16 ++++++++++++++++ 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/packages/nuxt-cli/src/dev/tui/session.ts b/packages/nuxt-cli/src/dev/tui/session.ts index c6111ec63..1d8b6886b 100644 --- a/packages/nuxt-cli/src/dev/tui/session.ts +++ b/packages/nuxt-cli/src/dev/tui/session.ts @@ -536,10 +536,10 @@ export function beginDevUI(options: PanelStartOptions & { start?: PanelStart } = surface.externalOutput = 'capture' const onSignal = () => teardown({ keep: true }) - const onInterrupt = () => { + const onInterrupt = (code: number) => () => { teardown({ keep: true }) if (!isShutdownAdopted()) { - process.exit(130) + process.exit(code) } } // Merely listening for SIGHUP suppresses its default exit, and nothing else in @@ -561,8 +561,8 @@ export function beginDevUI(options: PanelStartOptions & { start?: PanelStart } = } handlers = [ ['exit', onSignal], - ['SIGINT', onInterrupt], - ['SIGTERM', onInterrupt], + ['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 4e3f7925b..7aef7114c 100644 --- a/packages/nuxt-cli/test/unit/dev-tui.spec.ts +++ b/packages/nuxt-cli/test/unit/dev-tui.spec.ts @@ -2590,6 +2590,22 @@ describe('dev ui teardown', () => { }) }) + 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(() => {})