Skip to content

dev server: tell a page that loaded an outdated client bundle to reload - #42041

Open
robobun wants to merge 2 commits into
mainfrom
robobun/42a99afd/devserver-stale-generation-reload
Open

robobun wants to merge 2 commits into
mainfrom
robobun/42a99afd/devserver-stale-generation-reload

Conversation

@robobun

@robobun robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A truncate-then-write save (open(O_TRUNC), then write ~15 ms later) of a module with no import.meta.hot.accept boundary can leave the connected dev-server page permanently stale. It keeps running the bundle built from the empty intermediate file (TypeError: x is not a function). A fresh tab is correct.
  • Build 1 (empty file) reloads the page. Build 2 lands after the reloading page fetched its bundle but before its HMR socket subscribed. finalize_bundle publishes hot_update to current subscribers only, and the handshake never told the page its bundle was outdated.

Fix

  • The client always sends the i (init) frame with config.generation after it subscribes. The Init handler (dev_server/hmr_socket.rs) replies with a new MessageId::FullReload (R) frame when no route bundle has that generation any more, and the client reloads.
  • Correct because on_js_request only serves a current generation and only invalidate_client_bundle retires one. The check runs after Subscribe on the same socket, so each rebuild reaches the page as this reply or as a hot_update.
  • Verified: test/bake/dev/html.test.ts (two new tests, both fail on 1.4.3-canary), plus the rest of test/bake/.
  • Self-reviewed: 1 concern raised, 1 addressed (two sibling windows a bundle generation cannot detect, named in Notes, out of scope).

Background

  • client_script_generation is a random u32 per route bundle, regenerated by invalidate_client_bundle when a build changes code the route reaches. It is part of the script URL and is config.generation in the bundle.
  • An older guard covers the window before this one: on_js_request answers an outdated script URL with a location.reload() stub.
  • The i frame already existed for source-map ref counting, on HTML routes only.
