Skip to content

fix(nuxi): timeout NuxtDevServer.close() to avoid dev-restart deadlock#1328

Open
mttzzz wants to merge 6 commits into
nuxt:mainfrom
mttzzz:fix/dev-restart-deadlock
Open

fix(nuxi): timeout NuxtDevServer.close() to avoid dev-restart deadlock#1328
mttzzz wants to merge 6 commits into
nuxt:mainfrom
mttzzz:fix/dev-restart-deadlock

Conversation

@mttzzz
Copy link
Copy Markdown

@mttzzz mttzzz commented May 28, 2026

Issue

Refs nuxt/nuxt#32928

The Nuxt dev server can enter a permanent "Restarting Nuxt..." state after a nuxt.config.ts edit (or other change that triggers a full reload) when any nitro plugin holds a long-lived background handle.

NuxtDevServer.close() awaits currentNuxt.close() which in turn awaits every registered close hook. If any plugin owns a connection that is blocked on a server response (Bull BLPOP/BRPOPLPUSH, Postgres LISTEN, native WebSocket, an ioredis-shared client, etc.) the close hook never resolves — and the restart deadlocks.

This is fairly easy to hit in real apps: queues (BullMQ/Bull), realtime endpoints, background pollers all rely on connections that don't drain in a few hundred ms.

Change

Wrap the inner close() in a Promise.race against a 3-second timeout. After the deadline the restart proceeds; the new Nuxt instance binds the port (the OS reclaims the bind from the orphaned process) and the lingering handles are GC'd along with the dropped reference to the old Nuxt instance.

The timeout is overridable via NUXT_DEV_CLOSE_TIMEOUT_MS for projects that legitimately need more grace (e.g. heavy in-flight migrations on close).

async close(): Promise<void> {
  if (!this.#currentNuxt) return
  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<void>((resolve) => {
      timer = setTimeout(resolve, timeoutMs)
      timer.unref?.()
    }),
  ])
}

Behaviour

  • Plugins that close cleanly (the vast majority): no observable change — close() resolves in milliseconds, timer is cleared, no leftover handles in the event loop.
  • Plugins that block on a long-lived socket: we wait timeoutMs, then proceed with the restart. Equivalent to the user pressing Ctrl-C after the deadlock and re-running bun dev, only automatic.

Trade-offs

This is intentionally a safety net rather than a root-cause fix — the real fix lives in the user's plugin (queue.close(true), await server.unref() on a websocket, etc.). But the failure mode is hard to diagnose for users (CLI says "Restarting Nuxt..." with no further output) and the timeout makes the dev experience consistent with what most people expect from an HMR-driven workflow.

Tested

Reproduced locally with a Nuxt 4.4.6 + 6 Bull workers + Postgres LISTEN setup that previously deadlocked indefinitely on every nuxt.config.ts save. With the patch, restart completes in 6-10s consistently.

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 nuxt/nuxt#32928
@mttzzz mttzzz requested a review from danielroe as a code owner May 28, 2026 14:29
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 28, 2026

  • nuxt-cli-playground

    npm i https://pkg.pr.new/create-nuxt@1328
    
    npm i https://pkg.pr.new/nuxi@1328
    
    npm i https://pkg.pr.new/@nuxt/cli@1328
    

commit: cff9a05

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 28, 2026

Review Change Stack

Warning

Review limit reached

@mttzzz, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 3 minutes and 25 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: abfe1ef3-65dc-48cc-ba4a-f7ed6d5a9508

📥 Commits

Reviewing files that changed from the base of the PR and between c0531ff and cff9a05.

📒 Files selected for processing (1)
  • packages/nuxi/src/dev/utils.ts
📝 Walkthrough

Walkthrough

Adds DEFAULT_CLOSE_TIMEOUT_MS and closeWithTimeout(closer, timeoutMs) which races a provided closer against a timeout, clears the timer when settled, and swallows closer rejections and sync throws. NuxtDevServer.close() now no-ops when no #currentNuxt exists and otherwise uses closeWithTimeout with a configured timeout. Vitest tests validate timeout expiry, immediate resolution, swallowed failures, and timer cleanup; a test also asserts NuxtDevServer.close() resolves when called before initialization.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main change: adding a timeout mechanism to NuxtDevServer.close() to prevent dev-restart deadlock.
Description check ✅ Passed The description is comprehensive and directly related to the changeset, explaining the issue, the solution, behavior, trade-offs, and testing approach.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@codecov-commenter
Copy link
Copy Markdown

codecov-commenter commented May 28, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
⚠️ Please upload report for BASE (main@9dc5bcc). Learn more about missing BASE report.

Additional details and impacted files
@@           Coverage Diff           @@
##             main    #1328   +/-   ##
=======================================
  Coverage        ?   52.32%           
=======================================
  Files           ?       49           
  Lines           ?     1227           
  Branches        ?      341           
=======================================
  Hits            ?      642           
  Misses          ?      479           
  Partials        ?      106           

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@codspeed-hq
Copy link
Copy Markdown

codspeed-hq Bot commented May 28, 2026

Merging this PR will not alter performance

✅ 2 untouched benchmarks


Comparing mttzzz:fix/dev-restart-deadlock (cff9a05) with main (9dc5bcc)

Open in CodSpeed

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 28, 2026

Actionable comments posted: 0

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.
@mttzzz
Copy link
Copy Markdown
Author

