diff --git a/src/runtime/bake/dev_server/hmr_socket.rs b/src/runtime/bake/dev_server/hmr_socket.rs index 1d69aeb581b2..4ca9fc027aa5 100644 --- a/src/runtime/bake/dev_server/hmr_socket.rs +++ b/src/runtime/bake/dev_server/hmr_socket.rs @@ -146,10 +146,7 @@ impl HmrSocket { _ => {} } } - } else if new_bits.contains(bit) && !self.subscriptions.contains(bit) { - // Note: this `else if` condition is identical to the `if` - // above and is therefore unreachable; likely a bug - // (intended: `!new && old` → unsubscribe). + } else if !new_bits.contains(bit) && self.subscriptions.contains(bit) { let _ = ws.unsubscribe(&field.uws_topic()); } } @@ -309,7 +306,7 @@ impl HmrSocket { } if field.contains(HmrTopic::MemoryVisualizer.as_bit()) { dev.emit_memory_visualizer_events -= 1; - if dev.emit_incremental_visualizer_events == 0 + if dev.emit_memory_visualizer_events == 0 && dev.memory_visualizer_timer.state == EventLoopTimerState::ACTIVE { // Note (jsc/runtime crate cycle): `vm.timer` is `()` on the low-tier diff --git a/test/bake/dev/hot.test.ts b/test/bake/dev/hot.test.ts index 92e158155dea..ee7a6aa12a55 100644 --- a/test/bake/dev/hot.test.ts +++ b/test/bake/dev/hot.test.ts @@ -1,7 +1,7 @@ // Hot tests ensure that the `import.meta.hot` interface is functional import { expect } from "bun:test"; import { renameSync, unlinkSync, writeFileSync } from "node:fs"; -import { devTest, emptyHtmlFile } from "../bake-harness"; +import { Dev, devTest, emptyHtmlFile } from "../bake-harness"; devTest("import.meta.hot.accept basic", { files: { @@ -612,6 +612,119 @@ devTest("hot update frames are not delivered to application websocket topics", { }, }); +// Two routes, so that `roundTrip` below always has a different route to switch to. +const hmrSubscriptionFiles = { + "a.html": emptyHtmlFile({ scripts: ["a.ts"], body: "

A

" }), + "b.html": emptyHtmlFile({ body: "

B

" }), + "a.ts": ` + console.log(0); + `, +}; + +/** + * Opens a raw connection to the dev server's hmr socket, in addition to the + * one the harness holds in `dev.socket`, and records the id byte of every + * frame published to it. + */ +async function openHmrSocket(dev: Dev) { + const received: string[] = []; + const probeReplies: PromiseWithResolvers[] = []; + const opened = Promise.withResolvers(); + let failure: Error | undefined; + const fail = (why: string) => { + failure ??= new Error(why); + opened.reject(failure); + for (const reply of probeReplies.splice(0)) reply.reject(failure); + }; + const ws = new WebSocket(dev.baseUrl.replace("http", "ws") + "/_bun/hmr"); + ws.binaryType = "arraybuffer"; + ws.onerror = () => fail("hmr websocket errored"); + ws.onclose = () => fail("hmr websocket closed"); + ws.onmessage = event => { + const id = String.fromCharCode(new Uint8Array(event.data as ArrayBuffer)[0]); + if (id === "V") { + opened.resolve(); + } else if (id === "n") { + probeReplies.shift()!.resolve(); + } else { + received.push(id); + } + }; + await opened.promise; + + let probeRoute = "/a"; + return { + received, + send: (frame: string) => ws.send(frame), + /** + * The dev server answers SetUrl ('n' + route) directly on this socket, + * after handling every frame sent before it and after any frame it had + * already published to this socket. It only answers when the route + * changes, hence the alternation. + */ + async roundTrip() { + if (failure) throw failure; + probeRoute = probeRoute === "/a" ? "/b" : "/a"; + const reply = Promise.withResolvers(); + probeReplies.push(reply); + ws.send("n" + probeRoute); + await reply.promise; + }, + [Symbol.dispose]() { + ws.onclose = null; + ws.close(); + }, + }; +} + +devTest("re-subscribing the hmr socket with fewer topics stops delivery of the dropped topics", { + files: hmrSubscriptionFiles, + async test(dev) { + // Bundle the route once so that editing a.ts triggers rebuilds. The + // harness's own hmr socket stays subscribed to the watch synchronization + // topic ('r') throughout, so every rebuild below publishes to that topic; + // whether the second socket receives it depends only on its subscription. + await dev.fetch("/a").expect.toInclude("

A

"); + using socket = await openHmrSocket(dev); + + let edit = 0; + /** Subscribes to `topics`, rebuilds, and returns the message ids the socket was sent. */ + async function messagesDuringRebuild(topics: string) { + socket.send("s" + topics); + await socket.roundTrip(); + socket.received.length = 0; + await dev.write("a.ts", `console.log(${++edit});`); + await socket.roundTrip(); + return [...new Set(socket.received)].sort(); + } + + // 'h' delivers hot updates ('u'), 'r' delivers watch synchronization ('r'). + expect(await messagesDuringRebuild("hr")).toEqual(["r", "u"]); + expect(await messagesDuringRebuild("r")).toEqual(["r"]); + expect(await messagesDuringRebuild("")).toEqual([]); + expect(await messagesDuringRebuild("hr")).toEqual(["r", "u"]); + }, +}); + +devTest("unsubscribing the last memory visualizer socket stops its timer", { + files: hmrSubscriptionFiles, + async test(dev) { + // On builds with the visualizer hooks compiled in, subscribing to 'M' + // arms a timer, and subscribing again while it is still armed fails an + // assertion that closes the dev server. Unsubscribing has to disarm it + // based on the number of 'M' subscribers, which the 'v' subscriber held + // open here must not affect. On other builds the hooks are no-ops. + using incremental = await openHmrSocket(dev); + incremental.send("sv"); + await incremental.roundTrip(); + using memory = await openHmrSocket(dev); + memory.send("sM"); + memory.send("s"); + memory.send("sM"); + await memory.roundTrip(); + }, +}); + devTest("dev.write resolves only after the new module body has run", { files: { "index.html": emptyHtmlFile({ scripts: ["index.ts"] }),