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
28 changes: 25 additions & 3 deletions packages/nuxt-cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,16 @@ import { isReusePortSupported } from '../dev/listen'
import { ForkPool } from '../dev/pool'
import { formatRestartReason } from '../dev/reason'
import { setupShortcuts } from '../dev/shortcuts'
import { SUPERVISOR_SHUTDOWN_TIMEOUT_MS } from '../dev/shutdown'
import { formatTakeoverRefusal, takeOverDevServer } from '../dev/takeover'
import { summariseActiveResources } from '../utils/hang'
import { debug, logger } from '../utils/logger'
import { resolveRootDir } from '../utils/paths'
import { dotEnvArgs, envNameArgs, extendsArgs, logLevelArgs, profileArgs, rootDirArgs } from './_shared'

const startTime: number | undefined = Date.now()

const SHUTDOWN_TIMEOUT_MS = 3000
const SHUTDOWN_NOTICE_MS = 1500
const forkSupported = !isTest && (!isBun || isBunForkSupported())

const command = defineCommand({
Expand Down Expand Up @@ -356,19 +358,39 @@ type ArgsT = Exclude<
* Registering any listener for these signals (the fork pool and the CPU
* profiler both do) suppresses Node's default exit behaviour, so Ctrl-C would
* otherwise leave the server, its forks and any tunnel running.
*
* Shutdown is given enough time for `close` hooks (nitro plugins closing database
* connections, and so on) to finish; a second Ctrl-C skips the wait.
*/
function setupSignalHandlers(close: () => Promise<void>): void {
let closing = false
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.once(signal, () => {
process.on(signal, () => {
if (closing) {
process.exit(130)
}
closing = true

const notice = setTimeout(() => {
logger.info('Shutting down... press Ctrl-C again to exit immediately.')
}, SHUTDOWN_NOTICE_MS)
notice.unref?.()

// Ctrl-C should always give the terminal back, even if a watcher or an
// open connection stops the graceful shutdown from settling.
const deadline = setTimeout(() => process.exit(), SHUTDOWN_TIMEOUT_MS)
const deadline = setTimeout(() => {
const summary = summariseActiveResources()
logger.warn(`The dev server did not shut down within ${SUPERVISOR_SHUTDOWN_TIMEOUT_MS / 1000}s${summary ? `: ${summary}` : ''}. Exiting anyway.`)
process.exit()
}, SUPERVISOR_SHUTDOWN_TIMEOUT_MS)

close()
.catch((error) => {
console.error(error)
process.exitCode = 1
})
.finally(() => {
clearTimeout(notice)
clearTimeout(deadline)
process.exit()
})
Expand Down
76 changes: 59 additions & 17 deletions packages/nuxt-cli/src/dev/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,24 @@ interface InitializeOptions {
// IPC Hooks
class IPC {
enabled = !!process.send && !process.title?.includes('vitest') && process.env.__NUXT__FORK
#shutdown?: () => Promise<void>
#closing?: Promise<void>

constructor() {
// only kill process if it is a fork
if (this.enabled) {
// The terminal delivers Ctrl-C to the whole process group, so this fork has to
// run its own shutdown rather than being terminated mid-request.
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.once(signal, () => void this.close())
}
// Without a parent there is nobody to reap this process, and it may be
// holding the dev server port or the inspector port.
// holding the dev server port or the inspector port. A shutdown that is
// already running exits on its own once the `close` hooks have finished.
process.once('disconnect', () => {
process.exit(0)
if (!this.#closing) {
process.exit(0)
}
})
process.on('unhandledRejection', createRejectionHandler(
message => this.send({ type: 'nuxt:internal:dev:rejection', message }),
Expand All @@ -65,10 +76,37 @@ class IPC {
}
await initialize(message.context, { listenOverrides: message.listenOverrides })
}
else if (message.type === 'nuxt:internal:dev:shutdown') {
await this.close()
}
})
this.send({ type: 'nuxt:internal:dev:fork-ready' })
}

/** Register the shutdown routine to run before this fork exits. */
onShutdown(handler: () => Promise<void>): void {
this.#shutdown = handler
}

/**
* Run the dev server's `close` hooks to completion before exiting, so user code
* (nitro plugins, database connections) can tear itself down.
*/
close(): Promise<void> {
this.#closing ??= (async () => {
try {
await this.#shutdown?.()
}
catch (error) {
debug('Could not shut the dev server down cleanly:', error)
}
finally {
process.exit(0)
}
})()
return this.#closing
}

send<T extends NuxtDevIPCMessage>(message: T) {
if (this.enabled) {
process.send?.(message)
Expand Down Expand Up @@ -171,24 +209,28 @@ export async function initialize(devContext: NuxtDevContext, ctx: InitializeOpti

const armRestart = createRestartHook(devServer)

const close = () => {
closePromise ??= (async () => {
devServer.closeWatchers()
try {
await Promise.all([
devServer.listener.close(),
devServer.close(),
])
}
finally {
devServer.releaseLock()
}
})()
return closePromise
}

ipc.onShutdown(close)

return {
listener: devServer.listener,
reload: (reason?: DevRestartReason) => devServer.load(true, reason),
close: () => {
closePromise ??= (async () => {
devServer.closeWatchers()
try {
await Promise.all([
devServer.listener.close(),
devServer.close(),
])
}
finally {
devServer.releaseLock()
}
})()
return closePromise
},
close,
onReady: (callback: (address: string) => void) => {
if (address) {
callback(address)
Expand Down
75 changes: 65 additions & 10 deletions packages/nuxt-cli/src/dev/pool.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import type { ChildProcess } from 'node:child_process'
import type { InspectOptions } from './inspect'
import type { DevListenOverrides } from './listen'
import type { NuxtDevContext, NuxtDevIPCMessage } from './utils'
import type { NuxtDevContext, NuxtDevIPCMessage, NuxtParentIPCMessage } from './utils'

import { fork } from 'node:child_process'
import process from 'node:process'
import { debug } from '../utils/logger'
import { debug, logger } from '../utils/logger'
import { DEV_SHUTDOWN_TIMEOUT_MS, FORCE_KILL_TIMEOUT_MS } from './shutdown'

interface ForkPoolOptions {
rawArgs: string[]
Expand Down Expand Up @@ -54,13 +55,9 @@ export class ForkPool {
this.listenOverrides = options.listenOverrides
this.inspect = options.inspect

// Graceful shutdown
for (const signal of [
'exit',
'SIGTERM' /* Graceful shutdown */,
'SIGINT' /* Ctrl-C */,
'SIGQUIT' /* Ctrl-\ */,
] as const) {
// last-resort for forks that outlive this process. nuxt closes forks gracefully
// on `SIGINT`/`SIGTERM`, so we skip them.
for (const signal of ['exit', 'SIGQUIT'] as const) {
process.once(signal, () => {
this.killAll(signal === 'exit' ? 0 : signal)
})
Expand Down Expand Up @@ -116,7 +113,7 @@ export class ForkPool {
promote: () => {
fork.serving = true
},
close: () => this.killFork(fork),
close: () => this.closeFork(fork),
}
}

Expand Down Expand Up @@ -223,6 +220,9 @@ export class ForkPool {
// entry, or a kill), which would leave `ready` pending forever.
readyReject(new Error('Dev server fork exited before it finished starting.'))
if (pooledFork.serving && errorCode) {
// Ending the session on the crash of the process that holds the listener is
// silent otherwise, leaving no clue as to what stopped the dev server.
logger.error(`The dev server process (PID ${childProc.pid}) exited with code ${errorCode}.`)
// Active fork crashed
process.exit(errorCode)
}
Expand All @@ -241,6 +241,42 @@ export class ForkPool {
})
}

/**
* Ask a fork to shut down and wait for its `close` hooks to run, so nitro plugins
* and anything else the app opened get to tear down before the process goes away.
* A fork that takes too long is signalled instead.
*/
private async closeFork(fork: PooledFork): Promise<void> {
if (fork.state === 'dead' || fork.process.exitCode !== null || !fork.process.connected) {
return this.killFork(fork)
}

fork.state = 'dead'
// A fork we are shutting down on purpose must not end the session.
fork.serving = false
this.removeFork(fork)

const exited = waitForExit(fork.process)
fork.process.send({ type: 'nuxt:internal:dev:shutdown' } satisfies NuxtParentIPCMessage, (error) => {
if (error) {
fork.process.kill('SIGTERM')
}
})

if (await settlesWithin(exited, DEV_SHUTDOWN_TIMEOUT_MS)) {
return
}

debug(`Dev server fork ${fork.process.pid} did not shut down in time, terminating it`)
fork.process.kill('SIGTERM')
if (await settlesWithin(exited, FORCE_KILL_TIMEOUT_MS)) {
return
}

fork.process.kill('SIGKILL')
await settlesWithin(exited, FORCE_KILL_TIMEOUT_MS)
}

private killFork(fork: PooledFork, signal: NodeJS.Signals | number = 'SIGTERM'): Promise<void> {
const wasAlive = fork.state !== 'dead' && !!fork.process && fork.process.exitCode === null
fork.state = 'dead'
Expand Down Expand Up @@ -292,3 +328,22 @@ export class ForkPool {
}
}
}

function waitForExit(child: ChildProcess): Promise<void> {
return new Promise<void>((resolve) => {
child.once('exit', () => resolve())
child.once('close', () => resolve())
})
}

/** Resolves `true` if the promise settles before the timeout, `false` otherwise. */
function settlesWithin(promise: Promise<void>, timeout: number): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const timer = setTimeout(resolve, timeout, false)
timer.unref?.()
void promise.then(() => {
clearTimeout(timer)
resolve(true)
})
})
}
15 changes: 15 additions & 0 deletions packages/nuxt-cli/src/dev/shutdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* How long a dev server process is given to run its `close` hooks. Nitro plugins
* use these to close database connections and the like, so anything that signals
* a dev server has to allow for them before escalating.
*/
export const DEV_SHUTDOWN_TIMEOUT_MS = 10_000

/**
* How long the process supervising a dev server waits for it to go away. Longer
* than the budget above, since the fork spends that budget before exiting.
*/
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
5 changes: 2 additions & 3 deletions packages/nuxt-cli/src/dev/takeover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,8 @@ import { isCI } from 'std-env'
import { restoreRawMode, withDirectStdout } from '../utils/console'
import { clearStaleLock, clearTakeover, isInteractiveSession, isLockEnabled, isProcessAlive, markTakenOver, readLock } from '../utils/lockfile'
import { logger } from '../utils/logger'
import { DEV_SHUTDOWN_TIMEOUT_MS } from './shutdown'

/** How long the outgoing dev server has to exit and release its port. */
const TAKEOVER_TIMEOUT_MS = 5000
/** How long a `SIGKILL`ed process has to disappear before we give up. */
const TAKEOVER_KILL_TIMEOUT_MS = 1000
const TAKEOVER_POLL_INTERVAL_MS = 100
Expand Down Expand Up @@ -154,7 +153,7 @@ async function performTakeover(buildDir: string, existing: LockInfo, timeouts: T
// On Windows `SIGTERM` is not delivered as a signal and terminates the process
// outright, so the graceful window below simply passes quickly there.
signalAll(pids, 'SIGTERM')
if (await waitForRelease(pids, port, existing.hostname, timeouts.graceful ?? TAKEOVER_TIMEOUT_MS)) {
if (await waitForRelease(pids, port, existing.hostname, timeouts.graceful ?? DEV_SHUTDOWN_TIMEOUT_MS)) {
progress.stop(`Stopped the dev server on port ${port} (PID ${existing.pid})`)
return { action: 'taken', port, pid: existing.pid }
}
Expand Down
1 change: 1 addition & 0 deletions packages/nuxt-cli/src/dev/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import { formatChangedKeys, formatRestartReason, formatSkippedReload, mergeResta

export type NuxtParentIPCMessage
= | { type: 'nuxt:internal:dev:context', context: NuxtDevContext, listenOverrides: DevListenOverrides, inspect?: InspectOptions }
| { type: 'nuxt:internal:dev:shutdown' }

export type NuxtDevIPCMessage
= | { type: 'nuxt:internal:dev:fork-ready' }
Expand Down
3 changes: 2 additions & 1 deletion packages/nuxt-cli/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { provider } from 'std-env'
import { description, name, version } from '../package.json'
import { commands } from './commands'
import { cwdArgs } from './commands/_shared'
import { runCommand } from './run'
import { runCommand, setCurrentCommand } from './run'
import { normaliseCwdArg } from './utils/args'
import { setupGlobalConsole } from './utils/console'
import { checkEngines } from './utils/engines'
Expand Down Expand Up @@ -44,6 +44,7 @@ const _main = defineCommand({
normaliseCwdArg(ctx.rawArgs)

const command = ctx.args._[0]
setCurrentCommand(command)
setupGlobalConsole({ dev: command === 'dev' })

if (command !== '_dev' && provider !== 'stackblitz') {
Expand Down
17 changes: 16 additions & 1 deletion packages/nuxt-cli/src/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { runCommand as _runCommand, runMain as _runMain } from 'citty'
import { commands } from './commands'
import { main } from './main'
import { normaliseCwdArg } from './utils/args'
import { warnOnHang } from './utils/hang'

globalThis.__nuxt_cli__ = globalThis.__nuxt_cli__ || {
// Programmatic usage fallback
Expand All @@ -18,12 +19,26 @@ globalThis.__nuxt_cli__ = globalThis.__nuxt_cli__ || {
),
}

// Commands that keep serving after their `run` resolves, so an alive process is expected.
const LONG_RUNNING_COMMANDS = new Set(['dev', '_dev', 'analyze', 'test'])

let currentCommand: string | undefined

/** Record the command being run, so `runMain` knows whether the process is expected to exit. */
export function setCurrentCommand(command: string | undefined): void {
currentCommand = command
}

export async function runMain(): Promise<void> {
if (process.argv[2] === 'complete') {
const { initCompletions } = await import('./completions')
await initCompletions(main)
}
return _runMain(main)
await _runMain(main)

if (!currentCommand || !LONG_RUNNING_COMMANDS.has(currentCommand)) {
warnOnHang({ action: currentCommand ? `\`nuxt ${currentCommand}\`` : 'command' })
}
}

export async function runCommand(
Expand Down
Loading
Loading