mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-09-19 10:59:28 -04:00
Merge pull request #3312 from lightpanda-io/tool-session
mcp: fold Server.Session onto lp.ToolSession, bracket every isolate use
This commit is contained in:
6 files changed
+216
-154
No files matched your search
@@ -0,0 +1,115 @@
|
||||
// Copyright (C) 2023-2025 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/>.
|
||||
|
||||
//! An isolated browsing context driven through `lp.tools`: its own Browser
|
||||
//! (hence V8 isolate), Session, Notification and node Registry. `self` must
|
||||
//! not move after `init` — Browser registers self-pointers.
|
||||
|
||||
const App = @import("App.zig");
|
||||
const Browser = @import("browser/Browser.zig");
|
||||
const Session = @import("browser/Session.zig");
|
||||
const Notification = @import("Notification.zig");
|
||||
const CDPNode = @import("cdp/Node.zig");
|
||||
|
||||
const ToolSession = @This();
|
||||
|
||||
browser: Browser,
|
||||
session: *Session,
|
||||
notification: *Notification,
|
||||
registry: CDPNode.Registry,
|
||||
|
||||
/// Leaves the browser's isolate entered, like `Browser.init`; callers
|
||||
/// sharing one thread between several isolates park it with
|
||||
/// `exitIsolate` afterwards.
|
||||
pub fn init(self: *ToolSession, app: *App) !void {
|
||||
self.notification = try .init(app.allocator);
|
||||
errdefer self.notification.deinit();
|
||||
|
||||
self.registry = .init(app.allocator);
|
||||
errdefer self.registry.deinit();
|
||||
|
||||
try self.browser.init(app, .{}, null);
|
||||
errdefer self.browser.deinit();
|
||||
|
||||
try self.restartSession();
|
||||
}
|
||||
|
||||
/// Replace the session with a fresh one on the same browser: page, cookies,
|
||||
/// storage and history gone. The old `session` pointer is invalid afterwards.
|
||||
pub fn restartSession(self: *ToolSession) !void {
|
||||
self.session = try self.browser.newSession(self.notification);
|
||||
try self.session.enableConsoleCapture();
|
||||
}
|
||||
|
||||
/// The isolate must be current (`enterIsolate` if parked): Browser.deinit's
|
||||
/// Env.deinit exit has to balance against this context's isolate.
|
||||
pub fn deinit(self: *ToolSession) void {
|
||||
self.registry.deinit();
|
||||
self.browser.deinit();
|
||||
self.notification.deinit();
|
||||
}
|
||||
|
||||
/// V8's "current isolate" is a per-thread stack: when several contexts
|
||||
/// share a thread, bracket any use of the Browser/Session with
|
||||
/// enterIsolate/exitIsolate and leave it un-entered otherwise.
|
||||
pub fn enterIsolate(self: *ToolSession) void {
|
||||
self.browser.env.isolate.enter();
|
||||
}
|
||||
|
||||
pub fn exitIsolate(self: *ToolSession) void {
|
||||
self.browser.env.isolate.exit();
|
||||
}
|
||||
|
||||
const tools = @import("browser/tools.zig");
|
||||
const testing = @import("testing.zig");
|
||||
|
||||
test "ToolSession: isolates interleave on one thread and tear down balanced" {
|
||||
const arena = testing.arena_allocator;
|
||||
|
||||
var a: ToolSession = undefined;
|
||||
try a.init(testing.test_app);
|
||||
try loadBlank(&a);
|
||||
a.exitIsolate();
|
||||
|
||||
var b: ToolSession = undefined;
|
||||
try b.init(testing.test_app);
|
||||
try loadBlank(&b);
|
||||
b.exitIsolate();
|
||||
|
||||
a.enterIsolate();
|
||||
try testing.expectEqual("a", (try tools.evalScript(arena, a.session, &a.registry, "globalThis.tag = 'a'")).text);
|
||||
a.exitIsolate();
|
||||
|
||||
b.enterIsolate();
|
||||
try testing.expectEqual("undefined", (try tools.evalScript(arena, b.session, &b.registry, "String(globalThis.tag)")).text);
|
||||
b.exitIsolate();
|
||||
|
||||
a.enterIsolate();
|
||||
try testing.expectEqual("a", (try tools.evalScript(arena, a.session, &a.registry, "globalThis.tag")).text);
|
||||
a.deinit();
|
||||
|
||||
b.enterIsolate();
|
||||
b.deinit();
|
||||
}
|
||||
|
||||
fn loadBlank(ts: *ToolSession) !void {
|
||||
const page = try ts.session.createPage();
|
||||
try page.navigate("about:blank", .{});
|
||||
var runner = ts.session.runner(.{});
|
||||
try runner.waitForFrame(page.frame_id, 2000, .{ .until = .done });
|
||||
}
|
||||
+23
-38
@@ -32,7 +32,6 @@ const ScriptRuntime = lp.Runtime;
|
||||
const Candidate = zenai.provider.Candidate;
|
||||
|
||||
const App = @import("../App.zig");
|
||||
const CDPNode = @import("../cdp/Node.zig");
|
||||
const Conversation = @import("Conversation.zig");
|
||||
const Terminal = @import("Terminal.zig");
|
||||
const SlashCommand = @import("SlashCommand.zig");
|
||||
@@ -149,10 +148,7 @@ model_base_url: ?[:0]const u8,
|
||||
/// `model_completion_arena`; invalidated on `/provider` switch.
|
||||
model_completions: ?ModelCompletions,
|
||||
model_completion_arena: std.heap.ArenaAllocator,
|
||||
notification: *lp.Notification,
|
||||
browser: lp.Browser,
|
||||
session: *lp.Session,
|
||||
node_registry: CDPNode.Registry,
|
||||
ts: lp.ToolSession,
|
||||
terminal: Terminal,
|
||||
save_buffer: Recorder,
|
||||
save_path: ?[]u8,
|
||||
@@ -308,9 +304,6 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent
|
||||
std.debug.print("\n", .{});
|
||||
}
|
||||
|
||||
const notification: *lp.Notification = try .init(allocator);
|
||||
errdefer notification.deinit();
|
||||
|
||||
const self = try allocator.create(Agent);
|
||||
errdefer allocator.destroy(self);
|
||||
|
||||
@@ -328,10 +321,7 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent
|
||||
.model_base_url = opts.base_url,
|
||||
.model_completions = null,
|
||||
.model_completion_arena = .init(allocator),
|
||||
.notification = notification,
|
||||
.browser = undefined,
|
||||
.session = undefined,
|
||||
.node_registry = .init(allocator),
|
||||
.ts = undefined,
|
||||
.terminal = .init(allocator, history_paths, verbosity, will_repl),
|
||||
.save_buffer = .init(allocator),
|
||||
.save_path = null,
|
||||
@@ -345,16 +335,14 @@ pub fn init(allocator: std.mem.Allocator, app: *App, opts: Config.Agent) !*Agent
|
||||
.one_shot_attachments = if (opts.attach.items.len == 0) null else opts.attach.items,
|
||||
.available_providers = available_providers,
|
||||
};
|
||||
errdefer self.node_registry.deinit();
|
||||
errdefer self.terminal.deinit();
|
||||
errdefer self.conversation.deinit();
|
||||
self.terminal.installLogSink();
|
||||
errdefer self.terminal.uninstallLogSink();
|
||||
|
||||
try self.browser.init(app, .{}, null);
|
||||
errdefer self.browser.deinit();
|
||||
|
||||
try self.startSession();
|
||||
try self.ts.init(app);
|
||||
errdefer self.ts.deinit();
|
||||
self.installCancelHook();
|
||||
|
||||
self.ai_client = if (self.credential) |*c| try zenai.provider.Client.init(lp.io, allocator, c.provider, c.keySlice(), .{ .base_url = opts.base_url, .retry_policy = .long_running, .bill_to = hfBillTo(c.provider), .environ = lp.environ(), .account_id = c.accountId() }) else null;
|
||||
errdefer if (self.ai_client) |c| c.deinit(allocator);
|
||||
@@ -381,9 +369,7 @@ pub fn deinit(self: *Agent) void {
|
||||
self.terminal.deinit();
|
||||
self.conversation.deinit();
|
||||
self.model_completion_arena.deinit();
|
||||
self.node_registry.deinit();
|
||||
self.browser.deinit();
|
||||
self.notification.deinit();
|
||||
self.ts.deinit();
|
||||
if (self.ai_client) |ai_client| ai_client.deinit(self.allocator);
|
||||
if (self.credential) |*c| c.deinit(self.allocator);
|
||||
self.allocator.free(self.model);
|
||||
@@ -395,15 +381,13 @@ pub fn deinit(self: *Agent) void {
|
||||
/// isocline idle hook; returns the delay in ms before the next invocation.
|
||||
fn idlePump(arg: ?*anyopaque) callconv(.c) c_long {
|
||||
const self: *Agent = @ptrCast(@alignCast(arg.?));
|
||||
return self.session.idleSlice();
|
||||
return self.ts.session.idleSlice();
|
||||
}
|
||||
|
||||
/// Create a fresh browser session and wire its cancel hook back to this agent
|
||||
/// so Ctrl-C aborts in-flight page work. Startup and `/reset`.
|
||||
fn startSession(self: *Agent) !void {
|
||||
self.session = try self.browser.newSession(self.notification);
|
||||
self.session.cancel_hook = .{ .context = @ptrCast(self), .check = checkCancel };
|
||||
try self.session.enableConsoleCapture();
|
||||
/// Wire the session's cancel hook back to this agent so Ctrl-C aborts
|
||||
/// in-flight page work. Startup and `/reset`.
|
||||
fn installCancelHook(self: *Agent) void {
|
||||
self.ts.session.cancel_hook = .{ .context = @ptrCast(self), .check = checkCancel };
|
||||
}
|
||||
|
||||
// Compile-time constant; projected once per process to avoid rebuilding per call.
|
||||
@@ -435,7 +419,7 @@ pub fn requestCancel(self: *Agent) void {
|
||||
runtime.terminate();
|
||||
}
|
||||
}
|
||||
self.browser.env.terminate();
|
||||
self.ts.browser.env.terminate();
|
||||
}
|
||||
|
||||
/// Lives in main's stack so it can be registered with the sighandler before the
|
||||
@@ -477,7 +461,7 @@ fn drainCancellation(self: *Agent, baseline: usize) error{UserCancelled} {
|
||||
fn resetAfterCancel(self: *Agent, baseline: usize) void {
|
||||
self.endStreamedText();
|
||||
self.conversation.rollback(baseline);
|
||||
self.browser.env.cancelTerminate();
|
||||
self.ts.browser.env.cancelTerminate();
|
||||
self.cancel_requested.store(false, .release);
|
||||
self.http_interrupt.reset();
|
||||
}
|
||||
@@ -612,7 +596,7 @@ fn runRepl(self: *Agent) void {
|
||||
// Slash commands and idle Ctrl-C set the cancel flag without clearing
|
||||
// V8's terminate state; drain both before the next turn.
|
||||
if (self.cancel_requested.swap(false, .acq_rel)) {
|
||||
self.browser.env.cancelTerminate();
|
||||
self.ts.browser.env.cancelTerminate();
|
||||
}
|
||||
|
||||
const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace);
|
||||
@@ -630,7 +614,7 @@ fn runRepl(self: *Agent) void {
|
||||
// `line` keeps the `$LP_*` placeholder so the secret never reaches
|
||||
// the recorder; only the evaluated copy is expanded.
|
||||
const script = browser_tools.substituteEnvVars(aa, line) catch line;
|
||||
const result = browser_tools.evalScript(aa, self.session, &self.node_registry, script) catch |err| {
|
||||
const result = browser_tools.evalScript(aa, self.ts.session, &self.ts.registry, script) catch |err| {
|
||||
self.terminal.printError("{s}", .{switch (err) {
|
||||
error.OutOfMemory => "out of memory",
|
||||
error.FrameNotLoaded => "no page loaded — run /goto <url> first (Esc exits JS mode)",
|
||||
@@ -640,7 +624,7 @@ fn runRepl(self: *Agent) void {
|
||||
};
|
||||
// Surface console output: slash commands (and thus /consoleLogs)
|
||||
// are unreachable in JS mode, so a console must echo logs itself.
|
||||
const logs = std.mem.trimEnd(u8, self.session.drainConsoleMessages(), "\n");
|
||||
const logs = std.mem.trimEnd(u8, self.ts.session.drainConsoleMessages(), "\n");
|
||||
if (logs.len > 0) self.printData(logs);
|
||||
if (result.is_error) {
|
||||
self.terminal.printError("{s}", .{result.text});
|
||||
@@ -807,7 +791,7 @@ fn clearConversation(self: *Agent) void {
|
||||
if (self.save_path) |p| self.allocator.free(p);
|
||||
self.save_path = null;
|
||||
self.total_usage = .{};
|
||||
self.node_registry.reset();
|
||||
self.ts.registry.reset();
|
||||
}
|
||||
|
||||
/// Forget the conversation while leaving the browser session live — loaded page
|
||||
@@ -820,10 +804,11 @@ fn handleClear(self: *Agent) void {
|
||||
/// Full clean slate: everything `/clear` drops, plus a fresh browser session,
|
||||
/// so the loaded page, cookies, storage, and history are gone too.
|
||||
fn handleReset(self: *Agent) void {
|
||||
self.startSession() catch |err| {
|
||||
self.ts.restartSession() catch |err| {
|
||||
self.terminal.printError("reset failed: {s}", .{@errorName(err)});
|
||||
return;
|
||||
};
|
||||
self.installCancelHook();
|
||||
self.clearConversation();
|
||||
self.terminal.printInfo("Reset conversation and browser session. Page, cookies, and storage cleared.", .{});
|
||||
}
|
||||
@@ -1489,7 +1474,7 @@ fn runCommand(self: *Agent, arena: std.mem.Allocator, cmd: Command) browser_tool
|
||||
else => return .{ .text = "internal: command has no tool mapping", .is_error = true },
|
||||
};
|
||||
// The terminal can't show an image, but the conversation can.
|
||||
return browser_tools.call(arena, self.session, &self.node_registry, tc.name(), tc.args, .{ .inline_image = self.ai_client != null }) catch |err| .{
|
||||
return browser_tools.call(arena, self.ts.session, &self.ts.registry, tc.name(), tc.args, .{ .inline_image = self.ai_client != null }) catch |err| .{
|
||||
.text = switch (err) {
|
||||
error.OutOfMemory => "out of memory",
|
||||
error.FrameNotLoaded => "no page loaded — run /goto <url> first",
|
||||
@@ -1549,7 +1534,7 @@ fn runScript(self: *Agent, path: []const u8) bool {
|
||||
return false;
|
||||
};
|
||||
|
||||
const runtime = ScriptRuntime.init(self.allocator, self.browser.app, self.session, &self.node_registry) catch |err| {
|
||||
const runtime = ScriptRuntime.init(self.allocator, self.ts.browser.app, self.ts.session, &self.ts.registry) catch |err| {
|
||||
self.terminal.printError("Failed to initialize script runtime: {s}", .{@errorName(err)});
|
||||
return false;
|
||||
};
|
||||
@@ -1562,7 +1547,7 @@ fn runScript(self: *Agent, path: []const u8) bool {
|
||||
self.active_script_runtime = null;
|
||||
self.script_runtime_mutex.unlock(lp.io);
|
||||
runtime.cancelTerminate();
|
||||
self.browser.env.cancelTerminate();
|
||||
self.ts.browser.env.cancelTerminate();
|
||||
self.cancel_requested.store(false, .release);
|
||||
}
|
||||
|
||||
@@ -1936,7 +1921,7 @@ fn handleToolCall(ctx: *anyopaque, allocator: std.mem.Allocator, tool_name: []co
|
||||
|
||||
/// The text plus the rendered PNG, for backends that can show the model an image.
|
||||
fn toolOutcome(self: *Agent, allocator: std.mem.Allocator, tool_name: []const u8, arguments: ?std.json.Value) browser_tools.ToolError!zenai.provider.Client.ToolHandler.Result {
|
||||
const result = try browser_tools.call(allocator, self.session, &self.node_registry, tool_name, arguments, .{ .inline_image = true });
|
||||
const result = try browser_tools.call(allocator, self.ts.session, &self.ts.registry, tool_name, arguments, .{ .inline_image = true });
|
||||
const content = capToolOutput(allocator, tool_name, result.text);
|
||||
return .{
|
||||
.content = content,
|
||||
|
||||
@@ -34,6 +34,7 @@ pub const Page = @import("browser/Page.zig");
|
||||
pub const Frame = @import("browser/Frame.zig");
|
||||
pub const Browser = @import("browser/Browser.zig");
|
||||
pub const Session = @import("browser/Session.zig");
|
||||
pub const ToolSession = @import("ToolSession.zig");
|
||||
|
||||
pub const js = @import("browser/js/js.zig");
|
||||
pub const dump = @import("browser/dump.zig");
|
||||
|
||||
@@ -283,7 +283,7 @@ fn worker(self: *HttpServer) void {
|
||||
};
|
||||
defer server.deinit();
|
||||
|
||||
server.enableIsolateParking();
|
||||
server.multi_session = true;
|
||||
|
||||
self.worker_ok = true;
|
||||
self.worker_ready.set(lp.io);
|
||||
|
||||
+35
-88
@@ -9,7 +9,6 @@ const resources = @import("resources.zig");
|
||||
const router = @import("router.zig");
|
||||
const tools = @import("tools.zig");
|
||||
const Transport = @import("Transport.zig");
|
||||
const CDPNode = @import("../cdp/Node.zig");
|
||||
|
||||
const Self = @This();
|
||||
|
||||
@@ -18,39 +17,21 @@ const Self = @This();
|
||||
/// `Mcp-Session-Id`.
|
||||
pub const default_session_id = "default";
|
||||
|
||||
/// One isolated browsing context. Each owns its own V8 isolate (via
|
||||
/// `Browser`), so two agents driving different sessions never touch the same
|
||||
/// page. Heap-allocated and never moved after `init`: `Browser` registers
|
||||
/// self-pointers (watchdog, http_client) that must stay stable.
|
||||
pub const Session = struct {
|
||||
id: []const u8,
|
||||
browser: lp.Browser,
|
||||
session: *lp.Session,
|
||||
notification: *lp.Notification,
|
||||
node_registry: CDPNode.Registry,
|
||||
|
||||
fn isDefault(self: *const Session) bool {
|
||||
return std.mem.eql(u8, self.id, default_session_id);
|
||||
}
|
||||
};
|
||||
|
||||
allocator: std.mem.Allocator,
|
||||
app: *App,
|
||||
|
||||
sessions: std.StringHashMapUnmanaged(*Session) = .empty,
|
||||
sessions: std.StringHashMapUnmanaged(*lp.ToolSession) = .empty,
|
||||
/// Monotonic counter backing auto-generated session ids (`s1`, `s2`, …).
|
||||
session_seq: u32 = 0,
|
||||
/// When several sessions (each its own V8 isolate) share one thread, V8's
|
||||
/// "current isolate" is a per-thread stack, so an isolate must be *entered*
|
||||
/// around any use of it and left un-entered otherwise. The HTTP transport
|
||||
/// sets this; stdio (one isolate, permanently entered by `Env`) leaves it
|
||||
/// false and keeps its historical behavior. See `enterIsolate`/`exitIsolate`.
|
||||
park_isolates: bool = false,
|
||||
/// Whether the transport can route a request to a named session. HTTP does
|
||||
/// (`Mcp-Session-Id`); over stdio the session tools are refused, since a
|
||||
/// session created there could never be addressed.
|
||||
multi_session: bool = false,
|
||||
/// The session the request currently being handled targets. Safe as a single
|
||||
/// field because every request is dispatched on one thread, one at a time;
|
||||
/// the transport sets it (via `useSession`) before each dispatch. Tools and
|
||||
/// resources read it rather than threading a session through every call.
|
||||
active_session: *Session = undefined,
|
||||
active_session: *lp.ToolSession = undefined,
|
||||
transport: Transport,
|
||||
|
||||
pub fn init(allocator: std.mem.Allocator, app: *App, writer: *std.Io.Writer) !*Self {
|
||||
@@ -69,8 +50,8 @@ pub fn init(allocator: std.mem.Allocator, app: *App, writer: *std.Io.Writer) !*S
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Self) void {
|
||||
var it = self.sessions.valueIterator();
|
||||
while (it.next()) |entry| self.destroySession(entry.*);
|
||||
var it = self.sessions.iterator();
|
||||
while (it.next()) |kv| self.destroySession(kv.key_ptr.*, kv.value_ptr.*);
|
||||
self.sessions.deinit(self.allocator);
|
||||
|
||||
self.transport.deinit();
|
||||
@@ -78,102 +59,68 @@ pub fn deinit(self: *Self) void {
|
||||
}
|
||||
|
||||
/// Create the session named `id`, or return the existing one. The `id` is
|
||||
/// duped, so the caller keeps ownership of its slice.
|
||||
pub fn createSession(self: *Self, id: []const u8) !*Session {
|
||||
/// duped, so the caller keeps ownership of its slice. Sessions are
|
||||
/// heap-allocated and never moved: `Browser` registers self-pointers.
|
||||
pub fn createSession(self: *Self, id: []const u8) !*lp.ToolSession {
|
||||
if (self.sessions.get(id)) |existing| return existing;
|
||||
|
||||
const owned_id = try self.allocator.dupe(u8, id);
|
||||
errdefer self.allocator.free(owned_id);
|
||||
|
||||
const entry = try self.allocator.create(Session);
|
||||
const entry = try self.allocator.create(lp.ToolSession);
|
||||
errdefer self.allocator.destroy(entry);
|
||||
|
||||
const notification = try lp.Notification.init(self.allocator);
|
||||
errdefer notification.deinit();
|
||||
|
||||
entry.* = .{
|
||||
.id = owned_id,
|
||||
.browser = undefined,
|
||||
.session = undefined,
|
||||
.notification = notification,
|
||||
.node_registry = CDPNode.Registry.init(self.allocator),
|
||||
};
|
||||
errdefer entry.node_registry.deinit();
|
||||
|
||||
try entry.browser.init(self.app, .{}, null);
|
||||
errdefer entry.browser.deinit();
|
||||
|
||||
entry.session = try entry.browser.newSession(notification);
|
||||
try entry.session.enableConsoleCapture();
|
||||
try entry.init(self.app);
|
||||
errdefer entry.deinit();
|
||||
|
||||
// Only the default session is backed by the on-disk cookie file; named
|
||||
// sessions start clean so agents stay isolated by default.
|
||||
if (entry.isDefault()) {
|
||||
if (isDefault(id)) {
|
||||
if (self.app.config.cookieFile()) |cookie_path| {
|
||||
lp.cookies.loadFromFile(entry.session, cookie_path);
|
||||
}
|
||||
}
|
||||
|
||||
try self.sessions.put(self.allocator, owned_id, entry);
|
||||
// Browser.init left the isolate entered; park it (see park_isolates).
|
||||
self.exitIsolate(entry);
|
||||
entry.exitIsolate();
|
||||
return entry;
|
||||
}
|
||||
|
||||
/// Switch to the multi-isolate discipline: park the default (which `Server.init`
|
||||
/// left entered) and require every use to bracket with `enterIsolate`. The HTTP
|
||||
/// transport calls this on its worker thread before serving anyone.
|
||||
pub fn enableIsolateParking(self: *Self) void {
|
||||
self.park_isolates = true;
|
||||
self.exitIsolate(self.defaultSession());
|
||||
}
|
||||
|
||||
/// Make `entry`'s isolate the current one for this thread. Must bracket any
|
||||
/// use of its Browser/Session (dispatch, idle pumping, teardown). No-op under
|
||||
/// stdio, where the single isolate is permanently current.
|
||||
pub fn enterIsolate(self: *Self, entry: *Session) void {
|
||||
if (self.park_isolates) entry.browser.env.isolate.enter();
|
||||
}
|
||||
|
||||
pub fn exitIsolate(self: *Self, entry: *Session) void {
|
||||
if (self.park_isolates) entry.browser.env.isolate.exit();
|
||||
fn isDefault(id: []const u8) bool {
|
||||
return std.mem.eql(u8, id, default_session_id);
|
||||
}
|
||||
|
||||
/// Tear down the session named `id`. Returns false if no such session, or if
|
||||
/// it is the default (which lives for the whole process).
|
||||
pub fn closeSession(self: *Self, id: []const u8) bool {
|
||||
if (std.mem.eql(u8, id, default_session_id)) return false;
|
||||
const entry = self.sessions.fetchRemove(id) orelse return false;
|
||||
if (self.active_session == entry.value) self.active_session = self.defaultSession();
|
||||
self.destroySession(entry.value);
|
||||
if (isDefault(id)) return false;
|
||||
const kv = self.sessions.fetchRemove(id) orelse return false;
|
||||
if (self.active_session == kv.value) self.active_session = self.defaultSession();
|
||||
self.destroySession(kv.key, kv.value);
|
||||
return true;
|
||||
}
|
||||
|
||||
fn destroySession(self: *Self, entry: *Session) void {
|
||||
if (entry.isDefault()) {
|
||||
fn destroySession(self: *Self, id: []const u8, entry: *lp.ToolSession) void {
|
||||
if (isDefault(id)) {
|
||||
if (self.app.config.cookieJarFile()) |cookie_jar_path| {
|
||||
lp.cookies.saveToFile(&entry.session.cookie_jar, cookie_jar_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-enter so `Browser.deinit`'s `Env.deinit` exit stays balanced against
|
||||
// a parked isolate (and operates on the current one).
|
||||
self.enterIsolate(entry);
|
||||
entry.node_registry.deinit();
|
||||
entry.browser.deinit();
|
||||
entry.notification.deinit();
|
||||
self.allocator.free(entry.id);
|
||||
entry.enterIsolate();
|
||||
entry.deinit();
|
||||
self.allocator.free(id);
|
||||
self.allocator.destroy(entry);
|
||||
}
|
||||
|
||||
/// The session an un-scoped (stdio, or header-less HTTP) request targets.
|
||||
pub fn defaultSession(self: *Self) *Session {
|
||||
pub fn defaultSession(self: *Self) *lp.ToolSession {
|
||||
return self.sessions.get(default_session_id).?;
|
||||
}
|
||||
|
||||
/// Point subsequent tool/resource dispatch at the session named `id`, creating
|
||||
/// it on first use. A null or empty `id` selects the default.
|
||||
pub fn useSession(self: *Self, id: ?[]const u8) !*Session {
|
||||
pub fn useSession(self: *Self, id: ?[]const u8) !*lp.ToolSession {
|
||||
const wanted = id orelse "";
|
||||
self.active_session = if (wanted.len == 0) self.defaultSession() else try self.createSession(wanted);
|
||||
return self.active_session;
|
||||
@@ -196,9 +143,9 @@ pub fn idle(self: *Self) u31 {
|
||||
while (it.next()) |entry| {
|
||||
// Pumping may resume JS (e.g. a completed script fetch), so it needs
|
||||
// the session's isolate current.
|
||||
self.enterIsolate(entry.*);
|
||||
entry.*.enterIsolate();
|
||||
wait = @min(wait, entry.*.session.idleSlice());
|
||||
self.exitIsolate(entry.*);
|
||||
entry.*.exitIsolate();
|
||||
}
|
||||
return wait;
|
||||
}
|
||||
@@ -231,8 +178,8 @@ pub fn handleToolList(self: *Self, arena: std.mem.Allocator, req: protocol.Reque
|
||||
pub fn handleToolCall(self: *Self, arena: std.mem.Allocator, req: protocol.Request) !void {
|
||||
// Dispatch runs page JS, so enter the target isolate around it.
|
||||
const entry = self.active_session;
|
||||
self.enterIsolate(entry);
|
||||
defer self.exitIsolate(entry);
|
||||
entry.enterIsolate();
|
||||
defer entry.exitIsolate();
|
||||
return tools.handleCall(self, arena, req);
|
||||
}
|
||||
|
||||
@@ -242,8 +189,8 @@ pub fn handleResourceList(self: *Self, req: protocol.Request) !void {
|
||||
|
||||
pub fn handleResourceRead(self: *Self, arena: std.mem.Allocator, req: protocol.Request) !void {
|
||||
const entry = self.active_session;
|
||||
self.enterIsolate(entry);
|
||||
defer self.exitIsolate(entry);
|
||||
entry.enterIsolate();
|
||||
defer entry.exitIsolate();
|
||||
return resources.handleRead(self, arena, req);
|
||||
}
|
||||
|
||||
|
||||
+41
-27
@@ -156,7 +156,7 @@ fn dispatchBrowserTool(
|
||||
};
|
||||
|
||||
const active = server.active_session;
|
||||
const result = browser_tools.call(arena, active.session, &active.node_registry, name, arguments, .{ .inline_image = true }) catch |err| {
|
||||
const result = browser_tools.call(arena, active.session, &active.registry, name, arguments, .{ .inline_image = true }) catch |err| {
|
||||
// evaluate/extract surface failures in-band so the LLM can self-correct;
|
||||
// other tools' operational failures are protocol-level.
|
||||
if (surfacesErrorInBand(tool)) {
|
||||
@@ -215,12 +215,11 @@ fn handleSave(server: *Server, arena: std.mem.Allocator, id: std.json.Value, arg
|
||||
try sendToolResultText(server, id, msg, false);
|
||||
}
|
||||
|
||||
/// The session tools require the HTTP transport's parked-isolate discipline:
|
||||
/// a second session means a second V8 isolate, only safe when isolates are
|
||||
/// entered around use. Over stdio (one permanently-entered isolate) they are
|
||||
/// all unsupported, kept uniform so clients see one consistent rule.
|
||||
/// The session tools need a transport that routes by session id (HTTP's
|
||||
/// `Mcp-Session-Id`). Over stdio they are all unsupported, kept uniform so
|
||||
/// clients see one consistent rule.
|
||||
fn requireMultiSession(server: *Server, id: std.json.Value) !bool {
|
||||
if (server.park_isolates) return true;
|
||||
if (server.multi_session) return true;
|
||||
try sendToolResultText(server, id, "multiple sessions require the HTTP transport (start with --port)", true);
|
||||
return false;
|
||||
}
|
||||
@@ -247,10 +246,10 @@ fn handleSessionList(server: *Server, arena: std.mem.Allocator, id: std.json.Val
|
||||
const Entry = struct { id: []const u8, url: ?[]const u8 };
|
||||
var list: std.ArrayList(Entry) = .empty;
|
||||
|
||||
var it = server.sessions.valueIterator();
|
||||
while (it.next()) |entry| {
|
||||
const url: ?[]const u8 = if (entry.*.session.currentFrame()) |frame| frame.url else null;
|
||||
list.append(arena, .{ .id = entry.*.id, .url = url }) catch
|
||||
var it = server.sessions.iterator();
|
||||
while (it.next()) |kv| {
|
||||
const url: ?[]const u8 = if (kv.value_ptr.*.session.currentFrame()) |frame| frame.url else null;
|
||||
list.append(arena, .{ .id = kv.key_ptr.*, .url = url }) catch
|
||||
return sendErrorContent(server, id, "out of memory");
|
||||
}
|
||||
|
||||
@@ -271,7 +270,7 @@ fn handleSessionClose(server: *Server, arena: std.mem.Allocator, id: std.json.Va
|
||||
}
|
||||
// Closing the session serving this very call would tear down the isolate
|
||||
// mid-dispatch; require the client to be elsewhere first.
|
||||
if (std.mem.eql(u8, args.id, server.active_session.id)) {
|
||||
if (server.sessions.get(args.id) == server.active_session) {
|
||||
return sendErrorContent(server, id, "cannot close the session you are attached to");
|
||||
}
|
||||
if (!server.closeSession(args.id)) {
|
||||
@@ -1044,12 +1043,16 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
|
||||
var out: std.Io.Writer.Allocating = .init(aa);
|
||||
const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_actions.html", &out.writer);
|
||||
defer server.deinit();
|
||||
// Poking the page directly (node registration, JS) needs its isolate
|
||||
// entered, as a tool dispatch would.
|
||||
server.active_session.enterIsolate();
|
||||
defer server.active_session.exitIsolate();
|
||||
|
||||
const frame = server.active_session.session.currentFrame().?;
|
||||
|
||||
{
|
||||
const btn = frame.document.getElementById("btn", frame).?.asNode();
|
||||
const btn_id = (try server.active_session.node_registry.register(btn)).id;
|
||||
const btn_id = (try server.active_session.registry.register(btn)).id;
|
||||
var btn_id_buf: [12]u8 = undefined;
|
||||
const btn_id_str = std.fmt.bufPrint(&btn_id_buf, "{d}", .{btn_id}) catch unreachable;
|
||||
const click_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"click\",\"arguments\":{\"backendNodeId\":", btn_id_str, "}}}" });
|
||||
@@ -1061,7 +1064,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
|
||||
|
||||
{
|
||||
const inp = frame.document.getElementById("inp", frame).?.asNode();
|
||||
const inp_id = (try server.active_session.node_registry.register(inp)).id;
|
||||
const inp_id = (try server.active_session.registry.register(inp)).id;
|
||||
var inp_id_buf: [12]u8 = undefined;
|
||||
const inp_id_str = std.fmt.bufPrint(&inp_id_buf, "{d}", .{inp_id}) catch unreachable;
|
||||
const fill_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":2,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", inp_id_str, ",\"value\":\"hello\"}}}" });
|
||||
@@ -1073,7 +1076,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
|
||||
|
||||
{
|
||||
const sel = frame.document.getElementById("sel", frame).?.asNode();
|
||||
const sel_id = (try server.active_session.node_registry.register(sel)).id;
|
||||
const sel_id = (try server.active_session.registry.register(sel)).id;
|
||||
var sel_id_buf: [12]u8 = undefined;
|
||||
const sel_id_str = std.fmt.bufPrint(&sel_id_buf, "{d}", .{sel_id}) catch unreachable;
|
||||
const fill_sel_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":3,\"method\":\"tools/call\",\"params\":{\"name\":\"fill\",\"arguments\":{\"backendNodeId\":", sel_id_str, ",\"value\":\"opt2\"}}}" });
|
||||
@@ -1085,7 +1088,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
|
||||
|
||||
{
|
||||
const scrollbox = frame.document.getElementById("scrollbox", frame).?.asNode();
|
||||
const scrollbox_id = (try server.active_session.node_registry.register(scrollbox)).id;
|
||||
const scrollbox_id = (try server.active_session.registry.register(scrollbox)).id;
|
||||
var scroll_id_buf: [12]u8 = undefined;
|
||||
const scroll_id_str = std.fmt.bufPrint(&scroll_id_buf, "{d}", .{scrollbox_id}) catch unreachable;
|
||||
const scroll_msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":4,\"method\":\"tools/call\",\"params\":{\"name\":\"scroll\",\"arguments\":{\"backendNodeId\":", scroll_id_str, ",\"y\":50}}}" });
|
||||
@@ -1096,7 +1099,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
|
||||
|
||||
{
|
||||
const el = frame.document.getElementById("hoverTarget", frame).?.asNode();
|
||||
const el_id = (try server.active_session.node_registry.register(el)).id;
|
||||
const el_id = (try server.active_session.registry.register(el)).id;
|
||||
var id_buf: [12]u8 = undefined;
|
||||
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable;
|
||||
const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":5,\"method\":\"tools/call\",\"params\":{\"name\":\"hover\",\"arguments\":{\"backendNodeId\":", id_str, "}}}" });
|
||||
@@ -1107,7 +1110,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
|
||||
|
||||
{
|
||||
const el = frame.document.getElementById("keyTarget", frame).?.asNode();
|
||||
const el_id = (try server.active_session.node_registry.register(el)).id;
|
||||
const el_id = (try server.active_session.registry.register(el)).id;
|
||||
var id_buf: [12]u8 = undefined;
|
||||
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable;
|
||||
const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":6,\"method\":\"tools/call\",\"params\":{\"name\":\"press\",\"arguments\":{\"key\":\"Enter\",\"backendNodeId\":", id_str, "}}}" });
|
||||
@@ -1118,7 +1121,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
|
||||
|
||||
{
|
||||
const el = frame.document.getElementById("sel2", frame).?.asNode();
|
||||
const el_id = (try server.active_session.node_registry.register(el)).id;
|
||||
const el_id = (try server.active_session.registry.register(el)).id;
|
||||
var id_buf: [12]u8 = undefined;
|
||||
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable;
|
||||
const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":7,\"method\":\"tools/call\",\"params\":{\"name\":\"selectOption\",\"arguments\":{\"backendNodeId\":", id_str, ",\"value\":\"b\"}}}" });
|
||||
@@ -1129,7 +1132,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
|
||||
|
||||
{
|
||||
const el = frame.document.getElementById("chk", frame).?.asNode();
|
||||
const el_id = (try server.active_session.node_registry.register(el)).id;
|
||||
const el_id = (try server.active_session.registry.register(el)).id;
|
||||
var id_buf: [12]u8 = undefined;
|
||||
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable;
|
||||
const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":8,\"method\":\"tools/call\",\"params\":{\"name\":\"setChecked\",\"arguments\":{\"backendNodeId\":", id_str, ",\"checked\":true}}}" });
|
||||
@@ -1140,7 +1143,7 @@ test "MCP - Actions: click, fill, scroll, hover, press, selectOption, setChecked
|
||||
|
||||
{
|
||||
const el = frame.document.getElementById("rad", frame).?.asNode();
|
||||
const el_id = (try server.active_session.node_registry.register(el)).id;
|
||||
const el_id = (try server.active_session.registry.register(el)).id;
|
||||
var id_buf: [12]u8 = undefined;
|
||||
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{el_id}) catch unreachable;
|
||||
const msg = try std.mem.concat(aa, u8, &.{ "{\"jsonrpc\":\"2.0\",\"id\":9,\"method\":\"tools/call\",\"params\":{\"name\":\"setChecked\",\"arguments\":{\"backendNodeId\":", id_str, ",\"checked\":true}}}" });
|
||||
@@ -1181,11 +1184,15 @@ test "MCP - click that navigates clears node registry" {
|
||||
var out: std.Io.Writer.Allocating = .init(aa);
|
||||
const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_nav.html", &out.writer);
|
||||
defer server.deinit();
|
||||
// Poking the page directly (node registration, JS) needs its isolate
|
||||
// entered, as a tool dispatch would.
|
||||
server.active_session.enterIsolate();
|
||||
defer server.active_session.exitIsolate();
|
||||
|
||||
const before_frame = server.active_session.session.currentFrame().?;
|
||||
const link = before_frame.document.getElementById("navlink", before_frame).?.asNode();
|
||||
const link_id = (try server.active_session.node_registry.register(link)).id;
|
||||
try testing.expect(server.active_session.node_registry.lookup_by_id.contains(link_id));
|
||||
const link_id = (try server.active_session.registry.register(link)).id;
|
||||
try testing.expect(server.active_session.registry.lookup_by_id.contains(link_id));
|
||||
|
||||
var id_buf: [12]u8 = undefined;
|
||||
const id_str = std.fmt.bufPrint(&id_buf, "{d}", .{link_id}) catch unreachable;
|
||||
@@ -1197,7 +1204,7 @@ test "MCP - click that navigates clears node registry" {
|
||||
try router.handleMessage(server, aa, click_msg);
|
||||
|
||||
try testing.expect(server.active_session.session.currentFrame().? != before_frame);
|
||||
try testing.expect(!server.active_session.node_registry.lookup_by_id.contains(link_id));
|
||||
try testing.expect(!server.active_session.registry.lookup_by_id.contains(link_id));
|
||||
}
|
||||
|
||||
test "MCP - Actions by selector: hover, selectOption, setChecked" {
|
||||
@@ -1206,6 +1213,10 @@ test "MCP - Actions by selector: hover, selectOption, setChecked" {
|
||||
var out: std.Io.Writer.Allocating = .init(aa);
|
||||
const server = try testLoadPage("http://localhost:9582/src/browser/tests/mcp_actions.html", &out.writer);
|
||||
defer server.deinit();
|
||||
// Poking the page directly (node registration, JS) needs its isolate
|
||||
// entered, as a tool dispatch would.
|
||||
server.active_session.enterIsolate();
|
||||
defer server.active_session.exitIsolate();
|
||||
|
||||
// Single-page test: reach straight into the live page.
|
||||
const page = server.active_session.session.pages.items[0];
|
||||
@@ -1625,8 +1636,7 @@ test "MCP - sessions: new, list, attach isolation, close" {
|
||||
var out: std.Io.Writer.Allocating = .init(aa);
|
||||
var server = try Server.init(testing.allocator, testing.test_app, &out.writer);
|
||||
defer server.deinit();
|
||||
// Session tools require the HTTP transport's parked-isolate discipline.
|
||||
server.enableIsolateParking();
|
||||
server.multi_session = true;
|
||||
|
||||
try router.handleMessage(server, aa,
|
||||
\\{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"session_new","arguments":{"name":"a"}}}
|
||||
@@ -1673,10 +1683,14 @@ fn testLoadPage(url: [:0]const u8, writer: *std.Io.Writer) !*Server {
|
||||
var server = try Server.init(testing.allocator, testing.test_app, writer);
|
||||
errdefer server.deinit();
|
||||
|
||||
const page = try server.active_session.session.createPage();
|
||||
const session = server.active_session;
|
||||
session.enterIsolate();
|
||||
defer session.exitIsolate();
|
||||
|
||||
const page = try session.session.createPage();
|
||||
try page.navigate(url, .{});
|
||||
|
||||
var runner = server.active_session.session.runner(.{});
|
||||
var runner = session.session.runner(.{});
|
||||
try runner.waitForFrame(page.frame_id, 2000, .{ .until = .done });
|
||||
return server;
|
||||
}
|
||||
Reference in new issue
Block a user