Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/jsc/VirtualMachine.zig
Original file line number Diff line number Diff line change
Expand Up @@ -1332,6 +1332,13 @@ pub inline fn assertOnJSThread(vm: *const VirtualMachine) void {
}
}

/// The ScriptExecutionContext identifier for this VM's global. Main thread is
/// always 1; workers get a unique id allocated by ScriptExecutionContext::
/// generateIdentifier() in C++.
pub inline fn scriptExecutionContextId(vm: *const VirtualMachine) u32 {
return @intCast(vm.initial_script_execution_context_identifier);
}
Comment thread
robobun marked this conversation as resolved.

fn configureDebugger(this: *VirtualMachine, cli_flag: bun.cli.Command.Debugger) void {
if (bun.env_var.HYPERFINE_RANDOMIZED_ENVIRONMENT_OFFSET.get() != null) {
return;
Expand Down
5 changes: 5 additions & 0 deletions src/jsc/web_worker.zig
Original file line number Diff line number Diff line change
Expand Up @@ -729,6 +729,11 @@ fn shutdown(this: *WebWorker) noreturn {
// closeAll() fires on_close → JS callbacks. RareData.deinit() runs
// after teardownJSCVM and only deinit()s (asserts empty in debug).
if (vm.rare_data) |rare| rare.closeAllSocketGroups(vm);
// Auto-revoke blob: URLs created by this worker. The registry is
// process-global, so without this the duped Blob entries would outlive
// the worker. Runs before WebWorker__dispatchExit so the parent's close
// event observes the URLs as already gone.
bun.webcore.ObjectURLRegistry.singleton().revokeEntriesForContext(this.execution_context_id);
exit_code = vm.exit_handler.exit_code;
globalObject = vm.global;
}
Expand Down
35 changes: 33 additions & 2 deletions src/runtime/webcore/ObjectURLRegistry.zig
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,17 @@ map: std.AutoHashMap(UUID, *Entry) = std.AutoHashMap(UUID, *Entry).init(bun.defa

pub const Entry = struct {
blob: jsc.WebCore.Blob,
/// ScriptExecutionContext that registered this URL. Used to auto-revoke
/// all URLs created by a Worker when that Worker terminates, matching
/// the spec's "remove entries whose environment is the worker's settings
/// object" step and WebKit's BlobURLRegistry::unregisterURLsForContext.
context_id: u32,

pub const new = bun.TrivialNew(@This());
pub fn init(blob: *const jsc.WebCore.Blob) *Entry {
pub fn init(blob: *const jsc.WebCore.Blob, context_id: u32) *Entry {
return Entry.new(.{
.blob = blob.dupeWithContentType(true),
.context_id = context_id,
});
}

Expand All @@ -21,14 +27,39 @@ pub const Entry = struct {

pub fn register(this: *ObjectURLRegistry, vm: *jsc.VirtualMachine, blob: *const jsc.WebCore.Blob) UUID {
const uuid = vm.rareData().nextUUID();
const entry = Entry.init(blob);
const entry = Entry.init(blob, vm.scriptExecutionContextId());

this.lock.lock();
defer this.lock.unlock();
bun.handleOom(this.map.put(uuid, entry));
return uuid;
}

/// Revoke every blob URL registered by the given ScriptExecutionContext.
/// Called from worker teardown so entries created by a Worker don't leak
/// after the Worker is terminated.
pub fn revokeEntriesForContext(this: *ObjectURLRegistry, context_id: u32) void {
this.lock.lock();
defer this.lock.unlock();

// HashMap iteration does not support removal; collect keys first.
var to_remove: std.array_list.Managed(UUID) = .init(bun.default_allocator);
defer to_remove.deinit();

var it = this.map.iterator();
while (it.next()) |kv| {
if (kv.value_ptr.*.context_id == context_id) {
bun.handleOom(to_remove.append(kv.key_ptr.*));
}
}

for (to_remove.items) |uuid| {
if (this.map.fetchRemove(uuid)) |removed| {
removed.value.deinit();
}
}
}

pub fn singleton() *ObjectURLRegistry {
const Singleton = struct {
pub var registry: ObjectURLRegistry = undefined;
Expand Down
67 changes: 67 additions & 0 deletions test/js/web/workers/worker_blob.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect, test } from "bun:test";
import { resolveObjectURL } from "node:buffer";

test("Worker from a Blob", async () => {
const worker = new Worker(
Expand Down Expand Up @@ -124,3 +125,69 @@ 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 terminates", async () => {
// A blob URL created on the main thread; must survive the worker's teardown.
const parentUrl = URL.createObjectURL(new Blob(["from parent"]));

const worker = new Worker(
URL.createObjectURL(
new Blob(
[
`
const urls = [];
for (let i = 0; i < 4; i++) {
urls.push(URL.createObjectURL(new Blob(["from worker " + i])));
}
self.postMessage(urls);
// Stay alive until the parent terminates us.
setInterval(() => {}, 1_000_000);
`,
],
{ type: "application/javascript" },
),
),
);

const urls = await new Promise<string[]>(resolve => {
worker.onmessage = e => resolve(e.data);
});
expect(urls).toHaveLength(4);

// While the worker is alive, its blob URLs are resolvable from the parent.
const live = resolveObjectURL(urls[0]);
expect(live).toBeInstanceOf(Blob);
expect(await live!.text()).toBe("from worker 0");

const closed = new Promise<void>(resolve => worker.addEventListener("close", () => resolve(), { once: true }));
worker.terminate();
await closed;

// After termination, every URL the worker created must be auto-revoked.
// Previously these stayed in the process-global ObjectURLRegistry forever.
for (const url of urls) {
expect(resolveObjectURL(url)).toBeUndefined();
}

// URLs created by the parent context are unaffected.
const stillThere = resolveObjectURL(parentUrl);
expect(stillThere).toBeInstanceOf(Blob);
expect(await stillThere!.text()).toBe("from parent");
URL.revokeObjectURL(parentUrl);
});

test("blob URLs created inside a Worker are revoked when the worker exits naturally", async () => {
const worker = new Worker(
URL.createObjectURL(
new Blob([`self.postMessage(URL.createObjectURL(new Blob(["bye"])));`], { type: "application/javascript" }),
),
);

const url = await new Promise<string>(resolve => {
worker.onmessage = e => resolve(e.data);
});

await new Promise<void>(resolve => worker.addEventListener("close", () => resolve(), { once: true }));

expect(resolveObjectURL(url)).toBeUndefined();
});
Comment thread
robobun marked this conversation as resolved.
Loading