Skip to content

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

Closed

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

  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?

Activity

  1. added this to the needsTriage milestone on Jul 31, 2026
  2. added 2 commits that reference this issue on Jul 31, 2026
    7ffbbac
    c0cf4a5
  3. self-assigned this
    on Jul 31, 2026
  4. added theissue type on Jul 31, 2026
  5. added a commit that references this issue on Jul 31, 2026
    a4860d5
  6. added a commit that references this issue on Jul 31, 2026
    4fb1229
  7. angular-automatic-lock-bot commented on Sep 16, 2026

    @angular-automatic-lock-bot

    This issue has been automatically locked due to inactivity.
    Please file a new issue if you are encountering a similar or related problem.

    Read more about our automatic conversation locking policy.

    This action has been performed automatically by a bot.

  8. locked and limited conversation to collaborators on Sep 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions