From 2bac8cabc7170a29e2095ea1f1550a8056976ec5 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 27 Sep 2026 15:46:42 +0100 Subject: [PATCH 1/2] fix(dev): hotfix by force closing nitro sockets --- packages/nuxt-cli/package.json | 1 + .../nuxt-cli/runtime/dev-close-sockets.mjs | 20 +++++++ packages/nuxt-cli/src/dev/utils.ts | 29 ++++++---- .../test/e2e/dev-websocket-shutdown.spec.ts | 55 +++++++++++++++++++ 4 files changed, 95 insertions(+), 10 deletions(-) create mode 100644 packages/nuxt-cli/runtime/dev-close-sockets.mjs create mode 100644 packages/nuxt-cli/test/e2e/dev-websocket-shutdown.spec.ts diff --git a/packages/nuxt-cli/package.json b/packages/nuxt-cli/package.json index 255de8a51..a9520b4b1 100644 --- a/packages/nuxt-cli/package.json +++ b/packages/nuxt-cli/package.json @@ -12,6 +12,7 @@ "exports": { ".": "./dist/index.mjs", "./cli": "./bin/nuxi.mjs", + "./runtime/dev-close-sockets": "./runtime/dev-close-sockets.mjs", "./runtime/dev-request-context": "./runtime/dev-request-context.mjs" }, "types": "./dist/index.d.ts", diff --git a/packages/nuxt-cli/runtime/dev-close-sockets.mjs b/packages/nuxt-cli/runtime/dev-close-sockets.mjs new file mode 100644 index 000000000..f3f24f880 --- /dev/null +++ b/packages/nuxt-cli/runtime/dev-close-sockets.mjs @@ -0,0 +1,20 @@ +import { subscribe } from 'node:diagnostics_channel' + +/** + * Destroy sockets left open in the Nitro dev worker on shutdown, including + * upgraded ones that `server.closeAllConnections()` skips. + * + * TODO: remove once https://github.com/nitrojs/nitro/pull/4671 is released. + */ +export default function (nitroApp) { + const sockets = new Set() + subscribe('net.server.socket', ({ socket }) => { + sockets.add(socket) + socket.once('close', () => sockets.delete(socket)) + }) + nitroApp.hooks.hook('close', () => { + for (const socket of sockets) { + socket.destroy() + } + }) +} diff --git a/packages/nuxt-cli/src/dev/utils.ts b/packages/nuxt-cli/src/dev/utils.ts index 2f92c6741..71947472b 100644 --- a/packages/nuxt-cli/src/dev/utils.ts +++ b/packages/nuxt-cli/src/dev/utils.ts @@ -72,6 +72,16 @@ function registerRequestContextPlugin(nitro: NitroConfigForHook, cwd: string): v } } +function registerCloseSocketsPlugin(nitro: NitroConfigForHook): void { + try { + nitro.plugins ||= [] + nitro.plugins.push(fileURLToPath(import.meta.resolve('@nuxt/cli/runtime/dev-close-sockets'))) + } + catch (error) { + debug('Could not resolve the close sockets plugin:', error) + } +} + /** * The `consola` the app itself logs through, which is the one * `@nuxt/nitro-server` wraps `console` with: its own, not the CLI's. Reporting @@ -889,17 +899,16 @@ export class NuxtDevServer extends EventEmitter { clearScreen: this.options.clear, ...this.options.overrides.vite, }, - ...captureUIEvents - ? { - hooks: { - ...this.options.overrides.hooks, - 'nitro:config': (nitro) => { - registerRequestContextPlugin(nitro, this.options.cwd) - return this.options.overrides.hooks?.['nitro:config']?.(nitro) - }, - } satisfies NuxtConfig['hooks'], + hooks: { + ...this.options.overrides.hooks, + 'nitro:config': (nitro) => { + registerCloseSocketsPlugin(nitro) + if (captureUIEvents) { + registerRequestContextPlugin(nitro, this.options.cwd) } - : {}, + return this.options.overrides.hooks?.['nitro:config']?.(nitro) + }, + } satisfies NuxtConfig['hooks'], }, } diff --git a/packages/nuxt-cli/test/e2e/dev-websocket-shutdown.spec.ts b/packages/nuxt-cli/test/e2e/dev-websocket-shutdown.spec.ts new file mode 100644 index 000000000..3a0bd9d48 --- /dev/null +++ b/packages/nuxt-cli/test/e2e/dev-websocket-shutdown.spec.ts @@ -0,0 +1,55 @@ +import { spawn } from 'node:child_process' +import { once } from 'node:events' +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import process from 'node:process' +import { fileURLToPath } from 'node:url' +import { getPort } from 'get-port-please' +import { describe, expect, it } from 'vitest' +import { createDevFixture } from '../utils' + +const nuxi = fileURLToPath(new URL('../../bin/nuxi.mjs', import.meta.url)) +const fixtureDir = await createDevFixture('dev-websocket-shutdown') +await writeFile(join(fixtureDir, 'nuxt.config.ts'), 'export default defineNuxtConfig({ nitro: { experimental: { websocket: true } } })\n') +await mkdir(join(fixtureDir, 'server/routes'), { recursive: true }) +await writeFile(join(fixtureDir, 'server/routes/_ws.ts'), 'export default defineWebSocketHandler({ message(peer, message) { peer.send(message.text()) } })\n') + +describe('dev server shutdown', () => { + it('should exit promptly with an open websocket connection', { timeout: 120_000 }, async () => { + const host = '127.0.0.1' + const port = await getPort({ host, port: 3060 }) + // nitropack skips its graceful worker shutdown under test and CI + const env = Object.fromEntries(Object.entries(process.env).filter(([key]) => !['CI', 'TEST', 'VITEST', 'NODE_ENV', 'GITHUB_ACTIONS'].includes(key))) + const child = spawn(process.execPath, [nuxi, 'dev', '--no-fork', '--host', host, '--port', String(port)], { + cwd: fixtureDir, + env: { ...env, NO_COLOR: '1' }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let output = '' + const append = (data: string) => { + output += data + } + child.stdout.on('data', append) + child.stderr.on('data', append) + + try { + await expect.poll(() => output, { timeout: 90_000, interval: 250 }).toContain('Ready in') + + const ws = new WebSocket(`ws://${host}:${port}/_ws`) + const echo = await new Promise((resolve, reject) => { + ws.addEventListener('open', () => ws.send('ping')) + ws.addEventListener('message', event => resolve(String(event.data))) + ws.addEventListener('error', reject) + }) + expect(echo).toBe('ping') + + const start = performance.now() + child.kill('SIGINT') + await once(child, 'exit') + expect(performance.now() - start).toBeLessThan(5000) + } + finally { + child.kill('SIGKILL') + } + }) +}) From ac53d3dd8197d79dc37e37a0362c00131748f308 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Sun, 27 Sep 2026 16:15:12 +0100 Subject: [PATCH 2/2] chore: clean up subscription --- packages/nuxt-cli/runtime/dev-close-sockets.mjs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/nuxt-cli/runtime/dev-close-sockets.mjs b/packages/nuxt-cli/runtime/dev-close-sockets.mjs index f3f24f880..36ad78b06 100644 --- a/packages/nuxt-cli/runtime/dev-close-sockets.mjs +++ b/packages/nuxt-cli/runtime/dev-close-sockets.mjs @@ -1,4 +1,4 @@ -import { subscribe } from 'node:diagnostics_channel' +import { subscribe, unsubscribe } from 'node:diagnostics_channel' /** * Destroy sockets left open in the Nitro dev worker on shutdown, including @@ -8,11 +8,13 @@ import { subscribe } from 'node:diagnostics_channel' */ export default function (nitroApp) { const sockets = new Set() - subscribe('net.server.socket', ({ socket }) => { + const track = ({ socket }) => { sockets.add(socket) socket.once('close', () => sockets.delete(socket)) - }) + } + subscribe('net.server.socket', track) nitroApp.hooks.hook('close', () => { + unsubscribe('net.server.socket', track) for (const socket of sockets) { socket.destroy() }