mttzzz commented May 28, 2026

Added a unit test for the extracted closeWithTimeout helper (5 specs covering the fast-close, never-resolving closer, rejection, and timer-cleanup paths). Should bring patch coverage to 100% and re-trigger the CodeRabbit review that was aborted by the autofix commit.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
packages/nuxi/test/unit/close-with-timeout.spec.ts (2)

43-48: 💤 Low value

Consider verifying timer cleanup in the rejection test.

Test 5 (lines 50-55) verifies that timers are cleaned up after a fast close, but this rejection test doesn't include a similar check. Adding expect(vi.getTimerCount()).toBe(0) would ensure consistent verification that the timer is cleared in the .finally() block even when the closer rejects.

🧹 Proposed enhancement
 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()
+  expect(vi.getTimerCount()).toBe(0)
 })
🤖 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/test/unit/close-with-timeout.spec.ts` around lines 43 - 48, The
rejection test for closeWithTimeout should also verify the timers are cleaned
up: after calling closeWithTimeout(closer, 1000) and advancing timers
(vi.advanceTimersByTimeAsync(0)), add an assertion like
expect(vi.getTimerCount()).toBe(0) to confirm the timer created by
closeWithTimeout is cleared in its .finally() block even when the closer
rejects; locate the test using the closer mock and closeWithTimeout invocation
and add the timer count expectation after awaiting the result resolution.

9-56: ⚡ Quick win

Consider adding a test for closer that resolves after a delay but before timeout.

The test suite covers immediate resolution (0ms) and never-resolving closers, but not the scenario where the closer wins the race after a non-zero delay (e.g., closer takes 500ms when timeout is 1000ms). This would verify that the .finally() timer cleanup works correctly when the closer wins after some time has passed.

🧪 Proposed additional test case
it('resolves when closer wins after a delay and clears the timer', async () => {
  const closer = vi.fn(() => new Promise<void>((resolve) => {
    setTimeout(resolve, 500)
  }))
  const result = closeWithTimeout(closer, 1000)
  
  // Advance to when closer should resolve
  await vi.advanceTimersByTimeAsync(500)
  await expect(result).resolves.toBeUndefined()
  expect(closer).toHaveBeenCalledOnce()
  // Timer should be cleaned up even though closer won after delay
  expect(vi.getTimerCount()).toBe(0)
})
🤖 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/test/unit/close-with-timeout.spec.ts` around lines 9 - 56, Add
a test that verifies closeWithTimeout correctly handles a closer that resolves
after a delay but before the timeout: create a closer function (used by
closeWithTimeout) that resolves via setTimeout after e.g. 500ms, call
closeWithTimeout(closer, 1000), advance timers to 500ms with
vi.advanceTimersByTimeAsync, assert the returned promise resolves, assert the
closer was called (closer/vi.fn), and finally assert vi.getTimerCount() is 0 to
ensure the cleanup in closeWithTimeout's finally removed the timeout.
🤖 Prompt for all review comments with 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.

Inline comments:
In `@packages/nuxi/src/dev/utils.ts`:
- Around line 88-90: The call to closer() inside closeWithTimeout can throw
synchronously so the current .catch() won't handle it; change the invocation to
wrap closer with Promise.resolve().then(closer) (e.g., use
Promise.resolve().then(() => closer()) or Promise.resolve().then(closer)) when
building the Promise.race so both synchronous throws and async rejections from
closer are consistently caught and swallowed by the existing .catch(),
preserving restart safety.
- Around line 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.

---

Nitpick comments:
In `@packages/nuxi/test/unit/close-with-timeout.spec.ts`:
- Around line 43-48: The rejection test for closeWithTimeout should also verify
the timers are cleaned up: after calling closeWithTimeout(closer, 1000) and
advancing timers (vi.advanceTimersByTimeAsync(0)), add an assertion like
expect(vi.getTimerCount()).toBe(0) to confirm the timer created by
closeWithTimeout is cleared in its .finally() block even when the closer
rejects; locate the test using the closer mock and closeWithTimeout invocation
and add the timer count expectation after awaiting the result resolution.
- Around line 9-56: Add a test that verifies closeWithTimeout correctly handles
a closer that resolves after a delay but before the timeout: create a closer
function (used by closeWithTimeout) that resolves via setTimeout after e.g.
500ms, call closeWithTimeout(closer, 1000), advance timers to 500ms with
vi.advanceTimersByTimeAsync, assert the returned promise resolves, assert the
closer was called (closer/vi.fn), and finally assert vi.getTimerCount() is 0 to
ensure the cleanup in closeWithTimeout's finally removed the timeout.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: fddd0cdc-40c8-4b51-91cd-5e88d3e911f0

📥 Commits

Reviewing files that changed from the base of the PR and between 19154ed and acb55cb.

📒 Files selected for processing (2)
  • packages/nuxi/src/dev/utils.ts
  • packages/nuxi/test/unit/close-with-timeout.spec.ts

Comment thread packages/nuxi/src/dev/utils.ts
Comment on lines +530 to +533
await closeWithTimeout(
() => this.#currentNuxt!.close(),
Number(process.env.NUXT_DEV_CLOSE_TIMEOUT_MS) || DEFAULT_CLOSE_TIMEOUT_MS,
)
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.

mttzzz and others added 2 commits May 28, 2026 17:52
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#1328.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 28, 2026

Actionable comments posted: 0

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%.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants