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 net
ctx.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:189 calls this.vitest?.close() (no timeout)
executor.js:314 calls startVitest() which calls ctx.close() in its finally block (no timeout)
- Vitest CLI (
cac.CWGDZnXT.js:2317) calls ctx.exit() (has teardownTimeout safety 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.
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-testbuilder 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 callsvitest.close()which has no teardown timeout, while the vitest CLI callsvitest.exit()which has a safety-netsetTimeout → process.exit().Root Cause
The vitest CLI (
vitest run) handles shutdown like this (node_modules/vitest/dist/chunks/cac.CWGDZnXT.js:2317):ctx.exit()sets an unref'dsetTimeout(() => process.exit(), config.teardownTimeout)before callingclose(). If pool workers fail to shut down,process.exit()fires after 10s (defaultteardownTimeout).The Angular builder's
VitestExecutor(executor.js:189) usesclose()directly:Additionally,
startVitest()itself callsctx.close()in itsfinallyblock (cli-api.DuT9iuvY.js:14400-14402):close()awaitspool.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 callsthis.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, callctx.exit()for non-watch mode, matching what the vitest CLI does: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)cac.CWGDZnXT.js:2317) callsctx.exit()(hasteardownTimeoutsafety net)Reproducible with any large Angular project (232+ test files) on Windows using
@angular/build:unit-testwith 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 4ChildProcess+ 12Sockethandles from vitest forks pool workers that were never killed.Please provide the environment you discovered this bug in
Anything else?
Workaround: Add a
globalSetupteardown invitest-base.config.tsthat mirrorsctx.exit()'s safety net:The
_teardownGlobalSetup()runs insideVitest.close()beforepool.close(), so the timer fires during the pool hang.