Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion packages/nuxt-cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.
Expand Down
18 changes: 18 additions & 0 deletions packages/nuxt-cli/src/dev/shutdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
4 changes: 4 additions & 0 deletions packages/nuxt-cli/src/dev/tui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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)
}

Expand Down
11 changes: 9 additions & 2 deletions packages/nuxt-cli/src/dev/tui/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down
51 changes: 51 additions & 0 deletions packages/nuxt-cli/test/unit/dev-tui.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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), () => {})
Expand Down
Loading