Skip to content

Implement import.meta.hot for bun --hot - #32856

Open
robobun wants to merge 20 commits into
mainfrom
farm/d3f3c56f/import-meta-hot-for-bun-hot
Open

Implement import.meta.hot for bun --hot#32856
robobun wants to merge 20 commits into
mainfrom
farm/d3f3c56f/import-meta-hot-for-bun-hot

Conversation

@robobun

@robobun robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator

Fixes #7337
Fixes #8963

Problem

Under bun --hot, each save re-evaluates every module in the same process, but nothing disposed the previous generation's resources. Every top-level setInterval, process.on(...) listener, new Worker(...), etc. from prior generations kept running: N saves meant N live copies.

There was also no way for user code to clean up: import.meta.hot was undefined at runtime. The transpiler folded import.meta.hot (and calls like import.meta.hot.dispose(...)) to a literal undefined regardless of mode, and the actual HMR object only existed in the bundler's dev server. The --hot docs' own example is a top-level setInterval saved repeatedly, which leaked one interval per save.

// app.ts, run with: bun --hot app.ts   then save the file 3 times
let gen = (globalThis.__gen = (globalThis.__gen ?? 0) + 1);
setInterval(() => console.log(`tick from gen ${gen}`), 500);
process.on("beforeExit", () => {});
console.log("hot api:", import.meta.hot);   // => undefined
// after 3 saves: ticks from gen 1, 2, and 3 all firing; listener count 1 -> 2 -> 3

Cause

  • GlobalObject::reload() / VirtualMachine::reload() only cleared Bun.Cron jobs and the module registry; no other per-generation state was touched.
  • The import.meta.hot API was a compile-time rewrite (src/js_parser/fold.rs) gated on the bundler's hot_module_reloading flag. Outside the Bake dev server it always folded to E::Special::HotDisabled (printed as undefined), so the runtime never had a chance to provide it.

Fix

