Skip to content

@angular/build:unit-test hangs forever when two components in one file have identical inline styles #33317

@Klaster1

Description

@Klaster1

Command

test

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

21.2.9

Description

When two component definitions in the same source file declare byte-identical inline styles, the @angular/build:unit-test builder (vitest browser runner, watch:false) runs all tests, prints the passing summary, and then never exits. In CI the job is killed at its timeout.

The hang is caused by a leaked esbuild build context. process._getActiveHandles() at the hang shows a single non-stdio handle: a ChildProcess for …/@esbuild/…/esbuild --service=… --ping. esbuild's service child is a singleton kept alive by any live build context, so one orphaned context keeps the Node event loop alive forever. Killing only that esbuild child makes the runner exit immediately with code 0.

Root cause

Two compounding check-then-act races on the concurrent component-stylesheet bundling path:

  1. Cache.getOrCreate (tools/esbuild/cache.ts) is not concurrency-safe — it await store.get(), and only on undefined does it await creator() then store.set(). There is no in-flight promise memoization.

  2. Inline component styles are cached in a MemoryCache keyed on [language, sha256(content), filename] (tools/esbuild/angular/component-stylesheets.tsbundleInline, #inlineContexts). Two components in the same file with identical CSS produce the same key.

  3. The compiler plugin bundles component stylesheets concurrently, so two bundleInline calls hit getOrCreate with that same key, both see an empty cache, and both run the creator — each constructing a BundlerContext.

  4. Even when they end up sharing one BundlerContext, the leak is sealed by a second race inside BundlerContext.#performBundle() (tools/esbuild/bundler-context.ts):

    if (this.#esbuildContext) {
      result = await this.#esbuildContext.rebuild();
    } else {
      this.#esbuildContext = await context(this.#esbuildOptions); // await gap: both callers see undefined
      result = await this.#esbuildContext.rebuild();
    }

    Two concurrent bundle() calls both observe #esbuildContext === undefined, both call context(), and the second assignment overwrites the first. dispose() only disposes #esbuildContext (the second one); the first esbuild context is orphaned and never disposed, and esbuild.stop() is never called.

Instrumenting a real run (hooking BundlerContext.bundle/dispose) shows 432 contexts created, 431 disposed — exactly one orphan, whose bundleInline arguments are a pair of components with identical inline CSS. Differentiating the CSS by one byte balances the counts and the process exits cleanly.

Sequential bundling does not leak (the second call reuses #esbuildContext via rebuild()); the leak strictly requires the concurrency that parallel stylesheet transforms reliably hit, so it is deterministic.

Minimal Reproduction

Repo: (a stock @angular/build:unit-test setup plus two specs). A zip is attached below.

npm install
npx playwright install chromium

npm run test:hang   # two components, IDENTICAL inline styles -> tests pass, then HANGS FOREVER
npm run test:ok     # same, one byte different               -> tests pass, EXITS code 0

The two scenarios live in separate source folders (src/hang/, src/ok/) with their own tsConfig/build configuration, because the unit-test builder bundles every spec matched by the build's tsConfig (not just those selected by the test include); a single shared build would let the duplicate-style spec poison the "ok" run too.

The only difference between hanging and passing is whether two inline styles strings are byte-identical:

// hang: ComponentA and ComponentB both use
styles: [`.box { width: 100px; height: 100px; }`]

// ok: ComponentB differs by one byte
styles: [`.box { width: 100px; height: 100px; /* one byte different */ }`]

Expected: both commands exit with code 0 after the tests pass.
Actual: test:hang never exits; test:ok exits cleanly.

Exception or Error

(no error — tests pass, vitest prints its summary, then the process hangs)

 Test Files  1 passed (1)
      Tests  1 passed (1)

# process._getActiveHandles() shows one lingering handle:
ChildProcess  .../@esbuild/<platform>/esbuild(.exe) --service=<ver> --ping

Your Environment

@angular/build: 22.0.0   (also reproduced on current main)
@angular/cli:   22.0.0
vitest:         4.1.0
@vitest/browser-playwright: 4.1.0
playwright:     1.58.2
Node.js:        22.22.3
OS:             Windows 11 (also reproduces on Linux CI)
Runner:         @angular/build:unit-test, vitest browser (Chromium), watch:false

Anything else relevant?

  • Not a duplicate of @angular/build: leaves esbuild service child alive after watch:false build #33201 (esbuild child after ng build): that path (build-action/watch:false) is fixed in 22.0.0 and disposes its context correctly; this leak is one level down in the stylesheet bundler's per-context creation.
  • Not a duplicate of @angular/build:unit-test vitest executor hangs indefinitely — uses close() instead of exit() #32832 (vitest close() vs exit(), fork-pool worker IPC handles): in browser mode there are no fork workers, and the only lingering handle here is the esbuild child, not a worker socket. ctx.exit()'s force-exit safety net would mask this hang, but the orphaned context would remain.
  • Suggested fix: memoize the in-flight context() promise in BundlerContext.#performBundle() (clearing it in dispose()) so concurrent bundle() calls share one esbuild context — the same "create-once guard" shape as f102f81 (Initiate PostCSS only once). Making Cache.getOrCreate store the pending creator promise would additionally harden the ~16 other call sites, but is not sufficient on its own for this bug.
  • A "fix-it-in-userland" workaround is to ensure no two components in one file share byte-identical inline styles (change a selector, value, or add a comment).

cc-12526-bug.zip

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