mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-09-17 08:27:11 -04:00
Merge pull request #3329 from lightpanda-io/better-server
Better server
This commit is contained in:
14 files changed
+3031
-1865
No files matched your search
+69
-1
@@ -37,6 +37,10 @@ const Inbox = @This();
|
||||
mutex: std.Io.Mutex = .init,
|
||||
queue: DoublyLinkedList = .{},
|
||||
|
||||
// Payload bytes sitting in the queue. Used to disconnect a client if we've
|
||||
// fallen too far behind (largely to protect against a misbehaving client)
|
||||
queued_bytes: usize = 0,
|
||||
|
||||
// One-way latch, set by the worker's drainInbox the first time it
|
||||
// observes a .disconnect (or .close) and never cleared. Ensures that, on
|
||||
// multiple drains, the terminated state is preserved / communicated. This is
|
||||
@@ -51,6 +55,13 @@ pub fn deinit(self: *Inbox) void {
|
||||
const msg: *Message = @fieldParentPtr("node", node);
|
||||
msg.deinit();
|
||||
}
|
||||
self.queued_bytes = 0;
|
||||
}
|
||||
|
||||
pub fn queuedBytes(self: *Inbox) usize {
|
||||
self.mutex.lockUncancelable(lp.io);
|
||||
defer self.mutex.unlock(lp.io);
|
||||
return self.queued_bytes;
|
||||
}
|
||||
|
||||
pub fn push(self: *Inbox, arena: *lp.Arena, payload: Message.Payload) void {
|
||||
@@ -61,6 +72,7 @@ pub fn push(self: *Inbox, arena: *lp.Arena, payload: Message.Payload) void {
|
||||
msg.* = .{ .payload = payload, .arena = arena };
|
||||
self.mutex.lockUncancelable(lp.io);
|
||||
defer self.mutex.unlock(lp.io);
|
||||
self.queued_bytes += payload.size();
|
||||
self.queue.append(&msg.node);
|
||||
}
|
||||
|
||||
@@ -68,7 +80,9 @@ pub fn pop(self: *Inbox) ?*Message {
|
||||
self.mutex.lockUncancelable(lp.io);
|
||||
defer self.mutex.unlock(lp.io);
|
||||
const node = self.queue.popFirst() orelse return null;
|
||||
return @fieldParentPtr("node", node);
|
||||
const msg: *Message = @fieldParentPtr("node", node);
|
||||
self.queued_bytes -= msg.payload.size();
|
||||
return msg;
|
||||
}
|
||||
|
||||
// Peek for a message matching `predicate` without removing it. Used by
|
||||
@@ -99,6 +113,7 @@ pub fn popIf(self: *Inbox, predicate: *const fn (*Message) bool) ?*Message {
|
||||
const msg: *Message = @fieldParentPtr("node", node);
|
||||
if (predicate(msg)) {
|
||||
self.queue.remove(node);
|
||||
self.queued_bytes -= msg.payload.size();
|
||||
return msg;
|
||||
}
|
||||
}
|
||||
@@ -141,6 +156,14 @@ pub const Message = struct {
|
||||
// pushes this on peer EOF, fatal WS framing error, or
|
||||
// (now) JSON parse failure.
|
||||
disconnect: ?anyerror,
|
||||
|
||||
pub fn size(self: Payload) usize {
|
||||
return switch (self) {
|
||||
.cdp => |c| c.raw.len,
|
||||
.bidi, .ping => |b| b.len,
|
||||
.close, .disconnect => 0,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
pub const Cdp = struct {
|
||||
@@ -346,3 +369,48 @@ test "Inbox: popIf picks first match in FIFO order" {
|
||||
defer m.deinit();
|
||||
try testing.expectEqual("first", m.payload.ping);
|
||||
}
|
||||
|
||||
test "Inbox: queued bytes track the payloads" {
|
||||
const arena_pool = &testing.test_app.arena_pool;
|
||||
|
||||
var inbox = Inbox{};
|
||||
defer inbox.deinit();
|
||||
|
||||
try testing.expectEqual(0, inbox.queuedBytes());
|
||||
|
||||
{
|
||||
const arena = try arena_pool.acquire(.tiny, "inbox test");
|
||||
inbox.push(arena, .{ .ping = try arena.dupe(u8, "12345") });
|
||||
}
|
||||
try testing.expectEqual(5, inbox.queuedBytes());
|
||||
|
||||
{
|
||||
// control payloads are free; only what the peer sends counts
|
||||
const arena = try arena_pool.acquire(.tiny, "inbox test");
|
||||
inbox.push(arena, .{ .disconnect = null });
|
||||
}
|
||||
try testing.expectEqual(5, inbox.queuedBytes());
|
||||
|
||||
{
|
||||
const arena = try arena_pool.acquire(.tiny, "inbox test");
|
||||
inbox.push(arena, .{ .bidi = try arena.dupe(u8, "abc") });
|
||||
}
|
||||
try testing.expectEqual(8, inbox.queuedBytes());
|
||||
|
||||
// popIf cherry-picks out of the middle, and has to pay the same toll
|
||||
{
|
||||
const m = inbox.popIf(struct {
|
||||
fn f(msg: *Message) bool {
|
||||
return msg.payload == .bidi;
|
||||
}
|
||||
}.f).?;
|
||||
defer m.deinit();
|
||||
}
|
||||
try testing.expectEqual(5, inbox.queuedBytes());
|
||||
|
||||
{
|
||||
const m = inbox.pop().?;
|
||||
defer m.deinit();
|
||||
}
|
||||
try testing.expectEqual(0, inbox.queuedBytes());
|
||||
}
|
||||
+8
-2
@@ -20,8 +20,11 @@ const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
|
||||
const Metrics = @This();
|
||||
const Driver = @import("server/Handshake.zig").Driver;
|
||||
const Driver = @import("server/Driver.zig").Protocol;
|
||||
|
||||
serve_http_requests: CounterEnum("status", @import("network/http.zig").StatusCategory) = .{},
|
||||
serve_http_evictions: Counter = .{},
|
||||
serve_inbox_backlog: Counter = .{},
|
||||
serve_connections: CounterEnum("driver", Driver) = .{},
|
||||
serve_connection_limit: Counter = .{},
|
||||
serve_active_connections: GaugeEnum("driver", Driver) = .{},
|
||||
@@ -92,8 +95,11 @@ robots_access: CounterEnum("result", enum { allow, deny }) = .{},
|
||||
// Emitted as each metric's "# HELP" line. A field without an entry is a
|
||||
// compile error.
|
||||
const help = .{
|
||||
.serve_http_requests = "HTTP responses sent, by status category (includes the pre-parse 400/413 rejections)",
|
||||
.serve_http_evictions = "HTTP connections closed for sitting past their deadline without completing a request",
|
||||
.serve_inbox_backlog = "Websocket connections closed for queueing more unprocessed messages than the worker could drain",
|
||||
.serve_connections = "Websocket connections accepted, by driver protocol",
|
||||
.serve_connection_limit = "Connections rejected because --cdp-max-connections was reached (counted before the handshake, so no driver label)",
|
||||
.serve_connection_limit = "Accepts deferred because the connection budget was full: the listener pauses until a slot frees (counted before any handshake, so no driver label)",
|
||||
.serve_active_connections = "Currently connected clients, by driver protocol",
|
||||
.serve_commands = "Commands dispatched, by driver protocol",
|
||||
.serve_unknown_commands = "Commands rejected for an unknown domain, module or method, by driver protocol",
|
||||
|
||||
@@ -37,6 +37,7 @@ pub const Scope = enum {
|
||||
note,
|
||||
not_implemented,
|
||||
scheduler,
|
||||
serve,
|
||||
storage,
|
||||
telemetry,
|
||||
unknown_prop,
|
||||
|
||||
@@ -1,274 +0,0 @@
|
||||
// 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 App = @import("../App.zig");
|
||||
const Inbox = @import("../Inbox.zig");
|
||||
const WS = @import("../network/WS.zig");
|
||||
const sys_net = @import("../sys/net.zig");
|
||||
const ArenaPool = @import("../ArenaPool.zig");
|
||||
|
||||
const CDP = @import("cdp/CDP.zig");
|
||||
|
||||
const log = lp.log;
|
||||
const posix = std.posix;
|
||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
|
||||
pub const Connection = @This();
|
||||
|
||||
// is .starting until server.track is called
|
||||
const State = enum { starting, live };
|
||||
|
||||
const Protocol = enum { cdp, bidi };
|
||||
|
||||
// reference to http_client.inbox
|
||||
inbox: *Inbox,
|
||||
arena_pool: *ArenaPool,
|
||||
socket: posix.socket_t,
|
||||
socket_flags: usize,
|
||||
state: State = .starting,
|
||||
protocol: Protocol,
|
||||
reader: WS.Reader(true),
|
||||
send_arena: ArenaAllocator,
|
||||
|
||||
pub fn init(
|
||||
self: *Connection,
|
||||
app: *App,
|
||||
socket: posix.socket_t,
|
||||
protocol: Protocol,
|
||||
inbox: *Inbox,
|
||||
) !void {
|
||||
const socket_flags = try sys_net.fcntl(socket, posix.F.GETFL, 0);
|
||||
const nonblocking = @as(u32, @bitCast(posix.O{ .NONBLOCK = true }));
|
||||
if (lp.IS_TEST == false) {
|
||||
lp.assert(socket_flags & nonblocking == nonblocking, "Connection.init blocking", .{});
|
||||
}
|
||||
|
||||
const config = app.config;
|
||||
const allocator = app.allocator;
|
||||
|
||||
self.* = .{
|
||||
.inbox = inbox,
|
||||
.socket = socket,
|
||||
.protocol = protocol,
|
||||
.arena_pool = &app.arena_pool,
|
||||
.socket_flags = socket_flags,
|
||||
.reader = try .init(allocator, config.cdpMaxMessageSize()),
|
||||
.send_arena = ArenaAllocator.init(allocator),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Connection) void {
|
||||
self.reader.deinit();
|
||||
self.send_arena.deinit();
|
||||
}
|
||||
|
||||
pub fn send(self: *Connection, data: []const u8) !void {
|
||||
var pos: usize = 0;
|
||||
var changed_to_blocking: bool = false;
|
||||
defer _ = self.send_arena.reset(.{ .retain_with_limit = 1024 * 32 });
|
||||
|
||||
defer if (changed_to_blocking) {
|
||||
// We had to change our socket to blocking mode to get our write out
|
||||
// We need to change it back to non-blocking.
|
||||
_ = sys_net.fcntl(self.socket, posix.F.SETFL, self.socket_flags) catch |err| {
|
||||
log.err(.app, "ws restore nonblocking", .{ .err = err });
|
||||
};
|
||||
};
|
||||
|
||||
LOOP: while (pos < data.len) {
|
||||
const written = sys_net.write(self.socket, data[pos..]) catch |err| switch (err) {
|
||||
error.WouldBlock => {
|
||||
// self.socket is nonblocking, because we don't want to block
|
||||
// reads. But our life is a lot easier if we block writes,
|
||||
// largely, because we don't have to maintain a queue of pending
|
||||
// writes (which would each need their own allocations). So
|
||||
// if we get a WouldBlock error, we'll switch the socket to
|
||||
// blocking and switch it back to non-blocking after the write
|
||||
// is complete. Doesn't seem particularly efficiently, but
|
||||
// this should virtually never happen.
|
||||
lp.assert(changed_to_blocking == false, "Connection.double block", .{});
|
||||
changed_to_blocking = true;
|
||||
_ = try sys_net.fcntl(self.socket, posix.F.SETFL, self.socket_flags & ~@as(u32, @bitCast(posix.O{ .NONBLOCK = true })));
|
||||
continue :LOOP;
|
||||
},
|
||||
else => return err,
|
||||
};
|
||||
|
||||
if (written == 0) {
|
||||
return error.Closed;
|
||||
}
|
||||
pos += written;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sendPong(self: *Connection, data: []const u8) !void {
|
||||
if (data.len == 0) {
|
||||
return self.send(&WS.EMPTY_PONG);
|
||||
}
|
||||
var header_buf: [10]u8 = undefined;
|
||||
const header = WS.frameHeader(&header_buf, .pong, data.len);
|
||||
|
||||
const allocator = self.send_arena.allocator();
|
||||
const framed = try allocator.alloc(u8, header.len + data.len);
|
||||
@memcpy(framed[0..header.len], header);
|
||||
@memcpy(framed[header.len..], data);
|
||||
return self.send(framed);
|
||||
}
|
||||
|
||||
// Websocket frames have a variable length header. For server-client,
|
||||
// it could be anywhere from 2 to 10 bytes. Our IO.Loop doesn't have
|
||||
// writev, so we need to get creative. We'll JSON serialize to a
|
||||
// buffer, where the first 10 bytes are reserved. We can then backfill
|
||||
// the header and send the slice.
|
||||
pub fn sendJSON(self: *Connection, message: anytype, opts: std.json.Stringify.Options) !void {
|
||||
const allocator = self.send_arena.allocator();
|
||||
|
||||
var aw = try std.Io.Writer.Allocating.initCapacity(allocator, 512);
|
||||
|
||||
// reserve space for the maximum possible header
|
||||
try aw.writer.writeAll(&[_]u8{0} ** 10);
|
||||
try std.json.Stringify.value(message, opts, &aw.writer);
|
||||
const framed = WS.fillHeader(aw.toArrayList());
|
||||
return self.send(framed);
|
||||
}
|
||||
|
||||
pub fn sendJSONRaw(self: *Connection, buf: std.ArrayList(u8)) !void {
|
||||
// Dangerous API!. We assume the caller has reserved the first 10
|
||||
// bytes in `buf`.
|
||||
const framed = WS.fillHeader(buf);
|
||||
return self.send(framed);
|
||||
}
|
||||
|
||||
pub fn feed(self: *Connection, data: []const u8) !bool {
|
||||
var remaining = data;
|
||||
while (remaining.len > 0) {
|
||||
// we copy what will fit into our read buffer
|
||||
const dst = self.reader.readBuf();
|
||||
const used = @min(remaining.len, dst.len);
|
||||
@memcpy(dst[0..used], remaining[0..used]);
|
||||
self.reader.len += used;
|
||||
|
||||
// If we copied 1+ valid messages, this will process it.
|
||||
if ((try self.processMessages()) == false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
remaining = remaining[used..];
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Framing-only iteration over received bytes. Will process as many messages
|
||||
// as are buffered.
|
||||
fn processMessages(self: *Connection) !bool {
|
||||
var reader = &self.reader;
|
||||
while (true) {
|
||||
const msg = (try reader.next()) orelse break;
|
||||
|
||||
const keep = switch (msg.type) {
|
||||
.pong => true,
|
||||
.ping, .text, .binary => try self.handleMessage(msg),
|
||||
.close => blk: {
|
||||
_ = try self.handleMessage(msg);
|
||||
break :blk false;
|
||||
},
|
||||
};
|
||||
|
||||
if (msg.cleanup_fragment) {
|
||||
reader.cleanup();
|
||||
}
|
||||
|
||||
if (!keep) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// We might have read part of the next message. Our reader potentially
|
||||
// has to move data around in its buffer to make space.
|
||||
reader.compact();
|
||||
return true;
|
||||
}
|
||||
|
||||
fn handleMessage(self: *Connection, msg: WS.Message) !bool {
|
||||
switch (msg.type) {
|
||||
.text, .binary => return switch (self.protocol) {
|
||||
.cdp => self.pushCdp(msg.data),
|
||||
.bidi => self.pushBiDi(msg.data),
|
||||
},
|
||||
.ping => {
|
||||
const arena = try self.arena_pool.acquire(.tiny, "ws ping");
|
||||
errdefer arena.release();
|
||||
self.inbox.push(arena, .{ .ping = try arena.dupe(u8, msg.data) });
|
||||
return true;
|
||||
},
|
||||
.close => {
|
||||
const arena = try self.arena_pool.acquire(.tiny, "ws close");
|
||||
self.inbox.push(arena, .close);
|
||||
return true;
|
||||
},
|
||||
.pong => unreachable, // processMessages skips pong
|
||||
}
|
||||
}
|
||||
|
||||
// Parse a CDP JSON frame on the Network thread and push it onto the
|
||||
// inbox already-parsed. The consumer's allowlist check works on
|
||||
// `input.method` directly (no substring matching against raw JSON),
|
||||
// and the worker doesn't re-parse on dispatch. On parse failure we
|
||||
// push `.disconnect(error.InvalidJSON)` so the worker tears down —
|
||||
// treated the same way as a fatal WS framing error.
|
||||
fn pushCdp(self: *Connection, bytes: []const u8) !bool {
|
||||
// TODO: is it worth trying to pad this for the cost overhead of parsing?
|
||||
const arena = try self.arena_pool.acquire(bytes.len, "cdp data");
|
||||
errdefer arena.release();
|
||||
|
||||
const raw = try arena.dupe(u8, bytes);
|
||||
|
||||
const input = std.json.parseFromSliceLeaky(
|
||||
CDP.InputMessage,
|
||||
arena.allocator(),
|
||||
raw,
|
||||
.{ .ignore_unknown_fields = true },
|
||||
) catch {
|
||||
self.inbox.push(arena, .{ .disconnect = error.InvalidJSON });
|
||||
return false;
|
||||
};
|
||||
|
||||
self.inbox.push(arena, .{ .cdp = .{
|
||||
.raw = raw,
|
||||
.input = input,
|
||||
} });
|
||||
return true;
|
||||
}
|
||||
|
||||
// BiDi frames are pushed raw; the worker parses them. Unlike CDP there's
|
||||
// no allowlist that needs the method name on this thread yet — when BiDi
|
||||
// grows request interception, this is where that parse would go.
|
||||
fn pushBiDi(self: *Connection, bytes: []const u8) !bool {
|
||||
const arena = try self.arena_pool.acquire(bytes.len, "bidi data");
|
||||
errdefer arena.release();
|
||||
|
||||
self.inbox.push(arena, .{ .bidi = try arena.dupe(u8, bytes) });
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn shutdown(self: *Connection) void {
|
||||
sys_net.shutdown(self.socket, .recv) catch {};
|
||||
}
|
||||
+50
-39
@@ -19,63 +19,64 @@
|
||||
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 WS = @import("WS.zig");
|
||||
const Link = @import("Link.zig");
|
||||
|
||||
const CDP = @import("cdp/CDP.zig");
|
||||
const BiDi = @import("bidi/BiDi.zig");
|
||||
|
||||
const log = lp.log;
|
||||
|
||||
// Parts of the driver are owned by the server run loop, parts are owned by
|
||||
// Parts of the driver are owned by the server 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) {
|
||||
// Doubles as the metrics label
|
||||
pub const Protocol = enum { cdp, bidi };
|
||||
|
||||
pub const Impl = union(Protocol) {
|
||||
cdp: *CDP,
|
||||
bidi: *BiDi,
|
||||
};
|
||||
|
||||
impl: Impl,
|
||||
conn: *Connection,
|
||||
|
||||
// every implementation has this
|
||||
conn: *Link,
|
||||
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.
|
||||
// Called from CDP.init / BiDi.init, where conn and browser are both 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)),
|
||||
.scope = @field(log.Scope, @tagName(tag)), // The tag names line up with the log scopes of the same name.
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 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 loop. The socket is readable, drain up to budget bytes
|
||||
pub fn onReadable(self: *const Driver, budget: usize) anyerror!bool {
|
||||
const read = try self.conn.readAvailable(budget);
|
||||
if (read.pushed) {
|
||||
self.wakeup();
|
||||
}
|
||||
return read.keep;
|
||||
}
|
||||
|
||||
// Server run loop. Called when it drops the link unsolicited (peer EOF, ...)
|
||||
// server 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"),
|
||||
@@ -84,6 +85,23 @@ pub fn onLinkDisconnect(self: *const Driver, err: ?anyerror) void {
|
||||
// when tick() discovers the terminatePending flag is set.
|
||||
self.browser.http_client.inbox.push(arena, .{ .disconnect = err });
|
||||
self.browser.env.requestTerminate();
|
||||
self.wakeup();
|
||||
}
|
||||
|
||||
// server loop. We used 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.
|
||||
pub fn shutdown(self: *const Driver) void {
|
||||
self.browser.env.terminate();
|
||||
self.conn.shutdown();
|
||||
}
|
||||
|
||||
// a server-processed call (onReadable, onLinkDisconnect) wants to signal the
|
||||
// worker that there's data in its inbox waiting to be processed.
|
||||
fn wakeup(self: *const Driver) void {
|
||||
self.browser.http_client.handles.wakeup() catch |err| {
|
||||
log.err(self.scope, "wakeup", .{ .err = err });
|
||||
};
|
||||
}
|
||||
|
||||
// Worker thread. We're processing messages from the inbox.
|
||||
@@ -136,12 +154,16 @@ pub fn run(self: *const Driver) void {
|
||||
// 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.
|
||||
// Our own requestTerminate from onLinkDisconnect: the peer is gone or
|
||||
// sent garbage. Report it with its own close code, nothing to warn
|
||||
// about. Pops close/disconnect only: nothing else may be dispatched
|
||||
// in a shutting-down state.
|
||||
self.browser.http_client.drainTerminal() catch |err| switch (err) {
|
||||
error.ClientDisconnected => return false,
|
||||
};
|
||||
|
||||
// Anything else means someone decided this browser must die (e.g.
|
||||
// shutdown, or the heap limit was reached).
|
||||
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.
|
||||
@@ -195,14 +217,3 @@ fn pageWait(self: *const Driver) ?PageWait {
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
@@ -1,551 +0,0 @@
|
||||
// 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/>.
|
||||
|
||||
// The pre-upgrade HTTP phase of a connection. Owns the socket until it
|
||||
// either serves a plain HTTP request (/json/*, /metrics) and closes, or
|
||||
// completes a websocket upgrade — at which point the request path decides
|
||||
// which protocol driver the connection is handed to.
|
||||
|
||||
const std = @import("std");
|
||||
const lp = @import("lightpanda");
|
||||
|
||||
const App = @import("../App.zig");
|
||||
const sys_net = @import("../sys/net.zig");
|
||||
const uuidv4 = @import("../id.zig").uuidv4;
|
||||
const header_parser = @import("../network/header_parser.zig");
|
||||
const bidi_session = @import("bidi/session.zig");
|
||||
|
||||
const log = lp.log;
|
||||
const posix = std.posix;
|
||||
|
||||
const Handshake = @This();
|
||||
|
||||
pub const Driver = enum { cdp, bidi };
|
||||
|
||||
// Which driver an upgraded socket is handed to.
|
||||
pub const Route = union(Driver) {
|
||||
cdp,
|
||||
bidi: ?[36]u8, // the sessionId
|
||||
};
|
||||
|
||||
// Which route families are served.
|
||||
pub const Protocols = struct {
|
||||
cdp: bool = false,
|
||||
webdriver: bool = false,
|
||||
};
|
||||
|
||||
// What every handshake on a server needs; built once by Server.
|
||||
pub const Options = struct {
|
||||
protocols: Protocols,
|
||||
bidi_session_url: []const u8,
|
||||
json_version_response: []const u8,
|
||||
};
|
||||
|
||||
app: *App,
|
||||
len: usize = 0,
|
||||
socket: posix.socket_t,
|
||||
// cdpMaxHTTPMessageSize is a u14, so this covers any configured limit.
|
||||
buf: [std.math.maxInt(u14) + 1]u8 = undefined,
|
||||
options: *const Options,
|
||||
|
||||
const Result = union(enum) {
|
||||
more,
|
||||
close,
|
||||
upgrade: Route,
|
||||
};
|
||||
|
||||
// Runs the HTTP phase to completion. Returns the route to hand the
|
||||
// upgraded socket to, or null if the connection is done (plain HTTP
|
||||
// request served, error, timeout or disconnect).
|
||||
pub fn run(app: *App, socket: posix.socket_t, options: *const Options) ?Route {
|
||||
var self = Handshake{
|
||||
.app = app,
|
||||
.socket = socket,
|
||||
.options = options,
|
||||
};
|
||||
|
||||
while (true) {
|
||||
var pfds = [_]posix.pollfd{.{
|
||||
.fd = self.socket,
|
||||
.events = posix.POLL.IN,
|
||||
.revents = 0,
|
||||
}};
|
||||
const n = posix.poll(&pfds, 5000) catch return null;
|
||||
if (n == 0) {
|
||||
log.info(.cdp, "handshake timeout", .{});
|
||||
return null;
|
||||
}
|
||||
const read_bytes = posix.read(self.socket, self.buf[self.len..]) catch |err| {
|
||||
log.warn(.cdp, "handshake read", .{ .err = err });
|
||||
return null;
|
||||
};
|
||||
if (read_bytes == 0) {
|
||||
log.info(.cdp, "handshake disconnect", .{});
|
||||
return null;
|
||||
}
|
||||
self.len += read_bytes;
|
||||
|
||||
const result = self.processHttpRequest() catch return null;
|
||||
switch (result) {
|
||||
.more => continue,
|
||||
.close => return null,
|
||||
.upgrade => |route| return route,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn processHttpRequest(self: *Handshake) !Result {
|
||||
const request = self.buf[0..self.len];
|
||||
|
||||
if (request.len > self.app.config.cdpMaxHTTPMessageSize()) {
|
||||
log.warn(.cdp, "message too big", .{ .type = "HTTP", .len = request.len, .hint = "See the --cdp-max-http-message-size <bytes>" });
|
||||
self.sendHttpError(413, "Request too large");
|
||||
return error.RequestTooLarge;
|
||||
}
|
||||
|
||||
// Wait for the whole header block; put any more data here.
|
||||
const head_len = (std.mem.indexOf(u8, request, "\r\n\r\n") orelse return .more) + 4;
|
||||
|
||||
return self.handleHttpRequest(request, head_len) catch |err| {
|
||||
switch (err) {
|
||||
error.NotFound => self.sendHttpError(404, "Not found"),
|
||||
error.ForbiddenOrigin => self.sendHttpError(403, "Origin not allowed"),
|
||||
error.ForbiddenHost => self.sendHttpError(403, "Host not allowed"),
|
||||
error.InvalidRequest => self.sendHttpError(400, "Invalid request"),
|
||||
error.InvalidProtocol => self.sendHttpError(400, "Invalid HTTP protocol"),
|
||||
error.MissingHeaders => self.sendHttpError(400, "Missing required header"),
|
||||
error.InvalidUpgradeHeader => self.sendHttpError(400, "Unsupported upgrade type"),
|
||||
error.InvalidVersionHeader => self.sendHttpError(400, "Invalid websocket version"),
|
||||
error.InvalidConnectionHeader => self.sendHttpError(400, "Invalid connection header"),
|
||||
else => {
|
||||
log.err(.app, "server 500", .{ .err = err, .req = request[0..@min(100, request.len)] });
|
||||
self.sendHttpError(500, "Internal Server Error");
|
||||
},
|
||||
}
|
||||
return err;
|
||||
};
|
||||
}
|
||||
|
||||
fn handleHttpRequest(self: *Handshake, request: []u8, head_len: usize) !Result {
|
||||
if (request.len < 18) {
|
||||
// 18 is [generously] the smallest acceptable HTTP request
|
||||
return error.InvalidRequest;
|
||||
}
|
||||
|
||||
// The classic WebDriver session bootstrap is the only thing with a body.
|
||||
if (std.mem.startsWith(u8, request, "POST ") or std.mem.startsWith(u8, request, "DELETE ")) {
|
||||
if (!self.options.protocols.webdriver) {
|
||||
return error.NotFound;
|
||||
}
|
||||
return self.handleWebDriverRequest(request, head_len);
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, request[0..4], "GET ") == false) {
|
||||
return error.NotFound;
|
||||
}
|
||||
|
||||
// Everything else is a body-less GET: the header block is the request.
|
||||
if (head_len != request.len) {
|
||||
return .more;
|
||||
}
|
||||
|
||||
const url_end = std.mem.indexOfScalarPos(u8, request, 4, ' ') orelse {
|
||||
return error.InvalidRequest;
|
||||
};
|
||||
|
||||
const url = request[4..url_end];
|
||||
|
||||
if (std.mem.eql(u8, url, "/metrics") and self.app.config.metricsEndpointEnabled()) {
|
||||
try self.sendMetrics();
|
||||
self.shutdown();
|
||||
return .close;
|
||||
}
|
||||
|
||||
if (self.options.protocols.webdriver) {
|
||||
if (std.mem.eql(u8, url, "/session")) {
|
||||
// /session is the path Firefox advertises its BiDi endpoint on
|
||||
try self.upgrade(request);
|
||||
return .{ .upgrade = .{ .bidi = null } };
|
||||
}
|
||||
|
||||
if (std.mem.startsWith(u8, url, "/session/") and url.len == "/session/".len + 36) {
|
||||
// The URL a POST /session handed out; the session id is the suffix.
|
||||
var session_id: [36]u8 = undefined;
|
||||
@memcpy(&session_id, url["/session/".len..]);
|
||||
try self.upgrade(request);
|
||||
return .{ .upgrade = .{ .bidi = session_id } };
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, url, "/status")) {
|
||||
// WebDriver's discovery endpoint; `ready` is whether a new session
|
||||
// can be created, which the bootstrap never refuses.
|
||||
return self.sendWebDriver("200 OK", .{ .ready = true, .message = "" });
|
||||
}
|
||||
}
|
||||
|
||||
if (!self.options.protocols.cdp) {
|
||||
return error.NotFound;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, url, "/")) {
|
||||
try self.upgrade(request);
|
||||
return .{ .upgrade = .cdp };
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, url, "/json/version") or std.mem.eql(u8, url, "/json/version/")) {
|
||||
try self.send(self.options.json_version_response);
|
||||
// Chromedp (a Go driver) does an http request to /json/version
|
||||
// then to / (websocket upgrade) using a different connection.
|
||||
// Since we only allow 1 connection at a time, the 2nd one (the
|
||||
// websocket upgrade) blocks until the first one times out.
|
||||
// We can avoid that by closing the connection. json_version_response
|
||||
// has a Connection: Close header too.
|
||||
self.shutdown();
|
||||
return .close;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, url, "/json/list") or std.mem.eql(u8, url, "/json/list/") or
|
||||
std.mem.eql(u8, url, "/json") or std.mem.eql(u8, url, "/json/"))
|
||||
{
|
||||
try self.send(empty_json_list_response);
|
||||
self.shutdown();
|
||||
return .close;
|
||||
}
|
||||
|
||||
if (std.mem.eql(u8, url, "/json/protocol") or std.mem.eql(u8, url, "/json/protocol/")) {
|
||||
try self.send(protocol_response);
|
||||
self.shutdown();
|
||||
return .close;
|
||||
}
|
||||
|
||||
return error.NotFound;
|
||||
}
|
||||
|
||||
// TODO: Temporary solution that provides the bare minimum for Selenium to
|
||||
// connect. Serve a few of the (classic) WebDriver HTTP API. It's obvious that
|
||||
// Handshake.zig needs to become a more generic HTTP server/router, but that
|
||||
// can be done after the experimental BiDi code lands.
|
||||
fn handleWebDriverRequest(self: *Handshake, request: []const u8, head_len: usize) !Result {
|
||||
// A malformed request line or header maps to a 400 in processHttpRequest.
|
||||
const method, const path, _, var header_iterator = header_parser.parseRequest(request) catch {
|
||||
return error.InvalidProtocol;
|
||||
};
|
||||
|
||||
var content_length: usize = 0;
|
||||
while (header_iterator.next() catch return error.InvalidRequest) |header| {
|
||||
if (std.ascii.eqlIgnoreCase(header.key, "content-length")) {
|
||||
content_length = std.fmt.parseInt(usize, header.value, 10) catch return error.InvalidRequest;
|
||||
}
|
||||
}
|
||||
|
||||
const total_len = head_len + content_length;
|
||||
if (request.len < total_len) {
|
||||
return .more;
|
||||
}
|
||||
if (request.len > total_len) {
|
||||
return error.InvalidRequest;
|
||||
}
|
||||
const body = request[head_len..total_len];
|
||||
|
||||
switch (method) {
|
||||
.post => if (std.mem.eql(u8, path, "/session")) {
|
||||
return self.newSession(body);
|
||||
},
|
||||
.delete => if (std.mem.startsWith(u8, path, "/session/")) {
|
||||
return self.sendWebDriver("200 OK", null);
|
||||
},
|
||||
else => {},
|
||||
}
|
||||
return error.NotFound;
|
||||
}
|
||||
|
||||
fn newSession(self: *Handshake, body: []const u8) !Result {
|
||||
const allocator = self.app.allocator;
|
||||
|
||||
const Capability = struct { webSocketUrl: ?bool = null };
|
||||
const parsed = std.json.parseFromSlice(struct {
|
||||
capabilities: ?struct {
|
||||
alwaysMatch: ?Capability = null,
|
||||
firstMatch: ?[]const Capability = null,
|
||||
} = null,
|
||||
}, allocator, body, .{ .ignore_unknown_fields = true }) catch {
|
||||
return self.sendWebDriver("400 Bad Request", .{
|
||||
.@"error" = "invalid argument",
|
||||
.message = "invalid JSON body",
|
||||
.stacktrace = "",
|
||||
});
|
||||
};
|
||||
defer parsed.deinit();
|
||||
|
||||
// Without the capability the client intends to drive the session over
|
||||
// HTTP, which this server doesn't serve: tell it now rather than 404
|
||||
// its first real command.
|
||||
if (!requestsWebSocketUrl(parsed.value.capabilities)) {
|
||||
return self.sendWebDriver("500 Internal Server Error", .{
|
||||
.@"error" = "session not created",
|
||||
.message = "only WebDriver BiDi sessions are supported; request the webSocketUrl capability",
|
||||
.stacktrace = "",
|
||||
});
|
||||
}
|
||||
|
||||
var session_id: [36]u8 = undefined;
|
||||
uuidv4(&session_id);
|
||||
|
||||
const url = try std.fmt.allocPrint(allocator, "{s}{s}", .{ self.options.bidi_session_url, &session_id });
|
||||
defer allocator.free(url);
|
||||
|
||||
return self.sendWebDriver("200 OK", .{
|
||||
.sessionId = &session_id,
|
||||
.capabilities = bidi_session.Capabilities{
|
||||
.userAgent = self.app.config.http_headers.user_agent,
|
||||
.webSocketUrl = url,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
fn requestsWebSocketUrl(capabilities: anytype) bool {
|
||||
const caps = capabilities orelse return false;
|
||||
if (caps.alwaysMatch) |always| {
|
||||
if (always.webSocketUrl == true) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (caps.firstMatch orelse &.{}) |first| {
|
||||
if (first.webSocketUrl == true) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Answers a classic WebDriver request with {"value": value} and closes.
|
||||
fn sendWebDriver(self: *Handshake, comptime status: []const u8, value: anytype) !Result {
|
||||
const allocator = self.app.allocator;
|
||||
|
||||
var aw = try std.Io.Writer.Allocating.initCapacity(allocator, 512);
|
||||
defer aw.deinit();
|
||||
try std.json.Stringify.value(.{ .value = value }, .{}, &aw.writer);
|
||||
const body = aw.written();
|
||||
|
||||
const response = try std.fmt.allocPrint(allocator, "HTTP/1.1 " ++ status ++ "\r\n" ++
|
||||
"Content-Length: {d}\r\n" ++
|
||||
"Connection: Close\r\n" ++
|
||||
"Content-Type: application/json; charset=UTF-8\r\n\r\n" ++
|
||||
"{s}", .{ body.len, body });
|
||||
defer allocator.free(response);
|
||||
try self.send(response);
|
||||
self.shutdown();
|
||||
return .close;
|
||||
}
|
||||
|
||||
fn upgrade(self: *Handshake, request: []u8) !void {
|
||||
// We need to make sure that we got all the necessary headers + values;
|
||||
// a bit per required header.
|
||||
const FOUND_UPGRADE: u8 = 1 << 0; // Upgrade: websocket
|
||||
const FOUND_VERSION: u8 = 1 << 1; // Sec-WebSocket-Version: 13
|
||||
const FOUND_CONNECTION: u8 = 1 << 2; // Connection: upgrade
|
||||
const FOUND_KEY: u8 = 1 << 3; // Sec-WebSocket-Key
|
||||
const FOUND_ALL = FOUND_UPGRADE | FOUND_VERSION | FOUND_CONNECTION | FOUND_KEY;
|
||||
|
||||
// A malformed request line maps to a 400 in processHttpRequest.
|
||||
const method, _, const version, var header_iterator = header_parser.parseRequest(request) catch {
|
||||
return error.InvalidProtocol;
|
||||
};
|
||||
if (method != .get or version != .@"1.1") {
|
||||
return error.InvalidProtocol;
|
||||
}
|
||||
|
||||
var found_headers: u8 = 0;
|
||||
// We need to extract the `Sec-WebSocket-Key` value.
|
||||
var sec_websocket_key: []const u8 = "";
|
||||
|
||||
// A malformed header maps to a 400 in processHttpRequest.
|
||||
while (header_iterator.next() catch return error.InvalidRequest) |header| {
|
||||
const key = header.key;
|
||||
const value = header.value;
|
||||
|
||||
// Header names are case-insensitive; `Header.parse` keeps their
|
||||
// original casing.
|
||||
if (std.ascii.eqlIgnoreCase(key, "upgrade")) {
|
||||
if (!std.ascii.eqlIgnoreCase("websocket", value)) {
|
||||
return error.InvalidUpgradeHeader;
|
||||
}
|
||||
found_headers |= FOUND_UPGRADE;
|
||||
} else if (std.ascii.eqlIgnoreCase(key, "sec-websocket-version")) {
|
||||
if (value.len != 2 or value[0] != '1' or value[1] != '3') {
|
||||
return error.InvalidVersionHeader;
|
||||
}
|
||||
found_headers |= FOUND_VERSION;
|
||||
} else if (std.ascii.eqlIgnoreCase(key, "connection")) {
|
||||
// find if connection header has upgrade in it, example header:
|
||||
// Connection: keep-alive, Upgrade
|
||||
if (std.ascii.indexOfIgnoreCase(value, "upgrade") == null) {
|
||||
return error.InvalidConnectionHeader;
|
||||
}
|
||||
found_headers |= FOUND_CONNECTION;
|
||||
} else if (std.ascii.eqlIgnoreCase(key, "sec-websocket-key")) {
|
||||
sec_websocket_key = value;
|
||||
found_headers |= FOUND_KEY;
|
||||
} else if (std.ascii.eqlIgnoreCase(key, "origin")) {
|
||||
// Only a browser sends `Origin`, and a browser has no business
|
||||
// driving CDP: whatever page sent this is cross-origin to us by
|
||||
// definition, including one served from loopback itself. Scripted
|
||||
// clients (Puppeteer, Playwright, chromedp, ...) never send it.
|
||||
log.warn(.cdp, "rejected websocket origin", .{
|
||||
.origin = value[0..@min(value.len, 64)],
|
||||
});
|
||||
return error.ForbiddenOrigin;
|
||||
} else if (std.ascii.eqlIgnoreCase(key, "host")) {
|
||||
const host = value;
|
||||
const is_allowed = blk: {
|
||||
// allow literal localhost
|
||||
if (std.mem.startsWith(u8, host, "localhost:")) {
|
||||
break :blk true;
|
||||
}
|
||||
|
||||
_ = std.Io.net.IpAddress.parseLiteral(host) catch break :blk false;
|
||||
break :blk true;
|
||||
};
|
||||
|
||||
// Defense in depth against DNS rebinding: an IP literal is the only
|
||||
// thing that can legitimately reach us, because no name has to be
|
||||
// resolved to produce one. The one name we accept is
|
||||
// `localhost:<port>`, which browsers hardwire to loopback without
|
||||
// any DNS lookup. Any other name means something answered a DNS
|
||||
// lookup with our address, which is exactly what a rebinding
|
||||
// attack looks like. A request without a Host header isn't from a
|
||||
// browser, so it can't be the vector.
|
||||
if (!is_allowed) {
|
||||
log.warn(.cdp, "rejected websocket host", .{
|
||||
.host = host[0..@min(host.len, 64)],
|
||||
.hint = "connect to the CDP endpoint by IP address or localhost",
|
||||
});
|
||||
return error.ForbiddenHost;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we've received all related headers.
|
||||
if (found_headers != FOUND_ALL) {
|
||||
return error.MissingHeaders;
|
||||
}
|
||||
|
||||
// our caller has already made sure this request ended in \r\n\r\n
|
||||
// so it isn't something we need to check again
|
||||
|
||||
// Response to an upgrade request is always this, with the
|
||||
// Sec-Websocket-Accept value a special sha1 hash of the request
|
||||
// "sec-websocket-key" and a magic value.
|
||||
const template =
|
||||
"HTTP/1.1 101 Switching Protocols\r\n" ++
|
||||
"Upgrade: websocket\r\n" ++
|
||||
"Connection: upgrade\r\n" ++
|
||||
"Sec-Websocket-Accept: 0000000000000000000000000000\r\n\r\n";
|
||||
|
||||
var res: [template.len]u8 = template.*;
|
||||
|
||||
const key_pos = res.len - 32;
|
||||
var h: [20]u8 = undefined;
|
||||
var hasher = std.crypto.hash.Sha1.init(.{});
|
||||
hasher.update(sec_websocket_key);
|
||||
// websocket spec always used this value
|
||||
hasher.update("258EAFA5-E914-47DA-95CA-C5AB0DC85B11");
|
||||
hasher.final(&h);
|
||||
|
||||
_ = std.base64.standard.Encoder.encode(res[key_pos .. key_pos + 28], h[0..]);
|
||||
|
||||
return self.send(&res);
|
||||
}
|
||||
|
||||
fn sendMetrics(self: *Handshake) !void {
|
||||
const allocator = self.app.allocator;
|
||||
|
||||
var aw = try std.Io.Writer.Allocating.initCapacity(allocator, 4096);
|
||||
defer aw.deinit();
|
||||
lp.metrics.write(&aw.writer);
|
||||
const body = aw.written();
|
||||
|
||||
const response = try std.fmt.allocPrint(allocator, "HTTP/1.1 200 OK\r\n" ++
|
||||
"Content-Length: {d}\r\n" ++
|
||||
"Connection: Close\r\n" ++
|
||||
"Content-Type: text/plain; version=0.0.4; charset=utf-8\r\n\r\n" ++
|
||||
"{s}", .{ body.len, body });
|
||||
defer allocator.free(response);
|
||||
try self.send(response);
|
||||
}
|
||||
|
||||
fn sendHttpError(self: *Handshake, comptime status: u16, comptime body: []const u8) void {
|
||||
const response = std.fmt.comptimePrint(
|
||||
"HTTP/1.1 {d} \r\nConnection: Close\r\nContent-Length: {d}\r\n\r\n{s}",
|
||||
.{ status, body.len, body },
|
||||
);
|
||||
|
||||
// we're going to close this connection anyways, swallowing any
|
||||
// error seems safe
|
||||
self.send(response) catch {};
|
||||
}
|
||||
|
||||
// The socket is non-blocking (reads must never block once the network
|
||||
// thread owns them), but our responses are small one-shot writes, so on
|
||||
// WouldBlock we just wait for writability rather than queueing.
|
||||
fn send(self: *Handshake, data: []const u8) !void {
|
||||
var pos: usize = 0;
|
||||
while (pos < data.len) {
|
||||
const written = sys_net.write(self.socket, data[pos..]) catch |err| switch (err) {
|
||||
error.WouldBlock => {
|
||||
var pfds = [_]posix.pollfd{.{
|
||||
.fd = self.socket,
|
||||
.events = posix.POLL.OUT,
|
||||
.revents = 0,
|
||||
}};
|
||||
const n = try posix.poll(&pfds, 5000);
|
||||
if (n == 0) {
|
||||
return error.Timeout;
|
||||
}
|
||||
continue;
|
||||
},
|
||||
else => return err,
|
||||
};
|
||||
|
||||
if (written == 0) {
|
||||
return error.Closed;
|
||||
}
|
||||
pos += written;
|
||||
}
|
||||
}
|
||||
|
||||
fn shutdown(self: *Handshake) void {
|
||||
sys_net.shutdown(self.socket, .recv) catch {};
|
||||
}
|
||||
|
||||
const empty_json_list_response =
|
||||
"HTTP/1.1 200 OK\r\n" ++
|
||||
"Content-Length: 2\r\n" ++
|
||||
"Connection: Close\r\n" ++
|
||||
"Content-Type: application/json; charset=UTF-8\r\n\r\n" ++
|
||||
"[]";
|
||||
|
||||
const protocol_json = @embedFile("../data/protocol.json");
|
||||
|
||||
const protocol_response = std.fmt.comptimePrint(
|
||||
"HTTP/1.1 200 OK\r\n" ++
|
||||
"Content-Length: {d}\r\n" ++
|
||||
"Connection: Close\r\n" ++
|
||||
"Content-Type: application/json; charset=UTF-8\r\n\r\n",
|
||||
.{protocol_json.len},
|
||||
) ++ protocol_json;
|
||||
@@ -0,0 +1,380 @@
|
||||
// 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 App = @import("../App.zig");
|
||||
const Inbox = @import("../Inbox.zig");
|
||||
const ArenaPool = @import("../ArenaPool.zig");
|
||||
const sys_net = @import("../sys/net.zig");
|
||||
|
||||
const WS = @import("WS.zig");
|
||||
const CDP = @import("cdp/CDP.zig");
|
||||
const Driver = @import("Driver.zig");
|
||||
|
||||
const log = lp.log;
|
||||
const posix = std.posix;
|
||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
|
||||
// The worker's end of an upgraded connection (the loop's is Server.WebSocket).
|
||||
// Reads/framing happen on the server run loop (readAvailable → inbox); the worker
|
||||
// thread is the sole writer (send*). The two sides touch disjoint state
|
||||
// (reader+inbox vs send_arena+socket write) so no lock is needed beyond the
|
||||
// inbox's own.
|
||||
const Link = @This();
|
||||
|
||||
// A send that hits WouldBlock waits at most this long for the peer to take
|
||||
// more bytes.
|
||||
const SEND_TIMEOUT_MS = 5_000;
|
||||
|
||||
// The loop reads as fast as it can. The worker _can_ be slow to process its
|
||||
// inbox (e.g. stuck in a syncRequest). Still, we want _some_ limit on how much
|
||||
// data is queued. This is 32 * the configured max message size. Which should
|
||||
// be plenty for a well-behaving client.
|
||||
const INBOX_BACKLOG_MESSAGES = 32;
|
||||
|
||||
inbox: *Inbox,
|
||||
arena_pool: *ArenaPool,
|
||||
socket: posix.socket_t,
|
||||
protocol: Driver.Protocol,
|
||||
reader: WS.Reader,
|
||||
send_arena: ArenaAllocator,
|
||||
send_timeout_ms: i32,
|
||||
max_inbox_backlog: usize,
|
||||
|
||||
pub fn init(
|
||||
self: *Link,
|
||||
app: *App,
|
||||
socket: posix.socket_t,
|
||||
protocol: Driver.Protocol,
|
||||
inbox: *Inbox,
|
||||
) !void {
|
||||
if (lp.IS_TEST == false) {
|
||||
const socket_flags = try sys_net.fcntl(socket, posix.F.GETFL, 0);
|
||||
const nonblocking = @as(u32, @bitCast(posix.O{ .NONBLOCK = true }));
|
||||
lp.assert(socket_flags & nonblocking == nonblocking, "Link.init blocking", .{});
|
||||
}
|
||||
|
||||
const config = app.config;
|
||||
const allocator = app.allocator;
|
||||
|
||||
self.* = .{
|
||||
.inbox = inbox,
|
||||
.socket = socket,
|
||||
.protocol = protocol,
|
||||
.arena_pool = &app.arena_pool,
|
||||
.reader = try .init(allocator, config.cdpMaxMessageSize()),
|
||||
.send_arena = ArenaAllocator.init(allocator),
|
||||
.send_timeout_ms = SEND_TIMEOUT_MS,
|
||||
.max_inbox_backlog = @as(usize, config.cdpMaxMessageSize()) * INBOX_BACKLOG_MESSAGES,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Link) void {
|
||||
self.reader.deinit();
|
||||
self.send_arena.deinit();
|
||||
}
|
||||
|
||||
pub fn send(self: *Link, data: []const u8) !void {
|
||||
var pos: usize = 0;
|
||||
const socket = self.socket;
|
||||
defer _ = self.send_arena.reset(.{ .retain_with_limit = 1024 * 32 });
|
||||
|
||||
while (pos < data.len) {
|
||||
const written = sys_net.write(socket, data[pos..]) catch |err| switch (err) {
|
||||
// The socket is nonblocking so loop reads never stall. Writes are
|
||||
// simpler if they can wait: no per-connection pending-write queue
|
||||
// with its own allocations. Waiting is done with poll rather than
|
||||
// by flipping the fd to blocking: O_NONBLOCK lives on the open
|
||||
// file description, so a flip would reach the loop's reads too.
|
||||
// Should virtually never happen.
|
||||
error.WouldBlock => {
|
||||
// The socket is nonblocking so that the main read loop doesn't
|
||||
// block. But we don't want to make writes truly async, because
|
||||
// then we'd need to allocate the message and hook that back into
|
||||
// the main thread, so...we'll just pull until the we can write
|
||||
// or we hit our send timeout
|
||||
var fds = [_]posix.pollfd{.{ .fd = socket, .events = posix.POLL.OUT, .revents = 0 }};
|
||||
if ((try posix.poll(&fds, self.send_timeout_ms)) == 0) {
|
||||
return error.Timeout;
|
||||
}
|
||||
continue;
|
||||
},
|
||||
// a signal landed mid-write; nothing was written
|
||||
error.Interrupted => continue,
|
||||
else => return err,
|
||||
};
|
||||
|
||||
if (written == 0) {
|
||||
return error.Closed;
|
||||
}
|
||||
pos += written;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sendPong(self: *Link, data: []const u8) !void {
|
||||
if (data.len == 0) {
|
||||
return self.send(&WS.EMPTY_PONG);
|
||||
}
|
||||
var header_buf: [10]u8 = undefined;
|
||||
const header = WS.frameHeader(&header_buf, .pong, data.len);
|
||||
|
||||
const allocator = self.send_arena.allocator();
|
||||
const framed = try allocator.alloc(u8, header.len + data.len);
|
||||
@memcpy(framed[0..header.len], header);
|
||||
@memcpy(framed[header.len..], data);
|
||||
return self.send(framed);
|
||||
}
|
||||
|
||||
// Websocket frames have a variable-length header (2-10 bytes server->client).
|
||||
// We serialize into a buffer whose first 10 bytes are reserved, then
|
||||
// backfill the header right-aligned and send the slice.
|
||||
pub fn sendJSON(self: *Link, message: anytype, opts: std.json.Stringify.Options) !void {
|
||||
const allocator = self.send_arena.allocator();
|
||||
|
||||
var aw = try std.Io.Writer.Allocating.initCapacity(allocator, 512);
|
||||
try aw.writer.writeAll(&[_]u8{0} ** 10);
|
||||
try std.json.Stringify.value(message, opts, &aw.writer);
|
||||
const framed = WS.fillHeader(aw.toArrayList());
|
||||
return self.send(framed);
|
||||
}
|
||||
|
||||
pub fn sendJSONRaw(self: *Link, buf: std.ArrayList(u8)) !void {
|
||||
// Dangerous API! Assumes the caller reserved the first 10 bytes in buf.
|
||||
const framed = WS.fillHeader(buf);
|
||||
return self.send(framed);
|
||||
}
|
||||
|
||||
pub const Read = struct {
|
||||
// false once a close frame was consumed: stop reading, the worker
|
||||
// replies and disconnects itself
|
||||
keep: bool,
|
||||
// at least one frame landed in the inbox
|
||||
pushed: bool,
|
||||
};
|
||||
|
||||
// Server loop. The socket is readable
|
||||
pub fn readAvailable(self: *Link, budget: usize) !Read {
|
||||
if (self.inbox.queuedBytes() >= self.max_inbox_backlog) {
|
||||
lp.metrics.serve_inbox_backlog.incr();
|
||||
return error.InboxBacklog;
|
||||
}
|
||||
|
||||
var pushed = false;
|
||||
var remaining = budget;
|
||||
while (remaining > 0) {
|
||||
const dst = self.reader.readBuf();
|
||||
if (dst.len == 0) {
|
||||
// a partial message already fills the buffer
|
||||
return error.TooLarge;
|
||||
}
|
||||
const want = dst[0..@min(dst.len, remaining)];
|
||||
const n = posix.read(self.socket, want) catch |err| switch (err) {
|
||||
error.WouldBlock => break,
|
||||
else => return err,
|
||||
};
|
||||
if (n == 0) {
|
||||
return error.Closed;
|
||||
}
|
||||
self.reader.len += n;
|
||||
if ((try self.processMessages(&pushed)) == false) {
|
||||
return .{ .keep = false, .pushed = pushed };
|
||||
}
|
||||
remaining -= n;
|
||||
if (n < want.len) {
|
||||
// a short read: the socket is (very likely) drained
|
||||
break;
|
||||
}
|
||||
}
|
||||
return .{ .keep = true, .pushed = pushed };
|
||||
}
|
||||
|
||||
fn processMessages(self: *Link, pushed: *bool) !bool {
|
||||
var reader = &self.reader;
|
||||
while (true) {
|
||||
const msg = (try reader.next()) orelse break;
|
||||
|
||||
const keep = switch (msg.type) {
|
||||
.pong => true,
|
||||
.ping, .text, .binary => try self.handleMessage(msg, pushed),
|
||||
.close => blk: {
|
||||
_ = try self.handleMessage(msg, pushed);
|
||||
break :blk false;
|
||||
},
|
||||
};
|
||||
|
||||
if (msg.cleanup_fragment) {
|
||||
reader.cleanup();
|
||||
}
|
||||
if (!keep) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
reader.compact();
|
||||
return true;
|
||||
}
|
||||
|
||||
fn handleMessage(self: *Link, msg: WS.Message, pushed: *bool) !bool {
|
||||
switch (msg.type) {
|
||||
.text, .binary => return switch (self.protocol) {
|
||||
.cdp => self.pushCdp(msg.data, pushed),
|
||||
.bidi => self.pushBiDi(msg.data, pushed),
|
||||
},
|
||||
.ping => {
|
||||
const arena = try self.arena_pool.acquire(.tiny, "ws ping");
|
||||
errdefer arena.release();
|
||||
self.inbox.push(arena, .{ .ping = try arena.dupe(u8, msg.data) });
|
||||
pushed.* = true;
|
||||
return true;
|
||||
},
|
||||
.close => {
|
||||
const arena = try self.arena_pool.acquire(.tiny, "ws close");
|
||||
self.inbox.push(arena, .close);
|
||||
pushed.* = true;
|
||||
return true;
|
||||
},
|
||||
.pong => unreachable, // processMessages skips pong
|
||||
}
|
||||
}
|
||||
|
||||
// Parse a CDP JSON frame on the run loop and push it already-parsed: the
|
||||
// consumer's allowlist works on input.method directly and the worker
|
||||
// doesn't re-parse. On parse failure push .disconnect(InvalidJSON) so the
|
||||
// worker tears down, same as a fatal framing error.
|
||||
fn pushCdp(self: *Link, bytes: []const u8, pushed: *bool) !bool {
|
||||
const arena = try self.arena_pool.acquire(bytes.len, "cdp data");
|
||||
errdefer arena.release();
|
||||
|
||||
const raw = try arena.dupe(u8, bytes);
|
||||
const input = std.json.parseFromSliceLeaky(
|
||||
CDP.InputMessage,
|
||||
arena.allocator(),
|
||||
raw,
|
||||
.{ .ignore_unknown_fields = true },
|
||||
) catch {
|
||||
self.inbox.push(arena, .{ .disconnect = error.InvalidJSON });
|
||||
pushed.* = true;
|
||||
return false;
|
||||
};
|
||||
|
||||
self.inbox.push(arena, .{ .cdp = .{ .raw = raw, .input = input } });
|
||||
pushed.* = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// BiDi frames are pushed raw; the worker parses them.
|
||||
fn pushBiDi(self: *Link, bytes: []const u8, pushed: *bool) !bool {
|
||||
const arena = try self.arena_pool.acquire(bytes.len, "bidi data");
|
||||
errdefer arena.release();
|
||||
self.inbox.push(arena, .{ .bidi = try arena.dupe(u8, bytes) });
|
||||
pushed.* = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Server loop, closing only the read side. The worker can still send a message
|
||||
// (e.g. a close frame).
|
||||
pub fn shutdown(self: *Link) void {
|
||||
sys_net.shutdown(self.socket, .recv) catch {};
|
||||
}
|
||||
|
||||
const testing = @import("../testing.zig");
|
||||
|
||||
test "link: send gives up when the peer stops reading" {
|
||||
var pair: [2]posix.socket_t = undefined;
|
||||
if (std.c.socketpair(posix.AF.LOCAL, posix.SOCK.STREAM, 0, &pair) != 0) {
|
||||
return error.SocketPairFailed;
|
||||
}
|
||||
defer sys_net.close(pair[0]);
|
||||
defer sys_net.close(pair[1]);
|
||||
|
||||
const small = std.mem.toBytes(@as(c_int, 4096));
|
||||
try posix.setsockopt(pair[0], posix.SOL.SOCKET, posix.SO.RCVBUF, &small);
|
||||
try posix.setsockopt(pair[1], posix.SOL.SOCKET, posix.SO.SNDBUF, &small);
|
||||
|
||||
const nonblocking = @as(u32, @bitCast(posix.O{ .NONBLOCK = true }));
|
||||
const flags = try sys_net.fcntl(pair[1], posix.F.GETFL, 0);
|
||||
_ = try sys_net.fcntl(pair[1], posix.F.SETFL, flags | nonblocking);
|
||||
|
||||
var inbox: Inbox = .{};
|
||||
defer inbox.deinit();
|
||||
|
||||
var link: Link = undefined;
|
||||
try link.init(testing.test_app, pair[1], .cdp, &inbox);
|
||||
defer link.deinit();
|
||||
|
||||
// shorten the wait so the test doesn't sit out the real one
|
||||
link.send_timeout_ms = 50;
|
||||
|
||||
const payload = try testing.allocator.alloc(u8, 1024 * 1024);
|
||||
defer testing.allocator.free(payload);
|
||||
@memset(payload, 'a');
|
||||
|
||||
// Nobody drains pair[0]. send() waits for writability to finish the
|
||||
// write; unbounded, that parks the worker forever and, because shutdown
|
||||
// only half-closes the read side, hangs the whole process on SIGINT.
|
||||
try testing.expectError(error.Timeout, link.send(payload));
|
||||
|
||||
// and the run loop's reads share the fd: it must still be non-blocking
|
||||
try testing.expectEqual(flags | nonblocking, try sys_net.fcntl(pair[1], posix.F.GETFL, 0));
|
||||
}
|
||||
|
||||
test "link: stops reading once the worker's inbox backs up" {
|
||||
var pair: [2]posix.socket_t = undefined;
|
||||
if (std.c.socketpair(posix.AF.LOCAL, posix.SOCK.STREAM, 0, &pair) != 0) {
|
||||
return error.SocketPairFailed;
|
||||
}
|
||||
defer sys_net.close(pair[0]);
|
||||
defer sys_net.close(pair[1]);
|
||||
|
||||
const nonblocking = @as(u32, @bitCast(posix.O{ .NONBLOCK = true }));
|
||||
const flags = try sys_net.fcntl(pair[1], posix.F.GETFL, 0);
|
||||
_ = try sys_net.fcntl(pair[1], posix.F.SETFL, flags | nonblocking);
|
||||
|
||||
var inbox: Inbox = .{};
|
||||
defer inbox.deinit();
|
||||
|
||||
var link: Link = undefined;
|
||||
try link.init(testing.test_app, pair[1], .cdp, &inbox);
|
||||
defer link.deinit();
|
||||
|
||||
// a ceiling below one max-size message would refuse what was configured
|
||||
try testing.expect(link.max_inbox_backlog > testing.test_app.config.cdpMaxMessageSize());
|
||||
|
||||
// an empty inbox reads normally (nothing pending, so nothing pushed)
|
||||
const read = try link.readAvailable(1024);
|
||||
try testing.expectEqual(true, read.keep);
|
||||
try testing.expectEqual(false, read.pushed);
|
||||
|
||||
// a worker that has fallen this far behind isn't going to catch up
|
||||
{
|
||||
const arena = try testing.test_app.arena_pool.acquire(link.max_inbox_backlog, "backlog test");
|
||||
const payload = try arena.allocator().alloc(u8, link.max_inbox_backlog);
|
||||
inbox.push(arena, .{ .bidi = payload });
|
||||
}
|
||||
try testing.expectEqual(link.max_inbox_backlog, inbox.queuedBytes());
|
||||
try testing.expectError(error.InboxBacklog, link.readAvailable(1024));
|
||||
|
||||
// and it recovers once the worker drains
|
||||
{
|
||||
const msg = inbox.pop().?;
|
||||
defer msg.deinit();
|
||||
}
|
||||
try testing.expectEqual(0, inbox.queuedBytes());
|
||||
_ = try link.readAvailable(1024);
|
||||
}
|
||||
+1441
-917
File diff suppressed because it is too large.
Load diff
@@ -108,13 +108,18 @@ pub fn fillHeader(buf: std.ArrayList(u8)) []const u8 {
|
||||
const RECLAIM_TO = 256 * 1024;
|
||||
const RECLAIM_AFTER = 8;
|
||||
|
||||
// WebSocket message reader. Given websocket message, acts as an iterator that
|
||||
// can return zero or more Messages. When next returns null, any incomplete
|
||||
// message will remain in reader.data
|
||||
pub fn Reader(comptime EXPECT_MASK: bool) type {
|
||||
pub const Reader = ReaderM(true);
|
||||
pub const ReaderNoMask = ReaderM(false);
|
||||
|
||||
// WebSocket and HTTP aware reader. EXPECT_MASK is always true, (since this is
|
||||
// only used to read server mesages) except for testing, where we setup test
|
||||
// clients.
|
||||
fn ReaderM(comptime EXPECT_MASK: bool) type {
|
||||
return struct {
|
||||
allocator: Allocator,
|
||||
|
||||
buf: []u8,
|
||||
|
||||
// position in buf of the start of the next message
|
||||
pos: usize = 0,
|
||||
|
||||
@@ -124,8 +129,6 @@ pub fn Reader(comptime EXPECT_MASK: bool) type {
|
||||
|
||||
max_message_size: usize,
|
||||
|
||||
buf: []u8,
|
||||
|
||||
fragments: ?Fragments = null,
|
||||
|
||||
// consecutive messages we've received which fit i RECLAIM_TO
|
||||
@@ -220,7 +223,9 @@ pub fn Reader(comptime EXPECT_MASK: bool) type {
|
||||
buf = self.buf[0..len];
|
||||
// we need more data
|
||||
return null;
|
||||
} else if (buf.len < message_len) {
|
||||
}
|
||||
|
||||
if (buf.len < message_len) {
|
||||
// we need more data
|
||||
return null;
|
||||
}
|
||||
@@ -393,7 +398,7 @@ pub fn Reader(comptime EXPECT_MASK: bool) type {
|
||||
// don't need to narrow it first; unrecognized errors return null.
|
||||
pub fn errorReply(err: anyerror) ?[]const u8 {
|
||||
return switch (err) {
|
||||
error.TooLarge => &CLOSE_TOO_BIG,
|
||||
error.TooLarge, error.InboxBacklog => &CLOSE_TOO_BIG,
|
||||
error.Masked,
|
||||
error.NotMasked,
|
||||
error.ReservedFlags,
|
||||
@@ -537,7 +542,7 @@ fn feedAndDrain(reader: anytype, frame: []const u8) !void {
|
||||
|
||||
test "reader: reclaims buffer after a run of small messages" {
|
||||
const allocator = testing.allocator;
|
||||
var reader = try Reader(false).init(allocator, 4 * 1024 * 1024);
|
||||
var reader = try ReaderNoMask.init(allocator, 4 * 1024 * 1024);
|
||||
defer reader.deinit();
|
||||
|
||||
// A large message forces the buffer to grow well past RECLAIM_TO.
|
||||
@@ -577,3 +582,31 @@ test "reader: reclaims buffer after a run of small messages" {
|
||||
try testing.expect(reader.buf.len > RECLAIM_TO);
|
||||
try testing.expectEqual(@as(usize, 0), reader.small_message_streak);
|
||||
}
|
||||
|
||||
test "reader: control frame arriving in pieces" {
|
||||
const allocator = testing.allocator;
|
||||
var reader = try ReaderNoMask.init(allocator, 1024 * 1024);
|
||||
defer reader.deinit();
|
||||
|
||||
// A ping with a 114 byte payload; only the header and 50 bytes of it
|
||||
// have arrived. The control branch skips the "is the whole frame here"
|
||||
// check that the data branches do, so next() used to slice past len.
|
||||
var frame: [2 + 114]u8 = undefined;
|
||||
frame[0] = 128 | 9; // FIN + ping
|
||||
frame[1] = 114;
|
||||
@memset(frame[2..], 'a');
|
||||
|
||||
const partial = frame[0 .. 2 + 50];
|
||||
@memcpy(reader.readBuf()[0..partial.len], partial);
|
||||
reader.len += partial.len;
|
||||
try testing.expectEqual(@as(?Message, null), try reader.next());
|
||||
|
||||
// the rest arrives
|
||||
const rest = frame[2 + 50 ..];
|
||||
@memcpy(reader.readBuf()[0..rest.len], rest);
|
||||
reader.len += rest.len;
|
||||
|
||||
const msg = (try reader.next()) orelse return error.NoMessage;
|
||||
try testing.expectEqual(.ping, msg.type);
|
||||
try testing.expectEqual(114, msg.data.len);
|
||||
}
|
||||
@@ -25,11 +25,10 @@ const Server = @import("../Server.zig");
|
||||
const Browser = @import("../../browser/Browser.zig");
|
||||
const Session = @import("../../browser/Session.zig");
|
||||
const Notification = @import("../../Notification.zig");
|
||||
|
||||
const NodeRegistry = @import("../../NodeRegistry.zig");
|
||||
|
||||
const Link = @import("../Link.zig");
|
||||
const Driver = @import("../Driver.zig");
|
||||
const Connection = @import("../Connection.zig");
|
||||
|
||||
const script = @import("script.zig");
|
||||
const remote_value = @import("remote_value.zig");
|
||||
@@ -40,11 +39,7 @@ const Allocator = std.mem.Allocator;
|
||||
const BiDi = @This();
|
||||
|
||||
app: *App,
|
||||
conn: Connection,
|
||||
|
||||
// Server run-loop read-side handle for the socket. Server registers it
|
||||
// after the handshake and unregisters before teardown; see CDP.zig.
|
||||
link: Server.Link,
|
||||
conn: Link,
|
||||
|
||||
// Re-used arena for processing a message. Works because we strictly process
|
||||
// one message at a time.
|
||||
@@ -93,7 +88,6 @@ pub fn init(self: *BiDi, app: *App, socket: posix.socket_t, session_id: ?[36]u8)
|
||||
const allocator = app.allocator;
|
||||
self.* = .{
|
||||
.app = app,
|
||||
.link = undefined,
|
||||
.conn = undefined,
|
||||
.browser = undefined,
|
||||
.user_context = undefined,
|
||||
@@ -105,21 +99,14 @@ pub fn init(self: *BiDi, app: *App, socket: posix.socket_t, session_id: ?[36]u8)
|
||||
.session_arena = std.heap.ArenaAllocator.init(allocator),
|
||||
};
|
||||
|
||||
const driver: Driver = .init(.{ .bidi = self });
|
||||
const driver = Driver.init(.{ .bidi = self });
|
||||
|
||||
try self.browser.init(app, .{}, driver);
|
||||
errdefer self.browser.deinit();
|
||||
|
||||
const http_client = &self.browser.http_client;
|
||||
try self.conn.init(app, socket, .bidi, &http_client.inbox);
|
||||
try self.conn.init(app, socket, .bidi, &self.browser.http_client.inbox);
|
||||
errdefer self.conn.deinit();
|
||||
|
||||
self.link = .{
|
||||
.driver = driver,
|
||||
.state = .live,
|
||||
.socket = socket,
|
||||
.handles = http_client.handles,
|
||||
};
|
||||
self.notification = try Notification.init(allocator);
|
||||
errdefer self.notification.deinit();
|
||||
|
||||
|
||||
+13
-31
@@ -23,27 +23,27 @@ const App = @import("../../App.zig");
|
||||
const Inbox = @import("../../Inbox.zig");
|
||||
const Notification = @import("../../Notification.zig");
|
||||
|
||||
const WS = @import("../../network/WS.zig");
|
||||
const http = @import("../../network/http.zig");
|
||||
const Server = @import("../Server.zig");
|
||||
const HttpClient = @import("../../network/HttpClient.zig");
|
||||
|
||||
const js = @import("../../browser/js/js.zig");
|
||||
const Browser = @import("../../browser/Browser.zig");
|
||||
const Session = @import("../../browser/Session.zig");
|
||||
const Frame = @import("../../browser/Frame.zig");
|
||||
const Page = @import("../../browser/Page.zig");
|
||||
const Mime = @import("../../browser/Mime.zig");
|
||||
const Frame = @import("../../browser/Frame.zig");
|
||||
const Browser = @import("../../browser/Browser.zig");
|
||||
const Session = @import("../../browser/Session.zig");
|
||||
const Element = @import("../../browser/webapi/Element.zig");
|
||||
const Label = @import("../../browser/webapi/element/html/Label.zig");
|
||||
|
||||
const Connection = @import("../Connection.zig");
|
||||
const WS = @import("../WS.zig");
|
||||
const Link = @import("../Link.zig");
|
||||
const Server = @import("../Server.zig");
|
||||
const Driver = @import("../Driver.zig");
|
||||
const Incrementing = @import("id.zig").Incrementing;
|
||||
|
||||
const fetch = @import("domains/fetch.zig");
|
||||
const network_domain = @import("domains/network.zig");
|
||||
|
||||
const js = lp.js;
|
||||
const log = lp.log;
|
||||
const json = std.json;
|
||||
const posix = std.posix;
|
||||
@@ -62,15 +62,10 @@ pub const InvocationIdGen = Incrementing(u32, "INV");
|
||||
const CDP = @This();
|
||||
|
||||
app: *App,
|
||||
conn: Connection,
|
||||
conn: Link,
|
||||
browser: Browser,
|
||||
allocator: Allocator,
|
||||
|
||||
// Server run-loop read-side handle for the CDP socket. Populated in
|
||||
// init; Server.serve calls registerLink(&cdp.link) after the
|
||||
// worker-side handshake completes, and unregisterLink before teardown.
|
||||
link: Server.Link,
|
||||
|
||||
// when true, any target creation must be attached.
|
||||
target_auto_attach: bool = false,
|
||||
|
||||
@@ -103,16 +98,11 @@ browser_context_arena: std.heap.ArenaAllocator,
|
||||
// Files handed out as IO stream handles (Page.printToPDF ReturnAsStream).
|
||||
streams: @import("domains/io.zig").Streams,
|
||||
|
||||
pub fn init(
|
||||
self: *CDP,
|
||||
app: *App,
|
||||
socket: posix.socket_t,
|
||||
) !void {
|
||||
pub fn init(self: *CDP, app: *App, socket: posix.socket_t) !void {
|
||||
const allocator = app.allocator;
|
||||
|
||||
self.* = .{
|
||||
.app = app,
|
||||
.link = undefined,
|
||||
.conn = undefined,
|
||||
.browser = undefined,
|
||||
.allocator = allocator,
|
||||
@@ -124,20 +114,12 @@ pub fn init(
|
||||
.streams = .{ .allocator = allocator },
|
||||
};
|
||||
|
||||
const driver: Driver = .init(.{ .cdp = self });
|
||||
const driver = Driver.init(.{ .cdp = self });
|
||||
|
||||
try self.browser.init(app, .{ .env = .{ .with_inspector = true } }, driver);
|
||||
const http_client = &self.browser.http_client;
|
||||
errdefer self.browser.deinit();
|
||||
|
||||
try self.conn.init(app, socket, .cdp, &http_client.inbox);
|
||||
errdefer self.conn.deinit();
|
||||
|
||||
self.link = .{
|
||||
.driver = driver,
|
||||
.state = .live,
|
||||
.socket = socket,
|
||||
.handles = http_client.handles,
|
||||
};
|
||||
try self.conn.init(app, socket, .cdp, &self.browser.http_client.inbox);
|
||||
}
|
||||
|
||||
pub fn deinit(self: *CDP) void {
|
||||
@@ -1355,7 +1337,7 @@ pub const Command = struct {
|
||||
|
||||
// When we parse a JSON message from the client, this is the structure
|
||||
// we always expect. Parsed on the Network thread inside
|
||||
// Connection.handleMessage; the slices reference the raw JSON bytes
|
||||
// Link.handleMessage; the slices reference the raw JSON bytes
|
||||
// (or arena allocations for fields that needed unescaping). Both
|
||||
// outlive the InputMessage for the inbox message's lifetime.
|
||||
pub const InputMessage = struct {
|
||||
|
||||
@@ -0,0 +1,929 @@
|
||||
// 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 App = @import("../App.zig");
|
||||
const sys_net = @import("../sys/net.zig");
|
||||
const header_parser = @import("../network/header_parser.zig");
|
||||
const statusCategory = @import("../network/http.zig").statusCategory;
|
||||
|
||||
const Server = @import("Server.zig");
|
||||
const Driver = @import("Driver.zig");
|
||||
const bidi_session = @import("bidi/session.zig");
|
||||
const uuidv4 = @import("../id.zig").uuidv4;
|
||||
|
||||
const log = lp.log;
|
||||
const posix = std.posix;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
// A client connection in its http phase: loop-owned, pooled.
|
||||
pub const Connection = struct {
|
||||
state: State,
|
||||
buffer: Buffer,
|
||||
socket: posix.socket_t,
|
||||
address: sys_net.IpAddress,
|
||||
node: std.DoublyLinkedList.Node,
|
||||
|
||||
// When a keepalive (or just connected) connection should be closed
|
||||
deadline: u64,
|
||||
|
||||
// Response that couldn't be sent without blocking. Socket will switch to
|
||||
// "write-mode" until it's drained.
|
||||
pending: ?Writing,
|
||||
|
||||
pub const Writing = struct {
|
||||
pos: usize, // how ,uch of Data we've already written
|
||||
data: Data,
|
||||
keepalive: bool,
|
||||
|
||||
pub const Data = union(enum) {
|
||||
// copied out of the server's scratch buffer; freed once written
|
||||
owned: []const u8,
|
||||
|
||||
// lives as long as the server; referenced, never freed
|
||||
static: []const u8,
|
||||
};
|
||||
|
||||
pub fn remaining(self: *const Writing) []const u8 {
|
||||
return switch (self.data) {
|
||||
inline else => |d| d[self.pos..],
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *const Writing, allocator: Allocator) void {
|
||||
switch (self.data) {
|
||||
.static => {},
|
||||
.owned => |owned| allocator.free(owned),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
pub fn deinit(self: *Connection) void {
|
||||
self.buffer.deinit();
|
||||
}
|
||||
|
||||
// True if the request is in keepalive state and thus is a candidate to be
|
||||
// closed if we need its slot for a new connection.
|
||||
pub fn isIdle(self: *const Connection) bool {
|
||||
if (self.pending != null or self.buffer.len != 0) {
|
||||
// has a pending write, or has extra data to read
|
||||
return false;
|
||||
}
|
||||
return self.state == .header;
|
||||
}
|
||||
|
||||
pub const Request = struct {
|
||||
method: Method,
|
||||
// origin-form, query string stripped, always starts with '/'
|
||||
path: []const u8,
|
||||
keepalive: bool,
|
||||
body: []const u8,
|
||||
|
||||
// The raw request head (request line + headers, through the final
|
||||
// CRLF CRLF); a slice into the read buffer. Upgrade handlers re-parse
|
||||
// it for the WebSocket headers.
|
||||
head: []const u8,
|
||||
|
||||
// Filled in by the router for /session/{id}[/...] routes; points
|
||||
// into the read buffer like path does.
|
||||
session_id: ?*const [36]u8 = null,
|
||||
};
|
||||
|
||||
pub const Method = enum {
|
||||
GET,
|
||||
POST,
|
||||
PUT,
|
||||
DELETE,
|
||||
};
|
||||
|
||||
pub const State = union(enum) {
|
||||
header: void, // still parsing the header
|
||||
request: Request,
|
||||
|
||||
pub fn parseHeader(self: *State, data: []u8) !bool {
|
||||
const header_index = std.mem.indexOf(u8, data, "\r\n\r\n") orelse {
|
||||
return false;
|
||||
};
|
||||
|
||||
// include the last line's \r\n so every line, including the request
|
||||
// line of a header-less request, is terminated
|
||||
const header = data[0 .. header_index + 2];
|
||||
const method, const path, const keepalive, const line_1_end = try parseRequestLine(header);
|
||||
|
||||
_ = line_1_end;
|
||||
const body_start = header_index + 4;
|
||||
const total = body_start + try contentLength(header);
|
||||
if (data.len < total) {
|
||||
// the body is still arriving
|
||||
return false;
|
||||
}
|
||||
// A WebSocket upgrade may be pipelined with its first frames, but every
|
||||
// client we care about waits for the 101 first. Anything past the
|
||||
// declared body is unsupported (and rejects pipelining).
|
||||
if (data.len != total) {
|
||||
return error.BodyNotSupported;
|
||||
}
|
||||
|
||||
self.* = .{ .request = .{
|
||||
.method = method,
|
||||
.path = path,
|
||||
.keepalive = keepalive,
|
||||
.body = data[body_start..total],
|
||||
.head = data[0..body_start],
|
||||
} };
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// The classic WebDriver bootstrap (POST /session) is the only thing
|
||||
// that sends a body; everything else is 0.
|
||||
fn contentLength(header: []const u8) !usize {
|
||||
const key = "\r\ncontent-length:";
|
||||
const at = std.ascii.indexOfIgnoreCase(header, key) orelse return 0;
|
||||
const start = at + key.len;
|
||||
const end = std.mem.indexOfPos(u8, header, start, "\r\n") orelse return error.InvalidHeader;
|
||||
const value = std.mem.trim(u8, header[start..end], " \t");
|
||||
return std.fmt.parseInt(usize, value, 10) catch error.InvalidHeader;
|
||||
}
|
||||
|
||||
fn parseRequestLine(header: []const u8) !struct { Method, []const u8, bool, usize } {
|
||||
const l1 = std.mem.indexOfScalar(u8, header, '\r') orelse return error.InvalidHeader;
|
||||
if (l1 == header.len) {
|
||||
return error.InvalidHeader;
|
||||
}
|
||||
if (header[l1 + 1] != '\n') {
|
||||
return error.InvalidHeader;
|
||||
}
|
||||
|
||||
var it = std.mem.tokenizeScalar(u8, header[0..l1], ' ');
|
||||
const method = std.meta.stringToEnum(Method, it.next() orelse return error.InvalidHeader) orelse return error.InvalidHTTPMethod;
|
||||
|
||||
// Only the origin-form request-target is accepted; nothing we serve
|
||||
// reads the query string, so it's dropped here.
|
||||
const target = it.next() orelse return error.InvalidHeader;
|
||||
if (target[0] != '/') {
|
||||
return error.InvalidHeader;
|
||||
}
|
||||
const path = target[0 .. std.mem.indexOfScalar(u8, target, '?') orelse target.len];
|
||||
|
||||
const protocol = it.next() orelse return error.InvalidHeader;
|
||||
const keepalive = std.mem.indexOf(u8, protocol, "1.0") == null;
|
||||
|
||||
return .{ method, path, keepalive, l1 };
|
||||
}
|
||||
};
|
||||
|
||||
const Buffer = struct {
|
||||
buf: []u8,
|
||||
|
||||
// position in buf up until where we have valid data
|
||||
len: usize,
|
||||
|
||||
allocator: Allocator,
|
||||
|
||||
fn init(allocator: Allocator, size: usize) !Buffer {
|
||||
return .{
|
||||
.len = 0,
|
||||
.buf = try allocator.alloc(u8, size),
|
||||
.allocator = allocator,
|
||||
};
|
||||
}
|
||||
|
||||
fn deinit(self: *const Buffer) void {
|
||||
self.allocator.free(self.buf);
|
||||
}
|
||||
|
||||
pub fn read(self: *Buffer, socket: posix.socket_t) ![]u8 {
|
||||
const len = self.len;
|
||||
if (len == self.buf.len) {
|
||||
return error.RequestTooLarge;
|
||||
}
|
||||
|
||||
const n = try posix.read(socket, self.buf[len..]);
|
||||
if (n == 0) {
|
||||
return error.ConnectionClosed;
|
||||
}
|
||||
const total = len + n;
|
||||
self.len = total;
|
||||
return self.buf[0..total];
|
||||
}
|
||||
};
|
||||
|
||||
pub const Pool = struct {
|
||||
allocator: Allocator,
|
||||
free: std.DoublyLinkedList,
|
||||
live: usize, // acquired and not yet released
|
||||
retain: usize, // min # to keep
|
||||
free_count: usize, // # of connections available in free
|
||||
buffer_size: usize, // --cdp-max-http-message-size
|
||||
|
||||
pub fn init(app: *App) !Pool {
|
||||
const retain = app.config.maxConnections();
|
||||
var self = Pool{
|
||||
.live = 0,
|
||||
.free = .{},
|
||||
.free_count = 0,
|
||||
.retain = retain,
|
||||
.allocator = app.allocator,
|
||||
.buffer_size = app.config.cdpMaxHTTPMessageSize(),
|
||||
};
|
||||
errdefer self.deinit();
|
||||
|
||||
for (0..retain) |_| {
|
||||
const conn = try self.create();
|
||||
self.free.append(&conn.node);
|
||||
self.free_count += 1;
|
||||
}
|
||||
return self;
|
||||
}
|
||||
|
||||
// Every live connection must have been released (the server disconnects
|
||||
// them all on deinit).
|
||||
pub fn deinit(self: *Pool) void {
|
||||
lp.assert(self.live == 0, "Connection.Pool.deinit live", .{ .live = self.live });
|
||||
while (self.free.popFirst()) |node| {
|
||||
const conn: *Connection = @fieldParentPtr("node", node);
|
||||
self.destroy(conn);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn acquire(self: *Pool) !*Connection {
|
||||
const conn = blk: {
|
||||
if (self.free.popFirst()) |node| {
|
||||
self.free_count -= 1;
|
||||
break :blk @as(*Connection, @fieldParentPtr("node", node));
|
||||
}
|
||||
break :blk try self.create();
|
||||
};
|
||||
self.live += 1;
|
||||
return conn;
|
||||
}
|
||||
|
||||
pub fn release(self: *Pool, conn: *Connection) void {
|
||||
self.live -= 1;
|
||||
if (self.free_count == self.retain) {
|
||||
return self.destroy(conn);
|
||||
}
|
||||
|
||||
conn.node = .{};
|
||||
conn.socket = -1;
|
||||
conn.address = .{ .ip4 = .unspecified(0) };
|
||||
conn.deadline = 0;
|
||||
conn.pending = null;
|
||||
conn.buffer.len = 0;
|
||||
conn.state = .header;
|
||||
|
||||
self.free.prepend(&conn.node);
|
||||
self.free_count += 1;
|
||||
}
|
||||
|
||||
fn create(self: *Pool) !*Connection {
|
||||
const allocator = self.allocator;
|
||||
const conn = try allocator.create(Connection);
|
||||
errdefer allocator.destroy(conn);
|
||||
conn.* = .{
|
||||
.node = .{},
|
||||
.socket = -1,
|
||||
.address = .{ .ip4 = .unspecified(0) },
|
||||
.deadline = 0,
|
||||
.pending = null,
|
||||
.state = .header,
|
||||
.buffer = try .init(allocator, self.buffer_size),
|
||||
};
|
||||
return conn;
|
||||
}
|
||||
|
||||
fn destroy(self: *Pool, conn: *Connection) void {
|
||||
conn.deinit();
|
||||
self.allocator.destroy(conn);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// How long a connection may sit without completing a request before we close it.
|
||||
pub const IDLE_TIMEOUT_MS = 10_000;
|
||||
|
||||
pub fn processEvent(server: *Server, conn: *Connection, rw: Server.IOEvent.ReadWrite, now: u64) void {
|
||||
if (conn.pending != null) {
|
||||
// registered for OUT only; a hangup shows up as a write error
|
||||
if (rw.writable or rw.hangup) {
|
||||
flush(server, conn, now);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (rw.readable) {
|
||||
const keepalive = processHTTP(server, conn, now) catch |err| blk: {
|
||||
writeError(conn, err);
|
||||
break :blk false;
|
||||
};
|
||||
if (keepalive == false) {
|
||||
disconnect(server, conn);
|
||||
}
|
||||
// else: the socket is level-triggered and stays registered; the
|
||||
// deadline was refreshed by processHTTP when the response went out
|
||||
} else if (rw.hangup) {
|
||||
disconnect(server, conn);
|
||||
}
|
||||
}
|
||||
|
||||
// Continues a write that previously hit WouldBlock.
|
||||
fn flush(server: *Server, conn: *Connection, now: u64) void {
|
||||
const pending = &conn.pending.?;
|
||||
const remaining = pending.remaining();
|
||||
const n = write(conn.socket, remaining) catch |err| {
|
||||
log.debug(.serve, "flush", .{ .err = err });
|
||||
return disconnect(server, conn);
|
||||
};
|
||||
|
||||
if (n < remaining.len) {
|
||||
// hit a WouldBlock
|
||||
pending.pos += n;
|
||||
return;
|
||||
}
|
||||
|
||||
// write is complete
|
||||
|
||||
const keepalive = pending.keepalive;
|
||||
pending.deinit(server.app.allocator);
|
||||
conn.pending = null;
|
||||
|
||||
if (keepalive == false) {
|
||||
return disconnect(server, conn);
|
||||
}
|
||||
server.io_engine.waitReadable(conn) catch |err| {
|
||||
log.err(.serve, "wait readable", .{ .err = err });
|
||||
return disconnect(server, conn);
|
||||
};
|
||||
touch(server, conn, now);
|
||||
}
|
||||
|
||||
fn processHTTP(server: *Server, conn: *Connection, now: u64) !bool {
|
||||
const http = &conn.state;
|
||||
while (true) {
|
||||
switch (http.*) {
|
||||
.header => {
|
||||
const data = try conn.buffer.read(conn.socket);
|
||||
if (try http.parseHeader(data) == false) {
|
||||
// don't have a complete header yet
|
||||
return true;
|
||||
}
|
||||
if (comptime lp.IS_DEBUG) {
|
||||
// we do have a complete header, the state must have transitioned
|
||||
// to .request
|
||||
std.debug.assert(http.* == .request);
|
||||
}
|
||||
},
|
||||
.request => |*req| {
|
||||
if (try serveHTTP(server, conn, req) == .upgraded) {
|
||||
// The fd moved to a WebSocket (and out of server.http); all
|
||||
// that's left of this Connection is to recycle it.
|
||||
// upgradeConnection already took it out of http_connections.
|
||||
recycle(server, conn);
|
||||
return true;
|
||||
}
|
||||
|
||||
// req lives in http.*; read what we need before resetting it
|
||||
const keepalive = req.keepalive;
|
||||
http.* = .header;
|
||||
conn.buffer.len = 0;
|
||||
|
||||
if (conn.pending != null) {
|
||||
// We got a WouldBlock and now have a pending write. The
|
||||
// connection stays alive until we flush it. After the write
|
||||
// if flushed, we'll apply the keepalive result.
|
||||
return true;
|
||||
}
|
||||
|
||||
if (keepalive == false) {
|
||||
return false;
|
||||
}
|
||||
touch(server, conn, now);
|
||||
return true;
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error responses use a minimal, uniform shape: no reason phrase, an explicit
|
||||
// Connection: Close, and no Content-Type. errorResponse builds it at comptime.
|
||||
const invalid_request_response = errorResponse(400, "Invalid request");
|
||||
|
||||
const invalid_protocol_response = errorResponse(400, "Invalid HTTP protocol");
|
||||
|
||||
const missing_header_response = errorResponse(400, "Missing required header");
|
||||
|
||||
const forbidden_origin_response = errorResponse(403, "Origin not allowed");
|
||||
|
||||
const forbidden_host_response = errorResponse(403, "Host not allowed");
|
||||
|
||||
const request_too_large_response = errorResponse(413, "Request too large");
|
||||
|
||||
const not_found_response = errorResponse(404, "Not found");
|
||||
|
||||
const method_not_allowed_response = errorResponse(405, "Method not allowed");
|
||||
|
||||
const service_unavailable_response = errorResponse(503, "Too many connections");
|
||||
|
||||
const internal_error_response = errorResponse(500, "Internal server error");
|
||||
|
||||
const empty_json_list_response = staticResponse(.{ .status = "200 OK", .body = "[]", .content_type = "application/json; charset=UTF-8" });
|
||||
|
||||
// WebDriver's discovery endpoint; `ready` is whether a new session can be
|
||||
// created, which the bootstrap never refuses.
|
||||
const status_response = staticResponse(.{ .status = "200 OK", .body = "{\"value\":{\"ready\":true,\"message\":\"\"}}", .content_type = "application/json; charset=UTF-8" });
|
||||
|
||||
const delete_session_response = staticResponse(.{ .status = "200 OK", .body = "{\"value\":null}", .content_type = "application/json; charset=UTF-8" });
|
||||
|
||||
const protocol_response = staticResponse(.{ .status = "200 OK", .body = @embedFile("../data/protocol.json"), .content_type = "application/json; charset=UTF-8" });
|
||||
|
||||
const Served = enum {
|
||||
responded,
|
||||
upgraded,
|
||||
};
|
||||
|
||||
const Route = struct {
|
||||
method: Connection.Method,
|
||||
// exact match against the normalized path
|
||||
path: []const u8,
|
||||
gate: Gate = .none,
|
||||
handler: *const fn (*Server, *Connection, *Connection.Request) anyerror!Served,
|
||||
|
||||
// A closed gate makes the route invisible (404), not forbidden.
|
||||
const Gate = enum {
|
||||
none,
|
||||
cdp,
|
||||
webdriver,
|
||||
metrics,
|
||||
};
|
||||
};
|
||||
|
||||
const routes = [_]Route{
|
||||
.{ .method = .GET, .path = "/", .gate = .cdp, .handler = upgradeCDP },
|
||||
.{ .method = .GET, .path = "/metrics", .gate = .metrics, .handler = serveMetrics },
|
||||
.{ .method = .GET, .path = "/json/version", .gate = .cdp, .handler = serveJSONVersion },
|
||||
.{ .method = .GET, .path = "/json/list", .gate = .cdp, .handler = serveJSONList },
|
||||
.{ .method = .GET, .path = "/json", .gate = .cdp, .handler = serveJSONList },
|
||||
.{ .method = .GET, .path = "/json/protocol", .gate = .cdp, .handler = serveJSONProtocol },
|
||||
// /session is the path Firefox advertises its BiDi endpoint on
|
||||
.{ .method = .GET, .path = "/session", .gate = .webdriver, .handler = upgradeBiDi },
|
||||
.{ .method = .POST, .path = "/session", .gate = .webdriver, .handler = newSession },
|
||||
.{ .method = .GET, .path = "/status", .gate = .webdriver, .handler = serveStatus },
|
||||
};
|
||||
|
||||
const session_routes = [_]Route{
|
||||
.{ .method = .GET, .path = "", .handler = upgradeBiDi },
|
||||
.{ .method = .DELETE, .path = "", .handler = deleteSession },
|
||||
};
|
||||
|
||||
// Routes under /session/{id}; path is what follows the id ("" for the
|
||||
// session itself). The classic command surface goes here.
|
||||
const SESSION_PREFIX = "/session/";
|
||||
|
||||
const SESSION_ID_LEN = 36;
|
||||
|
||||
fn serveHTTP(server: *Server, conn: *Connection, req: *Connection.Request) !Served {
|
||||
var path = req.path;
|
||||
if (path.len > 1 and path[path.len - 1] == '/') {
|
||||
path = path[0 .. path.len - 1];
|
||||
}
|
||||
|
||||
if (std.mem.startsWith(u8, path, SESSION_PREFIX) and path.len >= SESSION_PREFIX.len + SESSION_ID_LEN) {
|
||||
if (!server.protocols.webdriver) {
|
||||
return serveNotFound(server, conn, req);
|
||||
}
|
||||
const tail = path[SESSION_PREFIX.len + SESSION_ID_LEN ..];
|
||||
if (tail.len != 0 and tail[0] != '/') {
|
||||
return serveNotFound(server, conn, req);
|
||||
}
|
||||
req.session_id = path[SESSION_PREFIX.len..][0..SESSION_ID_LEN];
|
||||
return dispatch(server, &session_routes, conn, req, tail);
|
||||
}
|
||||
return dispatch(server, &routes, conn, req, path);
|
||||
}
|
||||
|
||||
fn dispatch(server: *Server, comptime table: []const Route, conn: *Connection, req: *Connection.Request, path: []const u8) !Served {
|
||||
var path_matched = false;
|
||||
inline for (table) |route| {
|
||||
if (std.mem.eql(u8, route.path, path) and gateOpen(server, route.gate)) {
|
||||
if (route.method == req.method) {
|
||||
return route.handler(server, conn, req);
|
||||
}
|
||||
path_matched = true;
|
||||
}
|
||||
}
|
||||
if (path_matched) {
|
||||
return serveMethodNotAllowed(server, conn, req);
|
||||
}
|
||||
return serveNotFound(server, conn, req);
|
||||
}
|
||||
|
||||
// Best effort, connection is being closed. A partial write ends up as a partial
|
||||
// write: no pending, no retry.
|
||||
fn writeError(conn: *Connection, err: anyerror) void {
|
||||
const response: []const u8 = switch (err) {
|
||||
error.ConnectionClosed, error.ConnectionResetByPeer, error.BrokenPipe => return,
|
||||
error.InvalidHeader, error.InvalidHTTPMethod, error.BodyNotSupported => invalid_request_response,
|
||||
error.RequestTooLarge => request_too_large_response,
|
||||
else => blk: {
|
||||
log.warn(.serve, "serve error", .{ .err = err });
|
||||
break :blk internal_error_response;
|
||||
},
|
||||
};
|
||||
recordResponse(response);
|
||||
_ = write(conn.socket, response) catch {};
|
||||
}
|
||||
|
||||
// Every response starts with the status line our two builders emit, so the
|
||||
// category is read straight off the bytes rather than threaded through.
|
||||
fn recordResponse(response: []const u8) void {
|
||||
const prefix = "HTTP/1.1 ";
|
||||
lp.assert(std.mem.startsWith(u8, response, prefix), "Server.recordResponse status line", .{});
|
||||
const status = std.fmt.parseInt(u16, response[prefix.len..][0..3], 10) catch 0;
|
||||
lp.metrics.serve_http_requests.incr(statusCategory(status));
|
||||
}
|
||||
|
||||
const Response = union(enum) {
|
||||
// lives as long as the server; a queued remainder references it
|
||||
static: []const u8,
|
||||
// lives in server.scratch until the next response; a queued remainder is copied
|
||||
dynamic: []const u8,
|
||||
};
|
||||
|
||||
// Can do a partial write
|
||||
fn write(socket: posix.socket_t, data: []const u8) !usize {
|
||||
var pos: usize = 0;
|
||||
while (pos < data.len) {
|
||||
const n = sys_net.write(socket, data[pos..]) catch |err| switch (err) {
|
||||
error.WouldBlock => break,
|
||||
error.Interrupted => continue,
|
||||
else => return err,
|
||||
};
|
||||
pos += n;
|
||||
}
|
||||
return pos;
|
||||
}
|
||||
|
||||
// Dynamic responses are built in server.scratch with room for the header
|
||||
// reserved up front; once the body length is known the header is written
|
||||
// right-aligned against it (the same trick as WS.fillHeader).
|
||||
const HEADER_RESERVE = 192;
|
||||
fn beginBody(server: *Server) !*std.Io.Writer {
|
||||
server.scratch.clearRetainingCapacity();
|
||||
try server.scratch.writer.splatByteAll(0, HEADER_RESERVE);
|
||||
return &server.scratch.writer;
|
||||
}
|
||||
|
||||
fn serveDynamicHTTPResponse(server: *Server, conn: *Connection, req: *const Connection.Request, comptime status: []const u8, comptime content_type: []const u8) !Served {
|
||||
const header_format = "HTTP/1.1 " ++ status ++ "\r\n" ++
|
||||
"Content-Length: {d}\r\n" ++
|
||||
"Content-Type: " ++ content_type ++ "\r\n\r\n";
|
||||
|
||||
// a usize prints as at most 20 digits
|
||||
comptime std.debug.assert(header_format.len + 20 <= HEADER_RESERVE);
|
||||
|
||||
const buf = server.scratch.written();
|
||||
var header_buf: [HEADER_RESERVE]u8 = undefined;
|
||||
const header = std.fmt.bufPrint(&header_buf, header_format, .{buf.len - HEADER_RESERVE}) catch unreachable;
|
||||
const start = HEADER_RESERVE - header.len;
|
||||
@memcpy(buf[start..HEADER_RESERVE], header);
|
||||
return serveHTTPResponse(server, conn, req, .{ .dynamic = buf[start..] });
|
||||
}
|
||||
|
||||
fn errorResponse(comptime status: u16, comptime body: []const u8) []const u8 {
|
||||
return std.fmt.comptimePrint(
|
||||
"HTTP/1.1 {d} \r\nConnection: Close\r\nContent-Length: {d}\r\n\r\n{s}",
|
||||
.{ status, body.len, body },
|
||||
);
|
||||
}
|
||||
|
||||
fn staticResponse(comptime opts: struct {
|
||||
status: []const u8,
|
||||
body: []const u8,
|
||||
content_type: []const u8 = "text/plain",
|
||||
close: bool = false,
|
||||
}) []const u8 {
|
||||
return std.fmt.comptimePrint("HTTP/1.1 " ++ opts.status ++ "\r\n" ++
|
||||
"Content-Length: {d}\r\n" ++
|
||||
(if (opts.close) "Connection: Close\r\n" else "") ++
|
||||
"Content-Type: " ++ opts.content_type ++ "\r\n\r\n", .{opts.body.len}) ++ opts.body;
|
||||
}
|
||||
|
||||
fn gateOpen(server: *const Server, gate: Route.Gate) bool {
|
||||
return switch (gate) {
|
||||
.none => true,
|
||||
.cdp => server.protocols.cdp,
|
||||
.webdriver => server.protocols.webdriver,
|
||||
.metrics => server.app.config.metricsEndpointEnabled(),
|
||||
};
|
||||
}
|
||||
|
||||
fn upgradeCDP(server: *Server, conn: *Connection, req: *Connection.Request) !Served {
|
||||
return upgrade(server, conn, req, .cdp, null);
|
||||
}
|
||||
|
||||
fn serveJSONVersion(server: *Server, conn: *Connection, req: *Connection.Request) !Served {
|
||||
return serveHTTPResponse(server, conn, req, .{ .static = server.json_version_response });
|
||||
}
|
||||
|
||||
fn serveJSONList(server: *Server, conn: *Connection, req: *Connection.Request) !Served {
|
||||
return serveHTTPResponse(server, conn, req, .{ .static = empty_json_list_response });
|
||||
}
|
||||
|
||||
fn serveJSONProtocol(server: *Server, conn: *Connection, req: *Connection.Request) !Served {
|
||||
return serveHTTPResponse(server, conn, req, .{ .static = protocol_response });
|
||||
}
|
||||
|
||||
fn serveMetrics(server: *Server, conn: *Connection, req: *Connection.Request) !Served {
|
||||
const writer = try beginBody(server);
|
||||
lp.metrics.write(writer);
|
||||
return serveDynamicHTTPResponse(server, conn, req, "200 OK", "text/plain; version=0.0.4; charset=utf-8");
|
||||
}
|
||||
|
||||
fn serveStatus(server: *Server, conn: *Connection, req: *Connection.Request) !Served {
|
||||
return serveHTTPResponse(server, conn, req, .{ .static = status_response });
|
||||
}
|
||||
|
||||
// req.session_id is null for GET /session, set for GET /session/{id}
|
||||
fn upgradeBiDi(server: *Server, conn: *Connection, req: *Connection.Request) !Served {
|
||||
const session_id: ?[36]u8 = if (req.session_id) |s| s.* else null;
|
||||
return upgrade(server, conn, req, .bidi, session_id);
|
||||
}
|
||||
|
||||
// What Selenium does before it speaks BiDi: a classic POST /session that
|
||||
// hands back the websocket URL of a session that already exists.
|
||||
fn newSession(server: *Server, conn: *Connection, req: *Connection.Request) !Served {
|
||||
const allocator = server.app.allocator;
|
||||
|
||||
const Capability = struct { webSocketUrl: ?bool = null };
|
||||
const parsed = std.json.parseFromSlice(struct {
|
||||
capabilities: ?struct {
|
||||
alwaysMatch: ?Capability = null,
|
||||
firstMatch: ?[]const Capability = null,
|
||||
} = null,
|
||||
}, allocator, req.body, .{ .ignore_unknown_fields = true }) catch {
|
||||
return serveWebDriver(server, conn, req, "400 Bad Request", .{
|
||||
.@"error" = "invalid argument",
|
||||
.message = "invalid JSON body",
|
||||
.stacktrace = "",
|
||||
});
|
||||
};
|
||||
defer parsed.deinit();
|
||||
|
||||
// Without the capability the client intends to drive the session over
|
||||
// HTTP, which this server doesn't serve: tell it now rather than 404
|
||||
// its first real command.
|
||||
if (!requestsWebSocketUrl(parsed.value.capabilities)) {
|
||||
return serveWebDriver(server, conn, req, "500 Internal Server Error", .{
|
||||
.@"error" = "session not created",
|
||||
.message = "only WebDriver BiDi sessions are supported; request the webSocketUrl capability",
|
||||
.stacktrace = "",
|
||||
});
|
||||
}
|
||||
|
||||
var session_id: [36]u8 = undefined;
|
||||
uuidv4(&session_id);
|
||||
|
||||
const url = try std.fmt.allocPrint(allocator, "{s}{s}", .{ server.bidi_session_url, &session_id });
|
||||
defer allocator.free(url);
|
||||
|
||||
return serveWebDriver(server, conn, req, "200 OK", .{
|
||||
.sessionId = &session_id,
|
||||
.capabilities = bidi_session.Capabilities{
|
||||
.userAgent = server.app.config.http_headers.user_agent,
|
||||
.webSocketUrl = url,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
fn requestsWebSocketUrl(capabilities: anytype) bool {
|
||||
const caps = capabilities orelse return false;
|
||||
if (caps.alwaysMatch) |always| {
|
||||
if (always.webSocketUrl == true) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (caps.firstMatch orelse &.{}) |first| {
|
||||
if (first.webSocketUrl == true) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Answers a classic WebDriver request with {"value": value}.
|
||||
fn serveWebDriver(server: *Server, conn: *Connection, req: *const Connection.Request, comptime status: []const u8, value: anytype) !Served {
|
||||
const writer = try beginBody(server);
|
||||
try std.json.Stringify.value(.{ .value = value }, .{}, writer);
|
||||
return serveDynamicHTTPResponse(server, conn, req, status, "application/json; charset=UTF-8");
|
||||
}
|
||||
|
||||
fn deleteSession(server: *Server, conn: *Connection, req: *Connection.Request) !Served {
|
||||
return serveHTTPResponse(server, conn, req, .{ .static = delete_session_response });
|
||||
}
|
||||
|
||||
fn serveNotFound(server: *Server, conn: *Connection, req: *Connection.Request) !Served {
|
||||
return serveHTTPResponse(server, conn, req, .{ .static = not_found_response });
|
||||
}
|
||||
|
||||
fn serveMethodNotAllowed(server: *Server, conn: *Connection, req: *Connection.Request) !Served {
|
||||
return serveHTTPResponse(server, conn, req, .{ .static = method_not_allowed_response });
|
||||
}
|
||||
|
||||
// Writes what the socket will take now. Anything left is queued on the
|
||||
// connection, which switches to waiting for writability.
|
||||
fn serveHTTPResponse(server: *Server, conn: *Connection, req: *const Connection.Request, response: Response) !Served {
|
||||
const data = switch (response) {
|
||||
inline else => |d| d,
|
||||
};
|
||||
recordResponse(data);
|
||||
const n = try write(conn.socket, data);
|
||||
if (n == data.len) {
|
||||
return .responded;
|
||||
}
|
||||
|
||||
lp.assert(conn.pending == null, "Server.send pending", .{});
|
||||
conn.pending = .{
|
||||
.pos = 0,
|
||||
.keepalive = req.keepalive,
|
||||
.data = switch (response) {
|
||||
.static => .{ .static = data[n..] },
|
||||
.dynamic => .{ .owned = try server.app.allocator.dupe(u8, data[n..]) },
|
||||
},
|
||||
};
|
||||
// on failure the caller disconnects, which frees pending
|
||||
try server.io_engine.waitWritable(conn);
|
||||
return .responded;
|
||||
}
|
||||
|
||||
// HTTP-phase teardown. Websockets tear down via releaseWorker.
|
||||
pub fn disconnect(server: *Server, conn: *Connection) void {
|
||||
server.io_engine.remove(conn.socket);
|
||||
sys_net.close(conn.socket);
|
||||
if (conn.pending) |*pending| {
|
||||
pending.deinit(server.app.allocator);
|
||||
conn.pending = null;
|
||||
}
|
||||
server.http_connections.remove(&conn.node);
|
||||
recycle(server, conn);
|
||||
}
|
||||
|
||||
// Return a connection to the pool; a slot in the fd budget is free.
|
||||
fn recycle(server: *Server, conn: *Connection) void {
|
||||
server.http_connection_pool.release(conn);
|
||||
server.slotFreed();
|
||||
}
|
||||
|
||||
fn touch(server: *Server, conn: *Connection, now: u64) void {
|
||||
conn.deadline = now + IDLE_TIMEOUT_MS;
|
||||
const node = &conn.node;
|
||||
if (server.http_connections.last == node) {
|
||||
return;
|
||||
}
|
||||
|
||||
server.http_connections.remove(&conn.node);
|
||||
server.http_connections.append(&conn.node);
|
||||
}
|
||||
|
||||
pub fn buildJSONVersionResponse(app: *const App, port: u16) ![]const u8 {
|
||||
const host = app.config.advertiseHost();
|
||||
if (app.config.bindIsWildcard()) {
|
||||
// Serve is bound to INADDR_ANY but no --advertise-host was given;
|
||||
// advertiseHost() falls back to 127.0.0.1 so clients can still
|
||||
// connect locally. Surface the trade-off so users running
|
||||
// outside the same host know they have to opt in.
|
||||
log.note(.cdp, "advertising loopback for wildcard bind", .{
|
||||
.message = "--host is a wildcard (0.0.0.0 / ::) without --advertise-host; clients on other hosts will need --advertise-host to reach the CDP endpoint",
|
||||
});
|
||||
}
|
||||
const body_format =
|
||||
"{{" ++
|
||||
"\"Browser\": \"Lightpanda/1.0\", " ++
|
||||
"\"Protocol-Version\": \"1.3\", " ++
|
||||
"\"User-Agent\": \"Lightpanda/1.0\", " ++
|
||||
"\"Lightpanda-Version\": \"" ++ lp.build_config.version ++ "\", " ++
|
||||
"\"webSocketDebuggerUrl\": \"ws://{s}:{d}/\"" ++
|
||||
"}}";
|
||||
const body_len = std.fmt.count(body_format, .{ host, port });
|
||||
|
||||
const response_format =
|
||||
"HTTP/1.1 200 OK\r\n" ++
|
||||
"Content-Length: {d}\r\n" ++
|
||||
"Content-Type: application/json; charset=UTF-8\r\n\r\n" ++
|
||||
body_format;
|
||||
return try std.fmt.allocPrint(app.allocator, response_format, .{ body_len, host, port });
|
||||
}
|
||||
|
||||
// Shared upgrade path: validate the WebSocket headers, write the 101, park the
|
||||
// fd, and spawn the worker that will build the driver and attach it.
|
||||
fn upgrade(server: *Server, conn: *Connection, req: *Connection.Request, protocol: Driver.Protocol, session_id: ?[36]u8) !Served {
|
||||
if (server.websocket_pool.isFull()) {
|
||||
lp.metrics.serve_connection_limit.incr();
|
||||
return serveHTTPResponse(server, conn, req, .{ .static = service_unavailable_response });
|
||||
}
|
||||
|
||||
var accept_buf: [28]u8 = undefined;
|
||||
const accept_key = webSocketAccept(req.head, &accept_buf) catch |err| {
|
||||
const response: []const u8 = switch (err) {
|
||||
error.ForbiddenOrigin => forbidden_origin_response,
|
||||
error.ForbiddenHost => forbidden_host_response,
|
||||
error.InvalidProtocol => invalid_protocol_response,
|
||||
error.MissingHeader => missing_header_response,
|
||||
else => invalid_request_response,
|
||||
};
|
||||
return serveHTTPResponse(server, conn, req, .{ .static = response });
|
||||
};
|
||||
|
||||
// The 101 is ~129 bytes into an empty send buffer, so a single write
|
||||
// always completes; a partial write here means the peer is already gone.
|
||||
var response_buf: [160]u8 = undefined;
|
||||
const response = std.fmt.bufPrint(&response_buf, "HTTP/1.1 101 Switching Protocols\r\n" ++
|
||||
"Upgrade: websocket\r\n" ++
|
||||
"Connection: upgrade\r\n" ++
|
||||
"Sec-Websocket-Accept: {s}\r\n\r\n", .{accept_key}) catch unreachable;
|
||||
const n = write(conn.socket, response) catch return error.ConnectionClosed;
|
||||
if (n != response.len) {
|
||||
return error.ConnectionClosed;
|
||||
}
|
||||
|
||||
server.upgradeConnection(conn, protocol, session_id);
|
||||
return .upgraded;
|
||||
}
|
||||
|
||||
// Validate an incoming WebSocket upgrade request head and, on success, write
|
||||
// the Sec-WebSocket-Accept value into `out`. Mirrors the origin/host defenses
|
||||
// from the old Handshake path.
|
||||
fn webSocketAccept(head: []const u8, out: *[28]u8) ![]const u8 {
|
||||
const FOUND_UPGRADE: u8 = 1 << 0;
|
||||
const FOUND_VERSION: u8 = 1 << 1;
|
||||
const FOUND_CONNECTION: u8 = 1 << 2;
|
||||
const FOUND_KEY: u8 = 1 << 3;
|
||||
const FOUND_ALL = FOUND_UPGRADE | FOUND_VERSION | FOUND_CONNECTION | FOUND_KEY;
|
||||
|
||||
const method, _, const version, var it = header_parser.parseRequest(head) catch return error.InvalidRequest;
|
||||
if (method != .get or version != .@"1.1") {
|
||||
return error.InvalidProtocol;
|
||||
}
|
||||
|
||||
var found: u8 = 0;
|
||||
var key: []const u8 = "";
|
||||
while (it.next() catch return error.InvalidRequest) |h| {
|
||||
if (std.ascii.eqlIgnoreCase(h.key, "upgrade")) {
|
||||
if (!std.ascii.eqlIgnoreCase("websocket", h.value)) return error.MissingHeader;
|
||||
found |= FOUND_UPGRADE;
|
||||
} else if (std.ascii.eqlIgnoreCase(h.key, "sec-websocket-version")) {
|
||||
if (h.value.len != 2 or h.value[0] != '1' or h.value[1] != '3') return error.MissingHeader;
|
||||
found |= FOUND_VERSION;
|
||||
} else if (std.ascii.eqlIgnoreCase(h.key, "connection")) {
|
||||
if (std.ascii.indexOfIgnoreCase(h.value, "upgrade") == null) return error.MissingHeader;
|
||||
found |= FOUND_CONNECTION;
|
||||
} else if (std.ascii.eqlIgnoreCase(h.key, "sec-websocket-key")) {
|
||||
key = h.value;
|
||||
found |= FOUND_KEY;
|
||||
} else if (std.ascii.eqlIgnoreCase(h.key, "origin")) {
|
||||
// Only a browser sends Origin, and a browser has no business
|
||||
// driving CDP/BiDi: it's cross-origin to us by definition.
|
||||
log.warn(.serve, "rejected websocket origin", .{ .origin = h.value[0..@min(h.value.len, 64)] });
|
||||
return error.ForbiddenOrigin;
|
||||
} else if (std.ascii.eqlIgnoreCase(h.key, "host")) {
|
||||
// Defense in depth against DNS rebinding: only an IP literal can
|
||||
// legitimately reach us (no name resolution involved). The one
|
||||
// name we accept is `localhost:<port>`, which browsers hardwire
|
||||
// to loopback without a lookup.
|
||||
if (!std.mem.startsWith(u8, h.value, "localhost:")) {
|
||||
_ = std.Io.net.IpAddress.parseLiteral(h.value) catch {
|
||||
log.warn(.serve, "rejected websocket host", .{ .host = h.value[0..@min(h.value.len, 64)] });
|
||||
return error.ForbiddenHost;
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
if (found != FOUND_ALL) {
|
||||
return error.MissingHeader;
|
||||
}
|
||||
|
||||
var sha: [20]u8 = undefined;
|
||||
var hasher = std.crypto.hash.Sha1.init(.{});
|
||||
hasher.update(key);
|
||||
hasher.update("258EAFA5-E914-47DA-95CA-C5AB0DC85B11");
|
||||
hasher.final(&sha);
|
||||
_ = std.base64.standard.Encoder.encode(out, &sha);
|
||||
return out;
|
||||
}
|
||||
+93
-23
@@ -1,7 +1,7 @@
|
||||
// Copyright (C) 2023-2026 Lightpanda (Selecy SAS)
|
||||
//
|
||||
// Francis Bouvier <francis@lightpanda.io>
|
||||
// Pierre Tachoire <pierre@lightpanda.io>
|
||||
// Pierres 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
|
||||
@@ -180,28 +180,6 @@ pub fn getsockname(sock: socket_t, addr: *posix.sockaddr, len: *posix.socklen_t)
|
||||
}
|
||||
}
|
||||
|
||||
/// pipe2 semantics; flags applied via fcntl since macOS has no pipe2.
|
||||
pub fn pipe2(flags: struct { NONBLOCK: bool = false, CLOEXEC: bool = false }) ![2]posix.fd_t {
|
||||
var fds: [2]posix.fd_t = undefined;
|
||||
const rc = c.pipe(&fds);
|
||||
if (rc != 0) {
|
||||
return errnoError(c.errno(rc));
|
||||
}
|
||||
errdefer for (fds) |fd| {
|
||||
_ = c.close(fd);
|
||||
};
|
||||
for (fds) |fd| {
|
||||
if (flags.NONBLOCK) {
|
||||
const fl = try fcntl(fd, posix.F.GETFL, 0);
|
||||
_ = try fcntl(fd, posix.F.SETFL, fl | @as(u32, @bitCast(posix.O{ .NONBLOCK = true })));
|
||||
}
|
||||
if (flags.CLOEXEC) {
|
||||
_ = try fcntl(fd, posix.F.SETFD, posix.FD_CLOEXEC);
|
||||
}
|
||||
}
|
||||
return fds;
|
||||
}
|
||||
|
||||
pub fn connect(addr: *const IpAddress) !socket_t {
|
||||
const sock = try socket(family(addr), posix.SOCK.STREAM, posix.IPPROTO.TCP);
|
||||
errdefer _ = c.close(sock);
|
||||
@@ -242,6 +220,98 @@ pub fn fcntl(fd: posix.fd_t, cmd: i32, arg: usize) !usize {
|
||||
return @intCast(rc);
|
||||
}
|
||||
|
||||
pub fn close(fd: posix.fd_t) void {
|
||||
switch (c.errno(c.close(fd))) {
|
||||
.BADF => unreachable, // Always a race condition.
|
||||
.INTR => {}, // This is still a success. See https://github.com/ziglang/zig/issues/2425
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn epoll_create1(flags: u32) !i32 {
|
||||
const rc = c.epoll_create1(flags);
|
||||
return switch (c.errno(rc)) {
|
||||
.SUCCESS => return @intCast(rc),
|
||||
.INVAL => unreachable,
|
||||
.MFILE => error.ProcessFdQuotaExceeded,
|
||||
.NFILE => error.SystemFdQuotaExceeded,
|
||||
.NOMEM => error.SystemResources,
|
||||
else => error.Unexpected,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn eventfd(initval: u32, flags: u32) !i32 {
|
||||
const rc = c.eventfd(initval, flags);
|
||||
return switch (c.errno(rc)) {
|
||||
.SUCCESS => @intCast(rc),
|
||||
.INVAL => unreachable, // invalid parameters
|
||||
.MFILE => error.ProcessFdQuotaExceeded,
|
||||
.NFILE => error.SystemFdQuotaExceeded,
|
||||
.NODEV => error.SystemResources,
|
||||
.NOMEM => error.SystemResources,
|
||||
else => error.Unexpected,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn epoll_ctl(epfd: i32, op: u32, fd: i32, event: ?*c.epoll_event) !void {
|
||||
const rc = c.epoll_ctl(epfd, op, fd, event);
|
||||
return switch (c.errno(rc)) {
|
||||
.SUCCESS => {},
|
||||
.BADF => unreachable, // always a race condition if this happens
|
||||
.EXIST => error.FileDescriptorAlreadyPresentInSet,
|
||||
.INVAL => unreachable,
|
||||
.LOOP => error.OperationCausesCircularLoop,
|
||||
.NOENT => error.FileDescriptorNotRegistered,
|
||||
.NOMEM => error.SystemResources,
|
||||
.NOSPC => error.UserResourceLimitReached,
|
||||
.PERM => error.FileDescriptorIncompatibleWithEpoll,
|
||||
else => error.Unexpected,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn epoll_wait(epfd: i32, events: []c.epoll_event, timeout: i32) usize {
|
||||
while (true) {
|
||||
// TODO get rid of the @intCast
|
||||
const rc = c.epoll_wait(epfd, events.ptr, @intCast(events.len), timeout);
|
||||
switch (posix.errno(rc)) {
|
||||
.SUCCESS => return @intCast(rc),
|
||||
.INTR => continue,
|
||||
.BADF => unreachable,
|
||||
.FAULT => unreachable,
|
||||
.INVAL => unreachable,
|
||||
else => unreachable,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn kqueue() !i32 {
|
||||
const rc = c.kqueue();
|
||||
return switch (c.errno(rc)) {
|
||||
.SUCCESS => @intCast(rc),
|
||||
.MFILE => error.ProcessFdQuotaExceeded,
|
||||
.NFILE => error.SystemFdQuotaExceeded,
|
||||
else => error.Unexpected,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn kevent(kq: i32, changes: []const c.Kevent, events: []c.Kevent, timeout: ?*const c.timespec) !usize {
|
||||
while (true) {
|
||||
const rc = c.kevent(kq, changes.ptr, @intCast(changes.len), events.ptr, @intCast(events.len), timeout);
|
||||
switch (c.errno(rc)) {
|
||||
.SUCCESS => return @intCast(rc),
|
||||
.INTR => continue,
|
||||
.BADF => unreachable, // always a race condition if this happens
|
||||
.FAULT => unreachable,
|
||||
.INVAL => unreachable,
|
||||
.ACCES => return error.AccessDenied,
|
||||
.NOENT => return error.EventNotFound,
|
||||
.NOMEM => return error.SystemResources,
|
||||
.SRCH => return error.ProcessNotFound,
|
||||
else => return error.Unexpected,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn errnoError(e: posix.E) anyerror {
|
||||
return switch (e) {
|
||||
.AGAIN => error.WouldBlock,
|
||||
|
||||
+1
-1
@@ -524,7 +524,7 @@ var test_ws_server_thread: ?std.Thread = null;
|
||||
var sse_flag = std.atomic.Value(bool).init(false);
|
||||
var sse_reconnect_hits = std.atomic.Value(usize).init(0);
|
||||
|
||||
var test_config: Config = undefined;
|
||||
pub var test_config: Config = undefined;
|
||||
|
||||
test "tests:beforeAll" {
|
||||
log.opts.level = .warn;
|
||||
|
||||
Reference in new issue
Block a user