From 79dda79d0e2082873fc1bd28542afc0ad35e3dbc Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 27 Jul 2026 11:45:45 +0000 Subject: [PATCH 1/7] feat(dev): bind the dev server with SO_REUSEPORT where supported --- packages/nuxt-cli/src/dev/listen.ts | 85 +++++++++++++++++++--- packages/nuxt-cli/test/unit/listen.spec.ts | 16 +++- 2 files changed, 89 insertions(+), 12 deletions(-) diff --git a/packages/nuxt-cli/src/dev/listen.ts b/packages/nuxt-cli/src/dev/listen.ts index 694b1db0..17c15aba 100644 --- a/packages/nuxt-cli/src/dev/listen.ts +++ b/packages/nuxt-cli/src/dev/listen.ts @@ -23,6 +23,13 @@ export interface ListenOptions { port?: string | number /** Fail instead of falling back to another port when `port` is unavailable. */ strictPort?: boolean + /** Bind with `SO_REUSEPORT` so a successor can bind the same port before this one is released. */ + reusePort?: boolean + /** + * Bind `port` as given, without checking whether it is free. Used together with + * `reusePort` when taking over from a process that still holds the port. + */ + handover?: boolean hostname?: string baseURL?: string showURL?: boolean @@ -150,7 +157,9 @@ export async function listen(handler: RequestListener, options: ListenOptions = const hostname = validateHostname(options.hostname, options.public) ?? (options.public ? '' : 'localhost') const requestedPort = options.port === undefined || options.port === '' ? undefined : Number(options.port) - const port = await resolvePort(requestedPort, hostname, options.strictPort) + const port = options.handover && requestedPort + ? requestedPort + : await resolvePort(requestedPort, hostname, options.strictPort) const httpsOptions = options.https === true ? {} : options.https const certificate = httpsOptions ? await resolveCertificate(httpsOptions) : false @@ -158,17 +167,12 @@ export async function listen(handler: RequestListener, options: ListenOptions = ? createSecureServer(certificate, handler) : createHttpServer(handler) - await new Promise((resolve, reject) => { - const onError = (error: NodeJS.ErrnoException) => reject(describeBindError(error, port, hostname, options.strictPort)) - server.once('error', onError) - server.listen(port, hostname || undefined, () => { - server.removeListener('error', onError) - // Without a persistent handler, any later `error` event is unhandled and - // takes the whole dev process down. - server.on('error', error => logger.error(`Dev server error: ${error.message}`)) - resolve() - }) + await bindServer(server, port, hostname, !!options.reusePort).catch((error) => { + throw describeBindError(error, port, hostname, options.strictPort) }) + // Without a persistent handler, any later `error` event is unhandled and + // takes the whole dev process down. + server.on('error', error => logger.error(`Dev server error: ${error.message}`)) const address = server.address() as AddressInfo const protocol = certificate ? 'https' : 'http' @@ -295,6 +299,65 @@ export async function listen(handler: RequestListener, options: ListenOptions = } } +function bindServer(server: HttpServer, port: number, hostname: string, reusePort: boolean): Promise { + return new Promise((resolve, reject) => { + const onError = (error: NodeJS.ErrnoException) => { + if (reusePort && isUnsupportedOptionError(error)) { + debug('`reusePort` is unsupported on this platform, binding without it:', error) + bindServer(server, port, hostname, false).then(resolve, reject) + return + } + reject(error) + } + server.once('error', onError) + server.listen({ port, host: hostname || undefined, reusePort, exclusive: !reusePort }, () => { + server.removeListener('error', onError) + resolve() + }) + }) +} + +/** + * Whether the platform rejected the `reusePort` listen option itself, as opposed + * to refusing this particular bind. Linux reports `ENOTSUP`, Windows `EINVAL`, + * and Node validates the option before the bind on runtimes without support. + */ +function isUnsupportedOptionError(error: NodeJS.ErrnoException): boolean { + return error.code === 'ENOTSUP' || error.code === 'EINVAL' || error.code === 'ERR_INVALID_ARG_VALUE' +} + +let reusePortSupport: Promise | undefined + +/** + * Whether two sockets can bind the same port at once via `SO_REUSEPORT`, probed + * on an ephemeral port. A single successful bind is not enough: some platforms + * accept the option and still reject the second socket. Cached per process. + */ +export function isReusePortSupported(): Promise { + reusePortSupport ??= (async () => { + const first = createHttpServer() + const second = createHttpServer() + try { + await bindServer(first, 0, '127.0.0.1', true) + const { port } = first.address() as AddressInfo + await bindServer(second, port, '127.0.0.1', true) + return true + } + catch (error) { + debug('`reusePort` is not available:', error) + return false + } + finally { + for (const server of [first, second]) { + if (server.listening) { + server.close() + } + } + } + })() + return reusePortSupport +} + async function resolvePort(requestedPort: number | undefined, hostname: string, strictPort?: boolean): Promise { if (requestedPort === 0) { return getPort({ random: true, host: hostname || undefined }) diff --git a/packages/nuxt-cli/test/unit/listen.spec.ts b/packages/nuxt-cli/test/unit/listen.spec.ts index b505a10a..afe9e49a 100644 --- a/packages/nuxt-cli/test/unit/listen.spec.ts +++ b/packages/nuxt-cli/test/unit/listen.spec.ts @@ -4,7 +4,7 @@ import { networkInterfaces } from 'node:os' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { copyURL, formatDisplayURL, getNetworkAddresses, listen, openBrowser, resolveOpenCommand, validateHostname } from '../../src/dev/listen' +import { copyURL, formatDisplayURL, getNetworkAddresses, isReusePortSupported, listen, openBrowser, resolveOpenCommand, validateHostname } from '../../src/dev/listen' const writeText = vi.hoisted(() => vi.fn()) @@ -192,6 +192,20 @@ describe('listen', () => { expect(listener.url).toBe(`http://localhost:${listener.address.port}/`) }) + it('should keep the same port during a handover', async () => { + const reusePort = await isReusePortSupported() + const first = await start({ port: 0, reusePort }) + const port = first.address.port + + const takeover = start({ port, reusePort, handover: true }) + if (reusePort) { + expect((await takeover).address.port).toBe(port) + } + else { + await expect(takeover).rejects.toThrow(/already in use/) + } + }) + it('should use the requested port with `strictPort`', async () => { const { address } = await start({ port: 0 }) const port = address.port From d52195e6a3841da6a1014e654bb51b6265baaa1e Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 27 Jul 2026 11:45:45 +0000 Subject: [PATCH 2/7] feat(dev): allow a dev lock to be taken over by a named pid --- packages/nuxt-cli/src/utils/lockfile.ts | 12 +++++-- packages/nuxt-cli/test/unit/lockfile.spec.ts | 38 ++++++++++++++++++++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/packages/nuxt-cli/src/utils/lockfile.ts b/packages/nuxt-cli/src/utils/lockfile.ts index 89010bc0..6c3462a4 100644 --- a/packages/nuxt-cli/src/utils/lockfile.ts +++ b/packages/nuxt-cli/src/utils/lockfile.ts @@ -78,6 +78,11 @@ type LockResult = | { existing?: undefined, release: () => void } | { existing: LockInfo, release?: undefined } +export interface AcquireLockOptions { + /** PID whose lock may be claimed, for a handover where both processes overlap. */ + takeoverFrom?: number +} + /** * Atomically acquire a build/dev lock. * Returns `{ existing }` if another live process holds the lock, otherwise @@ -86,6 +91,7 @@ type LockResult export function acquireLock( buildDir: string, info: Omit, + options: AcquireLockOptions = {}, ): LockResult { if (!isLockEnabled()) { return { release: () => {} } @@ -117,17 +123,17 @@ export function acquireLock( throw err } const existing = readLockFile(lockPath) - if (existing && isLockActive(existing)) { + if (existing && existing.pid !== options.takeoverFrom && isLockActive(existing)) { return { existing } } - // Stale, corrupted, or self-owned; remove and retry. + // Stale, corrupted, self-owned, or handed over; remove and retry. tryUnlink(lockPath) } } // Two failures in a row; surface whatever we can read. const existing = readLockFile(lockPath) - if (existing && isLockActive(existing)) { + if (existing && existing.pid !== options.takeoverFrom && isLockActive(existing)) { return { existing } } return { release: () => {} } diff --git a/packages/nuxt-cli/test/unit/lockfile.spec.ts b/packages/nuxt-cli/test/unit/lockfile.spec.ts index e5314438..6be4f014 100644 --- a/packages/nuxt-cli/test/unit/lockfile.spec.ts +++ b/packages/nuxt-cli/test/unit/lockfile.spec.ts @@ -112,6 +112,44 @@ describe('lockfile', () => { } }) + it('takes over a live lock held by `takeoverFrom`', () => { + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true as unknown as true) + try { + writeFileSync(join(tempDir, 'nuxt.lock'), JSON.stringify({ + pid: 424242, + command: 'dev', + cwd: '/project', + startedAt: Date.now(), + })) + + const lock = acquireLock(tempDir, { command: 'dev', cwd: '/project' }, { takeoverFrom: 424242 }) + expect(lock.existing).toBeUndefined() + expect(JSON.parse(readFileSync(join(tempDir, 'nuxt.lock'), 'utf-8')).pid).toBe(process.pid) + lock.release!() + } + finally { + killSpy.mockRestore() + } + }) + + it('does not take over a live lock held by another PID', () => { + const killSpy = vi.spyOn(process, 'kill').mockImplementation(() => true as unknown as true) + try { + writeFileSync(join(tempDir, 'nuxt.lock'), JSON.stringify({ + pid: 424242, + command: 'dev', + cwd: '/project', + startedAt: Date.now(), + })) + + const lock = acquireLock(tempDir, { command: 'dev', cwd: '/project' }, { takeoverFrom: 111111 }) + expect(lock.existing?.pid).toBe(424242) + } + finally { + killSpy.mockRestore() + } + }) + it('takes over a lock whose PID is dead', async () => { writeFileSync(join(tempDir, 'nuxt.lock'), JSON.stringify({ pid: 999999999, From 5201155697fa1a5fed58e8eec4a729a229f86b60 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 27 Jul 2026 11:45:45 +0000 Subject: [PATCH 3/7] fix(dev): keep the outgoing dev server up until its replacement is ready --- packages/nuxt-cli/src/commands/dev.ts | 92 ++++++++++++++----- packages/nuxt-cli/src/dev/index.ts | 1 + packages/nuxt-cli/src/dev/pool.ts | 108 ++++++++++++++-------- packages/nuxt-cli/src/dev/utils.ts | 7 ++ packages/nuxt-cli/test/unit/pool.spec.ts | 112 +++++++++++++++++++++++ 5 files changed, 258 insertions(+), 62 deletions(-) create mode 100644 packages/nuxt-cli/test/unit/pool.spec.ts diff --git a/packages/nuxt-cli/src/commands/dev.ts b/packages/nuxt-cli/src/commands/dev.ts index aa1bb5d0..f4fb8a68 100644 --- a/packages/nuxt-cli/src/commands/dev.ts +++ b/packages/nuxt-cli/src/commands/dev.ts @@ -13,6 +13,7 @@ import { satisfies } from 'verkit' import { initialize } from '../dev' import { closeInspector, openInspector, resolveInspectOptions } from '../dev/inspect' +import { isReusePortSupported } from '../dev/listen' import { ForkPool } from '../dev/pool' import { formatRestartReason } from '../dev/reason' import { setupShortcuts } from '../dev/shortcuts' @@ -146,6 +147,11 @@ const command = defineCommand({ const listenOverrides = resolveListenOverrides(ctx.args) + // With `SO_REUSEPORT` an incoming fork can bind the port before this process + // releases it, so a hard restart never leaves the port unserved. + const reusePort = ctx.args.fork && !ctx.args.profile && await isReusePortSupported() + listenOverrides.reusePort = reusePort + // The inspector belongs to whichever process is currently serving the app: // this one until a hard restart hands over to a fork. const inspect = resolveInspectOptions(ctx.rawArgs) @@ -191,39 +197,79 @@ const command = defineCommand({ // On hard restart, use a fork from the pool let cleanupCurrentFork: (() => Promise) | undefined + // Whatever is serving the app right now: this process, then each fork in turn. + let closeCurrent = close + let currentPid = process.pid async function restartWithFork(reason?: DevRestartReason) { logger.info(formatRestartReason(reason, { rootDir: cwd, hard: true })) + // The inspector port cannot be shared, so the handover has to stay + // serialised whenever the inspector is open. + const handover = reusePort && !inspect + // Get a fork from the pool (warm if available, cold otherwise) - const context: NuxtDevContext = { cwd, args: ctx.args } - - // Release the inspector port before the incoming fork tries to bind it - await Promise.all([ - cleanupCurrentFork?.(), - inspect ? closeInspector() : undefined, - ]) - - cleanupCurrentFork = await pool.getFork(context, (message) => { - // Handle IPC messages from the fork - if (message.type === 'nuxt:internal:dev:ready') { - if (startTime) { - debug(`Dev server ready for connections in ${Date.now() - startTime}ms`) + const context: NuxtDevContext = { + cwd, + args: ctx.args, + handoverFrom: handover ? currentPid : undefined, + } + + if (!handover) { + await Promise.all([ + closeCurrent(), + inspect ? closeInspector() : undefined, + ]) + } + + let serving = false + const fork = await pool.getFork(context, { + listenOverrides: handover + ? { port: listener.address.port, handover: true } + : undefined, + onMessage: (message) => { + // Handle IPC messages from the fork + if (message.type === 'nuxt:internal:dev:ready') { + if (startTime) { + debug(`Dev server ready for connections in ${Date.now() - startTime}ms`) + } } - } - else if (message.type === 'nuxt:internal:dev:restart') { - // Fork is requesting another restart - void restartWithFork(message.reason) - } - else if (message.type === 'nuxt:internal:dev:rejection') { - void restartWithFork({ type: 'error', message: message.message }) - } + else if (!serving) { + // Failures before the fork serves anything are handled by the + // handover below, which leaves the outgoing server in place. + } + else if (message.type === 'nuxt:internal:dev:restart') { + // Fork is requesting another restart + void restartWithFork(message.reason) + } + else if (message.type === 'nuxt:internal:dev:rejection') { + void restartWithFork({ type: 'error', message: message.message }) + } + }, }) + + try { + await fork.serving + } + catch (error) { + await fork.close() + logger.error(`Could not restart the dev server, keeping the current one: ${error instanceof Error ? error.message : error}`) + if (handover) { + onRestart(restart) + } + return + } + + serving = true + fork.promote() + const closePrevious = handover ? closeCurrent : undefined + cleanupCurrentFork = fork.close + closeCurrent = fork.close + currentPid = fork.pid ?? currentPid + await closePrevious?.() } async function restart(reason?: DevRestartReason) { - // Close the in-process dev server - await close() await restartWithFork(reason) } diff --git a/packages/nuxt-cli/src/dev/index.ts b/packages/nuxt-cli/src/dev/index.ts index dd98f252..1b10346a 100644 --- a/packages/nuxt-cli/src/dev/index.ts +++ b/packages/nuxt-cli/src/dev/index.ts @@ -99,6 +99,7 @@ export async function initialize(devContext: NuxtDevContext, ctx: InitializeOpti envName: devContext.args.envName, showBanner: ctx.showBanner !== false && !ipc.enabled, listenOverrides: ctx.listenOverrides, + handoverFrom: devContext.handoverFrom, }) let address: string diff --git a/packages/nuxt-cli/src/dev/pool.ts b/packages/nuxt-cli/src/dev/pool.ts index 82088935..e657d006 100644 --- a/packages/nuxt-cli/src/dev/pool.ts +++ b/packages/nuxt-cli/src/dev/pool.ts @@ -18,6 +18,23 @@ interface PooledFork { process: ChildProcess ready: Promise state: 'warming' | 'ready' | 'active' | 'dead' + /** Whether this fork is the one serving the app, so its crash ends the session. */ + serving: boolean +} + +export interface ActiveFork { + pid?: number + /** Resolves once the fork reports it is serving requests, rejects if it dies first. */ + serving: Promise + /** Promote the fork so that a later crash takes the dev session down. */ + promote: () => void + close: () => Promise +} + +interface GetForkOptions { + onMessage?: (message: NuxtDevIPCMessage) => void + /** Listen options for this fork only, merged over the pool-wide overrides. */ + listenOverrides?: Partial } export class ForkPool { @@ -59,55 +76,67 @@ export class ForkPool { } } - async getFork(context: NuxtDevContext, onMessage?: (message: NuxtDevIPCMessage) => void): Promise<() => Promise> { + async getFork(context: NuxtDevContext, options: GetForkOptions = {}): Promise { // Once the app is served by a fork, file changes are no longer visible to // this process, so a restart is the only signal left that more may follow. this.warming = true - // Try to get a ready fork from the pool - const readyFork = this.pool.find(f => f.state === 'ready') - - if (readyFork) { - readyFork.state = 'active' - if (onMessage) { - this.attachMessageHandler(readyFork.process, onMessage) - } - await this.sendContext(readyFork.process, context) + const pooledFork = this.pool.find(f => f.state === 'ready') + ?? this.pool.find(f => f.state === 'warming') - this.warmFork() - - return () => this.killFork(readyFork) + if (!pooledFork) { + debug('No pre-warmed forks available, starting cold fork') } - // No ready fork available, try a warming fork - const warmingFork = this.pool.find(f => f.state === 'warming') - if (warmingFork) { - await warmingFork.ready - warmingFork.state = 'active' - if (onMessage) { - this.attachMessageHandler(warmingFork.process, onMessage) - } - await this.sendContext(warmingFork.process, context) - - this.warmFork() - - return () => this.killFork(warmingFork) + const fork = pooledFork ?? this.createFork() + if (!pooledFork) { + this.pool.push(fork) } + await fork.ready + fork.state = 'active' - // No forks in pool, create a cold fork - debug('No pre-warmed forks available, starting cold fork') - const coldFork = this.createFork() - this.pool.push(coldFork) - await coldFork.ready - coldFork.state = 'active' - if (onMessage) { - this.attachMessageHandler(coldFork.process, onMessage) + const serving = this.trackServing(fork) + if (options.onMessage) { + this.attachMessageHandler(fork.process, options.onMessage) } - await this.sendContext(coldFork.process, context) + await this.sendContext(fork.process, context, options.listenOverrides) this.warmFork() - return () => this.killFork(coldFork) + return { + pid: fork.process.pid, + serving, + promote: () => { + fork.serving = true + }, + close: () => this.killFork(fork), + } + } + + /** + * Resolves when the fork has bound its listener and is answering requests, so + * the caller can keep the outgoing server up until then. + */ + private trackServing(fork: PooledFork): Promise { + return new Promise((resolve, reject) => { + function settle(finish: () => void) { + fork.process.off('message', onMessage) + fork.process.off('close', onExit) + fork.process.off('error', onExit) + finish() + } + function onMessage(message: NuxtDevIPCMessage) { + if (message.type === 'nuxt:internal:dev:ready' || message.type === 'nuxt:internal:dev:loading:error') { + settle(resolve) + } + } + function onExit() { + settle(() => reject(new Error('Dev server fork exited before it was ready.'))) + } + fork.process.on('message', onMessage) + fork.process.once('close', onExit) + fork.process.once('error', onExit) + }) } private attachMessageHandler(childProc: ChildProcess, onMessage: (message: NuxtDevIPCMessage) => void): void { @@ -160,6 +189,7 @@ export class ForkPool { process: childProc, ready, state: 'warming', + serving: false, } // Listen for fork-ready message @@ -177,7 +207,7 @@ export class ForkPool { // Handle unexpected exit childProc.on('close', (errorCode) => { - if (pooledFork.state === 'active' && errorCode) { + if (pooledFork.serving && errorCode) { // Active fork crashed process.exit(errorCode) } @@ -187,10 +217,10 @@ export class ForkPool { return pooledFork } - private async sendContext(childProc: ChildProcess, context: NuxtDevContext): Promise { + private async sendContext(childProc: ChildProcess, context: NuxtDevContext, listenOverrides?: Partial): Promise { childProc.send({ type: 'nuxt:internal:dev:context', - listenOverrides: this.listenOverrides, + listenOverrides: { ...this.listenOverrides, ...listenOverrides }, inspect: this.inspect, context, }) diff --git a/packages/nuxt-cli/src/dev/utils.ts b/packages/nuxt-cli/src/dev/utils.ts index fa8ffdb4..3eaa11cd 100644 --- a/packages/nuxt-cli/src/dev/utils.ts +++ b/packages/nuxt-cli/src/dev/utils.ts @@ -49,6 +49,8 @@ export type NuxtDevIPCMessage export interface NuxtDevContext { cwd: string + /** PID of the dev server this process is taking over from, if any. */ + handoverFrom?: number args: { clear?: boolean logLevel?: string @@ -74,6 +76,7 @@ interface NuxtDevServerOptions { loadingTemplate?: ({ loading }: { loading: string }) => string showBanner?: boolean listenOverrides?: DevListenOverrides + handoverFrom?: number } // https://regex101.com/r/7HkR5c/1 @@ -684,6 +687,10 @@ export class NuxtDevServer extends EventEmitter { const lock = acquireLock(buildDir, { command: 'dev', cwd: this.options.cwd, + }, { + // During a handover the outgoing dev server still holds the lock until + // this process is ready to serve. + takeoverFrom: this.options.handoverFrom, }) if (lock.existing) { console.error(formatLockError(lock.existing)) diff --git a/packages/nuxt-cli/test/unit/pool.spec.ts b/packages/nuxt-cli/test/unit/pool.spec.ts new file mode 100644 index 00000000..0e0c86b8 --- /dev/null +++ b/packages/nuxt-cli/test/unit/pool.spec.ts @@ -0,0 +1,112 @@ +import type { NuxtDevContext } from '../../src/dev/utils' + +import { EventEmitter } from 'node:events' +import process from 'node:process' + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +import { ForkPool } from '../../src/dev/pool' + +let nextPid = 1000 + +class FakeFork extends EventEmitter { + pid = nextPid++ + exitCode: number | null = null + sent: any[] = [] + killed?: NodeJS.Signals | number + + send(message: unknown) { + this.sent.push(message) + return true + } + + kill(signal: NodeJS.Signals | number) { + this.killed = signal + this.exitCode = 0 + this.emit('exit', 0, null) + return true + } + + ready() { + this.emit('message', { type: 'nuxt:internal:dev:fork-ready' }) + } +} + +const forks: FakeFork[] = [] +const fork = vi.hoisted(() => vi.fn()) + +vi.mock('node:child_process', () => ({ fork })) + +const context: NuxtDevContext = { cwd: '/app', args: {} as NuxtDevContext['args'] } + +function createPool() { + return new ForkPool({ rawArgs: [], listenOverrides: { port: 3000 } }) +} + +describe('fork pool', () => { + beforeEach(() => { + globalThis.__nuxt_cli__ = { ...globalThis.__nuxt_cli__, devEntry: '/app/dev.mjs' } + forks.length = 0 + fork.mockReset() + fork.mockImplementation(() => { + const child = new FakeFork() + forks.push(child) + queueMicrotask(() => child.ready()) + return child + }) + }) + + it('should resolve `serving` once the fork reports it is ready', async () => { + const active = await createPool().getFork(context) + const [child] = forks + + child!.emit('message', { type: 'nuxt:internal:dev:ready', address: 'http://localhost:3000' }) + + await expect(active.serving).resolves.toBeUndefined() + }) + + it('should reject `serving` when the fork dies before it is ready', async () => { + const active = await createPool().getFork(context) + const [child] = forks + + child!.emit('close', 1, null) + + await expect(active.serving).rejects.toThrow(/exited before it was ready/) + }) + + it('should not end the session when a fork dies before it serves the app', async () => { + const exit = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never) + const active = await createPool().getFork(context) + const [child] = forks + + child!.emit('close', 1, null) + await expect(active.serving).rejects.toThrow() + expect(exit).not.toHaveBeenCalled() + + const promoted = await createPool().getFork(context) + promoted.serving.catch(() => {}) + promoted.promote() + forks.find(f => f.pid === promoted.pid)!.emit('close', 1, null) + expect(exit).toHaveBeenCalledWith(1) + + exit.mockRestore() + }) + + it('should merge per-fork listen overrides into the context message', async () => { + await createPool().getFork(context, { listenOverrides: { port: 4000, handover: true } }) + + expect(forks[0]!.sent[0]).toMatchObject({ + type: 'nuxt:internal:dev:context', + listenOverrides: { port: 4000, handover: true }, + }) + }) + + it('should forward messages other than fork readiness', async () => { + const onMessage = vi.fn() + await createPool().getFork(context, { onMessage }) + forks[0]!.ready() + forks[0]!.emit('message', { type: 'nuxt:internal:dev:restart' }) + + expect(onMessage).toHaveBeenCalledExactlyOnceWith({ type: 'nuxt:internal:dev:restart' }) + }) +}) From 049d83b27c1464dca4181fc14c7b601f609c6b37 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 27 Jul 2026 15:04:39 +0000 Subject: [PATCH 4/7] fix(dev): do not report an ignored fork readiness failure as unhandled --- packages/nuxt-cli/src/dev/pool.ts | 4 ++++ packages/nuxt-cli/test/unit/pool.spec.ts | 1 - 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/nuxt-cli/src/dev/pool.ts b/packages/nuxt-cli/src/dev/pool.ts index e657d006..45218b04 100644 --- a/packages/nuxt-cli/src/dev/pool.ts +++ b/packages/nuxt-cli/src/dev/pool.ts @@ -96,6 +96,10 @@ export class ForkPool { fork.state = 'active' const serving = this.trackServing(fork) + // Callers that never await `serving` (a caller that only wants the fork, or + // one that has already given up on it) must not turn its rejection into an + // unhandled rejection. + serving.catch(() => {}) if (options.onMessage) { this.attachMessageHandler(fork.process, options.onMessage) } diff --git a/packages/nuxt-cli/test/unit/pool.spec.ts b/packages/nuxt-cli/test/unit/pool.spec.ts index 0e0c86b8..f6d8cd3a 100644 --- a/packages/nuxt-cli/test/unit/pool.spec.ts +++ b/packages/nuxt-cli/test/unit/pool.spec.ts @@ -84,7 +84,6 @@ describe('fork pool', () => { expect(exit).not.toHaveBeenCalled() const promoted = await createPool().getFork(context) - promoted.serving.catch(() => {}) promoted.promote() forks.find(f => f.pid === promoted.pid)!.emit('close', 1, null) expect(exit).toHaveBeenCalledWith(1) From 3e76651889f84764dd5b4a245c4bb764eb2ddd6b Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 27 Jul 2026 15:10:21 +0000 Subject: [PATCH 5/7] fix(dev): re-arm the restart hook without stacking listeners --- packages/nuxt-cli/src/dev/index.ts | 71 ++++++++++---- .../nuxt-cli/test/unit/restart-hook.spec.ts | 96 +++++++++++++++++++ 2 files changed, 146 insertions(+), 21 deletions(-) create mode 100644 packages/nuxt-cli/test/unit/restart-hook.spec.ts diff --git a/packages/nuxt-cli/src/dev/index.ts b/packages/nuxt-cli/src/dev/index.ts index 1b10346a..890d69f6 100644 --- a/packages/nuxt-cli/src/dev/index.ts +++ b/packages/nuxt-cli/src/dev/index.ts @@ -153,6 +153,8 @@ export async function initialize(devContext: NuxtDevContext, ctx: InitializeOpti let closePromise: Promise | undefined + const armRestart = createRestartHook(devServer) + return { listener: devServer.listener, reload: (reason?: DevRestartReason) => devServer.load(true, reason), @@ -182,26 +184,53 @@ export async function initialize(devContext: NuxtDevContext, ctx: InitializeOpti onFileChange: (callback: () => void) => { devServer.once('change', callback) }, - onRestart: (callback: (reason?: DevRestartReason) => void) => { - let restarted = false - function restart(reason?: DevRestartReason) { - if (!restarted) { - restarted = true - process.off('uncaughtException', restartOnError) - process.off('unhandledRejection', restartOnError) - callback(reason) - } - } - function restartOnError(error: unknown) { - if (isBrokenPipe(error)) { - debug('Ignoring broken pipe:', error) - return - } - restart({ type: 'error', message: formatErrorMessage(error) }) - } - devServer.once('restart', restart) - process.on('uncaughtException', restartOnError) - process.on('unhandledRejection', restartOnError) - }, + onRestart: armRestart, + } +} + +interface RestartSource { + once: (event: 'restart', handler: (reason?: DevRestartReason) => void) => void +} + +/** + * Connect the triggers for a hard restart (an explicit `restart` event, and + * errors that leave this process unable to serve) to a single callback, which + * fires at most once per arming. Re-arming after a restart that could not be + * completed swaps the callback in without stacking another set of listeners. + */ +export function createRestartHook(source: RestartSource): (callback: (reason?: DevRestartReason) => void) => void { + let callback: ((reason?: DevRestartReason) => void) | undefined + let fired = false + let armed = false + + function restart(reason?: DevRestartReason) { + if (fired) { + return + } + fired = true + armed = false + process.off('uncaughtException', restartOnError) + process.off('unhandledRejection', restartOnError) + callback?.(reason) + } + + function restartOnError(error: unknown) { + if (isBrokenPipe(error)) { + debug('Ignoring broken pipe:', error) + return + } + restart({ type: 'error', message: formatErrorMessage(error) }) + } + + return (next: (reason?: DevRestartReason) => void) => { + callback = next + fired = false + if (armed) { + return + } + armed = true + source.once('restart', restart) + process.on('uncaughtException', restartOnError) + process.on('unhandledRejection', restartOnError) } } diff --git a/packages/nuxt-cli/test/unit/restart-hook.spec.ts b/packages/nuxt-cli/test/unit/restart-hook.spec.ts new file mode 100644 index 00000000..2ef1cfcc --- /dev/null +++ b/packages/nuxt-cli/test/unit/restart-hook.spec.ts @@ -0,0 +1,96 @@ +import type { DevRestartReason } from '../../src/dev/reason' + +import { EventEmitter } from 'node:events' +import process from 'node:process' + +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { createRestartHook } from '../../src/dev' + +const ERROR_EVENTS = ['uncaughtException', 'unhandledRejection'] as const + +function errorListeners() { + return ERROR_EVENTS.flatMap(event => process.listeners(event as 'uncaughtException')) +} + +describe('restart hook', () => { + const installed = new Set<(...args: any[]) => void>() + + afterEach(() => { + for (const listener of installed) { + for (const event of ERROR_EVENTS) { + process.off(event, listener as never) + } + } + installed.clear() + }) + + /** Arm the hook, remembering its process listeners so they can be removed. */ + function arm(source: EventEmitter, callback: (reason?: DevRestartReason) => void, armRestart = createRestartHook(source)) { + const before = new Set(errorListeners()) + armRestart(callback) + for (const listener of errorListeners()) { + if (!before.has(listener)) { + installed.add(listener) + } + } + return armRestart + } + + it('should call the restart callback once', () => { + const source = new EventEmitter() + const callback = vi.fn() + arm(source, callback) + + source.emit('restart', { type: 'shortcut' }) + source.emit('restart', { type: 'shortcut' }) + + expect(callback).toHaveBeenCalledExactlyOnceWith({ type: 'shortcut' }) + }) + + it('should restart on an error that is not a broken pipe', () => { + const source = new EventEmitter() + const callback = vi.fn() + arm(source, callback) + + const [onError] = [...installed] + onError!(Object.assign(new Error('write EPIPE'), { code: 'EPIPE' })) + expect(callback).not.toHaveBeenCalled() + + onError!(new Error('boom')) + expect(callback).toHaveBeenCalledWith({ type: 'error', message: expect.stringContaining('boom') }) + }) + + it('should not stack listeners when re-armed', () => { + const source = new EventEmitter() + const first = vi.fn() + const second = vi.fn() + const armRestart = arm(source, first) + + source.emit('restart', { type: 'shortcut' }) + const afterRestart = errorListeners().length + arm(source, second, armRestart) + + expect(errorListeners().length).toBe(afterRestart + ERROR_EVENTS.length) + expect(source.listenerCount('restart')).toBe(1) + + source.emit('restart', { type: 'config' }) + expect(first).toHaveBeenCalledTimes(1) + expect(second).toHaveBeenCalledExactlyOnceWith({ type: 'config' }) + }) + + it('should replace the callback without re-arming while still armed', () => { + const source = new EventEmitter() + const first = vi.fn() + const second = vi.fn() + const armRestart = arm(source, first) + const armed = errorListeners().length + + arm(source, second, armRestart) + + expect(errorListeners().length).toBe(armed) + source.emit('restart') + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledTimes(1) + }) +}) From 668796db01dddd4b7edd071b11f0417b97db87f6 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 27 Jul 2026 15:10:22 +0000 Subject: [PATCH 6/7] fix(dev): serialise hard restarts and recover from a failed fork --- packages/nuxt-cli/src/commands/dev.ts | 83 ++++++++++++++++++--------- 1 file changed, 55 insertions(+), 28 deletions(-) diff --git a/packages/nuxt-cli/src/commands/dev.ts b/packages/nuxt-cli/src/commands/dev.ts index f4fb8a68..26146be9 100644 --- a/packages/nuxt-cli/src/commands/dev.ts +++ b/packages/nuxt-cli/src/commands/dev.ts @@ -1,6 +1,7 @@ import type { ParsedArgs } from 'citty' import type { HTTPSOptions } from '../dev/cert' import type { DevListenOverrides } from '../dev/listen' +import type { ActiveFork } from '../dev/pool' import type { DevRestartReason } from '../dev/reason' import type { NuxtDevContext } from '../dev/utils' @@ -201,7 +202,30 @@ const command = defineCommand({ let closeCurrent = close let currentPid = process.pid - async function restartWithFork(reason?: DevRestartReason) { + // A hard restart can be asked for from several places at once (the config + // watcher, the `r` shortcut, and the fork that is currently serving), and two + // overlapping handovers would both bind the port and then race over which one + // is recorded as current. + let inFlight: Promise | undefined + let pendingReason: { reason?: DevRestartReason } | undefined + + function restartWithFork(reason?: DevRestartReason): Promise { + if (inFlight) { + pendingReason = { reason } + return inFlight + } + inFlight = replaceWithFork(reason).finally(() => { + inFlight = undefined + const pending = pendingReason + pendingReason = undefined + if (pending) { + void restartWithFork(pending.reason) + } + }) + return inFlight + } + + async function replaceWithFork(reason?: DevRestartReason) { logger.info(formatRestartReason(reason, { rootDir: cwd, hard: true })) // The inspector port cannot be shared, so the handover has to stay @@ -223,37 +247,40 @@ const command = defineCommand({ } let serving = false - const fork = await pool.getFork(context, { - listenOverrides: handover - ? { port: listener.address.port, handover: true } - : undefined, - onMessage: (message) => { - // Handle IPC messages from the fork - if (message.type === 'nuxt:internal:dev:ready') { - if (startTime) { - debug(`Dev server ready for connections in ${Date.now() - startTime}ms`) - } - } - else if (!serving) { - // Failures before the fork serves anything are handled by the - // handover below, which leaves the outgoing server in place. - } - else if (message.type === 'nuxt:internal:dev:restart') { - // Fork is requesting another restart - void restartWithFork(message.reason) - } - else if (message.type === 'nuxt:internal:dev:rejection') { - void restartWithFork({ type: 'error', message: message.message }) - } - }, - }) - + let fork: ActiveFork | undefined try { + fork = await pool.getFork(context, { + listenOverrides: handover + ? { port: listener.address.port, handover: true } + : undefined, + onMessage: (message) => { + // Handle IPC messages from the fork + if (message.type === 'nuxt:internal:dev:ready') { + if (startTime) { + debug(`Dev server ready for connections in ${Date.now() - startTime}ms`) + } + } + else if (!serving) { + // Failures before the fork serves anything are handled below, which + // leaves the outgoing server in place. + } + else if (message.type === 'nuxt:internal:dev:restart') { + // Fork is requesting another restart + void restartWithFork(message.reason) + } + else if (message.type === 'nuxt:internal:dev:rejection') { + void restartWithFork({ type: 'error', message: message.message }) + } + }, + }) await fork.serving } catch (error) { - await fork.close() - logger.error(`Could not restart the dev server, keeping the current one: ${error instanceof Error ? error.message : error}`) + await fork?.close() + const detail = error instanceof Error ? error.message : String(error) + logger.error(handover + ? `Could not restart the dev server, keeping the current one: ${detail}` + : `Could not restart the dev server: ${detail}`) if (handover) { onRestart(restart) } From e8d6ea1deadbe4567faca52d966b6095a9648749 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 27 Jul 2026 15:41:01 +0000 Subject: [PATCH 7/7] fix(dev): settle a fork's readiness and detach its restart listener on exit --- packages/nuxt-cli/src/dev/index.ts | 4 ++++ packages/nuxt-cli/src/dev/pool.ts | 3 +++ packages/nuxt-cli/test/unit/pool.spec.ts | 11 +++++++++++ packages/nuxt-cli/test/unit/restart-hook.spec.ts | 12 ++++++++++++ 4 files changed, 30 insertions(+) diff --git a/packages/nuxt-cli/src/dev/index.ts b/packages/nuxt-cli/src/dev/index.ts index 890d69f6..99713af1 100644 --- a/packages/nuxt-cli/src/dev/index.ts +++ b/packages/nuxt-cli/src/dev/index.ts @@ -190,6 +190,7 @@ export async function initialize(devContext: NuxtDevContext, ctx: InitializeOpti interface RestartSource { once: (event: 'restart', handler: (reason?: DevRestartReason) => void) => void + off: (event: 'restart', handler: (reason?: DevRestartReason) => void) => void } /** @@ -209,6 +210,9 @@ export function createRestartHook(source: RestartSource): (callback: (reason?: D } fired = true armed = false + // An error-triggered restart leaves the `restart` listener in place, since + // `once` only removes it when the event itself fires. + source.off('restart', restart) process.off('uncaughtException', restartOnError) process.off('unhandledRejection', restartOnError) callback?.(reason) diff --git a/packages/nuxt-cli/src/dev/pool.ts b/packages/nuxt-cli/src/dev/pool.ts index 45218b04..e8c6e705 100644 --- a/packages/nuxt-cli/src/dev/pool.ts +++ b/packages/nuxt-cli/src/dev/pool.ts @@ -211,6 +211,9 @@ export class ForkPool { // Handle unexpected exit childProc.on('close', (errorCode) => { + // A fork can exit without ever emitting `error` (a throw while loading the + // 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) { // Active fork crashed process.exit(errorCode) diff --git a/packages/nuxt-cli/test/unit/pool.spec.ts b/packages/nuxt-cli/test/unit/pool.spec.ts index f6d8cd3a..1af1b94a 100644 --- a/packages/nuxt-cli/test/unit/pool.spec.ts +++ b/packages/nuxt-cli/test/unit/pool.spec.ts @@ -91,6 +91,17 @@ describe('fork pool', () => { exit.mockRestore() }) + it('should reject when the fork exits before it starts', async () => { + fork.mockImplementation(() => { + const child = new FakeFork() + forks.push(child) + queueMicrotask(() => child.emit('close', 1, null)) + return child + }) + + await expect(createPool().getFork(context)).rejects.toThrow(/exited before it finished starting/) + }) + it('should merge per-fork listen overrides into the context message', async () => { await createPool().getFork(context, { listenOverrides: { port: 4000, handover: true } }) diff --git a/packages/nuxt-cli/test/unit/restart-hook.spec.ts b/packages/nuxt-cli/test/unit/restart-hook.spec.ts index 2ef1cfcc..7849f08f 100644 --- a/packages/nuxt-cli/test/unit/restart-hook.spec.ts +++ b/packages/nuxt-cli/test/unit/restart-hook.spec.ts @@ -79,6 +79,18 @@ describe('restart hook', () => { expect(second).toHaveBeenCalledExactlyOnceWith({ type: 'config' }) }) + it('should not stack listeners when re-armed after an error-triggered restart', () => { + const source = new EventEmitter() + const armRestart = arm(source, vi.fn()) + + for (let attempt = 0; attempt < 3; attempt++) { + const [onError] = [...installed] + onError!(new Error('boom')) + arm(source, vi.fn(), armRestart) + expect(source.listenerCount('restart')).toBe(1) + } + }) + it('should replace the callback without re-arming while still armed', () => { const source = new EventEmitter() const first = vi.fn()