Files
browser/src/browser/webapi/SharedWorkerGlobalScope.zig
T
Karl Seguin bdce49a4b5 refactor: Introduce Arena wrapper
Introduces a ArenaAllocator wrappre (lp.Arena). This is groundwork for better
memory tracking and reporting memory usage to v8. This is almost purely a
mechanical change to lay a foundation for a follow up PR that will address
https://github.com/lightpanda-io/browser/issues/3027

Some code became a bit leaner: a pooled arena can release itself (it has a
reference to the ArenaPool).

Some code became uglier: The Frame has a `_local_arena: *lp.Arena` and a
`local_arena: Allocator` (same with call_arena, and same with a few other types)
so that consumers aren't impacted (they continue to use `frame.local_arena`).
2026-07-30 13:02:13 +08:00

412 lines
14 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 Frame = @import("../Frame.zig");
const Transfer = @import("../../network/HttpClient.zig").Transfer;
const Worker = @import("Worker.zig");
const MessagePort = @import("MessagePort.zig");
const WorkerGlobalScope = @import("WorkerGlobalScope.zig");
const MessageEvent = @import("event/MessageEvent.zig");
const log = lp.log;
const IS_DEBUG = @import("builtin").mode == .Debug;
const SharedWorkerGlobalScope = @This();
pub const Proto = WorkerGlobalScope;
_proto: *WorkerGlobalScope,
_arena: *lp.Arena,
_url: [:0]const u8,
_name: []const u8,
_type: Worker.WorkerType,
// Key under which this scope is registered in session.shared_workers. Empty
// until registered; deinit/close use it to unregister.
_registry_key: []const u8 = "",
// used by HttpClient when generating notification
// Ultimately used by CDP to generate request/loader ids.
_frame_id: u32,
_loader_id: u32,
_closed: bool = false,
_script_loaded: bool = false,
_script_arena: ?*lp.Arena = null,
_script_buffer: std.ArrayList(u8) = .empty,
_http_transfer: ?*Transfer = null,
_on_connect: ?js.Function.Global = null,
// Ports pending connnection, created before the initial script finished
_pending_connects: std.ArrayList(*MessagePort) = .empty,
pub fn init(frame: *Frame, url: [:0]const u8, name: []const u8, worker_type: Worker.WorkerType) !*SharedWorkerGlobalScope {
const session = frame._session;
const arena = try session.getArena(.small, "SharedWorker");
errdefer arena.release();
const owned_url = try arena.dupeZ(u8, url);
const frame_id = session.nextFrameId();
const loader_id = session.nextLoaderId();
const self = try WorkerGlobalScope.init(
arena.allocator(),
owned_url,
.shared,
SharedWorkerGlobalScope{
._proto = undefined,
._arena = arena,
._url = owned_url,
._name = try arena.dupe(u8, name),
._type = worker_type,
._frame_id = frame_id,
._loader_id = loader_id,
},
worker_type == .module,
frame_id,
loader_id,
frame,
);
const proto = self._proto;
errdefer proto.deinit();
if (!session.worker_loading_enabled) {
log.debug(.browser, "shared worker disabled", .{ .url = owned_url });
return self;
}
self._script_arena = try session.getArena(.large, "SharedWorker.script");
errdefer self.releaseScriptArena();
const transfer = proto.newRequest(.{
.ctx = self,
.method = .GET,
.url = owned_url,
.frame_id = self._frame_id,
.loader_id = self._loader_id,
.resource_type = .script,
.cookie_jar = &session.cookie_jar,
.cookie_origin = owned_url,
.notification = session.notification,
.header_callback = httpHeaderCallback,
.data_callback = httpDataCallback,
.done_callback = httpDoneCallback,
.error_callback = httpErrorCallback,
.shutdown_callback = httpShutdownCallback,
}) catch |err| {
log.err(.browser, "SharedWorker request", .{ .url = owned_url, .err = err });
return err;
};
// Held for deinit's abort; the done, error and shutdown callbacks clear
// it. The shutdown one matters: WorkerGlobalScope.deinit's abortOwner
// kills the transfer, and deinit must not abort a freed transfer.
self._http_transfer = transfer;
transfer.submit() catch |err| {
log.err(.browser, "SharedWorker request", .{ .url = owned_url, .err = err });
return err;
};
return self;
}
// Called from Page.deinit of the owning (creating) page.
pub fn deinit(self: *SharedWorkerGlobalScope) void {
if (self._http_transfer) |transfer| {
transfer.abort(error.Abort);
self._http_transfer = null;
}
self.releaseScriptArena();
self.unregister();
self._proto.deinit();
self._arena.release();
}
pub fn register(self: *SharedWorkerGlobalScope, lookup_key: []const u8) !void {
// The key lives in our arena: every removal path (deinit, close) unregisters
// before the arena is released.
const key = try self._arena.dupe(u8, lookup_key);
const session = self._proto._session;
try session.shared_workers.put(session.arena.allocator(), key, self);
self._registry_key = key;
}
fn unregister(self: *SharedWorkerGlobalScope) void {
if (self._registry_key.len == 0) {
return;
}
_ = self._proto._session.shared_workers.remove(self._registry_key);
self._registry_key = "";
}
// Establishes a new connection from a client context: creates the entangled
// port pair and queues the connect event. Returns the client's end.
pub fn connect(self: *SharedWorkerGlobalScope, client_exec: *js.Execution) !*MessagePort {
const client_port = try MessagePort.init(client_exec);
const worker_port = try MessagePort.init(&self._proto.js.execution);
MessagePort.entangle(client_port, worker_port);
if (self._script_loaded == false) {
try self._pending_connects.append(self._arena.allocator(), worker_port);
return client_port;
}
try self.scheduleConnect(worker_port);
return client_port;
}
pub fn getName(self: *const SharedWorkerGlobalScope) []const u8 {
return self._name;
}
pub fn close(self: *SharedWorkerGlobalScope) void {
// Once closed, new SharedWorker(url, name) must create a fresh instance.
self.unregister();
// TODO: we should also stop new tasks from being scheduled
self._proto._session.idb.detachContext(self._proto.js);
self._proto.js.scheduler.reset();
self._closed = true;
}
pub fn getOnConnect(self: *const SharedWorkerGlobalScope) ?js.Function.Global {
return self._on_connect;
}
pub fn setOnConnect(self: *SharedWorkerGlobalScope, setter: ?WorkerGlobalScope.FunctionSetter) void {
self._on_connect = WorkerGlobalScope.getFunctionFromSetter(setter);
}
fn httpHeaderCallback(transfer: *Transfer) !Transfer.HeaderResult {
const self: *SharedWorkerGlobalScope = @ptrCast(@alignCast(transfer.req.ctx));
const status = transfer.responseStatus() orelse return .abort;
if (status < 200 or status >= 300) {
log.warn(.browser, "SharedWorker status", .{
.url = self._url,
.status = status,
});
return .abort;
}
if (transfer.getContentLength()) |cl| {
try self._script_buffer.ensureTotalCapacity(self._script_arena.?.allocator(), cl);
}
return .proceed;
}
fn httpDataCallback(transfer: *Transfer, data: []const u8) !void {
const self: *SharedWorkerGlobalScope = @ptrCast(@alignCast(transfer.req.ctx));
try self._script_buffer.appendSlice(self._script_arena.?.allocator(), data);
}
fn httpDoneCallback(ctx: *anyopaque) !void {
const self: *SharedWorkerGlobalScope = @ptrCast(@alignCast(ctx));
self._http_transfer = null;
defer self.releaseScriptArena();
const url = self._url;
const script = self._script_buffer.items;
if (comptime IS_DEBUG) {
log.info(.browser, "shared worker fetch done", .{
.url = url,
.len = script.len,
});
}
try self.loadInitialScript(script);
}
fn httpShutdownCallback(ctx: *anyopaque) void {
const self: *SharedWorkerGlobalScope = @ptrCast(@alignCast(ctx));
self._http_transfer = null;
self.releaseScriptArena();
}
fn httpErrorCallback(ctx: *anyopaque, err: anyerror) void {
const self: *SharedWorkerGlobalScope = @ptrCast(@alignCast(ctx));
self._http_transfer = null;
self.releaseScriptArena();
log.err(.browser, "shared worker fetch error", .{
.url = self._url,
.err = err,
});
// The worker will never load and onconnect will never be registered.
// Drain the buffered connects so they get dispatched (and dropped at the
// "no listener" check) rather than accumulating until teardown.
self._script_loaded = true;
self.drainPendingConnects();
}
fn loadInitialScript(self: *SharedWorkerGlobalScope, script: []const u8) !void {
const js_context = self._proto.js;
if (js_context.env.terminatePending()) {
return;
}
// The flip-and-drain runs after eval (and the trailing runMacrotasks) —
// by which point the outer script has had its only chance to register
// onconnect. On eval-throw the defer still fires; the connect events get
// scheduled and then drop at the "no listener" check.
defer {
self._script_loaded = true;
self.drainPendingConnects();
}
var ls: js.Local.Scope = undefined;
js_context.localScope(&ls);
defer ls.deinit();
var try_catch: js.TryCatch = undefined;
try_catch.init(&ls.local);
defer try_catch.deinit();
switch (self._type) {
.classic => _ = ls.local.eval(script, self._url) catch |err| {
if (js_context.env.terminatePending()) {
return;
}
js_context.page.recordJsError(err);
const caught = try_catch.caughtOrError(self._script_arena.?.allocator(), err);
log.err(.browser, "shared worker script error", .{ .url = self._url, .caught = caught });
return;
},
.module => js_context.module(false, &ls.local, script, self._url, true) catch |err| {
if (js_context.env.terminatePending()) {
return;
}
js_context.page.recordJsError(err);
const caught = try_catch.caughtOrError(self._script_arena.?.allocator(), err);
log.err(.browser, "shared worker module error", .{ .url = self._url, .caught = caught });
return;
},
}
ls.local.runMacrotasks();
}
// Idempotent: reached from the done, error and shutdown callbacks and from
// deinit (the abort there re-enters via the error callback).
fn releaseScriptArena(self: *SharedWorkerGlobalScope) void {
const arena = self._script_arena orelse return;
self._script_arena = null;
self._script_buffer = .empty;
arena.release();
}
fn drainPendingConnects(self: *SharedWorkerGlobalScope) void {
for (self._pending_connects.items) |port| {
self.scheduleConnect(port) catch |err| {
log.warn(.browser, "shared worker drain connect failed", .{ .err = err });
};
}
self._pending_connects.clearRetainingCapacity();
}
fn scheduleConnect(self: *SharedWorkerGlobalScope, port: *MessagePort) !void {
const wgs = self._proto;
const session = wgs._session;
const connect_arena = try session.getArena(.tiny, "SharedWorkerGlobalScope.connect");
errdefer connect_arena.release();
const callback = try connect_arena.create(ConnectCallback);
callback.* = .{
.port = port,
.worker_scope = self,
.arena = connect_arena,
};
try wgs.js.scheduler.add(callback, ConnectCallback.run, 0, .{
.name = "SharedWorkerGlobalScope.connect",
.low_priority = false,
.finalizer = ConnectCallback.cancelled,
});
}
const ConnectCallback = struct {
port: *MessagePort,
arena: *lp.Arena,
worker_scope: *SharedWorkerGlobalScope,
fn cancelled(ctx: *anyopaque) void {
const self: *ConnectCallback = @ptrCast(@alignCast(ctx));
self.deinit();
}
fn deinit(self: *ConnectCallback) void {
self.arena.release();
}
fn run(ctx: *anyopaque) !?u32 {
const self: *ConnectCallback = @ptrCast(@alignCast(ctx));
defer self.deinit();
const worker_scope = self.worker_scope;
const wgs = worker_scope._proto;
const target = wgs.asEventTarget();
const on_connect = worker_scope._on_connect;
if (!wgs._event_manager.hasDirectListeners(target, "connect", on_connect)) {
return null;
}
// Per spec the connect event's data is the empty string and the new
// port is both the sole entry of `ports` and the `source`.
const event = (try MessageEvent.initTrusted(comptime .wrap("connect"), .{
.data = .{ .string = "" },
.source = .{ .port = self.port },
.ports = &.{self.port},
.bubbles = false,
.cancelable = false,
}, wgs._page)).asEvent();
try wgs.dispatch(target, event, on_connect, .{ .context = "SharedWorkerGlobalScope.connect" });
return null;
}
};
pub const JsApi = struct {
pub const bridge = js.Bridge(SharedWorkerGlobalScope);
pub const Meta = struct {
pub const name = "SharedWorkerGlobalScope";
pub const prototype_chain = bridge.prototypeChain();
pub var class_id: bridge.ClassId = undefined;
};
pub const name = bridge.accessor(SharedWorkerGlobalScope.getName, null, .{});
pub const close = bridge.function(SharedWorkerGlobalScope.close, .{});
pub const onconnect = bridge.accessor(SharedWorkerGlobalScope.getOnConnect, SharedWorkerGlobalScope.setOnConnect, .{});
};