Skip to content

Bun.serve: keep the chunk routes of a built html route across server.reload() - #41576

Open
robobun wants to merge 7 commits into
mainfrom
robobun/a88afd83/serve-reload-html-chunks
Open

robobun wants to merge 7 commits into
mainfrom
robobun/a88afd83/serve-reload-html-chunks

Conversation

@robobun

@robobun robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • After server.reload(), the hashed /chunk-*.js and /chunk-*.css urls of an html import route answer 404 (they fall through to fetch) until some client requests the html page again. A client that holds the document, or that races a reload between the document and its subresources, loses the app's script and style. Seen with development: false and { hmr: false } on 1.3.14 through 1.4.3 and main.
  • The cause: on_reload_from_zig (src/runtime/server/server_body.rs:2178) replaces config.static_routes with the new config's list, and html_route_from_js gives the new config a fresh Route in State::Pending. The chunk routes that the old route's bundle appended in on_complete (src/runtime/server/HTMLBundle.rs) lived only on the replaced list.

Fix

  • html_bundle::Route records the static routes its bundle appends (assets). on_complete goes through append_asset for every output and resets the list at the start of each successful build.
  • On reload, a new html route for the same HTMLBundle takes the old route's chunk routes (adopt_assets) and the reload appends them to the new config before it replaces the list.
  • The page itself is not carried over. The next request to it bundles again, as before, so a reload still picks up an edit on disk. The chunks of the previous build stay served next to the new ones until the following reload.
  • Verified: test/js/bun/http/bun-serve-html.test.ts, two new cases for development: false and { hmr: false }, each at / and at /index.html (eight tests, all fail on 1.4.3). Also the other bun-serve-html-* files and bun-serve-routes.test.ts.

Background

  • An html import route is a html_bundle::Route. The first request to it runs the bundler. on_complete turns each output into a StaticRoute, appends it to config.static_routes, and re-registers every route on the uws app.
  • config.static_routes is input state. server.reload() replaces it with the list parsed from the new config. Function routes and html routes come from that new config. The chunk routes have no source in it.
  • The dev server (development: true) keys its route bundles on the html file and is not affected.
Notes
  • Reported by a fuzz soak, not a tracker issue: 9 of about 110k asset fetches 404'd, each within 0.5 s of a server.reload().
  • Probe against the fix: 8 concurrent clients fetching a chunk while the server reloads every 10 ms for 5 s. 3589 fetches, 0 with status 404, 244 reloads. Before the fix the same probe produced 404s after every reload.
  • Adoption keys on HTMLBundle pointer identity. A reload with a different HTMLBundle object for the same file (for example after bun --hot re-evaluates the import) does not adopt. The next page request bundles and registers the chunks as before.
  • adopt_assets adopts once per new route. The same route can appear under several paths in one config (the dedupe_html_bundle_map in html_route_from_js), so the loop in on_reload_from_zig sees it more than once.
  • The page is also registered at its bundler output path (/index.html). It is not an asset: a reload drops it so the page bundles again. When an html route is mounted at that same path, the alias replaced the html route in the list after the first build (normalize_static_routes_list keeps the later entry), so the page never bundled again and a reload had no old route to take the chunks from. The alias is now skipped in that case.
  • Self-reviewed. The first draft also carried the built page over, so a production reload with the same HTMLBundle never bundled again. That is a semantic change the docs do not describe (docs/runtime/http/routing.mdx says server.reload() is how static routes are refreshed). The draft was narrowed to carry only the chunk routes.
  • Two related findings from the same soak are not in this PR. reload({ fetch }) without a routes key drops static and html routes but keeps function routes. A failed bundle answers 500 once, then 200 with an empty body (the State::Err arm of on_any_request).

[human-review] gate passed · iteration 2 · 3 files touched

fails on main (without fix)
ASAN without fix: 8 failed, 2 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/http/bun-serve-html.test.ts
bun test v1.4.3 (f42e98025)

test/js/bun/http/bun-serve-html.test.ts:
waitForServer /tmp/html-css-js_UZo6a2 {
  "/": "/tmp/html-css-js_UZo6a2/index.html",
  "/dashboard": "/tmp/html-css-js_UZo6a2/dashboard.html",
}
[0.07ms] bundle index.html 1.09 KB
[0.05ms] bundle dashboard.html 1.27 KB
(pass) serve html [690.30ms]
waitForServer /tmp/bun-serve-html-txt_wHKnRp {
  "/": "/tmp/bun-serve-html-txt_wHKnRp/index.html",
}
[0.69ms] bundle index.html 0.40 KB
HASH rq3x286y
(pass) serve plugins > basic plugin [1367.99ms]
waitForServer /tmp/html-css-js-failing-plugin_fZVSK9 {
  "/": "/tmp/html-css-js-failing-plugin_fZVSK9/index.html",
}
error: Plugin failed intentionally
    at /tmp/html-css-js-failing-plugin_fZVSK9/styles.css:0
