diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 191c40190c98..3e24dbbadca8 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1820,6 +1820,12 @@ pub struct RuntimeHooks { /// never lazily created. Called from `WebWorker::shutdown` / `global_exit` /// right after `close_all_socket_groups`. pub close_dns_for_terminate: fn(), + /// `ObjectURLRegistry::singleton().sweep_owner(owner)` — drop every + /// `URL.createObjectURL` entry registered by the given script-execution + /// context. Called from `WebWorker::shutdown` so a worker that never calls + /// `revokeObjectURL` does not pin its blob payloads for the lifetime of + /// the process. The registry lives in `bun_runtime::webcore` (forward-dep). + pub sweep_object_urls_for_owner: fn(owner: i32), } /// Canonical `EventLoopCtx` vtable for a `*mut VirtualMachine` owner — the JS diff --git a/src/jsc/web_worker.rs b/src/jsc/web_worker.rs index 3124acf81235..4119a8ed00bb 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -1313,6 +1313,12 @@ impl WebWorker { if let Some(rare) = vm.rare_data.as_deref_mut() { rare.release_js_handles(); } + if let Some(hooks) = runtime_hooks() { + // Drop this worker's URL.createObjectURL entries from the + // process-global registry; nothing else revokes them once the + // worker is gone. + (hooks.sweep_object_urls_for_owner)(vm.initial_script_execution_context_identifier); + } exit_code = i32::from(vm.exit_handler.exit_code); global_object = Some(vm.global); } diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 0c719212132e..d9907876c242 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -1487,6 +1487,7 @@ pub(crate) static __BUN_RUNTIME_HOOKS: RuntimeHooks = RuntimeHooks { retroactively_report_discovered_tests, cancel_all_timers, close_dns_for_terminate, + sweep_object_urls_for_owner, }; // ════════════════════════════════════════════════════════════════════════════ @@ -1636,6 +1637,12 @@ fn close_dns_for_terminate() { } } +/// `RuntimeHooks::sweep_object_urls_for_owner` — drop every blob-URL registry +/// entry whose recording context is `owner`. +fn sweep_object_urls_for_owner(owner: i32) { + crate::webcore::object_url_registry::ObjectURLRegistry::singleton().sweep_owner(owner); +} + pub(crate) fn close_isolation_handles(vm: &mut VirtualMachine) { let state = runtime_state(); if state.is_null() { diff --git a/src/runtime/webcore/ObjectURLRegistry.rs b/src/runtime/webcore/ObjectURLRegistry.rs index 33a11c042c6f..c83bd66216bd 100644 --- a/src/runtime/webcore/ObjectURLRegistry.rs +++ b/src/runtime/webcore/ObjectURLRegistry.rs @@ -28,19 +28,23 @@ impl Default for ObjectURLRegistry { pub struct Entry { blob: Blob, + /// `VirtualMachine::initial_script_execution_context_identifier` of the + /// registering context; see [`ObjectURLRegistry::sweep_owner`]. + owner: i32, } -// `Entry` is auto-`Send`: its sole field is `Blob`, which already asserts -// `Send + Sync` (see `webcore_types::Blob`). No `unsafe impl` needed. +// `Entry` is auto-`Send`: `Blob` already asserts `Send + Sync` +// (see `webcore_types::Blob`). No `unsafe impl` needed. const _: fn() = || { fn assert_send() {} assert_send::(); }; impl Entry { - pub fn init(blob: &Blob) -> Box { + pub fn init(blob: &Blob, owner: i32) -> Box { Box::new(Entry { blob: blob.dupe_with_content_type(true), + owner, }) } } @@ -54,8 +58,9 @@ impl Drop for Entry { impl ObjectURLRegistry { pub fn register(&self, vm: &mut VirtualMachine, blob: &Blob) -> UUID { + let owner = vm.initial_script_execution_context_identifier; let uuid = vm.rare_data().next_uuid(); - let entry = Entry::init(blob); + let entry = Entry::init(blob, owner); self.map.lock().insert(uuid.bytes, entry); uuid @@ -97,6 +102,22 @@ impl ObjectURLRegistry { }; self.map.lock().contains_key(&uuid.bytes) } + + /// Drop every entry registered by `owner`. Called from + /// `WebWorker::shutdown` (via `RuntimeHooks`) so blob URLs minted inside a + /// worker are released when that worker exits. Entries from the main + /// context (or other still-live workers) are left untouched. + pub fn sweep_owner(&self, owner: i32) { + let mut map = self.map.lock(); + let keys: Vec<[u8; 16]> = map + .iter() + .filter(|(_, e)| e.owner == owner) + .map(|(k, _)| *k) + .collect(); + for k in keys { + let _ = map.remove(&k); + } + } } fn uuid_from_pathname(pathname: &[u8]) -> Option { diff --git a/test/js/web/workers/worker_blob.test.ts b/test/js/web/workers/worker_blob.test.ts index aaf58ae0ea3e..830feb1e1956 100644 --- a/test/js/web/workers/worker_blob.test.ts +++ b/test/js/web/workers/worker_blob.test.ts @@ -1,4 +1,5 @@ import { expect, test } from "bun:test"; +import { bunEnv, bunExe, isWindows } from "harness"; test("Worker from a Blob", async () => { const worker = new Worker( @@ -124,3 +125,92 @@ test("Worker on a revoked blob still works", async () => { expect(revoked).toBe("revoked."); }); + +// The blob-URL registry is process-global. Before the per-owner sweep, an entry +// registered inside a worker stayed in the map after the worker exited, and the +// duped blob store pinned the payload for the lifetime of the process (roughly +// 1 MB retained per MB minted per dead worker). After the sweep, a dead +// worker's URL resolves like a revoked URL and the payload is released. +test("blob URLs created inside a Worker are released when the Worker exits", async () => { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + ` + const workerBody = ${JSON.stringify(` + const urls = []; + for (let i = 0; i < 4; i++) { + const u8 = new Uint8Array(1 << 20); + u8.fill(65); + urls.push(URL.createObjectURL(new Blob([u8]))); + } + postMessage(urls); + `)}; + const src = URL.createObjectURL( + new Blob([workerBody], { type: "application/javascript" }), + ); + + async function runWorker() { + const w = new Worker(src); + const urls = await new Promise((resolve, reject) => { + w.onmessage = e => resolve(e.data); + w.onerror = e => reject(new Error("worker errored: " + e.message)); + }); + await new Promise(r => w.addEventListener("close", r, { once: true })); + return urls; + } + + // Establish the allocator high-water mark before measuring. + for (let i = 0; i < 3; i++) await runWorker(); + Bun.gc(true); + Bun.gc(true); + + const rss0 = process.memoryUsage.rss(); + let deadUrl; + for (let i = 0; i < 30; i++) { + const urls = await runWorker(); + deadUrl ??= urls[0]; + } + Bun.gc(true); + Bun.gc(true); + const growthMB = (process.memoryUsage.rss() - rss0) / 2 ** 20; + + let deadUrlServed = false; + try { + await fetch(deadUrl); + deadUrlServed = true; + } catch {} + + // The parent-minted worker-source URL must survive the sweep. + const parentUrlStillResolves = (await (await fetch(src)).text()) === workerBody; + + console.log(JSON.stringify({ growthMB, deadUrlServed, parentUrlStillResolves })); + `, + ], + env: { + ...bunEnv, + // Under ASAN the 1 MB payload frees go into the quarantine (default + // quarantine_size_mb=256) instead of being returned, so the RSS delta + // measures the quarantine rather than the registry. Disable it so the + // threshold is meaningful on ASAN builds. + ASAN_OPTIONS: [bunEnv.ASAN_OPTIONS, "quarantine_size_mb=0"].filter(Boolean).join(":"), + }, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const { growthMB, deadUrlServed, parentUrlStillResolves } = JSON.parse(stdout); + if (!isWindows) { + // 30 workers * 4 MB: when leaking, growth is ~120 MB; when swept, growth + // is allocator noise (typically under 15 MB even on debug builds). RSS + // does not drop after worker threads exit on Windows (per-thread mimalloc + // arenas stay committed), so the threshold is meaningless there. + expect(growthMB).toBeLessThan(40); + } + expect(deadUrlServed).toBe(false); + expect(parentUrlStillResolves).toBe(true); + expect(exitCode).toBe(0); +}, 60_000);