Skip to content

js_parser: don't leak the import.meta.hot drop flag into the next call - #36875

Open
robobun wants to merge 3 commits into
mainfrom
farm/d62a550a/hot-accept-read-flag-leak
Open

js_parser: don't leak the import.meta.hot drop flag into the next call#36875
robobun wants to merge 3 commits into
mainfrom
farm/d62a550a/hot-accept-read-flag-leak

Conversation

@robobun

@robobun robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator

Problem

With HMR disabled (the default for bun build), reading an import.meta.hot method without calling it silently drops the next unrelated call expression in the file:

// in.js
globalThis.x = import.meta.hot.accept;
foo();
bar();
$ bun build --no-bundle in.js
globalThis.x = undefined;
bar();

foo() is gone. The same happens for dispose / decline / prune / invalidate / on / off / send, and for both --no-bundle and the default bundling mode.

Cause

maybe_rewrite_property_access (src/js_parser/fold.rs) handles the HotEnabled | HotDisabled target. When HMR is off, the accept branch and the decline|dispose|prune|invalidate|on|off|send branch both do:

p.method_call_must_be_replaced_with_undefined = true;
return Some(EUndefined);

method_call_must_be_replaced_with_undefined is a parser-wide flag that e_call reads (and then clears) after visiting its own target. The --drop path that uses the same flag (visit_expr.rs e_identifier / e_dot) gates its write on in_.property_access_for_method_call_maybe_should_replace_with_undefined, which e_call sets only while visiting its target chain. The import.meta.hot path has no such gate, so a bare property read sets the flag with no enclosing e_call to consume it, and the next call anywhere in the module picks it up and is replaced with undefined.

Fix

Gate both writes on identifier_opts.is_call_target(). That bit is true exactly when the property access node is p.call_target, i.e. when an enclosing e_call set it and will consume the flag on return. A bare read still folds to undefined; it just no longer poisons an unrelated call.

Tests

Two new cases in test/bundler/bundler_minify.test.ts next to the existing ImportMetaHotTreeShaking coverage:

  • minify/ImportMetaHotReadWithoutCall (bundled, run): reads every affected method name without calling, asserts each following console.log is present in the output and runs, and re-asserts that direct calls are still stripped.
  • minify/ImportMetaHotReadWithoutCallNoBundle: --no-bundle variant of the repro.

Both fail on main (the after-* lines are missing from the bundle) and pass with this change. bundler_minify.test.ts (44 pass), bundler_drop.test.ts (14 pass) and bake/dev-and-prod.test.ts (12 pass) are green.


[review] gate passed · iteration 0 · 2 files touched

fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/bundler_minify.test.ts
bun test v1.4.0 (3964047a3)

test/bundler/bundler_minify.test.ts:
(pass) bundler > minify/TemplateStringFolding [580.19ms]
(pass) bundler > minify/StringAdditionFolding [107.26ms]
(pass) bundler > minify/FunctionExpressionRemoveName [106.50ms]
(pass) bundler > minify/KeepNamesPreservesNames [128.93ms]
(pass) bundler > minify/KeepNamesWithMinifyIdentifiers [85.82ms]
(pass) bundler > minify/PrivateIdentifiersNameCollision [1164.19ms]
(pass) bundler > minify/MergeAdjacentVars [389.61ms]
(pass) bundler > minify/UnusedCommaAndStrictEqChains [398.83ms]
(pass) bundler > minify/Infinity [117.67ms]
(pass) bundler > minify+whitespace/Infinity [93.94ms]
(pass) bundler > minify/NumericPropertyKeysPrintedAsComputed [378.00ms]
(pass) bundler > minify/InlineArraySpread [86.36ms]
(pass) bundler > minify/ForAndWhileLoopsWithMissingBlock [369.28ms]
(pass) bundler > minify/MissingExpressionBlocks [433.44ms]
(pass) bundler > minify/BunRequireStatement [1014.57ms]
(pass) bundler > minify/SwitchUndefined [547.82ms]
(pass)
... (truncated)

