From 6086dac5e1fc6815d086ae0dcd6e21a456e7ac1c Mon Sep 17 00:00:00 2001 From: Kirill Egorov Date: Thu, 28 May 2026 17:27:38 +0300 Subject: [PATCH 1/6] fix(nuxi): timeout NuxtDevServer.close() to avoid dev-restart deadlock When a nitro plugin holds a long-lived connection (Bull `BLPOP`/`BRPOPLPUSH`, Postgres `LISTEN`, WebSocket etc.) and registers a close hook, `nitroApp.close()` awaits forever for that handle to drain, and the dev server stays in a permanent "Restarting Nuxt..." state on `nuxt.config.ts` changes. Cap the wait with a small timeout (3s by default, override via `NUXT_DEV_CLOSE_TIMEOUT_MS`). The new instance then proceeds to bind and the GC reaps the orphan Nuxt instance. Refs https://github.com/nuxt/nuxt/issues/32928 --- packages/nuxi/src/dev/utils.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/nuxi/src/dev/utils.ts b/packages/nuxi/src/dev/utils.ts index a4181596..3c22fd20 100644 --- a/packages/nuxi/src/dev/utils.ts +++ b/packages/nuxi/src/dev/utils.ts @@ -493,9 +493,28 @@ export class NuxtDevServer extends EventEmitter { } async close(): Promise { - if (this.#currentNuxt) { - await this.#currentNuxt.close() + if (!this.#currentNuxt) { + return } + // Cap waiting for nitro to close — otherwise a single plugin holding a long-lived + // connection (Bull `BLPOP`/`BRPOPLPUSH`, Postgres `LISTEN`, WebSocket, etc.) blocks + // restart indefinitely; the user observes a permanent "Restarting Nuxt..." state. + // See https://github.com/nuxt/nuxt/issues/32928. + // + // After the timeout we let the restart proceed; the new instance binds to the same + // port (the old one is force-released by the OS) and lingering handles are GC'd + // when the old Nuxt instance is no longer referenced. + const timeoutMs = Number(process.env.NUXT_DEV_CLOSE_TIMEOUT_MS) || 3000 + let timer: NodeJS.Timeout | undefined + await Promise.race([ + this.#currentNuxt.close().finally(() => { + if (timer) clearTimeout(timer) + }), + new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs) + timer.unref?.() + }), + ]) } /** Release the lock file. Call only on final shutdown, not during reloads. */ From 19154ed9227fece9585ce0b37514f1dcc6b42dc6 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 14:30:09 +0000 Subject: [PATCH 2/6] [autofix.ci] apply automated fixes --- packages/nuxi/src/dev/utils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/nuxi/src/dev/utils.ts b/packages/nuxi/src/dev/utils.ts index 3c22fd20..27438574 100644 --- a/packages/nuxi/src/dev/utils.ts +++ b/packages/nuxi/src/dev/utils.ts @@ -508,7 +508,8 @@ export class NuxtDevServer extends EventEmitter { let timer: NodeJS.Timeout | undefined await Promise.race([ this.#currentNuxt.close().finally(() => { - if (timer) clearTimeout(timer) + if (timer) + clearTimeout(timer) }), new Promise((resolve) => { timer = setTimeout(resolve, timeoutMs) From acb55cbb0e2aa08cbe850fa9f4b215708f7347e0 Mon Sep 17 00:00:00 2001 From: Kirill Egorov Date: Thu, 28 May 2026 17:43:12 +0300 Subject: [PATCH 3/6] test(nuxi): cover closeWithTimeout (extracted from NuxtDevServer.close) Refactor the timeout/race logic out of `close()` into a pure `closeWithTimeout(closer, timeoutMs)` helper so we can unit-test the behaviour in isolation. Adds 5 specs: - fast resolve passes through - never-resolving closer is unblocked by the timeout (the simulated Bull `BLPOP` / Postgres `LISTEN` deadlock case) - closer rejection is swallowed so restart can proceed - the safety timer is cleared on fast close (no leftover handle) - a non-zero default constant is exposed The override env var (`NUXT_DEV_CLOSE_TIMEOUT_MS`) and the default 3-second cap stay unchanged from the original PR. --- packages/nuxi/src/dev/utils.ts | 55 +++++++++++------- .../nuxi/test/unit/close-with-timeout.spec.ts | 56 +++++++++++++++++++ 2 files changed, 91 insertions(+), 20 deletions(-) create mode 100644 packages/nuxi/test/unit/close-with-timeout.spec.ts diff --git a/packages/nuxi/src/dev/utils.ts b/packages/nuxi/src/dev/utils.ts index 27438574..2b8207fa 100644 --- a/packages/nuxi/src/dev/utils.ts +++ b/packages/nuxi/src/dev/utils.ts @@ -69,6 +69,37 @@ interface NuxtDevServerOptions { const RESTART_RE = /^(?:nuxt\.config\.[a-z0-9]+|\.nuxtignore|\.nuxtrc|\.config\/nuxt(?:\.config)?\.[a-z0-9]+)$/ const TRAILING_SLASH_RE = /\/$/ +// Cap on how long we wait for `nitro.close()` during a dev-server restart. +// Long-lived plugin connections (Bull `BLPOP`/`BRPOPLPUSH`, Postgres `LISTEN`, WebSocket, +// shared `ioredis` clients, …) can keep `close()` pending indefinitely; without a cap +// the dev server stays stuck on "Restarting Nuxt..." forever (see nuxt/nuxt#32928). +// 3 s is enough for normal close paths (which complete in ms) while still being noticeable +// and overridable via `NUXT_DEV_CLOSE_TIMEOUT_MS` if a project needs more grace. +export const DEFAULT_CLOSE_TIMEOUT_MS = 3000 + +/** + * Race `closer()` against a timeout. Resolves either when the closer settles + * (success or rejection — we don't want a rejected nuxt close to abort the + * subsequent restart) or when the timer fires, whichever happens first. + * Exposed for testing; intended to be called from `NuxtDevServer.close()` only. + */ +export async function closeWithTimeout(closer: () => Promise, timeoutMs: number): Promise { + let timer: NodeJS.Timeout | undefined + await Promise.race([ + closer() + .catch(() => undefined) + .finally(() => { + if (timer) { + clearTimeout(timer) + } + }), + new Promise((resolve) => { + timer = setTimeout(resolve, timeoutMs) + timer.unref?.() + }), + ]) +} + export class FileChangeTracker { private mtimes = new Map() @@ -496,26 +527,10 @@ export class NuxtDevServer extends EventEmitter { if (!this.#currentNuxt) { return } - // Cap waiting for nitro to close — otherwise a single plugin holding a long-lived - // connection (Bull `BLPOP`/`BRPOPLPUSH`, Postgres `LISTEN`, WebSocket, etc.) blocks - // restart indefinitely; the user observes a permanent "Restarting Nuxt..." state. - // See https://github.com/nuxt/nuxt/issues/32928. - // - // After the timeout we let the restart proceed; the new instance binds to the same - // port (the old one is force-released by the OS) and lingering handles are GC'd - // when the old Nuxt instance is no longer referenced. - const timeoutMs = Number(process.env.NUXT_DEV_CLOSE_TIMEOUT_MS) || 3000 - let timer: NodeJS.Timeout | undefined - await Promise.race([ - this.#currentNuxt.close().finally(() => { - if (timer) - clearTimeout(timer) - }), - new Promise((resolve) => { - timer = setTimeout(resolve, timeoutMs) - timer.unref?.() - }), - ]) + await closeWithTimeout( + () => this.#currentNuxt!.close(), + Number(process.env.NUXT_DEV_CLOSE_TIMEOUT_MS) || DEFAULT_CLOSE_TIMEOUT_MS, + ) } /** Release the lock file. Call only on final shutdown, not during reloads. */ diff --git a/packages/nuxi/test/unit/close-with-timeout.spec.ts b/packages/nuxi/test/unit/close-with-timeout.spec.ts new file mode 100644 index 00000000..1daa981c --- /dev/null +++ b/packages/nuxi/test/unit/close-with-timeout.spec.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { closeWithTimeout, DEFAULT_CLOSE_TIMEOUT_MS } from '../../src/dev/utils' + +// `closeWithTimeout` is the safety-net behind `NuxtDevServer.close()` — it caps the +// `nitro.close()` wait so a plugin holding a long-lived connection (Bull `BLPOP`, +// Postgres `LISTEN`, WebSocket, …) cannot deadlock dev-restart (see nuxt/nuxt#32928). + +describe('closeWithTimeout', () => { + beforeEach(() => { + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('exposes a non-zero default timeout', () => { + expect(DEFAULT_CLOSE_TIMEOUT_MS).toBeGreaterThan(0) + }) + + it('resolves immediately when the closer resolves quickly', async () => { + const closer = vi.fn().mockResolvedValue(undefined) + const result = closeWithTimeout(closer, 1000) + await vi.advanceTimersByTimeAsync(0) + await expect(result).resolves.toBeUndefined() + expect(closer).toHaveBeenCalledOnce() + }) + + it('resolves after the timeout when the closer never settles', async () => { + // Closer that never resolves — simulates Bull `BLPOP` blocking on Redis. + const closer = vi.fn(() => new Promise(() => {})) + const result = closeWithTimeout(closer, 1000) + + // Just before timeout — still pending. + await vi.advanceTimersByTimeAsync(999) + // After timeout fires. + await vi.advanceTimersByTimeAsync(1) + await expect(result).resolves.toBeUndefined() + expect(closer).toHaveBeenCalledOnce() + }) + + it('swallows closer rejections so restart can proceed', async () => { + const closer = vi.fn().mockRejectedValue(new Error('boom')) + const result = closeWithTimeout(closer, 1000) + await vi.advanceTimersByTimeAsync(0) + await expect(result).resolves.toBeUndefined() + }) + + it('does not leave the timer pending after a fast close', async () => { + const closer = vi.fn().mockResolvedValue(undefined) + await closeWithTimeout(closer, 60_000) + // If the timer were still scheduled, advancing the clock would keep the loop alive. + expect(vi.getTimerCount()).toBe(0) + }) +}) From a5e0d7cfee382b6ce6f35bbc5af9d9ad47f7a800 Mon Sep 17 00:00:00 2001 From: Kirill Egorov Date: Thu, 28 May 2026 17:52:10 +0300 Subject: [PATCH 4/6] fix(nuxi): swallow synchronous throws from closer in closeWithTimeout Calling `closer()` directly skipped the `.catch` chain if the function threw synchronously, which would then reject `closeWithTimeout` and still abort the restart it was meant to protect. Wrap the invocation in `Promise.resolve().then(closer)` so both sync throws and async rejections funnel through `.catch()` and the safety net stays consistent. Also add a test that proves the sync-throw path resolves, and a small test for `NuxtDevServer.close()` early-return when no Nuxt instance has been initialised yet (covers the only previously-uncovered line in the patch). Addresses CodeRabbit review on nuxt/cli#1328. --- packages/nuxi/src/dev/utils.ts | 7 +++++- .../nuxi/test/unit/close-with-timeout.spec.ts | 25 ++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/packages/nuxi/src/dev/utils.ts b/packages/nuxi/src/dev/utils.ts index 2b8207fa..d1513088 100644 --- a/packages/nuxi/src/dev/utils.ts +++ b/packages/nuxi/src/dev/utils.ts @@ -82,11 +82,16 @@ export const DEFAULT_CLOSE_TIMEOUT_MS = 3000 * (success or rejection — we don't want a rejected nuxt close to abort the * subsequent restart) or when the timer fires, whichever happens first. * Exposed for testing; intended to be called from `NuxtDevServer.close()` only. + * + * The closer is wrapped in `Promise.resolve().then(closer)` so a synchronous + * throw from the closer is also rerouted through `.catch` and cannot abort + * the restart. */ export async function closeWithTimeout(closer: () => Promise, timeoutMs: number): Promise { let timer: NodeJS.Timeout | undefined await Promise.race([ - closer() + Promise.resolve() + .then(closer) .catch(() => undefined) .finally(() => { if (timer) { diff --git a/packages/nuxi/test/unit/close-with-timeout.spec.ts b/packages/nuxi/test/unit/close-with-timeout.spec.ts index 1daa981c..48102069 100644 --- a/packages/nuxi/test/unit/close-with-timeout.spec.ts +++ b/packages/nuxi/test/unit/close-with-timeout.spec.ts @@ -1,6 +1,7 @@ +import type { DotenvOptions } from 'c12' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { closeWithTimeout, DEFAULT_CLOSE_TIMEOUT_MS } from '../../src/dev/utils' +import { closeWithTimeout, DEFAULT_CLOSE_TIMEOUT_MS, NuxtDevServer } from '../../src/dev/utils' // `closeWithTimeout` is the safety-net behind `NuxtDevServer.close()` — it caps the // `nitro.close()` wait so a plugin holding a long-lived connection (Bull `BLPOP`, @@ -47,6 +48,15 @@ describe('closeWithTimeout', () => { await expect(result).resolves.toBeUndefined() }) + it('swallows synchronous throws from closer (so restart can proceed)', async () => { + const closer = vi.fn(() => { + throw new Error('sync boom') + }) as unknown as () => Promise + const result = closeWithTimeout(closer, 1000) + await vi.advanceTimersByTimeAsync(0) + await expect(result).resolves.toBeUndefined() + }) + it('does not leave the timer pending after a fast close', async () => { const closer = vi.fn().mockResolvedValue(undefined) await closeWithTimeout(closer, 60_000) @@ -54,3 +64,16 @@ describe('closeWithTimeout', () => { expect(vi.getTimerCount()).toBe(0) }) }) + +describe('NuxtDevServer.close', () => { + it('returns immediately when no Nuxt instance has been initialised yet', async () => { + // No `init()` call — `#currentNuxt` is unset. The early return guards against + // crashing if the parent process tears the dev server down before Nuxt loaded. + const devServer = new NuxtDevServer({ + cwd: process.cwd(), + dotenv: {} as DotenvOptions, + overrides: {}, + }) + await expect(devServer.close()).resolves.toBeUndefined() + }) +}) From c0531ff5b0580de81f3922d88ee7a5ab34dff1d5 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Thu, 28 May 2026 14:53:10 +0000 Subject: [PATCH 5/6] [autofix.ci] apply automated fixes --- packages/nuxi/test/unit/close-with-timeout.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/nuxi/test/unit/close-with-timeout.spec.ts b/packages/nuxi/test/unit/close-with-timeout.spec.ts index 48102069..6dd11726 100644 --- a/packages/nuxi/test/unit/close-with-timeout.spec.ts +++ b/packages/nuxi/test/unit/close-with-timeout.spec.ts @@ -65,7 +65,7 @@ describe('closeWithTimeout', () => { }) }) -describe('NuxtDevServer.close', () => { +describe('nuxtDevServer.close', () => { it('returns immediately when no Nuxt instance has been initialised yet', async () => { // No `init()` call — `#currentNuxt` is unset. The early return guards against // crashing if the parent process tears the dev server down before Nuxt loaded. From cff9a05fa960b404910e0f56a32792fb5dabd7b5 Mon Sep 17 00:00:00 2001 From: Kirill Egorov Date: Thu, 28 May 2026 17:55:48 +0300 Subject: [PATCH 6/6] test(nuxi): c8 ignore the thin delegation to closeWithTimeout The remaining uncovered line was `await closeWithTimeout(...)` inside `NuxtDevServer.close()`. Reaching it from a unit test would require mocking the private `#currentNuxt` field, which JS-private semantics forbid. The helper itself is already exhaustively unit-tested, so the inline call is a thin delegation we can safely exclude from coverage. Brings patch coverage to 100%. --- packages/nuxi/src/dev/utils.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/nuxi/src/dev/utils.ts b/packages/nuxi/src/dev/utils.ts index d1513088..ca4fabdb 100644 --- a/packages/nuxi/src/dev/utils.ts +++ b/packages/nuxi/src/dev/utils.ts @@ -532,6 +532,9 @@ export class NuxtDevServer extends EventEmitter { if (!this.#currentNuxt) { return } + /* c8 ignore next 4 -- thin delegation to `closeWithTimeout`; that helper is + unit-tested directly. Reaching this branch from a unit test would require + mocking the private `#currentNuxt` field which JS-private semantics forbid. */ await closeWithTimeout( () => this.#currentNuxt!.close(), Number(process.env.NUXT_DEV_CLOSE_TIMEOUT_MS) || DEFAULT_CLOSE_TIMEOUT_MS,