Command
build
Is this a regression?
The previous version in which this bug was not present was
No response
Description
When a non-watch ng build throws a real Error during the post-bundle phase (index-HTML generation / i18n inlining), the CLI prints the error (An unhandled exception occurred: …) but the Node process never exits. It stays alive indefinitely with its event loop idle (epoll_wait) and its worker threads parked in futex_wait, holding open handles to the persistent-cache SQLite databases (angular-compiler.db, angular-i18n.db).
Because the process does not terminate, any parent that waits on it (CI runner, Bazel action via aspect_rules_js / rules_angular, a shell script, npm run build) hangs forever. What should be a fast build failure (exit code 1) instead looks like an infinitely slow / stuck build. In our monorepo we found orphaned ng build processes still alive 23+ hours after they had already logged the fatal error, each holding ~5 GB RSS and the cache-DB locks.
Root cause (source-level). In src/builders/application/build-action.js, runEsBuildBuildAction runs the initial build like this:
let result;
try {
result = await withProgress('Building...', () => action()); // <-- action() THROWS
await logMessages(logger, result, colors, jsonLogs);
}
finally {
// Ensure Sass workers are shutdown if not watching
if (!watch) {
shutdownSassWorkerPool(); // only Sass is torn down
}
}
// result.dispose() lives further down, guarded by code paths the throw skips:
// } finally { if (!watchLoopStarted && result) { await result.dispose(); } }
// ...and the watch-loop finally: await Promise.allSettled([watcher.close(), result.dispose()]);
When action() (→ executeBuild → inlineI18n → executePostBundleSteps → IndexHtmlGenerator.readIndex) throws instead of returning a { kind: Failure } result:
result is never assigned.
- The only cleanup that runs is
shutdownSassWorkerPool().
result.dispose() is never called, so the esbuild bundler context, the Angular/TS compilation worker pool, and the persistent-cache SQLite connections (angular-compiler.db, angular-i18n.db) are all leaked.
- The leaked handles/worker threads keep the event loop alive →
process never exits.
Note the i18n inliner’s own worker pool is correctly closed (i18n.js has finally { await inliner.close(); }), which is why that specific pool is not the culprit — the leaked handles are the ones owned by the build result that never gets disposed on the throw path.
The general defect: a thrown error from the build action bypasses result disposal. Any post-bundle failure that throws (missing/unreadable index file, index generator error, i18n inlining error, etc.) triggers the hang.
Minimal Reproduction
The trigger just needs the post-bundle index step to throw. A missing index file does it.
ng new hang-repro --defaults && cd hang-repro
- Point the build at a non-existent index file so
IndexHtmlGenerator.readIndex throws at post-bundle time. In angular.json, under projects.hang-repro.architect.build.options:
"index": "src/does-not-exist.html"
(Reproduces with or without "localize"; enabling localization routes the same readIndex call through inlineI18n, which is the exact path we hit.)
- Run a production, non-watch build:
ng build --configuration production
Observed: the CLI logs An unhandled exception occurred: Failed to read index HTML file "…/src/does-not-exist.html" and then the command never returns — the node process sits idle in epoll_wait indefinitely and must be killed manually. No exit code is ever produced.
Validation (freshly reproduced on a clean vanilla workspace)
Scaffolded with npx @angular/cli@21.2.19 new hang-repro --defaults (no localize, no custom config beyond the broken index path above), then built. Paths below are trimmed to /tmp/hang-repro.
1. It has to be force-killed — it never exits on its own. Running under timeout shows the process was still alive at the full timeout and was killed (137 = 128 + SIGKILL), not exited:
❯ Building...
✖ Building... [FAILED: Failed to read index HTML file "/tmp/hang-repro/src/does-not-exist.html".]
An unhandled exception occurred: Failed to read index HTML file "/tmp/hang-repro/src/does-not-exist.html".
See "/tmp/ng-XXXXXX/angular-errors.log" for further details.
=== ng build exit=137 wallclock=120s === # timeout --signal=KILL 120; a correct build exits 1 in ~5s
2. Process state while "running" (actually hung). The fatal error prints at ~5s; everything after that is an idle process that won't die:
fatal error printed at ~5s
=== is the process still alive AFTER printing a FATAL error? ===
YES — pid 947970 still running (should have exited with code 1)
### main-thread state (3 samples / 6s) — idle event loop:
225 Sl ep_poll # NOTE: ps %CPU is a lifetime average; it decays 225 -> 149 -> 112
149 Sl ep_poll # as the process sits idle after the CPU-heavy bundle. wchan=ep_poll
112 Sl ep_poll # (epoll_wait) => the event loop is parked, doing nothing.
### open persistent-cache SQLite handles held open:
2 angular-compiler.db
1 angular-compiler.db-lock
### thread/worker states (wchan):
2 Sl ep_poll
11 Sl futex_wait_queue # worker-pool threads parked; these keep the loop alive
### never-exits proof: etimes t1=10s -> t2=16s (still alive, growing)
(In the localize-enabled variant, angular-i18n.db is held open as well, and the stack gains an inlineI18n frame — see below.)
Exception or Error
Captured `angular-errors.log` from the vanilla reproduction above (no localize). The `readIndex` rejection is thrown, not caught into a `Failure` result:
[error] Error: Failed to read index HTML file "/tmp/hang-repro/src/does-not-exist.html".
at IndexHtmlGenerator.readIndex (.../@angular/build/src/utils/index-file/index-html-generator.js:100:19)
at async IndexHtmlGenerator.process (.../@angular/build/src/utils/index-file/index-html-generator.js:52:23)
at async executePostBundleSteps (.../@angular/build/src/builders/application/execute-post-bundle.js:50:62)
at async executeBuild (.../@angular/build/src/builders/application/execute-build.js:229:24)
at async watch (.../@angular/build/src/builders/application/index.js:110:24)
at async Task.task [as taskFn] (.../@angular/build/src/tools/esbuild/utils.js:143:26)
at async Task.run (.../listr2/dist/index.cjs:1936:5)
With `"localize"` enabled the same `readIndex` rejection is reached through the i18n inliner, adding one frame:
at async executePostBundleSteps (.../@angular/build/src/builders/application/execute-post-bundle.js:50:62)
at async inlineI18n (.../@angular/build/src/builders/application/i18n.js:54:120)
at async executeBuild (.../@angular/build/src/builders/application/execute-build.js:223:24)
The error is printed correctly; the defect is that the process does not exit afterward.
Your Environment
Angular CLI: 21.2.19
Node: 20.20.2
Package Manager: npm 10.8.2 (clean `ng new` workspace used for the validated repro)
OS: linux x64 (Ubuntu 25.04, kernel 6.8)
Angular: 21.2.19
... @angular/build 21.2.19
... @angular/cli 21.2.19
... @angular/core 21.2.19
Anything else relevant?
Command
build
Is this a regression?
The previous version in which this bug was not present was
No response
Description
When a non-watch
ng buildthrows a realErrorduring the post-bundle phase (index-HTML generation / i18n inlining), the CLI prints the error (An unhandled exception occurred: …) but the Node process never exits. It stays alive indefinitely with its event loop idle (epoll_wait) and its worker threads parked infutex_wait, holding open handles to the persistent-cache SQLite databases (angular-compiler.db,angular-i18n.db).Because the process does not terminate, any parent that waits on it (CI runner, Bazel action via
aspect_rules_js/rules_angular, a shell script,npm run build) hangs forever. What should be a fast build failure (exit code 1) instead looks like an infinitely slow / stuck build. In our monorepo we found orphanedng buildprocesses still alive 23+ hours after they had already logged the fatal error, each holding ~5 GB RSS and the cache-DB locks.Root cause (source-level). In
src/builders/application/build-action.js,runEsBuildBuildActionruns the initial build like this:When
action()(→executeBuild→inlineI18n→executePostBundleSteps→IndexHtmlGenerator.readIndex) throws instead of returning a{ kind: Failure }result:resultis never assigned.shutdownSassWorkerPool().result.dispose()is never called, so the esbuild bundler context, the Angular/TS compilation worker pool, and the persistent-cache SQLite connections (angular-compiler.db,angular-i18n.db) are all leaked.processnever exits.Note the i18n inliner’s own worker pool is correctly closed (
i18n.jshasfinally { await inliner.close(); }), which is why that specific pool is not the culprit — the leaked handles are the ones owned by the buildresultthat never gets disposed on the throw path.The general defect: a thrown error from the build action bypasses
resultdisposal. Any post-bundle failure that throws (missing/unreadable index file, index generator error, i18n inlining error, etc.) triggers the hang.Minimal Reproduction
The trigger just needs the post-bundle index step to throw. A missing index file does it.
ng new hang-repro --defaults && cd hang-reproIndexHtmlGenerator.readIndexthrows at post-bundle time. Inangular.json, underprojects.hang-repro.architect.build.options:"localize"; enabling localization routes the samereadIndexcall throughinlineI18n, which is the exact path we hit.)Observed: the CLI logs
An unhandled exception occurred: Failed to read index HTML file "…/src/does-not-exist.html"and then the command never returns — thenodeprocess sits idle inepoll_waitindefinitely and must be killed manually. No exit code is ever produced.Validation (freshly reproduced on a clean vanilla workspace)
Scaffolded with
npx @angular/cli@21.2.19 new hang-repro --defaults(no localize, no custom config beyond the brokenindexpath above), then built. Paths below are trimmed to/tmp/hang-repro.1. It has to be force-killed — it never exits on its own. Running under
timeoutshows the process was still alive at the full timeout and was killed (137= 128 + SIGKILL), not exited:2. Process state while "running" (actually hung). The fatal error prints at ~5s; everything after that is an idle process that won't die:
(In the localize-enabled variant,
angular-i18n.dbis held open as well, and the stack gains aninlineI18nframe — see below.)Exception or Error
Your Environment
Anything else relevant?
@angular/buildhanging after a successful build on 21.2.14–21.2.18 (esbuild one-shot switched toesbuild.context()/.rebuild()in PR fix(@angular/build): prevent esbuild service child process leakage #33267, itself fixing leak @angular/build: leaves esbuild service child alive after watch:false build #33201); both are closed. This report is different: it is the failure path — the process hangs specifically when the build throws — and it still reproduces on 21.2.19 (a successful 21.2.19 build exits normally in my testing). ng build never exits when using an incompatible version of TypeScript #23233 (webpack builder, TS-version unhandled rejection) and i18nMissingTranslation error does not exit process #12770 (2018,i18nMissingTranslation) are the same symptom on different/older builders. I could not find an existing report for the thrown-post-bundle-error case on the current@angular/build:applicationbuilder.rules_angular+aspect_rules_js) the action never completes, so the build appears to run for thousands of seconds rather than failing fast; orphanedng buildprocesses accumulate and hold persistent-cache DB locks. The same would affect any CI thatawaits the child process.result(esbuild context + persistent SQLite cache connections + compilation worker pool) is disposed even whenaction()throws — e.g. dispose in the initial-buildfinally, or wrap the whole non-watch build so that a thrown error still tears down all pools/handles before rethrowing. As a defense-in-depth measure, the CLI couldprocess.exitCode = 1; process.exit()after reporting a fatal builder exception rather than relying on the event loop draining.