release without fix: 3 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/bundler/bundler_minify.test.ts:
(pass) bundler > minify/TemplateStringFolding [13.13ms]
(pass) bundler > minify/StringAdditionFolding [3.30ms]
(pass) bundler > minify/FunctionExpressionRemoveName [3.49ms]
(pass) bundler > minify/KeepNamesPreservesNames [3.20ms]
(pass) bundler > minify/KeepNamesWithMinifyIdentifiers [3.08ms]
(pass) bundler > minify/PrivateIdentifiersNameCollision [22.46ms]
(pass) bundler > minify/MergeAdjacentVars [17.74ms]
(pass) bundler > minify/UnusedCommaAndStrictEqChains [17.81ms]
(pass) bundler > minify/Infinity [3.28ms]
(pass) bundler > minify+whitespace/Infinity [2.90ms]
(pass) bundler > minify/NumericPropertyKeysPrintedAsComputed [16.57ms]
(pass) bundler > minify/InlineArraySpread [3.32ms]
(pass) bundler > minify/ForAndWhileLoopsWithMissingBlock [16.10ms]
(pass) bundler > minify/MissingExpressionBlocks [19.54ms]
(pass) bundler > minify/BunRequireStatement [27.67ms]
(pass) bundler > minify/SwitchUndefined [20.71ms]
(pass) bundler > minify/RequireInDeadBranch [3.55ms]
(pass) bundler > minify/TypeOfRequire [3.09ms]
(pass) bundler > minify/RequireMainToImportMetaMain [2.97ms]
(pass) bundler > minify/Con
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/bundler/bundler_minify.test.ts
bun test v1.4.0 (3964047a3)

