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 cause — createDepsOptimizer() (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._pendingRequests → DevEnvironment.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
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
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 runexits.What actually happens: the tests pass, but
vitest runnever exits — the process hangs indefinitely and has to be killed manually. The hang is the Vite dev server'sclose()never resolving during teardown.Root cause —
createDepsOptimizer()(packages/vite/src/node/optimizer/optimizer.ts) leaves adepOptimizationProcessingpromise permanently unresolved when the server is closed while an optimize run is in flight:depOptimizationProcessingpromise plus a queue of enqueued ones. A dep discovered viaregisterMissingImportgetsoptimizedDepInfo.processingset to the current promise;startNextDiscoveredBatch()later moves it into the queue.runOptimizer()'s pre-optimizeclosedguard resolves the current and the enqueued promises; its post-optimizeclosedguard resolves only the enqueued ones; andclose()resolves neither..processing. Itsvite:optimized-depsload()awaitsinfo.processingforever → the request never leavesenvironment._pendingRequests→DevEnvironment.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, mirroringrunOptimizer's pre-optimizeclosedguard: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 testThe test passes, then the process hangs and never exits (kill with Ctrl-C). Applying the included
fix.patchto vite makesnpm testexit cleanly.The two small plugins in
vitest.config.tsonly make the race deterministic —slow-optimizekeeps an optimize run in flight at teardown, andslow-chainopens a >100ms gap between dependency discoveries so one lands on the in-flight run's current batch. They are timing controls, not the cause: withfix.patchapplied, 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 withcoverage.include+ defaultserver.preTransformRequests).System Info
Used Package Manager
npm
Logs
No response
Validations