Under bun --hot (main thread only; Workers are never reloaded, so they keep import.meta.hot === undefined), every module's import.meta gets a hot object:

  • data: an accessor backed by a JSMap on the global keyed by module URL, so it survives the module being re-evaluated. Reading it creates {} on first use; assigning to it writes through to the map (the Bake runtime's behaviour; Vite makes it getter-only).
  • dispose(cb): queues (cb, module URL) on the global. VirtualMachine::reload() now runs in two phases: it first calls the new JSC__JSGlobalObject__runImportMetaHotDispose, which drains the whole queue (so a module the next generation no longer imports is disposed too) and calls each callback, in registration order, with its own module's current data. If any callback returns a pending Promise, the export returns a promise that fulfills once all of them have settled; reload() stores it, marks the reload deferred and returns. The existing deferred-reload poll (report_exception_in_hot_reloaded_module_if_needed) re-enters reload(), which sees the settled promise and proceeds with phase two (cron clear, registry reset, re-evaluate); file changes that arrive while waiting coalesce into that reload. This matches what Vite, the Bake runtime and devserver.d.ts already promise for dispose.
  • Resuming from the poll: a reload that proceeds from report_exception_in_hot_reloaded_module_if_needed runs outside the task queue, so nothing drained the new generation's module-loader microtasks until the next tick, and an idle process only ticks again when the bounded 1 s park in tick_possibly_forever expires. The poll now drains microtasks and pre-arms the waker after a reload it resumed (the same thing a task-driven reload gets from the task runner). Settle-to-re-evaluate for a dispose promise in a debug build: about 1015 ms before, about 15 ms after, for every kind of settle source tried (microtask, timer, fs read, server.stop(), setImmediate). Main's pre-existing deferrals behind a pending entry promise take the same path and get the same fix.
  • Errors: a throwing callback, or a rejected promise from one, is printed through Bun__logUnhandledException (VirtualMachine::run_error_handler), the same non-fatal path used for errors in the reloaded file itself. Bun__reportUnhandledError was wrong here: once a --hot script has gone idle after touching process, exit_on_uncaught_exception is set and that path exits the process. Remaining callbacks still run and the reload still happens either way.
  • accept, decline, on, off, prune, invalidate, send: no-ops, so code written for Vite or the dev server loads. All methods live on a shared ImportMetaHotPrototype and validate this (ERR_INVALID_THIS); the per-module object only carries its URL under a private name.

Where the property comes from: VirtualMachine::is_hot_reload_enabled() is the single predicate. The C++ side calls it once, when the import.meta prototype is created, to decide whether a hot accessor exists at all (no per-access check); both runtime transpile paths (jsc_hooks.rs for entry points and the RuntimeTranspilerStore worker-pool path that every imported module takes) use it to set the new runtime_hot parser feature.

Why the parser needs a flag: fold.rs used to fold import.meta.hot to undefined everywhere except the Bake dev server, which is what makes unguarded import.meta.hot.dispose(...) calls disappear from plain bun run, bun build and Bun.Transpiler output. That folding is kept in every mode; runtime_hot is the one case (the --hot runtime transpiler) where the expression is left alone so the runtime property can answer. Removing the flag would either break DCE in plain runs or make the property unreachable under --hot.

Transpiler cache: runtime_hot is not part of the features hash, because it only changes output for files that use import.meta.hot (the fold in fold.rs is the one place the parser reads it). The parser sets has_import_meta_hot at that fold, and after parsing records an ImportMetaHotMode (Unused, Plain or Hot) into the cache entry's metadata next to exports_kind. RuntimeTranspilerCache::get() rejects and unlinks an entry whose recorded mode does not match the current run, so the file is re-transpiled and the entry rewritten, the same path a features_hash mismatch takes. Files that never touch import.meta.hot keep one entry shared by bun --hot and plain runs; files that do get theirs rewritten on every mode switch, in both directions and however the expression is spelled. The same applies within one --hot process between the main VM and Workers, which transpile in plain mode: such a file loaded by both sides is re-transpiled by whichever side did not write the entry last. Keeping both outputs would need a second file name plus a two-step lookup (the mode is only known after parsing), which this case does not justify. The metadata grows by one byte, so the cache version goes from 25 to 26 and entries written by earlier builds are rewritten once.

Docs (docs/runtime/watch-mode.mdx) describe the above. They limit the no-guard claim to what the non-hot fold handles (full import.meta.hot.<method>(...) calls and single import.meta.hot.data expressions; anything else needs if (import.meta.hot), same rules as the bundler docs), say that dispose callbacks run on the next reload whether or not the module is evaluated again, that a --preload script is evaluated once per process, and that Bun.serve() is reused across reloads by default: a fresh server per reload means calling server.stop() from dispose() (the port is released synchronously), while id: null on its own only disables the reuse and leaves the old server listening. devserver.d.ts, the only ImportMeta.hot declaration, carries the same notes for hot, data and dispose.

Verification

test/cli/hot/hot.test.ts (import.meta.hot block):

  • undefined without --hot; unguarded dispose/accept/on calls and a data.x ??= expression are folded and run fine.
  • Entry module: dispose runs before each reload with data, data persists, previous generation's interval/listener are released; object identity is stable, methods are on the prototype, this-less calls throw ERR_INVALID_THIS.
  • data assignment persists into the next generation, and a dispose callback registered before the assignment still receives the assigned object.
  • Entry and an imported module (the RuntimeTranspilerStore path) each stamp their own data and record what their dispose callback was handed: the two data objects are distinct, each callback gets its own module's object, and the dependency's callback runs before the entry's.
  • A module only loaded by generation 1 via await import() has its dispose run on the 1 to 2 reload and not on 2 to 3.
  • Two dispose callbacks: A (registered first) rejects after two setImmediates, B resolves after one. Gen 2 observes dispose A, dispose B, resolve B, reject A, evaluate, and A's message is printed once per generation. Verified by mutation: fulfilling the aggregate on the first settle makes gen 2 evaluate before reject A; dropping the settled call from the rejected reaction makes the reload never resume.
  • Per-generation sources: gen 1 sets data.x and registers a dispose, gen 2 registers an async dispose and then throws at top level, gen 3 is plain. Gen 3 sees data.x and both disposes, and gen 2's error is printed once, so a reload parked on dispose resumes through the poll's already-reported Rejected arm.
  • The Bun.serve() recipe: gen 1 listens on port 0 and stores the port in data, later generations bind that port after dispose stopped the previous server; each generation is a new Server object and a request after each generation is answered by that generation's handler.
  • --preload, both shapes: a preload not imported by the entry is evaluated once and its dispose runs once across three generations; a preload the entry also imports is evaluated and disposed every generation.
  • Throwing and rejecting dispose callbacks in a module that has process.on("exit") registered: errors printed, later callbacks run, reload happens, process stays alive. (Fails before this revision: exits with code 1.)
  • import.meta.hot is undefined inside a Worker spawned under --hot.

test/cli/run/transpiler-cache.test.ts: a plain file and one using import.meta.url keep byte-identical cache entries across plain, --hot and plain runs; a file reading import.meta.hot (written with a line break after import, which a textual check would miss) prints undefined / object / object / undefined across plain, --hot, --hot and plain runs, with its entry rewritten on each switch and the other entries untouched. The existing header-layout test in that file has its offsets moved by the new byte.

Also exercised: the error paths under BUN_JSC_validateExceptionChecks=1, a require()d CommonJS module using import.meta.hot, and the existing hot, transpiler-cache, bundler_minify (ImportMetaHotTreeShaking) and import-meta suites.

Related

  • Rebased onto current main twice during review. The second rebase overlapped with main moving the --watch re-exec to the top of VirtualMachine::reload(); the dispose phase here therefore only covers --hot (where import.meta.hot exists) and the --watch branch is not duplicated. Main also narrowed neighbouring field visibility to pub(crate); the two fields this PR adds follow suit.
  • Third rebase (onto 3d3016e329, 322 commits): main turned the GlobalObject lazy-property initLater calls into offset tables, so the m_importMetaHotStructure initializer is now an entry in lazyStructureInits next to the other two import.meta structures; hot_reload became the HotReload enum, so is_hot_reload_enabled() compares against HotReload::Hot; and ImportMetaHotPrototype uses the new out-of-line cell helpers (Bun::allocatePlainObjectCell, reifyStaticPropertyTable, putToStringTagWithoutTransition, createClassStructure) that main converted this file's other prototype to. Everything else merged as is (checked by comparing the added and removed lines before and after). The intro example keeps its declare global block again, which the first revision had dropped for no reason.
  • Incremental build hmr #28486 is a much larger bundler/HMR feature PR (71 files, predates the Rust migration, currently conflicted) that also adds an import.meta.hot getter as one part. This PR is independent and narrowly scoped to the runtime --hot hook.

no test proof · iteration 12 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/hot/hot.test.ts

@mintlify

mintlify Bot commented Jun 27, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
bun 🟢 Ready View Preview Jun 27, 2026, 2:40 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Adds import.meta.hot support for bun --hot, including dispose(), persistent data, reload-time callback execution, parser/runtime hot-state propagation, and matching docs and tests.

import.meta.hot support for bun --hot

Layer / File(s) Summary
runtime_hot feature propagation
src/js_parser/parser.rs, src/js_parser/fold.rs, src/js_parser/parse/parse_entry.rs, src/bundler/transpiler.rs, src/runtime/api/JSTranspiler.rs
Adds runtime_hot to runtime features and parse options, propagates it through transpilation, updates the runtime transpiler hash, and changes import.meta.hot folding when runtime hot mode is enabled.
VM hot-reload export and parse wiring
src/jsc/virtual_machine_exports.rs, src/jsc/bindings/ZigGlobalObject.h, src/jsc/RuntimeTranspilerStore.rs, src/runtime/jsc_hooks.rs
Adds the VM hot-reload export and uses it to set runtime_hot in the JSC transpiler parse path.
import.meta.hot object
src/jsc/bindings/ImportMetaObject.h, src/jsc/bindings/ImportMetaObject.cpp
Implements the hot accessor, dispose host function, no-op compatibility methods, lazy per-module hot object creation, shared hot data storage, and GC visitation.
GlobalObject dispose storage and reload hook
src/jsc/bindings/ZigGlobalObject.h, src/jsc/bindings/ZigGlobalObject.cpp
Adds GC-tracked hot data and dispose-list storage on GlobalObject, implements disposal callback draining and execution, and calls it at the start of reload.
Tests and docs
test/cli/hot/hot.test.ts, docs/runtime/watch-mode.mdx
Updates watch-mode docs with the hot cleanup pattern and import.meta.hot API description, and adds integration tests for undefined access, dispose execution, data persistence, error handling, and argument validation.

Suggested reviewers

  • Jarred-Sumner
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The description links #7337 and #8963 and explains how the implementation addresses both resource cleanup and pre-reload hooks.
Out of Scope Changes check ✅ Passed The changes remain focused on runtime hot reload support, parser integration, documentation, and targeted tests.
Title check ✅ Passed The title clearly and concisely summarizes the main change: implementing import.meta.hot for bun --hot.
Description check ✅ Passed The description thoroughly explains the problem, implementation, verification, related issues, and known CI limitation.

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

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:22 PM PT - Aug 20th, 2026

@robobun, your commit 140591c has 1 failures in Build #101968 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 32856

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

bun-32856 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. Run code before reload with bun --watch and bun --hot #7337 - Requests ability to run cleanup code before reload; import.meta.hot.dispose() provides exactly this mechanism
  2. Timers are not canceled after --hot reload #8963 - Timers persist across --hot reloads with no way to cancel them; import.meta.hot.dispose() enables cleanup
  3. Docs suggest HMR isn't supported #30911 - Docs state import.meta.hot is "planned for a future version"; this PR implements it

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #7337
Fixes #8963
Fixes #30911

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Incremental build hmr #28486 - Also implements import.meta.hot for bun --hot mode in ImportMetaObject.cpp, including the hot getter, dispose/accept/data API, and per-module HMR state

🤖 Generated with Claude Code

Comment thread test/cli/hot/hot.test.ts
Comment thread src/jsc/bindings/ImportMetaObject.cpp Outdated
Comment thread src/js_parser/fold.rs

@coderabbitai coderabbitai 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.

Actionable comments posted: 5

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/runtime/watch-mode.mdx`:
- Around line 147-155: The `import.meta.hot.data` example in `server.ts` is not
runnable because it references an undefined `tick` and persists a timer handle
without proper cleanup, which can leave a stale interval in `hot.data.interval`
across reloads. Update the snippet so it uses a real callback/function defined
in the example, and make the `import.meta.hot.dispose` handler clear the
interval and remove or reset the stored handle in `import.meta.hot.data`. Keep
the example self-contained and ensure the `setInterval`/`dispose` flow in the
watch-mode docs demonstrates correct lifecycle handling.

In `@src/jsc/bindings/ZigGlobalObject.cpp`:
- Around line 3395-3403: `import.meta.hot.data` entries are never cleared for
modules that are no longer loaded, so `ImportMetaObject::hotProperty` keeps
stale per-URL objects alive across reloads. Update the hot-reload cleanup path
in `GlobalObject::reload()` to also remove or reset the `m_importMetaHotDataMap`
entries for unloaded module URLs, alongside the existing module loader and
`requireMap` clearing. Use the existing
`importMetaHotDataMap()`/`m_importMetaHotDataMap` access points to locate the
cache and ensure unloaded modules no longer retain strong references.
- Around line 3422-3457: The dispose list handling in
ZigGlobalObject::runImportMetaHotDispose callbacks is using an unrooted raw list
after m_importMetaHotDisposeList.clear(), so GC during profiledCall can
invalidate list/entry accesses. Fix this by following the same pattern as
handleRejectedPromises(): take a rooted snapshot of the dispose entries before
clearing the global list, then iterate that rooted snapshot while invoking
callbacks. Ensure any JSValue kept across the native call is rooted or copied in
a GC-safe container.

In `@src/jsc/virtual_machine_exports.rs`:
- Around line 29-31: The hot-reload export in hot_reload_mode is exposing the
raw enum byte, which ties ImportMetaObject.cpp to a numeric contract. Change the
VirtualMachine export to return a boolean hot/not-hot check instead, and update
the C++ consumer to use the new boolean symbol consistently so import.meta.hot
no longer depends on the underlying enum value.

In `@test/cli/hot/hot.test.ts`:
- Around line 845-872: The hot-runner stream drain in the `stderrDone`/`stdout`
reader can throw after `runner.kill()`, making the `--hot` tests flaky. Update
the async reader pattern in `hot.test.ts` so the background `runner.stderr`
drain ignores the abort race (for example by wrapping the reader promise with
the established catch-and-suppress pattern used elsewhere in these hot tests),
and apply the same fix to both `--hot` test blocks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d8e733d6-699c-449d-9b9a-d5657ac49778

📥 Commits

Reviewing files that changed from the base of the PR and between df92f8f and 51d09cb.

📒 Files selected for processing (14)
  • docs/runtime/watch-mode.mdx
  • src/bundler/transpiler.rs
  • src/js_parser/fold.rs
  • src/js_parser/parse/parse_entry.rs
  • src/js_parser/parser.rs
  • src/jsc/RuntimeTranspilerStore.rs
  • src/jsc/bindings/ImportMetaObject.cpp
  • src/jsc/bindings/ImportMetaObject.h
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/ZigGlobalObject.h
  • src/jsc/virtual_machine_exports.rs
  • src/runtime/api/JSTranspiler.rs
  • src/runtime/jsc_hooks.rs
  • test/cli/hot/hot.test.ts

Comment thread docs/runtime/watch-mode.mdx
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
Comment thread src/jsc/virtual_machine_exports.rs Outdated
Comment thread test/cli/hot/hot.test.ts Outdated
Comment thread test/cli/hot/hot.test.ts Outdated
Comment thread src/jsc/bindings/ZigGlobalObject.cpp Outdated
Comment thread docs/runtime/watch-mode.mdx Outdated

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/jsc/bindings/ImportMetaObject.cpp (1)

630-633: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Keep this comment within the 3-line limit.

As per coding guidelines, “Keep code comments to 3 lines max.”

Proposed cleanup
-// `bun --hot` re-evaluates every module on each reload, so the full
-// Vite accept() graph / event semantics don't apply; accept()/decline()/
-// on()/off()/prune()/invalidate()/send() are all no-ops. They exist so
-// Vite-flavoured code guarded by `if (import.meta.hot)` does not throw.
+// `bun --hot` re-evaluates every module, so Vite graph/event
+// methods are no-ops. They exist so guarded Vite-flavoured code
+// can call them without throwing.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/bindings/ImportMetaObject.cpp` around lines 630 - 633, Shorten the
explanatory comment in ImportMetaObject’s hot-module block to fit within 3 lines
while preserving the key point that bun --hot makes
accept/decline/on/off/prune/invalidate/send no-ops for import.meta.hot
compatibility. Keep the comment near the existing hot-reload semantics code and
trim redundant wording so it stays within the guideline.

Source: Coding guidelines

src/jsc/bindings/ZigGlobalObject.cpp (1)

3468-3473: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Preserve termination exceptions from dispose callbacks.

Line 3467 runs user JS. If that produces a VM termination exception, lines 3468-3473 currently report/clear it like an ordinary throw, which can swallow termination during reload. Check vm.isTerminationException(...) before reporting or clearing.

Proposed fix
         JSC::profiledCall(this, ProfilingReason::API, callback, callData, jsUndefined(), args, returnedException);
         if (auto* ex = returnedException.get()) {
+            if (vm.isTerminationException(ex)) [[unlikely]]
+                return;
             Bun__reportUnhandledError(this, JSValue::encode(JSValue(ex)));
         }
-        if (scope.exception()) [[unlikely]] {
-            scope.clearException();
+        if (auto* ex = scope.exception()) [[unlikely]] {
+            if (vm.isTerminationException(ex)) [[unlikely]]
+                return;
+            (void)scope.tryClearException();
         }

As per coding guidelines, C++ code that can enter JS must handle exception state before continuing, and abort/termination paths must not be swallowed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/bindings/ZigGlobalObject.cpp` around lines 3468 - 3473, The
dispose-callback exception handling in ZigGlobalObject::dispose-style JS entry
needs to preserve VM termination exceptions instead of treating them as normal
errors. Update the returnedException/scope.exception() handling to check
vm.isTerminationException(...) first, and if it is a termination exception, skip
Bun__reportUnhandledError and do not clear it so reload/abort can propagate
correctly. Keep the ordinary reporting/clearing path only for non-termination
exceptions.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/jsc/bindings/ImportMetaObject.cpp`:
- Around line 630-633: Shorten the explanatory comment in ImportMetaObject’s
hot-module block to fit within 3 lines while preserving the key point that bun
--hot makes accept/decline/on/off/prune/invalidate/send no-ops for
import.meta.hot compatibility. Keep the comment near the existing hot-reload
semantics code and trim redundant wording so it stays within the guideline.

In `@src/jsc/bindings/ZigGlobalObject.cpp`:
- Around line 3468-3473: The dispose-callback exception handling in
ZigGlobalObject::dispose-style JS entry needs to preserve VM termination
exceptions instead of treating them as normal errors. Update the
returnedException/scope.exception() handling to check
vm.isTerminationException(...) first, and if it is a termination exception, skip
Bun__reportUnhandledError and do not clear it so reload/abort can propagate
correctly. Keep the ordinary reporting/clearing path only for non-termination
exceptions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 80521ef4-d440-4b0e-baee-37af008db063

📥 Commits

Reviewing files that changed from the base of the PR and between 51d09cb and f710a1f.

📒 Files selected for processing (6)
  • docs/runtime/watch-mode.mdx
  • src/jsc/bindings/ImportMetaObject.cpp
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/ZigGlobalObject.h
  • src/jsc/virtual_machine_exports.rs
  • test/cli/hot/hot.test.ts

Comment thread src/jsc/bindings/ImportMetaObject.cpp Outdated
Comment thread src/jsc/bindings/ImportMetaObject.cpp Outdated
Comment thread test/cli/hot/hot.test.ts 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.

No further issues from my side — all prior feedback has been addressed — but this introduces a new public API (import.meta.hot shape, no-op accept/prune/etc., fire-and-forget async dispose) and adds GC-tracked state to GlobalObject/ImportMetaObject, so a maintainer should sign off on the API surface and the JSC bindings.

Extended reasoning...

Overview

This PR implements import.meta.hot for bun --hot, adding a runtime HMR object with data/dispose() plus Vite-compat no-op stubs. It spans 14 files: a new hot getter and LazyProperty on ImportMetaObject (C++), two new WriteBarrier fields and a runImportMetaHotDisposeCallbacks() drain loop on Zig::GlobalObject invoked from reload(), a new runtime_hot parser feature flag threaded through fold.rs/parser.rs/transpiler.rs/RuntimeTranspilerStore.rs/jsc_hooks.rs (plus the runtime-transpiler cache hash), a new Bun__VirtualMachine__isHotReloadMode FFI export, ~280 lines of new tests in hot.test.ts, and a docs rewrite of the --hot section in watch-mode.mdx.

Security risks

None identified. The feature is gated to dev-mode bun --hot on the main thread; it stores user-provided callbacks/data in GC-tracked JS objects and invokes them via profiledCall with standard exception handling. No auth, network, filesystem, or privilege boundaries are touched.

Level of scrutiny

High. This is a new user-facing API whose shape (which Vite methods are real vs. no-op, dispose not awaiting Promises, prune deferred to a follow-up, workers excluded) is a product decision a maintainer should ratify. The C++ side adds WriteBarrier<JSMap>/WriteBarrier<JSArray> fields to GlobalObject, a new LazyProperty with visitChildren wiring, a MarkedArgumentBuffer snapshot loop that runs arbitrary JS during reload(), and termination-exception handling — all GC- and exception-scope-sensitive code where review by someone fluent in JSC conventions is warranted. The transpiler change alters when import.meta.hot is folded vs. left live, which affects DCE behavior across bun run/bun build/Bun.Transpiler.

Other factors

I left eight rounds of inline comments on earlier revisions (test flake races, missing Vite no-op stubs, the runtime_hot parser-flag approach to preserve plain-bun run DCE, GC rooting of the dispose snapshot, async-dispose docs caveat, worker gating, etc.); all were addressed and resolved. The bug hunter found nothing on the current revision. The robobun CI comment shows musl build failures on an earlier commit (657774b); the status of the latest commit (1811525) isn't reflected in the thread yet. Test coverage for the new surface is solid (undefined-without---hot, dispose ordering, data persistence, throwing dispose, arg validation, worker exclusion), but the overall scope and the explicitly-deferred follow-ups (async dispose await, prune semantics) are the kind of thing a human should approve.

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

CI on 1811525 is green on everything this diff touches (282/286 jobs passed; test/cli/hot/hot.test.ts passed on all lanes). The two red jobs are unrelated:

  • darwin 26 aarch64buildkite-agent artifact download timed out after 120s (infra; same agent timed out on the previous build too)
  • ubuntu 25.04 x64v8-heap-snapshot.test.ts killed by SIGKILL (OOM killer; hits many unrelated open PRs)

plus two flaky-warning retries in bun install tests. All 14 review threads resolved; ready for maintainer review of the API surface.

@robobun

robobun commented Jun 27, 2026

Copy link
Copy Markdown
Collaborator Author

The retrigger (build 65741) confirms it: 281/282 completed jobs passed and the previous v8-heap-snapshot OOM flake cleared on the rerun. The single remaining red lane is:

  • darwin 26 aarch64 - test-bun: buildkite-agent artifact download timed out after 120s for step 'darwin-aarch64-build-bun'

That is the same agent (darwin-aarch64-26-5-1-1) failing on the identical artifact-download timeout across builds 65593, 65601, and 65741 — pure CI infrastructure, nothing runs. test/cli/hot/hot.test.ts passed on every lane that executed it, including darwin 14/26 aarch64 variants on other agents.

The diff is green on everything it touches. Ready for maintainer review.

@robobun

robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator Author

CI status (head 1f4fcde24c, build 71622)

285/286 jobs passed. test/cli/hot/hot.test.ts (the tests for this change) passed on every lane. The single red job is unrelated CI environment setup:

darwin 14 aarch64 - test-buntest/js/third_party/grpc-js/test-tonic.test.ts fails with rustup could not choose a version of cargo to run, because one wasn't specified explicitly, and no default is configured. The test's beforeAll needs cargo to build the tonic server; the agent's rustup has no default toolchain set, so nothing in this diff is exercised.

(The persistent darwin 26 aarch64 artifact-download timeout and the musl test-net-connect-memleak flake from earlier builds on this PR both cleared.)

The diff is green on everything it exercises.

Comment thread src/jsc/RuntimeTranspilerStore.rs Outdated
Comment thread src/js_parser/fold.rs Outdated
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

CI status (head fe4557d630, build 95333)

177 of 179 jobs passed; no test failed. The build is marked failed only because the two darwin aarch64 previous-release-tier test jobs (the job and its retry) expired in the queue before an agent picked them up (the six agents carrying release-tier=previous on test-darwin were all busy; the build sat for several hours). The tests in this PR (test/cli/hot/hot.test.ts, including the cases added in this round, and test/cli/run/transpiler-cache.test.ts) passed on every lane that ran, including darwin 26 aarch64, both Windows lanes, the musl lanes and the ASAN lane. The seven tests Buildkite lists as flaky for this build all passed on retry or when rerun alone and are unrelated (bun install registry, child_process IPC, fs read stream, and similar).

Nothing to change on this side; the diff is green on everything it exercises.

ebowwa pushed a commit to ebowwa/cordis that referenced this pull request Aug 17, 2026
Focused upstream integration (no Bun fork, no runtime-source changes):

- docs: correct the --watch/--hot semantics to match Bun's official
  documentation and re-measurement on 1.3.14 — --watch is a hard restart
  (globalThis fresh, no handler accumulation), --hot is the in-process
  soft reload preserving globalThis (handlers/timers accumulate). The old
  Phase 5 notes had these attributes swapped; bin.bun.js's globalThis root
  guard was always the correct --hot strategy.
- bin.bun.js: register import.meta.hot.dispose(() => disposePrevious())
  when the runtime provides it (PR build) — runtime-awaited disposal
  before re-evaluation; no-op on stock Bun/Node. globalThis guard kept as
  defense-in-depth.
- tests/bun/hot.spec.ts: 2 PR-build-gated integration tests (skip cleanly
  when bun-32856 is absent, verified): awaited async disposal completes
  before reactivation, no resource duplication across 3 generations; broken
  generation still disposes the old root and recovers on next edit.
- CI: install the PR build best-effort (bunx bun-pr 32856) and run the
  hot suite; download failures leave the tests skipped, real failures fail.
- repros: hot-pr-dispose-order.ts, signal-handler-accumulation.ts; README
  corrected and extended.

bun test tests/bun: 60/60 (216 expect) · Node suite: 163/163 ·
yakumo esbuild+tsc: exit 0. No Bun-source changes needed; nothing to
report on PR #32856.
@ebowwa

ebowwa commented Aug 17, 2026

Copy link
Copy Markdown

Downstream validation from Cordis — a plugin/composition framework whose dev reload needs exactly the contract this PR adds. Sharing in case it's useful signal; everything below passed on the first artifact we tried (Aug 13 CI build, bun-1.4.0-pr32856), on both macOS arm64 and ubuntu-latest.

Our integration: our Bun entrypoint registers import.meta.hot.dispose() to fully dispose the previous root context (an async fiber disposal: timers, listeners, services) before the module is re-evaluated. That makes bun --hot usable as an in-process dev reload for framework-style apps where each reload otherwise leaks the previous generation's effects.

Verified cases (fixture: tests/bun/hot.spec.ts, 3 tests, gated on the PR binary and skipped when it's absent):

  • Awaited dispose ordering — a 150 ms async disposer completes before the next generation's module code runs (not just starts).
  • Dynamic import graph — editing a dynamically imported plugin re-evaluates the whole reachable graph; dispose callbacks for the entry and the plugin run, awaited, strictly before any re-evaluation.
  • No duplication across repeated reloads — across 3 generations, timers from generation N never fire after generation N+1 activates.
  • Removed modules — when generation 2 drops a helper import, the helper's dispose callback still runs, and completes, before generation 2 activates; its timer never fires again.
  • Failed evaluation — a syntax-error generation: the old root is still disposed first (no leaked resources), the broken generation never activates, and the next valid edit recovers cleanly; SIGINT exits 0.

hot.data persistence across reloads also works as documented.

One observed behavior worth having in the docs (not a bug): editing a non-module file (our YAML config) triggers no reload — expected, since it's outside the module graph. Apps that reload on config changes still need their own file watcher for those.

Thanks for the PR — it closes the gap that forced us onto a process-supervisor pattern for Bun.

ebowwa pushed a commit to ebowwa/cordis that referenced this pull request Aug 17, 2026
Owner-approved. Upstream activity remains a comment only — no Bun code,
clone, or defect report (nothing failed).
robobun and others added 20 commits August 21, 2026 00:24
Under bun --hot, each save re-evaluates every module but nothing
disposed the previous generation's timers, event listeners, Workers,
or other resources. There was also no way for user code to clean
them up: import.meta.hot was undefined at runtime (the transpiler
folded it to a literal undefined) and only existed in the bundler's
dev server.

This adds a runtime import.meta.hot under bun --hot:

- import.meta.hot.data: a plain object keyed by module URL and
  persisted across reloads, for carrying state between generations.
- import.meta.hot.dispose(cb): register a callback that runs
  immediately before the next reload, receiving the module's data
  object. Exceptions from dispose callbacks are reported but do not
  block the reload.
- import.meta.hot.accept()/decline(): no-ops for Vite compatibility
  (bun --hot always re-evaluates every module).

Without --hot, import.meta.hot is undefined.

The transpiler no longer folds import.meta.hot to undefined in
runtime mode; it is left as a property access so the runtime getter
can answer. The bundler still dead-code-eliminates it as before.

The watch-mode docs are updated so the --hot example no longer
leaks one interval per save, and the new API is documented.
The stderr reader was a fire-and-forget async task, so the assertion
on its accumulated contents could run before the pipe had been read.
Capture the reader promise and await it (plus process exit) before
asserting.
The previous change stopped folding import.meta.hot to undefined in
the runtime transpiler unconditionally, which regressed plain bun run
(no --hot): an unguarded import.meta.hot.dispose(fn) used to be
dead-code-eliminated but would now throw TypeError at runtime.

Instead, thread a new runtime_hot parser feature flag from the VM
through ParseOptions into the parser. import.meta.hot is only left as
a runtime property access when runtime_hot is true (bun --hot); in
every other mode (plain bun run, bun build, Bun.Transpiler) it folds
to HotDisabled and calls on it continue to dead-code-eliminate as
before.

The runtime import.meta.hot object also gains no-op on/off/prune/
invalidate/send so that Vite-style code guarded by if (import.meta.hot)
does not throw under bun --hot when it calls those methods.
…s/test polish

- runImportMetaHotDisposeCallbacks now snapshots entries into a
  MarkedArgumentBuffer before clearing the global list and invoking
  callbacks, mirroring handleRejectedPromises.
- Replace Bun__VirtualMachine__hotReloadMode (u8) with
  Bun__VirtualMachine__isHotReloadMode (bool) so the C++ side is not
  coupled to the enum encoding.
- Make the import.meta.hot docs example self-contained.
- Suppress post-kill stream aborts in the stderr readers and use the
  combined {stdout, stderr, exitCode} assertion form.
If a dispose callback triggers a termination exception, return early
instead of reporting and clearing it, matching handleRejectedPromises.
Worker VMs inherit hot_reload from the parent but have no watcher and
never reload, so dispose callbacks registered there would never fire
and the if (import.meta.hot) guard would be misleadingly truthy. Gate
both the runtime getter and the runtime_hot parser flag on the main
VM so workers see import.meta.hot as undefined (and unguarded HMR
calls still fold away), matching pre-PR behaviour.

Also break the stdout drain loop after the final line is received so
a doubled watcher event cannot over-fill the expected array.
RuntimeTranspilerStore::run executes on a WorkPool thread concurrent with
the JS thread, so every *vm access in it uses the (*vm).field place form
(see the SAFETY note at its top). Inline is_main_thread() as
(*vm).worker.is_none() so the runtime_hot computation matches that
convention. Also shorten the fold.rs comment to three lines.
Address review of the runtime import.meta.hot API:

- Await dispose. VirtualMachine::reload() now runs in two phases: the
  new JSC__JSGlobalObject__runImportMetaHotDispose export drains the
  dispose queue and, if any callback returned a pending promise,
  returns a promise that fulfills once they have all settled. reload()
  stores it and defers; the existing deferred-reload poll re-enters
  reload() once it settles and proceeds with the registry reset and
  re-evaluation. Changes arriving meanwhile coalesce into that reload.
- Report dispose errors (sync throws and rejected promises) through
  Bun__logUnhandledException, the non-fatal path used for errors in
  the reloaded file. Bun__reportUnhandledError exits the process once
  a script that touched `process` has gone idle.
- Make `data` an accessor over the per-URL map so assignment persists,
  and look data up by URL when running callbacks instead of capturing
  it at registration. Methods live on a shared ImportMetaHotPrototype
  and validate `this`; the per-module object only carries its URL.
- Decide whether `hot` exists once, when the import.meta prototype is
  created, via the single VirtualMachine::is_hot_reload_enabled()
  predicate that both runtime transpile paths also use. The
  RuntimeTranspilerStore job samples it on the JS thread.
- Hash runtime_hot into the transpiler cache only for sources that
  mention import.meta, so --hot and plain runs stop evicting each
  other's entries; the parser refuses to cache the rare file where
  that textual check misses a real import.meta use.
- Replace the JSArray dispose list with a WriteBarrierList of
  (callback, url) InternalFieldTuples, removing the speculative
  exception clearing around reads that cannot throw.
- Docs: data example works without --hot, Bun.serve needs no dispose
  hook (id: null for a fresh server), async/error semantics; document
  the --hot subset in devserver.d.ts.
- Tests: imported-module path, data reassignment, awaited dispose
  ordering, errors with a process listener registered, ERR_INVALID_THIS,
  and cache entries surviving mode switches.
…nning the source

The parser sets a flag where it folds import.meta.hot, and the cache
entry stores whether the output was produced with or without the hot
runtime. Entries for files that never touch import.meta.hot are shared
between bun run and bun --hot; the others are rejected and rewritten
when the mode changes. Bumps the cache version for the new header byte.
A reload resumed from report_exception_in_hot_reloaded_module_if_needed
runs outside the task queue, so the module loader's microtasks were only
drained by the next tick, and the idle loop parks for up to a second
before that tick. Drain them and pre-arm the waker, as a task-driven
reload effectively does. Measured settle-to-evaluate for a dispose
promise in a debug build: about 1015 ms before, about 15 ms after.
Docs: the unguarded forms are only full import.meta.hot.<method>() calls
and single import.meta.hot.data expressions; dispose callbacks run on the
next reload whether or not the module is evaluated again; --preload
scripts are evaluated once; a fresh server per reload needs server.stop()
in dispose, id: null alone leaves the old one listening.

Tests: per-module data and dispose (and their order), a dropped module's
dispose still running, a rejecting promise that settles last, resuming a
parked reload after a generation that failed to evaluate, the Bun.serve
recipe, and both --preload shapes.
hot_reload is a HotReload enum now; ImportMetaHotPrototype uses the
shared out-of-line cell helpers like its neighbours; the watch-mode
intro example keeps its declare global block.
@robobun
robobun force-pushed the farm/d3f3c56f/import-meta-hot-for-bun-hot branch from fe4557d to 140591c Compare August 21, 2026 00:47
@robobun

robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (3d3016e329) and pushed as 140591c. Two conflicts, both from main's binary-size work, plus one compile fix, all in the last commit ("Adapt to main after rebase") so they are easy to see:

  • ZigGlobalObject.cpp: the lazy-property initLater calls are offset tables now, so m_importMetaHotStructure is an entry in lazyStructureInits after the two existing import.meta structures.
  • ImportMetaObject.cpp: kept main's Bun::reifyStaticPropertyTable in the import.meta prototype and converted ImportMetaHotPrototype to the same out-of-line helpers main moved this file's other prototype to.
  • VirtualMachine.rs: hot_reload is the HotReload enum now, so is_hot_reload_enabled() compares against HotReload::Hot.

Every other file merged with its added and removed lines unchanged (diffed the before and after patches per file to check). The watch-mode intro example keeps its declare global block again; the first revision had dropped it without a reason. On the rebased build, hot (25), transpiler-cache, the #30887 cache-version test, watch, the other --hot users, bundler_minify and import-meta all pass locally; the Related section of the body has the same summary.

@robobun

robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for running this against the artifact. The five cases you list match the contract as documented and tested here (the removed-module and failed-evaluation cases have tests of their own in test/cli/hot/hot.test.ts since the last round), so that is useful confirmation. Note that the head has changed since the August 13 build you used: async dispose rejections and the resume after a parked reload were tightened, and the next generation is now evaluated right after the dispose promises settle instead of up to a second later.

On the YAML point: files outside the import graph are not watched by bun --hot regardless of this PR; the --hot section already says the watch list is built from the imported source files, so I am leaving the docs as they are here.

@robobun

robobun commented Aug 21, 2026

Copy link
Copy Markdown
Collaborator Author

CI status (rebased head 140591c74d, build 101968)

Final: 178 of 179 jobs passed. The two previous-release-tier darwin aarch64 jobs finished late and passed; on that lane test/cli/hot/hot.test.ts needed one retry because the pre-existing "should hot reload when a file is renamed() into place" case (delete plus rename of the entry, which exercises the watcher's directory-event fallback on kqueue) timed out once; all thirteen import.meta.hot cases passed on both attempts, and the file passed first time on the other darwin lanes, as it did in every earlier build of this PR. The one failed job is windows 11 aarch64 - test-bun, and the only test that failed in it is test/cli/install/bun-install-registry.test.ts ("it should hoist 1.0.1 when peer *", a-dep resolves to 1.0.9), which fails the same way on main (for example main build 101808, where it needed two retries on the same lane); it is reported for main-break triage. test/cli/hot/hot.test.ts and test/cli/run/transpiler-cache.test.ts passed on every lane that ran them, including that Windows job.

The flaky entries (bun-lock, bun-inspector-protocol, ffi, migrate, spawn-ipc-gc, test-cluster-shared-leak, h2-conformance) passed on retry or alone. test/js/bun/resolve/bun-main-entry-point.test.ts ("regenerates cleanly across --hot reloads") timed out in the Windows aarch64 parallel batch and passed alone; it does the same in roughly 5 of the last 12 main builds (101032, 101404, 101464, 101647, 101808), so it predates this PR. It also passes locally on this head together with the other --hot users.

Nothing to change on this side.

Comment thread src/jsc/RuntimeTranspilerCache.rs
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.

Timers are not canceled after --hot reload Run code before reload with bun --watch and bun --hot

3 participants