fix(@angular/build): prevent unit-test vitest runner from hanging after tests complete#33319
fix(@angular/build): prevent unit-test vitest runner from hanging after tests complete#33319hebus wants to merge 1 commit into
Conversation
|
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. |
There was a problem hiding this comment.
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.
| 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(); | ||
| } |
There was a problem hiding this comment.
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.
| 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(); | |
| } |
There was a problem hiding this comment.
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.
…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
9bbe782 to
9535a75
Compare
PR Checklist
PR Type
What is the current behavior?
In non-watch mode,
ng testwith the vitest runner hangs indefinitely after all testscomplete and results are printed. The executor disposes the Vitest instance with
close(), which has no teardown timeout. Whether the hang occurs depends on the sizeof 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 ofvitest.close(),mirroring what the
vitest runCLI does:exit()arms an unref'dteardownTimeoutsafety net (
process.exit()after 10s by default) before closing. When teardowncompletes normally nothing changes; when something keeps the event loop alive, vitest
reports it and force-exits:
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):
Does this PR introduce a breaking change?
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.