diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 191c40190c98..a581fa7dfb45 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -1820,6 +1820,10 @@ 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::revoke_all_for_context` — drops every blob URL a + /// worker registered, called from `WebWorker::shutdown`. Registry lives in + /// `bun_runtime::webcore`. + pub revoke_object_urls_for_context: fn(context_id: 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..ed2afcd57337 100644 --- a/src/jsc/web_worker.rs +++ b/src/jsc/web_worker.rs @@ -1251,6 +1251,9 @@ impl WebWorker { vm.on_exit(); if let Some(hooks) = runtime_hooks() { (hooks.cron_clear_all_teardown)(vm); + // Before `dispatchExit` so the parent never observes `exit` + // with this worker's blob URLs still live. + (hooks.revoke_object_urls_for_context)(self.execution_context_id as i32); // Drain `TimeoutObject`s from this worker's timer heap before // `close_all_socket_groups` / `WebWorker__teardownJSCVM` so // their heap nodes are unlinked while `runtime_state` and the diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 0c719212132e..f1b8fe5785c0 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -1274,6 +1274,12 @@ fn has_blob_url(blob_id: &[u8]) -> bool { crate::webcore::object_url_registry::ObjectURLRegistry::singleton().has(blob_id) } +/// `WebCore.ObjectURLRegistry.singleton().revoke_all_for_context(context_id)`. +fn revoke_object_urls_for_context(context_id: i32) { + crate::webcore::object_url_registry::ObjectURLRegistry::singleton() + .revoke_all_for_context(context_id) +} + /// `Response::get_blob_without_call_frame` / /// `Request::get_blob_without_call_frame`. Downcasts /// `value` to a `Response`/`Request` (whose data shapes + `BodyMixin` impl live @@ -1487,6 +1493,7 @@ pub(crate) static __BUN_RUNTIME_HOOKS: RuntimeHooks = RuntimeHooks { retroactively_report_discovered_tests, cancel_all_timers, close_dns_for_terminate, + revoke_object_urls_for_context, }; // ════════════════════════════════════════════════════════════════════════════ diff --git a/src/runtime/webcore/ObjectURLRegistry.rs b/src/runtime/webcore/ObjectURLRegistry.rs index 33a11c042c6f..83efec9357c7 100644 --- a/src/runtime/webcore/ObjectURLRegistry.rs +++ b/src/runtime/webcore/ObjectURLRegistry.rs @@ -28,6 +28,8 @@ impl Default for ObjectURLRegistry { pub struct Entry { blob: Blob, + /// Registering realm; see [`ObjectURLRegistry::revoke_all_for_context`]. + context_id: i32, } // `Entry` is auto-`Send`: its sole field is `Blob`, which already asserts @@ -38,9 +40,10 @@ const _: fn() = || { }; impl Entry { - pub fn init(blob: &Blob) -> Box { + pub fn init(blob: &Blob, context_id: i32) -> Box { Box::new(Entry { blob: blob.dupe_with_content_type(true), + context_id, }) } } @@ -55,7 +58,7 @@ impl Drop for Entry { impl ObjectURLRegistry { pub fn register(&self, vm: &mut VirtualMachine, blob: &Blob) -> UUID { let uuid = vm.rare_data().next_uuid(); - let entry = Entry::init(blob); + let entry = Entry::init(blob, vm.initial_script_execution_context_identifier); self.map.lock().insert(uuid.bytes, entry); uuid @@ -91,6 +94,19 @@ impl ObjectURLRegistry { let _ = self.map.lock().remove(&uuid.bytes); } + /// Remove every entry registered by `context_id` (worker teardown). + pub fn revoke_all_for_context(&self, context_id: i32) { + let mut map = self.map.lock(); + let keys: Vec<[u8; 16]> = map + .iter() + .filter(|(_, e)| e.context_id == context_id) + .map(|(k, _)| *k) + .collect(); + for k in keys { + let _ = map.remove(&k); + } + } + pub fn has(&self, pathname: &[u8]) -> bool { let Some(uuid) = uuid_from_pathname(pathname) else { return false; diff --git a/test/js/web/workers/worker_blob.test.ts b/test/js/web/workers/worker_blob.test.ts index aaf58ae0ea3e..48cb268f44de 100644 --- a/test/js/web/workers/worker_blob.test.ts +++ b/test/js/web/workers/worker_blob.test.ts @@ -1,4 +1,6 @@ import { expect, test } from "bun:test"; +import { resolveObjectURL } from "node:buffer"; +import { Worker as NodeWorker } from "node:worker_threads"; test("Worker from a Blob", async () => { const worker = new Worker( @@ -124,3 +126,37 @@ test("Worker on a revoked blob still works", async () => { expect(revoked).toBe("revoked."); }); + +test("Blob URLs created inside a Worker are revoked when the worker exits", async () => { + const worker = new NodeWorker( + `const { parentPort } = require("node:worker_threads"); + const u8 = new Uint8Array(1024).fill(7); + parentPort.postMessage(URL.createObjectURL(new Blob([u8])));`, + { eval: true }, + ); + const url = await new Promise(resolve => worker.once("message", resolve)); + await new Promise(resolve => worker.once("exit", resolve)); + + expect(resolveObjectURL(url)).toBeUndefined(); + await expect(fetch(url)).rejects.toThrow(); +}); + +test("Worker exit does not revoke Blob URLs created by other threads", async () => { + const parentUrl = URL.createObjectURL(new Blob([new Uint8Array(64).fill(1)])); + try { + const worker = new NodeWorker( + `const { parentPort } = require("node:worker_threads"); + parentPort.postMessage(URL.createObjectURL(new Blob([new Uint8Array(64)])));`, + { eval: true }, + ); + const workerUrl = await new Promise(resolve => worker.once("message", resolve)); + await new Promise(resolve => worker.once("exit", resolve)); + + expect(resolveObjectURL(workerUrl)).toBeUndefined(); + const blob = resolveObjectURL(parentUrl); + expect(blob).toBeInstanceOf(Blob); + expect(new Uint8Array(await blob!.arrayBuffer())).toEqual(new Uint8Array(64).fill(1)); + } finally { + URL.revokeObjectURL(parentUrl); + } +});