Skip to content
Open
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
47 changes: 45 additions & 2 deletions packages/nuxi/src/dev/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,42 @@ 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.
*
* 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<void>, timeoutMs: number): Promise<void> {
let timer: NodeJS.Timeout | undefined
await Promise.race([
Promise.resolve()
.then(closer)
.catch(() => undefined)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.finally(() => {
if (timer) {
clearTimeout(timer)
}
}),
new Promise<void>((resolve) => {
timer = setTimeout(resolve, timeoutMs)
timer.unref?.()
}),
])
}

export class FileChangeTracker {
private mtimes = new Map<string, number>()

Expand Down Expand Up @@ -493,9 +529,16 @@ export class NuxtDevServer extends EventEmitter<DevServerEventMap> {
}

async close(): Promise<void> {
if (this.#currentNuxt) {
await this.#currentNuxt.close()
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,
)
Comment on lines +538 to +541
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate the env timeout before using it.

Number(...) || DEFAULT_CLOSE_TIMEOUT_MS does not guard against negative or non-finite values. Validating for finite positive numbers avoids accidental immediate/invalid timeout behavior.

Suggested fix
+    const envTimeout = Number(process.env.NUXT_DEV_CLOSE_TIMEOUT_MS)
+    const timeoutMs = Number.isFinite(envTimeout) && envTimeout > 0
+      ? envTimeout
+      : DEFAULT_CLOSE_TIMEOUT_MS
     await closeWithTimeout(
       () => this.#currentNuxt!.close(),
-      Number(process.env.NUXT_DEV_CLOSE_TIMEOUT_MS) || DEFAULT_CLOSE_TIMEOUT_MS,
+      timeoutMs,
     )
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
await closeWithTimeout(
() => this.#currentNuxt!.close(),
Number(process.env.NUXT_DEV_CLOSE_TIMEOUT_MS) || DEFAULT_CLOSE_TIMEOUT_MS,
)
const envTimeout = Number(process.env.NUXT_DEV_CLOSE_TIMEOUT_MS)
const timeoutMs = Number.isFinite(envTimeout) && envTimeout > 0
? envTimeout
: DEFAULT_CLOSE_TIMEOUT_MS
await closeWithTimeout(
() => this.#currentNuxt!.close(),
timeoutMs,
)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/nuxi/src/dev/utils.ts` around lines 530 - 533, The timeout value
derived from process.env.NUXT_DEV_CLOSE_TIMEOUT_MS is not validated and using
Number(...) || DEFAULT_CLOSE_TIMEOUT_MS can still allow negative, zero, NaN or
Infinity to sneak through; update the call site that invokes closeWithTimeout
(the lambda calling this.#currentNuxt!.close()) to first parse the env value
into a numeric variable (e.g., parsedTimeout), check
Number.isFinite(parsedTimeout) and parsedTimeout > 0, and only then pass
parsedTimeout to closeWithTimeout; otherwise fallback to
DEFAULT_CLOSE_TIMEOUT_MS so closeWithTimeout always receives a finite positive
timeout.

}

/** Release the lock file. Call only on final shutdown, not during reloads. */
Expand Down
79 changes: 79 additions & 0 deletions packages/nuxi/test/unit/close-with-timeout.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import type { DotenvOptions } from 'c12'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

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`,
// 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<void>(() => {}))
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('swallows synchronous throws from closer (so restart can proceed)', async () => {
const closer = vi.fn(() => {
throw new Error('sync boom')
}) as unknown as () => Promise<void>
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)
})
})

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()
})
})
Loading