bake: unsubscribe the HMR socket from topics dropped by a re-subscribe - #37878
bake: unsubscribe the HMR socket from topics dropped by a re-subscribe#37878robobun wants to merge 3 commits into
Conversation
The Subscribe handler's unsubscribe branch repeated the subscribe condition, so a socket that sent a new topic set without a topic it had previously subscribed to kept receiving that topic's publishes, while HmrSocket.subscriptions said it was unsubscribed.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (2)
WalkthroughThe HMR socket now removes topics omitted from updated subscriptions. Memory visualizer timer cleanup uses the memory visualizer subscription count. Tests cover topic delivery changes and independent visualizer subscription cleanup. ChangesHMR subscription lifecycle
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Status: ready for review. The diff is done; CI is red only on build lanes that could not download vendored dependencies from github.com. Reproduced on the released build ( The second commit (from review) fixes the counter checked in With both, CI: in build 93404 every lane that built passed its tests (the only test failures were retried flakes in unrelated files); the darwin x64, linux aarch64 and x64-musl build lanes failed with |
|
Updated 3:06 PM PT - Aug 12th, 2026
❌ @robobun, your commit d7b31ce has 4 failures in
🧪 To try this PR locally: bunx bun-pr 37878That installs a local version of the PR into your bun-37878 --bun |
There was a problem hiding this comment.
LGTM — the guard flip is exactly what the removed comment prescribed, and the test's SetUrl round-trip synchronization is a clean way to avoid timing. The inline note on on_unsubscribe is a pre-existing, BAKE_DEBUGGING_FEATURES-gated sibling of the same copy-paste class; worth folding in but not a blocker.
Extended reasoning...
Overview
One-line condition fix in src/runtime/bake/dev_server/hmr_socket.rs: the else if guarding ws.unsubscribe in the Subscribe handler was new && !old (identical to the if above it, so unreachable) and is now !new && old. The removed code comment already documented this as the intended fix. A new test in test/bake/dev/hot.test.ts opens a raw /_bun/hmr socket, cycles through subscription sets hr → r → none → hr, and asserts exactly which message ids arrive after each rebuild.
Security risks
None. The HMR socket is a dev-server-only local protocol; the change narrows delivery (unsubscribes stale topics) rather than widening any surface. No auth, crypto, or untrusted-input parsing is touched.
Level of scrutiny
Low-to-medium. The Rust change is a single boolean condition whose correct form was already spelled out in a comment at the site, and the surrounding handler already did the matching bookkeeping (on_unsubscribe(!new_bits & self.subscriptions) and self.subscriptions = new_bits) — only the uws-side unsubscribe was dead. The test is where most of the review effort went: it wires error/close to reject every pending promise, uses SetUrl round-trips on the same socket for ordering (no sleeps), and relies on dev.write resolving via the harness socket's watch-synchronization ack so the rebuild's publishes have already been enqueued to the second socket before the post-write roundTrip() drains them. Checked bake-harness.ts to confirm dev.write → batchChanges → waitForHotReload awaits the watch_synchronization event, so the ordering assumption holds without a client attached.
Other factors
The bug hunter flagged one same-class pre-existing issue in on_unsubscribe (memory-visualizer timer removal checks emit_incremental_visualizer_events instead of emit_memory_visualizer_events). It's real but gated behind feature_flags::BAKE_DEBUGGING_FEATURES, not introduced here, and a one-word fix — noted inline as a nit rather than a blocker. Also checked that the test's [...new Set(received)].sort() normalization can't mask the failure mode: the unfixed build produces ["r", "u"] on the r-only and empty steps, which the exact-array assertions reject. No prior human review comments to address.
…count on_unsubscribe decremented emit_memory_visualizer_events but checked emit_incremental_visualizer_events before removing the timer, so the timer stayed armed whenever an incremental visualizer socket was connected, and the next memory visualizer subscription failed the timer-not-active assertion.
|
Folded the |
There was a problem hiding this comment.
LGTM — the follow-up commit addresses the on_unsubscribe counter mismatch I flagged, with a test covering it.
Reviewed: the two one-line condition fixes in hmr_socket.rs (both were copy-paste slips, the first already flagged by a code comment); the new openHmrSocket helper and both tests use SetUrl round-trips for ordering rather than sleeps, wire error/close to reject the awaited promise, and clean up via using. Checked that a.html/b.html map to /a and /b under the multi-file branch of indexHtmlScript, so the alternating roundTrip probe always resolves. The second test has no explicit expect but is not vacuous — the assertion failure closes the socket, which rejects roundTrip().
Extended reasoning...
Overview
Two one-line fixes in src/runtime/bake/dev_server/hmr_socket.rs, plus two tests and a shared helper in test/bake/dev/hot.test.ts.
The first change flips the else if guard in the Subscribe handler from new && !old (dead — identical to the if above it, and a code comment already said so) to !new && old, so re-subscribing with fewer topics actually calls ws.unsubscribe for the dropped ones. Without it, self.subscriptions and the uws subscription set diverge, which the PR description traces to the ref_count > 0 assertion in SourceMapStore.
The second change is the fix for the same-class issue I raised on the previous revision: on_unsubscribe now gates memory_visualizer_timer removal on emit_memory_visualizer_events == 0 instead of the unrelated incremental counter. The author added a dedicated test and updated the PR description to cover it.
Security risks
None. This is internal dev-server HMR socket bookkeeping. The Subscribe handler already bounds topics.len() and validates each byte via HmrTopic::from_u8; nothing here changes what input is accepted or how it's parsed.
Level of scrutiny
Low-to-medium. Both source changes are single-token corrections to conditions whose intent is unambiguous from the surrounding code (the subscribe branch mirrors the unsubscribe branch; the timer-arm branch mirrors the timer-disarm branch). Neither changes control flow beyond making the documented path reachable. The visualizer fix is additionally gated behind BAKE_DEBUGGING_FEATURES.
The tests warranted closer reading. openHmrSocket follows the pattern of the existing "hot update frames are not delivered to application websocket topics" test just above it. Ordering is established by SetUrl round-trips on the same socket (per-socket WebSocket frame ordering guarantees the n response arrives after anything already queued), not by sleeping. onerror/onclose are wired into fail(), which rejects both the open promise and every pending roundTrip, so a server crash surfaces as a test failure rather than a hang. I checked indexHtmlScript in bake-harness.ts: with two HTML files it maps a.html→/a and b.html→/b, so the alternating probe always hits a valid route and the SetUrl handler always replies. The dedupe-and-sort on received makes the first test's assertions robust to multiple r frames per rebuild.
Other factors
My previous review's only finding has been addressed in full (code + test + description). The rest of hot.test.ts is reported passing on a debug build. No outstanding human reviewer comments.
There was a problem hiding this comment.
LGTM — both fixes are the obvious correction to copy-paste slips (one already had a comment calling itself a bug), and the earlier review point about on_unsubscribe checking the wrong counter is now addressed with a test.
What was reviewed:
- The
!new && oldguard now mirrors the subscribe branch;on_unsubscribe(!new_bits & self.subscriptions)right after already handles the visualizer counters, so the else-if correctly only needs thews.unsubscribecall. - The timer disarm now checks
emit_memory_visualizer_events, symmetric with the 0→1 arm path above it. - Tests: no sleeps — ordering is via SetUrl round-trips on the same socket, and error/close are wired to reject pending promises so a server crash surfaces as a test failure rather than a hang.
usinghandles cleanup.
Extended reasoning...
Overview
Two one-line fixes in src/runtime/bake/dev_server/hmr_socket.rs:
- In the
Subscribehandler, theelse ifguardingws.unsubscribewas identical to theifabove it (new && !oldtwice), so it never ran. The code even had a comment stating this was likely a bug. Now!new && old, so dropped topics are unsubscribed from uws and the socket'ssubscriptionsbitset stays in sync with the uws subscription set. - In
on_unsubscribe, the memory visualizer timer disarm was gated onemit_incremental_visualizer_events == 0(wrong counter). Now checksemit_memory_visualizer_events == 0, matching the subscribe path that arms the timer on that counter's 0→1 transition.
Two new tests in test/bake/dev/hot.test.ts plus a shared openHmrSocket helper.
Security risks
None. This is internal dev-server subscription bookkeeping on a local-only HMR WebSocket. No auth, crypto, or untrusted-input parsing changed; the message parsing paths touched already length-check their frames.
Level of scrutiny
Low-to-medium. The Rust changes are two obvious copy-paste corrections in a handler whose surrounding structure (loop over all topics, then on_unsubscribe for the dropped set, then overwrite self.subscriptions) already assumed exactly this behavior. Per the PR description, no in-tree client sends a second subscribe frame today, so the first fix is protocol correctness rather than a user-visible behavior change; the second is behind BAKE_DEBUGGING_FEATURES. I verified the else if branch correctly only calls ws.unsubscribe (the on-unsubscribe hooks for visualizer counters are already handled by the self.on_unsubscribe(!new_bits & self.subscriptions) call two lines later).
Other factors
- The one prior review comment (mine, flagging the wrong counter in
on_unsubscribe) has been fully addressed in f8f2d1b with a dedicated test; the thread is resolved. - Test design is solid per REVIEW.md: no
setTimeout/sleep— synchronization uses SetUrl round-trips whose response is ordered after any frames the server had already published on the same connection. Every failure event (error,close) rejects the awaited promise, so a dev-server crash (the pre-fix debug assertion) fails the test instead of hanging. Resources are released viausing/Symbol.dispose. - The author confirmed the first test fails on
USE_SYSTEM_BUN=1(still receivesuafter unsubscribing) and the second fails on an unfixed debug build with the timer-state assertion, satisfying the "fails for the right reason" requirement. - The full
hot.test.tssuite (13 tests) passes on a debug build.
|
Heads up: #37936 changes the same |
Problem
u(hot update) frames on every edit.panic: assertion failed: ref_count > 0inSourceMapStore::put_or_increment_ref_count, fromfinalize_bundle. A release build keeps a zero-reference source map entry instead.panic: assertion failed: dev.memory_visualizer_timer.state != EventLoopTimerState::ACTIVE.Fix
uafter dropping the topic (checked on release and debug builds); without the second commit the second trips the timer assertion on a debug build and is a no-op on builds without the hooks. No in-tree client sends a second subscribe frame today.Background
Bun.serve({ development: true })(bake) serves a WebSocket at/_bun/hmr. Frames start with a one-byte id:splus topic letters subscribes,nplus a route sets the page URL and is answered directly, and the server publishes ids such asu(hot update) andr(watch synchronization).HmrSocket.subscriptionsis the dev server's own bitset.finalize_bundleuses the uws count to decide whether to publish a hot update and the bitset to take one source map reference per receiving socket.M(memory) andv(incremental) visualizer topics exist only on builds withBAKE_DEBUGGING_FEATURES; since Narrow crate-internal Rust visibility across all targets and delete the code it proves dead #36184 they only maintain counters and a timer.Original description
Repro
Against a dev server (here
Bun.serve({ development: true, routes: { "/a": html } })witha.htmlloadinga.ts, run from the project directory), connect to the HMR socket, subscribe, then re-subscribe with fewer topics, and edit a file:Released build: the socket keeps logging
u(a hot update frame) on every edit, for the rest of the connection. Debug build: the dev server aborts on the first edit withCause
In the
IncomingMessageId::Subscribehandler insrc/runtime/bake/dev_server/hmr_socket.rs, the branch meant to callws.unsubscribewas guarded by the same condition as thews.subscribebranch above it (new && !oldtwice), so it never ran. A comment next to it already said so. The condition was carried over unchanged from the Zig version of the file.The handler otherwise treats the frame as a replacement of the whole set:
on_unsubscribeis called with the dropped bits andHmrSocket.subscriptionsis overwritten with the new set. Only the uws subscription was left behind, so from then on the socket's bookkeeping and uws disagreed.finalize_bundlerelies on the two agreeing:num_subscribers(HotUpdate)(uws) decides whether to build and publish a hot update at all, while the source map reference for the chunk is taken once per socket whoseis_subscribed(HotUpdate)bit (bookkeeping) is set. With a stale uws subscription the payload is published to a socket that no reference was taken for; when that socket was the only listener and the chunk's script id is new,put_or_increment_ref_countis handed a count of 0, which is the assertion above in debug builds and a zero-reference entry that nothing removes in release builds.Fix
The guard is now
!new && old, so a re-subscribe unsubscribes the dropped uws topics, and the two views of the subscription set stay in sync in every case (added, dropped, unchanged). None of the in-tree clients send a second subscribe frame today, so this is about the protocol behaving as documented by its own handler rather than something the shipped runtime triggers.The second commit fixes the same kind of slip in
on_unsubscribe, two lines below (raised in review): it decrementsemit_memory_visualizer_eventsbut then checkedemit_incremental_visualizer_eventsbefore disarmingmemory_visualizer_timer. The subscribe path arms the timer when the memory count goes 0 to 1 and asserts it is not already armed, so with an incremental visualizer socket connected, unsubscribing and re-subscribing a memory visualizer socket failedThese hooks only exist on builds with
BAKE_DEBUGGING_FEATURES, and since #36184 removed the visualizer routes they only maintain the counters and the timer, so this is bookkeeping consistency rather than anything a user can reach.Verification
Two tests in
test/bake/dev/hot.test.ts, sharing a small helper that opens a second raw/_bun/hmrsocket and records the id byte of every frame published to it. Ordering uses SetUrl round trips on that socket rather than timing: the server answersnonly after processing the frames sent before it, and after anything it had already published to the socket."re-subscribing the hmr socket with fewer topics stops delivery of the dropped topics": for each subscription set
hr,r, none, andhragain, rebuilds a file and checks which message ids arrived. The harness's own socket stays subscribed tor, so therpublishes still happen during every rebuild and the only variable is whether the second socket receives them. Without the first commit therstep receives["r", "u"]and the none step receives["r", "u"](checked separately, on the released build and on an unfixed debug build); with it all four steps match, and the snippet above reloads three times without tripping the assertion."unsubscribing the last memory visualizer socket stops its timer": holds a
vsubscriber open, then on a second socket sendssM,s,sMand waits for a round trip. On a debug build without the second commit the dev server fails the assertion above and the test reports the socket closing; with it the round trip completes. On builds without the hooks the frames are no-ops and the test passes either way.The whole of
hot.test.ts(13 tests) passes on a debug build.