test/bundler/bundler_minify.test.ts:
(pass) bundler > minify/TemplateStringFolding [580.02ms]
(pass) bundler > minify/StringAdditionFolding [103.83ms]
(pass) bundler > minify/FunctionExpressionRemoveName [108.87ms]
(pass) bundler > minify/KeepNamesPreservesNames [119.82ms]
(pass) bundler > minify/KeepNamesWithMinifyIdentifiers [104.53ms]
(pass) bundler > minify/PrivateIdentifiersNameCollision [1140.06ms]
(pass) bundler > minify/MergeAdjacentVars [380.38ms]
(pass) bundler > minify/UnusedCommaAndStrictEqChains [413.30ms]
(pass) bundler > minify/Infinity [99.89ms]
(pass) bundler > minify+whitespace/Infinity [94.15ms]
(pass) bundler > minify/NumericPropertyKeysPrintedAsComputed [380.06ms]
(pass) bundler > minify/InlineArraySpread [95.45ms]
(pass) bundler > minify/ForAndWhileLoopsWithMissingBlock [382.14ms]
(pass) bundler > minify/MissingExpressionBlocks [442.22ms]
(pass) bundler > minify/BunRequireStatement [1010.48ms]
(pass) bundler > minify/SwitchUndefined [538.04ms]
(pass)
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 690ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[0/111] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_http_types v0.0.0 (/workspace/bun/src/http_types)
�[1m�[92m   Compiling�[0m bun_analytics v0.0.0 (/workspace/bun/src/analytics)
�[1m�[92m   Compiling�[0m bun_threading v0.0.0 (/workspace/bun/src/threading)
�[1m�[92m   Compiling�[0m bun_libarchive v0.0.0 (/workspace/bun/src/libarchive)
�[1m�[92m   Compiling�[0m bun_boringssl v0.0.0 (/workspace/bun/src/boringssl)
�[1m�[92m   Compiling�[0m bun_glob v0.0.0 (/workspace/bun/src/glob)
�[1m�[92m   Compiling�[0m bun_md v0.0.0 (/workspace/bun/src/md)
�[1m�[92m   Compiling�[0m bun_ast v0.0.0 (/workspace/bun/src/ast)
�[1m�[92m   Compiling�[0m bun_dns v0.0.0 (/workspace/bun/src/dns)
�[1m�[92m   Compiling�[0m digest v0.10.7
�[1m�[92m   Compiling�[0m cipher v0.4.4
�[1m�[92m   Compiling�[0m rust-argon2 v3.0.0
�[1m�[92m   Compiling�[0m bun_spawn_sys v0.0.0 (/workspac
... (truncated)
diff hotspot
src/js_parser/fold.rs               |  8 ++++--
 test/bundler/bundler_minify.test.ts | 55 +++++++++++++++++++++++++++++++++++++
 2 files changed, 61 insertions(+), 2 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                 reads  edits  tests
src/js_parser/fold.rs                    4      4      0
test/bundler/bundler_minify.test.ts      1      1      0

When HMR is disabled, reading import.meta.hot.accept (or
dispose/prune/on/off/send/decline/invalidate) without calling it would
set p.method_call_must_be_replaced_with_undefined unconditionally. That
flag is parser-wide and only consumed by e_call after visiting its
target, so the write leaked into the next unrelated call expression and
silently dropped it from the output.

Gate the flag write on identifier_opts.is_call_target() so it is only
set when an enclosing e_call will actually consume it. The --drop path
already gates on the equivalent condition.
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The parser now treats disabled import.meta.hot method reads differently from calls. Minification tests cover bundled and non-bundled builds, including retained reads, removed calls, and preserved logging.

Changes

HMR property folding

Layer / File(s) Summary
Context-sensitive HMR folding
src/js_parser/fold.rs
Disabled HMR methods set the replacement flag only when used as call targets. Non-call property reads still resolve to undefined.
Minification regression coverage
test/bundler/bundler_minify.test.ts
Bundled and non-bundled tests verify retained method reads, removed direct calls, removed callbacks, and preserved logging.

Possibly related PRs

  • oven-sh/bun#35961: Both changes modify import.meta property-access handling in src/js_parser/fold.rs.
  • oven-sh/bun#36734: Both changes control import.meta.hot rewrites based on operation context.

Suggested reviewers: jarred-sumner

🚥 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.
Title check ✅ Passed The title clearly identifies the parser flag leak caused by import.meta.hot access and the intended fix.
Description check ✅ Passed The description explains the problem, cause, fix, affected modes, regression tests, and verification results.

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

@github-actions github-actions Bot added the claude label Aug 4, 2026
@robobun

robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 4:12 AM PT - Aug 4th, 2026

@robobun, your commit 3964047a36df5afa2da7994af58a4c68ac0edea9 passed in Build #88742! 🎉


🧪   To try this PR locally:

bunx bun-pr 36875

That installs a local version of the PR into your bun-36875 executable, so you can run:

bun-36875 --bun

@robobun

robobun commented Aug 4, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced with:

$ printf 'globalThis.x = import.meta.hot.accept;\nfoo();\nbar();\n' > /tmp/r.js
$ bun build --no-bundle /tmp/r.js
globalThis.x = undefined;
bar();

After the change:

globalThis.x = undefined;
foo();
bar();

The diff is green locally (bundler_minify.test.ts 44 pass, bundler_drop.test.ts 14 pass, bake/dev-and-prod.test.ts 12 pass) and the gate passed. The remaining CI failures on builds 88730 and 88742 are unrelated to this change:

  • test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js: JSC ExceptionScope::assertNoException() during worker termination (reported to main-break triage; did not reproduce on 88742).
  • test/js/node/zlib/zlib-estimated-size-gc.test.ts, test/js/bun/test/pretty-format-overflow.test.ts, test/cli/run/run-crash-handler.test.ts, test/js/node/watch/fs.watch.deadlock.test.ts: all failed only in the parallel batch and passed when re-run alone.

Ready for review.

@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 — surgical gate on the parser-wide drop flag; the leak into unrelated calls is fixed and the direct-call stripping path is unchanged.

What was reviewed

  • Traced is_call_target at both maybe_rewrite_property_access call sites (e_dot/e_index) back to p.call_target, which e_call sets before visiting and whose consumer clears the flag on return — the gate matches the flag's lifetime exactly.
  • Confirmed the --drop writers of the same flag already gate on in_.property_access_for_method_call_maybe_should_replace_with_undefined, so this brings the import.meta.hot path in line.
  • Checked the third caller in p.rs (define-chain builder) passes default opts, so it can't reach the HotDisabled arm with is_call_target true.
  • New tests cover every affected method name, both bundled and --no-bundle, plus a re-assertion that direct calls are still stripped.
Extended reasoning...

Overview

Two-line fix in src/js_parser/fold.rs (maybe_rewrite_property_access, the ESpecial::HotEnabled|HotDisabled arm): the writes to p.method_call_must_be_replaced_with_undefined in the accept and decline|dispose|prune|invalidate|on|off|send branches are now gated on identifier_opts.is_call_target(). Two new itBundled cases in test/bundler/bundler_minify.test.ts sit next to the existing ImportMetaHotTreeShaking test.

Mechanism check

method_call_must_be_replaced_with_undefined is a parser-wide bool. Its only consumer is e_call (visit_expr.rs:1965-1968), which reads and clears it immediately after visiting its own target. is_call_target at the two visit_expr.rs call sites (887, 1343) is computed by pointer-comparing the current node against p.call_target, which e_call sets before that visit. So identifier_opts.is_call_target() is true exactly when an enclosing e_call will consume the flag on return — the gate is precise. The other two writers of this flag (visit_expr.rs:302, 1368, for --drop) already gate on the analogous in_.property_access_for_method_call_maybe_should_replace_with_undefined, so the hot path was the outlier.

The change is a strict narrowing: when is_call_target() is true, behavior is byte-identical to before (flag set, EUndefined returned, enclosing call dropped). When false, the property still folds to EUndefined but the flag stays clear, so the next unrelated call in the file survives. The one behavioral corner case I noted — import.meta.hot.accept.call(...) — now emits undefined.call(...) instead of being silently dropped, which is arguably more correct given import.meta.hot.accept is undefined when HMR is off.

Security risks

None. Bundler-output correctness only; no untrusted-input parsing, no allocation, no FFI.

Level of scrutiny

Medium — js_parser is production-critical, but the diff is a two-token guard on a niche branch (HMR-disabled import.meta.hot property access) with a well-understood consumer. The bug it fixes (silently dropping user code) is far more serious than any edge the narrowing could introduce.

Other factors

Tests are placed alongside existing coverage per repo convention, exercise every method name in the lookup table, cover both bundling modes, run the output, and negative-assert that direct calls are still stripped and import.meta.hot is gone from output. PR description reports bundler_minify, bundler_drop, and bake/dev-and-prod suites passing; robobun confirmed the repro before/after.

Comment thread src/js_parser/fold.rs Outdated

@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 — targeted fix that gates the parser-wide drop flag on is_call_target(), matching how the --drop path already guards it.

What was reviewed:

  • Traced p.call_target set in e_call (visit_expr.rs:1845) → is_call_target computed on the EDot node → passed through to maybe_rewrite_property_access; the flag is now only set when the enclosing e_call will read-and-clear it at visit_expr.rs:1965-1968.
  • Confirmed the non-call path still returns EUndefined (bare reads fold correctly), and the existing ImportMetaHotTreeShaking test plus the new FAIL-*-call assertions guard against regressing the call-stripping behavior.
  • Both callers in visit_expr.rs (EDot line 1429, EIndex line 1037) thread is_call_target through, so the gate applies to import.meta.hot["accept"] too.
Extended reasoning...

Overview

Two-line fix in src/js_parser/fold.rs wrapping p.method_call_must_be_replaced_with_undefined = true in an if identifier_opts.is_call_target() guard for both the accept branch and the decline|dispose|prune|invalidate|on|off|send branch of the HotEnabled | HotDisabled arm. Two new itBundled tests in test/bundler/bundler_minify.test.ts covering bundled+run and --no-bundle variants.

Security risks

None. This is bundler/transpiler constant folding for import.meta.hot — no auth, crypto, filesystem, or untrusted-input parsing surface.

Level of scrutiny

Low-medium. The mechanism is well-explained and I verified it against the source: e_call sets p.call_target = target.data (visit_expr.rs:1845) before visiting, the EDot/EIndex visitors compute is_call_target by pointer equality against that, and e_call consumes and clears the flag immediately after the target visit (visit_expr.rs:1965-1968). Gating on is_call_target() therefore guarantees an enclosing consumer exists. The change is strictly a narrowing — the flag is set in a subset of the cases it was before — so the only risk is that call-stripping stops working, which both the pre-existing ImportMetaHotTreeShaking test and the new not.toContain("FAIL") / not.toContain("import.meta.hot") assertions cover.

Other factors

The comment-cop inline about a paragraph-long explanatory comment was addressed in 3964047 (comment removed; thread resolved). Tests are placed next to existing import.meta.hot coverage per repo convention, exercise all eight affected method names, assert both output contents and runtime stdout, and re-verify the positive case (direct calls still stripped). PR description reports bundler_minify, bundler_drop, and bake/dev-and-prod suites green.

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