From 50c9bbebe0355d418c6c2a3fd53347cf96f732bc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 21:56:32 +0000 Subject: [PATCH 1/4] bake: re-arm the memory visualizer timer when it fires The DevServerMemoryVisualizerTick handler was an empty function, so the timer node stayed marked ACTIVE and in the heap after the event loop had popped it. Unsubscribing the socket (or dropping the DevServer) then removed a node the heap no longer contained: debug builds fail `self.root == v` in heap.rs, release builds silently discard whichever timer is at the root of the heap. The handler now marks the node fired, publishes the frame and schedules the next tick. Arming and disarming live on DevServer and are shared by the subscribe hook, the unsubscribe hook and Drop. The unsubscribe hook also disarms on the memory visualizer subscriber count instead of the incremental visualizer count, and emit_memory_visualizer_message_if_needed publishes again after a source map sweep. --- src/runtime/bake/DevServer.rs | 55 ++++++-- src/runtime/bake/dev_server/hmr_socket.rs | 31 +---- src/runtime/dispatch.rs | 2 +- test/bake/dev/hot.test.ts | 152 +++++++++++++++++++++- 4 files changed, 201 insertions(+), 39 deletions(-) diff --git a/src/runtime/bake/DevServer.rs b/src/runtime/bake/DevServer.rs index ac4a40151a96..aa7d00a3d277 100644 --- a/src/runtime/bake/DevServer.rs +++ b/src/runtime/bake/DevServer.rs @@ -1090,10 +1090,7 @@ impl Drop for DevServer { debug_assert!(self.active_websocket_connections.is_empty()); } - if self.memory_visualizer_timer.state == EventLoopTimerState::ACTIVE { - let timer_ptr: *mut EventLoopTimer = &raw mut self.memory_visualizer_timer; - self.timer_heap().remove(timer_ptr); - } + self.disarm_memory_visualizer_timer(); self.graph_safety_lock.lock(); // Hand ownership of the heap allocation to the watcher thread (which frees it in // `thread_main` once `running` flips false). Auto-dropping the `Box` @@ -5451,13 +5448,53 @@ impl DevServer { crate::jsc_hooks::timer_all_mut() } - pub fn emit_memory_visualizer_message_timer( - _timer: &mut EventLoopTimer, - _: &bun_core::Timespec, - ) { + /// Interval between `MemoryVisualizer` frames while at least one HMR + /// socket is subscribed to the topic. + const MEMORY_VISUALIZER_TICK_MS: i64 = 1000; + + /// (Re)schedules `memory_visualizer_timer` one tick from now. Called when + /// the first subscriber arrives and from every tick; `disarm` undoes it + /// when the last subscriber leaves. + pub(crate) fn arm_memory_visualizer_timer(&mut self) { + let next = bun_core::Timespec::ms_from_now( + bun_core::TimespecMockMode::ForceRealTime, + Self::MEMORY_VISUALIZER_TICK_MS, + ); + let timer_ptr: *mut EventLoopTimer = &raw mut self.memory_visualizer_timer; + self.timer_heap().update(timer_ptr, &next); + } + + pub(crate) fn disarm_memory_visualizer_timer(&mut self) { + if self.memory_visualizer_timer.state != EventLoopTimerState::ACTIVE { + return; + } + let timer_ptr: *mut EventLoopTimer = &raw mut self.memory_visualizer_timer; + self.timer_heap().remove(timer_ptr); } - pub fn emit_memory_visualizer_message_if_needed(&mut self) {} + /// `DevServerMemoryVisualizerTick` handler. The event loop has already + /// popped `timer` from the heap, so it must be marked fired before anything + /// else runs: a subscriber leaving while the node still reads `ACTIVE` would + /// `remove()` a node the heap no longer contains. + /// + /// # Safety + /// `timer` must point to the `memory_visualizer_timer` field of a live, + /// heap-allocated `DevServer`. + pub(crate) unsafe fn emit_memory_visualizer_message_timer(timer: *mut EventLoopTimer) { + // SAFETY: caller contract; `from_timer_ptr` recovers the owning DevServer. + let dev: &mut DevServer = unsafe { &mut *DevServer::from_timer_ptr(timer) }; + debug_assert!(dev.magic == Magic::Valid); + dev.memory_visualizer_timer.state = EventLoopTimerState::FIRED; + dev.emit_memory_visualizer_message(); + dev.arm_memory_visualizer_timer(); + } + + pub fn emit_memory_visualizer_message_if_needed(&mut self) { + if self.emit_memory_visualizer_events == 0 { + return; + } + self.emit_memory_visualizer_message(); + } pub fn emit_memory_visualizer_message(&mut self) { debug_assert!(self.emit_memory_visualizer_events > 0); diff --git a/src/runtime/bake/dev_server/hmr_socket.rs b/src/runtime/bake/dev_server/hmr_socket.rs index 38ef17a2be0a..2fd32fde438d 100644 --- a/src/runtime/bake/dev_server/hmr_socket.rs +++ b/src/runtime/bake/dev_server/hmr_socket.rs @@ -124,23 +124,7 @@ impl HmrSocket { dev.memory_visualizer_timer.state != EventLoopTimerState::ACTIVE ); - // Note (jsc/runtime crate cycle): `vm.timer` is `()` on the - // low-tier `VirtualMachine`; the real `timer::All` - // lives in `RuntimeState` (see jsc_hooks.rs). - let state = crate::jsc_hooks::runtime_state(); - let next = bun_core::Timespec::ms_from_now( - bun_core::TimespecMockMode::ForceRealTime, - 1000, - ); - // SAFETY: `runtime_state()` is non-null after - // `bun_runtime::init()`; JS-thread only, sole - // `&mut` to `timer` in this scope. - unsafe { - (*state).timer.update( - &raw mut dev.memory_visualizer_timer, - &next, - ); - } + dev.arm_memory_visualizer_timer(); } } _ => {} @@ -309,17 +293,8 @@ impl HmrSocket { } if field.contains(HmrTopic::MemoryVisualizer.as_bit()) { dev.emit_memory_visualizer_events -= 1; - if dev.emit_incremental_visualizer_events == 0 - && dev.memory_visualizer_timer.state == EventLoopTimerState::ACTIVE - { - // Note (jsc/runtime crate cycle): `vm.timer` is `()` on the low-tier - // `VirtualMachine`; the real `timer::All` lives in `RuntimeState`. - let state = crate::jsc_hooks::runtime_state(); - // SAFETY: `runtime_state()` is non-null after `bun_runtime::init()`; - // JS-thread only, sole `&mut` to `timer` in this scope. - unsafe { - (*state).timer.remove(&raw mut dev.memory_visualizer_timer); - } + if dev.emit_memory_visualizer_events == 0 { + dev.disarm_memory_visualizer_timer(); } } } diff --git a/src/runtime/dispatch.rs b/src/runtime/dispatch.rs index 5111bb4e6f83..e1e7159790fc 100644 --- a/src/runtime/dispatch.rs +++ b/src/runtime/dispatch.rs @@ -1097,7 +1097,7 @@ pub(crate) unsafe fn __bun_fire_timer( EventLoopTimerTag::DevServerMemoryVisualizerTick => { // SAFETY: per fn contract; `t` is the `memory_visualizer_timer` // field of a live DevServer. - DevServer::emit_memory_visualizer_message_timer(unsafe { &mut *t }, unsafe { &*now }); + unsafe { DevServer::emit_memory_visualizer_message_timer(t) }; Ok(()) } EventLoopTimerTag::BunTest => { diff --git a/test/bake/dev/hot.test.ts b/test/bake/dev/hot.test.ts index 92e158155dea..91f1e40d5eb2 100644 --- a/test/bake/dev/hot.test.ts +++ b/test/bake/dev/hot.test.ts @@ -1,7 +1,8 @@ // Hot tests ensure that the `import.meta.hot` interface is functional import { expect } from "bun:test"; +import { isDebug } from "harness"; import { renameSync, unlinkSync, writeFileSync } from "node:fs"; -import { devTest, emptyHtmlFile } from "../bake-harness"; +import { Dev, devTest, emptyHtmlFile, WAIT_MULTIPLIER } from "../bake-harness"; devTest("import.meta.hot.accept basic", { files: { @@ -612,6 +613,155 @@ devTest("hot update frames are not delivered to application websocket topics", { }, }); +// The `M` (memory visualizer) HMR topic only exists in builds with +// `BAKE_DEBUGGING_FEATURES` (canary or debug). A stable release accepts the +// subscription but never emits a frame, so there is nothing to observe there. +const hasBakeDebuggingFeatures = isDebug || Bun.version_with_sha.includes("-canary."); + +const memoryVisualizerApp = { + "index.html": emptyHtmlFile({}), + "bun.app.ts": ` + import html from "./index.html"; + export default { + static: { + "/": html, + }, + async fetch(req) { + if (new URL(req.url).pathname === "/sleep") { + console.log("sleep: timer armed"); + // Long enough to still be pending when the subscriber's close frame + // reaches the server; the test waits on the response, not on time. + await Bun.sleep(500); + return new Response("slept"); + } + return new Response("Not Found", { status: 404 }); + }, + }; + `, +}; + +/** + * Opens an extra `/_bun/hmr` socket and subscribes it to the memory + * visualizer topic (`s` = subscribe, `M` = topic). The server answers the + * subscription with one `M` frame immediately and then one per timer tick. + */ +async function subscribeMemoryVisualizer(dev: Dev) { + const ws = new WebSocket(dev.baseUrl + "/_bun/hmr"); + ws.binaryType = "arraybuffer"; + let open = false; + let frames = 0; + // The step currently being awaited: socket progress resolves it, socket + // failure or the deadline rejects it. + let step: { what: string; done: () => boolean; resolve: () => void; reject: (err: Error) => void } | null = null; + const check = () => { + if (step === null || !step.done()) return; + const { resolve } = step; + step = null; + resolve(); + }; + const fail = (reason: string) => { + if (step === null) return; + const { what, reject } = step; + step = null; + reject(new Error(`${reason} while ${what} (received ${frames} memory visualizer frames)`)); + }; + ws.onopen = () => { + open = true; + check(); + }; + ws.onerror = () => fail("hmr socket errored"); + ws.onclose = event => fail(`hmr socket closed with code ${event.code}`); + ws.onmessage = event => { + if (new Uint8Array(event.data as ArrayBuffer)[0] !== "M".charCodeAt(0)) return; + frames++; + check(); + }; + const waitUntil = (what: string, done: () => boolean) => + new Promise((resolve, reject) => { + const deadline = setTimeout(() => fail("timed out"), 5_000 * WAIT_MULTIPLIER); + step = { + what, + done, + resolve: () => { + clearTimeout(deadline); + resolve(); + }, + reject: err => { + clearTimeout(deadline); + reject(err); + }, + }; + check(); + }); + + const handle = { + get frames() { + return frames; + }, + /** Resolves once this socket has received `count` memory visualizer frames in total. */ + waitForFrames: (count: number) => waitUntil(`waiting for memory visualizer frame #${count}`, () => frames >= count), + /** Closes the socket, which unsubscribes it on the server, and waits for the close handshake. */ + close: () => + new Promise(resolve => { + if (ws.readyState === WebSocket.CLOSED) return resolve(); + ws.onclose = () => resolve(); + ws.close(); + }), + }; + + await waitUntil("opening the hmr socket", () => open); + ws.send("sM"); + await handle.waitForFrames(1); + return handle; +} + +if (hasBakeDebuggingFeatures) { + devTest("memory visualizer topic ticks while subscribed and unsubscribes without disturbing other timers", { + files: memoryVisualizerApp, + htmlFiles: [], + async test(dev) { + const start = performance.now(); + const subscriber = await subscribeMemoryVisualizer(dev); + // Frame 1 is sent synchronously on subscribe and frame 2 by the timer + // armed at that point; frame 3 only arrives if the tick handler + // re-armed the timer after firing. + await subscriber.waitForFrames(3); + // Two real ticks take at least 2s. A handler that re-inserted the timer + // without moving its deadline would deliver frame 3 right after frame 2, + // i.e. at roughly 1s; 1.5s keeps clear of both. + expect(performance.now() - start).toBeGreaterThanOrEqual(1500); + + // Arm an unrelated timer inside the dev server, then unsubscribe while it + // is pending. Unsubscribing removes the tick timer from the heap; if the + // tick left its node marked as still in the heap, that removal discarded + // whichever timer was at the root instead (debug builds assert). + const sleeping = dev.fetch("/sleep"); + await dev.output.waitForLine(/sleep: timer armed/); + await subscriber.close(); + await sleeping.equals("slept"); + + // The timer can be re-armed after being disarmed. This socket is left + // open on purpose: the harness's graceful exit closes it while the timer + // is armed, covering the teardown path. + await subscribeMemoryVisualizer(dev); + }, + }); + + devTest("memory visualizer timer stays armed while another subscriber remains", { + files: memoryVisualizerApp, + htmlFiles: [], + async test(dev) { + const first = await subscribeMemoryVisualizer(dev); + const second = await subscribeMemoryVisualizer(dev); + await first.close(); + // A frame published by a tick just before the close can still be in + // flight, so only the second frame from here on proves the timer is + // still armed for the remaining subscriber. + await second.waitForFrames(second.frames + 2); + }, + }); +} + devTest("dev.write resolves only after the new module body has run", { files: { "index.html": emptyHtmlFile({ scripts: ["index.ts"] }), From 97be4cc2ccbeacdf229a2cab9aeb2a02ccb2fe2e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:18:10 +0000 Subject: [PATCH 2/4] bake: trim memory visualizer timer comments --- src/runtime/bake/DevServer.rs | 14 ++------------ 1 file changed, 2 insertions(+), 12 deletions(-) diff --git a/src/runtime/bake/DevServer.rs b/src/runtime/bake/DevServer.rs index aa7d00a3d277..7857fa919dfb 100644 --- a/src/runtime/bake/DevServer.rs +++ b/src/runtime/bake/DevServer.rs @@ -5448,13 +5448,8 @@ impl DevServer { crate::jsc_hooks::timer_all_mut() } - /// Interval between `MemoryVisualizer` frames while at least one HMR - /// socket is subscribed to the topic. const MEMORY_VISUALIZER_TICK_MS: i64 = 1000; - /// (Re)schedules `memory_visualizer_timer` one tick from now. Called when - /// the first subscriber arrives and from every tick; `disarm` undoes it - /// when the last subscriber leaves. pub(crate) fn arm_memory_visualizer_timer(&mut self) { let next = bun_core::Timespec::ms_from_now( bun_core::TimespecMockMode::ForceRealTime, @@ -5472,18 +5467,13 @@ impl DevServer { self.timer_heap().remove(timer_ptr); } - /// `DevServerMemoryVisualizerTick` handler. The event loop has already - /// popped `timer` from the heap, so it must be marked fired before anything - /// else runs: a subscriber leaving while the node still reads `ACTIVE` would - /// `remove()` a node the heap no longer contains. - /// /// # Safety - /// `timer` must point to the `memory_visualizer_timer` field of a live, - /// heap-allocated `DevServer`. + /// `timer` must be the `memory_visualizer_timer` field of a live, heap-allocated `DevServer`. pub(crate) unsafe fn emit_memory_visualizer_message_timer(timer: *mut EventLoopTimer) { // SAFETY: caller contract; `from_timer_ptr` recovers the owning DevServer. let dev: &mut DevServer = unsafe { &mut *DevServer::from_timer_ptr(timer) }; debug_assert!(dev.magic == Magic::Valid); + // Already popped by the event loop; left ACTIVE, `disarm` would remove() it again. dev.memory_visualizer_timer.state = EventLoopTimerState::FIRED; dev.emit_memory_visualizer_message(); dev.arm_memory_visualizer_timer(); From 36a69017689f1ac90318a80930dafe129ff6e3e4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:33:44 +0000 Subject: [PATCH 3/4] bake: use the file's SAFETY doc form for the tick handler --- src/runtime/bake/DevServer.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/runtime/bake/DevServer.rs b/src/runtime/bake/DevServer.rs index 7857fa919dfb..32f0b3ee1a4b 100644 --- a/src/runtime/bake/DevServer.rs +++ b/src/runtime/bake/DevServer.rs @@ -5467,8 +5467,7 @@ impl DevServer { self.timer_heap().remove(timer_ptr); } - /// # Safety - /// `timer` must be the `memory_visualizer_timer` field of a live, heap-allocated `DevServer`. + /// SAFETY: `timer` must be the `memory_visualizer_timer` field of a live, boxed `DevServer`. pub(crate) unsafe fn emit_memory_visualizer_message_timer(timer: *mut EventLoopTimer) { // SAFETY: caller contract; `from_timer_ptr` recovers the owning DevServer. let dev: &mut DevServer = unsafe { &mut *DevServer::from_timer_ptr(timer) }; From f65f0ef09abb4c3c717a0470ba81cb643c6ae0bc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Wed, 12 Aug 2026 23:47:45 +0000 Subject: [PATCH 4/4] bake: observe the unrelated timer through an interval and latch socket failures in the visualizer tests --- test/bake/dev/hot.test.ts | 47 ++++++++++++++++++++++++--------------- 1 file changed, 29 insertions(+), 18 deletions(-) diff --git a/test/bake/dev/hot.test.ts b/test/bake/dev/hot.test.ts index 91f1e40d5eb2..d365149d658b 100644 --- a/test/bake/dev/hot.test.ts +++ b/test/bake/dev/hot.test.ts @@ -622,17 +622,19 @@ const memoryVisualizerApp = { "index.html": emptyHtmlFile({}), "bun.app.ts": ` import html from "./index.html"; + // Always armed in the dev server's timer heap; /tick answers on its next fire. + const waiters = []; + setInterval(() => { + for (const resolve of waiters.splice(0)) resolve(); + }, 20); export default { static: { "/": html, }, async fetch(req) { - if (new URL(req.url).pathname === "/sleep") { - console.log("sleep: timer armed"); - // Long enough to still be pending when the subscriber's close frame - // reaches the server; the test waits on the response, not on time. - await Bun.sleep(500); - return new Response("slept"); + if (new URL(req.url).pathname === "/tick") { + await new Promise(resolve => waiters.push(resolve)); + return new Response("ticked"); } return new Response("Not Found", { status: 404 }); }, @@ -650,6 +652,9 @@ async function subscribeMemoryVisualizer(dev: Dev) { ws.binaryType = "arraybuffer"; let open = false; let frames = 0; + // Set when the socket errors or closes unexpectedly; fails the step being + // awaited at that moment and every step started afterwards. + let failure: Error | null = null; // The step currently being awaited: socket progress resolves it, socket // failure or the deadline rejects it. let step: { what: string; done: () => boolean; resolve: () => void; reject: (err: Error) => void } | null = null; @@ -660,17 +665,21 @@ async function subscribeMemoryVisualizer(dev: Dev) { resolve(); }; const fail = (reason: string) => { + const err = new Error( + `${reason}${step ? ` while ${step.what}` : ""} (received ${frames} memory visualizer frames)`, + ); + failure ??= err; if (step === null) return; - const { what, reject } = step; + const { reject } = step; step = null; - reject(new Error(`${reason} while ${what} (received ${frames} memory visualizer frames)`)); + reject(err); }; ws.onopen = () => { open = true; check(); }; ws.onerror = () => fail("hmr socket errored"); - ws.onclose = event => fail(`hmr socket closed with code ${event.code}`); + ws.onclose = event => fail(`hmr socket closed unexpectedly with code ${event.code}`); ws.onmessage = event => { if (new Uint8Array(event.data as ArrayBuffer)[0] !== "M".charCodeAt(0)) return; frames++; @@ -678,6 +687,7 @@ async function subscribeMemoryVisualizer(dev: Dev) { }; const waitUntil = (what: string, done: () => boolean) => new Promise((resolve, reject) => { + if (failure) return reject(failure); const deadline = setTimeout(() => fail("timed out"), 5_000 * WAIT_MULTIPLIER); step = { what, @@ -702,8 +712,9 @@ async function subscribeMemoryVisualizer(dev: Dev) { waitForFrames: (count: number) => waitUntil(`waiting for memory visualizer frame #${count}`, () => frames >= count), /** Closes the socket, which unsubscribes it on the server, and waits for the close handshake. */ close: () => - new Promise(resolve => { - if (ws.readyState === WebSocket.CLOSED) return resolve(); + new Promise((resolve, reject) => { + if (failure) return reject(failure); + ws.onerror = () => reject(new Error("hmr socket errored during the close handshake")); ws.onclose = () => resolve(); ws.close(); }), @@ -731,14 +742,14 @@ if (hasBakeDebuggingFeatures) { // i.e. at roughly 1s; 1.5s keeps clear of both. expect(performance.now() - start).toBeGreaterThanOrEqual(1500); - // Arm an unrelated timer inside the dev server, then unsubscribe while it - // is pending. Unsubscribing removes the tick timer from the heap; if the - // tick left its node marked as still in the heap, that removal discarded - // whichever timer was at the root instead (debug builds assert). - const sleeping = dev.fetch("/sleep"); - await dev.output.waitForLine(/sleep: timer armed/); + // The fixture's interval is pending in the same heap while the subscriber + // leaves. Unsubscribing removes the visualizer timer from the heap; when + // the tick had left that node marked as still in the heap, the removal + // discarded whichever timer was at the root instead (debug builds assert), + // and the interval never fired again. + await dev.fetch("/tick").equals("ticked"); await subscriber.close(); - await sleeping.equals("slept"); + await dev.fetch("/tick").equals("ticked"); // The timer can be re-armed after being disarmed. This socket is left // open on purpose: the harness's graceful exit closes it while the timer