Skip to content

@angular/build: process hangs instead of exiting on a thrown post-bundle error (result never disposed) #33716

Description

@saxicek

Command

build

Is this a regression?

  • Yes, this behavior used to work in the previous version

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() (→ executeBuildinlineI18nexecutePostBundleStepsIndexHtmlGenerator.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.

  1. ng new hang-repro --defaults && cd hang-repro
  2. 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.)
  3. 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?

Metadata

Metadata

Assignees

Type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions