Skip to content

bake: unsubscribe the HMR socket from topics that a subscribe frame drops - #43995

Merged
Jarred-Sumner merged 2 commits into
mainfrom
robobun/b98c8556/hmr-socket-unsubscribe
Sep 25, 2026
Merged

Jarred-Sumner merged 2 commits into
mainfrom
robobun/b98c8556/hmr-socket-unsubscribe

Conversation

@robobun

@robobun robobun commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • A /_bun/hmr socket that drops a topic still receives it. After sh, then s, three edits give ["u","u","u"], not [].
  • With no other h subscriber, the next bundle aborts a debug build: panic: assertion failed: ref_count > 0. A release build leaks the source map entry.
  • The cause is at src/runtime/bake/dev_server/hmr_socket.rs:149. The else if that calls ws.unsubscribe repeats the condition of its if. No user reported this.

Fix

  • The condition becomes !new_bits.contains(bit) && self.subscriptions.contains(bit).
  • This is correct because the handler already replaces the whole set. It calls on_unsubscribe for the dropped bits and overwrites subscriptions.
  • Verified: a new case in test/bake/hmr-socket-protocol.test.ts fails on main and passes here. Also ran test/bake/dev/hot.test.ts.
  • Self-reviewed: 10 concerns raised, 9 addressed. Deferred: a clippy lint for this pattern.

Background

Downsides

  • A client that dropped a topic no longer gets its frames. No client in the tree sends a second subscribe frame.
  • HmrSocket::on_message grows by 37 bytes (4945 to 4982, linux-x64 release). The file size does not change.
Notes

