From e6241c1918fa162cb70555f5d40e029d4d690aa0 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 21 Aug 2026 07:34:15 +0800 Subject: [PATCH 1/5] serve: Improve cdp/bidi server Significant rework of the CDP/BiDi server. There are two main changes: 1 - poll replaced with EPoll/Kqueue (1) 2 - make http serving a first class citizen The change from poll -> epoll/kqueue isn't performance driven, it's just about tighter code. Both epoll and kqueue let you associate arbitrary data with a socket, so we don't need to keep arrays in sync in order to associate a socket with a CDP by index. They both provide some event/notification mechanism, which is cleaner than the pipe required by poll. The poll -> epoll/kqueue change could almost have been mechanical. Making HTTP a first class citizen is the more significant of the two changes In `main`, a new connection always spawns a thread and, until does its own little read loop until the connection is upgraded. This is not efficient, it uses up a connection slot, and it's inconsistent with the final WebSocket connection which _is_ polled off the main loop. Using up a slot means that keepalive isn't possible, else HTTP connections would quickly use up all available slots/threads. This commit parses and serves HTTP requests on the main thread (safe because none of the processing is blocking). The approach is better streamlined for HTTP requests which never upgrade (/metrics, WebDriver) without causing any performance overhead for those that do. It simplifies some things (e.g. an "http" socket or a "websocket" socket is monitored and read in a similar manner (on the main loop)). It makes other things more complicated; the flow is no longer accept -> spawn -> upgrade -> websocket loop. It's loop -> accept -> loop -> process -> (http | ws). This is built ontop of the BiDi branch because (a) WebDriver is what needs better HTTP support and (b) some of the more mechanical changes already exist in that branch (e.g. src/cdp/, src/server.zig -> src/server/*) (1) kqueue landing in 2 commits from now on this branch. --- src/Metrics.zig | 8 +- src/log.zig | 1 + src/server/Driver.zig | 89 +- src/server/Handshake.zig | 551 ------- src/server/{Connection.zig => Link.zig} | 171 +- src/server/Server.zig | 1917 ++++++++++++----------- src/{network => server}/WS.zig | 17 +- src/server/bidi/BiDi.zig | 21 +- src/server/cdp/CDP.zig | 44 +- src/server/http.zig | 934 +++++++++++ src/sys/net.zig | 88 +- 11 files changed, 2155 insertions(+), 1686 deletions(-) delete mode 100644 src/server/Handshake.zig rename src/server/{Connection.zig => Link.zig} (56%) rename src/{network => server}/WS.zig (98%) create mode 100644 src/server/http.zig diff --git a/src/Metrics.zig b/src/Metrics.zig index ecd6df925..eff9fc6f7 100644 --- a/src/Metrics.zig +++ b/src/Metrics.zig @@ -20,8 +20,10 @@ 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: CounterEnum("reason", enum { first_request, idle }) = .{}, serve_connections: CounterEnum("driver", Driver) = .{}, serve_connection_limit: Counter = .{}, serve_active_connections: GaugeEnum("driver", Driver) = .{}, @@ -92,8 +94,10 @@ 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 exceeding a deadline; first_request never completed a request, idle is a keepalive connection that went quiet", .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/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/Connection.zig b/src/server/Link.zig similarity index 56% rename from src/server/Connection.zig rename to src/server/Link.zig index 691625cef..621fb9212 100644 --- a/src/server/Connection.zig +++ b/src/server/Link.zig @@ -21,44 +21,43 @@ 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 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; -pub const Connection = @This(); +// 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(); -// 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), +protocol: Driver.Protocol, +reader: WS.Reader, send_arena: ArenaAllocator, pub fn init( - self: *Connection, + self: *Link, app: *App, socket: posix.socket_t, - protocol: Protocol, + protocol: Driver.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 nonblocking = @as(u32, @bitCast(posix.O{ .NONBLOCK = true })); + lp.assert(socket_flags & nonblocking == nonblocking, "Link.init blocking", .{}); } const config = app.config; @@ -75,19 +74,17 @@ pub fn init( }; } -pub fn deinit(self: *Connection) void { +pub fn deinit(self: *Link) void { self.reader.deinit(); self.send_arena.deinit(); } -pub fn send(self: *Connection, data: []const u8) !void { +pub fn send(self: *Link, 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 }); }; @@ -96,15 +93,12 @@ pub fn send(self: *Connection, data: []const u8) !void { 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", .{}); + // The socket is nonblocking so loop reads never stall. Writes + // are simpler if we can block: no per-connection pending-write + // queue with its own allocations. On WouldBlock we flip the + // socket to blocking for this write and flip it back after. + // Should virtually never happen. + lp.assert(changed_to_blocking == false, "Link 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; @@ -119,7 +113,7 @@ pub fn send(self: *Connection, data: []const u8) !void { } } -pub fn sendPong(self: *Connection, data: []const u8) !void { +pub fn sendPong(self: *Link, data: []const u8) !void { if (data.len == 0) { return self.send(&WS.EMPTY_PONG); } @@ -133,61 +127,74 @@ pub fn sendPong(self: *Connection, data: []const u8) !void { 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 { +// 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); - - // 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`. +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 fn feed(self: *Connection, data: []const u8) !bool { - var remaining = data; - while (remaining.len > 0) { - // we copy what will fit into our read buffer +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 { + var pushed = false; + var remaining = budget; + while (remaining > 0) { 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; + 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; } - - remaining = remaining[used..]; } - return true; + return .{ .keep = true, .pushed = pushed }; } -// Framing-only iteration over received bytes. Will process as many messages -// as are buffered. -fn processMessages(self: *Connection) !bool { +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), + .ping, .text, .binary => try self.handleMessage(msg, pushed), .close => blk: { - _ = try self.handleMessage(msg); + _ = try self.handleMessage(msg, pushed); break :blk false; }, }; @@ -195,52 +202,46 @@ fn processMessages(self: *Connection) !bool { 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 { +fn handleMessage(self: *Link, msg: WS.Message, pushed: *bool) !bool { switch (msg.type) { .text, .binary => return switch (self.protocol) { - .cdp => self.pushCdp(msg.data), - .bidi => self.pushBiDi(msg.data), + .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 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? +// 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(), @@ -248,27 +249,25 @@ fn pushCdp(self: *Connection, bytes: []const u8) !bool { .{ .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, - } }); + self.inbox.push(arena, .{ .cdp = .{ .raw = raw, .input = input } }); + pushed.* = true; 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 { +// 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; } -pub fn shutdown(self: *Connection) void { +// Called from the worker (Driver.shutdown) to break the loop's read. +pub fn shutdown(self: *Link) void { sys_net.shutdown(self.socket, .recv) catch {}; } diff --git a/src/server/Server.zig b/src/server/Server.zig index f05486350..38257e4a1 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,846 @@ 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, + +// 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, }; 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 {}; + 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; - - const wakeup_fd = &self.pollfds[0]; - const listen_fd = &self.pollfds[1]; + self.io_engine.monitorListener(self.listener) catch |err| { + log.fatal(.serve, "io listen", .{ .err = err }); + return; + }; while (true) { - self.preparePollFds(); - - // 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; + 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); }; - // check wakeup pipe - if (wakeup_fd.revents != 0) { - wakeup_fd.revents = 0; - while (true) - _ = posix.read(self.wakeup_pipe[0], &drain_buf) catch break; - } - - // accept new connections - if (listen_fd.revents != 0) { - listen_fd.revents = 0; - self.acceptConnections(); - } - - 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(); - break; - } - } - - 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; + var events = self.io_engine.wait(deadline); + const now = lp.datetime.milliTimestamp(.boot); + while (events.next()) |event| { + switch (event) { + .accept => self.accept(now) catch |err| log.err(.serve, "accept", .{ .err = err }), + .read_write => |rw| switch (rw.target) { + .http => |conn| http.processEvent(self, conn, rw, now), + .ws => |ws| self.processWebSocketEvent(ws, rw), + }, + .signal => self.drainWorkerQueue(), + .shutdown => self.beginShutdown(), } - log.warn(.app, "listener shutdown", .{ .err = err }); - }; - _ = std.c.close(self.listener); - self.listener = -1; + } + + // 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(if (conn.served) .idle else .first_request); + http.disconnect(self, conn); + } + + // Keep looping until every worker has released; their terminate was + // requested in beginShutdown. Only then is it safe to return (deinit + // frees state the workers reference). + if (self.shutdown_begun and self.websocket_pool.live == 0) { + return; + } } } -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); + const peer = sys_net.addressFromSockaddr(@ptrCast(&address)); + if (comptime lp.IS_DEBUG) { + log.debug(.serve, "client connected", .{ .address = peer }); + } - configureSocket(socket) catch { - _ = std.c.close(socket); - continue; - }; + const conn = try self.http_connection_pool.acquire(); + errdefer self.http_connection_pool.release(conn); + conn.socket = socket; + conn.address = peer; - self.spawnWorker(socket) catch |err| { - log.err(.app, "CDP spawn", .{ .err = err }); - _ = std.c.close(socket); - }; + try self.io_engine.monitorHTTP(conn); + conn.deadline = now + http.FIRST_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(); +fn liveConnections(self: *const Server) usize { + return self.http_connection_pool.live + self.websocket_pool.live; } -// 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(); +// 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; + } } - 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) { + // 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; } - 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; + try self.io_engine.pauseListener(self.listener); + self.listener_paused = true; } -// 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; +fn processWebSocketEvent(self: *Server, ws: *WebSocket, rw: IOEvent.ReadWrite) void { + if (ws.monitored == false) { + // can have an event that comes in during the same batch as a release + // and there's no guarantee about the order that we process them in. + return; } - // 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 driver = ws.driver orelse { + // the socket is only monitered after an attach, which sets the driver + lp.assert(false, "Server.processWebSocketEvent driver", .{}); + unreachable; + }; - 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 (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 (n == 0) { - // peer EOF - self.dropLink(link, null, .{ .notify = true, .shutdown_socket = true }); - any_removed = true; - continue; + if (keep == false) { + // Close frame consumed: the framer already pushed .close, the + // worker will reply and disconnect itself. + return self.dropWebSocket(ws, null, false); } - - 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); + } else if (rw.hangup) { + return self.dropWebSocket(ws, null, true); } } -// 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; - }; - - 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; - }; - - 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; +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; } } -fn spawnWorker(self: *Server, socket: posix.socket_t) !void { - if (self.shutting_down.load(.acquire)) { - return error.ShuttingDown; - } +// 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); - // 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; - } - errdefer _ = self.active_conns.fetchSub(1, .monotonic); + 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; + } - _ = self.active_threads.fetchAdd(1, .monotonic); - errdefer _ = self.active_threads.fetchSub(1, .monotonic); + // but, let's be safe.. + log.err(.serve, "websocket slot", .{ .err = err }); + sys_net.close(conn.socket); + return; + }; - const thread = try std.Thread.spawn(.{}, handleConnection, .{ self, socket }); + 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); + + 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 }); + 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) { + self.io_engine.remove(ws.socket); + } + self.releaseWebSocket(ws); + 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 openWebSocket 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; + }; + // unlimited is really "as many as we'd care to hold 4KB buffers for" + const budget = @min(soft, 1 << 16) -| 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 { + 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; + // It's possible the terminate flag is set. Our teardown (e.g. cdp.deinit() + // and bidi.deinit() in our caller) might need V8 in a usable state. So + // clear the terminate flag + driver.browser.env.cancelTerminate(); + Worker.releaseConnection(server, ws); + } + + // 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); - } - - 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 }); - } - - 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; - } - - // 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; - } - - // 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; - } - - 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 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)); -} - -// 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", - }); - } - 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 }); -} +}; 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 +900,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 +939,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 +983,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 +1021,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 +1223,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 +1236,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 +1280,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 +1307,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 +1342,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 +1350,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 +1554,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 +1585,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 +1655,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 +1723,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 +1743,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 +1763,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" ++ diff --git a/src/network/WS.zig b/src/server/WS.zig similarity index 98% rename from src/network/WS.zig rename to src/server/WS.zig index 05cc736ee..9f99faa35 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 @@ -537,7 +540,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. 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..acc666d34 --- /dev/null +++ b/src/server/http.zig @@ -0,0 +1,934 @@ +// 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, + + // Whether at least one request has been answered. A deadline miss before + // that is a client that connected and never spoke; after, an idle keepalive. + served: bool, + + // 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 + + 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, + }; + 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.served = false; + 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, + .served = false, + .pending = null, + .state = .header, + .buffer = try .init(allocator, 4096), + }; + return conn; + } + + fn destroy(self: *Pool, conn: *Connection) void { + conn.deinit(); + self.allocator.destroy(conn); + } + }; +}; + +// How long a keepalive connection may sit without a complete request before +// we close it. +const IDLE_TIMEOUT_MS = 10_000; + +pub const FIRST_TIMEOUT_MS = 5_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. + 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 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 => { + log.warn(.serve, "serve error", .{ .err = err }); + return; + }, + }; + 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.served = true; + 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..e5881491a 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,70 @@ 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, + } + } +} + fn errnoError(e: posix.E) anyerror { return switch (e) { .AGAIN => error.WouldBlock, From 116dab4c85c6edc9c1686ec960735809c752ccda Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 28 Aug 2026 17:16:43 +0800 Subject: [PATCH 2/5] mac/bsd support (kqueue) --- src/server/Server.zig | 192 +++++++++++++++++++++++++++++++++++++++++- src/sys/net.zig | 28 ++++++ 2 files changed, 219 insertions(+), 1 deletion(-) diff --git a/src/server/Server.zig b/src/server/Server.zig index 38257e4a1..34b781a00 100644 --- a/src/server/Server.zig +++ b/src/server/Server.zig @@ -666,7 +666,7 @@ const Worker = struct { const IOEngine = switch (builtin.os.tag) { .linux => EPoll, - // .macos, .ios, .tvos, .watchos, .freebsd, .netbsd, .dragonfly, .openbsd => KQueue, + .macos, .ios, .tvos, .watchos, .freebsd, .netbsd, .dragonfly, .openbsd => KQueue, else => unreachable, }; @@ -855,6 +855,196 @@ const EPoll = struct { }; }; +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; + } + + fn deinit(self: *const KQueue) void { + sys_net.close(self.fd); + } + + fn stop(self: *const KQueue) void { + self.change(&.{userEvent(SHUTDOWN, 0, NOTE.TRIGGER)}) catch |err| { + log.fatal(.serve, "network close", .{ .err = err, .type = "kqueue" }); + }; + } + + fn signal(self: *const KQueue) void { + self.change(&.{userEvent(SIGNAL, 0, NOTE.TRIGGER)}) catch |err| { + log.err(.serve, "network signal", .{ .err = err, .type = "kqueue" }); + }; + } + + fn monitorListener(self: *const KQueue, fd: posix.fd_t) !void { + return self.monitor(fd, EVFILT.READ, LISTENER); + } + + fn pauseListener(self: *const KQueue, fd: posix.fd_t) !void { + return self.change(&.{socketEvent(fd, EVFILT.READ, EV.DELETE, 0)}); + } + + fn monitorHTTP(self: *const KQueue, conn: *Connection) !void { + return self.monitor(conn.socket, EVFILT.READ, @intFromPtr(conn)); + } + + 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)), + }); + } + + 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 http.buildJSONVersionResponse(testing.test_app, testing.test_app.config.port()); diff --git a/src/sys/net.zig b/src/sys/net.zig index e5881491a..08ad2ef93 100644 --- a/src/sys/net.zig +++ b/src/sys/net.zig @@ -284,6 +284,34 @@ pub fn epoll_wait(epfd: i32, events: []c.epoll_event, timeout: i32) usize { } } +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, From 17c2aa7d1f8422b9b5a22bd45910ca2093ce8698 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 28 Aug 2026 20:27:48 +0800 Subject: [PATCH 3/5] Hardening Give it one pass through some Claude fuzz testing. Add a max message size, protect against weird interactions during a shutdown and we had some pending accepts. Put a time limit on blocked writes. --- src/Inbox.zig | 70 +++++++- src/Metrics.zig | 6 +- src/server/Link.zig | 119 +++++++++++++- src/server/Server.zig | 361 ++++++++++++++++++++++++++++++++++++------ src/server/WS.zig | 34 +++- src/server/http.zig | 19 +-- src/testing.zig | 2 +- 7 files changed, 544 insertions(+), 67 deletions(-) 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 eff9fc6f7..89a351495 100644 --- a/src/Metrics.zig +++ b/src/Metrics.zig @@ -23,7 +23,8 @@ const Metrics = @This(); const Driver = @import("server/Driver.zig").Protocol; serve_http_requests: CounterEnum("status", @import("network/http.zig").StatusCategory) = .{}, -serve_http_evictions: CounterEnum("reason", enum { first_request, idle }) = .{}, +serve_http_evictions: Counter = .{}, +serve_inbox_backlog: Counter = .{}, serve_connections: CounterEnum("driver", Driver) = .{}, serve_connection_limit: Counter = .{}, serve_active_connections: GaugeEnum("driver", Driver) = .{}, @@ -95,7 +96,8 @@ robots_access: CounterEnum("result", enum { allow, deny }) = .{}, // 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 exceeding a deadline; first_request never completed a request, idle is a keepalive connection that went quiet", + .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 = "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", diff --git a/src/server/Link.zig b/src/server/Link.zig index 621fb9212..faeed6459 100644 --- a/src/server/Link.zig +++ b/src/server/Link.zig @@ -39,6 +39,16 @@ const ArenaAllocator = std.heap.ArenaAllocator; // inbox's own. const Link = @This(); +// A send that has flipped to blocking (below) waits at most this long for the +// peer to take more bytes. +const SEND_TIMEOUT_S = 5; + +// 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, @@ -46,6 +56,7 @@ socket_flags: usize, protocol: Driver.Protocol, reader: WS.Reader, send_arena: ArenaAllocator, +max_inbox_backlog: usize, pub fn init( self: *Link, @@ -60,6 +71,9 @@ pub fn init( lp.assert(socket_flags & nonblocking == nonblocking, "Link.init blocking", .{}); } + const timeout = std.mem.toBytes(posix.timeval{ .sec = SEND_TIMEOUT_S, .usec = 0 }); + try posix.setsockopt(socket, posix.SOL.SOCKET, posix.SO.SNDTIMEO, &timeout); + const config = app.config; const allocator = app.allocator; @@ -71,6 +85,7 @@ pub fn init( .socket_flags = socket_flags, .reader = try .init(allocator, config.cdpMaxMessageSize()), .send_arena = ArenaAllocator.init(allocator), + .max_inbox_backlog = @as(usize, config.cdpMaxMessageSize()) * INBOX_BACKLOG_MESSAGES, }; } @@ -98,11 +113,18 @@ pub fn send(self: *Link, data: []const u8) !void { // queue with its own allocations. On WouldBlock we flip the // socket to blocking for this write and flip it back after. // Should virtually never happen. - lp.assert(changed_to_blocking == false, "Link double block", .{}); + if (changed_to_blocking) { + // We already flipped, so this is SO_SNDTIMEO firing: the + // peer has stopped draining entirely. Treat it as gone + // rather than parking the worker on it. + return error.Timeout; + } 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; }, + // a signal landed mid-write; nothing was written + error.Interrupted => continue :LOOP, else => return err, }; @@ -156,6 +178,11 @@ pub const Read = struct { // 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) { @@ -267,7 +294,95 @@ fn pushBiDi(self: *Link, bytes: []const u8, pushed: *bool) !bool { return true; } -// Called from the worker (Driver.shutdown) to break the loop's read. +// 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 blocking window so the test doesn't sit out the real one + const timeout = std.mem.toBytes(posix.timeval{ .sec = 0, .usec = 50_000 }); + try posix.setsockopt(pair[1], posix.SOL.SOCKET, posix.SO.SNDTIMEO, &timeout); + + const payload = try testing.allocator.alloc(u8, 1024 * 1024); + defer testing.allocator.free(payload); + @memset(payload, 'a'); + + // Nobody drains pair[0]. send() flips to blocking 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 socket has to be non-blocking again for the run loop's reads + 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 34b781a00..ad26fc98e 100644 --- a/src/server/Server.zig +++ b/src/server/Server.zig @@ -280,47 +280,64 @@ pub fn run(self: *Server) void { return; }; - while (true) { - 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); - }; + while (self.runOnce()) {} +} - var events = self.io_engine.wait(deadline); - const now = lp.datetime.milliTimestamp(.boot); - while (events.next()) |event| { - switch (event) { - .accept => self.accept(now) catch |err| log.err(.serve, "accept", .{ .err = err }), - .read_write => |rw| switch (rw.target) { - .http => |conn| http.processEvent(self, conn, rw, now), - .ws => |ws| self.processWebSocketEvent(ws, rw), - }, - .signal => self.drainWorkerQueue(), - .shutdown => self.beginShutdown(), - } - } +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); + }; - // 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(if (conn.served) .idle else .first_request); - http.disconnect(self, conn); - } + var events = self.io_engine.wait(deadline); + const now = lp.datetime.milliTimestamp(.boot); - // Keep looping until every worker has released; their terminate was - // requested in beginShutdown. Only then is it safe to return (deinit - // frees state the workers reference). - if (self.shutdown_begun and self.websocket_pool.live == 0) { - return; + 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, } } + + // 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(); + } + + // 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.shutdown_begun and self.websocket_pool.live == 0) { + return false; + } + return true; } fn accept(self: *Server, now: u64) !void { @@ -349,6 +366,8 @@ fn accept(self: *Server, now: u64) !void { } }; errdefer sys_net.close(socket); + configureSocket(socket); + const peer = sys_net.addressFromSockaddr(@ptrCast(&address)); if (comptime lp.IS_DEBUG) { log.debug(.serve, "client connected", .{ .address = peer }); @@ -360,7 +379,7 @@ fn accept(self: *Server, now: u64) !void { conn.address = peer; try self.io_engine.monitorHTTP(conn); - conn.deadline = now + http.FIRST_TIMEOUT_MS; + conn.deadline = now + http.IDLE_TIMEOUT_MS; self.http_connections.append(&conn.node); if (self.liveConnections() == self.max_connections) { @@ -369,6 +388,33 @@ fn accept(self: *Server, now: u64) !void { } } +// 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, + }; + 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 (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 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; } @@ -399,8 +445,8 @@ fn saturated(self: *Server) !void { fn processWebSocketEvent(self: *Server, ws: *WebSocket, rw: IOEvent.ReadWrite) void { if (ws.monitored == false) { - // can have an event that comes in during the same batch as a release - // and there's no guarantee about the order that we process them in. + // only attachWorker puts a websocket in the poll set, and only once + // the driver is set; an unmonitored slot has no business here. return; } @@ -516,15 +562,17 @@ fn attachWorker(self: *Server, ws: *WebSocket, driver: Driver) void { 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 openWebSocket when there never was a worker. +// 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); @@ -585,8 +633,9 @@ fn fdBudget(config: *const Config) usize { }; break :blk limit.cur; }; - // unlimited is really "as many as we'd care to hold 4KB buffers for" - const budget = @min(soft, 1 << 16) -| reserve; + // 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)); } @@ -640,11 +689,14 @@ const Worker = struct { // 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; - // It's possible the terminate flag is set. Our teardown (e.g. cdp.deinit() - // and bidi.deinit() in our caller) might need V8 in a usable state. So - // clear the terminate flag - driver.browser.env.cancelTerminate(); + // 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 @@ -2036,3 +2088,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/server/WS.zig b/src/server/WS.zig index 9f99faa35..1ca441840 100644 --- a/src/server/WS.zig +++ b/src/server/WS.zig @@ -223,7 +223,9 @@ fn ReaderM(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; } @@ -396,7 +398,7 @@ fn ReaderM(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, @@ -580,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/http.zig b/src/server/http.zig index acc666d34..2390e638a 100644 --- a/src/server/http.zig +++ b/src/server/http.zig @@ -44,10 +44,6 @@ pub const Connection = struct { // When a keepalive (or just connected) connection should be closed deadline: u64, - // Whether at least one request has been answered. A deadline miss before - // that is a client that connected and never spoke; after, an idle keepalive. - served: bool, - // Response that couldn't be sent without blocking. Socket will switch to // "write-mode" until it's drained. pending: ?Writing, @@ -236,6 +232,7 @@ pub const Connection = struct { 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(); @@ -245,6 +242,7 @@ pub const Connection = struct { .free_count = 0, .retain = retain, .allocator = app.allocator, + .buffer_size = app.config.cdpMaxHTTPMessageSize(), }; errdefer self.deinit(); @@ -288,7 +286,6 @@ pub const Connection = struct { conn.socket = -1; conn.address = .{ .ip4 = .unspecified(0) }; conn.deadline = 0; - conn.served = false; conn.pending = null; conn.buffer.len = 0; conn.state = .header; @@ -306,10 +303,9 @@ pub const Connection = struct { .socket = -1, .address = .{ .ip4 = .unspecified(0) }, .deadline = 0, - .served = false, .pending = null, .state = .header, - .buffer = try .init(allocator, 4096), + .buffer = try .init(allocator, self.buffer_size), }; return conn; } @@ -321,11 +317,8 @@ pub const Connection = struct { }; }; -// How long a keepalive connection may sit without a complete request before -// we close it. -const IDLE_TIMEOUT_MS = 10_000; - -pub const FIRST_TIMEOUT_MS = 5_000; +// 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) { @@ -402,6 +395,7 @@ fn processHTTP(server: *Server, conn: *Connection, now: u64) !bool { 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; } @@ -796,7 +790,6 @@ fn recycle(server: *Server, conn: *Connection) void { } fn touch(server: *Server, conn: *Connection, now: u64) void { - conn.served = true; conn.deadline = now + IDLE_TIMEOUT_MS; const node = &conn.node; if (server.http_connections.last == node) { 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; From 7f29bf6931ffbbe45bf52193950235990c3c2605 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 2 Sep 2026 13:40:59 +0800 Subject: [PATCH 4/5] 500 on write error and remove blocking writes --- src/server/Link.zig | 68 ++++++++++++++++++++------------------------- src/server/http.zig | 6 ++-- 2 files changed, 34 insertions(+), 40 deletions(-) diff --git a/src/server/Link.zig b/src/server/Link.zig index faeed6459..f37a78950 100644 --- a/src/server/Link.zig +++ b/src/server/Link.zig @@ -39,9 +39,9 @@ const ArenaAllocator = std.heap.ArenaAllocator; // inbox's own. const Link = @This(); -// A send that has flipped to blocking (below) waits at most this long for the -// peer to take more bytes. -const SEND_TIMEOUT_S = 5; +// 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 @@ -52,10 +52,10 @@ const INBOX_BACKLOG_MESSAGES = 32; inbox: *Inbox, arena_pool: *ArenaPool, socket: posix.socket_t, -socket_flags: usize, protocol: Driver.Protocol, reader: WS.Reader, send_arena: ArenaAllocator, +send_timeout_ms: i32, max_inbox_backlog: usize, pub fn init( @@ -65,15 +65,12 @@ pub fn init( protocol: Driver.Protocol, inbox: *Inbox, ) !void { - const socket_flags = try sys_net.fcntl(socket, posix.F.GETFL, 0); 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 timeout = std.mem.toBytes(posix.timeval{ .sec = SEND_TIMEOUT_S, .usec = 0 }); - try posix.setsockopt(socket, posix.SOL.SOCKET, posix.SO.SNDTIMEO, &timeout); - const config = app.config; const allocator = app.allocator; @@ -82,9 +79,9 @@ pub fn init( .socket = socket, .protocol = protocol, .arena_pool = &app.arena_pool, - .socket_flags = socket_flags, .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, }; } @@ -96,35 +93,31 @@ pub fn deinit(self: *Link) void { pub fn send(self: *Link, data: []const u8) !void { var pos: usize = 0; - var changed_to_blocking: bool = false; + const socket = self.socket; defer _ = self.send_arena.reset(.{ .retain_with_limit = 1024 * 32 }); - defer if (changed_to_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) { + 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 loop reads never stall. Writes - // are simpler if we can block: no per-connection pending-write - // queue with its own allocations. On WouldBlock we flip the - // socket to blocking for this write and flip it back after. - // Should virtually never happen. - if (changed_to_blocking) { - // We already flipped, so this is SO_SNDTIMEO firing: the - // peer has stopped draining entirely. Treat it as gone - // rather than parking the worker on it. + // 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; } - 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; + continue; }, // a signal landed mid-write; nothing was written - error.Interrupted => continue :LOOP, + error.Interrupted => continue, else => return err, }; @@ -325,20 +318,19 @@ test "link: send gives up when the peer stops reading" { try link.init(testing.test_app, pair[1], .cdp, &inbox); defer link.deinit(); - // shorten the blocking window so the test doesn't sit out the real one - const timeout = std.mem.toBytes(posix.timeval{ .sec = 0, .usec = 50_000 }); - try posix.setsockopt(pair[1], posix.SOL.SOCKET, posix.SO.SNDTIMEO, &timeout); + // 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() flips to blocking 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. + // 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 socket has to be non-blocking again for the run loop's reads + // 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)); } diff --git a/src/server/http.zig b/src/server/http.zig index 2390e638a..a5c400afd 100644 --- a/src/server/http.zig +++ b/src/server/http.zig @@ -442,6 +442,8 @@ 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 @@ -540,9 +542,9 @@ fn writeError(conn: *Connection, err: anyerror) void { error.ConnectionClosed, error.ConnectionResetByPeer, error.BrokenPipe => return, error.InvalidHeader, error.InvalidHTTPMethod, error.BodyNotSupported => invalid_request_response, error.RequestTooLarge => request_too_large_response, - else => { + else => blk: { log.warn(.serve, "serve error", .{ .err = err }); - return; + break :blk internal_error_response; }, }; recordResponse(response); From c189d72adf5dc11b29b3a8841a927e64b91b2a94 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 3 Sep 2026 07:11:26 +0800 Subject: [PATCH 5/5] add safer shutdown --- src/server/Server.zig | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/server/Server.zig b/src/server/Server.zig index ad26fc98e..ae4815618 100644 --- a/src/server/Server.zig +++ b/src/server/Server.zig @@ -144,6 +144,9 @@ 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, @@ -244,6 +247,7 @@ pub fn init(app: *App, address: sys_net.IpAddress) !*Server { .worker_queue = worker_queue, .worker_drain = worker_drain, .shutdown_begun = false, + .workers = .{}, }; return self; } @@ -251,6 +255,7 @@ pub fn init(app: *App, address: sys_net.IpAddress) !*Server { pub fn deinit(self: *Server) void { const allocator = self.app.allocator; + self.workers.wait(); lp.assert(self.websockets.first == null, "Server.deinit websockets", .{}); while (self.http_connections.first) |node| { http.disconnect(self, @fieldParentPtr("node", node)); @@ -516,9 +521,11 @@ pub fn upgradeConnection(self: *Server, conn: *Connection, protocol: Driver.Prot 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; @@ -646,6 +653,7 @@ fn signal(self: *Server) void { // 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);