Notes
  • cp, shell > redirection and several editors save this way. A page load is three steps: fetch the HTML, fetch the client bundle it names (/_bun/client/{name}-{rbi}{generation}.js), open /_bun/hmr and send she (subscribe), n (set_url), i (init).
  • The three faces from the report: the runtime-error overlay (TypeError: import_f2.f2 is not a function), silently wrong values (the empty module has no exports), and a blank document when index.html itself was saved this way. All three are the page running the bundle of build 1 and never hearing about build 2.
  • Protocol-level repro on 1.4.3-canary (no browser): serve HTML and bundle at generation A, write the file and wait for the rebuild (generation B), then open /_bun/hmr and send she, n/, iA. The server answers V, n and nothing else. With this change it also answers R.
  • End-to-end repro with the test/bake happy-dom client and a real open(O_TRUNC) / sleep / write sweep over gaps of 0 to 100 ms: 1.4.3-canary gets stuck at the 8 ms gap on 3 of 3 runs (#out keeps the value from the empty module). With this change every gap converges, through one extra reload when the stale bundle was loaded. That sweep is timing-dependent, so the committed tests force the window instead: one replays the handshake on a raw socket, the other puts a proxy in front of the dev server that parks the page's /_bun/hmr upgrade until the rebuild has landed.
  • The check scans dev.route_bundles instead of reading only the socket's active_route. It then does not depend on the order of n and i, and does not misfire when location.pathname maps to a different route bundle than the one whose script the page loaded (a pushState before the socket connected). A false "current" verdict needs a u32 collision between two route bundles. SourceMapStore already keys route bundles by the generation alone (generation << 32).
  • If a rebuild lands between the server handling s and i of one handshake, the page receives both the hot_update and R and reloads once more than needed. The outcome is still a current page.
  • Sending i on every page instead of HTML routes only means the source-map weak ref of a framework route's bundle is upgraded to a socket-held ref (released on close) instead of expiring on the sweep timer.
  • Two sibling windows remain, both outside what a client-bundle generation can detect. The bundler error page (hmr-runtime-error.ts) reloads only when an e frame empties its error list, and can miss the e frame of the build that fixed the error; it has no bundle generation and needs its own resync (tracked separately). A Bake framework route whose server code is rebuilt in the same window gets no R either, because a server-only rebuild does not call invalidate_client_bundle; the page keeps the SSR output it was served until the next change.
  • test/bake/dev-and-prod.test.ts ("hmr handles rapid consecutive edits", test(bake): re-write rapid-edits sentinel across full reloads #33981) re-writes its sentinel file on every reload because of this exact window. With this change the page would recover through R anyway. Trimming that workaround is a follow-up; it needs a Windows run.
  • Also ran test/js/bun/http/bun-serve-html.test.ts, test/cli/inspect/BunFrontendDevServer.test.ts and test/bake/deinitialization.test.ts.
  • src/runtime/bake/generated.ts (the TypeScript MessageId enum) is generated from dev_server/mod.rs by src/codegen/bake-codegen.ts, so the new variant needs no manual TS edit.

[human-review] gate passed · iteration 0 · 4 files touched

fails on main (without fix)
ASAN without fix: BUILD FAILED (no junit output)
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bake/dev/html.test.ts
ninja: Entering directory `/workspace/bun/build/debug'
[1/165] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[2/164] gen compressed/codegen/bake.client.js.zst
[3/164] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 242 extern-C blocks audited
[4/164] gen cpp.rs (cppbind)
[4/164] cargo bun_runtime → libbun_runtime.a
FAILED: rust-target/x86_64-unknown-linux-gnu/debug/libbun_runtime.a 
/workspace/bun/build/release/bun /workspace/bun/scripts/build/stream.ts rust --console --cwd=/workspace/bun --env=CARGO_TERM_COLOR=always --env=BUN_CODEGEN_DIR=/workspace/bun/build/debug/codegen --env=CC=/usr/lib/llvm-21/bin/clang --env=CXX=/usr/lib/llvm-21/bin/clang++ --env=AR=/usr/lib/llvm-21/bin/llvm-ar --env=CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_LINKER=/usr/lib/llvm-21/bin/clang++ --env=CARGO_HOME=/root/.cargo --env=RUSTUP_HOME=/root/.rustup --env=RUSTUP_TOOLCHAIN=nightly-2026-07-20 --env=CARGO_PROFILE_RELEASE_LTO=off --env=CARGO
... (truncated)

release without fix: all passed
bun test v1.4.3-canary.1 (8403a997e)

test/bake/dev/html.test.ts:
Dev server testing directory: /tmp/bun-dev-test-Hb3eGA
�[0;30mdev|�[0m Started development server: http://localhost:42991
�[0;30mdev|�[0m �[32mBundled page in 1ms�[0m�[2m:�[0m index.html
�[0;30mdev|�[0m �[32mReloaded in 1ms�[0m�[2m:�[0m index.html
�[0;30mweb|�[0m [I] [Bun] Hot-module-reloading socket connected, waiting for changes...
�[0;30mdev|�[0m �[36m[x2]�[0m �[32mReloaded in 1ms�[0m�[2m:�[0m index.html
�[0;30mweb|�[0m [I] [Bun] Server-side code changed, reloading!
�[0;30mweb|�[0m [I] [Bun] Hot-module-reloading socket connected, waiting for changes...
�[0;30mdev|�[0m �[36m[x3]�[0m �[32mReloaded in 1ms�[0m�[2m:�[0m index.html
�[0;30mweb|�[0m [I] [Bun] Server-side code changed, reloading!
�[0;30mweb|�[0m [I] [Bun] Hot-module-reloading socket connected, waiting for changes...
�[0;30mdev|�[0m �[36m[x4]�[0m �[32mReloaded in 1ms�[0m�[2m:�[0m script.ts
�[0;30mweb|�[0m [I] [Bun] Hot-module-reloading socket connected, waiting for changes...
(pass)  DEV:html-1: html file is watched [788.75ms]
�[0;30mdev|�[0m Started development server: http://localhost:36507
�[0;30mdev|�[0m �[32mBundled page in 1ms�[0m�[2m:
... (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/pr_gate.xml" test/bake/dev/html.test.ts
bun test v1.4.3 (f42e98025)

test/bake/dev/html.test.ts:
Dev server testing directory: /tmp/bun-dev-test-JipjTR
�[0;30mdev|�[0m Started development server: http://localhost:38447
�[0;30mdev|�[0m �[32mBundled page in 61ms�[0m�[2m:�[0m index.html
�[0;30mdev|�[0m �[32mReloaded in 56ms�[0m�[2m:�[0m index.html
�[0;30mweb|�[0m [I] [Bun] Hot-module-reloading socket connected, waiting for changes...
�[0;30mdev|�[0m �[36m[x2]�[0m �[32mReloaded in 47ms�[0m�[2m:�[0m index.html
�[0;30mweb|�[0m [I] [Bun] Server-side code changed, reloading!
�[0;30mweb|�[0m [I] [Bun] Hot-module-reloading socket connected, waiting for changes...
�[0;30mdev|�[0m �[36m[x3]�[0m �[32mReloaded in 47ms�[0m�[2m:�[0m index.html
�[0;30mweb|�[0m [I] [Bun] Server-side code changed, reloading!
�[0;30mweb|�[0m [I] [Bun] Hot-module-reloading socket connected, waiting for changes...
�[0;30mdev|�[0m �[36m[x4]�[0m �[32mReloaded in 45ms�[0m�[2m:�[0m script.ts
�[0;30mweb|�[0m [I] [Bun] Hot-module-reloading socket connected, waiting for changes...
(pass)  DEV:h
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 718ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/126] gen bake.{client,server,error}.js
-> bake.client.js, bake.server.js, bake.error.js
[2/124] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 242 extern-C blocks audited
[3/124] gen cpp.rs (cppbind)
[3/124] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_base64 v0.0.0 (/workspace/bun/src/base64)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/w
... (truncated)
diff hotspot
src/runtime/bake/dev_server/hmr_socket.rs |  12 +++
 src/runtime/bake/dev_server/mod.rs        |  10 ++
 src/runtime/bake/hmr-runtime-client.ts    |   7 +-
 test/bake/dev/html.test.ts                | 147 +++++++++++++++++++++++++++++-
 4 files changed, 174 insertions(+), 2 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                       reads  edits  tests
src/runtime/bake/dev_server/hmr_socket.rs      2      2     12
src/runtime/bake/dev_server/mod.rs             2      2     12
src/runtime/bake/hmr-runtime-client.ts         1      2     12
test/bake/dev/html.test.ts                     1      4     12

A page load fetches the HTML, then the client bundle, then opens the
HMR socket and subscribes to hot updates. A rebuild that finishes
between the last two steps publishes its hot update to nobody, and
nothing told the page afterwards that its bundle was stale. A
truncate-then-write save (open(O_TRUNC), write ~15ms later) of a module
without an import.meta.hot.accept boundary hits this window: the first
build (empty file) reloads the page, the second build lands while the
page reloads, and the page keeps running the empty module forever.

The client now always sends the init frame with its bundle generation
after subscribing. The server replies with a new FullReload frame when
no route bundle has that generation any more, and the client reloads.
@coderabbitai

coderabbitai Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

  • Run on-demand review

On-demand reviews are free for the next 12 days. After that, they cost $0.25 per reviewed file.

Or wait 1 minute for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 76440ad9-1bc1-4e04-bd06-ccfa84a423ff

📥 Commits

Reviewing files that changed from the base of the PR and between d745f03 and 8403a99.

📒 Files selected for processing (4)
  • src/runtime/bake/dev_server/hmr_socket.rs
  • src/runtime/bake/dev_server/mod.rs
  • src/runtime/bake/hmr-runtime-client.ts
  • test/bake/dev/html.test.ts

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

@robobun

robobun commented Sep 8, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

Reproduced two ways before the fix, both on 1.4.3-canary (f42e980):

  • Protocol level: GET /, GET /_bun/client/...{generation A}.js, write the module and wait for the rebuild, then open /_bun/hmr and send she, n/, iA. The server answers V, n and nothing else. This is now the first new test in test/bake/dev/html.test.ts.
  • End to end with the test/bake happy-dom client: open(O_TRUNC), sleep, write of an imported module, sweeping the gap from 0 to 100 ms. The page sticks at the 8 ms gap on 3 of 3 runs (keeps the empty-module value). With this branch every gap converges.

CI (build 113107): 180 of 181 jobs passed, the new tests passed on every lane. The one red job is test/js/node/test/parallel/test-crypto-dh-leak.js on debian x64-asan, which also fails on main and is unrelated to this diff.

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

I reviewed this PR and didn't find any bugs. The fix is well-scoped and the tests are solid, but since it adds a protocol message and changes i to fire on every route (shifting source-map refs from weak-with-sweep to socket-held for framework routes), a quick look from someone with bake dev-server context would be worthwhile.

What was reviewed:

  • The Init handler's route_bundles.iter().any(...) scan — no unwraps, let _ on send, runs after Subscribe so the race window is closed as described.
  • Client change: i is now unconditional; confirmed fullReload() and sendBuffered are already in scope and the bun:loadData cleanup is preserved.
  • Tests: hmrHandshake wires onclose to reject, asserts exact frame arrays for both stale and current generations; the proxy test uses port: 0, await using, and Promise.withResolvers gating instead of sleeps. Noted the helper sends i before n (opposite of the real client) so the n reply bounds the collected frames — the second test covers the real client order end-to-end.
Extended reasoning...

Overview

This PR fixes an HMR staleness race in the bake dev server: a page that fetches its client bundle, then has the route rebuilt before its /_bun/hmr socket subscribes, would never receive the missed hot_update and stay stale forever. The fix adds MessageId::FullReload (b'R') in src/runtime/bake/dev_server/mod.rs, has the Init handler in hmr_socket.rs compare the client-reported generation against every route_bundle.client_script_generation and reply R on mismatch, makes hmr-runtime-client.ts send i<generation> unconditionally after Subscribe/set_url and handle full_reload by calling fullReload(), and adds two tests to test/bake/dev/html.test.ts (a raw-socket handshake replay asserting ["V","R","n"] vs ["V","n"], and a proxy that parks the WS upgrade until after a rebuild to verify the real client reloads).

Security risks

None. This is a local dev-server hot-reload signal — a single-byte binary frame over the existing /_bun/hmr WebSocket that triggers location.reload(). No auth, crypto, filesystem, or network-boundary parsing is touched; the generation is already parsed and bounds-checked by the pre-existing msg.len() != 9 / hex-decode guards above the added lines.

Level of scrutiny

Moderate. The Rust change is ~12 lines with no new unsafe, no allocation, and no new error paths — it reuses the dev reference already obtained for the source-map upgrade and does a linear scan over route bundles. The client change is small but has a documented side effect: i now fires for framework routes too, which upgrades their source-map weak ref to a socket-held ref instead of letting it expire on the sweep timer. The PR description calls this out and it looks benign (released on socket close), but that's the one behavioral shift I'd want a bake maintainer to nod at rather than rubber-stamp.

Other factors

Test quality is high by the repo's own bar: exact toEqual frame assertions covering both the stale and current case, onclose wired to reject, no sleeps or timing assumptions (all sequencing via Promise.withResolvers), port: 0, await using for the proxy and client, and placement in the existing test/bake/dev/html.test.ts. The hmrHandshake helper deliberately sends i before n (inverting the real client's order) so the n reply serves as a barrier proving i was handled — the PR notes the server-side check is order-independent by design, and the second proxy-based test exercises the real client's actual order. No CODEOWNERS cover these paths, no prior reviewer objections exist on the timeline, and the bug-hunt exited on dry_streak with nothing found.

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.

2 participants