-
Notifications
You must be signed in to change notification settings - Fork 11.9k
Description
Which @angular/* package(s) are the source of the bug?
@angular/build
Is this a regression?
No — the vitest runner has always used close() since it was introduced.
Description
When running tests with the @angular/build:unit-test builder using the vitest runner in non-watch mode, the process hangs indefinitely after all tests complete and results are printed. This happens because the builder calls vitest.close() which has no teardown timeout, while the vitest CLI calls vitest.exit() which has a safety-net setTimeout → process.exit().
Root Cause
The vitest CLI (vitest run) handles shutdown like this (node_modules/vitest/dist/chunks/cac.CWGDZnXT.js:2317):
const ctx = await startVitest(mode, cliFilters, options);
if (!ctx.shouldKeepServer()) await ctx.exit(); // ← has teardownTimeout safety netctx.exit() sets an unref'd setTimeout(() => process.exit(), config.teardownTimeout) before calling close(). If pool workers fail to shut down, process.exit() fires after 10s (default teardownTimeout).
The Angular builder's VitestExecutor (executor.js:189) uses close() directly:
async [Symbol.asyncDispose]() {
await this.vitest?.close();
}Additionally, startVitest() itself calls ctx.close() in its finally block (cli-api.DuT9iuvY.js:14400-14402):
finally {
if (!ctx?.shouldKeepServer()) {
await ctx.close(); // hangs here — no timeout
}
}close() awaits pool.close() with no timeout. If any vitest pool worker doesn't respond to the "stop" message within 60s (STOP_TIMEOUT, hardcoded in vitest), PoolRunner.stop() throws but never calls this.worker.stop() to kill the process. The zombie workers' IPC channels (ref=true) keep the Node.js event loop alive indefinitely.
This is a known class of bugs in Vitest 4's pool implementation (vitest-dev/vitest#8766, vitest-dev/vitest#9494, vitest-dev/vitest#8861).
Suggested Fix
After startVitest() returns, call ctx.exit() for non-watch mode, matching what the vitest CLI does:
const ctx = await startVitest('test', undefined, vitestConfig, vitestServerConfig);
if (!ctx?.config?.watch) {
await ctx.exit();
}Or add an equivalent teardown timeout in the [Symbol.asyncDispose]() method.
Please provide a link to a minimal reproduction of the bug
No external reproduction — the issue is observable from source code analysis:
executor.js:189callsthis.vitest?.close()(no timeout)executor.js:314callsstartVitest()which callsctx.close()in its finally block (no timeout)- Vitest CLI (
cac.CWGDZnXT.js:2317) callsctx.exit()(hasteardownTimeoutsafety net)
Reproducible with any large Angular project (232+ test files) on Windows using @angular/build:unit-test with vitest runner and --no-watch.
Please provide the exception or error you saw
No error. The process completes all tests, prints the summary, then hangs indefinitely. process._getActiveHandles() shows 4 ChildProcess + 12 Socket handles from vitest forks pool workers that were never killed.
Please provide the environment you discovered this bug in
@angular/build: 21.2.2
vitest: 4.1.0
Node.js: 22.14.0
OS: Windows 11 Pro 10.0.26200
Anything else?
Workaround: Add a globalSetup teardown in vitest-base.config.ts that mirrors ctx.exit()'s safety net:
// vitest-global-teardown.ts
export async function teardown() {
setTimeout(() => process.exit(process.exitCode ?? 0), 10_000).unref();
}The _teardownGlobalSetup() runs inside Vitest.close() before pool.close(), so the timer fires during the pool hang.