Skip to content

test(bundler): run bundler_dynamic_import_dce.test.ts concurrently and pin output shapes - #41108

Open
robobun wants to merge 1 commit into
mainfrom
robobun/d7e82bc4/dynamic-import-dce-test-speed
Open

robobun wants to merge 1 commit into
mainfrom
robobun/d7e82bc4/dynamic-import-dce-test-speed

Conversation

@robobun

@robobun robobun commented Sep 1, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • test/bundler/bundler_dynamic_import_dce.test.ts takes 27s on the x64-asan lane (build 108977): 164 cases in sequence, 151 with a bun subprocess.
  • describe.concurrent alone does nothing: API-backend cases register as it.serial because the harness chdirs around Bun.build().
  • Most cases only check that a DROPPED sentinel is absent, so a regression in the shape of the export object passes.

Fix

  • Every case builds with the CLI backend through a one-line wrapper (as test(bundler): run esbuild/importstar.test.ts concurrently and tighten its assertions #41028 and test(bundler): run esbuild/dce.test.ts concurrently and tighten its assertions #41057) under describe.concurrent. ASAN bun test overlaps 5 cases.
  • 15 cases whose comments describe an output shape pin it with an inline snapshot of the bundle body (runtime helpers and chunk hash stripped). Six caught-throw runs expect an empty stderr. Two ThenableImportee cases become one loop. Every run stays, nothing is deleted or skipped.
  • Verified: bun bd test test/bundler/bundler_dynamic_import_dce.test.ts (debug ASAN): 79.2s, 75.7s, 76.3s before, 24.1s, 24.2s, 24.0s after. CI build 109038: 8.6s on x64-asan, every lane green. expectBundled.ts is untouched.
  • Self-reviewed: 6 concerns raised, 0 addressed. Four prefer a harness fix (serialize the chdir window in expectBundled.ts) to per-file wrappers: a follow-up that deletes this wrapper. Also itBundled.only/.skip are unavailable (unused here) and Bun.build() no longer runs these cases (the linker is shared).

Background

  • itBundled (test/bundler/expectBundled.ts) builds in-process with Bun.build() by default, or with a bun build subprocess under backend: "cli". Only CLI cases overlap.
  • run: { stdout } spawns bun on the output and requires exit code 0 and an exact stdout. It checks stderr only when set.
  • With splitting, the importee is a [name]-[hash].js chunk whose export { } list is the narrowed set. Without it, its exports_x object is narrowed.
Notes

Every run stays because it is the only positive check: the text checks prove a dropped export is absent, the run proves the kept exports still resolve. Removing a run would let over-shaking pass.

CI build 109038, this file per lane: x64-asan 8.59s (27.4s in build 108977, 19.8s in test/expected-durations.json), debian x64 1.62s, alpine x64 1.10s, Windows 2019 x64 2.11s, Windows 11 aarch64 3.46s, darwin aarch64 2.31s. 164 pass and 15 snapshots on each.

Timings in this container (16 vCPUs), same machine for before and after, all green. Per case, debug ASAN: 350ms to 1100ms serial before (the run subprocess is most of it). After, a case takes 500ms to 1000ms wall because a bun build subprocess is added and 5 cases share the CPU, but 5 run at once.

The released bun (1.4.1) does not have the import() tree-shaking these cases cover (93 of 164 fail on main with USE_SYSTEM_BUN=1), so there is no meaningful release-build timing.

The CLI path fails a case on an unexpected bundler warning. No case here warns. Options this file uses and how the CLI branch handles them: splitting, format, target, outdir, minifySyntax, define (already CLI on main because of resolveBackend), entryPoints, runtimeFiles, bundleErrors (parsed from stderr, UnwrapCjsAssignStillErrors passes). Every case already wrote to its own directory under the harness temp root, and every node_modules fixture is a file the case writes. Nothing contacts the network.

bundleBody(): the first line matching ^// [\w./-]+$ is the first module comment (// b.js, // node_modules/lib/index.js). // @bun (target bun) does not match, so it is dropped with the helpers. The chunk hash is 8 chars, the same pattern bundler_splitting.test.ts strips. jsOutputs() sorts the file names so a multi-file snapshot has a stable order. A snapshot holds only the narrowed __export object, the chunk's export { } list, and the import sites.

Snapshot cases: AwaitDestructure, TwoSitesUnion, BailoutRest, SplittingNarrowedExports, SplittingTwoImportersUnion, WebpackExportStarAsBehindImport, SplitRequireDestructureNarrows, NoSplitRequireNarrows, RolldownAwaitDestructPartial, RolldownUnusedDynamicImportedChunk, RolldownInlineDynamicImportsThenNarrow, InlineThenRejectHandlerBails, InlineLetDestructureUnused, InlineDuplicateDestructureKeyBails, InlineThenWrappedSiblingExportsRefLive (this one had a run only). Each snapshot replaces the substring checks it subsumes. The other substring checks stay: a full snapshot of every case would make any printer change touch 150 snapshots.

stderr: "" cases: MinifyInlinedNamespaceLocalEscapes (5 shapes), WebpackCallingNamespaceKeepsAll, InlineThenRejectHandlerBails, NoSplitTryCatchCatches, NoSplitTryAwaitThenCatches, InlineThenCatchChainBails, InlineThenFinallyCatchChainBails.

Other near-identical pairs (Splitting/Inline ExportedDestructureKept, ThenFunctionArgumentsBailout, ThenRejectHandler, ThenCatchChain) differ in their inputs or expectations, so they stay separate cases.

Also ran prettier on the file.


[auto-merge] gate passed · iteration 0 · 1 files touched

passes on PR (with fix)
Test-only change.

Debug/ASAN (expected pass):
$ bun bd test 'test/bundler/bundler_dynamic_import_dce.test.ts'
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test test/bundler/bundler_dynamic_import_dce.test.ts
bun test v1.4.1 (a6c4cc276)

test/bundler/bundler_dynamic_import_dce.test.ts:
(pass) bundler > dynamic_import_dce/AwaitDestructureAlias [579.26ms]
(pass) bundler > dynamic_import_dce/AwaitDestructure [978.15ms]
(pass) bundler > dynamic_import_dce/AwaitIndex [625.45ms]
(pass) bundler > dynamic_import_dce/AwaitDot [660.99ms]
(pass) bundler > dynamic_import_dce/LetBinding [647.28ms]
(pass) bundler > dynamic_import_dce/TwoSitesUnion [568.03ms]
(pass) bundler > dynamic_import_dce/BailoutRest [565.22ms]
(pass) bundler > dynamic_import_dce/BailoutComputed [523.80ms]
(pass) bundler > dynamic_import_dce/BailoutDefault [569.11ms]
(pass) bundler > dynamic_import_dce/SplittingNarrowedExports [616.87ms]
(pass) bundler > dynamic_import_dce/SplittingEscapeKeepsAll [560.70ms]
(pass) bundler > dynamic_import_dce/SplittingThenDestructure [549.45ms]
(pass) bundler > dynamic_import_dce/SplittingAwaitDot [622.65ms]
(pass) bundler > dynamic_import_dce/SplittingPromiseAllDestructure [569.65ms]
(pass) bundler > dynamic_import_dce/SplittingTwoImportersUnion [964.49ms]
(pass) bundler > dynamic_import_dce/SplittingPromiseAllThenDestructure [665.62ms]
(pass) bundler > dynamic_import_dce/BareStatementSideEffectsOnlySplitting [638.07ms]
(pass) bundler > dynamic_import_dce/BareStatementSideEffectsOnlyNoSplit [629.29ms]
(pass) bundler > dynamic_import_dce/SplittingPromiseAllEscapeKeepsAll [769.81ms]
(pass) bundler > dynamic_import_dce/BareRequireStatementSideEffectsOnly [647.15ms]
(pass) bundler > dynamic_import_dce/AwaitedRequireStatementKeepsThen [613.39ms]
(pass) bundler > dynamic_import_dce/GatedRequireBuildTimeTrue [571.19ms]
(pass) bundler > dynamic_import_dce/GatedRequireRuntime [655.70ms]
(pass) bundler > dynamic_import_dce/GatedRequireBothBranchesNarrowAlike [656.19ms]
(pass) bundler > dynamic_import_dce/GatedRequireOrEscapes [641.49ms]
(pass) bundler 
... (truncated)
Exit: 0
diff hotspot
test/bundler/bundler_dynamic_import_dce.test.ts | 358 +++++++++++++++++++-----
 1 file changed, 294 insertions(+), 64 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                             reads  edits  tests
test/bundler/bundler_dynamic_import_dce.test.ts      9     23     25

self-review · 6 surviving concerns

  • should-fix: Test-infra/perf change: the concurrency half of this PR is the Nth per-file workaround for one harness invariant (the process.chdir+configRef window around await Bun.build() at expectBundled.ts:1306-1338 that forces it.serial at :1972), and a ~15-line promise-chain mutex in expectBundled.…
  • should-fix: Test-infra/perf change: the CLI-wrapper + describe.concurrent hunk is the 7th hand copy of a workaround for a 30-line harness limitation in expectBundled.ts that the same author has already prototyped fixing at the source (and measured as faster), so the backend flip should be replaced by that on…
  • should-fix: Test-infra/perf change: the speed win is real (27s -> 8.6s on the asan lane), but the per-file backend: "cli" wrapper is a workaround for a 12-line harness gap (it.serial on API cases at expectBundled.ts:1972) that a measured harness-level lock beats by ~25% with one fewer ASAN child per case…
  • should-fix: Test-infra/perf change: the per-file CLI-backend wrapper is a workaround for one it.serial line in expectBundled.ts; a 4-hunk promise-chain mutex in the harness is faster on this very file under the ASAN cap (20.5s vs 26.0s), keeps Bun.build() coverage, fixes every describe.concurrent bundl…
  • consider: Kind: test-infra (speed + assertion tightening, justified by the measured 27s ASAN-lane time). One file, no open PR overlaps it, so nothing to fold or absorb - but this is the fifth hand-copied CLI-default wrapper and third hand-rolled chunk-hash normalizer across main plus three sibling sweep PR…
  • consider: Test-infra change whose own cost is small (1 test file, no harness or src edits, no new public surface, measured 3x wall-clock win), but it is the third copy of a per-file backend: "cli" wrapper that reverses Make the bundler tests use the API by default in most cases #22646's API-by-default decision file-by-file and adds 15 inline snapshots that will c…

31 concerns were raised and did not survive verification.

…d pin output shapes

Every case builds with the CLI backend and the describe block is
describe.concurrent, so the build and run subprocesses of up to five
cases overlap instead of running one at a time. The file takes 24s
instead of 77s under the debug ASAN build.

Fifteen cases whose comments describe an output shape now pin that
shape with an inline snapshot of the bundle body (runtime helpers and
chunk hashes stripped) instead of substring checks. The runs of the
six cases that exercise a caught throw also expect an empty stderr.
The two ThenableImportee cases become one loop over the splitting
flag.
@github-actions github-actions Bot added the claude label Sep 1, 2026
@robobun

robobun commented Sep 1, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: test-only change, no src/ or expectBundled.ts edit.

Reproduced: bun bd test test/bundler/bundler_dynamic_import_dce.test.ts on main (debug, ASAN, 16 vCPUs): 79.2s, 75.7s, 76.3s for 164 passing cases, 350ms to 1100ms each in sequence. The per-case time is the bun build plus the bun subprocess that runs the output.

With this branch: 24.1s, 24.2s, 24.0s, 164 pass, 15 inline snapshots.

@coderabbitai

coderabbitai Bot commented Sep 1, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 6078f663-a88d-49af-8851-785c05cb6cec

📥 Commits

Reviewing files that changed from the base of the PR and between 561641b and 5f74952.

📒 Files selected for processing (1)
  • test/bundler/bundler_dynamic_import_dce.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 1 remains after this review.


Walkthrough

The pull request updates dynamic-import DCE tests to use CLI-backed concurrent execution, normalized inline snapshots, exact export-shape checks, and explicit runtime error assertions.

Changes

Dynamic import DCE

Layer / File(s) Summary
CLI test harness and output normalization
test/bundler/bundler_dynamic_import_dce.test.ts
The test wrapper defaults to the CLI backend, enables concurrent execution, and normalizes bundle output by removing runtime helpers and chunk hashes.
Export filtering and chunk snapshots
test/bundler/bundler_dynamic_import_dce.test.ts
Exact snapshots cover narrowed namespaces, split and shared chunks, namespace bailouts, unused code removal, and filtered inlined imports.
Require interop and runtime error behavior
test/bundler/bundler_dynamic_import_dce.test.ts
Assertions cover split and no-split require() output, wrapped namespaces, thenable importees, rejection handling, and empty stderr.

Suggested reviewers: jarred-sumner

Merge Risk: ⚪ Minimal · up to 5f749

This change only updates one test file to run cases concurrently and pin export shapes; no actionable merge-blocking risk remains after normal checks and review.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The description clearly explains the problem, implementation, verification results, performance impact, and test coverage. It does not use the exact template headings, but it provides the required inf…
Title check ✅ Passed The title clearly identifies the test file and the two primary changes: concurrent execution and pinned output shapes.
Full details: Description check

Explanation

The description clearly explains the problem, implementation, verification results, performance impact, and test coverage. It does not use the exact template headings, but it provides the required information.


Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM — test-only refactor that strengthens assertions and reuses an established concurrency pattern.

What was reviewed:

  • The backend: "cli" wrapper matches bundler_compile.test.ts / esbuild/importstar.test.ts verbatim, and expectBundled.ts:1972 confirms only API-backend cases are forced to it.serial, so describe.concurrent is safe here.
  • bundleBody's ^// [\w./-]+$ correctly skips // @ bun and the hash regex only touches -[hash].js chunk names; jsOutputs sorts and CRLF-normalizes for stable cross-platform snapshots.
  • Collapsed ThenableImportee loop: adding outdir: "/out" + run.file to the no-split case is consistent with other non-splitting cases in this file that already use outdir.
  • No case is deleted or weakened — every prior .not.toContain("DROPPED") / run check is subsumed by the inline snapshot or kept as-is.
Extended reasoning...

Overview

This PR refactors a single bundler test file, test/bundler/bundler_dynamic_import_dce.test.ts, with no production code changes. It (1) wraps itBundled to default backend: "cli" so cases can overlap under describe.concurrent, (2) replaces ~15 loose .toContain / .not.toContain substring checks with toMatchInlineSnapshot over a normalized bundle body (new jsOutputs / bundleBody / outputBodies helpers that sort outputs, normalize CRLF, strip the runtime-helper prelude, and mask 8-char chunk hashes), (3) adds stderr: "" to six run: specs that exercise caught throws, (4) collapses two identical ThenableImportee cases into a for (const splitting of [true, false]) loop, and (5) adds an onAfterBundle snapshot to one previously assertion-less case.

Security risks

None. This is test-only code that spawns the debug bun build binary against per-case temp directories via the existing expectBundled harness. No network, no auth, no crypto, no user-facing surface.

Level of scrutiny

Low-to-moderate. The concurrency change is the only part with correctness implications, and it reuses the exact const itBundled = (id, opts) => itBundledBase(id, { backend: "cli", ...opts }) wrapper already committed in bundler_compile.test.ts and esbuild/importstar.test.ts. I verified in expectBundled.ts that resolveBackend returns opts.backend when set and that only "api" cases are registered with it.serial (because of the process.chdir around Bun.build), so CLI-backend cases legitimately overlap under describe.concurrent. Each case writes to its own harness temp root, so there's no shared filesystem state.

Other factors

REVIEW.md's "never silently weaken an existing test" and "assert the strongest invariant" both cut in favor of this change: every replaced substring check is subsumed by a full inline snapshot of the narrowed export object / chunk, and every run: block is retained. The bundleBody regex /^\/\/ [\w./-]+$/m correctly excludes // @ bun (target-bun prelude) since @ isn't in the character class, and the -[a-z0-9]{8}\.js hash mask matches the same pattern bundler_splitting.test.ts strips. The collapsed ThenableImportee loop adds outdir: "/out" to the no-split variant, which is consistent with many other non-splitting cases in this file already using outdir + run.file. No CODEOWNERS entry covers test/bundler/, and there are no outstanding reviewer objections in the timeline. Exit reason was dry_streak.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant