diff --git a/src/Inbox.zig b/src/Inbox.zig index d5a4092d6..24cdd80dc 100644 --- a/src/Inbox.zig +++ b/src/Inbox.zig @@ -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()); +} diff --git a/src/Metrics.zig b/src/Metrics.zig index ecd6df925..89a351495 100644 --- a/src/Metrics.zig +++ b/src/Metrics.zig @@ -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", diff --git a/src/log.zig b/src/log.zig index a513776e4..82ed15c6e 100644 --- a/src/log.zig +++ b/src/log.zig @@ -37,6 +37,7 @@ pub const Scope = enum { note, not_implemented, scheduler, + serve, storage, telemetry, unknown_prop, diff --git a/src/server/Connection.zig b/src/server/Connection.zig deleted file mode 100644 index 691625cef..000000000 --- a/src/server/Connection.zig +++ /dev/null @@ -1,274 +0,0 @@ -// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) -// -// Francis Bouvier -// Pierre Tachoire -// -// 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 . - -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 {}; -} diff --git a/src/server/Driver.zig b/src/server/Driver.zig index 0832dde5e..747560c57 100644 --- a/src/server/Driver.zig +++ b/src/server/Driver.zig @@ -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(); -} diff --git a/src/server/Handshake.zig b/src/server/Handshake.zig deleted file mode 100644 index 581e27509..000000000 --- a/src/server/Handshake.zig +++ /dev/null @@ -1,551 +0,0 @@ -// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) -// -// Francis Bouvier -// Pierre Tachoire -// -// 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 . - -// 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 " }); - 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:`, 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; diff --git a/src/server/Link.zig b/src/server/Link.zig new file mode 100644 index 000000000..f37a78950 --- /dev/null +++ b/src/server/Link.zig @@ -0,0 +1,380 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +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); +} diff --git a/src/server/Server.zig b/src/server/Server.zig index f05486350..ae4815618 100644 --- a/src/server/Server.zig +++ b/src/server/Server.zig @@ -1,4 +1,4 @@ -// Copyright (C) 2023-2025 Lightpanda (Selecy SAS) +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier // Pierre Tachoire @@ -25,840 +25,1096 @@ const Config = @import("../Config.zig"); const sys_net = @import("../sys/net.zig"); const CDP = @import("cdp/CDP.zig"); -const http = @import("../network/http.zig"); - const BiDi = @import("bidi/BiDi.zig"); -const Handshake = @import("Handshake.zig"); + +const WS = @import("WS.zig"); +const http = @import("http.zig"); const Driver = @import("Driver.zig"); const log = lp.log; const posix = std.posix; +const Connection = http.Connection; const Allocator = std.mem.Allocator; const DoublyLinkedList = std.DoublyLinkedList; +// Which protocol-specific routes we serve +const Protocols = struct { + cdp: bool = false, + webdriver: bool = false, +}; + const Server = @This(); -// Read side of a client (CDP / BiDi) WebSocket, registered with the -// server's run loop so bytes are read off the socket there and dispatched -// into the protocol layer via direct method calls on `driver`. The loop -// never sends on the socket — the worker is the sole writer. After -// registerLink returns, the worker must not call posix.read on this socket -// directly. unregisterLink is synchronous: it blocks until the loop -// confirms the link has been dropped from its poll set and won't touch it -// again. -pub const Link = struct { - driver: Driver, - state: State, - socket: posix.socket_t, - // The worker's HttpClient.Handles (by value — it's one pointer wide). - // The loop calls handles.wakeup() to unblock the worker from - // curl_multi_poll whenever it pushes to the worker's inbox. - handles: http.Handles, - node: DoublyLinkedList.Node = .{}, +// fds the process needs beyond client connections and the HTTP client's +const FD_HEADROOM = 128; - pub const State = enum { - live, - // Worker called unregisterLink; the loop will drop the link on - // its next iteration and signal link_removed. - unregistering, - // The loop has dropped the link from its poll set. The worker - // can safely free anything the link's callbacks closed over. - removed, +// How much one readable websocket may pull in per loop iteration; sized so a +// large driver message (Playwright sends ~400KB) takes a couple of turns +// rather than dozens, without starving the other connections. +const WS_READ_BUDGET = 256 * 1024; + +// A websocket connection, this loop's side of Link.zig (the worker's side) +const WebSocket = struct { + socket: posix.socket_t, + address: sys_net.IpAddress, + // threads `websockets` while live, the pool's free list otherwise + node: DoublyLinkedList.Node, + protocol: Driver.Protocol, + // null until the worker attaches + driver: ?Driver = null, + // whether or not socket is in the poll set. Makes sure we don't double-remove + monitored: bool = false, + + const Pool = struct { + slab: []WebSocket, + free: DoublyLinkedList, + live: usize, // acquired and not yet released + + fn init(allocator: Allocator, capacity: usize) !Pool { + const slab = try allocator.alloc(WebSocket, capacity); + var free: DoublyLinkedList = .{}; + for (slab) |*ws| { + ws.node = .{}; + free.append(&ws.node); + } + return .{ .slab = slab, .free = free, .live = 0 }; + } + + fn deinit(self: *Pool, allocator: Allocator) void { + allocator.free(self.slab); + } + + fn acquire(self: *Pool) !*WebSocket { + const node = self.free.popFirst() orelse return error.NoWebSocketSlot; + self.live += 1; + return @fieldParentPtr("node", node); + } + + pub fn isFull(self: *const Pool) bool { + return self.live == self.slab.len; + } + + fn release(self: *Pool, ws: *WebSocket) void { + self.live -= 1; + ws.node = .{}; + self.free.append(&ws.node); + } }; }; -// Number of fixed pollfds entries (wakeup pipe + listener). -const PSEUDO_POLLFDS = 2; +// Worker -> loop request, see worker_queue. +const WorkerRequest = struct { + ws: *WebSocket, + op: union(enum) { + attach: Driver, + release: *std.Io.Event, + }, +}; app: *App, -max_connections: usize, -protocols: Handshake.Protocols, -bidi_session_url: []const u8, -json_version_response: []const u8, - -driver_mutex: std.Io.Mutex = .init, -drivers: std.ArrayList(Driver) = .empty, - -// list of sockets that haven't yet (and might never) be updated to a driver -// (needed for a clean shutdown). -handshakes: std.ArrayList(posix.socket_t) = .empty, - -// Number of active conns, used to enforce the cdp-max-connections limit. -active_conns: std.atomic.Value(u32) = .init(0), -// Number of existing threads, used to deinit correctly. -// It can be higher than active_conns b/c we free conn slots early. -active_threads: std.atomic.Value(u32) = .init(0), - -cdp_pool: std.heap.MemoryPool(CDP), - +io_engine: IOEngine, listener: posix.socket_t, -// pollfds layout: -// [0] wakeup pipe -// [1] listener -// [PSEUDO_POLLFDS .. + max_connections] link sockets -pollfds: []posix.pollfd, +listener_paused: bool, -// Wakeup pipe: other threads write to [1], the run loop polls [0] -wakeup_pipe: [2]posix.fd_t, +// # of client connections we can have alive. Not --cdp-max-connections which +// limits drivers (which cost a lot of memory). We need a higher limit to support +// keepalive and hits to /json/version, /metrics, etc. +max_connections: usize, -shutting_down: std.atomic.Value(bool) = .init(false), +// the protocols (cdp/bidi) we support +protocols: Protocols, -// Registered client read endpoints. Producer-side (the worker doing -// register/unregister) and consumer-side (the run loop) are serialized -// by link_mutex. link_removed signals when a link transitions to -// .removed so unregisterLink can return. -links: DoublyLinkedList = .{}, -link_mutex: std.Io.Mutex = .init, -link_removed: std.Io.Condition = .init, -// Per-iteration snapshot of Links whose sockets are in pollfds. Sized at -// max_connections at init time so we never allocate inside run(). -// Parallel to pollfds[PSEUDO_POLLFDS..][0..poll_count]. Persists across -// iterations; only rebuilt when `links_dirty` is set. -poll_snapshot: []?*Link, -poll_count: usize = 0, +// Live connections still in the HTTP phase, ordered by deadline +http_connections: DoublyLinkedList, +http_connection_pool: Connection.Pool, -// Set whenever the links list changes (register / unregister / natural -// drop). preparePollFds rebuilds the snapshot only when this is true; -// idle iterations skip the rebuild. run() ticks hundreds of times per -// second, and the link set is stable between connection lifecycle -// events, so the steady-state cost of the poll prep is one mutex -// acquire + one bool read. -links_dirty: bool = false, +// Websocket connections, attached or not +websockets: DoublyLinkedList, +websocket_pool: WebSocket.Pool, + +// Worker communicates with the main loop through this queue, protected by the +// mutex. +worker_mutex: std.Io.Mutex, +worker_queue: std.ArrayList(WorkerRequest), +// the queue is a double-buffer so that we don't have to hold worker_mutex while +// draining it, just need to swap the two. +worker_drain: std.ArrayList(WorkerRequest), + +// A shutdown has been signaled AND the loop has started to process it +shutdown_begun: bool, + +// Will block on this until all workers are shutdown +workers: lp.WaitGroup, + +// Dynamic responses (/metrics, ...) are built here. If they can't be written +// immediately, it will be copied to the Connection's pending +scratch: std.Io.Writer.Allocating, + +json_version_response: []const u8, +// ws://host:port/session/ — what POST /session advertises, the id goes on the end +bidi_session_url: []const u8, pub fn init(app: *App, address: sys_net.IpAddress) !*Server { + const config = app.config; const allocator = app.allocator; - const self = try allocator.create(Server); - errdefer allocator.destroy(self); - const pipe = try sys_net.pipe2(.{ .NONBLOCK = true, .CLOEXEC = true }); - errdefer for (pipe) |fd| { - _ = std.c.close(fd); + const io_engine = try IOEngine.init(); + errdefer io_engine.deinit(); + + var scratch = try std.Io.Writer.Allocating.initCapacity(allocator, 8192); + errdefer scratch.deinit(); + + var json_version_response: []const u8 = ""; + var bidi_session_url: []const u8 = ""; + + const max_connections = fdBudget(config); + + const listener = blk: { + const flags = posix.SOCK.STREAM | posix.SOCK.CLOEXEC | posix.SOCK.NONBLOCK; + const l = try sys_net.socket(sys_net.family(&address), flags, posix.IPPROTO.TCP); + errdefer sys_net.close(l); + + try posix.setsockopt(l, posix.SOL.SOCKET, posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1))); + if (@hasDecl(posix.TCP, "NODELAY")) { + try posix.setsockopt(l, posix.IPPROTO.TCP, posix.TCP.NODELAY, &std.mem.toBytes(@as(c_int, 1))); + } + + const sa = sys_net.sockaddrFromAddress(&address); + try sys_net.bind(l, sa.ptr(), sa.len); + { + // look this up incase --port 0 was used + var bound: posix.sockaddr.storage = undefined; + var bound_len: posix.socklen_t = @sizeOf(posix.sockaddr.storage); + try sys_net.getsockname(l, @ptrCast(&bound), &bound_len); + const bound_address = sys_net.addressFromSockaddr(@ptrCast(&bound)); + + json_version_response = try http.buildJSONVersionResponse(app, bound_address.getPort()); + errdefer allocator.free(json_version_response); + + bidi_session_url = try std.fmt.allocPrint(allocator, "ws://{s}:{d}/session/", .{ config.advertiseHost(), bound_address.getPort() }); + errdefer allocator.free(bidi_session_url); + + try sys_net.listen(l, config.maxPendingConnections()); + log.note(.note, "server running", .{ + .address = bound_address, + .max_connections = max_connections, + .max_browser_connections = config.maxConnections(), + }); + } + + break :blk l; }; + errdefer sys_net.close(listener); - const max_connections = app.config.maxConnections(); - const pollfds = try allocator.alloc(posix.pollfd, PSEUDO_POLLFDS + max_connections); - errdefer allocator.free(pollfds); - @memset(pollfds, .{ .fd = -1, .events = 0, .revents = 0 }); - pollfds[0] = .{ .fd = pipe[0], .events = posix.POLL.IN, .revents = 0 }; + var http_connection_pool = try Connection.Pool.init(app); + errdefer http_connection_pool.deinit(); - const poll_snapshot = try allocator.alloc(?*Link, max_connections); - errdefer allocator.free(poll_snapshot); - @memset(poll_snapshot, null); - - // Bind first so /json/version can advertise the OS-assigned port (--port 0). - var bound_address = address; - const listener = try bindListener(app.config, &bound_address); - errdefer _ = std.c.close(listener); - pollfds[1] = .{ .fd = listener, .events = posix.POLL.IN, .revents = 0 }; - log.note(.note, "server running", .{ .address = bound_address }); - - const port = bound_address.getPort(); - const json_version_response = try buildJSONVersionResponse(app, port); - errdefer allocator.free(json_version_response); - - const bidi_session_url = try std.fmt.allocPrint(allocator, "ws://{s}:{d}/session/", .{ app.config.advertiseHost(), port }); - errdefer allocator.free(bidi_session_url); - - var protocols: Handshake.Protocols = .{}; - for (app.config.protocols()) |p| switch (p) { + var protocols: Protocols = .{}; + for (config.protocols()) |p| switch (p) { .cdp => protocols.cdp = true, .webdriver => protocols.webdriver = true, }; + const request_capacity = 2 * config.maxConnections(); + var worker_queue: std.ArrayList(WorkerRequest) = try .initCapacity(allocator, request_capacity); + errdefer worker_queue.deinit(allocator); + + var worker_drain: std.ArrayList(WorkerRequest) = try .initCapacity(allocator, request_capacity); + errdefer worker_drain.deinit(allocator); + + var websocket_pool = try WebSocket.Pool.init(allocator, config.maxConnections()); + errdefer websocket_pool.deinit(allocator); + + const self = try allocator.create(Server); + errdefer allocator.destroy(self); + self.* = .{ .app = app, - .cdp_pool = .empty, + .io_engine = io_engine, + .listener = listener, + .listener_paused = false, + .scratch = scratch, + .protocols = protocols, + .http_connections = .{}, + .http_connection_pool = http_connection_pool, .json_version_response = json_version_response, .bidi_session_url = bidi_session_url, .max_connections = max_connections, - .listener = listener, - .pollfds = pollfds, - .wakeup_pipe = pipe, - .poll_snapshot = poll_snapshot, - .protocols = protocols, + .websockets = .{}, + .websocket_pool = websocket_pool, + .worker_mutex = .init, + .worker_queue = worker_queue, + .worker_drain = worker_drain, + .shutdown_begun = false, + .workers = .{}, }; return self; } -fn bindListener(config: *const Config, address: *sys_net.IpAddress) !posix.socket_t { - const flags = posix.SOCK.STREAM | posix.SOCK.CLOEXEC | posix.SOCK.NONBLOCK; - const listener = try sys_net.socket(sys_net.family(address), flags, posix.IPPROTO.TCP); - errdefer _ = std.c.close(listener); - - try posix.setsockopt(listener, posix.SOL.SOCKET, posix.SO.REUSEADDR, &std.mem.toBytes(@as(c_int, 1))); - if (@hasDecl(posix.TCP, "NODELAY")) { - try posix.setsockopt(listener, posix.IPPROTO.TCP, posix.TCP.NODELAY, &std.mem.toBytes(@as(c_int, 1))); - } - - const sa = sys_net.sockaddrFromAddress(address); - try sys_net.bind(listener, sa.ptr(), sa.len); - try sys_net.listen(listener, config.maxPendingConnections()); - - // When the caller requests port 0, the OS assigns an ephemeral port; read - // the actual bound address back so callers (e.g. logging) see the real port. - var bound: posix.sockaddr.storage = undefined; - var bound_len: posix.socklen_t = @sizeOf(posix.sockaddr.storage); - try sys_net.getsockname(listener, @ptrCast(&bound), &bound_len); - address.* = sys_net.addressFromSockaddr(@ptrCast(&bound)); - - return listener; -} - -// Stop accepting, make run() return, and terminate every live worker. -// Idempotent: the signal handler calls it, and so does deinit. -pub fn shutdown(self: *Server) void { - self.shutting_down.store(true, .release); - self.wakeup(); - - self.driver_mutex.lockUncancelable(lp.io); - defer self.driver_mutex.unlock(lp.io); - - for (self.drivers.items) |*driver| { - driver.shutdown(); - } - for (self.handshakes.items) |socket| { - sys_net.shutdown(socket, .recv) catch {}; - } -} - pub fn deinit(self: *Server) void { - self.shutdown(); + const allocator = self.app.allocator; - while (self.active_threads.load(.monotonic) > 0) { - lp.io.sleep(.fromMilliseconds(10), .awake) catch {}; + self.workers.wait(); + lp.assert(self.websockets.first == null, "Server.deinit websockets", .{}); + while (self.http_connections.first) |node| { + http.disconnect(self, @fieldParentPtr("node", node)); } - const allocator = self.app.allocator; - self.drivers.deinit(allocator); - self.handshakes.deinit(allocator); - self.cdp_pool.deinit(allocator); + self.scratch.deinit(); + self.websocket_pool.deinit(allocator); + self.worker_queue.deinit(allocator); + self.worker_drain.deinit(allocator); + self.http_connection_pool.deinit(); allocator.free(self.json_version_response); allocator.free(self.bidi_session_url); - allocator.free(self.pollfds); - allocator.free(self.poll_snapshot); - for (self.wakeup_pipe) |fd| { - _ = std.c.close(fd); - } - if (self.listener >= 0) { - // run() never ran (or never returned through its exit path). - _ = std.c.close(self.listener); - } + sys_net.close(self.listener); + self.io_engine.deinit(); allocator.destroy(self); } -// Blocks the calling thread servicing the listener and the registered client -// read sockets until shutdown(). Page fetches run on per-worker HttpClient -// multis and telemetry on its own thread, so nothing here drives libcurl. +// Any thread (signal handler, mcp). +pub fn shutdown(self: *Server) void { + self.io_engine.stop(); +} + +// blocks the caller pub fn run(self: *Server) void { - var drain_buf: [64]u8 = undefined; + self.io_engine.monitorListener(self.listener) catch |err| { + log.fatal(.serve, "io listen", .{ .err = err }); + return; + }; - const wakeup_fd = &self.pollfds[0]; - const listen_fd = &self.pollfds[1]; + while (self.runOnce()) {} +} - while (true) { - self.preparePollFds(); +fn runOnce(self: *Server) bool { + const deadline = blk: { + // self.http_connections is ordered by deadline + const node = self.http_connections.first orelse break :blk null; + const conn: *Connection = @fieldParentPtr("node", node); + break :blk conn.deadline -| lp.datetime.milliTimestamp(.boot); + }; - // wait until we get a client message or a signal on the wakeup pipe - _ = posix.poll(self.pollfds, -1) catch |err| { - log.err(.app, "poll", .{ .err = err }); - continue; - }; + var events = self.io_engine.wait(deadline); + const now = lp.datetime.milliTimestamp(.boot); - // check wakeup pipe - if (wakeup_fd.revents != 0) { - wakeup_fd.revents = 0; - while (true) - _ = posix.read(self.wakeup_pipe[0], &drain_buf) catch break; + var pending_accept = false; + var pending_signal = false; + var pending_shutdown = false; + while (events.next()) |event| { + switch (event) { + .accept => pending_accept = true, + .read_write => |rw| switch (rw.target) { + .http => |conn| http.processEvent(self, conn, rw, now), + .ws => |ws| self.processWebSocketEvent(ws, rw), + }, + .signal => pending_signal = true, + .shutdown => pending_shutdown = true, } + } - // accept new connections - if (listen_fd.revents != 0) { - listen_fd.revents = 0; - self.acceptConnections(); - } + // signal first: a worker that released frees a slot the accept can use. + if (pending_signal) { + self.drainWorkerQueue(); + } + if (pending_accept) { + self.accept(now) catch |err| log.err(.serve, "accept", .{ .err = err }); + } + // shutdown last: it's terminal, and draining the queue first keeps it from + // walking a websocket whose worker has already gone. + if (pending_shutdown) { + self.beginShutdown(); + } - self.processLinks(); - - if (self.shutting_down.load(.acquire)) { - // Drain any live links so their workers can exit (issue #2510), - // then stop. Existing connections are torn down by shutdown(); - // there is nothing else to flush here. - self.shutdownLinks(); + // evict http connections that have passed their deadline + while (self.http_connections.first) |node| { + const conn: *Connection = @fieldParentPtr("node", node); + if (conn.deadline > now) { + // self.http_connections is ordered by deadline, so as soon as we find one + // that hasn't reach its deadline, none of the ones after can. break; } + lp.metrics.serve_http_evictions.incr(); + http.disconnect(self, conn); } - if (self.listener >= 0) { - sys_net.shutdown(self.listener, .both) catch |err| blk: { - if (err == error.SocketNotConnected and builtin.os.tag != .linux) { - // This error is normal/expected on BSD/MacOS. We probably - // shouldn't bother calling shutdown at all, but I guess this - // is safer. - break :blk; - } - log.warn(.app, "listener shutdown", .{ .err = err }); - }; - _ = std.c.close(self.listener); - self.listener = -1; + if (self.shutdown_begun and self.websocket_pool.live == 0) { + return false; } + return true; } -fn wakeup(self: *Server) void { - _ = sys_net.write(self.wakeup_pipe[1], &.{1}) catch {}; -} - -fn acceptConnections(self: *Server) void { - if (self.shutting_down.load(.acquire)) { - return; - } - if (self.listener < 0) { - return; +fn accept(self: *Server, now: u64) !void { + if (self.liveConnections() >= self.max_connections) { + return self.saturated(); } while (true) { - const socket = sys_net.accept(self.listener, null, null, posix.SOCK.NONBLOCK) catch |err| { + var address: posix.sockaddr.storage = undefined; + var address_len: posix.socklen_t = @sizeOf(posix.sockaddr.storage); + const socket = sys_net.accept(self.listener, @ptrCast(&address), &address_len, posix.SOCK.NONBLOCK) catch |err| { switch (err) { error.WouldBlock => break, - error.SocketNotListening => { - self.pollfds[1] = .{ .fd = -1, .events = 0, .revents = 0 }; - _ = std.c.close(self.listener); - self.listener = -1; - return; - }, error.ConnectionAborted => { - log.warn(.app, "accept connection aborted", .{}); + log.warn(.serve, "accept connection aborted", .{}); continue; }, + error.ProcessFdQuotaExceeded, error.SystemFdQuotaExceeded => { + log.warn(.serve, "accept fd limit", .{ .err = err }); + return self.saturated(); + }, else => { - log.err(.app, "accept error", .{ .err = err }); + log.err(.serve, "accept error", .{ .err = err }); continue; }, } }; + errdefer sys_net.close(socket); + configureSocket(socket); - configureSocket(socket) catch { - _ = std.c.close(socket); - continue; - }; + const peer = sys_net.addressFromSockaddr(@ptrCast(&address)); + if (comptime lp.IS_DEBUG) { + log.debug(.serve, "client connected", .{ .address = peer }); + } - self.spawnWorker(socket) catch |err| { - log.err(.app, "CDP spawn", .{ .err = err }); - _ = std.c.close(socket); - }; + const conn = try self.http_connection_pool.acquire(); + errdefer self.http_connection_pool.release(conn); + conn.socket = socket; + conn.address = peer; + + try self.io_engine.monitorHTTP(conn); + conn.deadline = now + http.IDLE_TIMEOUT_MS; + self.http_connections.append(&conn.node); + + if (self.liveConnections() == self.max_connections) { + return; + } } } -// Hand a client WebSocket's read side over to the run loop. The caller owns -// the link and must keep it alive until unregisterLink is called. The -// caller must not read from the socket. -pub fn registerLink(self: *Server, link: *Link) void { - self.link_mutex.lockUncancelable(lp.io); - self.links.append(&link.node); - self.links_dirty = true; - self.link_mutex.unlock(lp.io); - self.wakeup(); -} - -// Synchronous teardown. Blocks the caller until the run loop has dropped -// the link from its poll set and won't invoke any of the link's -// callbacks. Safe to call after the loop has already dropped the link -// unsolicited (state == .removed) — returns immediately in that case. -pub fn unregisterLink(self: *Server, link: *Link) void { - self.link_mutex.lockUncancelable(lp.io); - defer self.link_mutex.unlock(lp.io); - if (link.state == .live) { - link.state = .unregistering; - self.links_dirty = true; - self.wakeup(); - } - - while (link.state != .removed) { - // condition variable, waiting for a signal - self.link_removed.waitUncancelable(lp.io, &self.link_mutex); - } -} - -const DropLinkOpts = struct { - // on_disconnect is fired iff `notify` is true. false when the worker already - // knows the link is dead. - notify: bool, - - // Set when we know the peer is dead. Can help unblock a blocked worker's send() - shutdown_socket: bool = false, -}; - -// Drop a link from the poll set. Caller must hold link_mutex. -fn dropLink(self: *Server, link: *Link, err: ?anyerror, opts: DropLinkOpts) void { - self.links.remove(&link.node); - link.state = .removed; - self.links_dirty = true; - - if (opts.shutdown_socket) { - sys_net.shutdown(link.socket, .both) catch {}; - } - - if (opts.notify) { - // notify=true means the worker hasn't been told yet — push the - // disconnect into the inbox and break it out of curl_multi_poll. - // notify=false paths have already woken the worker (close frame - // case) or are about to be unblocked via link_removed.broadcast - // (unregister case); no extra wakeup needed. - link.driver.onLinkDisconnect(err); - link.handles.wakeup() catch |e| { - log.warn(.app, "client link wakeup", .{ .err = e }); - }; - } -} - -// Build the link portion of pollfds and snapshot the matching *Link -// pointers so we can correlate revents after poll() returns. Called -// before poll, under link_mutex. -fn preparePollFds(self: *Server) void { - self.link_mutex.lockUncancelable(lp.io); - defer self.link_mutex.unlock(lp.io); - - // Idle fast-path: link set unchanged since last rebuild, so the - // snapshot + pollfds entries from the previous iteration are still - // correct. Kernel will overwrite `revents` in the next poll() call. - if (!self.links_dirty) { - return; - } - self.links_dirty = false; - - const link_pollfds = self.pollfds[PSEUDO_POLLFDS..]; - @memset(link_pollfds, .{ .fd = -1, .events = 0, .revents = 0 }); - - var i: usize = 0; - var it = self.links.first; - while (it) |node| : (it = node.next) { - lp.assert(i < self.poll_snapshot.len, "poll snapshot overflow", .{ .i = i, .len = self.poll_snapshot.len }); - const link: *Link = @fieldParentPtr("node", node); - if (link.state != .live) { - // Will be handled in processLinks; don't poll its fd. - continue; - } - - link_pollfds[i] = .{ - .fd = link.socket, - .events = posix.POLL.IN, - .revents = 0, - }; - self.poll_snapshot[i] = link; - i += 1; - } - self.poll_count = i; -} - -// Per-iteration link handling: process pending unregistrations, then -// process revents on each polled link. Called after poll(). -fn processLinks(self: *Server) void { - var any_removed = false; - - self.link_mutex.lockUncancelable(lp.io); - defer self.link_mutex.unlock(lp.io); - - // First pass: pending unregister requests. - var it = self.links.first; - while (it) |node| { - const next = node.next; - const link: *Link = @fieldParentPtr("node", node); - if (link.state == .unregistering) { - self.dropLink(link, null, .{ .notify = false }); - any_removed = true; - } - it = next; - } - - // Second pass: revents on the snapshot. Skip links the first pass - // (or a prior natural drop) has already removed. - const link_pollfds = self.pollfds[PSEUDO_POLLFDS..]; - for (self.poll_snapshot[0..self.poll_count], 0..) |link_opt, i| { - const link = link_opt orelse continue; - if (link.state != .live) { - continue; - } - const pfd = link_pollfds[i]; - if (pfd.revents == 0) { - continue; - } - - const fatal_events: i16 = comptime @intCast(posix.POLL.HUP | posix.POLL.ERR | posix.POLL.NVAL); - if (pfd.revents & fatal_events != 0) { - self.dropLink(link, null, .{ .notify = true, .shutdown_socket = true }); - any_removed = true; - continue; - } - - if (pfd.revents & posix.POLL.IN == 0) { - continue; - } - - var buf: [16 * 1024]u8 = undefined; - const n = posix.read(link.socket, &buf) catch |err| switch (err) { - error.WouldBlock => continue, - else => { - log.warn(.app, "client read", .{ .err = err }); - self.dropLink(link, err, .{ .notify = true, .shutdown_socket = true }); - any_removed = true; - continue; - }, - }; - - if (n == 0) { - // peer EOF - self.dropLink(link, null, .{ .notify = true, .shutdown_socket = true }); - any_removed = true; - continue; - } - - const keep = link.driver.onData(buf[0..n]) catch |err| { - // Fatal frame/feed error. Whatever messages on_bytes - // managed to push are still in the inbox; the failing - // frame was NOT pushed, and the worker has no way to - // know it should exit. Drop with notify=true so - // on_disconnect surfaces a .disconnect into the inbox. - // dropLink wakes the worker. - log.info(.app, "client onData", .{ .err = err }); - self.dropLink(link, err, .{ .notify = true }); - any_removed = true; - continue; - }; - - // on_bytes succeeded — wake the worker so it observes anything - // new in the inbox (data / ping / close). - link.handles.wakeup() catch |err| { - log.warn(.app, "client link wakeup", .{ .err = err }); - }; - - if (!keep) { - // Close frame: the handler already pushed .close. Worker's - // drainInbox will call on_disconnect itself after replying, - // so we drop without re-notifying. - self.dropLink(link, null, .{ .notify = false }); - any_removed = true; - } - } - - if (any_removed) { - self.link_removed.broadcast(lp.io); - } -} - -// On shutdown, force-disconnect every still-live link. Each link's -// worker thread blocks in curl_multi_poll and is woken ONLY by this -// thread via dropLink -> handles.wakeup(). If the run loop exits with -// links still live, those workers never wake and deinit() spins on -// active_threads forever (issue #2510). Mirrors the peer-EOF path in -// processLinks: dropLink(notify=true) pushes a .disconnect into the -// worker's inbox and wakes it, so cdp.tick() returns false and the -// worker exits. -fn shutdownLinks(self: *Server) void { - self.link_mutex.lockUncancelable(lp.io); - defer self.link_mutex.unlock(lp.io); - - var it = self.links.first; - while (it) |node| { - it = node.next; - const link: *Link = @fieldParentPtr("node", node); - if (link.state == .live) { - self.dropLink(link, null, .{ .notify = true }); - } - } - - self.link_removed.broadcast(lp.io); -} - -// Liveness is enforced at the TCP layer via keepalive probes sent by the -// kernel. This is transparent to CDP clients — unlike a WebSocket ping, which -// go-rod panics on and chromedp logs as "malformed". Tunables in Config.zig. -fn configureSocket(socket: posix.socket_t) !void { - posix.setsockopt(socket, posix.SOL.SOCKET, posix.SO.KEEPALIVE, &std.mem.toBytes(@as(c_int, 1))) catch |err| { - log.warn(.app, "SO_KEEPALIVE", .{ .err = err }); - return err; - }; +// A peer that goes away without a FIN (a killed VM, a NAT timeout) leaves an +// upgraded connection holding a worker, a browser and a --cdp-max-connections +// slot; nothing above the socket notices. Keepalive is what ends it, and +// Driver.tick's wait cadence is built on that. Best effort: a socket we can't +// configure is still a usable socket. +fn configureSocket(socket: posix.socket_t) void { + setSocketOption(socket, posix.SOL.SOCKET, posix.SO.KEEPALIVE, @as(c_int, 1), "SO_KEEPALIVE"); const idle_opt = switch (builtin.os.tag) { .macos, .ios => posix.TCP.KEEPALIVE, else => posix.TCP.KEEPIDLE, }; - posix.setsockopt(socket, posix.IPPROTO.TCP, idle_opt, &std.mem.toBytes(Config.CDP_KEEPALIVE_IDLE_S)) catch |err| { - log.warn(.app, "TCP_KEEPIDLE", .{ .err = err }); - return err; - }; - posix.setsockopt(socket, posix.IPPROTO.TCP, posix.TCP.KEEPINTVL, &std.mem.toBytes(Config.CDP_KEEPALIVE_INTVL_S)) catch |err| { - log.warn(.app, "TCP_KEEPINTVL", .{ .err = err }); - return err; - }; - posix.setsockopt(socket, posix.IPPROTO.TCP, posix.TCP.KEEPCNT, &std.mem.toBytes(Config.CDP_KEEPALIVE_CNT)) catch |err| { - log.warn(.app, "TCP_KEEPCNT", .{ .err = err }); - return err; - }; + setSocketOption(socket, posix.IPPROTO.TCP, idle_opt, Config.CDP_KEEPALIVE_IDLE_S, "TCP_KEEPIDLE"); + setSocketOption(socket, posix.IPPROTO.TCP, posix.TCP.KEEPINTVL, Config.CDP_KEEPALIVE_INTVL_S, "TCP_KEEPINTVL"); + setSocketOption(socket, posix.IPPROTO.TCP, posix.TCP.KEEPCNT, Config.CDP_KEEPALIVE_CNT, "TCP_KEEPCNT"); - if (builtin.os.tag == .linux) { - posix.setsockopt(socket, posix.IPPROTO.TCP, std.os.linux.TCP.USER_TIMEOUT, &std.mem.toBytes(Config.CDP_TCP_USER_TIMEOUT_MS)) catch |err| { - log.warn(.app, "TCP_USER_TIMEOUT", .{ .err = err }); - return err; - }; + if (comptime builtin.os.tag == .linux) { + setSocketOption(socket, posix.IPPROTO.TCP, std.os.linux.TCP.USER_TIMEOUT, Config.CDP_TCP_USER_TIMEOUT_MS, "TCP_USER_TIMEOUT"); } } -fn spawnWorker(self: *Server, socket: posix.socket_t) !void { - if (self.shutting_down.load(.acquire)) { - return error.ShuttingDown; +fn setSocketOption(socket: posix.socket_t, level: i32, option: u32, value: anytype, comptime name: []const u8) void { + posix.setsockopt(socket, level, option, &std.mem.toBytes(value)) catch |err| { + log.warn(.serve, "setsockopt", .{ .err = err, .option = name }); + }; +} + +fn liveConnections(self: *const Server) usize { + return self.http_connection_pool.live + self.websocket_pool.live; +} + +// We want to accept a connection, but have reached the connection limit. See +// If there is any we can disconnect. +fn saturated(self: *Server) !void { + lp.metrics.serve_connection_limit.incr(); + var node = self.http_connections.first; + while (node) |n| : (node = n.next) { + const conn: *Connection = @fieldParentPtr("node", n); + if (conn.isIdle()) { + // we found an idle connection, bye. + http.disconnect(self, conn); + return; + } } - // Atomically increment active_conns only if below max_connections. - // Uses CAS loop to avoid race between checking the limit and incrementing. - // - // cmpxchgWeak may fail for two reasons: - // 1. Another thread changed the value (increment or decrement) - // 2. Spurious failure on some architectures (e.g. ARM) - // - // We use Weak instead of Strong because we need a retry loop anyway: - // if CAS fails because a conn slot was freed (counter decreased), we should - // retry rather than return an error - there may now be room for a new connection. - // - // On failure, cmpxchgWeak returns the actual value, which we reuse to avoid - // an extra load on the next iteration. - var current = self.active_conns.load(.monotonic); - while (current < self.max_connections) { - current = self.active_conns.cmpxchgWeak(current, current + 1, .monotonic, .monotonic) orelse break; - } else { - lp.metrics.serve_connection_limit.incr(); - return error.MaxConnectionsReached; + // there isn't an available slot, we need to pause the listener (so that + // new connections sit in the OS backlog) + if (self.listener_paused) { + // ...we already did that + return; } - errdefer _ = self.active_conns.fetchSub(1, .monotonic); + try self.io_engine.pauseListener(self.listener); + self.listener_paused = true; +} - _ = self.active_threads.fetchAdd(1, .monotonic); - errdefer _ = self.active_threads.fetchSub(1, .monotonic); +fn processWebSocketEvent(self: *Server, ws: *WebSocket, rw: IOEvent.ReadWrite) void { + if (ws.monitored == false) { + // only attachWorker puts a websocket in the poll set, and only once + // the driver is set; an unmonitored slot has no business here. + return; + } - const thread = try std.Thread.spawn(.{}, handleConnection, .{ self, socket }); + const driver = ws.driver orelse { + // the socket is only monitered after an attach, which sets the driver + lp.assert(false, "Server.processWebSocketEvent driver", .{}); + unreachable; + }; + + if (rw.readable) { + const keep = driver.onReadable(WS_READ_BUDGET) catch |err| switch (err) { + error.Closed => return self.dropWebSocket(ws, null, true), // peer EOF + // read error or fatal framing error: the worker doesn't know, so notify + else => return self.dropWebSocket(ws, err, true), + }; + if (keep == false) { + // Close frame consumed: the framer already pushed .close, the + // worker will reply and disconnect itself. + return self.dropWebSocket(ws, null, false); + } + } else if (rw.hangup) { + return self.dropWebSocket(ws, null, true); + } +} + +pub fn slotFreed(self: *Server) void { + if (self.listener_paused and !self.shutdown_begun) { + // the listener was paused (since we had no free slots) + // unpause it (we now have a free slot). + self.io_engine.monitorListener(self.listener) catch |err| { + // the next recycle retries + log.err(.serve, "resume listener", .{ .err = err }); + return; + }; + self.listener_paused = false; + } +} + +// The 101 has been written: take the fd off the http Connection (the http +// side recycles it) into a websocket slot/ +pub fn upgradeConnection(self: *Server, conn: *Connection, protocol: Driver.Protocol, session_id: ?[36]u8) void { + // it'll get added back once the Worker is started and able to process messages + self.io_engine.remove(conn.socket); + self.http_connections.remove(&conn.node); + + const ws = self.websocket_pool.acquire() catch |err| { + if (comptime lp.IS_DEBUG) { + // should not be reachable. In the HTTP upgrade processing, we + // checked isFull() + unreachable; + } + + // but, let's be safe.. + log.err(.serve, "websocket slot", .{ .err = err }); + sys_net.close(conn.socket); + return; + }; + + ws.* = .{ + .node = .{}, + .socket = conn.socket, + .address = conn.address, + .protocol = protocol, + }; + self.websockets.append(&ws.node); + + lp.metrics.serve_connections.incr(protocol); + lp.metrics.serve_active_connections.incr(protocol); + + self.workers.start(); + const thread = std.Thread.spawn(.{}, Worker.start, .{ self, ws, session_id }) catch |err| { + // cleanup what we just did prior to spawning. + log.err(.serve, "worker spawn", .{ .err = err }); + self.workers.finish(); + sys_net.close(ws.socket); + self.releaseWebSocket(ws); + return; + }; thread.detach(); } -fn handleConnection(self: *Server, socket: posix.socket_t) void { - var active_conns_early_release = false; - defer { - if (!active_conns_early_release) { - _ = self.active_conns.fetchSub(1, .monotonic); +fn drainWorkerQueue(self: *Server) void { + self.worker_mutex.lockUncancelable(lp.io); + std.mem.swap(std.ArrayList(WorkerRequest), &self.worker_queue, &self.worker_drain); + self.worker_mutex.unlock(lp.io); + + for (self.worker_drain.items) |request| { + switch (request.op) { + .attach => |driver| self.attachWorker(request.ws, driver), + .release => |notify| self.releaseWorker(request.ws, notify), } } - defer _ = self.active_threads.fetchSub(1, .monotonic); - defer _ = std.c.close(socket); + self.worker_drain.clearRetainingCapacity(); +} - const route = self.handshake(socket) orelse return; - switch (route) { - .cdp => self.serveCDP(socket, &active_conns_early_release), - .bidi => |session_id| self.serveBiDi(socket, session_id, &active_conns_early_release), +// The Worker is spawned, the Driver is setup. It has signaled us that it's +// ready to receive messages and given us the driver to associate to the +// connection. +fn attachWorker(self: *Server, ws: *WebSocket, driver: Driver) void { + if (comptime lp.IS_DEBUG) { + // a worker attaches exactly once + lp.assert(ws.driver == null, "Server.attachWorker attached", .{}); + } + ws.driver = driver; + if (self.shutdown_begun) { + driver.shutdown(); + } + self.io_engine.monitorWebSocket(ws) catch |err| { + log.err(.serve, "ws monitor", .{ .err = err }); + // never monitored, so this only tells the worker + return self.dropWebSocket(ws, err, true); + }; + ws.monitored = true; +} + +fn releaseWorker(self: *Server, ws: *WebSocket, notify: *std.Io.Event) void { + if (ws.monitored) { + ws.monitored = false; + self.io_engine.remove(ws.socket); + } + self.releaseWebSocket(ws); + // The worker is free to deinit its driver and close the fd from here. + notify.set(lp.io); +} + +// Frees the slot once the loop is done with the fd. The fd itself is closed +// by whoever owns the end of its life: the worker after its driver's deinit, +// or upgradeConnection when there never was a worker. +fn releaseWebSocket(self: *Server, ws: *WebSocket) void { + self.websockets.remove(&ws.node); + lp.metrics.serve_active_connections.decr(ws.protocol); + self.websocket_pool.release(ws); + self.slotFreed(); +} + +// unlike close above, this stops the polling on the socket and, optionally, +// informs the Worker that it should shut down. Ultimately, when it does shutdown +// releaseWebSocket above will be called. +fn dropWebSocket(self: *Server, ws: *WebSocket, err: ?anyerror, notify: bool) void { + if (ws.monitored) { + // only turned on in attachWorker, so it'll never be turned on again + ws.monitored = false; + self.io_engine.remove(ws.socket); + } + + if (notify) { + // Some closes the drivers knows about, some it doesn't. But the driver + // is always the final authority on cleanup, so we always inform it of + // the close. + if (ws.driver) |driver| { + driver.onLinkDisconnect(err); + } } } -fn handshake(self: *Server, socket: posix.socket_t) ?Handshake.Route { - { - self.driver_mutex.lockUncancelable(lp.io); - defer self.driver_mutex.unlock(lp.io); - self.handshakes.append(self.app.allocator, socket) catch return null; +fn beginShutdown(self: *Server) void { + if (self.shutdown_begun) { + return; } - defer { - self.driver_mutex.lockUncancelable(lp.io); - defer self.driver_mutex.unlock(lp.io); - for (self.handshakes.items, 0..) |s, i| { - if (s == socket) { - _ = self.handshakes.swapRemove(i); - break; + self.shutdown_begun = true; + + if (!self.listener_paused) { + self.io_engine.pauseListener(self.listener) catch {}; + self.listener_paused = true; + } + while (self.http_connections.first) |node| { + http.disconnect(self, @fieldParentPtr("node", node)); + } + + var node = self.websockets.first; + while (node) |n| : (node = n.next) { + const ws: *WebSocket = @fieldParentPtr("node", n); + // not attached yet: attachWorker terminates it on arrival + if (ws.driver) |driver| { + driver.shutdown(); + } + } +} + +fn fdBudget(config: *const Config) usize { + const reserve: usize = @as(usize, config.httpMaxConcurrent()) + config.wsMaxConcurrent() + FD_HEADROOM; + const soft: u64 = blk: { + const limit = posix.getrlimit(.NOFILE) catch |err| { + log.warn(.serve, "getrlimit", .{ .err = err }); + break :blk 1024; + }; + break :blk limit.cur; + }; + // put some limit incase of a unlimited or very large rlimit + const ceiling = (64 * 1024 * 1024) / @max(@as(u64, config.cdpMaxHTTPMessageSize()), 1); + const budget = @min(soft, ceiling) -| reserve; + return @intCast(@max(budget, 8)); +} + +fn signal(self: *Server) void { + self.io_engine.signal(); +} + +// Stateless, but helps to group things that run on the Worker thread +const Worker = struct { + fn start(server: *Server, ws: *WebSocket, session_id: ?[36]u8) void { + defer server.workers.finish(); + Worker._start(server, ws, session_id) catch |err| { + log.err(.serve, "worker init", .{ .err = err }); + Worker.releaseConnection(server, ws); + }; + } + + fn _start(server: *Server, ws: *WebSocket, session_id: ?[36]u8) !void { + const allocator = server.app.allocator; + // The socket outlives the slot: the driver's deinit below still + // writes to it (inspector detach notifications), so it closes last, + // after the loop has released us. `ws` itself must not be touched + // after releaseConnection returns. + const socket = ws.socket; + defer sys_net.close(socket); + switch (ws.protocol) { + .cdp => { + const cdp = try allocator.create(CDP); + defer allocator.destroy(cdp); + try cdp.init(server.app, ws.socket); + defer cdp.deinit(); + Worker.run(server, ws, .init(.{ .cdp = cdp })); + }, + .bidi => { + const bidi = try allocator.create(BiDi); + defer allocator.destroy(bidi); + try bidi.init(server.app, ws.socket, session_id); + defer bidi.deinit(); + Worker.run(server, ws, .init(.{ .bidi = bidi })); + }, + } + } + + fn run(server: *Server, ws: *WebSocket, driver: Driver) void { + // Gates HttpClient's block in curl_multi_poll: false (tests, before + // the attach) means "nobody will wake us, don't sleep on it". From + // here the loop is about to feed our inbox and wake us, so the + // worker parks in poll instead of spinning through tick(). + driver.browser.http_client.driver_link_active = true; + Worker.notifyLoopOfChange(server, .{ .ws = ws, .op = .{ .attach = driver } }); + driver.run(); + // The loop is done with us once releaseConnection returns; the + // driver's deinit may still tick the client, without a producer. + defer driver.browser.http_client.driver_link_active = false; + // Release first: until the loop has let go of this websocket it can + // still drop the link, and onLinkDisconnect requests a terminate. Doing + // it the other way round left that request landing after the cancel, + // so the teardown below ran with a pending terminate -- which is the + // one thing the cancel is here to prevent (cdp.deinit() and + // bidi.deinit() in our caller need V8 in a usable state). + Worker.releaseConnection(server, ws); + driver.browser.env.cancelTerminate(); + } + + // Worker -> loop: synchronous release. Blocks until the loop has dropped the + // fd and won't call feed() again, so the caller can safely deinit the driver + // (which frees the reader). + fn releaseConnection(server: *Server, ws: *WebSocket) void { + var notify: std.Io.Event = .unset; + Worker.notifyLoopOfChange(server, .{ .ws = ws, .op = .{ .release = ¬ify } }); + notify.waitUncancelable(lp.io); + } + + fn notifyLoopOfChange(server: *Server, request: WorkerRequest) void { + server.worker_mutex.lockUncancelable(lp.io); + server.worker_queue.appendAssumeCapacity(request); + server.worker_mutex.unlock(lp.io); + server.io_engine.signal(); + } +}; + +const IOEngine = switch (builtin.os.tag) { + .linux => EPoll, + .macos, .ios, .tvos, .watchos, .freebsd, .netbsd, .dragonfly, .openbsd => KQueue, + else => unreachable, +}; + +// Abstraction over an EPoll or KQueue event +pub const IOEvent = union(enum) { + accept: void, + signal: void, + shutdown: void, + read_write: ReadWrite, + + pub const ReadWrite = struct { + target: union(enum) { + ws: *WebSocket, + http: *Connection, + }, + hangup: bool, + readable: bool, + writable: bool, + }; +}; + +const EPoll = struct { + fd: posix.socket_t, + close_fd: posix.socket_t, // to signal shutdown + signal_fd: posix.socket_t, // to signal external + event_list: [128]EpollEvent, + + const linux = std.os.linux; + const EpollEvent = linux.epoll_event; + + fn init() !EPoll { + const fd = try sys_net.epoll_create1(0); + errdefer sys_net.close(fd); + + const close_fd = try sys_net.eventfd(0, std.os.linux.EFD.CLOEXEC | std.os.linux.EFD.NONBLOCK); + errdefer sys_net.close(close_fd); + + const signal_fd = try sys_net.eventfd(0, std.os.linux.EFD.CLOEXEC | std.os.linux.EFD.NONBLOCK); + errdefer sys_net.close(signal_fd); + + // Both eventfds are edge-triggered and never read: every write is its + // own edge, and one delivery services everything that arrived. + { + var event = linux.epoll_event{ + .data = .{ .ptr = 1 }, + .events = linux.EPOLL.IN | linux.EPOLL.ET, + }; + try sys_net.epoll_ctl(fd, linux.EPOLL.CTL_ADD, close_fd, &event); + } + + { + var event = linux.epoll_event{ + .data = .{ .ptr = 2 }, + .events = linux.EPOLL.IN | linux.EPOLL.ET, + }; + try sys_net.epoll_ctl(fd, linux.EPOLL.CTL_ADD, signal_fd, &event); + } + + return .{ + .fd = fd, + .close_fd = close_fd, + .signal_fd = signal_fd, + .event_list = undefined, + }; + } + + fn deinit(self: *const EPoll) void { + sys_net.close(self.close_fd); + sys_net.close(self.signal_fd); + sys_net.close(self.fd); + } + + fn stop(self: *const EPoll) void { + const increment: u64 = 1; + _ = sys_net.write(self.close_fd, std.mem.asBytes(&increment)) catch |err| { + log.fatal(.serve, "network close", .{ .err = err, .type = "epoll" }); + }; + } + + fn signal(self: *const EPoll) void { + const increment: u64 = 1; + _ = sys_net.write(self.signal_fd, std.mem.asBytes(&increment)) catch |err| { + log.err(.serve, "network signal", .{ .err = err, .type = "epoll" }); + }; + } + + fn monitorListener(self: *const EPoll, fd: posix.fd_t) !void { + var event = linux.epoll_event{ .events = linux.EPOLL.IN | linux.EPOLL.EXCLUSIVE, .data = .{ .ptr = 0 } }; + return sys_net.epoll_ctl(self.fd, linux.EPOLL.CTL_ADD, fd, &event); + } + + fn pauseListener(self: *const EPoll, fd: posix.fd_t) !void { + return sys_net.epoll_ctl(self.fd, linux.EPOLL.CTL_DEL, fd, null); + } + + const READ_EVENTS = linux.EPOLL.IN | linux.EPOLL.RDHUP; + + // No RDHUP while writing: it's level-triggered, so a half-closed peer + // would wake us continuously while the send buffer is full. A gone peer + // surfaces as a write error instead. + const WRITE_EVENTS = linux.EPOLL.OUT; + + // Poll data carries the owner: an http Connection as-is, an WebSocket with + // the low bit set (both are word-aligned, so the bit is free). + const WS_TAG: usize = 1; + + fn monitorHTTP(self: *const EPoll, conn: *Connection) !void { + var event = linux.epoll_event{ + .data = .{ .ptr = @intFromPtr(conn) }, + .events = READ_EVENTS, + }; + return sys_net.epoll_ctl(self.fd, linux.EPOLL.CTL_ADD, conn.socket, &event); + } + + fn monitorWebSocket(self: *const EPoll, ws: *WebSocket) !void { + var event = linux.epoll_event{ + .data = .{ .ptr = @intFromPtr(ws) | WS_TAG }, + .events = READ_EVENTS, + }; + return sys_net.epoll_ctl(self.fd, linux.EPOLL.CTL_ADD, ws.socket, &event); + } + + pub fn waitWritable(self: *const EPoll, conn: *Connection) !void { + return self.modify(conn, WRITE_EVENTS); + } + + pub fn waitReadable(self: *const EPoll, conn: *Connection) !void { + return self.modify(conn, READ_EVENTS); + } + + fn modify(self: *const EPoll, conn: *Connection, events: u32) !void { + var event = linux.epoll_event{ + .data = .{ .ptr = @intFromPtr(conn) }, + .events = events, + }; + return sys_net.epoll_ctl(self.fd, linux.EPOLL.CTL_MOD, conn.socket, &event); + } + + pub fn remove(self: *const EPoll, socket: posix.socket_t) void { + sys_net.epoll_ctl(self.fd, linux.EPOLL.CTL_DEL, socket, null) catch {}; + } + + // null blocks until an event arrives + fn wait(self: *EPoll, timeout_ms: ?u64) Iterator { + const event_list = &self.event_list; + const timeout: i32 = if (timeout_ms) |ms| @intCast(@min(ms, std.math.maxInt(i32))) else -1; + + const event_count = sys_net.epoll_wait(self.fd, event_list, timeout); + return .{ + .index = 0, + .events = event_list[0..event_count], + }; + } + + const Iterator = struct { + index: usize, + events: []EpollEvent, + + fn next(self: *Iterator) ?IOEvent { + const index = self.index; + const events = self.events; + if (index == events.len) { + return null; + } + self.index = index + 1; + + const event = &events[index]; + switch (event.data.ptr) { + 0 => return .{ .accept = {} }, + 1 => return .{ .shutdown = {} }, + 2 => return .{ .signal = {} }, + else => |nptr| { + const flags = event.events; + return .{ .read_write = .{ + .target = if (nptr & WS_TAG == 0) + .{ .http = @ptrFromInt(nptr) } + else + .{ .ws = @ptrFromInt(nptr & ~WS_TAG) }, + .readable = flags & linux.EPOLL.IN != 0, + .writable = flags & linux.EPOLL.OUT != 0, + .hangup = flags & (linux.EPOLL.RDHUP | linux.EPOLL.HUP | linux.EPOLL.ERR) != 0, + } }; + }, } } - } - return Handshake.run(self.app, socket, &.{ - .protocols = self.protocols, - .json_version_response = self.json_version_response, - .bidi_session_url = self.bidi_session_url, - }); -} - -// The socket is an upgraded websocket speaking CDP. -fn serveCDP(self: *Server, socket: posix.socket_t, active_conns_early_release: *bool) void { - const cdp = blk: { - const allocator = self.app.allocator; - self.driver_mutex.lockUncancelable(lp.io); - defer self.driver_mutex.unlock(lp.io); - break :blk self.cdp_pool.create(allocator) catch @panic("OOM"); }; - defer { - self.driver_mutex.lockUncancelable(lp.io); - defer self.driver_mutex.unlock(lp.io); - self.cdp_pool.destroy(cdp); +}; + +const KQueue = struct { + fd: i32, + event_list: [128]Kevent, + + const EV = std.c.EV; + const NOTE = std.c.NOTE; + const EVFILT = std.c.EVFILT; + const Kevent = std.c.Kevent; + + // Poll data carries the owner: an http Connection as-is, an WebSocket with + // the low bit set (both are word-aligned, so the bit is free). + const WS_TAG: usize = 1; + + // The listener carries 0. The two wake channels are EVFILT_USER events, + // whose ident only has to be unique amongst user events, so it doubles as + // the udata sentinel. + const LISTENER: usize = 0; + const SHUTDOWN: usize = 1; + const SIGNAL: usize = 2; + + fn init() !KQueue { + const fd = try sys_net.kqueue(); + errdefer sys_net.close(fd); + + var self = KQueue{ .fd = fd, .event_list = undefined }; + + // Both wake channels are edge-triggered and never drained: every + // NOTE_TRIGGER is its own edge, EV_CLEAR resets the event as it is + // delivered, and one delivery services everything that arrived. + try self.change(&.{ + userEvent(SHUTDOWN, EV.ADD | EV.CLEAR, 0), + userEvent(SIGNAL, EV.ADD | EV.CLEAR, 0), + }); + + return self; } - cdp.init(self.app, socket) catch |err| { - log.err(.app, "CDP init", .{ .err = err }); - return; - }; - defer cdp.deinit(); - - lp.metrics.serve_connections.incr(.cdp); - lp.metrics.serve_active_connections.incr(.cdp); - defer lp.metrics.serve_active_connections.decr(.cdp); - - self.serve(.init(.{ .cdp = cdp }), active_conns_early_release); -} - -// The socket is an upgraded websocket speaking WebDriver BiDi. session_id is -// set when the client came through a classic POST /session, which already -// created the session it's about to use. -fn serveBiDi(self: *Server, socket: posix.socket_t, session_id: ?[36]u8, active_conns_early_release: *bool) void { - const allocator = self.app.allocator; - - // heap-allocated: BiDi embeds a Browser - const bidi = allocator.create(BiDi) catch @panic("OOM"); - defer allocator.destroy(bidi); - - bidi.init(self.app, socket, session_id) catch |err| { - log.err(.app, "BiDi init", .{ .err = err }); - return; - }; - defer bidi.deinit(); - - lp.metrics.serve_connections.incr(.bidi); - lp.metrics.serve_active_connections.incr(.bidi); - defer lp.metrics.serve_active_connections.decr(.bidi); - - self.serve(.init(.{ .bidi = bidi }), active_conns_early_release); -} - -// Everything a live connection needs regardless of which protocol it -// speaks: tracking (so shutdown can reach it), handing the read side to -// the run loop, and running the worker loop. -fn serve(self: *Server, driver: Driver, active_conns_early_release: *bool) void { - const conn = driver.conn; - - if (log.enabled(.app, .info)) { - const client_address = getClientAddress(conn.socket) catch null; - log.info(.app, "client connected", .{ .ip = client_address }); + fn deinit(self: *const KQueue) void { + sys_net.close(self.fd); } - self.track(driver); - defer self.untrack(driver); - - { - // Transition from .starting state to .live - // Lock needed even though the main thread hasn't seen this yet because - // shutdown could access this from the sighandler thread. - self.driver_mutex.lockUncancelable(lp.io); - defer self.driver_mutex.unlock(lp.io); - conn.state = .live; + fn stop(self: *const KQueue) void { + self.change(&.{userEvent(SHUTDOWN, 0, NOTE.TRIGGER)}) catch |err| { + log.fatal(.serve, "network close", .{ .err = err, .type = "kqueue" }); + }; } - // Hand the read side of the socket over to the run loop. - // From here until the matching unregisterLink, the worker must NOT - // read from the socket directly — bytes arrive via the inbox. - // unregisterLink is synchronous, so by the time it returns the loop - // is guaranteed to be done with this link. - // - // driver_link_active gates HttpClient.perform's block in - // curl_multi_poll: with it false (tests, pre-handshake), perform - // skips the poll when there's no in-flight curl work — sleeping - // would just eat the timeout waiting for a wakeup that won't - // come. We set it true *after* registerLink so the loop is already - // accepting wakeups by the time the worker might poll, and clear - // it *after* unregisterLink returns (the loop is guaranteed done - // with us by then). - self.registerLink(driver.link); - driver.browser.http_client.driver_link_active = true; - defer { - self.unregisterLink(driver.link); - driver.browser.http_client.driver_link_active = false; + fn signal(self: *const KQueue) void { + self.change(&.{userEvent(SIGNAL, 0, NOTE.TRIGGER)}) catch |err| { + log.err(.serve, "network signal", .{ .err = err, .type = "kqueue" }); + }; } - // Check shutdown after markLive so that a concurrent shutdown either - // sees us as .live and terminates us, or we observe the stop signal - // here. Otherwise we could miss it and block deinit() indefinitely. - if (self.shutting_down.load(.acquire)) { - return; + fn monitorListener(self: *const KQueue, fd: posix.fd_t) !void { + return self.monitor(fd, EVFILT.READ, LISTENER); } - driver.run(); - - // Try to release the connection as soon as possible: the browser/V8 - // teardown in our callers' defers can take milliseconds, and a client - // that disconnects and immediately reconnects shouldn't be rejected - // for a slot we're merely unwinding. - active_conns_early_release.* = true; - _ = self.active_conns.fetchSub(1, .monotonic); -} - -fn track(self: *Server, driver: Driver) void { - self.driver_mutex.lockUncancelable(lp.io); - defer self.driver_mutex.unlock(lp.io); - self.drivers.append(self.app.allocator, driver) catch {}; -} - -fn untrack(self: *Server, driver: Driver) void { - self.driver_mutex.lockUncancelable(lp.io); - defer self.driver_mutex.unlock(lp.io); - - for (self.drivers.items, 0..) |*d, i| { - if (d.conn == driver.conn) { - _ = self.drivers.swapRemove(i); - break; - } + fn pauseListener(self: *const KQueue, fd: posix.fd_t) !void { + return self.change(&.{socketEvent(fd, EVFILT.READ, EV.DELETE, 0)}); } -} -fn getClientAddress(socket: posix.socket_t) !sys_net.IpAddress { - var storage: posix.sockaddr.storage = undefined; - var socklen: posix.socklen_t = @sizeOf(posix.sockaddr.storage); - try posix.getpeername(socket, @ptrCast(&storage), &socklen); - return sys_net.addressFromSockaddr(@ptrCast(&storage)); -} + fn monitorHTTP(self: *const KQueue, conn: *Connection) !void { + return self.monitor(conn.socket, EVFILT.READ, @intFromPtr(conn)); + } -// The pointed-to driver is owned by its worker thread -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", + fn monitorWebSocket(self: *const KQueue, ws: *WebSocket) !void { + return self.monitor(ws.socket, EVFILT.READ, @intFromPtr(ws) | WS_TAG); + } + + // A socket only ever has one of the two filters registered, so flipping is + // a delete plus an add. The callers only ever flip a connection that is + // registered for the filter being dropped, so the delete can't fail and + // abort the rest of the list. + pub fn waitWritable(self: *const KQueue, conn: *Connection) !void { + return self.flip(conn, EVFILT.READ, EVFILT.WRITE); + } + + pub fn waitReadable(self: *const KQueue, conn: *Connection) !void { + return self.flip(conn, EVFILT.WRITE, EVFILT.READ); + } + + fn flip(self: *const KQueue, conn: *Connection, from: i16, to: i16) !void { + return self.change(&.{ + socketEvent(conn.socket, from, EV.DELETE, 0), + socketEvent(conn.socket, to, EV.ADD | EV.ENABLE, @intFromPtr(conn)), }); } - 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 }); - // We send a Connection: Close (and actually close the connection) - // because chromedp (Go driver) sends a request to /json/version and then - // does an upgrade request, on a different connection. Since we only allow - // 1 connection at a time, the upgrade connection doesn't proceed until we - // timeout the /json/version. So, instead of waiting for that, we just - // always close HTTP requests. - const response_format = - "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" ++ - body_format; - return try std.fmt.allocPrint(app.allocator, response_format, .{ body_len, host, port }); -} + pub fn remove(self: *const KQueue, socket: posix.socket_t) void { + // We don't track which of the two a socket is registered for, and it + // might not be registered at all. + self.unmonitor(socket, EVFILT.READ); + self.unmonitor(socket, EVFILT.WRITE); + } + + // No EV_CLEAR, no EV_DISPATCH: socket filters stay level-triggered, the + // loop relies on an unread remainder waking us again. + fn monitor(self: *const KQueue, socket: posix.socket_t, filter: i16, udata: usize) !void { + return self.change(&.{socketEvent(socket, filter, EV.ADD | EV.ENABLE, udata)}); + } + + fn unmonitor(self: *const KQueue, socket: posix.socket_t, filter: i16) void { + self.change(&.{socketEvent(socket, filter, EV.DELETE, 0)}) catch {}; + } + + // Registrations go through their own kevent call rather than riding along + // with the next wait: an empty event list makes kqueue report a bad change + // through errno, so the callers above can keep an honest error union. + fn change(self: *const KQueue, changes: []const Kevent) !void { + var none: [0]Kevent = .{}; + _ = try sys_net.kevent(self.fd, changes, &none, null); + } + + fn userEvent(ident: usize, flags: u16, fflags: u32) Kevent { + return .{ + .ident = ident, + .filter = EVFILT.USER, + .flags = flags, + .fflags = fflags, + .data = 0, + .udata = ident, + }; + } + + fn socketEvent(socket: posix.socket_t, filter: i16, flags: u16, udata: usize) Kevent { + return .{ + .ident = @intCast(socket), + .filter = filter, + .flags = flags, + .fflags = 0, + .data = 0, + .udata = udata, + }; + } + + // null blocks until an event arrives + fn wait(self: *KQueue, timeout_ms: ?u64) Iterator { + const event_list = &self.event_list; + + var ts: std.c.timespec = undefined; + const timeout: ?*const std.c.timespec = if (timeout_ms) |ms| blk: { + ts = .{ .sec = @intCast(ms / 1000), .nsec = @intCast((ms % 1000) * std.time.ns_per_ms) }; + break :blk &ts; + } else null; + + // With no changes to apply, only programmer errors are possible. + const event_count = sys_net.kevent(self.fd, &.{}, event_list, timeout) catch unreachable; + return .{ + .index = 0, + .events = event_list[0..event_count], + }; + } + + const Iterator = struct { + index: usize, + events: []Kevent, + + fn next(self: *Iterator) ?IOEvent { + const index = self.index; + const events = self.events; + if (index == events.len) { + return null; + } + self.index = index + 1; + + const event = &events[index]; + switch (event.udata) { + LISTENER => return .{ .accept = {} }, + SHUTDOWN => return .{ .shutdown = {} }, + SIGNAL => return .{ .signal = {} }, + else => |nptr| { + return .{ + .read_write = .{ + .target = if (nptr & WS_TAG == 0) + .{ .http = @ptrFromInt(nptr) } + else + .{ .ws = @ptrFromInt(nptr & ~WS_TAG) }, + .readable = event.filter == EVFILT.READ, + .writable = event.filter == EVFILT.WRITE, + // EV_EOF on a read filter can still come with buffered + // bytes; readers deal with the two together. + .hangup = event.flags & (EV.EOF | EV.ERROR) != 0, + }, + }; + }, + } + } + }; +}; const testing = @import("../testing.zig"); test "server: buildJSONVersionResponse" { - const res = try buildJSONVersionResponse(testing.test_app, testing.test_app.config.port()); + const res = try http.buildJSONVersionResponse(testing.test_app, testing.test_app.config.port()); defer testing.test_app.allocator.free(res); // The response includes the build version, so check structure rather than exact bytes. try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 200 OK\r\n")); try testing.expect(std.mem.indexOf(u8, res, "Content-Type: application/json") != null); - try testing.expect(std.mem.indexOf(u8, res, "Connection: Close") != null); + // HTTP connections are kept alive now + try testing.expect(std.mem.indexOf(u8, res, "Connection: Close") == null); // Verify all required JSON fields are present in the body try testing.expect(std.mem.indexOf(u8, res, "\"Browser\": \"Lightpanda/") != null); @@ -894,9 +1150,10 @@ test "Client: http invalid handshake" { "GET /over/9000 HTTP/1.1\r\n\r\n", ); + // A known path with the wrong method is a 405 now (it used to 404). try assertHTTPError( - 404, - "Not found", + 405, + "Method not allowed", "POST / HTTP/1.1\r\n\r\n", ); @@ -932,7 +1189,7 @@ test "Client: http invalid handshake" { } test "Client: http handshake origin" { - testing.silenceLog(&.{.cdp}); + testing.expectLog(&.{ .serve, .serve, .serve, .serve, .serve }); const with_origin = "GET / HTTP/1.1\r\n" ++ @@ -976,7 +1233,7 @@ test "Client: http handshake origin" { } test "Client: http handshake host" { - testing.silenceLog(&.{.cdp}); + testing.expectLog(&.{ .serve, .serve, .serve, .serve }); // Any name in Host means something resolved to us that shouldn't // have (rebinding); only an IP literal gets through. @@ -1014,146 +1271,29 @@ test "Client: http handshake host" { } } -test "Client: http valid handshake" { - var c = try createTestClient(); - defer c.deinit(); +// --cdp-max-connections caps websockets only. Drivers (chromedp, for one) hit +// /json/version on a keepalive connection and then upgrade on a fresh one; +// with a single shared pool, X drivers needed 2X slots and the Xth upgrade +// was refused. +test "Client: idle http connections don't consume websocket slots" { + const cap: usize = testing.test_app.config.maxConnections(); + const idle = try testing.allocator.alloc(TestClient, cap + 4); + defer testing.allocator.free(idle); - // No Origin (i.e. not a browser) and a Host we're reachable at: what - // every CDP driver sends. - const request = - "GET / HTTP/1.1\r\n" ++ - "Host: 127.0.0.1:9583\r\n" ++ - "Connection: upgrade\r\n" ++ - "Upgrade: websocket\r\n" ++ - "sec-websocket-version:13\r\n" ++ - "sec-websocket-key: this is my key\r\n" ++ - "Custom: Header-Value\r\n\r\n"; - - const res = try c.httpRequest(request); - try testing.expectEqual("HTTP/1.1 101 Switching Protocols\r\n" ++ - "Upgrade: websocket\r\n" ++ - "Connection: upgrade\r\n" ++ - "Sec-Websocket-Accept: flzHu2DevQ2dSCSVqKSii5e9C2o=\r\n\r\n", res); -} - -test "Client: read invalid websocket message" { - // 131 = 128 (fin) | 3 where 3 isn't a valid type - try assertWebSocketError( - 1002, - &.{ 131, 128, 'm', 'a', 's', 'k' }, - ); - - for ([_]u8{ 16, 32, 64 }) |rsv| { - // none of the reserve flags should be set - try assertWebSocketError( - 1002, - &.{ rsv, 128, 'm', 'a', 's', 'k' }, - ); - - // as a bitmask - try assertWebSocketError( - 1002, - &.{ rsv + 4, 128, 'm', 'a', 's', 'k' }, - ); + var opened: usize = 0; + defer for (idle[0..opened]) |*c| c.deinit(); + for (idle) |*c| { + c.* = try createTestClient(); + opened += 1; + const res = try c.httpRequest("GET /json/version HTTP/1.1\r\n\r\n"); + try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 200 OK\r\n")); } - // client->server messages must be masked - try assertWebSocketError( - 1002, - &.{ 129, 1, 'a' }, - ); - - // control types (ping/ping/close) can't be > 125 bytes - for ([_]u8{ 136, 137, 138 }) |op| { - try assertWebSocketError( - 1002, - &.{ op, 254, 1, 1 }, - ); - } - - { - testing.expectLog(&.{.cdp}); - // length of message is 0, 0, 0, 0, 0, 16, 0, 1 i.e: 1024 * 1024 + 1 - try assertWebSocketError(1009, &.{ 129, 255, 0, 0, 0, 0, 0, 16, 0, 1, 'm', 'a', 's', 'k' }); - } - - // continuation type message must come after a normal message - // even when not a fin frame - try assertWebSocketError( - 1002, - &.{ 0, 129, 'm', 'a', 's', 'k', 'd' }, - ); - - // continuation type message must come after a normal message - // even as a fin frame - try assertWebSocketError( - 1002, - &.{ 128, 129, 'm', 'a', 's', 'k', 'd' }, - ); - - // text (non-fin) - text (non-fin) - try assertWebSocketError( - 1002, - &.{ 1, 129, 'm', 'a', 's', 'k', 'd', 1, 128, 'k', 's', 'a', 'm' }, - ); - - // text (non-fin) - text (fin) should always been continuation after non-fin - try assertWebSocketError( - 1002, - &.{ 1, 129, 'm', 'a', 's', 'k', 'd', 129, 128, 'k', 's', 'a', 'm' }, - ); - - // close must be fin - try assertWebSocketError( - 1002, - &.{ - 8, 129, 'm', 'a', 's', 'k', 'd', - }, - ); - - // ping must be fin - try assertWebSocketError( - 1002, - &.{ - 9, 129, 'm', 'a', 's', 'k', 'd', - }, - ); - - // pong must be fin - try assertWebSocketError( - 1002, - &.{ - 10, 129, 'm', 'a', 's', 'k', 'd', - }, - ); -} - -test "Client: ping reply" { - try assertWebSocketMessage( - // fin | pong, len - &.{ 138, 0 }, - - // fin | ping, masked | len, 4-byte mask - &.{ 137, 128, 0, 0, 0, 0 }, - ); - - try assertWebSocketMessage( - // fin | pong, len, payload - &.{ 138, 5, 100, 96, 97, 109, 104 }, - - // fin | ping, masked | len, 4-byte mask, 5 byte payload - &.{ 137, 133, 0, 5, 7, 10, 100, 101, 102, 103, 104 }, - ); -} - -test "Client: close message" { - try assertWebSocketMessage( - // fin | close, len, close code (normal) - &.{ 136, 2, 3, 232 }, - - // fin | close, masked | len, 4-byte mask - &.{ 136, 128, 0, 0, 0, 0 }, - ); + // more idle http connections than the websocket cap, and the upgrade + // still goes through + var ws = try createTestClient(); + defer ws.deinit(); + try ws.handshake("/"); } test "server: bidi session lifecycle" { @@ -1333,52 +1473,6 @@ test "server: bidi browsingContext" { try assertBidiMessage(&c, .{ .type = "success", .id = 9, .result = .{ .contexts = .{} } }); } -fn discardBidiMessage(c: *TestClient) !void { - const msg = try c.readWebsocketMessage() orelse return error.NoMessage; - if (msg.cleanup_fragment) { - c.reader.cleanup(); - } -} - -fn assertBidiEvent(c: *TestClient, method: []const u8, context: []const u8, url: []const u8) !void { - const msg = try c.readWebsocketMessage() orelse return error.NoMessage; - defer if (msg.cleanup_fragment) { - c.reader.cleanup(); - }; - - const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, msg.data, .{}); - defer parsed.deinit(); - const obj = parsed.value.object; - try testing.expectEqual("event", obj.get("type").?.string); - try testing.expectEqual(method, obj.get("method").?.string); - - const p = obj.get("params").?.object; - try testing.expectEqual(context, p.get("context").?.string); - try testing.expectEqual(url, p.get("url").?.string); - try testing.expectEqual(36, p.get("navigation").?.string.len); -} - -fn assertBidiMessage(c: *TestClient, expected: anytype) !void { - const msg = try c.readWebsocketMessage() orelse return error.NoMessage; - defer if (msg.cleanup_fragment) { - c.reader.cleanup(); - }; - - try testing.expectEqual(.text, msg.type); - try testing.expectJson(expected, msg.data); -} - -test "server: 404" { - var c = try createTestClient(); - defer c.deinit(); - - const res = try c.httpRequest("GET /unknown HTTP/1.1\r\n\r\n"); - try testing.expectEqual("HTTP/1.1 404 \r\n" ++ - "Connection: Close\r\n" ++ - "Content-Length: 9\r\n\r\n" ++ - "Not found", res); -} - test "server: classic session bootstrap" { // What Selenium does before it speaks BiDi: a classic POST /session // that hands back the websocket URL, then a DELETE on quit. @@ -1392,7 +1486,6 @@ test "server: classic session bootstrap" { "Content-Length: {d}\r\n\r\n" ++ "{s}", .{ body.len, body })); try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 200 OK\r\n")); - try testing.expect(std.mem.indexOf(u8, res, "\r\nConnection: Close\r\n") != null); const json = res[std.mem.indexOf(u8, res, "\r\n\r\n").? + 4 ..]; const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, json, .{}); @@ -1437,7 +1530,6 @@ test "server: classic session bootstrap" { const res = try c.httpRequest(try std.fmt.bufPrint(&request_buf, "DELETE /session/{s} HTTP/1.1\r\nContent-Length: 0\r\n\r\n", .{&session_id})); try testing.expectEqual("HTTP/1.1 200 OK\r\n" ++ "Content-Length: 14\r\n" ++ - "Connection: Close\r\n" ++ "Content-Type: application/json; charset=UTF-8\r\n\r\n" ++ "{\"value\":null}", res); } @@ -1465,7 +1557,8 @@ test "server: classic session bootstrap errors" { } try assertHTTPError(404, "Not found", "POST /session/abc HTTP/1.1\r\nContent-Length: 0\r\n\r\n"); - try assertHTTPError(404, "Not found", "DELETE /session HTTP/1.1\r\nContent-Length: 0\r\n\r\n"); + // the path exists (POST), the method doesn't + try assertHTTPError(405, "Method not allowed", "DELETE /session HTTP/1.1\r\nContent-Length: 0\r\n\r\n"); // a websocket upgrade on /session/ needs a real session id try assertHTTPError(404, "Not found", "GET /session/abc HTTP/1.1\r\n\r\n"); } @@ -1499,7 +1592,6 @@ test "server: protocol gate" { const res = try c.httpRequest("GET /status HTTP/1.1\r\n\r\n"); try testing.expectEqual("HTTP/1.1 200 OK\r\n" ++ "Content-Length: 37\r\n" ++ - "Connection: Close\r\n" ++ "Content-Type: application/json; charset=UTF-8\r\n\r\n" ++ "{\"value\":{\"ready\":true,\"message\":\"\"}}", res); } @@ -1508,10 +1600,181 @@ test "server: protocol gate" { var c = try createTestClient(); defer c.deinit(); const res = try c.httpRequestAlloc("GET /metrics HTTP/1.1\r\n\r\n"); + defer testing.allocator.free(res); try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 200 OK\r\n")); } } +test "Client: http valid handshake" { + var c = try createTestClient(); + defer c.deinit(); + + // No Origin (i.e. not a browser) and a Host we're reachable at: what + // every CDP driver sends. + const request = + "GET / HTTP/1.1\r\n" ++ + "Host: 127.0.0.1:9583\r\n" ++ + "Connection: upgrade\r\n" ++ + "Upgrade: websocket\r\n" ++ + "sec-websocket-version:13\r\n" ++ + "sec-websocket-key: this is my key\r\n" ++ + "Custom: Header-Value\r\n\r\n"; + + const res = try c.httpRequest(request); + try testing.expectEqual("HTTP/1.1 101 Switching Protocols\r\n" ++ + "Upgrade: websocket\r\n" ++ + "Connection: upgrade\r\n" ++ + "Sec-Websocket-Accept: flzHu2DevQ2dSCSVqKSii5e9C2o=\r\n\r\n", res); +} + +// A frame larger than WS_READ_BUDGET takes the loop more than one turn to +// assemble; the body isn't JSON, so the worker answers with a protocol error +// once it has the whole thing. +test "Client: websocket message larger than the read budget" { + const payload_len = WS_READ_BUDGET + WS_READ_BUDGET / 2; + const frame = try testing.allocator.alloc(u8, 14 + payload_len); + defer testing.allocator.free(frame); + + frame[0] = 129; // fin | text + frame[1] = 255; // masked | 127: 8-byte length follows + std.mem.writeInt(u64, frame[2..10], payload_len, .big); + @memset(frame[10..14], 0); // mask + @memset(frame[14..], 'x'); + + try assertWebSocketError(1002, frame); +} + +test "Client: read invalid websocket message" { + // 131 = 128 (fin) | 3 where 3 isn't a valid type + try assertWebSocketError( + 1002, + &.{ 131, 128, 'm', 'a', 's', 'k' }, + ); + + for ([_]u8{ 16, 32, 64 }) |rsv| { + // none of the reserve flags should be set + try assertWebSocketError( + 1002, + &.{ rsv, 128, 'm', 'a', 's', 'k' }, + ); + + // as a bitmask + try assertWebSocketError( + 1002, + &.{ rsv + 4, 128, 'm', 'a', 's', 'k' }, + ); + } + + // client->server messages must be masked + try assertWebSocketError( + 1002, + &.{ 129, 1, 'a' }, + ); + + // control types (ping/ping/close) can't be > 125 bytes + for ([_]u8{ 136, 137, 138 }) |op| { + try assertWebSocketError( + 1002, + &.{ op, 254, 1, 1 }, + ); + } + + { + testing.expectLog(&.{.cdp}); + // length of message is 0, 0, 0, 0, 0, 16, 0, 1 i.e: 1024 * 1024 + 1 + try assertWebSocketError(1009, &.{ 129, 255, 0, 0, 0, 0, 0, 16, 0, 1, 'm', 'a', 's', 'k' }); + } + + // continuation type message must come after a normal message + // even when not a fin frame + try assertWebSocketError( + 1002, + &.{ 0, 129, 'm', 'a', 's', 'k', 'd' }, + ); + + // continuation type message must come after a normal message + // even as a fin frame + try assertWebSocketError( + 1002, + &.{ 128, 129, 'm', 'a', 's', 'k', 'd' }, + ); + + // text (non-fin) - text (non-fin) + try assertWebSocketError( + 1002, + &.{ 1, 129, 'm', 'a', 's', 'k', 'd', 1, 128, 'k', 's', 'a', 'm' }, + ); + + // text (non-fin) - text (fin) should always been continuation after non-fin + try assertWebSocketError( + 1002, + &.{ 1, 129, 'm', 'a', 's', 'k', 'd', 129, 128, 'k', 's', 'a', 'm' }, + ); + + // close must be fin + try assertWebSocketError( + 1002, + &.{ + 8, 129, 'm', 'a', 's', 'k', 'd', + }, + ); + + // ping must be fin + try assertWebSocketError( + 1002, + &.{ + 9, 129, 'm', 'a', 's', 'k', 'd', + }, + ); + + // pong must be fin + try assertWebSocketError( + 1002, + &.{ + 10, 129, 'm', 'a', 's', 'k', 'd', + }, + ); +} + +test "Client: ping reply" { + try assertWebSocketMessage( + // fin | pong, len + &.{ 138, 0 }, + + // fin | ping, masked | len, 4-byte mask + &.{ 137, 128, 0, 0, 0, 0 }, + ); + + try assertWebSocketMessage( + // fin | pong, len, payload + &.{ 138, 5, 100, 96, 97, 109, 104 }, + + // fin | ping, masked | len, 4-byte mask, 5 byte payload + &.{ 137, 133, 0, 5, 7, 10, 100, 101, 102, 103, 104 }, + ); +} + +test "Client: close message" { + try assertWebSocketMessage( + // fin | close, len, close code (normal) + &.{ 136, 2, 3, 232 }, + + // fin | close, masked | len, 4-byte mask + &.{ 136, 128, 0, 0, 0, 0 }, + ); +} + +test "server: 404" { + var c = try createTestClient(); + defer c.deinit(); + + const res = try c.httpRequest("GET /unknown HTTP/1.1\r\n\r\n"); + try testing.expectEqual("HTTP/1.1 404 \r\n" ++ + "Connection: Close\r\n" ++ + "Content-Length: 9\r\n\r\n" ++ + "Not found", res); +} + test "server: get /json/version" { { // twice on the same connection @@ -1541,6 +1804,7 @@ test "server: get /json/protocol" { defer c.deinit(); const res = try c.httpRequestAlloc("GET /json/protocol HTTP/1.1\r\n\r\n"); + defer testing.allocator.free(res); try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 200 OK\r\n")); try testing.expect(std.mem.indexOf(u8, res, "Content-Type: application/json") != null); @@ -1571,9 +1835,45 @@ test "server: get /metrics" { try testing.expect(std.mem.startsWith(u8, res, "HTTP/1.1 200 OK\r\n")); try testing.expect(std.mem.indexOf(u8, res, "Content-Type: text/plain; version=0.0.4") != null); try testing.expect(std.mem.indexOf(u8, res, "build_info{version=") != null); + try testing.expect(std.mem.indexOf(u8, res, "# TYPE serve_http_requests_total counter") != null); try testing.expect(std.mem.indexOf(u8, res, "# TYPE serve_connections_total counter") != null); } +fn discardBidiMessage(c: *TestClient) !void { + const msg = try c.readWebsocketMessage() orelse return error.NoMessage; + if (msg.cleanup_fragment) { + c.reader.cleanup(); + } +} + +fn assertBidiEvent(c: *TestClient, method: []const u8, context: []const u8, url: []const u8) !void { + const msg = try c.readWebsocketMessage() orelse return error.NoMessage; + defer if (msg.cleanup_fragment) { + c.reader.cleanup(); + }; + + const parsed = try std.json.parseFromSlice(std.json.Value, testing.allocator, msg.data, .{}); + defer parsed.deinit(); + const obj = parsed.value.object; + try testing.expectEqual("event", obj.get("type").?.string); + try testing.expectEqual(method, obj.get("method").?.string); + + const p = obj.get("params").?.object; + try testing.expectEqual(context, p.get("context").?.string); + try testing.expectEqual(url, p.get("url").?.string); + try testing.expectEqual(36, p.get("navigation").?.string.len); +} + +fn assertBidiMessage(c: *TestClient, expected: anytype) !void { + const msg = try c.readWebsocketMessage() orelse return error.NoMessage; + defer if (msg.cleanup_fragment) { + c.reader.cleanup(); + }; + + try testing.expectEqual(.text, msg.type); + try testing.expectJson(expected, msg.data); +} + fn assertHTTPError( comptime expected_status: u16, comptime expected_body: []const u8, @@ -1605,7 +1905,8 @@ fn assertWebSocketError(close_code: u16, input: []const u8) !void { try testing.expectEqual(.close, msg.type); try testing.expectEqual(2, msg.data.len); - try testing.expectEqual(close_code, std.mem.readInt(u16, msg.data[0..2], .big)); + const code = std.mem.readInt(u16, msg.data[0..2], .big); + try testing.expectEqual(close_code, code); } fn assertWebSocketMessage(expected: []const u8, input: []const u8) !void { @@ -1672,12 +1973,10 @@ fn createTestClient() !TestClient { const TestClient = struct { socket: posix.socket_t, buf: [8192]u8 = undefined, - reader: WS.Reader(false), - - const WS = @import("../network/WS.zig"); + reader: WS.ReaderNoMask, fn deinit(self: *TestClient) void { - _ = std.c.close(self.socket); + sys_net.close(self.socket); self.reader.deinit(); } @@ -1694,25 +1993,7 @@ const TestClient = struct { pos += n; const response = self.buf[0..pos]; if (total_length == null) { - const header_end = std.mem.indexOf(u8, response, "\r\n\r\n") orelse continue; - const header = response[0 .. header_end + 4]; - - const cl = blk: { - const cl_header = "Content-Length: "; - const start = (std.mem.indexOf(u8, header, cl_header) orelse { - break :blk 0; - }) + cl_header.len; - - const end = std.mem.indexOfScalarPos(u8, header, start, '\r') orelse { - return error.InvalidContentLength; - }; - - break :blk std.fmt.parseInt(usize, header[start..end], 10) catch { - return error.InvalidContentLength; - }; - }; - - total_length = cl + header.len; + total_length = try responseLength(response) orelse continue; } if (total_length) |tl| { @@ -1732,15 +2013,41 @@ const TestClient = struct { try sys_net.writeAll(self.socket, req); var response: std.ArrayList(u8) = .empty; + defer response.deinit(testing.allocator); + var total_length: ?usize = null; while (true) { const n = try posix.read(self.socket, &self.buf); if (n == 0) { - return response.items; + return error.NoMoreData; + } + try response.appendSlice(testing.allocator, self.buf[0..n]); + if (total_length == null) { + total_length = try responseLength(response.items) orelse continue; + } + if (response.items.len >= total_length.?) { + return response.toOwnedSlice(testing.allocator); } - try response.appendSlice(testing.arena_allocator, self.buf[0..n]); } } + // Header + Content-Length once the header block is complete, else null. + // The server keeps HTTP/1.1 connections open, so EOF never marks the end + // of a response. + fn responseLength(response: []const u8) !?usize { + const header_end = std.mem.indexOf(u8, response, "\r\n\r\n") orelse return null; + const header = response[0 .. header_end + 4]; + + const cl_header = "Content-Length: "; + const start = (std.mem.indexOf(u8, header, cl_header) orelse return header.len) + cl_header.len; + const end = std.mem.indexOfScalarPos(u8, header, start, '\r') orelse { + return error.InvalidContentLength; + }; + const cl = std.fmt.parseInt(usize, header[start..end], 10) catch { + return error.InvalidContentLength; + }; + return cl + header.len; + } + fn handshake(self: *TestClient, path: []const u8) !void { var request_buf: [256]u8 = undefined; const request = try std.fmt.bufPrint(&request_buf, "GET {s} HTTP/1.1\r\n" ++ @@ -1789,3 +2096,220 @@ const TestClient = struct { } } }; + +// A server of our own, bound to an ephemeral port and never run(): these +// tests drive its handlers by hand to reproduce what one event batch does. +// The real test server (port 9583) is shared and can't be torn down. +const LoopTest = struct { + server: *Server, + address: sys_net.IpAddress, + + fn init() !LoopTest { + const server = try Server.init(testing.test_app, .{ .ip4 = .loopback(0) }); + errdefer server.deinit(); + server.protocols = .{ .cdp = true, .webdriver = true }; + // run() does this; runOnce() on its own would never see an accept + try server.io_engine.monitorListener(server.listener); + + var bound: posix.sockaddr.storage = undefined; + var bound_len: posix.socklen_t = @sizeOf(posix.sockaddr.storage); + try sys_net.getsockname(server.listener, @ptrCast(&bound), &bound_len); + + return .{ .server = server, .address = sys_net.addressFromSockaddr(@ptrCast(&bound)) }; + } + + fn deinit(self: *LoopTest) void { + self.server.deinit(); + } + + fn expectResponse(self: *const LoopTest, client: posix.socket_t, prefix: []const u8) !void { + _ = self; + var buf: [512]u8 = undefined; + const n = try posix.read(client, &buf); + try testing.expect(std.mem.startsWith(u8, buf[0..n], prefix)); + } + + // Connects a client and runs the accept the loop would have run, + // returning the client's end and the Connection the loop now owns. + fn accept(self: *LoopTest) !struct { posix.socket_t, *Connection } { + const client = try sys_net.connect(&self.address); + errdefer sys_net.close(client); + // never block the suite on a response that isn't coming + const timeout = std.mem.toBytes(posix.timeval{ .sec = 5, .usec = 0 }); + try posix.setsockopt(client, posix.SOL.SOCKET, posix.SO.RCVTIMEO, &timeout); + + const before = self.server.http_connections.last; + try self.server.accept(lp.datetime.milliTimestamp(.boot)); + + const node = self.server.http_connections.last orelse return error.NotAccepted; + if (node == before) { + return error.NotAccepted; + } + return .{ client, @fieldParentPtr("node", node) }; + } +}; + +// epoll and kqueue both report in the order things became ready, so making the +// deferred event ready first and the socket readable second puts the batch in +// the order that used to be fatal: the recycle before the event that names it. +test "server: a shutdown in the same batch as a readable connection" { + var lt = try LoopTest.init(); + defer lt.deinit(); + + const client, _ = try lt.accept(); + defer sys_net.close(client); + + // shutdown first... + lt.server.shutdown(); + // ...then the request, so it lands behind it in the batch + try sys_net.writeAll(client, "GET /json/version HTTP/1.1\r\n\r\n"); + + // beginShutdown drops every http connection, so running it where it + // arrived left the rest of the batch pointing at a recycled (or freed) + // Connection. The request has to be answered first. + try testing.expectEqual(false, lt.server.runOnce()); + try lt.expectResponse(client, "HTTP/1.1 200 OK\r\n"); + try testing.expect(lt.server.shutdown_begun); +} + +test "server: an accept at the connection limit in the same batch as a readable connection" { + var lt = try LoopTest.init(); + defer lt.deinit(); + + const client, _ = try lt.accept(); + defer sys_net.close(client); + + // one connection, and no room for another: the next accept has to make + // room by disconnecting an idle connection + lt.server.max_connections = 1; + try testing.expectEqual(1, lt.server.liveConnections()); + + // the accept first... + const second = try sys_net.connect(<.address); + defer sys_net.close(second); + // ...then the request, so it lands behind it in the batch + try sys_net.writeAll(client, "GET /json/version HTTP/1.1\r\n\r\n"); + + // saturated() picks the idle connection to drop, which is the one the + // batch is still holding a pointer to. Its request comes first. + _ = lt.server.runOnce(); + try lt.expectResponse(client, "HTTP/1.1 200 OK\r\n"); +} + +// Why the ordering matters rather than a flag on the connection: past the +// pool's retain count a release doesn't recycle, it destroys, so a stale +// pointer isn't merely pointing at the wrong client -- it's dangling. +test "server: releasing past the pool's retain destroys the connection" { + var lt = try LoopTest.init(); + defer lt.deinit(); + + const pool = <.server.http_connection_pool; + const retain = pool.retain; + + const clients = try testing.allocator.alloc(posix.socket_t, retain + 1); + defer testing.allocator.free(clients); + var doomed: *Connection = undefined; + for (clients, 0..) |*client, i| { + client.*, const conn = try lt.accept(); + if (i == clients.len - 1) { + doomed = conn; + } + } + defer for (clients) |client| sys_net.close(client); + try testing.expectEqual(retain + 1, pool.live); + try testing.expectEqual(0, pool.free_count); + + while (lt.server.http_connections.first) |node| { + http.disconnect(lt.server, @fieldParentPtr("node", node)); + } + + // retain + 1 released but only retain came back, and `doomed` is not among + // them: it was destroyed, not pooled. + try testing.expectEqual(0, pool.live); + try testing.expectEqual(retain, pool.free_count); + var node = pool.free.first; + while (node) |n| : (node = n.next) { + try testing.expect(@as(*Connection, @fieldParentPtr("node", n)) != doomed); + } +} + +test "server: the connection budget is bounded by buffer memory" { + const opts = &testing.test_config.mode.serve; + const original = opts.cdp_max_http_message_size; + defer opts.cdp_max_http_message_size = original; + + // whatever NOFILE happens to be, we never sign up for more read buffers + // than fdBudget's ceiling pays for (kept in step with it by hand) + const ceiling = 64 * 1024 * 1024; + for ([_]u14{ 1024, 4096, 16383 }) |size| { + opts.cdp_max_http_message_size = size; + const budget = fdBudget(testing.test_app.config); + try testing.expect(budget * size <= ceiling); + try testing.expect(budget >= 8); + } +} + +test "server: accepted sockets get TCP keepalive" { + var lt = try LoopTest.init(); + defer lt.deinit(); + + const client, const conn = try lt.accept(); + defer sys_net.close(client); + + // Driver.tick leans on this for liveness: without it a peer that goes + // away without a FIN holds a worker and a connection slot forever. + var value: c_int = 0; + var len: posix.socklen_t = @sizeOf(c_int); + try testing.expectEqual(0, std.c.getsockopt(conn.socket, posix.SOL.SOCKET, posix.SO.KEEPALIVE, &value, &len)); + try testing.expectEqual(1, value); + + http.disconnect(lt.server, conn); +} + +test "server: the http read buffer is sized by --cdp-max-http-message-size" { + // the pool is built in Server.init, so this has to move first + const opts = &testing.test_config.mode.serve; + const original = opts.cdp_max_http_message_size; + defer opts.cdp_max_http_message_size = original; + opts.cdp_max_http_message_size = 8192; + + var lt = try LoopTest.init(); + defer lt.deinit(); + + const client, const conn = try lt.accept(); + defer sys_net.close(client); + + try testing.expectEqual(8192, conn.buffer.buf.len); + + http.disconnect(lt.server, conn); +} + +test "server: http connections stay ordered by deadline" { + var lt = try LoopTest.init(); + defer lt.deinit(); + + // A connects and completes a request, so it gets the served deadline... + const client_a, const conn_a = try lt.accept(); + defer sys_net.close(client_a); + try sys_net.writeAll(client_a, "GET /json/version HTTP/1.1\r\n\r\n"); + const readable: IOEvent.ReadWrite = .{ .target = .{ .http = conn_a }, .readable = true, .writable = false, .hangup = false }; + http.processEvent(lt.server, conn_a, readable, lp.datetime.milliTimestamp(.boot)); + + // ...and B connects after it, so it sits behind A in the list. + const client_b, const conn_b = try lt.accept(); + defer sys_net.close(client_b); + + // run() reads the wait timeout off the head and the eviction sweep stops + // at the first unexpired entry, so a later node may never hold an earlier + // deadline. + var node = lt.server.http_connections.first; + var previous: u64 = 0; + while (node) |n| : (node = n.next) { + const conn: *Connection = @fieldParentPtr("node", n); + try testing.expect(conn.deadline >= previous); + previous = conn.deadline; + } + + http.disconnect(lt.server, conn_a); + http.disconnect(lt.server, conn_b); +} diff --git a/src/network/WS.zig b/src/server/WS.zig similarity index 93% rename from src/network/WS.zig rename to src/server/WS.zig index 05cc736ee..1ca441840 100644 --- a/src/network/WS.zig +++ b/src/server/WS.zig @@ -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); +} diff --git a/src/server/bidi/BiDi.zig b/src/server/bidi/BiDi.zig index 6a7d25e8f..bd26bd7e8 100644 --- a/src/server/bidi/BiDi.zig +++ b/src/server/bidi/BiDi.zig @@ -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(); diff --git a/src/server/cdp/CDP.zig b/src/server/cdp/CDP.zig index 65816abb6..b82491b53 100644 --- a/src/server/cdp/CDP.zig +++ b/src/server/cdp/CDP.zig @@ -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 { diff --git a/src/server/http.zig b/src/server/http.zig new file mode 100644 index 000000000..a5c400afd --- /dev/null +++ b/src/server/http.zig @@ -0,0 +1,929 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// 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 . + +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:`, 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; +} diff --git a/src/sys/net.zig b/src/sys/net.zig index 30b4a9446..08ad2ef93 100644 --- a/src/sys/net.zig +++ b/src/sys/net.zig @@ -1,7 +1,7 @@ // Copyright (C) 2023-2026 Lightpanda (Selecy SAS) // // Francis Bouvier -// Pierre Tachoire +// Pierres Tachoire // // 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, diff --git a/src/testing.zig b/src/testing.zig index 8fdeac8c1..bfc8c7f50 100644 --- a/src/testing.zig +++ b/src/testing.zig @@ -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;