Files
browser/src/server/Driver.zig
T
Karl Seguin a68697921e cdp: remove race between terminate/disconnect
A client that disconnects might get treated as a harsher terminate failure (e.g.
watchdog). This doesn't have a huge impact, but it makes the CI flaky and it
produces more logs than is necessary.

In a terminate state, the driver will now check its inbox to see if this is a
client disconnection.
2026-09-01 12:11:07 +08:00

209 lines
7.3 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 WS = @import("../network/WS.zig");
const Inbox = @import("../Inbox.zig");
const CDP = @import("cdp/CDP.zig");
const Server = @import("Server.zig");
const BiDi = @import("bidi/BiDi.zig");
const Connection = @import("Connection.zig");
const Browser = @import("../browser/Browser.zig");
const Session = @import("../browser/Session.zig");
const log = lp.log;
// Parts of the driver are owned by the server run loop, parts are owned by
// the worker thread. The run loop reads messages and pushes to the inbox,
// the worker mostly just writes to the socket.
//
// What every protocol has - a connection, a browser, a link to the network
// thread - lives here rather than behind `impl`, so the shared paths are plain
// field access. Only what genuinely differs switches on `impl`.
const Driver = @This();
pub const Impl = union(enum) {
cdp: *CDP,
bidi: *BiDi,
};
impl: Impl,
conn: *Connection,
browser: *Browser,
link: *Server.Link,
// The protocol's log scope, so shared code still logs as .cdp / .bidi.
scope: log.Scope,
// Called from CDP.init / BiDi.init, where conn, link and browser are all
// still undefined: we only take their addresses, which the impl's own
// allocation already fixed.
pub fn init(impl: Impl) Driver {
return switch (impl) {
// The tag names line up with the log scopes of the same name.
inline else => |d, tag| .{
.impl = impl,
.conn = &d.conn,
.link = &d.link,
.browser = &d.browser,
.scope = @field(log.Scope, @tagName(tag)),
},
};
}
// Server run loop. Received data, driver returns false to signal it should
// disconnect.
pub fn onData(self: *const Driver, data: []const u8) anyerror!bool {
return self.conn.feed(data);
}
// Server run loop. Called when it drops the link unsolicited (peer EOF, ...)
pub fn onLinkDisconnect(self: *const Driver, err: ?anyerror) void {
const arena = self.browser.arena_pool.acquire(.tiny, "driver disconnect") catch |e| switch (e) {
error.OutOfMemory => @panic("OOM"),
};
// order matters, this ensures that the disconnect message is in the inbox
// when tick() discovers the terminatePending flag is set.
self.browser.http_client.inbox.push(arena, .{ .disconnect = err });
self.browser.env.requestTerminate();
}
// Worker thread. We're processing messages from the inbox.
pub fn onMessage(self: *const Driver, msg: *Inbox.Message) anyerror!void {
return switch (self.impl) {
.cdp => |cdp| cdp.onMessage(&msg.payload.cdp),
.bidi => |bidi| bidi.onMessage(msg.payload.bidi),
};
}
// Worker Thread. We're processing messages from the inbox.
pub fn onPing(self: *const Driver, body: []const u8) void {
self.conn.sendPong(body) catch |err| {
log.warn(self.scope, "pong", .{ .err = err });
};
}
// Worker Thread. We're processing messages from the inbox.
pub fn onClose(self: *const Driver) void {
self.conn.send(&WS.CLOSE_NORMAL) catch |err| {
log.warn(self.scope, "close reply", .{ .err = err });
};
self.onDisconnect(null);
}
// Worker Thread. We're processing messages from the inbox.
pub fn onDisconnect(self: *const Driver, err: ?anyerror) void {
if (err) |e| {
if (WS.errorReply(e)) |close_frame| {
self.conn.send(close_frame) catch {};
}
}
log.info(self.scope, "disconnect", .{ .err = err });
}
// Worker thread. Once the websocket connection is established, the server
// calls this (from the worker thread) and it becomes the driving loop.
pub fn run(self: *const Driver) void {
while (true) {
const alive = self.tick() catch |err| {
log.err(self.scope, "tick", .{ .err = err });
return;
};
if (alive == false) {
return;
}
}
}
// One iteration of the worker loop. Returns false to disconnect.
fn tick(self: *const Driver) !bool {
if (self.browser.env.terminatePending()) {
// Maybe something bad happened (e.g. watchdog) or maybe the client
// just disconnected. Check the inbox to see if there's a disconnect
// message and, if so, it'll handle it directly.
self.browser.http_client.drainTerminal() catch |err| switch (err) {
error.ClientDisconnected => return false,
};
log.warn(self.scope, "closing connection", .{ .reason = "pending terminate" });
// The worker thread is the sole writer of this socket, so sending
// the close frame here can't interleave with another write.
self.conn.send(&WS.CLOSE_GOING_AWAY) catch |err| {
log.warn(self.scope, "terminate close", .{ .err = err });
};
return false;
}
// Liveness is enforced by TCP keepalive configured in
// Server.configureSocket; the wakeup lets V8 run or terminate.
const wait_ms: u32 = 1000; // 1s
if (self.pageWait()) |wait| {
var runner = wait.session.runner(.{});
runner.waitForFrameCDP(wait.frame_id, wait_ms, .done) catch |err| switch (err) {
error.ClientDisconnected => return false,
else => return err,
};
return true;
}
// No active page yet (or a teardown is in flight). Fall back to ticking
// the http client directly so commands still get dispatched.
_ = self.browser.http_client.tick(wait_ms) catch |err| switch (err) {
error.ClientDisconnected => return false,
else => {
log.err(self.scope, "http tick", .{ .err = err });
return false;
},
};
return true;
}
const PageWait = struct {
session: *Session,
frame_id: u32,
};
// The page this connection's tick should wait on, null when there isn't one.
fn pageWait(self: *const Driver) ?PageWait {
switch (self.impl) {
.cdp => |cdp| {
const bc = &(cdp.browser_context orelse return null);
const page = bc.page_handle orelse return null;
return .{ .session = bc.session, .frame_id = page.frame_id };
},
.bidi => |bidi| {
const context = bidi.browsing_context orelse return null;
return .{ .session = bidi.user_context.session, .frame_id = context.frame_id };
},
}
}
// signal handler thread
pub fn shutdown(self: *const Driver) void {
if (self.conn.state == .live) {
self.browser.env.terminate();
// We use to send a nice WS close frame here but (a) it isn't
// strictly required and (b) we'd have to protect against an interleaved
// write from the worker thread.
}
self.conn.shutdown();
}