Skip to content

server.close() hangs forever when a dependency is discovered mid-optimize (optimizer never settles its processing promise) #22934

Description

@bent0b0x

Describe the bug

What I'm doing: running a Vitest browser-mode suite (vitest run, which drives a Vite dev server) on a cold optimizer cache, where dependencies are discovered progressively while the dep optimizer is still running.

What I expect: once the tests finish, vitest run exits.

What actually happens: the tests pass, but vitest run never exits — the process hangs indefinitely and has to be killed manually. The hang is the Vite dev server's close() never resolving during teardown.

Root causecreateDepsOptimizer() (packages/vite/src/node/optimizer/optimizer.ts) leaves a depOptimizationProcessing promise permanently unresolved when the server is closed while an optimize run is in flight:

  • The optimizer tracks pending work with a current depOptimizationProcessing promise plus a queue of enqueued ones. A dep discovered via registerMissingImport gets optimizedDepInfo.processing set to the current promise; startNextDiscoveredBatch() later moves it into the queue.
  • On shutdown these are settled inconsistently: runOptimizer()'s pre-optimize closed guard resolves the current and the enqueued promises; its post-optimize closed guard resolves only the enqueued ones; and close() resolves neither.
  • So a dep discovered into the current batch while a run is in flight keeps a forever-pending .processing. Its vite:optimized-deps load() awaits info.processing forever → the request never leaves environment._pendingRequestsDevEnvironment.close()'s drain loop (while (this._pendingRequests.size > 0) await ...) never finishes → close() never resolves.

Reproduced on vite 8.0.8 and 8.1.4.

I intend to submit a PR. The proposed fix is to have close() settle the pending promises, mirroring runOptimizer's pre-optimize closed guard:

 async function close() {
   closed = true
+  depOptimizationProcessing.resolve()
+  resolveEnqueuedProcessingPromises()
   await Promise.allSettled([
     discover?.cancel(),
     depsOptimizer.scanProcessing,
     optimizationResult?.cancel(),
   ])
 }

Reproduction

https://github.com/bent0b0x/vite-optimizer-orphan-hang

Steps to reproduce

npm install
npm test

The test passes, then the process hangs and never exits (kill with Ctrl-C). Applying the included fix.patch to vite makes npm test exit cleanly.

The two small plugins in vitest.config.ts only make the race deterministic — slow-optimize keeps an optimize run in flight at teardown, and slow-chain opens a >100ms gap between dependency discoveries so one lands on the in-flight run's current batch. They are timing controls, not the cause: with fix.patch applied, the identical config exits cleanly. The same race arises naturally on a cold optimizer cache when dependencies are discovered progressively during a slow pre-bundle (e.g. a Vitest browser run with coverage.include + default server.preTransformRequests).

System Info

System:    macOS 26.5.1, arm64 (Apple M1 Max)
Binaries:  Node 22.14.0, npm 10.9.2, pnpm 10.33.0
Browsers:  Chrome 150.0.7871.102
npmPackages:
  vite: ^8 => 8.1.4
  vitest: ^4 => 4.1.10
  @vitest/browser-playwright: ^4 => 4.1.10
  playwright: ^1.59 => 1.61.1

Used Package Manager

npm

Logs

No response

Validations

Metadata

Metadata

Assignees

No one assigned

    Type

    Fields

    No fields configured for Bug.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions