Skip to content

ng build deadlocks in Docker on Linux since 21.2.14 — esbuild.context() for production builds breaks standard CI/CD #33480

Description

@isms-core-project

Which @angular/* package(s) are the source of the bug?

@angular/build

Is this a regression?

Yes — introduced in 21.2.14 by commit `d46c082fb` ("fix(@angular/build): prevent esbuild service child process leakage")

Description

`ng build --configuration=production` deadlocks permanently inside Docker on Linux after 8–20 minutes. No error, no output, process never exits. This breaks every standard Docker-based CI/CD pipeline.


Root cause — confirmed by source diff between 21.2.12 and 21.2.14:

`src/tools/esbuild/bundler-context.js`, non-incremental build path changed from:

// 21.2.12 — correct for production: one-shot, no persistent process
result = await esbuild_1.build(this.#esbuildOptions);

to:

// 21.2.14+ — wrong for production: persistent child process + IPC pipe
this.#esbuildContext = await esbuild_1.context(this.#esbuildOptions);
result = await this.#esbuildContext.rebuild();

Why this deadlocks:

`esbuild.context()` spawns a persistent child process communicating via stdin/stdout IPC pipe. Angular's TypeScript compiler uses worker threads that call `Atomics.wait()` on a SharedArrayBuffer, blocking until the main thread signals completion. The main thread awaits the esbuild IPC response. Inside Docker on Linux, pipe buffering behaviour differs from the host — the esbuild child blocks writing to a pipe that nobody reads. Circular deadlock. All threads sleep permanently.

Observed state during deadlock:

  • All 11 Node.js worker threads: `State: S`, `wchan: 0` (userspace `Atomics.wait()`)
  • esbuild child process: 0.4% CPU (idle ping only)
  • CPU ticks delta over 5 seconds: 0 — confirmed deadlock, not a slow build

Why this change is wrong for production builds:

The commit message states the leakage "primarily impacts programmatic usage and custom Architect decorators, where the hosting Node.js process remains alive after build completion."

That is precisely not what `ng build` is. A production build runs once and exits. There is nothing to leak. `esbuild.build()` was always correct for non-incremental builds.

Switching non-incremental production builds to `esbuild.context()` + `.rebuild()` to fix a leakage that doesn't exist in standard `ng build` usage introduced a critical regression for Docker/CI environments — which is the majority of production Angular build infrastructure.


Impact:

Corporate and enterprise environments run Angular builds inside Docker containers in CI/CD pipelines (GitHub Actions, GitLab CI, Jenkins, Kubernetes build jobs). These environments have standard resource allocations. They cannot and should not need to provision extra CPU and RAM to work around a pipe IPC timing issue introduced by an unnecessary API switch for production builds.

This is a silent failure: no error message, no log output, the build simply hangs. Teams debugging this lose hours with no indication of the cause.


Proposed fix:

Restore `esbuild.build()` for the non-incremental branch in `src/tools/esbuild/bundler-context.js`. The scoped `esbuild.context()` should only be used when `this.incremental` is true (watch mode), which is exactly what 21.2.12 and 21.2.13 did correctly:

if (this.#esbuildContext) {
    // Rebuild using the existing incremental build context
    result = await this.#esbuildContext.rebuild();
}
else if (this.incremental) {
    // Create an incremental build context and perform the first build.
    this.#esbuildContext = await esbuild_1.context(this.#esbuildOptions);
    result = await this.#esbuildContext.rebuild();
}
else {
    // For non-incremental builds, perform a single build — esbuild.build() is correct here.
    // esbuild.context() creates a persistent IPC pipe that deadlocks in Docker on Linux.
    result = await esbuild_1.build(this.#esbuildOptions);
}

If the child process leakage needs fixing for non-incremental builds too, it should be addressed within the `esbuild.build()` path (e.g. explicit service stop after completion) — not by switching to `esbuild.context()` which introduces IPC timing sensitivity.


Request:

Either revert the non-incremental build path to `esbuild.build()`, or provide a technical justification for why `esbuild.context()` is necessary for non-incremental production builds and how Docker environments are expected to handle the resulting IPC deadlock.

Environment

Node: 22.x
Docker: Linux amd64 (node:22-slim)
@angular/build: 21.2.14 through 21.2.17 (all affected)
Confirmed working: @angular/build 21.2.12 and 21.2.13
OS inside container: Debian (slim) / Alpine

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions