Skip to content

fix(@angular/build): prevent unit-test vitest runner from hanging after tests complete#33319

Open
hebus wants to merge 1 commit into
angular:mainfrom
hebus:fix-unit-test-vitest-exit
Open

fix(@angular/build): prevent unit-test vitest runner from hanging after tests complete#33319
hebus wants to merge 1 commit into
angular:mainfrom
hebus:fix-unit-test-vitest-exit

Conversation

@hebus
Copy link
Copy Markdown

@hebus hebus commented Jun 7, 2026

PR Checklist

PR Type

  • Bugfix

What is the current behavior?

In non-watch mode, ng test with the vitest runner hangs indefinitely after all tests
complete and results are printed. The executor disposes the Vitest instance with
close(), which has no teardown timeout. Whether the hang occurs depends on the size
of the TypeScript program of the spec tsconfig (full analysis in the linked issue) —
freshly generated projects exit cleanly while larger real-world projects hang on every
run, leaving orphaned node/esbuild processes in CI.

Issue Number: #32832 (likely also #33317)

What is the new behavior?

When not in watch mode, the executor calls vitest.exit() instead of vitest.close(),
mirroring what the vitest run CLI does: exit() arms an unref'd teardownTimeout
safety net (process.exit() after 10s by default) before closing. When teardown
completes normally nothing changes; when something keeps the event loop alive, vitest
reports it and force-exits:

close timed out after 10000ms
Tests closed successfully but something prevents the main process from exiting

Watch mode still uses close() and is unaffected.

Validated on a project that reproduces the hang on 100% of runs (315 tests, 19 spec files):

  • run mode, passing tests: process now exits on its own, exit code 0
  • run mode, failing tests: exit code 1 preserved
  • watch mode: process stays alive, re-runs on change
  • no orphaned node/esbuild processes after the run

Does this PR introduce a breaking change?

  • No

Other information

No test added: asserting "the process exits" requires a real hanging handle, which the
builder test harness does not reproduce (small programs tear down cleanly — that is the
bug's timing dependency). Happy to add one if there is a suitable seam.

@google-cla
Copy link
Copy Markdown

google-cla Bot commented Jun 7, 2026

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request updates the VitestExecutor disposal logic to call vitest.exit() instead of vitest.close() in non-watch mode, preventing hanging processes. The review feedback recommends adding a defensive check to verify that vitest.exit is a function before calling it, and warns that calling exit() could prematurely terminate the host process when executed programmatically.

Comment on lines +208 to +217
if (this.vitest && !this.options.watch) {
// In run (non-watch) mode, mirror the `vitest run` CLI by using `exit()`, which arms an
// unref'd `teardownTimeout` safety net (`process.exit()`) before closing. `close()` alone
// can wait indefinitely when a worker or service keeps the event loop alive, leaving the
// process hanging after all tests have completed.
// See https://github.com/angular/angular-cli/issues/32832
await this.vitest.exit();
} else {
await this.vitest?.close();
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

1. Defensive Programming Check

Since vitest is a dependency that might be resolved to different versions in user workspaces, it is safer to defensively check if exit is a function before calling it to prevent runtime TypeError exceptions.

2. Programmatic Execution & process.exit() Side-Effects

Calling vitest.exit() arms a teardown timeout that eventually calls process.exit(). While this successfully prevents hanging in standard CLI runs, it can be problematic when the builder is executed programmatically (for example, via the Architect API in custom scripts, monorepo tools like Nx, or IDE integrations). In those cases, abruptly calling process.exit() will terminate the entire host process, preventing subsequent tasks or cleanup from running.

Consider adding a defensive check for typeof this.vitest.exit === 'function', and potentially exposing a way to opt-out of the force-exit behavior if run programmatically.

Suggested change
if (this.vitest && !this.options.watch) {
// In run (non-watch) mode, mirror the `vitest run` CLI by using `exit()`, which arms an
// unref'd `teardownTimeout` safety net (`process.exit()`) before closing. `close()` alone
// can wait indefinitely when a worker or service keeps the event loop alive, leaving the
// process hanging after all tests have completed.
// See https://github.com/angular/angular-cli/issues/32832
await this.vitest.exit();
} else {
await this.vitest?.close();
}
if (this.vitest && !this.options.watch && typeof this.vitest.exit === 'function') {
// In run (non-watch) mode, mirror the `vitest run` CLI by using `exit()`, which arms an
// unref'd `teardownTimeout` safety net (`process.exit()`) before closing. `close()` alone
// can wait indefinitely when a worker or service keeps the event loop alive, leaving the
// process hanging after all tests have completed.
// See https://github.com/angular/angular-cli/issues/32832
await this.vitest.exit();
} else {
await this.vitest?.close();
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Thanks for the review. Responding to both points:

1. Defensive typeof check — declined for consistency: this file already calls other Vitest instance methods (close(), start(), invalidateFile()) without runtime guards. exit() is long-standing public API on the Vitest class — it is what the vitest run CLI itself calls on shutdown — covered by the package's vitest peer range and type-checked via the Vitest type from vitest/node.

2. process.exit() side-effects in programmatic execution — this trade-off was weighed when preparing the change. The key property of exit() is that its teardownTimeout safety net only fires in the pathological case where close() would never settle; in every healthy teardown, exit() and close() behave identically and process.exit() is never invoked. Comparing the scenarios:

  • CLI runs (the common case, and where @angular/build:unit-test vitest executor hangs indefinitely — uses close() instead of exit() #32832 is reported): infinite hang → bounded exit.
  • One-shot programmatic hosts hitting the hang: the host currently hangs forever as well; with this change it terminates after completing its work.
  • Long-lived in-process hosts (daemons) hitting the hang: the only scenario that regresses — the daemon previously survived with leaked handles and would now be terminated after teardownTimeout. Runners like Nx and IDE integrations typically execute builders in dedicated forked processes, which narrows this further.

If the maintainers consider the daemon scenario blocking, happy to gate this (e.g. only arm the safety net when the builder owns the process, or behind an option) — though that would give up protection precisely where most users hit #32832.

@hebus hebus closed this Jun 7, 2026
@hebus hebus reopened this Jun 7, 2026
…er tests complete

In non-watch mode, the vitest executor disposed the Vitest instance with
`close()`, which can wait indefinitely when a worker or service keeps the
event loop alive. The process then never exits even though all tests have
completed and their results were reported.

Use `exit()` instead when not in watch mode, mirroring the `vitest run`
CLI behavior: `exit()` arms an unref'd `teardownTimeout` safety net that
force-exits the process if teardown does not settle in time, while still
performing a normal `close()` when teardown succeeds. Watch mode behavior
is unchanged.

Fixes angular#32832
@hebus hebus force-pushed the fix-unit-test-vitest-exit branch from 9bbe782 to 9535a75 Compare June 7, 2026 08:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant