From f00c0ab276bb80a9bb4f783010f2f85ee96b39ba Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Fri, 15 May 2026 13:11:59 +0200 Subject: [PATCH] webmcp: implement abortSignal with _dependent --- src/Notification.zig | 3 +- src/browser/webapi/AbortSignal.zig | 34 ++++++++++++++--- src/browser/webapi/ModelContext.zig | 57 ++++++++++++++++++----------- src/cdp/domains/webmcp.zig | 19 +++++++--- 4 files changed, 81 insertions(+), 32 deletions(-) diff --git a/src/Notification.zig b/src/Notification.zig index d98f3ecc..ccfd886f 100644 --- a/src/Notification.zig +++ b/src/Notification.zig @@ -25,6 +25,7 @@ const Transfer = @import("browser/HttpClient.zig").Transfer; const Response = @import("browser/HttpClient.zig").Response; const log = lp.log; +const Execution = js.Execution; const List = std.DoublyLinkedList; const Allocator = std.mem.Allocator; @@ -231,7 +232,7 @@ pub const JavascriptDialogOpening = struct { }; pub const ModelContextToolEvent = struct { - frame: *Frame, + exec: *const Execution, tool: *const ModelContextTool, }; diff --git a/src/browser/webapi/AbortSignal.zig b/src/browser/webapi/AbortSignal.zig index 31a3f6a5..f6bd450f 100644 --- a/src/browser/webapi/AbortSignal.zig +++ b/src/browser/webapi/AbortSignal.zig @@ -24,18 +24,43 @@ const js = @import("../js/js.zig"); const Event = @import("Event.zig"); const EventTarget = @import("EventTarget.zig"); const DOMException = @import("DOMException.zig"); +const ModelContextTool = @import("ModelContext.zig").Tool; const log = lp.log; const Execution = js.Execution; const AbortSignal = @This(); +const Dependend = union(enum) { + signal: *AbortSignal, + model_context_tool: *ModelContextTool, + + fn markAborted(self: Dependend, reason_: ?Reason, exec: *const Execution) !void { + switch (self) { + .signal => |dep| { + if (dep._aborted) return; + try dep.markAborted(reason_, exec); + }, + .model_context_tool => |dep| { + try dep.markAborted(exec); + }, + } + } + + fn dispatchAbortEvent(self: Dependend, exec: *const Execution) !void { + switch (self) { + .signal => |dep| try dep.dispatchAbortEvent(exec), + .model_context_tool => {}, + } + } +}; + _proto: *EventTarget, _aborted: bool = false, _is_dependent: bool = false, _reason: Reason = .undefined, _on_abort: ?js.Function.Global = null, -_dependents: std.ArrayList(*AbortSignal) = .{}, +_dependents: std.ArrayList(Dependend) = .{}, _source_signals: std.ArrayList(*AbortSignal) = .{}, pub fn init(exec: *const Execution) !*AbortSignal { @@ -74,9 +99,8 @@ pub fn abort(self: *AbortSignal, reason_: ?Reason, exec: *const Execution) !void // Per spec: mark all direct dependents aborted (with this signal's reason) // BEFORE firing any abort events. The graph is flattened at any() creation, // so we never need to recurse here. - var to_dispatch: std.ArrayList(*AbortSignal) = .{}; + var to_dispatch: std.ArrayList(Dependend) = .{}; for (self._dependents.items) |dep| { - if (dep._aborted) continue; try dep.markAborted(self._reason, exec); try to_dispatch.append(exec.arena, dep); } @@ -136,11 +160,11 @@ pub fn createAny(signals: []const *AbortSignal, exec: *const Execution) !*AbortS for (signals) |source| { if (!source._is_dependent) { - try source._dependents.append(exec.arena, result); + try source._dependents.append(exec.arena, .{ .signal = result }); try result._source_signals.append(exec.arena, source); } else { for (source._source_signals.items) |s| { - try s._dependents.append(exec.arena, result); + try s._dependents.append(exec.arena, .{ .signal = result }); try result._source_signals.append(exec.arena, s); } } diff --git a/src/browser/webapi/ModelContext.zig b/src/browser/webapi/ModelContext.zig index be568196..c4890fe9 100644 --- a/src/browser/webapi/ModelContext.zig +++ b/src/browser/webapi/ModelContext.zig @@ -31,10 +31,10 @@ const std = @import("std"); const js = @import("../js/js.zig"); -const Frame = @import("../Frame.zig"); const Notification = @import("../../Notification.zig"); const AbortSignal = @import("AbortSignal.zig"); +const Execution = js.Execution; pub fn registerTypes() []const type { return &.{ ModelContext, ModelContextClient }; @@ -56,6 +56,7 @@ pub const Annotations = struct { }; pub const Tool = struct { + ctx: *ModelContext, name: []const u8, title: ?[]const u8, description: []const u8, @@ -66,6 +67,10 @@ pub const Tool = struct { // fires. Checked lazily on each `tools()` / `findTool()` call — fine // for headless usage where there's no synchronous observer to notify. signal: ?*AbortSignal, + + pub fn markAborted(self: *Tool, exec: *const Execution) !void { + try self.ctx.markAborted(self, exec); + } }; const ToolDict = struct { @@ -85,7 +90,7 @@ pub fn registerTool( self: *ModelContext, tool: ToolDict, options_: ?RegisterToolOptions, - frame: *Frame, + exec: *const Execution, ) !void { try validateName(tool.name); if (tool.description.len == 0) { @@ -104,16 +109,16 @@ pub fn registerTool( // Reject duplicate names. The spec says `InvalidStateError`. We compact // the list lazily here so a tool whose signal already aborted doesn't // block re-registering under the same name. - self.compactAborted(frame); for (self._tools.items) |existing| { if (std.mem.eql(u8, existing.name, tool.name)) { return error.InvalidStateError; } } - const arena = frame.arena; + const arena = exec.arena; const entry = try arena.create(Tool); entry.* = .{ + .ctx = self, .name = try arena.dupe(u8, tool.name), .title = if (tool.title) |t| try arena.dupe(u8, t) else null, .description = try arena.dupe(u8, tool.description), @@ -123,25 +128,31 @@ pub fn registerTool( .signal = options.signal, }; + if (entry.signal) |s| { + try s._dependents.append(arena, .{ .model_context_tool = entry }); + } try self._tools.append(arena, entry); // Fire `model_context_tool_added` so observers (CDP `WebMCP` domain, // native MCP forwarder) can surface the new tool. - const event: Notification.ModelContextToolEvent = .{ .frame = frame, .tool = entry }; - frame._session.notification.dispatch(.model_context_tool_added, &event); + const event: Notification.ModelContextToolEvent = .{ .exec = exec, .tool = entry }; + + const session = switch (exec.context.global) { + inline else => |g| g._session, + }; + + session.notification.dispatch(.model_context_tool_added, &event); } /// Snapshot of currently-registered tools, with aborted entries filtered. /// Used by the CDP `WebMCP.enable` replay and the native MCP forwarder. -pub fn tools(self: *ModelContext, frame: *Frame) []const *Tool { - self.compactAborted(frame); +pub fn tools(self: *ModelContext) []const *Tool { return self._tools.items; } /// Look up a tool by name. Returns null if not found or if its signal has /// fired. Used by CDP `WebMCP.invokeTool`. -pub fn findTool(self: *ModelContext, frame: *Frame, name: []const u8) ?*Tool { - self.compactAborted(frame); +pub fn findTool(self: *ModelContext, name: []const u8) ?*Tool { for (self._tools.items) |t| { if (std.mem.eql(u8, t.name, name)) return t; } @@ -151,17 +162,19 @@ pub fn findTool(self: *ModelContext, frame: *Frame, name: []const u8) ?*Tool { /// Walk the tool list and remove any whose `AbortSignal` has fired, /// dispatching `model_context_tool_removed` for each. Cheap when no /// signals fired (which is the common case). -fn compactAborted(self: *ModelContext, frame: *Frame) void { +fn markAborted(self: *ModelContext, tool: *Tool, exec: *const Execution) !void { + const session = switch (exec.context.global) { + inline else => |g| g._session, + }; + var i: usize = 0; while (i < self._tools.items.len) { const t = self._tools.items[i]; - if (t.signal) |signal| { - if (signal._aborted) { - _ = self._tools.swapRemove(i); - const event: Notification.ModelContextToolEvent = .{ .frame = frame, .tool = t }; - frame._session.notification.dispatch(.model_context_tool_removed, &event); - continue; - } + if (t == tool) { + _ = self._tools.swapRemove(i); + const event: Notification.ModelContextToolEvent = .{ .exec = exec, .tool = t }; + session.notification.dispatch(.model_context_tool_removed, &event); + return; } i += 1; } @@ -191,10 +204,12 @@ pub const ModelContextClient = struct { pub fn requestUserInteraction( _: *ModelContextClient, callback: js.Function, - frame: *Frame, + exec: *const Execution, ) !js.Promise { - const local = frame.js.local.?; - const resolver = local.createPromiseResolver(); + var ls: js.Local.Scope = undefined; + exec.context.global.getJs().localScope(&ls); + defer ls.deinit(); + const resolver = ls.local.createPromiseResolver(); var caught: js.TryCatch.Caught = undefined; if (callback.tryCall(js.Value, .{}, &caught)) |result| { diff --git a/src/cdp/domains/webmcp.zig b/src/cdp/domains/webmcp.zig index d35fe26a..eb591b12 100644 --- a/src/cdp/domains/webmcp.zig +++ b/src/cdp/domains/webmcp.zig @@ -65,7 +65,7 @@ fn enable(cmd: *CDP.Command) !void { // frame only; subframes will be added when they register. if (bc.session.currentFrame()) |frame| { const mc = frame.window.getModelContext(); - const tools = mc.tools(frame); + const tools = mc.tools(); if (tools.len > 0) { var ls: js.Local.Scope = undefined; frame.js.localScope(&ls); @@ -102,7 +102,7 @@ fn invokeTool(cmd: *CDP.Command) !void { const frame_id = try id.parseFrameId(params.frameId); const frame = bc.session.findFrameByFrameId(frame_id) orelse return error.FrameNotFound; const mc = frame.window.getModelContext(); - const tool = mc.findTool(frame, params.toolName) orelse return error.NotFound; + const tool = mc.findTool(params.toolName) orelse return error.NotFound; // Stringify the input once. We send it back to the client via // `toolInvoked.input` and pass the parsed form into the JS callback. @@ -256,12 +256,18 @@ pub fn onToolAdded( bc: *CDP.BrowserContext, event: *const Notification.ModelContextToolEvent, ) !void { + const global = event.exec.context.global; + var ls: js.Local.Scope = undefined; - event.frame.js.localScope(&ls); + global.getJs().localScope(&ls); defer ls.deinit(); + const frame_id = switch (global) { + inline else => |g| g._frame_id, + }; + const writer = ToolWriter{ - .frame_id = id.toFrameId(event.frame._frame_id), + .frame_id = id.toFrameId(frame_id), .tools = &.{event.tool}, .local = &ls.local, }; @@ -274,9 +280,12 @@ pub fn onToolRemoved( bc: *CDP.BrowserContext, event: *const Notification.ModelContextToolEvent, ) !void { + const frame_id = switch (event.exec.context.global) { + inline else => |g| g._frame_id, + }; try bc.cdp.sendEvent("WebMCP.toolsRemoved", .{ .tools = &.{ - .{ .name = event.tool.name, .frameId = id.toFrameId(event.frame._frame_id) }, + .{ .name = event.tool.name, .frameId = id.toFrameId(frame_id) }, }, }, .{ .session_id = bc.session_id }); }