Reach

  • The bug is in all builds, stable included. The Zig version of the handler had the same two conditions (src/bake/DevServer/HmrSocket.zig, lines 74 and 96, before Rewrite Bun in Rust #30412).
  • No client in the tree sends a second subscribe frame. hmr-runtime-client.ts sends she once, hmr-runtime-error.ts sends se once, the test harness sends sr once. The bug affects hand-written clients and tests.
  • There is no issue and no user report. The ground for the change is REVIEW.md: a failure that network bytes can reach must not be a panic.

Conditions for the abort

  • The build has debug assertions (debug, ASAN).
  • A socket dropped h, and no other socket has the h bit. A browser tab sends she, so a connected tab prevents the abort. The extra frames remain.
  • The bundle has a source map that the store does not hold yet.
  • An edit starts such a bundle. The first request for a page that is not bundled yet starts one too. The new test uses that.
panic: assertion failed: ref_count > 0
SourceMapStore::put_or_increment_ref_count   src/runtime/bake/dev_server/source_map_store.rs:467
finalize_bundle                              src/runtime/bake/DevServer.rs:4578

Script for the extra frames

import { mkdtempSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
const dir = mkdtempSync(join(tmpdir(), "elseif-"));
process.chdir(dir);
writeFileSync(join(dir, "a.html"), `<!doctype html><html><body><script type="module" src="./a.ts"></script></body></html>`);
writeFileSync(join(dir, "a.ts"), `console.log("v0");`);
const html = (await import(join(dir, "a.html"))).default;
const srv = Bun.serve({ routes: { "/a": html }, development: true, port: 0, hostname: "127.0.0.1" });
await (await fetch(`http://127.0.0.1:${srv.port}/a`)).text();
const spin = async ms => { const end = Date.now() + ms; while (Date.now() < end) await new Promise(r => setImmediate(r)); };
const ids = [];
const ws = new WebSocket(`ws://127.0.0.1:${srv.port}/_bun/hmr`);
ws.binaryType = "arraybuffer";
const ready = Promise.withResolvers();
ws.onmessage = e => {
  const id = String.fromCharCode(new Uint8Array(e.data)[0]);
  if (id === "V") { ws.send("sh"); ws.send("s"); ready.resolve(); } else ids.push(id);
};
ws.onclose = () => ids.push("<closed>");
await ready.promise;
await spin(300);
for (let i = 1; i <= 3; i++) { writeFileSync(join(dir, "a.ts"), `console.log("v${i}");`); await spin(700); }
console.log("frames after the socket dropped every topic:", JSON.stringify(ids));
process.exit(0);
Build Result
1.4.2+744846f84 (released) ["u","u","u"]
main 29d9638, release build, canary config ["u","u","u"]
main 29d9638, debug build, one socket panic: assertion failed: ref_count > 0
this branch, debug build []
this branch, debug build, shr then sr ["r","r","r"] (the kept topic still arrives)

Control: a socket that never sends sh gets [] on each build.

Source map entries on a release build

  • In a release build the assert is compiled out. put_or_increment_ref_count stores the entry with ref_count 0. The only removal path is unref_at_index, and no socket owns the entry.
  • Measurement: one socket, 20 edits, each with a new source map. After the socket closed, I requested the source map URL of each hot update.
Build Socket sends Hot updates received Source maps still served after close
1.4.2+744846f84 (released) sh, then s 20 20
1.4.2+744846f84 (released) sh 20 0
main 29d9638, release build sh, then s 20 20
main 29d9638, release build sh 20 0
this branch, debug build sh, then s 0 0

The test

  • One socket subscribes to four topic sets in turn: hr, r, none, hr. Each step requests a page that is not bundled yet. That starts exactly one bundle, and the response arrives after the dev server published the frames of that bundle.
  • A SetUrl round trip (n plus a route) opens and closes the frame window of a step. The server answers on the same socket, after each frame that it published to that socket before.
  • The test uses no file watcher and no second socket. An earlier draft used both and failed 2 of 1033 runs under load, because two sockets have no order between them.
  • Result on main, release build: the r step receives ["r","u"] and the step with no topic receives ["r","u"]. Result on main, debug build: the dev server aborts with the panic above.
Setup, debug build of this branch, linux-x64 Runs Failures
The new case alone 60 0
6 loops of the file beside 15 parallel workers that run other test files 219 0
Before 1cede56, the same two setups 60 and 353 0
Before 1cede56, 6 copies of the new case in one runner, 4 pinned CPUs, 2 parallel workers beside it 492 0
  • The test is not run on Windows or macOS before this PR. CI is the first run there.
  • Each awaited promise rejects on its own failure, on the process exit and on the socket close. Each rejection carries the stderr of the dev server.
  • After a panic, the dev server sends nothing more until the process exits. The crash handler of a debug build needs about 4 s for that. With the default 5 s timeout on a loaded machine, the test can then report a timeout and not the panic text. CI uses 90 s (270 s on the ASAN lane).

Cost for a client that never drops a topic

  • Each subscribe frame evaluates the new condition for each of the 6 topics. There is no allocation and no syscall on that path.
  • ws.unsubscribe runs only for a topic that the frame drops.
  • Sizes are from nm -S and size -A on bun-profile, release builds of 29d9638 and of this branch. File size: 80995912 bytes for both. bloaty is not installed.

Possible follow-up: a lint for this pattern

  • The clippy lint same_functions_in_if_condition reports an else if that repeats the call of its if. The default lint ifs_same_cond does not look at conditions with calls.
  • cargo clippy --workspace --no-deps --keep-going -- -D clippy::same_functions_in_if_condition gives one error on main (this line) and no error with this change (clippy 0.1.100, linux-x64).
  • The lint is not in this PR. A change to [workspace.lints.clippy] changes the rustc flags of each workspace crate, so each crate builds again once.

Relation to other PRs


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

fails on main (without fix)
ASAN without fix: 1 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/pr_gate.xml" test/bake/hmr-socket-protocol.test.ts
bun test v1.4.3 (367d939d9)

test/bake/hmr-socket-protocol.test.ts:
(pass) a duplicate testing-batch frame (H) during an in-flight bundle closes the socket instead of aborting [880.24ms]
(pass) a SetUrl frame without a leading slash ("nfoo") closes the socket instead of aborting [776.41ms]
(pass) a SetUrl frame without a leading slash ("n") closes the socket instead of aborting [1021.87ms]
(pass) releasing a testing batch while another bundle is in flight defers it [1042.66ms]
407 |   /** Awaits `promise`. Its failure, a process exit, or a socket close rejects with the dev server's stderr. */
408 |   async function orFail<T>(promise: Promise<T>) {
409 |     try {
410 |       return await Promise.race([promise, failed.promise]);
411 |     } catch (e) {
412 |       throw new Error(`${(e as Error).message}\n--- dev server stderr ---\n${dev.stderr()}`, { cause: e });
                      ^
error: hmr websocket closed (code 1006, reason "Connection ended")
--- dev server stderr ---
Bundled page in 119ms
... (truncated)

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

test/bake/hmr-socket-protocol.test.ts:
(pass) a SetUrl frame without a leading slash ("nfoo") closes the socket instead of aborting [37.14ms]
(pass) a SetUrl frame without a leading slash ("n") closes the socket instead of aborting [41.24ms]
(pass) a duplicate testing-batch frame (H) during an in-flight bundle closes the socket instead of aborting [52.96ms]
446 |     // Frames that arrive before this reply belong to the previous topic set.
447 |     await roundTrip();
448 |     await orFail(fetch(`http://127.0.0.1:${port}${page}`).then(response => response.text()));
449 |     delivered.push({ topics, ids: [...new Set(await roundTrip())].sort() });
450 |   }
451 |   expect(delivered).toEqual([
                          ^
error: expect(received).toEqual(expected)

  [
    {
      "ids": [
        "r",
        "u",
      ],
      "topics": "hr",
    },
    {
      "ids": [
        "r",
+       "u",
      ],
      "topics": "r",
    },
    {
-     "ids": [],
+     "ids": [
+       "r",
+       "u",
+     ],
      "topics": "",
    },
    {
      "ids": [
        "r",
        "u",
      ],
      "topics": "hr",
    },
  ]

- Expected
... (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/hmr-socket-protocol.test.ts
bun test v1.4.3 (367d939d9)

test/bake/hmr-socket-protocol.test.ts:
(pass) a SetUrl frame without a leading slash ("nfoo") closes the socket instead of aborting [874.43ms]
(pass) releasing a testing batch while another bundle is in flight defers it [1016.05ms]
(pass) a subscribe frame that drops a topic stops delivery of that topic [1140.59ms]
(pass) a SetUrl frame without a leading slash ("n") closes the socket instead of aborting [1316.42ms]
(pass) a duplicate testing-batch frame (H) during an in-flight bundle closes the socket instead of aborting [1595.68ms]

 5 pass
 0 fail
 9 expect() calls
Ran 5 tests across 1 file. [4.40s]
__F:0:S:0

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1306ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/57] gen generated_host_exports.rs
generated_host_exports.rs: 121 exports (host=5, lazy=10, generic=106, rust=0); 248 extern-C blocks audited
[2/56] rustc bun_csrf 
[3/56] rustc bun_s3_signing 
[4/56] rustc bun_exe_format 
[5/56] rustc bun_threading 
[6/56] rustc bun_dotenv 
[7/56] rustc bun_uws_sys 
[8/56] rustc bun_uws 
[9/56] rustc bun_libarchive 
[10/56] rustc bun_sql 
[11/56] rustc bun_watcher 
[12/56] rustc bun_io 
[13/56] rustc bun_event_loop 
[14/56] rustc bun_crash_handler 
[15/56] rustc bun_md 
[16/56] rustc bun_spawn 
[17/56] rustc bun_patch 
[18/56] rustc bun_ast 
[19/56] rustc bun_install_types 
[20/56] rustc bun_resolve_builtins 
[21/56] rustc bun_options_types 
[22/56] rustc bun_api 
[23/56] rustc bun_http 
[24/56] rustc bun_parsers 
[25/56] rustc bun_sourcemap 
[26/56] cxx obj/src/jsc/bindings/BunProcess.cpp.o
[27/56] rustc bun_js_printer 
[28/56] rustc bun_react_compiler 
[29/56] rustc bun_ini 
[30/56] rustc bun_css 
[31/56] rustc bun_js_parser 
[32/56] rustc bun_resolver 
[33/56] rustc
... (truncated)
diff hotspot
src/runtime/bake/dev_server/hmr_socket.rs |   5 +-
 test/bake/hmr-socket-protocol.test.ts     | 116 ++++++++++++++++++++++++++++++
 2 files changed, 117 insertions(+), 4 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      1      1     33
test/bake/hmr-socket-protocol.test.ts          3      5     31

…rops

The unsubscribe branch of the Subscribe handler repeated the condition
of the subscribe branch, so it never ran. A socket that sent a topic set
without a topic it had before kept the uWS subscription for that topic
and still received its frames, while HmrSocket.subscriptions said that
it was unsubscribed.
@robobun

robobun commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review.

How I reproduced the bug:

  • A release build of main (and the released 1.4.2): USE_SYSTEM_BUN=1 bun test test/bake/hmr-socket-protocol.test.ts -t "drops a topic" fails. The socket still receives u frames after it dropped topic h.
  • A debug build with the src/ of main: bun bd test test/bake/hmr-socket-protocol.test.ts fails. The dev server aborts with panic: assertion failed: ref_count > 0.
  • A debug build of this branch: the file passes (5 of 5).

@coderabbitai

coderabbitai Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository: oven-sh/bun/.coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 94bda432-c7c8-4ddb-8d26-a2aed2f12560

📥 Commits

Reviewing files that changed from the base of the PR and between a66ec29 and 1cede56.

📒 Files selected for processing (1)
  • test/bake/hmr-socket-protocol.test.ts

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


Walkthrough

The HMR socket now unsubscribes from topics removed from the updated subscription set. A new integration test changes the subscribed topics and checks which published frames the socket receives.

Changes

HMR topic subscriptions

Layer / File(s) Summary
Unsubscribe topics and verify delivery
src/runtime/bake/dev_server/hmr_socket.rs, test/bake/hmr-socket-protocol.test.ts
The unsubscribe condition now checks whether a topic was removed. The integration test cycles through topic sets and checks the delivered frame IDs. Its comments describe the failure scenario.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 1cede

Removed HMR topics no longer receive their frames, and the added protocol test checks that behavior across subscription changes. No actionable merge-blocking risk remains.

🚥 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: unsubscribing HMR sockets from topics removed by a subscribe frame.
Description check ✅ Passed The description explains the problem, fix, verification steps, test results, impact, and related context. Although it does not use the template headings exactly, it provides the information requested …

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

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — the one-line condition flip is the intended !new && old unsubscribe, and the existing on_unsubscribe(!new_bits & self.subscriptions) call on the next line already balances the visualizer counters and timer, so the new branch needs no matching hooks. The inline note on the test's fetch error path is a diagnostics nit, not a correctness concern.

Extended reasoning...

The change flips a duplicated else if condition in the Subscribe frame handler of src/runtime/bake/dev_server/hmr_socket.rs so uWS is actually unsubscribed from dropped topics, deletes the stale "likely a bug" comment, and adds one test.concurrent case to test/bake/hmr-socket-protocol.test.ts. It touches the dev-server HMR WebSocket (peer bytes), but only makes an existing unsubscribe call reachable; no auth, crypto, or parsing surface changes. I confirmed on_unsubscribe at hmr_socket.rs:295 already decrements emit_incremental_visualizer_events/emit_memory_visualizer_events and removes the memory-visualizer timer for dropped bits, so debug-feature bookkeeping is balanced without additional code in the new branch. The fix is a one-line, self-evidently correct change with a regression test wired to reject on server exit; the single surviving finding concerns test failure diagnostics only.

Comment thread test/bake/hmr-socket-protocol.test.ts Resolved
The page request and its body read now reject with their own error, and
they race the process exit and the socket close. Each rejection carries
the stderr of the dev server. Before, a failed request waited for the
process exit, so a request that failed while the dev server stayed alive
ended as a timeout with no cause.
@robobun

robobun commented Sep 25, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:33 PM PT - Sep 25th, 2026

✅ @robobun, your commit 1cede5667f7d58100ebd662367c6421677c022ca passed in Build #120691! 🎉


🧪   To try this PR locally:

bunx bun-pr 43995

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

bun-43995 --bun

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

LGTM — the one-line polarity fix is correct, and the follow-up commit resolves the fetch-error propagation I raised earlier (orFail now races each awaited step against failed and rethrows the fetch's own error with stderr attached).

What was reviewed:

  • The inverted else if at src/runtime/bake/dev_server/hmr_socket.rs:149 now matches the dropped-bit set that on_unsubscribe(!new_bits & self.subscriptions) on line 153 already accounted for, so uWS and dev-server bookkeeping agree; grepped for the same pattern elsewhere in src/ — only this site.
  • Checked the test's SetUrl round-trip barrier against vendored uWS: WebSocket::send drains the subscriber's pending topic publishes before the direct frame, and unsubscribing the last topic drains before freeing the subscriber, so the n reply cannot overtake earlier published frames.
  • Test conventions: tempDir, port: 0, test.concurrent, await using proc / using hmr registered before assertions, stdout/stderr drained concurrently, process exit and socket close both reject the awaited promise, exact toEqual on the delivered frame sets.
Extended reasoning...

The change touches one condition in the HMR socket subscribe-frame handler (src/runtime/bake/dev_server/hmr_socket.rs) and adds one concurrent test case to the existing test/bake/hmr-socket-protocol.test.ts. It handles peer-supplied WebSocket bytes but only flips which bit-set difference triggers ws.unsubscribe, with no new parsing, allocation, or trust boundary. The fix is self-evidently correct against the neighboring on_unsubscribe call, the new test exercises both the drop and re-subscribe transitions, the prior inline finding was addressed by the second commit, and neither file is covered by CODEOWNERS. No third-party review requested changes.

@Jarred-Sumner
Jarred-Sumner merged commit 0f63441 into main Sep 25, 2026
11 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the robobun/b98c8556/hmr-socket-unsubscribe branch September 25, 2026 23:49
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