error: Plugin failed intentionally
    at /tmp/html-css-js-failing-plugin_fZVSK9/styles.css:0
(pass) serve plugins > serve html with failing plugin [593.55ms]
waitForServer /tmp/html-css-js-empty-plugins_flMHUW {
  "/": "/tmp/html-css-js-empty-plugins_flMHUW/index.html",
}
[0.18ms] bu
... (truncated)

release without fix: 8 FAILED
bun test v1.4.3-canary.1 (f42e98025)

test/js/bun/http/bun-serve-html.test.ts:
waitForServer /tmp/html-css-js_wMqAY8 {
  "/": "/tmp/html-css-js_wMqAY8/index.html",
  "/dashboard": "/tmp/html-css-js_wMqAY8/dashboard.html",
}
[0.01ms] bundle index.html 1.09 KB
[0.00ms] bundle dashboard.html 1.27 KB
(pass) serve html [29.88ms]
waitForServer /tmp/bun-serve-html-txt_ZxAUMc {
  "/": "/tmp/bun-serve-html-txt_ZxAUMc/index.html",
}
[0.02ms] bundle index.html 0.40 KB
HASH rq3x286y
(pass) serve plugins > basic plugin [75.39ms]
waitForServer /tmp/html-css-js-failing-plugin_jbrbPN {
  "/": "/tmp/html-css-js-failing-plugin_jbrbPN/index.html",
}
error: Plugin failed intentionally
    at /tmp/html-css-js-failing-plugin_jbrbPN/styles.css:0
error: Plugin failed intentionally
    at /tmp/html-css-js-failing-plugin_jbrbPN/styles.css:0
