From 2c4179f1ad994f78be2dd5bc7ab827ce50aa9a82 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 24 Apr 2026 16:48:24 +0800 Subject: [PATCH 01/12] Support direct instantiation of custom element Support `new MyElement()` syntax to create custom element. The implementation for this is pretty straightforward, but it depends on: https://github.com/lightpanda-io/zig-v8-fork/pull/173. Also modify the `attributeChangedCallback` and pass a missing parameter: the namespace. Currently, we pass `null`, but this is less likely to cause issues than not passing anything. --- build.zig.zon | 4 +-- src/browser/Frame.zig | 34 +++++++++++++++++-- src/browser/js/Caller.zig | 15 ++++++-- src/browser/js/bridge.zig | 5 +++ .../tests/custom_elements/constructor.html | 34 +++++++++++++++++++ src/browser/webapi/CustomElementRegistry.zig | 2 +- src/browser/webapi/element/Html.zig | 17 +++++++--- src/browser/webapi/element/html/Custom.zig | 10 +++--- 8 files changed, 103 insertions(+), 18 deletions(-) diff --git a/build.zig.zon b/build.zig.zon index c89f08dc..01565f7f 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,8 +5,8 @@ .minimum_zig_version = "0.15.2", .dependencies = .{ .v8 = .{ - .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.4.0.tar.gz", - .hash = "v8-0.0.0-xddH61yIBAD04dV4CHW0qIFiqbOGvkN_-amGdmgbQ3dU", + .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/2785878461f2c07daec2aa87770dc8123d4c12db.tar.gz", + .hash = "v8-0.0.0-xddH61SJBACs_UKpNogg3DnocRLpgRzVp3XI6DbWiWlP", }, // .v8 = .{ .path = "../zig-v8-fork" }, .brotli = .{ diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 454ad784..51a52279 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -2374,6 +2374,7 @@ pub fn createElementNS(self: *Frame, namespace: Element.Namespace, name: []const attr._name, null, // old_value is null for initial attributes attr._value, + null, self, ); } @@ -2470,6 +2471,35 @@ fn populateElementAttributes(self: *Frame, element: *Element, list: anytype) !vo } } +// Called when `new MyElement()` is invoked directly in JS (not via the +// customElements.define/upgrade path). `new_target` is the constructor +// function that was used with `new`. We find the matching definition in the +// registry by function identity and allocate a detached Custom element with +// the registered tag name. +pub fn constructCustomElement(self: *Frame, new_target: JS.Function) !*Element { + var it = self.window._custom_elements._definitions.iterator(); + const definition = while (it.next()) |entry| { + if (entry.value_ptr.*.constructor.isEqual(new_target)) { + break entry.value_ptr.*; + } + } else return error.IllegalConstructor; + + // Customized built-ins (`class Foo extends HTMLDivElement`, etc.) would + // need to allocate the extended HTML type rather than Custom. Not yet + // supported via direct `new` — upgrade path still works for those. + if (definition.isCustomizedBuiltIn()) { + return error.IllegalConstructor; + } + + const tag_name = try String.init(self.arena, definition.name, .{}); + const node = try self.createHtmlElementT(Element.Html.Custom, .html, @as(?*Element.Attribute.List, null), .{ + ._proto = undefined, + ._tag_name = tag_name, + ._definition = definition, + }); + return node.as(Element); +} + pub fn createTextNode(self: *Frame, text: []const u8) !*Node { const cd = try self._factory.node(CData{ ._proto = undefined, @@ -2952,7 +2982,7 @@ pub fn attributeChange(self: *Frame, element: *Element, name: String, value: Str log.err(.bug, "build.attributeChange", .{ .tag = element.getTag(), .name = name, .value = value, .err = err, .type = self._type, .url = self.url }); }; - Element.Html.Custom.invokeAttributeChangedCallbackOnElement(element, name, old_value, value, self); + Element.Html.Custom.invokeAttributeChangedCallbackOnElement(element, name, old_value, value, null, self); var it: ?*std.DoublyLinkedList.Node = self._mutation_observers.first; while (it) |node| : (it = node.next) { @@ -2978,7 +3008,7 @@ pub fn attributeRemove(self: *Frame, element: *Element, name: String, old_value: log.err(.bug, "build.attributeRemove", .{ .tag = element.getTag(), .name = name, .err = err, .type = self._type, .url = self.url }); }; - Element.Html.Custom.invokeAttributeChangedCallbackOnElement(element, name, old_value, null, self); + Element.Html.Custom.invokeAttributeChangedCallbackOnElement(element, name, old_value, null, null, self); var it: ?*std.DoublyLinkedList.Node = self._mutation_observers.first; while (it) |node| : (it = node.next) { diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index 0cbf9e11..2b29c036 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -110,6 +110,10 @@ pub const CallOpts = struct { dom_exception: bool = false, null_as_undefined: bool = false, as_typed_array: bool = false, + // Constructor-only. When true, `new.target` is pulled from the + // FunctionCallbackInfo and passed as the first argument to the Zig + // function (as a js.Function). See bridge.Constructor.Opts. + new_target: bool = false, }; pub fn constructor(self: *Caller, comptime T: type, func: anytype, handle: *const v8.FunctionCallbackInfo, comptime opts: CallOpts) void { @@ -126,15 +130,20 @@ pub fn constructor(self: *Caller, comptime T: type, func: anytype, handle: *cons return; } - self._constructor(func, info) catch |err| { + self._constructor(func, info, opts) catch |err| { handleError(T, @TypeOf(func), local, err, info, opts); }; } -fn _constructor(self: *Caller, func: anytype, info: FunctionCallbackInfo) !void { +fn _constructor(self: *Caller, func: anytype, info: FunctionCallbackInfo, comptime opts: CallOpts) !void { const F = @TypeOf(func); const local = &self.local; - const args = try getArgs(F, 0, local, info); + const offset: comptime_int = if (opts.new_target) 1 else 0; + var args = try getArgs(F, offset, local, info); + if (comptime opts.new_target) { + const new_target_handle = v8.v8__FunctionCallbackInfo__NewTarget(info.handle).?; + @field(args, "0") = js.Function{ .local = local, .handle = @ptrCast(new_target_handle) }; + } const res = @call(.auto, func, args); const ReturnType = @typeInfo(F).@"fn".return_type orelse { diff --git a/src/browser/js/bridge.zig b/src/browser/js/bridge.zig index 00bcc713..73ddccab 100644 --- a/src/browser/js/bridge.zig +++ b/src/browser/js/bridge.zig @@ -111,6 +111,10 @@ pub const Constructor = struct { const Opts = struct { dom_exception: bool = false, + // When true, the constructor function receives `new.target` (as a + // js.Function) as its first parameter. Used by HTMLElement to support + // direct instantiation of custom elements via `new MyElement()`. + new_target: bool = false, }; fn init(comptime T: type, comptime func: anytype, comptime opts: Opts) Constructor { @@ -125,6 +129,7 @@ pub const Constructor = struct { caller.constructor(T, func, handle.?, .{ .dom_exception = opts.dom_exception, + .new_target = opts.new_target, }); } }.wrap }; diff --git a/src/browser/tests/custom_elements/constructor.html b/src/browser/tests/custom_elements/constructor.html index c99639a6..2364c20d 100644 --- a/src/browser/tests/custom_elements/constructor.html +++ b/src/browser/tests/custom_elements/constructor.html @@ -52,6 +52,40 @@ const el = document.createElement('no-constructor-element'); testing.expectEqual('NO-CONSTRUCTOR-ELEMENT', el.tagName); } + +{ + // Direct instantiation: `new MyElement()` should work for a registered + // autonomous custom element. + class DirectInstantiation extends HTMLElement { + constructor() { + super(); + this.init = 'direct'; + } + } + customElements.define('direct-instantiation', DirectInstantiation); + + const el = new DirectInstantiation(); + testing.expectEqual(true, el instanceof DirectInstantiation); + testing.expectEqual(true, el instanceof HTMLElement); + testing.expectEqual('direct-instantiation', el.localName); + testing.expectEqual('DIRECT-INSTANTIATION', el.tagName); + testing.expectEqual('direct', el.init); +} + +{ + // `new HTMLElement()` directly is illegal (no registered constructor). + let threw = false; + try { new HTMLElement(); } catch (e) { threw = true; } + testing.expectEqual(true, threw); +} + +{ + // Unregistered subclass of HTMLElement is also illegal. + class Unregistered extends HTMLElement {} + let threw = false; + try { new Unregistered(); } catch (e) { threw = true; } + testing.expectEqual(true, threw); +}
diff --git a/src/browser/webapi/CustomElementRegistry.zig b/src/browser/webapi/CustomElementRegistry.zig index 2fb7bdbf..ecd6c546 100644 --- a/src/browser/webapi/CustomElementRegistry.zig +++ b/src/browser/webapi/CustomElementRegistry.zig @@ -195,7 +195,7 @@ pub fn upgradeCustomElement(custom: *Custom, definition: *CustomElementDefinitio while (attr_it.next()) |attr| { const name = attr._name; if (definition.isAttributeObserved(name)) { - custom.invokeAttributeChangedCallback(name, null, attr._value, frame); + custom.invokeAttributeChangedCallback(name, null, attr._value, null, frame); } } diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index 4def3186..0eb4cb19 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -103,10 +103,17 @@ const HtmlElement = @This(); _type: Type, _proto: *Element, -// Special constructor for custom elements -pub fn construct(frame: *Frame) !*Element { - const node = frame._upgrading_element orelse return error.IllegalConstructor; - return node.is(Element) orelse return error.IllegalConstructor; +// Special constructor for custom elements. +// Two paths: +// - Upgrade path: customElements.define / createElement / upgrade set +// `_upgrading_element` before calling newInstance, and we just return it. +// - Direct path: `new MyElement()` from user code. `new.target` tells us +// which custom element class was invoked; look it up in the registry. +pub fn construct(new_target: js.Function, frame: *Frame) !*Element { + if (frame._upgrading_element) |node| { + return node.is(Element) orelse return error.IllegalConstructor; + } + return frame.constructCustomElement(new_target); } pub const Type = union(enum) { @@ -1225,7 +1232,7 @@ pub const JsApi = struct { pub var class_id: bridge.ClassId = undefined; }; - pub const constructor = bridge.constructor(HtmlElement.construct, .{}); + pub const constructor = bridge.constructor(HtmlElement.construct, .{ .new_target = true }); pub const innerText = bridge.accessor(_innerText, HtmlElement.setInnerText, .{}); fn _innerText(self: *HtmlElement, frame: *const Frame) ![]const u8 { diff --git a/src/browser/webapi/element/html/Custom.zig b/src/browser/webapi/element/html/Custom.zig index 1a535038..ed8fd58e 100644 --- a/src/browser/webapi/element/html/Custom.zig +++ b/src/browser/webapi/element/html/Custom.zig @@ -66,12 +66,12 @@ pub fn invokeDisconnectedCallback(self: *Custom, frame: *Frame) void { self.invokeCallback("disconnectedCallback", .{}, frame); } -pub fn invokeAttributeChangedCallback(self: *Custom, name: String, old_value: ?String, new_value: ?String, frame: *Frame) void { +pub fn invokeAttributeChangedCallback(self: *Custom, name: String, old_value: ?String, new_value: ?String, namespace: ?String, frame: *Frame) void { const definition = self._definition orelse return; if (!definition.isAttributeObserved(name)) { return; } - self.invokeCallback("attributeChangedCallback", .{ name, old_value, new_value }, frame); + self.invokeCallback("attributeChangedCallback", .{ name, old_value, new_value, namespace }, frame); } pub fn invokeConnectedCallbackOnElement(comptime from_parser: bool, element: *Element, frame: *Frame) !void { @@ -146,17 +146,17 @@ pub fn invokeDisconnectedCallbackOnElement(element: *Element, frame: *Frame) voi invokeCallbackOnElement(element, definition, "disconnectedCallback", .{}, frame); } -pub fn invokeAttributeChangedCallbackOnElement(element: *Element, name: String, old_value: ?String, new_value: ?String, frame: *Frame) void { +pub fn invokeAttributeChangedCallbackOnElement(element: *Element, name: String, old_value: ?String, new_value: ?String, namespace: ?String, frame: *Frame) void { // Autonomous custom element if (element.is(Custom)) |custom| { - custom.invokeAttributeChangedCallback(name, old_value, new_value, frame); + custom.invokeAttributeChangedCallback(name, old_value, new_value, namespace, frame); return; } // Customized built-in element - check if attribute is observed const definition = frame.getCustomizedBuiltInDefinition(element) orelse return; if (!definition.isAttributeObserved(name)) return; - invokeCallbackOnElement(element, definition, "attributeChangedCallback", .{ name, old_value, new_value }, frame); + invokeCallbackOnElement(element, definition, "attributeChangedCallback", .{ name, old_value, new_value, namespace }, frame); } fn invokeCallbackOnElement(element: *Element, definition: *CustomElementDefinition, comptime callback_name: [:0]const u8, args: anytype, frame: *Frame) void { From 9bb6c3ca32978ed2daa9ff311a77d313c6f80047 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Fri, 24 Apr 2026 10:05:33 +0200 Subject: [PATCH 02/12] rename nightly.yml into release.yml --- .github/workflows/{nightly.yml => release.yml} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename .github/workflows/{nightly.yml => release.yml} (99%) diff --git a/.github/workflows/nightly.yml b/.github/workflows/release.yml similarity index 99% rename from .github/workflows/nightly.yml rename to .github/workflows/release.yml index cc5aed83..333e87fe 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: nightly build +name: release build env: AWS_ACCESS_KEY_ID: ${{ vars.NIGHTLY_BUILD_AWS_ACCESS_ID }} From 757c70b6db68bb881abe3e6ddab6deced0130a64 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 24 Apr 2026 17:26:39 +0800 Subject: [PATCH 03/12] Add adoptedCallback for CustomElements Fired when elements are moved from one document to another. I don't think this is really used much, but it helps pass a number of WPT cases. This required tweaking insertAdjacentHTML as it was creating a full document and trigger spurious callbacks in the new code. A DocumentFragment is now used instead. --- build.zig.zon | 4 +- src/browser/Frame.zig | 20 ++++++-- .../tests/custom_elements/adopted.html | 48 +++++++++++++++++++ src/browser/webapi/Node.zig | 12 +++-- src/browser/webapi/element/Html.zig | 39 +++------------ src/browser/webapi/element/html/Custom.zig | 14 ++++++ 6 files changed, 94 insertions(+), 43 deletions(-) create mode 100644 src/browser/tests/custom_elements/adopted.html diff --git a/build.zig.zon b/build.zig.zon index 01565f7f..c412c504 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,8 +5,8 @@ .minimum_zig_version = "0.15.2", .dependencies = .{ .v8 = .{ - .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/2785878461f2c07daec2aa87770dc8123d4c12db.tar.gz", - .hash = "v8-0.0.0-xddH61SJBACs_UKpNogg3DnocRLpgRzVp3XI6DbWiWlP", + .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/2785878461f2c07daec2aa87770dc8123d4c12db.tar.gz", + .hash = "v8-0.0.0-xddH61SJBACs_UKpNogg3DnocRLpgRzVp3XI6DbWiWlP", }, // .v8 = .{ .path = "../zig-v8-fork" }, .brotli = .{ diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 51a52279..99163e2f 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -1668,11 +1668,17 @@ pub fn setNodeOwnerDocument(self: *Frame, node: *Node, owner: *Document) !void { } // Recursively sets the owner document for a node and all its descendants -pub fn adoptNodeTree(self: *Frame, node: *Node, new_owner: *Document) !void { +pub fn adoptNodeTree(self: *Frame, node: *Node, old_owner: *Document, new_owner: *Document) !void { try self.setNodeOwnerDocument(node, new_owner); + + // Per spec, adopted steps run on each element after its document is set. + if (node.is(Element)) |el| { + Element.Html.Custom.invokeAdoptedCallbackOnElement(el, old_owner, new_owner, self); + } + var it = node.childrenIterator(); while (it.next()) |child| { - try self.adoptNodeTree(child, new_owner); + try self.adoptNodeTree(child, old_owner, new_owner); } } @@ -2945,7 +2951,10 @@ pub fn _insertNodeRelative(self: *Frame, comptime from_parser: bool, parent: *No } if (opts.child_already_connected and !opts.adopting_to_new_document) { - // The child is already connected in the same document, we don't have to reconnect it + // The child is already connected in the same document, we don't have to reconnect it. + // On cross-document adoption the child has already fired + // disconnectedCallback against the old tree and must re-fire + // connectedCallback for the new tree, so we fall through. return; } @@ -2963,7 +2972,10 @@ pub fn _insertNodeRelative(self: *Frame, comptime from_parser: bool, parent: *No // Only invoke connectedCallback if the root child is transitioning from // disconnected to connected. When that happens, all descendants should also // get connectedCallback invoked (they're becoming connected as a group). - const should_invoke_connected = parent_is_connected and !opts.child_already_connected; + // Cross-document adoption also counts as a transition: the element fired + // disconnectedCallback against the old tree during removeNode and must + // now fire connectedCallback against the new tree. + const should_invoke_connected = parent_is_connected and (!opts.child_already_connected or opts.adopting_to_new_document); var tw = @import("webapi/TreeWalker.zig").Full.Elements.init(child, .{}); while (tw.next()) |el| { diff --git a/src/browser/tests/custom_elements/adopted.html b/src/browser/tests/custom_elements/adopted.html new file mode 100644 index 00000000..80dbc72d --- /dev/null +++ b/src/browser/tests/custom_elements/adopted.html @@ -0,0 +1,48 @@ + + + + + diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index 593d9f2b..1c69fae4 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -241,12 +241,16 @@ pub fn appendChild(self: *Node, child: *Node, frame: *Frame) !*Node { if (child._parent) |parent| { // we can signal removeNode that the child will remain connected // (when it's appended to self) so that it can be a bit more efficient. - frame.removeNode(parent, child, .{ .will_be_reconnected = self.isConnected() }); + // But on cross-document moves the child must fully disconnect from the + // source document (firing disconnectedCallback) before adoption. + frame.removeNode(parent, child, .{ + .will_be_reconnected = self.isConnected() and !adopting_to_new_document, + }); } // Adopt the node tree if moving between documents if (adopting_to_new_document) { - try frame.adoptNodeTree(child, parent_owner); + try frame.adoptNodeTree(child, child_owner.?, parent_owner); } try frame.appendNode(self, child, .{ @@ -591,14 +595,14 @@ pub fn insertBefore(self: *Node, new_node: *Node, ref_node_: ?*Node, frame: *Fra const adopting_to_new_document = child_owner != null and child_owner.? != parent_owner; frame.domChanged(); - const will_be_reconnected = self.isConnected(); + const will_be_reconnected = self.isConnected() and !adopting_to_new_document; if (new_node._parent) |parent| { frame.removeNode(parent, new_node, .{ .will_be_reconnected = will_be_reconnected }); } // Adopt the node tree if moving between documents if (adopting_to_new_document) { - try frame.adoptNodeTree(new_node, parent_owner); + try frame.adoptNodeTree(new_node, child_owner.?, parent_owner); } try frame.insertNodeRelative( diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index 0eb4cb19..58d63f63 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -293,42 +293,15 @@ pub fn insertAdjacentHTML( html: []const u8, frame: *Frame, ) !void { - - // Create a new HTMLDocument. - const doc = try frame._factory.document(@import("../HTMLDocument.zig"){ - ._proto = undefined, - }); - const doc_node = doc.asNode(); - - const arena = try frame.getArena(.medium, "HTML.insertAdjacentHTML"); - defer frame.releaseArena(arena); - - const Parser = @import("../../parser/Parser.zig"); - var parser = Parser.init(arena, doc_node, frame); - parser.parse(html); - - // Check if there's parsing error. - if (parser.err) |_| { - return error.Invalid; - } - - // The parser wraps content in a document structure: - // - Typical: ...... - // - Head-only: (no body) - // - Empty/comments: May have no element at all - const html_node = doc_node.firstChild() orelse return; + const DocumentFragment = @import("../DocumentFragment.zig"); + const fragment = (try DocumentFragment.init(frame)).asNode(); + try frame.parseHtmlAsChildren(fragment, html); const target_node, const prev_node = try self.asElement().asNode().findAdjacentNodes(position); - // Iterate through all children of (typically and/or ) - // and insert their children (not the containers themselves) into the target. - // This handles both body content AND head-only elements like , , etc. - var html_children = html_node.childrenIterator(); - while (html_children.next()) |container| { - var iter = container.childrenIterator(); - while (iter.next()) |child_node| { - _ = try target_node.insertBefore(child_node, prev_node, frame); - } + var iter = fragment.childrenIterator(); + while (iter.next()) |child_node| { + _ = try target_node.insertBefore(child_node, prev_node, frame); } } diff --git a/src/browser/webapi/element/html/Custom.zig b/src/browser/webapi/element/html/Custom.zig index ed8fd58e..630312f9 100644 --- a/src/browser/webapi/element/html/Custom.zig +++ b/src/browser/webapi/element/html/Custom.zig @@ -24,6 +24,7 @@ const Frame = @import("../../../Frame.zig"); const Node = @import("../../Node.zig"); const Element = @import("../../Element.zig"); +const Document = @import("../../Document.zig"); const HtmlElement = @import("../Html.zig"); const CustomElementDefinition = @import("../../CustomElementDefinition.zig"); @@ -74,6 +75,19 @@ pub fn invokeAttributeChangedCallback(self: *Custom, name: String, old_value: ?S self.invokeCallback("attributeChangedCallback", .{ name, old_value, new_value, namespace }, frame); } +pub fn invokeAdoptedCallback(self: *Custom, old_document: *Document, new_document: *Document, frame: *Frame) void { + self.invokeCallback("adoptedCallback", .{ old_document, new_document }, frame); +} + +pub fn invokeAdoptedCallbackOnElement(element: *Element, old_document: *Document, new_document: *Document, frame: *Frame) void { + if (element.is(Custom)) |custom| { + custom.invokeAdoptedCallback(old_document, new_document, frame); + return; + } + const definition = frame.getCustomizedBuiltInDefinition(element) orelse return; + invokeCallbackOnElement(element, definition, "adoptedCallback", .{ old_document, new_document }, frame); +} + pub fn invokeConnectedCallbackOnElement(comptime from_parser: bool, element: *Element, frame: *Frame) !void { // Autonomous custom element if (element.is(Custom)) |custom| { From ce541fbc6372122bba5afb4e9a80185ab25c3203 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire <pierre@lightpanda.io> Date: Fri, 24 Apr 2026 10:28:30 +0200 Subject: [PATCH 04/12] ci: add arch linux package generation --- .github/workflows/release.yml | 86 +++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 333e87fe..4842ce9c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -63,6 +63,13 @@ jobs: tag: ${{ env.RELEASE }} makeLatest: true + - name: Share binary with packaging jobs + uses: actions/upload-artifact@v4 + with: + name: lightpanda-${{ env.ARCH }}-${{ env.OS }} + path: lightpanda-${{ env.ARCH }}-${{ env.OS }} + retention-days: 1 + build-linux-aarch64: env: ARCH: aarch64 @@ -103,6 +110,13 @@ jobs: tag: ${{ env.RELEASE }} makeLatest: true + - name: Share binary with packaging jobs + uses: actions/upload-artifact@v4 + with: + name: lightpanda-${{ env.ARCH }}-${{ env.OS }} + path: lightpanda-${{ env.ARCH }}-${{ env.OS }} + retention-days: 1 + build-macos-aarch64: env: ARCH: aarch64 @@ -184,3 +198,75 @@ jobs: artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }} tag: ${{ env.RELEASE }} makeLatest: true + + package-archlinux: + if: github.ref_type == 'tag' + strategy: + fail-fast: false + matrix: + arch: [x86_64, aarch64] + + env: + ARCH: ${{ matrix.arch }} + OS: linux + + needs: + - build-linux-x86_64 + - build-linux-aarch64 + runs-on: ubuntu-22.04 + container: archlinux:latest + timeout-minutes: 10 + + steps: + - uses: actions/checkout@v6 + + - name: Install packaging deps + run: pacman -Syu --noconfirm --needed base-devel sudo + + - name: Download linux binary + uses: actions/download-artifact@v4 + with: + name: lightpanda-${{ env.ARCH }}-${{ env.OS }} + path: . + + - name: Build Arch package + run: | + useradd -m builder + echo "builder ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers + + RAW_VERSION="${{ env.RELEASE }}" + PKGVER="${RAW_VERSION#v}" + PKGREL="1" + echo "PKGVER=${PKGVER}" >> "$GITHUB_ENV" + echo "PKGREL=${PKGREL}" >> "$GITHUB_ENV" + + mkdir -p pkg + cp lightpanda-${{ env.ARCH }}-${{ env.OS }} pkg/ + cp LICENSE pkg/ + + cat > pkg/PKGBUILD <<EOF + pkgname=lightpanda + pkgver=${PKGVER} + pkgrel=${PKGREL} + pkgdesc="Lightpanda, headless browser built for AI and automation" + arch=('x86_64' 'aarch64') + url="https://lightpanda.io" + license=('AGPL-3.0-or-later') + options=(!strip !debug) + package() { + install -Dm755 "\$startdir/lightpanda-\$CARCH-linux" "\$pkgdir/usr/bin/lightpanda" + install -Dm644 "\$startdir/LICENSE" "\$pkgdir/usr/share/licenses/\$pkgname/LICENSE" + } + EOF + + chown -R builder:builder pkg + cd pkg + sudo -u builder env CARCH=${{ env.ARCH }} makepkg -f --noconfirm -A + + - name: Upload Arch package to release + uses: ncipollo/release-action@v1 + with: + allowUpdates: true + artifacts: pkg/lightpanda-${{ env.PKGVER }}-${{ env.PKGREL }}-${{ env.ARCH }}.pkg.tar.zst + tag: ${{ env.RELEASE }} + makeLatest: true From 368bbc75e07860b5eacb47d5275f039635789e34 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire <pierre@lightpanda.io> Date: Fri, 24 Apr 2026 10:51:43 +0200 Subject: [PATCH 05/12] ci: refacto build step into 1 job per os + CPU matrix --- .github/workflows/release.yml | 129 ++++++++-------------------------- 1 file changed, 30 insertions(+), 99 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4842ce9c..62f75198 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,12 +23,23 @@ permissions: contents: write jobs: - build-linux-x86_64: + build-linux: + strategy: + fail-fast: false + matrix: + include: + - arch: x86_64 + runner: ubuntu-22.04 + cpu_flag: -Dcpu=x86_64 + - arch: aarch64 + runner: ubuntu-22.04-arm + cpu_flag: -Dcpu=generic + env: - ARCH: x86_64 + ARCH: ${{ matrix.arch }} OS: linux - runs-on: ubuntu-22.04 + runs-on: ${{ matrix.runner }} timeout-minutes: 20 steps: @@ -45,7 +56,7 @@ jobs: run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin - name: zig build - run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=x86_64 ${{ env.VERSION_FLAG }} + run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast ${{ matrix.cpu_flag }} ${{ env.VERSION_FLAG }} - name: Rename binary run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} @@ -70,101 +81,23 @@ jobs: path: lightpanda-${{ env.ARCH }}-${{ env.OS }} retention-days: 1 - build-linux-aarch64: + build-macos: + strategy: + fail-fast: false + matrix: + include: + # macos-14 runs on arm CPU. see + # https://github.com/actions/runner-images?tab=readme-ov-file + - arch: aarch64 + runner: macos-14 + - arch: x86_64 + runner: macos-14-large + env: - ARCH: aarch64 - OS: linux - - runs-on: ubuntu-22.04-arm - timeout-minutes: 20 - - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - uses: ./.github/actions/install - with: - os: ${{env.OS}} - arch: ${{env.ARCH}} - - - name: v8 snapshot - run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin - - - name: zig build - run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast -Dcpu=generic ${{ env.VERSION_FLAG }} - - - name: Rename binary - run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} - - - name: upload on s3 - run: | - export DIR=`git show --no-patch --no-notes --pretty='%cs_%h'` - aws s3 cp --storage-class=GLACIER_IR lightpanda-${{ env.ARCH }}-${{ env.OS }} s3://lpd-nightly-build/${DIR}/lightpanda-${{ env.ARCH }}-${{ env.OS }} - - - name: Upload the build - uses: ncipollo/release-action@v1 - with: - allowUpdates: true - artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }} - tag: ${{ env.RELEASE }} - makeLatest: true - - - name: Share binary with packaging jobs - uses: actions/upload-artifact@v4 - with: - name: lightpanda-${{ env.ARCH }}-${{ env.OS }} - path: lightpanda-${{ env.ARCH }}-${{ env.OS }} - retention-days: 1 - - build-macos-aarch64: - env: - ARCH: aarch64 + ARCH: ${{ matrix.arch }} OS: macos - # macos-14 runs on arm CPU. see - # https://github.com/actions/runner-images?tab=readme-ov-file - runs-on: macos-14 - timeout-minutes: 20 - - steps: - - uses: actions/checkout@v6 - with: - fetch-depth: 0 - - - uses: ./.github/actions/install - with: - os: ${{env.OS}} - arch: ${{env.ARCH}} - - - name: v8 snapshot - run: zig build -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast snapshot_creator -- src/snapshot.bin - - - name: zig build - run: zig build -Dsnapshot_path=../../snapshot.bin -Dprebuilt_v8_path=v8/libc_v8.a -Doptimize=ReleaseFast ${{ env.VERSION_FLAG }} - - - name: Rename binary - run: mv zig-out/bin/lightpanda lightpanda-${{ env.ARCH }}-${{ env.OS }} - - - name: upload on s3 - run: | - export DIR=`git show --no-patch --no-notes --pretty='%cs_%h'` - aws s3 cp --storage-class=GLACIER_IR lightpanda-${{ env.ARCH }}-${{ env.OS }} s3://lpd-nightly-build/${DIR}/lightpanda-${{ env.ARCH }}-${{ env.OS }} - - - name: Upload the build - uses: ncipollo/release-action@v1 - with: - allowUpdates: true - artifacts: lightpanda-${{ env.ARCH }}-${{ env.OS }} - tag: ${{ env.RELEASE }} - makeLatest: true - - build-macos-x86_64: - env: - ARCH: x86_64 - OS: macos - - runs-on: macos-14-large + runs-on: ${{ matrix.runner }} timeout-minutes: 20 steps: @@ -210,9 +143,7 @@ jobs: ARCH: ${{ matrix.arch }} OS: linux - needs: - - build-linux-x86_64 - - build-linux-aarch64 + needs: build-linux runs-on: ubuntu-22.04 container: archlinux:latest timeout-minutes: 10 From 7b50dff9562dbf9ead76fd6ae29d5b0f396fa98b Mon Sep 17 00:00:00 2001 From: Karl Seguin <k@openmymind.io> Date: Fri, 24 Apr 2026 18:19:12 +0800 Subject: [PATCH 06/12] Fix a user-after-free on an empty (and invalid) empty location Improves WPT: /fetch/api/redirect/redirect-empty-location.any.html --- src/browser/HttpClient.zig | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig index bc31adc2..fc217e3d 100644 --- a/src/browser/HttpClient.zig +++ b/src/browser/HttpClient.zig @@ -1564,10 +1564,18 @@ pub const Transfer = struct { return error.LocationNotFound; }; - const base_url = try conn.getEffectiveUrl(); - const url = try URL.resolve(arena, std.mem.span(base_url), location.value, .{}); - try transfer.updateURL(url); + const url: [:0]const u8 = blk: { + if (location.value.len == 0) { + // Might seem silly, but URL.resovle will return location.value as-is + // if empty, and location.value is memory owned by libcurl. + break :blk ""; + } + const base_url = try conn.getEffectiveUrl(); + break :blk try URL.resolve(arena, std.mem.span(base_url), location.value, .{}); + }; + + try transfer.updateURL(url); // 301, 302, 303 → change to GET, drop body. // 307, 308 → keep method and body. const status = try conn.getResponseCode(); From c7d004fefb17484051d7f9d1e009907e7cbd2e1d Mon Sep 17 00:00:00 2001 From: Nikolay Govorov <me@govorov.online> Date: Thu, 23 Apr 2026 15:37:02 +0100 Subject: [PATCH 07/12] Setup timeout via tcp keepalive --- src/Config.zig | 7 +++- src/Server.zig | 73 +++++++++++++++++++++++++++++---------- src/cdp/testing.zig | 2 +- src/network/websocket.zig | 4 +-- 4 files changed, 62 insertions(+), 24 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index 4c858975..a78fd14a 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -637,7 +637,12 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\ Useful, for example, when --host is 0.0.0.0. \\ Defaults to --host value \\ - \\--timeout Inactivity timeout in seconds before disconnecting clients + \\--timeout Approximate time in seconds after which a dead client + \\ is dropped. Implemented via TCP keepalive: the kernel + \\ probes silent peers and closes the socket if they do + \\ not respond. Does not affect clients that are actively + \\ sending or receiving data, or waiting on a slow page + \\ load. 0 disables keepalive (OS defaults apply). \\ Defaults to 10 (seconds). Limited to 604800 (1 week). \\ \\--cdp-max-connections diff --git a/src/Server.zig b/src/Server.zig index b4c2b119..e9b4355b 100644 --- a/src/Server.zig +++ b/src/Server.zig @@ -90,9 +90,49 @@ fn onAccept(ctx: *anyopaque, socket: posix.socket_t) void { }; } +// 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 +// some clients — go-rod panics, chromedp logs spurious "malformed" — handle +// incorrectly). Detection window is roughly `timeout_s`: +// keepidle = timeout_s - keepcnt * keepintvl (clamped >= 1s) +// keepintvl = 2s, keepcnt = 3 → 6s of probe escalation. +// timeout_ms == 0 leaves keepalive off (OS defaults apply — typically 2h). +fn setTcpKeepalive(socket: posix.socket_t, timeout_ms: u32) void { + if (timeout_ms == 0) return; + + 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; + }; + + const keepcnt: u32 = 3; + const keepintvl: u32 = 2; + const timeout_s: u32 = @max(timeout_ms / 1000, 1); + const probe_window: u32 = keepcnt * keepintvl; + const keepidle: u32 = if (timeout_s > probe_window) timeout_s - probe_window else 1; + + if (@hasDecl(posix.TCP, "KEEPIDLE")) { + posix.setsockopt(socket, posix.IPPROTO.TCP, posix.TCP.KEEPIDLE, &std.mem.toBytes(@as(c_int, @intCast(keepidle)))) catch |err| { + log.warn(.app, "TCP_KEEPIDLE", .{ .err = err }); + }; + } + if (@hasDecl(posix.TCP, "KEEPINTVL")) { + posix.setsockopt(socket, posix.IPPROTO.TCP, posix.TCP.KEEPINTVL, &std.mem.toBytes(@as(c_int, @intCast(keepintvl)))) catch |err| { + log.warn(.app, "TCP_KEEPINTVL", .{ .err = err }); + }; + } + if (@hasDecl(posix.TCP, "KEEPCNT")) { + posix.setsockopt(socket, posix.IPPROTO.TCP, posix.TCP.KEEPCNT, &std.mem.toBytes(@as(c_int, @intCast(keepcnt)))) catch |err| { + log.warn(.app, "TCP_KEEPCNT", .{ .err = err }); + }; + } +} + fn handleConnection(self: *Server, socket: posix.socket_t, timeout_ms: u32) void { defer posix.close(socket); + setTcpKeepalive(socket, timeout_ms); + // Client is HUGE (> 512KB) because it has a large read buffer. // V8 crashes if this is on the stack (likely related to its size). const client = self.getClient() catch |err| { @@ -106,7 +146,6 @@ fn handleConnection(self: *Server, socket: posix.socket_t, timeout_ms: u32) void self.allocator, self.app, self.json_version_response, - timeout_ms, ) catch |err| { log.err(.app, "CDP client init", .{ .err = err }); return; @@ -216,9 +255,8 @@ pub const Client = struct { allocator: Allocator, app: *App, json_version_response: []const u8, - timeout_ms: u32, ) !Client { - var ws = try Net.WsConnection.init(socket, allocator, json_version_response, timeout_ms); + var ws = try Net.WsConnection.init(socket, allocator, json_version_response); errdefer ws.deinit(); if (log.enabled(.app, .info)) { @@ -277,15 +315,19 @@ pub const Client = struct { fn httpLoop(self: *Client, http: *HttpClient) !void { lp.assert(self.mode == .http, "Client.httpLoop invalid mode", .{}); + // Liveness is enforced by TCP keepalive configured in + // Server.setTcpKeepalive; the kernel closes dead sockets, which + // surfaces as EOF/error from readSocket. The loop blocks for ~24 days + // on each poll rather than tracking app-level timeouts. Capped at + // i32-max because HttpClient.tick narrows to c_int. + const wait_ms: u32 = std.math.maxInt(i32); + while (true) { - const status = http.tick(self.ws.timeout_ms) catch |err| { + const status = http.tick(wait_ms) catch |err| { log.err(.app, "http tick", .{ .err = err }); return; }; - if (status != .cdp_socket) { - log.info(.app, "CDP timeout", .{}); - return; - } + if (status != .cdp_socket) continue; if (self.readSocket() == false) { return; @@ -297,19 +339,15 @@ pub const Client = struct { } var cdp = &self.mode.cdp; - const timeout_ms = self.ws.timeout_ms; while (true) { - const result = cdp.pageWait(timeout_ms) catch |wait_err| switch (wait_err) { + const result = cdp.pageWait(wait_ms) catch |wait_err| switch (wait_err) { error.NoPage => { - const status = http.tick(timeout_ms) catch |err| { + const status = http.tick(wait_ms) catch |err| { log.err(.app, "http tick", .{ .err = err }); return; }; - if (status != .cdp_socket) { - log.info(.app, "CDP timeout", .{}); - return; - } + if (status != .cdp_socket) continue; if (self.readSocket() == false) { return; } @@ -324,10 +362,7 @@ pub const Client = struct { return; } }, - .done => { - log.info(.app, "CDP timeout", .{}); - return; - }, + .done => {}, } } } diff --git a/src/cdp/testing.zig b/src/cdp/testing.zig index 8be66b43..1838ef39 100644 --- a/src/cdp/testing.zig +++ b/src/cdp/testing.zig @@ -315,7 +315,7 @@ pub fn context() !TestContext { try posix.setsockopt(pair[1], posix.SOL.SOCKET, posix.SO.RCVBUF, &std.mem.toBytes(@as(c_int, 32_768))); try posix.setsockopt(pair[1], posix.SOL.SOCKET, posix.SO.SNDBUF, &std.mem.toBytes(@as(c_int, 32_768))); - const client = try Server.Client.init(pair[1], base.arena_allocator, base.test_app, "json-version", 2000); + const client = try Server.Client.init(pair[1], base.arena_allocator, base.test_app, "json-version"); return .{ .client = client, diff --git a/src/network/websocket.zig b/src/network/websocket.zig index c0d66862..4ecb5daa 100644 --- a/src/network/websocket.zig +++ b/src/network/websocket.zig @@ -319,9 +319,8 @@ pub const WsConnection = struct { reader: Reader(true), send_arena: ArenaAllocator, json_version_response: []const u8, - timeout_ms: u32, - pub fn init(socket: posix.socket_t, allocator: Allocator, json_version_response: []const u8, timeout_ms: u32) !WsConnection { + pub fn init(socket: posix.socket_t, allocator: Allocator, json_version_response: []const u8) !WsConnection { const socket_flags = try posix.fcntl(socket, posix.F.GETFL, 0); const nonblocking = @as(u32, @bitCast(posix.O{ .NONBLOCK = true })); if (builtin.is_test == false) { @@ -337,7 +336,6 @@ pub const WsConnection = struct { .reader = reader, .send_arena = ArenaAllocator.init(allocator), .json_version_response = json_version_response, - .timeout_ms = timeout_ms, }; } From 87a99749b74a1a721ff33dafdd504971bb94a9a2 Mon Sep 17 00:00:00 2001 From: Karl Seguin <k@openmymind.io> Date: Fri, 24 Apr 2026 09:45:42 +0800 Subject: [PATCH 08/12] Use TCP_KEEPALIVE instead of TCP_KEEPIDLE on MacOS See: https://github.com/nginx/nginx/issues/336 --- src/Server.zig | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/Server.zig b/src/Server.zig index e9b4355b..871a0f3b 100644 --- a/src/Server.zig +++ b/src/Server.zig @@ -111,11 +111,15 @@ fn setTcpKeepalive(socket: posix.socket_t, timeout_ms: u32) void { const probe_window: u32 = keepcnt * keepintvl; const keepidle: u32 = if (timeout_s > probe_window) timeout_s - probe_window else 1; - if (@hasDecl(posix.TCP, "KEEPIDLE")) { - posix.setsockopt(socket, posix.IPPROTO.TCP, posix.TCP.KEEPIDLE, &std.mem.toBytes(@as(c_int, @intCast(keepidle)))) catch |err| { - log.warn(.app, "TCP_KEEPIDLE", .{ .err = err }); - }; - } + const option = switch (@import("builtin").os.tag) { + .macos, .ios => posix.TCP.KEEPALIVE, + else => posix.TCP.KEEPIDLE, + }; + + posix.setsockopt(socket, posix.IPPROTO.TCP, option, &std.mem.toBytes(@as(c_int, @intCast(keepidle)))) catch |err| { + log.warn(.app, "TCP_KEEPIDLE", .{ .err = err }); + }; + if (@hasDecl(posix.TCP, "KEEPINTVL")) { posix.setsockopt(socket, posix.IPPROTO.TCP, posix.TCP.KEEPINTVL, &std.mem.toBytes(@as(c_int, @intCast(keepintvl)))) catch |err| { log.warn(.app, "TCP_KEEPINTVL", .{ .err = err }); From 999f57b7297cd299c5f38703a46fb0c843582135 Mon Sep 17 00:00:00 2001 From: Nikolay Govorov <me@govorov.online> Date: Fri, 24 Apr 2026 12:40:09 +0100 Subject: [PATCH 09/12] Remove timeout flag --- src/Config.zig | 27 ++++++++++----------------- src/Server.zig | 39 +++++++++++++-------------------------- 2 files changed, 23 insertions(+), 43 deletions(-) diff --git a/src/Config.zig b/src/Config.zig index a78fd14a..52c541ff 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -37,6 +37,12 @@ pub const CDP_MAX_HTTP_REQUEST_SIZE = 4096; // +140 for the max control packet that might be interleaved in a message pub const CDP_MAX_MESSAGE_SIZE = 512 * 1024 + 14 + 140; +// TCP keepalive parameters applied to accepted CDP connections. +// Detection window ≈ IDLE + CNT * INTVL = 4 + 3*2 = 10s. +pub const CDP_KEEPALIVE_IDLE_S: c_int = 4; +pub const CDP_KEEPALIVE_INTVL_S: c_int = 2; +pub const CDP_KEEPALIVE_CNT: c_int = 3; + const Config = @This(); fn logFilterScopesValidator(allocator: Allocator, args: *std.process.ArgIterator, list: *std.ArrayList(log.Scope)) !void { @@ -108,7 +114,7 @@ const Commands = cli.Builder(.{ .{ .name = "host", .type = []const u8, .default = "127.0.0.1" }, .{ .name = "port", .type = u16, .default = 9222 }, .{ .name = "advertise_host", .type = ?[]const u8 }, - .{ .name = "timeout", .type = u31, .default = 10 }, + .{ .name = "timeout", .type = ?u31 }, .{ .name = "cdp_max_connections", .type = u16, .default = 16 }, .{ .name = "cdp_max_pending_connections", .type = u16, .default = 128 }, }, @@ -292,14 +298,6 @@ pub fn cookieJarFile(self: *const Config) ?[]const u8 { }; } -pub fn cdpTimeout(self: *const Config) usize { - return switch (self.mode) { - .serve => |opts| if (opts.timeout > 604_800) 604_800_000 else @as(usize, opts.timeout) * 1_000, - .mcp => 10_000, // Default timeout for MCP-CDP - else => unreachable, - }; -} - pub fn port(self: *const Config) u16 { return switch (self.mode) { .serve => |opts| opts.port, @@ -637,14 +635,6 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { \\ Useful, for example, when --host is 0.0.0.0. \\ Defaults to --host value \\ - \\--timeout Approximate time in seconds after which a dead client - \\ is dropped. Implemented via TCP keepalive: the kernel - \\ probes silent peers and closes the socket if they do - \\ not respond. Does not affect clients that are actively - \\ sending or receiving data, or waiting on a slow page - \\ load. 0 disables keepalive (OS defaults apply). - \\ Defaults to 10 (seconds). Limited to 604800 (1 week). - \\ \\--cdp-max-connections \\ Maximum number of simultaneous CDP connections. \\ Defaults to 16. @@ -687,6 +677,9 @@ pub fn printUsageAndExit(self: *const Config, success: bool) void { pub fn parseArgs(allocator: Allocator) !Config { const exec_name, const command = try Commands.parse(allocator); + if (command == .serve and command.serve.timeout != null) { + log.warn(.app, "--timeout is deprecated", .{}); + } return .init(allocator, exec_name, command); } diff --git a/src/Server.zig b/src/Server.zig index 871a0f3b..50f81d8a 100644 --- a/src/Server.zig +++ b/src/Server.zig @@ -83,59 +83,46 @@ pub fn deinit(self: *Server) void { fn onAccept(ctx: *anyopaque, socket: posix.socket_t) void { const self: *Server = @ptrCast(@alignCast(ctx)); - const timeout_ms: u32 = @intCast(self.app.config.cdpTimeout()); - self.spawnWorker(socket, timeout_ms) catch |err| { + self.spawnWorker(socket) catch |err| { log.err(.app, "CDP spawn", .{ .err = err }); posix.close(socket); }; } // 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 -// some clients — go-rod panics, chromedp logs spurious "malformed" — handle -// incorrectly). Detection window is roughly `timeout_s`: -// keepidle = timeout_s - keepcnt * keepintvl (clamped >= 1s) -// keepintvl = 2s, keepcnt = 3 → 6s of probe escalation. -// timeout_ms == 0 leaves keepalive off (OS defaults apply — typically 2h). -fn setTcpKeepalive(socket: posix.socket_t, timeout_ms: u32) void { - if (timeout_ms == 0) return; - +// 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 setTcpKeepalive(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; }; - const keepcnt: u32 = 3; - const keepintvl: u32 = 2; - const timeout_s: u32 = @max(timeout_ms / 1000, 1); - const probe_window: u32 = keepcnt * keepintvl; - const keepidle: u32 = if (timeout_s > probe_window) timeout_s - probe_window else 1; - const option = switch (@import("builtin").os.tag) { .macos, .ios => posix.TCP.KEEPALIVE, else => posix.TCP.KEEPIDLE, }; - posix.setsockopt(socket, posix.IPPROTO.TCP, option, &std.mem.toBytes(@as(c_int, @intCast(keepidle)))) catch |err| { + posix.setsockopt(socket, posix.IPPROTO.TCP, option, &std.mem.toBytes(Config.CDP_KEEPALIVE_IDLE_S)) catch |err| { log.warn(.app, "TCP_KEEPIDLE", .{ .err = err }); }; if (@hasDecl(posix.TCP, "KEEPINTVL")) { - posix.setsockopt(socket, posix.IPPROTO.TCP, posix.TCP.KEEPINTVL, &std.mem.toBytes(@as(c_int, @intCast(keepintvl)))) catch |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 }); }; } if (@hasDecl(posix.TCP, "KEEPCNT")) { - posix.setsockopt(socket, posix.IPPROTO.TCP, posix.TCP.KEEPCNT, &std.mem.toBytes(@as(c_int, @intCast(keepcnt)))) catch |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 }); }; } } -fn handleConnection(self: *Server, socket: posix.socket_t, timeout_ms: u32) void { +fn handleConnection(self: *Server, socket: posix.socket_t) void { defer posix.close(socket); - setTcpKeepalive(socket, timeout_ms); + setTcpKeepalive(socket); // Client is HUGE (> 512KB) because it has a large read buffer. // V8 crashes if this is on the stack (likely related to its size). @@ -198,7 +185,7 @@ fn unregisterClient(self: *Server, client: *Client) void { } } -fn spawnWorker(self: *Server, socket: posix.socket_t, timeout_ms: u32) !void { +fn spawnWorker(self: *Server, socket: posix.socket_t) !void { if (self.app.shutdown()) { return error.ShuttingDown; } @@ -225,13 +212,13 @@ fn spawnWorker(self: *Server, socket: posix.socket_t, timeout_ms: u32) !void { } errdefer _ = self.active_threads.fetchSub(1, .monotonic); - const thread = try std.Thread.spawn(.{}, runWorker, .{ self, socket, timeout_ms }); + const thread = try std.Thread.spawn(.{}, runWorker, .{ self, socket }); thread.detach(); } -fn runWorker(self: *Server, socket: posix.socket_t, timeout_ms: u32) void { +fn runWorker(self: *Server, socket: posix.socket_t) void { defer _ = self.active_threads.fetchSub(1, .monotonic); - handleConnection(self, socket, timeout_ms); + handleConnection(self, socket); } fn joinThreads(self: *Server) void { From 8a08d1b407f35759547a7c8cde3b17c8d5511623 Mon Sep 17 00:00:00 2001 From: Karl Seguin <k@openmymind.io> Date: Fri, 24 Apr 2026 21:51:50 +0800 Subject: [PATCH 10/12] Fix a test-only use-after-free Only surfaced if an individual test was ran. When running multiple tests, the bucket reuse of the underlying arena hides the issue. --- src/browser/structured_data.zig | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/src/browser/structured_data.zig b/src/browser/structured_data.zig index 8b03add6..09cf72a2 100644 --- a/src/browser/structured_data.zig +++ b/src/browser/structured_data.zig @@ -317,9 +317,12 @@ fn collectLink( const testing = @import("../testing.zig"); +// Caller is responsible for `defer testing.test_session.removePage()` after a +// successful call — the returned StructuredData's slices live in the page's +// call_arena, which is released when the page is removed. fn testStructuredData(html: []const u8) !StructuredData { const frame = try testing.test_session.createPage(); - defer testing.test_session.removePage(); + errdefer testing.test_session.removePage(); const doc = frame.window._document; const div = try doc.createElement("div", null, frame); @@ -341,6 +344,7 @@ test "structured_data: json-ld" { \\{"@context":"https://schema.org","@type":"Article","headline":"Test"} \\</script> ); + defer testing.test_session.removePage(); try testing.expectEqual(1, data.json_ld.len); try testing.expect(std.mem.indexOf(u8, data.json_ld[0], "Article") != null); } @@ -351,6 +355,7 @@ test "structured_data: multiple json-ld" { \\<script type="application/ld+json">{"@type":"BreadcrumbList"}</script> \\<script type="text/javascript">var x = 1;</script> ); + defer testing.test_session.removePage(); try testing.expectEqual(2, data.json_ld.len); } @@ -363,6 +368,7 @@ test "structured_data: open graph" { \\<meta property="og:type" content="article"> \\<meta property="article:published_time" content="2026-03-10"> ); + defer testing.test_session.removePage(); try testing.expectEqual(6, data.open_graph.len); try testing.expectEqual("My Page", findProperty(data.open_graph, "title").?); try testing.expectEqual("article", findProperty(data.open_graph, "type").?); @@ -376,6 +382,7 @@ test "structured_data: open graph duplicate keys" { \\<meta property="og:image" content="https://example.com/img2.jpg"> \\<meta property="og:image" content="https://example.com/img3.jpg"> ); + defer testing.test_session.removePage(); // Duplicate keys are preserved as separate Property entries. try testing.expectEqual(4, data.open_graph.len); @@ -404,6 +411,7 @@ test "structured_data: twitter card" { \\<meta name="twitter:site" content="@example"> \\<meta name="twitter:title" content="My Page"> ); + defer testing.test_session.removePage(); try testing.expectEqual(3, data.twitter_card.len); try testing.expectEqual("summary_large_image", findProperty(data.twitter_card, "card").?); try testing.expectEqual("@example", findProperty(data.twitter_card, "site").?); @@ -417,6 +425,7 @@ test "structured_data: meta tags" { \\<meta name="keywords" content="test, example"> \\<meta name="robots" content="index, follow"> ); + defer testing.test_session.removePage(); try testing.expectEqual("Page Title", findProperty(data.meta, "title").?); try testing.expectEqual("A test page", findProperty(data.meta, "description").?); try testing.expectEqual("Test Author", findProperty(data.meta, "author").?); @@ -431,6 +440,7 @@ test "structured_data: link elements" { \\<link rel="manifest" href="/manifest.json"> \\<link rel="stylesheet" href="/style.css"> ); + defer testing.test_session.removePage(); try testing.expectEqual(3, data.links.len); try testing.expectEqual("https://example.com/page", findProperty(data.links, "canonical").?); // stylesheet should be filtered out @@ -442,6 +452,7 @@ test "structured_data: alternate links" { \\<link rel="alternate" href="https://example.com/fr" hreflang="fr" title="French"> \\<link rel="alternate" href="https://example.com/de" hreflang="de"> ); + defer testing.test_session.removePage(); try testing.expectEqual(2, data.alternate.len); try testing.expectEqual("fr", data.alternate[0].hreflang.?); try testing.expectEqual("French", data.alternate[0].title.?); @@ -455,6 +466,7 @@ test "structured_data: non-metadata elements ignored" { \\<p>More text</p> \\<a href="/link">Link</a> ); + defer testing.test_session.removePage(); try testing.expectEqual(0, data.json_ld.len); try testing.expectEqual(0, data.open_graph.len); try testing.expectEqual(0, data.twitter_card.len); @@ -467,6 +479,7 @@ test "structured_data: charset and http-equiv" { \\<meta charset="utf-8"> \\<meta http-equiv="Content-Type" content="text/html; charset=utf-8"> ); + defer testing.test_session.removePage(); try testing.expectEqual("utf-8", findProperty(data.meta, "charset").?); try testing.expectEqual("text/html; charset=utf-8", findProperty(data.meta, "Content-Type").?); } @@ -480,6 +493,7 @@ test "structured_data: mixed content" { \\<link rel="canonical" href="https://example.com"> \\<script type="application/ld+json">{"@type":"WebSite"}</script> ); + defer testing.test_session.removePage(); try testing.expectEqual(1, data.json_ld.len); try testing.expectEqual(1, data.open_graph.len); try testing.expectEqual(1, data.twitter_card.len); From 5ec7c660dafa6081d51ebae2f6795c856cec02ef Mon Sep 17 00:00:00 2001 From: Karl Seguin <k@openmymind.io> Date: Sat, 25 Apr 2026 09:51:58 +0800 Subject: [PATCH 11/12] update v8 dep --- .github/actions/install/action.yml | 2 +- Dockerfile | 2 +- build.zig.zon | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index cd3999e8..e9ccde0b 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -13,7 +13,7 @@ inputs: zig-v8: description: 'zig v8 version to install' required: false - default: 'v0.4.0' + default: 'v0.4.1' v8: description: 'v8 version to install' required: false diff --git a/Dockerfile b/Dockerfile index fd872c45..33501041 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,7 +3,7 @@ FROM debian:stable-slim ARG MINISIG=0.12 ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U ARG V8=14.0.365.4 -ARG ZIG_V8=v0.4.0 +ARG ZIG_V8=v0.4.1 ARG TARGETPLATFORM RUN apt-get update -yq && \ diff --git a/build.zig.zon b/build.zig.zon index c412c504..16bd2378 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,8 +5,8 @@ .minimum_zig_version = "0.15.2", .dependencies = .{ .v8 = .{ - .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/2785878461f2c07daec2aa87770dc8123d4c12db.tar.gz", - .hash = "v8-0.0.0-xddH61SJBACs_UKpNogg3DnocRLpgRzVp3XI6DbWiWlP", + .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.4.1.tar.gz", + .hash = "v8-0.0.0-xddH672HBAA1hQIa2Uv4mzs_qHC9-Py-M5ssqSSVhWtK", }, // .v8 = .{ .path = "../zig-v8-fork" }, .brotli = .{ From 093555b3741fee308001602f479a88af5b79d142 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adri=C3=A0=20Arrufat?= <adria.arrufat@gmail.com> Date: Sat, 25 Apr 2026 08:19:49 +0200 Subject: [PATCH 12/12] browser: hint v8 gc during long waits Runner._wait can iterate for the full opts.ms budget (up to 30s in fetch, longer in agent tool-use loops). V8 was only nudged to GC on session/page teardown (Browser.deinit, Page.deinit), so a page that stays alive while running heavy JS accumulates wrappers and external-ref'd Zig allocations V8 has no reason to drop. Fire memoryPressureNotification(.moderate) once per second from the wait loop. --- src/browser/Runner.zig | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/browser/Runner.zig b/src/browser/Runner.zig index b6274eb0..68a7ad59 100644 --- a/src/browser/Runner.zig +++ b/src/browser/Runner.zig @@ -72,7 +72,21 @@ fn _wait(self: *Runner, comptime is_cdp: bool, opts: WaitOpts) !CDPWaitResult { .ms = 200, .until = opts.until, }; + + // Periodic V8 GC hint during long waits. V8 is otherwise only nudged on + // session/page teardown (Browser.zig, Page.zig), so a page that stays + // alive for seconds while running heavy JS accumulates wrappers and + // external-ref'd Zig allocations V8 has no reason to drop. `.moderate` + // speeds up incremental GC without stalling the tick. + const gc_hint_period_ns: u64 = std.time.ns_per_s; + var gc_hint_timer = std.time.Timer.start() catch unreachable; + while (true) { + if (gc_hint_timer.read() >= gc_hint_period_ns) { + gc_hint_timer.reset(); + self.session.browser.env.memoryPressureNotification(.moderate); + } + const tick_result = self._tick(is_cdp, tick_opts) catch |err| { switch (err) { error.JsError => {}, // already logged (with hopefully more context)