Files
browser/src/browser/webapi/BroadcastChannel.zig
Karl Seguin 2a7b399d90 refactor: Rework how v8::Globals are managed
We typically persist a v8::Value by calling persist() or temp() on our v8
wrappers. Both persist() and temp() create a v8::Global, but persist() tracks
it in the page's globals ArrayList while temp() tracks it in the page's temps
HashMap.

Globals are only freed when the page is torn down, hence a simple ArrayList is
all we need. Temps can be freed at any point, hence HashMap is used. (Temps are
also freed on teardown, but they can be freed before that too).

This commit removes Temp (js.Value.Temp, js.Promise.Temp, js.Function.Temp)
and all the mechinery around them (e.g. pseudo Global generic). There are now
only Globals and any global can be freed at any time (while still being tracked
in the page in order to be able to free any un-freed on page teardown).

Many files were touched because .Temp -> .Global and .temp() -> .persist().

This commit is based off the indexeddb branch because indexeddb introduced
"bare" globals (globals which aren't tracked by the Page). Building this new
model to include "Bare" globals avoids a messy merge later.
2026-07-08 10:48:18 +08:00

272 lines
10 KiB
Zig

// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
//
// Francis Bouvier <francis@lightpanda.io>
// Pierre Tachoire <pierre@lightpanda.io>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as
// published by the Free Software Foundation, either version 3 of the
// License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
const std = @import("std");
const lp = @import("lightpanda");
const js = @import("../js/js.zig");
const EventTarget = @import("EventTarget.zig");
const MessageEvent = @import("event/MessageEvent.zig");
const log = lp.log;
const Execution = js.Execution;
const BroadcastChannel = @This();
_proto: *EventTarget,
_exec: *Execution,
_name: lp.String,
_sequence: u64,
_closed: bool = false,
_on_message: ?js.Function.Global = null,
_on_message_error: ?js.Function.Global = null,
// Intrusive node, registered in `frame._broadcast_channels` (or WGS)
// for the lifetime of the channel (until close()). The registry is how a
// postMessage on one channel finds the other same-named channels to deliver to.
_node: std.DoublyLinkedList.Node = .{},
pub fn init(name: lp.String.Global, exec: *Execution) !*BroadcastChannel {
const page = exec.page;
const self = try exec._factory.eventTarget(BroadcastChannel{
._proto = undefined,
._exec = exec,
._name = name.str,
._sequence = page.broadcast_sequence,
});
page.broadcast_sequence += 1;
exec.getBroadcastChannels().append(&self._node);
return self;
}
pub fn asEventTarget(self: *BroadcastChannel) *EventTarget {
return self._proto;
}
pub fn getName(self: *const BroadcastChannel) lp.String {
return self._name;
}
// https://html.spec.whatwg.org/multipage/web-messaging.html#dom-broadcastchannel-postmessage
// The message is delivered asynchronously to every other BroadcastChannel
// object with the same name in the same agent cluster, never to the sender.
pub fn postMessage(self: *BroadcastChannel, message: js.Value, exec: *Execution) !void {
if (self._closed) {
return error.InvalidStateError;
}
// StructuredSerialize runs synchronously (per spec): clone the message once
// now so an unserializable value throws a DataCloneError to the caller.
const snapshot = blk: {
var ls: js.Local.Scope = undefined;
exec.js.localScope(&ls);
defer ls.deinit();
// Contain any V8 exception raised by a failed serialization so we can
// re-raise it as a clean DataCloneError (per spec) instead of leaking a
// stale pending exception into the caller. deinit() (no rethrow) clears it.
var try_catch: js.TryCatch = undefined;
try_catch.init(&ls.local);
defer try_catch.deinit();
const cloned = message.structuredCloneTo(&ls.local) catch {
return error.DataClone;
};
break :blk try cloned.persist();
};
errdefer snapshot.release();
const callback = try exec._factory.create(PostMessageCallback{
.exec = exec,
.sender = self,
.message = snapshot,
.post_sequence = exec.page.broadcast_sequence,
});
try exec.js.scheduler.add(callback, PostMessageCallback.run, 0, .{
.name = "BroadcastChannel.postMessage",
.low_priority = false,
.finalizer = PostMessageCallback.cancelled,
});
}
pub fn close(self: *BroadcastChannel) void {
if (self._closed) {
return;
}
self._closed = true;
self._exec.getBroadcastChannels().remove(&self._node);
}
pub fn getOnMessage(self: *const BroadcastChannel) ?js.Function.Global {
return self._on_message;
}
pub fn setOnMessage(self: *BroadcastChannel, cb: ?js.Function.Global) !void {
self._on_message = cb;
}
pub fn getOnMessageError(self: *const BroadcastChannel) ?js.Function.Global {
return self._on_message_error;
}
pub fn setOnMessageError(self: *BroadcastChannel, cb: ?js.Function.Global) !void {
self._on_message_error = cb;
}
const PostMessageCallback = struct {
sender: *BroadcastChannel,
// A self-owned structured-clone snapshot of the posted message. Re-cloned
// (never shared) into each receiver's MessageEvent, then released.
message: js.Value.Global,
exec: *Execution,
post_sequence: u64,
// Called by the scheduler if the task is dropped before it runs (e.g. page
// teardown). `run` and `cancelled` are mutually exclusive, so the snapshot
// is released exactly once.
fn cancelled(ctx: *anyopaque) void {
const self: *PostMessageCallback = @ptrCast(@alignCast(ctx));
self.message.release();
self.deinit();
}
fn deinit(self: *PostMessageCallback) void {
self.exec._factory.destroy(self);
}
fn run(ctx: *anyopaque) !?u32 {
const self: *PostMessageCallback = @ptrCast(@alignCast(ctx));
// LIFO: deinit() frees `self`, so it must run last; the snapshot is
// released after delivery (no MessageEvent owns it) but before deinit.
defer self.deinit();
defer self.message.release();
const sender = self.sender;
const page = self.exec.page;
const origin = self.exec.origin();
// MessageEvent.origin is the serialization of the sender's origin (same
// for every receiver); an opaque origin serializes to "null".
const sender_origin = origin orelse "null";
// Snapshot every same-origin global up front. Dispatch (below) runs user
// JS that can create or tear down frames/workers, so we must not hold a
// live frame-tree walk across it. Per-global channel lists are intrusive
// (never realloc) and teardown is deferred to the next tick, so walking
// each channel list live during dispatch is safe.
const arena = try page.getArena(.tiny, "BroadcastChannel.postMessage");
defer page.releaseArena(arena);
// Opaque origins have no string form and are unique per execution, so
// the sender is the only same-origin context
const executions = if (origin) |o|
try page.executionsForOrigin(arena, o)
else
(&self.exec)[0..1];
for (executions) |exec| {
// The MessageEvent and its cloned `data` must live in the receiver's
// realm, and its listeners are in the receiver's event manager.
// localScope enters the receiver's v8 context, so both happen there.
var ls: js.Local.Scope = undefined;
exec.js.localScope(&ls);
defer ls.deinit();
const snapshot = self.message.local(&ls.local);
var it = exec.getBroadcastChannels().*.first;
while (it) |node| : (it = node.next) {
const channel: *BroadcastChannel = @alignCast(@fieldParentPtr("_node", node));
// Never deliver to the sender, to closed channels, to channels
// created after this message was posted, or across names.
if (channel == sender or channel._closed) {
continue;
}
if (channel._sequence >= self.post_sequence) {
continue;
}
if (sender._name.eql(channel._name) == false) {
continue;
}
const target = channel.asEventTarget();
if (!exec.hasDirectListeners(target, "message", channel._on_message)) {
continue;
}
// Independent clone per receiver, in the receiver's realm. The
// snapshot is already plain, serializable data, so this cannot
// raise a DataCloneError. The resulting temp is owned by the
// MessageEvent, which releases it on teardown — so each receiver
// frees only its own handle.
const cloned = snapshot.structuredCloneTo(&ls.local) catch |err| {
log.err(.dom, "BroadcastChannel.postMessage", .{ .err = err });
continue;
};
const cloned_temp = cloned.persist() catch |err| {
log.err(.dom, "BroadcastChannel.postMessage", .{ .err = err });
continue;
};
const event = (MessageEvent.initTrusted(comptime .wrap("message"), .{
.data = .{ .value = cloned_temp },
.origin = sender_origin,
.source = null,
}, exec.page) catch |err| {
cloned_temp.release();
log.err(.dom, "BroadcastChannel.postMessage", .{ .err = err });
continue;
}).asEvent();
exec.dispatch(target, event, channel._on_message, .{ .context = "BroadcastChannel message" }) catch |err| {
log.err(.dom, "BroadcastChannel.postMessage", .{ .err = err });
};
}
}
return null;
}
};
pub const JsApi = struct {
pub const bridge = js.Bridge(BroadcastChannel);
pub const Meta = struct {
pub const name = "BroadcastChannel";
pub var class_id: bridge.ClassId = undefined;
pub const prototype_chain = bridge.prototypeChain();
};
pub const constructor = bridge.constructor(BroadcastChannel.init, .{});
pub const name = bridge.accessor(BroadcastChannel.getName, null, .{});
pub const postMessage = bridge.function(BroadcastChannel.postMessage, .{});
pub const close = bridge.function(BroadcastChannel.close, .{});
pub const onmessage = bridge.accessor(BroadcastChannel.getOnMessage, BroadcastChannel.setOnMessage, .{});
pub const onmessageerror = bridge.accessor(BroadcastChannel.getOnMessageError, BroadcastChannel.setOnMessageError, .{});
};
const testing = @import("../../testing.zig");
test "WebApi: BroadcastChannel" {
try testing.htmlRunner("broadcast_channel.html", .{});
}