(pass) serve plugins > serve html with failing plugin [63.12ms]
waitForServer /tmp/html-css-js-empty-plugins_zNclD6 {
  "/": "/tmp/html-css-js-empty-plugins_zNclD6/index.html",
}
[0.02ms] bundle index.html 0.71 KB
(pass) serve plugins > empty plugin array [58.01ms]
Waiting for server
waitForServer /tmp/html-css-js-concurrent-plugins_0stm1T {
  "/": "/tmp/
... (truncated)
passes on PR (with fix)
ASAN with fix: 2 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/js/bun/http/bun-serve-html.test.ts
bun test v1.4.3 (f42e98025)

test/js/bun/http/bun-serve-html.test.ts:
waitForServer /tmp/html-css-js_N3g4wz {
  "/": "/tmp/html-css-js_N3g4wz/index.html",
  "/dashboard": "/tmp/html-css-js_N3g4wz/dashboard.html",
}
[0.11ms] bundle index.html 1.09 KB
[0.08ms] bundle dashboard.html 1.27 KB
(pass) serve html [897.83ms]
waitForServer /tmp/bun-serve-html-txt_64GmvE {
  "/": "/tmp/bun-serve-html-txt_64GmvE/index.html",
}
[0.16ms] bundle index.html 0.40 KB
HASH rq3x286y
(pass) serve plugins > basic plugin [890.11ms]
waitForServer /tmp/html-css-js-failing-plugin_4xXZ9a {
  "/": "/tmp/html-css-js-failing-plugin_4xXZ9a/index.html",
}
error: Plugin failed intentionally
    at /tmp/html-css-js-failing-plugin_4xXZ9a/styles.css:0
error: Plugin failed intentionally
    at /tmp/html-css-js-failing-plugin_4xXZ9a/styles.css:0
(pass) serve plugins > serve html with failing plugin [1033.61ms]
waitForServer /tmp/html-css-js-empty-plugins_B4P3qO {
  "/": "/tmp/html-css-js-empty-plugins_B4P3qO/index.html",
}
[0.12ms] bu
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 7442ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/8] gen generated_host_exports.rs
generated_host_exports.rs: 122 exports (host=5, lazy=10, generic=107, rust=0); 242 extern-C blocks audited
[1/8] cargo bun_runtime → libbun_runtime.a
�[1m�[92m   Compiling�[0m bun_runtime v0.0.0 (/workspace/bun/src/runtime)
�[1m�[92m    Finished�[0m `release` profile [optimized + debuginfo] target(s) in 1m 57s
[3/8] link bun-profile
rust-lld: warning: Linking two modules of different target triples: 'obj/codegen/GeneratedSSLConfig.cpp.o' is 'x86_64-pc-linux-gnu' whereas 'rust-target/x86_64-unknown-linux-gnu/release/libbun_runtime.a(bun_jsc-e420352fc50a33cc.bun_jsc.782f39e5b0322f0c-cgu.0.rcgu.o at 80179190)' is 'x86_64-unknown-linux-gnu'


rust-lld: warning: Linking two modules of different target triples: 'obj/codegen/GeneratedSocketConfig.cpp.o' is 'x86_64-pc-linux-gnu' whereas 'rust-target/x86_64-unknown-linux-gnu/release/libbun_runtime.a(bun_jsc-e420352fc50a33cc.bun_jsc.782f39e5b0322f0c-cgu.0.rcgu.o at 80179190)' is 'x86_64-unknown-linux-gnu'


rust-lld: warning: L
... (truncated)
diff hotspot
src/runtime/server/HTMLBundle.rs        | 60 ++++++++++++++++----
 src/runtime/server/server_body.rs       | 20 +++++++
 test/js/bun/http/bun-serve-html.test.ts | 97 +++++++++++++++++++++++++++++++++
 3 files changed, 167 insertions(+), 10 deletions(-)

gate history · 1 passed · 0 rejected · iteration 2

evidence per changed file
file                                     reads  edits  tests
src/runtime/server/HTMLBundle.rs             3      5     24
src/runtime/server/server_body.rs            2      2     24
test/js/bun/http/bun-serve-html.test.ts      4      1     23

root cause · written by the author bot

HTML routes only registered their bundled JS and CSS chunk paths as static routes from the bundle-completion path triggered by a request to the HTML route, and server.reload() rebuilt the static route list from the new config, so the previously appended chunk routes were dropped until the next request to the HTML path re-ran the cached completion. The fix makes each HTML route record its built chunk assets on the route object itself, and on reload any matching HTML route in the new config adopts those assets and re-appends them to the static route list so the chunk URLs keep serving witho…

…reload()

server.reload() builds a fresh Route for every html import in the new
config and replaces the server's static route list. The js and css
chunk routes that the old route's bundle appended lived only on that
list, so after a reload the chunk urls in a document a client already
held fell through to fetch() until some client requested the page again.

The Route now records the static routes its bundle appended. On reload,
a new Route for the same HTMLBundle takes those routes from the old one
and re-appends them to the new config. The page itself still bundles
again on the next request, as before. The chunks of the previous build
stay served until the next build replaces them.
@robobun

robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:56 AM PT - Sep 6th, 2026

❌ @autofix-ci[bot], your commit 3aa6256 has 3 failures in Build #111276 (All Failures):

  • test/js/node/test/parallel/test-crypto-dh-leak.js - code 1 on 🐧 13 x64-asan
  • test/js/node/child_process/child_process_ipc_handle.test.ts - code 1 on 🐧 13 x64-asan
  • ❌ CPU instruction violation on Linux x64 — 1 check(s) failed
  • The baseline build contains instructions not available on Nehalem (SSE4.2, no AVX/AVX2/AVX512).

    • Static instruction scan

    Static scan violations

    llint_op_wide16  [INVLPGB]  (1 insns)
    

    If these are runtime-dispatched behind a CPUID gate: add each symbol to scripts/verify-baseline-static/allowlist-x64.txt with a comment pointing at the gate.

    If there's no gate: this is a real bug — a -march leaked into a subbuild.

  • ❌ CPU instruction violation on Linux x64 — 1 check(s) failed
  • The baseline build contains instructions not available on Nehalem (SSE4.2, no AVX/AVX2/AVX512).

    • Static instruction scan

    Static scan violations

    llint_op_wide16  [INVLPGB]  (1 insns)
    

    If these are runtime-dispatched behind a CPUID gate: add each symbol to scripts/verify-baseline-static/allowlist-x64.txt with a comment pointing at the gate.

    If there's no gate: this is a real bug — a -march leaked into a subbuild.


🧪   To try this PR locally:

bunx bun-pr 41576

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

bun-41576 --bun

Comment thread src/runtime/server/HTMLBundle.rs Outdated
Comment thread src/runtime/server/HTMLBundle.rs Outdated
Comment thread src/runtime/server/HTMLBundle.rs Outdated
Comment thread src/runtime/server/server_body.rs Outdated
@coderabbitai

coderabbitai Bot commented Sep 6, 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: dbb14e33-2a53-439b-a6c6-d29998d1e8ff

📥 Commits

Reviewing files that changed from the base of the PR and between 9f9d984 and 3aa6256.

📒 Files selected for processing (3)
  • src/runtime/server/HTMLBundle.rs
  • src/runtime/server/server_body.rs
  • test/js/bun/http/bun-serve-html.test.ts

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


Walkthrough

Changes

HTML routes now record assets from successful bundles and reuse matching route assets during reloads. Reload tests cover preserved JavaScript and CSS chunks before and after rebuilds in development and HMR-disabled modes.

HTML route reload

Layer / File(s) Summary
Track bundle assets
src/runtime/server/HTMLBundle.rs
Routes record bundle assets, clear them before new bundle output, and register outputs through append_asset.
Adopt assets during reload
src/runtime/server/server_body.rs
Reload handling adopts assets from matching HTML routes and adds them to the static route list.
Validate preserved chunks
test/js/bun/http/bun-serve-html.test.ts
Tests verify JavaScript and CSS chunks remain available across reloads and that new builds coexist with prior chunks in both development configurations.

Suggested reviewers: jarred-sumner, dylan-conway

Merge Risk: ⚪ Minimal · up to 3aa62

HTML bundle chunk URLs remain available across server reloads while the page rebuilds from disk on its next request. Current coverage includes the affected route and configuration variants, with no remaining merge-blocking risk identified.

🚥 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 and concisely describes the main change: preserving built HTML chunk routes across server.reload().
Description check ✅ Passed The description explains the problem, root cause, fix, behavior, scope, and verification results. It does not use the exact template headings, but it provides the required information in equivalent se…

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

@robobun

robobun commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

Shortened the three doc comments the comment check flagged (77f9735). No code change.

Comment thread src/runtime/server/HTMLBundle.rs Outdated
Comment thread src/runtime/server/HTMLBundle.rs
@robobun

robobun commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

CI status for 77f9735 (build 111076): the new tests pass on every lane. The red lanes are not from this diff.

  • verify-baseline (Linux x64): a static instruction scan violation in JavaScriptCore's llint_op_wide16. It is red on main at the base commit (build 110907, ae7b8f4) too.
  • test/js/node/test/parallel/test-crypto-dh-leak.js on debian 13 x64-asan: fails on main as well.

The other failures passed on retry. 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.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline nits, I also checked whether recording the built page at its bundler output path in assets (HTMLBundle.rs:673) means adopt_assets would shadow the html route and skip re-bundling when the user's mount path equals that output path — the html route is registered after the appended static routes on reload and wins the match, so re-bundling still happens. Also traced the RefPtr<StaticRoute> clones through adopt_assets → append_static_route: each clone is moved into an AnyRoute::Static and the vec is dropped, so the counts balance.

Extended reasoning...

Two minor findings are posted inline (stale doc-comment method name; memory_cost() not accounting for the new assets field). Beyond those, the review looked at whether carrying the page-at-output-path entry across reload could suppress the intended re-bundle and at the refcount balance of the cloned RefPtr<StaticRoute> values flowing through adopt_assets into new_config.append_static_route; both were ruled out. One further verified finding was pruned from posting, so approval is withheld.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 src/runtime/server/HTMLBundle.rs — nit: Route::memory_cost() was not updated for the new assets field, so the heap bytes it now owns (the Vec capacity plus each Box<[u8]> path) are not reported to GC pressure accounting after a bundle completes or a reload adopts chunks. Fix: add the assets vec's element size × len and the summed path lengths to memory_cost(), matching how pending_responses is accounted just above (do not add the StaticRoute bodies — those are already counted via the config's static_routes list).

    Extended reasoning...

    Route::memory_cost() at HTMLBundle.rs:191-197 sums size_of::<Route>(), pending_responses.len() * size_of::<PendingResponse>(), and state.memory_cost(). The struct gained assets: JsCell<Vec<(Box<[u8]>, RefPtr<StaticRoute>)>> (line 165), populated by append_asset on every bundle output and by adopt_assets on reload. REVIEW.md requires every consumer of a struct's fields be updated atomically with a new field; memory_cost() is such a consumer (StaticRouteEntry::memory_cost at ServerConfig.rs:209 shows the pattern of adding path.len()). On the base branch there was no assets field so nothing was under-reported; after this change a built route owning several chunk paths under-reports its retained memory. Minor correctness gap, not a leak — refs are still balanced via Drop.

    Verification: nit — Route::memory_cost() at src/runtime/server/HTMLBundle.rs:191-197 was not updated for the new assets: JsCell<Vec<(Box<[u8]>, RefPtr<StaticRoute>)>> field added at line 165. The function sums only size_of::<Route>() (which covers the Vec header inline in the struct, not its heap buffer), pending_responses.get().len() * size_of::<PendingResponse>(), and state.get().memory_cost().…

Comment thread src/runtime/server/HTMLBundle.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.

Code review found no issues

No high-confidence issues detected in this change.

Comment thread src/runtime/server/HTMLBundle.rs Outdated
…d do not alias it over an html route

Only the chunk routes are assets. The page at its bundler output path
(/index.html) is registered as before but not recorded, so a reload drops
it and the new html route bundles again.

When an html route is mounted at that same path, the alias replaced the
html route in the static route list after the first build, so the page
never bundled again and a reload could not find the route to take the
chunks from. Skip the alias in that case.
Comment thread src/runtime/server/HTMLBundle.rs
Comment thread src/runtime/server/HTMLBundle.rs
Comment thread src/runtime/server/HTMLBundle.rs Outdated
Comment thread src/runtime/server/HTMLBundle.rs
@robobun

robobun commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the reviews:

  • e82211d: Route::memory_cost counts the assets entries.
  • d84c4fa: the page at its output path is not an asset, so a reload still bundles the page again. When an html route is mounted at that same path (/index.html), the alias no longer replaces it. Tests now run each case at / and at /index.html (8 tests, all fail on 1.4.3).
  • 756bbe0: comment trims.

@robobun

robobun commented Sep 6, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI for 3aa6256 (build 111276): the PR's tests pass on every lane. The red lanes are not from this diff.

  • verify-baseline: a JavaScriptCore LLInt instruction scan violation, red on main as well.
  • test/js/node/child_process/child_process_ipc_handle.test.ts on debian 13 x64-asan (a received handle lands on fd 9 instead of fd 0) and test/js/node/test/parallel/test-crypto-dh-leak.js: unrelated to server routes, both reported for main.

The other failures passed on retry. 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.

Re-reviewed after d84c4fa/756bbe0e — the html page is no longer pushed into assets, and the output-path alias now skips paths that already carry an Html route, so the /index.html carry-over I flagged no longer applies; the new route: "/index.html" matrix rows exercise exactly that. No further issues found. Given this touches RefPtr<StaticRoute> lifetime across server.reload(), a human pass on the ownership shape is still worth having.

What was reviewed

  • on_complete: html entry now bypasses append_asset and is guarded by html_path_has_html_route; chunk outputs alone populate assets.
  • adopt_assets: pointer-identity + empty-destination guard means the same deduped Route seen at multiple paths adopts once and returns Vec::new() thereafter.
  • Test matrix now covers {development: false | {hmr:false}} × {"/", "/index.html"} and asserts sameChunk: false + v2: true after an on-disk edit + reload.
Extended reasoning...

Overview

Incremental commits since the last review (d84c4fa, 756bbe0, 3aa6256) narrow Route.assets to chunk outputs only: the built html page is registered via server.append_static_route directly instead of append_asset, and is additionally skipped when an AnyRoute::Html entry already sits at the bundler's html output path. The test matrix in test/js/bun/http/bun-serve-html.test.ts was widened from two development values to four {development, route} combinations, adding route: "/index.html" — the case where the user's mount path equals the bundler's un-hashed entry-point output path.

Security risks

None. The change is server-internal route bookkeeping across server.reload(); no new input parsing, no auth/crypto/permission surface, no untrusted-size arithmetic.

Level of scrutiny

Medium-high. The mechanics are contained (one new JsCell<Vec<...>> field, two small methods, a ~20-line reload-path loop), but they own RefPtr<StaticRoute> values whose lifetime now extends across a config swap, and the O(new_html × old_html) loop plus the .any() scan over static_routes sit on the reload/bundle-complete paths. REVIEW.md calls out refcount balance on every terminal path and state stored on the object whose lifetime matches it — both are in play here and merit a human eye even though nothing looked wrong on inspection (RefPtr is cloned into containers, assets.set(Vec::new()) at build start drops the previous generation, memory_cost accounts for the field).

Other factors

The prior inline finding about the html page being carried over is addressed in code and now covered by an automated test that would fail on the earlier draft (the /index.html rebuild case asserts sameChunk: false and v2: true). The adopt_assets guard (!self.assets.get().is_empty()) makes the nested reload loop idempotent when the same deduped Route appears under multiple paths, matching the PR notes. No CODEOWNERS gate was found for these paths. Deferring rather than approving because the change is not mechanical and sits in refcounted server-lifetime code.

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