From f75b6f966b73254301e82d1e19462ae3eb82c5da Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Sun, 28 Jun 2026 14:09:52 -0700 Subject: [PATCH 01/49] add simple 1 day heuristic cache --- src/network/cache/Cache.zig | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/src/network/cache/Cache.zig b/src/network/cache/Cache.zig index 809612425..161789ece 100644 --- a/src/network/cache/Cache.zig +++ b/src/network/cache/Cache.zig @@ -274,10 +274,11 @@ pub fn tryCache( log.debug(.cache, "no store", .{ .url = url, .vary = v, .reason = "vary" }); return null; }; + const cc = blk: { if (cache_control == null) { - log.debug(.cache, "no store", .{ .url = url, .reason = "no cache control" }); - return null; + log.debug(.cache, "heuristic cache", .{ .url = url, .max_age = 86400 }); + break :blk CacheControl{ .max_age = 86400, .must_revalidate = false }; } if (CacheControl.parse(cache_control.?)) |cc| { break :blk cc; @@ -442,3 +443,25 @@ test "Cache: CachedMetadata.renew updates etag and last_modified" { try testing.expectEqualSlices(u8, "\"new-etag\"", meta.etag.?); try testing.expectEqualSlices(u8, "Tue, 02 Jan 2024 00:00:00 GMT", meta.last_modified.?); } + +test "Cache: tryCache heuristic when no cache-control" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + const result = try tryCache( + arena.allocator(), + 1000, + "https://example.com", + 200, + "text/html", + null, + null, + null, + null, + null, + false, + false, + ); + try testing.expectEqual(@as(u64, 86400), result.?.cache_control.max_age); + try testing.expectEqual(false, result.?.cache_control.must_revalidate); +} From 65cf678822acdc02785113316b4b9443927537ca Mon Sep 17 00:00:00 2001 From: Muki Kiboigo Date: Tue, 30 Jun 2026 09:49:01 -0700 Subject: [PATCH 02/49] heuristically cache only with Last-Modified header --- src/network/cache/Cache.zig | 38 ++++++++++++++++++++++++++++++++----- 1 file changed, 33 insertions(+), 5 deletions(-) diff --git a/src/network/cache/Cache.zig b/src/network/cache/Cache.zig index 161789ece..4daa65219 100644 --- a/src/network/cache/Cache.zig +++ b/src/network/cache/Cache.zig @@ -276,14 +276,21 @@ pub fn tryCache( }; const cc = blk: { - if (cache_control == null) { + if (cache_control) |c| { + if (CacheControl.parse(c)) |cc| { + break :blk cc; + } + } else if (last_modified != null) { + // Requires Last-Modified to be present to heuristically cache. log.debug(.cache, "heuristic cache", .{ .url = url, .max_age = 86400 }); break :blk CacheControl{ .max_age = 86400, .must_revalidate = false }; } - if (CacheControl.parse(cache_control.?)) |cc| { - break :blk cc; - } - log.debug(.cache, "no store", .{ .url = url, .cache_control = cache_control.?, .reason = "cache control" }); + + log.debug(.cache, "no store", .{ + .url = url, + .cache_control = cache_control orelse "null", + .last_modified = last_modified orelse "null", + }); return null; }; @@ -462,6 +469,27 @@ test "Cache: tryCache heuristic when no cache-control" { false, false, ); + try testing.expectEqual(null, result); +} + +test "Cache: tryCache heuristic when no cache-control with last-modified" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + const result = try tryCache( + arena.allocator(), + 1000, + "https://example.com", + 200, + "text/html", + null, + null, + null, + null, + "Wed, 21 Oct 2015 07:28:00 GMT", + false, + false, + ); try testing.expectEqual(@as(u64, 86400), result.?.cache_control.max_age); try testing.expectEqual(false, result.?.cache_control.must_revalidate); } From ad3cf997df62b148101a058169ba208c1d003d8c Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 2 Jul 2026 08:05:25 +0800 Subject: [PATCH 03/49] v8: When re-using window, detach from original context first Depends on: https://github.com/lightpanda-io/zig-v8-fork/pull/185 Fixes a WPT DCHECK crash in /html/browsers/the-window-object/named-access-on-the-window-object/navigated-named-objects.window.html https://github.com/lightpanda-io/browser/pull/2742 added the re-use of the global (window) when an iframe or popup is re-navigated. This is fine with v8, but we need to detach it from the origin first. --- src/browser/Session.zig | 2 ++ src/browser/js/Context.zig | 18 ++++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/browser/Session.zig b/src/browser/Session.zig index 4d113012f..50901ae82 100644 --- a/src/browser/Session.zig +++ b/src/browser/Session.zig @@ -660,6 +660,7 @@ fn _processFrameNavigation(self: *Session, frame: *Frame, qn: *QueuedNavigation) const frame_id = frame._frame_id; const reuse_window = frame.window; const page = frame._page; + frame.js.detachGlobal(); frame.deinit(); frame.* = undefined; @@ -705,6 +706,7 @@ fn processPopupNavigation(_: *Session, frame: *Frame, qn: *QueuedNavigation) !vo const frame_id = frame._frame_id; const page = frame._page; + frame.js.detachGlobal(); frame.deinit(); frame.* = undefined; diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 7cc777f23..ec77a7e87 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -229,6 +229,24 @@ pub fn deinit(self: *Context) void { v8.v8__MicrotaskQueue__DELETE(self.microtask_queue); } +// The global (e.g. Window) can be reused across contexts. If you do: +// +// var w = iframe.contentWindow; +// iframe.src = 'two.html'; +// w === iframe.contentWindow (must be true) +// +// so when we navigate, the Window/Global is re-used. That's fine with v8, but +// we need to explicitly detach it from the original before we can safely attach +// it to the new +pub fn detachGlobal(self: *Context) void { + var hs: js.HandleScope = undefined; + hs.init(self.isolate); + defer hs.deinit(); + + const local_v8_context: *const v8.Context = @ptrCast(v8.v8__Global__Get(&self.handle, self.isolate.handle)); + v8.v8__Context__DetachGlobal(local_v8_context); +} + // setOrigin is called at navigation (opaque -> real origin) and again when a // script sets document.domain (real origin -> '!'-marked effective domain). pub fn setOrigin(self: *Context, key: ?[]const u8) !void { From a750a6942dac0282a07f1433a261a859904661ca Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 2 Jul 2026 12:43:56 +0800 Subject: [PATCH 04/49] webapi: structuredClone for host (aka Zig) objects Adds the infrastructure for [de]serializing Zig objects via structuredClone. Adds support to Blob, File, FileList and ImageData. These are the easiest to implement. Blob is used extensively by WPT IndexedDB tests, but this PR can be merged prior to IndexedDB landing. --- src/browser/js/Value.zig | 351 +++++++++++++++++++++++++------ src/browser/js/js.zig | 18 ++ src/browser/webapi/Blob.zig | 27 ++- src/browser/webapi/File.zig | 23 ++ src/browser/webapi/FileList.zig | 35 +++ src/browser/webapi/ImageData.zig | 22 ++ src/browser/webapi/Window.zig | 3 +- 7 files changed, 407 insertions(+), 72 deletions(-) diff --git a/src/browser/js/Value.zig b/src/browser/js/Value.zig index c777a87e7..c9d4fb5e3 100644 --- a/src/browser/js/Value.zig +++ b/src/browser/js/Value.zig @@ -20,8 +20,10 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("js.zig"); +const TaggedOpaque = @import("TaggedOpaque.zig"); const v8 = js.v8; +const bridge = js.bridge; const IS_DEBUG = @import("builtin").mode == .Debug; @@ -324,8 +326,9 @@ pub fn jsonStringify(self: Value, jws: anytype) !void { jws.endWriteRaw(); } -// Throws a DataCloneError for host objects (Blob, File, etc.) that cannot be serialized. -// Does not support transferables which require additional delegate callbacks. +// Clones host objects listed in cloneable_types; throws a DataCloneError for +// any other. Does not support transferables which require additional delegate +// callbacks. pub fn structuredClone(self: Value) !Value { return self.structuredCloneTo(self.local); } @@ -333,76 +336,284 @@ pub fn structuredClone(self: Value) !Value { // Clone a value to a different context (within the same isolate). // Used for cross-context messaging (e.g., Worker <-> Page). pub fn structuredCloneTo(self: Value, target: *const js.Local) !Value { - const source_context = self.local.handle; - const target_context = target.handle; - const v8_isolate = target.isolate.handle; - - const SerializerDelegate = struct { - // Called when V8 encounters a host object it doesn't know how to serialize. - // Returns false to indicate the object cannot be cloned, and throws a DataCloneError. - // V8 asserts has_exception() after this returns false, so we must throw here. - fn writeHostObject(_: ?*anyopaque, isolate: ?*v8.Isolate, _: ?*const v8.Object) callconv(.c) v8.MaybeBool { - const iso = isolate orelse return .{ .has_value = true, .value = false }; - const message = v8.v8__String__NewFromUtf8(iso, "The object cannot be cloned.", v8.kNormal, -1); - const error_value = v8.v8__Exception__Error(message) orelse return .{ .has_value = true, .value = false }; - _ = v8.v8__Isolate__ThrowException(iso, error_value); - return .{ .has_value = true, .value = false }; - } - - // Called by V8 to report serialization errors. The exception should already be thrown. - fn throwDataCloneError(_: ?*anyopaque, _: ?*const v8.String) callconv(.c) void {} - - // Called when V8 encounters a SharedArrayBuffer. We don't support sharing them across - // contexts, so throw a DataCloneError and return false. V8's WriteJSArrayBuffer calls - // RETURN_VALUE_IF_EXCEPTION after this, so throwing prevents the fatal FromJust call. - fn getSharedArrayBufferId(_: ?*anyopaque, isolate: ?*v8.Isolate, _: ?*const v8.SharedArrayBuffer, _: ?*u32) callconv(.c) bool { - const iso = isolate orelse return false; - const message = v8.v8__String__NewFromUtf8(iso, "SharedArrayBuffer cannot be cloned.", v8.kNormal, -1); - const error_value = v8.v8__Exception__Error(message) orelse return false; - _ = v8.v8__Isolate__ThrowException(iso, error_value); - return false; - } - }; - - const size, const data = blk: { - const serializer = v8.v8__ValueSerializer__New(v8_isolate, &.{ - .data = null, - .get_shared_array_buffer_id = SerializerDelegate.getSharedArrayBufferId, - .write_host_object = SerializerDelegate.writeHostObject, - .throw_data_clone_error = SerializerDelegate.throwDataCloneError, - }) orelse return error.JsException; - - defer v8.v8__ValueSerializer__DELETE(serializer); - - var write_result: v8.MaybeBool = undefined; - v8.v8__ValueSerializer__WriteHeader(serializer); - v8.v8__ValueSerializer__WriteValue(serializer, source_context, self.handle, &write_result); - if (!write_result.has_value or !write_result.value) { - return error.JsException; - } - - var size: usize = undefined; - const data = v8.v8__ValueSerializer__Release(serializer, &size) orelse return error.JsException; - break :blk .{ size, data }; - }; - - defer v8.v8__ValueSerializer__FreeBuffer(data); - - const cloned_handle = blk: { - const deserializer = v8.v8__ValueDeserializer__New(v8_isolate, data, size, null) orelse return error.JsException; - defer v8.v8__ValueDeserializer__DELETE(deserializer); - - var read_header_result: v8.MaybeBool = undefined; - v8.v8__ValueDeserializer__ReadHeader(deserializer, target_context, &read_header_result); - if (!read_header_result.has_value or !read_header_result.value) { - return error.JsException; - } - break :blk v8.v8__ValueDeserializer__ReadValue(deserializer, target_context) orelse return error.JsException; - }; - - return .{ .local = target, .handle = cloned_handle }; + const serialized = try self.serialize(); + defer serialized.deinit(); + return deserialize(target, serialized.bytes()); } +// A structured-serialized value: a V8-owned byte buffer. Caller must free it +// and must dupe the bytes if they want it to outlive the current local scope. +pub const Serialized = struct { + data: [*c]u8, + size: usize, + + pub fn bytes(self: Serialized) []const u8 { + return self.data[0..self.size]; + } + + pub fn deinit(self: Serialized) void { + v8.v8__ValueSerializer__FreeBuffer(self.data); + } +}; + +// Serialize `self` into a V8-owned buffer. The caller must call deinit on the +// result. Raises a JS exception (DataCloneError) for unserializable values. +pub fn serialize(self: Value) !Serialized { + var delegate_ctx = CloneDelegate.SerializeContext{ + .local = self.local, + .serializer = undefined, + }; + const serializer = v8.v8__ValueSerializer__New(self.local.isolate.handle, &.{ + .data = &delegate_ctx, + .get_shared_array_buffer_id = CloneDelegate.getSharedArrayBufferId, + .write_host_object = CloneDelegate.writeHostObject, + .throw_data_clone_error = CloneDelegate.throwDataCloneError, + }) orelse return error.JsException; + defer v8.v8__ValueSerializer__DELETE(serializer); + // the delegate callbacks only fire during WriteValue, after this is set + delegate_ctx.serializer = serializer; + + var write_result: v8.MaybeBool = undefined; + v8.v8__ValueSerializer__WriteHeader(serializer); + v8.v8__ValueSerializer__WriteValue(serializer, self.local.handle, self.handle, &write_result); + if (!write_result.has_value or !write_result.value) { + return error.JsException; + } + + var size: usize = undefined; + const data = v8.v8__ValueSerializer__Release(serializer, &size) orelse return error.JsException; + return .{ .data = data, .size = size }; +} + +// Deserialize a structured-serialized buffer (from `serialize`) into a value in +// `local`'s context. A malformed buffer surfaces as error.JsException. +pub fn deserialize(local: *const js.Local, bytes: []const u8) !Value { + var delegate_ctx = CloneDelegate.DeserializeContext{ + .local = local, + .deserializer = undefined, + }; + const deserializer = v8.v8__ValueDeserializer__New(local.isolate.handle, bytes.ptr, bytes.len, &.{ + .data = &delegate_ctx, + .read_host_object = CloneDelegate.readHostObject, + }) orelse return error.JsException; + defer v8.v8__ValueDeserializer__DELETE(deserializer); + delegate_ctx.deserializer = deserializer; + + var read_header_result: v8.MaybeBool = undefined; + v8.v8__ValueDeserializer__ReadHeader(deserializer, local.handle, &read_header_result); + if (!read_header_result.has_value or !read_header_result.value) { + return error.JsException; + } + + const handle = v8.v8__ValueDeserializer__ReadValue(deserializer, local.handle) orelse return error.JsException; + return .{ .local = local, .handle = handle }; +} + +// Host object types that support structured cloning via structuredSerialize / +// structuredDeserialize hooks. The serialized payload tags each host object +// with its position in this list; buffers never outlive the process, so the +// order only has to be consistent within a build. +const cloneable_types = .{ + @import("../webapi/Blob.zig"), + @import("../webapi/File.zig"), + @import("../webapi/FileList.zig"), + @import("../webapi/ImageData.zig"), +}; + +// Passed to a type's structuredSerialize hook to write its payload into the +// V8 serialization buffer. +pub const StructuredWriter = struct { + local: *const js.Local, + serializer: *v8.ValueSerializer, + + pub fn writeUint32(self: *const StructuredWriter, value: u32) void { + v8.v8__ValueSerializer__WriteUint32(self.serializer, value); + } + + pub fn writeUint64(self: *const StructuredWriter, value: u64) void { + v8.v8__ValueSerializer__WriteUint64(self.serializer, value); + } + + pub fn writeBytes(self: *const StructuredWriter, bytes: []const u8) void { + v8.v8__ValueSerializer__WriteUint32(self.serializer, @intCast(bytes.len)); + if (bytes.len > 0) { + v8.v8__ValueSerializer__WriteRawBytes(self.serializer, bytes.ptr, bytes.len); + } + } +}; + +// Passed to a type's structuredDeserialize hook to read back the payload its +// structuredSerialize hook wrote. +pub const StructuredReader = struct { + local: *const js.Local, + deserializer: *v8.ValueDeserializer, + + pub fn readUint32(self: *const StructuredReader) !u32 { + var out: u32 = undefined; + if (!v8.v8__ValueDeserializer__ReadUint32(self.deserializer, &out)) { + return error.DataClone; + } + return out; + } + + pub fn readUint64(self: *const StructuredReader) !u64 { + var out: u64 = undefined; + if (!v8.v8__ValueDeserializer__ReadUint64(self.deserializer, &out)) { + return error.DataClone; + } + return out; + } + + // The returned slice points into the serialization buffer; dupe anything + // that must outlive deserialization. + pub fn readBytes(self: *const StructuredReader) ![]const u8 { + const len = try self.readUint32(); + if (len == 0) { + return ""; + } + var ptr: ?*const anyopaque = null; + if (!v8.v8__ValueDeserializer__ReadRawBytes(self.deserializer, len, &ptr)) { + return error.DataClone; + } + return @as([*]const u8, @ptrCast(ptr.?))[0..len]; + } +}; + +const CloneDelegate = struct { + const SerializeContext = struct { + local: *const js.Local, + serializer: *v8.ValueSerializer, + }; + + const DeserializeContext = struct { + local: *const js.Local, + deserializer: *v8.ValueDeserializer, + }; + + // Called when V8 encounters an object with embedder fields, i.e. one of + // our wrapped Zig instances. Serialize it if its type (or a prototype) + // is in cloneable_types, otherwise throw a DataCloneError. V8 asserts + // has_exception() after a false return, so we must throw here. + fn writeHostObject(data: ?*anyopaque, _: ?*v8.Isolate, object: ?*const v8.Object) callconv(.c) v8.MaybeBool { + const ctx: *SerializeContext = @ptrCast(@alignCast(data.?)); + + blk: { + const obj = object orelse break :blk; + if (v8.v8__Object__InternalFieldCount(obj) == 0) { + break :blk; + } + const tao_ptr = v8.v8__Object__GetAlignedPointerFromInternalField(obj, 0) orelse break :blk; + const tao: *TaggedOpaque = @ptrCast(@alignCast(tao_ptr)); + + const prototype_chain = tao.prototype_chain[0..tao.prototype_len]; + if (writeCloneable(ctx, prototype_chain[0].index, tao.value)) |result| { + return result; + } + + // Walk up the prototype chain so a subtype serializes as its + // closest cloneable supertype (mirrors TaggedOpaque.fromJS). + var ptr = @intFromPtr(tao.value); + for (prototype_chain[1..]) |proto| { + ptr += proto.offset; + const proto_ptr: **anyopaque = @ptrFromInt(ptr); + if (writeCloneable(ctx, proto.index, proto_ptr.*)) |result| { + return result; + } + ptr = @intFromPtr(proto_ptr.*); + } + } + + throwDataCloneException(ctx.local, null); + return .{ .has_value = true, .value = false }; + } + + fn writeCloneable( + ctx: *SerializeContext, + type_index: bridge.JsApiLookup.BackingInt, + value_ptr: *anyopaque, + ) ?v8.MaybeBool { + inline for (cloneable_types, 0..) |T, tag| { + if (type_index == bridge.JsApiLookup.getId(T.JsApi)) { + v8.v8__ValueSerializer__WriteUint32(ctx.serializer, tag); + var writer = StructuredWriter{ .local = ctx.local, .serializer = ctx.serializer }; + const instance: *const T = @ptrCast(@alignCast(value_ptr)); + instance.structuredSerialize(&writer) catch { + throwDataCloneException(ctx.local, null); + return .{ .has_value = true, .value = false }; + }; + return .{ .has_value = true, .value = true }; + } + } + return null; + } + + // Called by V8 to read back what writeHostObject wrote. Returning null + // aborts deserialization, so we throw first to surface a proper error. + fn readHostObject(data: ?*anyopaque, _: ?*v8.Isolate) callconv(.c) ?*const v8.Object { + const ctx: *DeserializeContext = @ptrCast(@alignCast(data.?)); + const local = ctx.local; + + var tag: u32 = undefined; + if (v8.v8__ValueDeserializer__ReadUint32(ctx.deserializer, &tag)) { + var reader = StructuredReader{ .local = local, .deserializer = ctx.deserializer }; + inline for (cloneable_types, 0..) |T, i| { + if (tag == i) { + return readCloneable(T, &reader) orelse { + throwDataCloneException(local, null); + return null; + }; + } + } + } + + throwDataCloneException(local, null); + return null; + } + + fn readCloneable(comptime T: type, reader: *StructuredReader) ?*const v8.Object { + const local = reader.local; + const instance = T.structuredDeserialize(reader, local.ctx.page) catch return null; + const js_obj = local.mapZigInstanceToJs(null, instance) catch return null; + return js_obj.handle; + } + + // Called by V8 when a built-in can't be serialized (e.g. an out-of-bounds + // TypedArray). The delegate is responsible for actually throwing. + fn throwDataCloneError(data: ?*anyopaque, message: ?*const v8.String) callconv(.c) void { + const ctx: *SerializeContext = @ptrCast(@alignCast(data.?)); + const local = ctx.local; + const msg: ?[]const u8 = blk: { + const handle = message orelse break :blk null; + const str = js.String{ .local = local, .handle = handle }; + // the exception can outlive this call; dupe onto the context arena + break :blk str.toSliceWithAlloc(local.ctx.arena) catch null; + }; + throwDataCloneException(local, msg); + } + + // Called when V8 encounters a SharedArrayBuffer. We don't support sharing + // them across contexts, so throw a DataCloneError and return false. V8's + // WriteJSArrayBuffer calls RETURN_VALUE_IF_EXCEPTION after this, so + // throwing prevents the fatal FromJust call. + fn getSharedArrayBufferId(data: ?*anyopaque, _: ?*v8.Isolate, _: ?*const v8.SharedArrayBuffer, _: ?*u32) callconv(.c) bool { + const ctx: *SerializeContext = @ptrCast(@alignCast(data.?)); + throwDataCloneException(ctx.local, "SharedArrayBuffer cannot be cloned"); + return false; + } + + fn throwDataCloneException(local: *const js.Local, message: ?[]const u8) void { + const DOMException = @import("../webapi/DOMException.zig"); + const isolate = local.isolate; + const js_value = local.zigValueToJs(DOMException.init(message, "DataCloneError"), .{}) catch { + const str = v8.v8__String__NewFromUtf8(isolate.handle, "The object can not be cloned", v8.kNormal, -1); + const error_value = v8.v8__Exception__Error(str) orelse return; + _ = v8.v8__Isolate__ThrowException(isolate.handle, error_value); + return; + }; + _ = isolate.throwException(js_value.handle); + } +}; + pub fn persist(self: Value) !Global { return self._persist(true); } diff --git a/src/browser/js/js.zig b/src/browser/js/js.zig index 3095d20b3..cbbba7cbb 100644 --- a/src/browser/js/js.zig +++ b/src/browser/js/js.zig @@ -43,6 +43,8 @@ pub const Isolate = @import("Isolate.zig"); pub const HandleScope = @import("HandleScope.zig"); pub const Value = @import("Value.zig"); +pub const StructuredWriter = Value.StructuredWriter; +pub const StructuredReader = Value.StructuredReader; pub const Array = @import("Array.zig"); pub const String = @import("String.zig"); pub const Object = @import("Object.zig"); @@ -178,6 +180,22 @@ pub fn ArrayBufferRef(comptime kind: ArrayType) type { return .{ .handle = global }; } + + // Direct view into the typed array's backing memory. + pub fn slice(self: *const Self) []BackingInt { + const view: *const v8.ArrayBufferView = @ptrCast(self.handle); + const byte_len = v8.v8__ArrayBufferView__ByteLength(view); + if (byte_len == 0) { + return @constCast(&[_]BackingInt{}); + } + const byte_offset = v8.v8__ArrayBufferView__ByteOffset(view); + const array_buffer = v8.v8__ArrayBufferView__Buffer(view).?; + const backing_store_ptr = v8.v8__ArrayBuffer__GetBackingStore(array_buffer); + const backing_store = v8.std__shared_ptr__v8__BackingStore__get(&backing_store_ptr).?; + const data = v8.v8__BackingStore__Data(backing_store).?; + const base = @as([*]u8, @ptrCast(data)) + byte_offset; + return @as([*]BackingInt, @ptrCast(@alignCast(base)))[0 .. byte_len / @sizeOf(BackingInt)]; + } }; } diff --git a/src/browser/webapi/Blob.zig b/src/browser/webapi/Blob.zig index 2d4916db7..4b7dfbfda 100644 --- a/src/browser/webapi/Blob.zig +++ b/src/browser/webapi/Blob.zig @@ -96,7 +96,8 @@ pub fn init(parts_: ?[]const js.Value, opts_: ?InitOptions, page: *Page) !*Blob /// Creates a new Blob from raw byte slices (for internal Zig use). pub fn initFromBytes(data: []const u8, content_type: []const u8, page: *Page) !*Blob { - const arena = try page.getArena(.large, "Blob"); + // + 256 because the arena might also be used by File + const arena = try page.getArena(data.len + content_type.len + 256, "Blob"); errdefer page.releaseArena(arena); const mime = try Mime.serialize(arena, content_type); @@ -124,6 +125,30 @@ pub fn acquireRef(self: *Blob) void { self._rc.acquire(); } +pub fn structuredSerialize(self: *const Blob, writer: *js.StructuredWriter) !void { + writer.writeBytes(self._mime); + writer.writeBytes(self._slice); +} + +pub fn structuredDeserialize(reader: *js.StructuredReader, page: *Page) !*Blob { + const mime = try reader.readBytes(); + const data = try reader.readBytes(); + + const arena = try page.getArena(data.len + mime.len + 256, "Blob.clone"); + errdefer page.releaseArena(arena); + + const self = try arena.create(Blob); + self.* = .{ + ._rc = .{}, + ._arena = arena, + ._type = .generic, + ._slice = try arena.dupe(u8, data), + // the serialized mime is already in normalized form; copy it verbatim + ._mime = try arena.dupe(u8, mime), + }; + return self; +} + const largest_vector = @max(std.simd.suggestVectorLength(u8) orelse 1, 8); /// Array of possible vector sizes for the current arch in decrementing order. /// We may move this to some file for SIMD helpers in the future. diff --git a/src/browser/webapi/File.zig b/src/browser/webapi/File.zig index b71fcb90b..ae00f11c7 100644 --- a/src/browser/webapi/File.zig +++ b/src/browser/webapi/File.zig @@ -72,6 +72,29 @@ pub fn acquireRef(self: *File) void { self._proto.acquireRef(); } +pub fn structuredSerialize(self: *const File, writer: *js.StructuredWriter) !void { + try self._proto.structuredSerialize(writer); + writer.writeBytes(self._name); + writer.writeUint64(@bitCast(self._last_modified)); +} + +pub fn structuredDeserialize(reader: *js.StructuredReader, page: *Page) !*File { + const blob = try Blob.structuredDeserialize(reader, page); + errdefer blob.deinit(page); + + const name = try reader.readBytes(); + const last_modified = try reader.readUint64(); + + const file = try blob._arena.create(File); + file.* = .{ + ._proto = blob, + ._name = try blob._arena.dupe(u8, name), + ._last_modified = @bitCast(last_modified), + }; + blob._type = .{ .file = file }; + return file; +} + pub fn getName(self: *const File) []const u8 { return self._name; } diff --git a/src/browser/webapi/FileList.zig b/src/browser/webapi/FileList.zig index c139155ab..e85166c04 100644 --- a/src/browser/webapi/FileList.zig +++ b/src/browser/webapi/FileList.zig @@ -1,4 +1,23 @@ +// 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 js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); const File = @import("File.zig"); @@ -24,6 +43,22 @@ pub fn item(self: *const FileList, index: u32) ?*File { return self._files[index]; } +pub fn structuredSerialize(self: *const FileList, writer: *js.StructuredWriter) !void { + writer.writeUint32(@intCast(self._files.len)); + for (self._files) |file| { + try file.structuredSerialize(writer); + } +} + +pub fn structuredDeserialize(reader: *js.StructuredReader, page: *Page) !FileList { + const count = try reader.readUint32(); + const files = try reader.local.ctx.arena.alloc(*File, count); + for (files) |*file| { + file.* = try File.structuredDeserialize(reader, page); + } + return .{ ._files = files }; +} + pub fn iterator(self: *FileList, exec: *const js.Execution) !*Iterator { return Iterator.init(.{ .index = 0, diff --git a/src/browser/webapi/ImageData.zig b/src/browser/webapi/ImageData.zig index 333630e86..1bf39f714 100644 --- a/src/browser/webapi/ImageData.zig +++ b/src/browser/webapi/ImageData.zig @@ -20,6 +20,7 @@ const std = @import("std"); const lp = @import("lightpanda"); const js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); const String = lp.String; const Execution = js.Execution; @@ -84,6 +85,27 @@ pub fn init( }); } +pub fn structuredSerialize(self: *const ImageData, writer: *js.StructuredWriter) !void { + writer.writeUint32(self._width); + writer.writeUint32(self._height); + writer.writeBytes(self._data.local(writer.local).slice()); +} + +pub fn structuredDeserialize(reader: *js.StructuredReader, page: *Page) !*ImageData { + const width = try reader.readUint32(); + const height = try reader.readUint32(); + const bytes = try reader.readBytes(); + + const data = reader.local.createTypedArray(.uint8_clamped, bytes.len); + @memcpy(data.slice(), bytes); + + return page.factory.create(ImageData{ + ._width = width, + ._height = height, + ._data = try data.persist(), + }); +} + pub fn getWidth(self: *const ImageData) u32 { return self._width; } diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 7a488fac7..c1616f5b0 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -705,7 +705,8 @@ pub fn atob(_: *const Window, input: base64.BinInput, frame: *Frame) !js.String. } pub fn structuredClone(_: *const Window, value: js.Value) !js.Value { - return value.structuredClone(); + // the serializer already threw (e.g. a DataCloneError); keep it + return value.structuredClone() catch error.TryCatchRethrow; } pub fn getFrame(self: *Window, idx: usize) !?*Window { From 6e75a0caa9daf4fa523a959fa83befad3550f55f Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 1 Jul 2026 11:57:10 +0300 Subject: [PATCH 05/49] `libcrypto`: bind couple X509, X509_STORE and SSL_CTX helpers --- src/sys/libcrypto.zig | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/sys/libcrypto.zig b/src/sys/libcrypto.zig index 0e62df336..1e47f8a69 100644 --- a/src/sys/libcrypto.zig +++ b/src/sys/libcrypto.zig @@ -343,6 +343,26 @@ pub extern fn EVP_MD_CTX_free(ctx: ?*EVP_MD_CTX) void; pub const struct_evp_md_ctx_st = opaque {}; pub const EVP_MD_CTX = struct_evp_md_ctx_st; +pub extern fn X509_free(x509: ?*X509) void; +pub extern fn d2i_X509(out: [*c]?*X509, inp: *[*]const u8, len: c_long) ?*X509; + +//pub const struct_x509_store_ctx_st = opaque {}; +//pub const X509_STORE_CTX = struct_x509_store_ctx_st; +pub const struct_x509_store_st = opaque {}; +pub const X509_STORE = struct_x509_store_st; + +pub extern fn X509_STORE_new() ?*X509_STORE; +pub extern fn X509_STORE_free(store: *X509_STORE) void; +pub extern fn X509_STORE_add_cert(store: ?*X509_STORE, x: ?*X509) c_int; +pub extern fn X509_STORE_add_crl(store: ?*X509_STORE, x: ?*X509_CRL) c_int; + +pub const struct_ssl_ctx_st = opaque {}; +pub const SSL_CTX = struct_ssl_ctx_st; +/// SSL_CTX takes ownership. +pub extern fn SSL_CTX_set0_verify_cert_store(ctx: ?*SSL_CTX, store: ?*X509_STORE) c_int; +/// Ownership remains, ref count increments. +pub extern fn SSL_CTX_set1_verify_cert_store(ctx: ?*SSL_CTX, store: ?*X509_STORE) c_int; + /// Returns the desired digest by its name. pub fn findDigest(name: []const u8) error{Invalid}!*const EVP_MD { if (std.mem.eql(u8, "SHA-256", name)) { From 00123d5102a4364dcc34a221a827a80771fe49d4 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 1 Jul 2026 11:58:26 +0300 Subject: [PATCH 06/49] `libcurl`: more bindings * Make curl_easy_setopt aware of SSL_CTX_FUNCTION and SSL_CTX_DATA, * Add CURLE_* errors. --- src/sys/libcurl.zig | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/sys/libcurl.zig b/src/sys/libcurl.zig index 2804db00e..3774857fa 100644 --- a/src/sys/libcurl.zig +++ b/src/sys/libcurl.zig @@ -36,6 +36,11 @@ pub const CurlSocket = c.curl_socket_t; pub const CurlBlob = c.curl_blob; pub const CurlOffT = c.curl_off_t; +pub const CURLE = struct { + pub const OK = c.CURLE_OK; + pub const ABORTED_BY_CALLBACK = c.CURLE_ABORTED_BY_CALLBACK; +}; + pub const CurlDebugFunction = fn (*Curl, CurlInfoType, [*c]u8, usize, *anyopaque) c_int; pub const CurlHeaderFunction = fn ([*]const u8, usize, usize, *anyopaque) usize; pub const CurlWriteFunction = fn ([*]const u8, usize, usize, *anyopaque) usize; @@ -229,6 +234,8 @@ pub const CurlOption = enum(c.CURLoption) { upload = c.CURLOPT_UPLOAD, opensocket_function = c.CURLOPT_OPENSOCKETFUNCTION, opensocket_data = c.CURLOPT_OPENSOCKETDATA, + ssl_ctx_function = c.CURLOPT_SSL_CTX_FUNCTION, + ssl_ctx_data = c.CURLOPT_SSL_CTX_DATA, }; pub const CurlMOption = enum(c.CURLMoption) { @@ -743,6 +750,15 @@ pub fn curl_easy_setopt(easy: *Curl, comptime option: CurlOption, value: anytype }; break :blk c.curl_easy_setopt(easy, opt, cb); }, + .ssl_ctx_function => blk: { + const cb: c.curl_ssl_ctx_callback = @ptrCast(value); + break :blk c.curl_easy_setopt(easy, opt, cb); + }, + .ssl_ctx_data => blk: { + // We can make sure that passed data is always X509_STORE since we + // don't require anything else throughout project. + break :blk c.curl_easy_setopt(easy, opt, value); + }, }; try errorCheck(code); } From 820f5684cbb7be17c89a0afd601f84b1ad0621fe Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 1 Jul 2026 12:02:48 +0300 Subject: [PATCH 07/49] networking: replace `ca_blob` with `X509_STORE` Idea here is to skip re-parsing that happen for each connection; we already use BoringSSL, so we can take more advantage of it by directly mutating cert store of `SSL_CTX`. --- src/network/Network.zig | 122 ++++++++++++----------------------- src/network/http.zig | 36 +++++++---- src/telemetry/lightpanda.zig | 2 +- 3 files changed, 66 insertions(+), 94 deletions(-) diff --git a/src/network/Network.zig b/src/network/Network.zig index dde76146e..4edc00aa7 100644 --- a/src/network/Network.zig +++ b/src/network/Network.zig @@ -25,6 +25,7 @@ const Config = @import("../Config.zig"); const CDP = @import("../cdp/CDP.zig"); const libcurl = @import("../sys/libcurl.zig"); +const crypto = @import("../sys/libcrypto.zig"); const http = @import("http.zig"); const IpFilter = @import("IpFilter.zig"); @@ -86,7 +87,8 @@ allocator: Allocator, app: *App, cache: ?Cache, config: *const Config, -ca_blob: ?http.Blob, +/// Holds certificate bundle. +x509_store: *crypto.X509_STORE, robot_store: RobotStore, web_bot_auth: ?WebBotAuth, @@ -175,10 +177,15 @@ pub fn init(allocator: Allocator, app: *App, config: *const Config) !Network { @memset(pollfds, .{ .fd = -1, .events = 0, .revents = 0 }); pollfds[0] = .{ .fd = pipe[0], .events = posix.POLL.IN, .revents = 0 }; - var ca_blob: ?http.Blob = null; - if (config.tlsVerifyHost()) { - ca_blob = try loadCerts(allocator); - } + const x509_store = blk: { + if (config.tlsVerifyHost()) { + break :blk try createX509Store(allocator); + } + break :blk crypto.X509_STORE_new() orelse { + return error.FailedToCreateX509Store; + }; + }; + errdefer crypto.X509_STORE_free(x509_store); // IP filter for blocking requests to private/internal networks. const block_private = config.blockPrivateNetworks(); @@ -204,7 +211,7 @@ pub fn init(allocator: Allocator, app: *App, config: *const Config) !Network { var available: DoublyLinkedList = .{}; for (0..count) |i| { - connections[i] = try http.Connection.init(ca_blob, config, ip_filter); + connections[i] = try http.Connection.init(x509_store, config, ip_filter); available.append(&connections[i].node); } @@ -232,7 +239,7 @@ pub fn init(allocator: Allocator, app: *App, config: *const Config) !Network { return .{ .allocator = allocator, .config = config, - .ca_blob = ca_blob, + .x509_store = x509_store, .pollfds = pollfds, .wakeup_pipe = pipe, @@ -266,10 +273,7 @@ pub fn deinit(self: *Network) void { self.allocator.free(self.pollfds); self.allocator.free(self.cdp_poll_snapshot); - if (self.ca_blob) |ca_blob| { - const data: [*]u8 = @ptrCast(ca_blob.data); - self.allocator.free(data[0..ca_blob.len]); - } + crypto.X509_STORE_free(self.x509_store); for (self.connections) |*conn| { conn.deinit(); @@ -675,7 +679,7 @@ pub fn releaseConnection(self: *Network, conn: *http.Connection) void { self.ws_count -= 1; }, else => { - conn.reset(self.config, self.ca_blob, self.ip_filter) catch |err| { + conn.reset(self.config, self.x509_store, self.ip_filter) catch |err| { lp.assert(false, "couldn't reset curl easy", .{ .err = err }); }; self.conn_mutex.lock(); @@ -700,7 +704,7 @@ pub fn newConnection(self: *Network) ?*http.Connection { }; // don't do this under lock - conn.* = http.Connection.init(self.ca_blob, self.config, self.ip_filter) catch { + conn.* = http.Connection.init(self.x509_store, self.config, self.ip_filter) catch { self.ws_mutex.lock(); defer self.ws_mutex.unlock(); self.ws_pool.destroy(conn); @@ -712,89 +716,43 @@ pub fn newConnection(self: *Network) ?*http.Connection { return conn; } -// Wraps lines @ 64 columns. A PEM is basically a base64 encoded DER (which is -// what Zig has), with lines wrapped at 64 characters and with a basic header -// and footer -const LineWriter = struct { - col: usize = 0, - inner: std.ArrayList(u8).Writer, - - pub fn writeAll(self: *LineWriter, data: []const u8) !void { - var writer = self.inner; - - var col = self.col; - const len = 64 - col; - - var remain = data; - if (remain.len > len) { - col = 0; - try writer.writeAll(data[0..len]); - try writer.writeByte('\n'); - remain = data[len..]; - } - - while (remain.len > 64) { - try writer.writeAll(remain[0..64]); - try writer.writeByte('\n'); - remain = remain[64..]; - } - try writer.writeAll(remain); - self.col = col + remain.len; - } -}; +const CreateX509StoreError = std.crypto.Certificate.Bundle.RescanError || error{FailedToCreateX509Store}; // TODO: on BSD / Linux, we could just read the PEM file directly. // This whole rescan + decode is really just needed for MacOS. On Linux // bundle.rescan does find the .pem file(s) which could be in a few different // places, so it's still useful, just not efficient. -fn loadCerts(allocator: Allocator) !libcurl.CurlBlob { +// +/// NEVER give full ownership of store to SSL_CTX, always rely on ref counting. +fn createX509Store(allocator: Allocator) CreateX509StoreError!*crypto.X509_STORE { + const store = crypto.X509_STORE_new() orelse return error.FailedToCreateX509Store; + errdefer crypto.X509_STORE_free(store); + var bundle: std.crypto.Certificate.Bundle = .{}; try bundle.rescan(allocator); defer bundle.deinit(allocator); const bytes = bundle.bytes.items; if (bytes.len == 0) { - lp.log.warn(.app, "No system certificates", .{}); - return .{ - .len = 0, - .flags = 0, - .data = bytes.ptr, - }; + log.warn(.app, "No system certificates", .{}); + return store; } - - const encoder = std.base64.standard.Encoder; - var arr: std.ArrayList(u8) = .empty; - - const encoded_size = encoder.calcSize(bytes.len); - const buffer_size = encoded_size + - (bundle.map.count() * 75) + // start / end per certificate + extra, just in case - (encoded_size / 64) // newline per 64 characters - ; - try arr.ensureTotalCapacity(allocator, buffer_size); - errdefer arr.deinit(allocator); - var writer = arr.writer(allocator); - var it = bundle.map.valueIterator(); while (it.next()) |index| { - const cert = try std.crypto.Certificate.der.Element.parse(bytes, index.*); - - try writer.writeAll("-----BEGIN CERTIFICATE-----\n"); - var line_writer = LineWriter{ .inner = writer }; - try encoder.encodeWriter(&line_writer, bytes[index.*..cert.slice.end]); - try writer.writeAll("\n-----END CERTIFICATE-----\n"); + // d2i_X509 reads the cert's own DER length header to find its end and + // advances `ptr` past it, so we just hand it the rest of the buffer. + var ptr: [*]const u8 = bytes.ptr + index.*; + const x509 = crypto.d2i_X509(null, &ptr, @intCast(bytes.len - index.*)) orelse { + log.warn(.app, "Skipping unparseable system cert", .{}); + continue; + }; + defer crypto.X509_free(x509); // add_cert takes its own ref; drop ours. + // TODO: Handle error. + const result = crypto.X509_STORE_add_cert(store, x509); + if (result != 1) { + log.warn(.app, "Failed to add X509 cert to store", .{}); + } } - // Final encoding should not be larger than our initial size estimate - lp.assert(buffer_size > arr.items.len, "Http loadCerts", .{ .estimate = buffer_size, .len = arr.items.len }); - - // Allocate exactly the size needed and copy the data - const result = try allocator.dupe(u8, arr.items); - // Free the original oversized allocation - arr.deinit(allocator); - - return .{ - .len = result.len, - .data = result.ptr, - .flags = 0, - }; + return store; } diff --git a/src/network/http.zig b/src/network/http.zig index 481a991a6..96c06e930 100644 --- a/src/network/http.zig +++ b/src/network/http.zig @@ -21,6 +21,7 @@ const posix = std.posix; const Config = @import("../Config.zig"); const libcurl = @import("../sys/libcurl.zig"); +const crypto = @import("../sys/libcrypto.zig"); const IpFilter = @import("IpFilter.zig"); const log = @import("lightpanda").log; @@ -368,7 +369,7 @@ pub const Connection = struct { }; pub fn init( - ca_blob: ?libcurl.CurlBlob, + x509_store: *crypto.X509_STORE, config: *const Config, ip_filter: ?*const IpFilter, ) !Connection { @@ -377,7 +378,7 @@ pub const Connection = struct { var self = Connection{ ._easy = easy, .in_use = false, .transport = .none }; errdefer self.deinit(); - try self.reset(config, ca_blob, ip_filter); + try self.reset(config, x509_store, ip_filter); return self; } @@ -501,7 +502,7 @@ pub const Connection = struct { pub fn reset( self: *Connection, config: *const Config, - ca_blob: ?libcurl.CurlBlob, + x509_store: *crypto.X509_STORE, ip_filter: ?*const IpFilter, ) !void { libcurl.curl_easy_reset(self._easy); @@ -524,15 +525,28 @@ pub const Connection = struct { try libcurl.curl_easy_setopt(self._easy, .proxy, null); } - // tls - if (ca_blob) |ca| { - try libcurl.curl_easy_setopt(self._easy, .ca_info_blob, ca); - if (http_proxy != null) { - try libcurl.curl_easy_setopt(self._easy, .proxy_ca_info_blob, ca); - } - } else { - assert(config.tlsVerifyHost() == false, "Http.init tls_verify_host", .{}); + // TLS. + if (config.tlsVerifyHost()) { + // Provide certificate store to connection's SSL_CTX. + try libcurl.curl_easy_setopt(self._easy, .ssl_ctx_function, &(struct { + fn wrap( + _: *libcurl.Curl, + raw_ssl_ctx: *anyopaque, + raw_x509_store: *anyopaque, + ) callconv(.c) libcurl.CurlCode { + const ssl_ctx: *crypto.SSL_CTX = @ptrCast(raw_ssl_ctx); + const store: *crypto.X509_STORE = @ptrCast(raw_x509_store); + const result = crypto.SSL_CTX_set1_verify_cert_store(ssl_ctx, store); + if (result != 1) { + return libcurl.CURLE.ABORTED_BY_CALLBACK; + } + return libcurl.CURLE.OK; + } + }).wrap); + // Pass our store to CURLOPT_SSL_CTX_FUNCTION. + try libcurl.curl_easy_setopt(self._easy, .ssl_ctx_data, x509_store); + } else { try libcurl.curl_easy_setopt(self._easy, .ssl_verify_host, false); try libcurl.curl_easy_setopt(self._easy, .ssl_verify_peer, false); diff --git a/src/telemetry/lightpanda.zig b/src/telemetry/lightpanda.zig index 769ae9c11..c0a56c587 100644 --- a/src/telemetry/lightpanda.zig +++ b/src/telemetry/lightpanda.zig @@ -132,7 +132,7 @@ pub fn send(self: *LightPanda, raw_event: telemetry.Event) !void { fn run(self: *LightPanda) void { // The connection is created, owned, and torn down entirely on this thread; // the network thread never sees it (Transport == .none). - var conn = http.Connection.init(self.network.ca_blob, self.network.config, self.network.ip_filter) catch |err| { + var conn = http.Connection.init(self.network.x509_store, self.network.config, self.network.ip_filter) catch |err| { // Essentially OOM — the process is already in trouble. The thread // handle stays set so send() won't respawn; events drop at the cap. log.warn(.telemetry, "connection init", .{ .err = err }); From fd0bff3af2f5a2b5049d106695bae1515826401d Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 1 Jul 2026 12:02:59 +0300 Subject: [PATCH 08/49] `http`: remove dead code --- src/network/http.zig | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/network/http.zig b/src/network/http.zig index 96c06e930..ace8fa270 100644 --- a/src/network/http.zig +++ b/src/network/http.zig @@ -25,11 +25,9 @@ const crypto = @import("../sys/libcrypto.zig"); const IpFilter = @import("IpFilter.zig"); const log = @import("lightpanda").log; -const assert = @import("lightpanda").assert; pub const ENABLE_DEBUG = false; -pub const Blob = libcurl.CurlBlob; pub const WaitFd = libcurl.CurlWaitFd; pub const readfunc_pause = libcurl.curl_readfunc_pause; pub const writefunc_error = libcurl.curl_writefunc_error; From 5e567adf1b8c3f257b6b7a291dcd773532349c76 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Wed, 1 Jul 2026 12:49:57 +0300 Subject: [PATCH 09/49] ci: run `serve` command with TLS host verification disabled --- .github/workflows/e2e-test.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/e2e-test.yml b/.github/workflows/e2e-test.yml index 9ac05be88..6c9f54271 100644 --- a/.github/workflows/e2e-test.yml +++ b/.github/workflows/e2e-test.yml @@ -281,7 +281,7 @@ jobs: fi mkdir -p $CG_ROOT/$CG - cgexec -g memory:$CG ./lightpanda serve & echo $! > LPD.pid + cgexec -g memory:$CG ./lightpanda serve --insecure-disable-tls-host-verification & echo $! > LPD.pid sleep 2 From c86dad543043d6161739464e97879b8ee5613e52 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Thu, 2 Jul 2026 08:46:01 +0300 Subject: [PATCH 10/49] `libcrypto`: remove unused utilities --- src/sys/libcrypto.zig | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/sys/libcrypto.zig b/src/sys/libcrypto.zig index 1e47f8a69..1dc9bb396 100644 --- a/src/sys/libcrypto.zig +++ b/src/sys/libcrypto.zig @@ -346,21 +346,15 @@ pub const EVP_MD_CTX = struct_evp_md_ctx_st; pub extern fn X509_free(x509: ?*X509) void; pub extern fn d2i_X509(out: [*c]?*X509, inp: *[*]const u8, len: c_long) ?*X509; -//pub const struct_x509_store_ctx_st = opaque {}; -//pub const X509_STORE_CTX = struct_x509_store_ctx_st; pub const struct_x509_store_st = opaque {}; pub const X509_STORE = struct_x509_store_st; pub extern fn X509_STORE_new() ?*X509_STORE; pub extern fn X509_STORE_free(store: *X509_STORE) void; pub extern fn X509_STORE_add_cert(store: ?*X509_STORE, x: ?*X509) c_int; -pub extern fn X509_STORE_add_crl(store: ?*X509_STORE, x: ?*X509_CRL) c_int; pub const struct_ssl_ctx_st = opaque {}; pub const SSL_CTX = struct_ssl_ctx_st; -/// SSL_CTX takes ownership. -pub extern fn SSL_CTX_set0_verify_cert_store(ctx: ?*SSL_CTX, store: ?*X509_STORE) c_int; -/// Ownership remains, ref count increments. pub extern fn SSL_CTX_set1_verify_cert_store(ctx: ?*SSL_CTX, store: ?*X509_STORE) c_int; /// Returns the desired digest by its name. From e56a4f6259d8ce86b4cc240651917ca209854a1a Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Thu, 2 Jul 2026 11:19:53 +0200 Subject: [PATCH 11/49] use getrandom syscall for std.crypto.random MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit std.crypto.random's default backend mmaps a thread-local 528-byte state page on first use and never unmaps it — there is no thread-exit hook. With one detached thread per CDP connection (Server.handleConnection), that leaks one resident page per connection (uuidv4 in Page.getOrCreateOrigin touches it), ~4KB/conn of unbounded RSS growth. Route every std.crypto.random call to the getrandom syscall instead. .crypto_always_getrandom = true, --- src/main.zig | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/main.zig b/src/main.zig index 439c3a36b..318db0f98 100644 --- a/src/main.zig +++ b/src/main.zig @@ -27,6 +27,16 @@ const Config = lp.Config; const SigHandler = @import("Sighandler.zig"); pub const panic = lp.crash_handler.panic; +pub const std_options: std.Options = .{ + // std.crypto.random's default backend mmaps a thread-local 528-byte state + // page on first use and never unmaps it — there is no thread-exit hook. + // With one detached thread per CDP connection (Server.handleConnection), + // that leaks one resident page per connection (uuidv4 in + // Page.getOrCreateOrigin touches it), ~4KB/conn of unbounded RSS growth. + // Route every std.crypto.random call to the getrandom syscall instead. + .crypto_always_getrandom = true, +}; + pub fn main() !void { // allocator // - in Debug mode we use the General Purpose Allocator to detect memory leaks From 37a7f034c84de59b59a28fddf0757673e6e5a244 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Thu, 2 Jul 2026 11:23:45 +0200 Subject: [PATCH 12/49] use writerStreaming in log --- src/log.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/log.zig b/src/log.zig index f9b7a793b..20ac2fa11 100644 --- a/src/log.zig +++ b/src/log.zig @@ -172,7 +172,11 @@ pub fn log(scope: Scope, level: Level, msg: []const u8, data: anytype) void { var buf: [4096]u8 = undefined; var stderr = std.fs.File.stderr(); - var writer = stderr.writer(&buf); + // writerStreaming, not writer: the default positional mode starts each + // fresh Writer at offset 0, so when stderr is redirected to a regular file + // every log line overwrites the previous one. Streaming writes at the fd + // offset, which is what append-style logging needs. + var writer = stderr.writerStreaming(&buf); logTo(scope, level, msg, data, &writer.interface) catch |log_err| { std.debug.print("$time={d} $level=fatal $scope={s} $msg=\"log err\" err={s} log_msg=\"{s}\"\n", .{ timestamp(.clock), @errorName(log_err), @tagName(scope), msg }); From 8f8df00b0fc960ddd2ab418c118d63864ee3aed4 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 2 Jul 2026 19:28:09 +0800 Subject: [PATCH 13/49] crash, worker: Fix crash when MessageEvent.source is called from worker The source for a worker is always null. The getter cannot receive a *Frame since it can be called from a Worker's context. --- src/browser/webapi/event/MessageEvent.zig | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/browser/webapi/event/MessageEvent.zig b/src/browser/webapi/event/MessageEvent.zig index 47188f3b6..6874b5b7e 100644 --- a/src/browser/webapi/event/MessageEvent.zig +++ b/src/browser/webapi/event/MessageEvent.zig @@ -21,7 +21,6 @@ const lp = @import("lightpanda"); const js = @import("../../js/js.zig"); const Page = @import("../../Page.zig"); -const Frame = @import("../../Frame.zig"); const Event = @import("../Event.zig"); const MessagePort = @import("../MessagePort.zig"); @@ -29,6 +28,7 @@ const Window = @import("../Window.zig"); const String = lp.String; const Allocator = std.mem.Allocator; +const IS_DEBUG = @import("builtin").mode == .Debug; const MessageEvent = @This(); @@ -117,9 +117,20 @@ pub fn getOrigin(self: *const MessageEvent) []const u8 { return self._origin; } -pub fn getSource(self: *const MessageEvent, frame: *Frame) ?Window.Access { - const source = self._source orelse return null; - return Window.Access.init(frame.window, source); +pub fn getSource(self: *const MessageEvent, exec: *js.Execution) ?Window.Access { + switch (exec.js.global) { + .frame => |frame| { + const source = self._source orelse return null; + return Window.Access.init(frame.window, source); + }, + .worker => { + // source for worker should always be null + if (comptime IS_DEBUG) { + std.debug.assert(self._source == null); + } + return null; + }, + } } pub fn getPorts(self: *const MessageEvent) []const *MessagePort { From 69547db8baffe25cba99ab009bf6079ad2b107f0 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Thu, 2 Jul 2026 20:54:33 +0800 Subject: [PATCH 14/49] webapi: Make all interfaces non-enumerable When I added this, I was going through the list in /dom/interface-objects.html thinking that was exhaustive. But no, no interfaces should be enumerable and various other WPT tests (usually the idlharness ones) assert that for their respective types. Make it _always_ non enumerable means we no longer need Meta.enumerable to be declared true/false (it's always false). --- src/browser/js/Snapshot.zig | 22 +++++++-------- src/browser/tests/net/url_search_params.html | 27 ++++++++++++------- src/browser/webapi/AbortController.zig | 1 - src/browser/webapi/AbortSignal.zig | 1 - src/browser/webapi/CData.zig | 1 - src/browser/webapi/DOMImplementation.zig | 1 - src/browser/webapi/DOMNodeIterator.zig | 1 - src/browser/webapi/DOMTreeWalker.zig | 1 - src/browser/webapi/Document.zig | 1 - src/browser/webapi/DocumentFragment.zig | 1 - src/browser/webapi/DocumentType.zig | 1 - src/browser/webapi/Element.zig | 1 - src/browser/webapi/Event.zig | 1 - src/browser/webapi/EventTarget.zig | 1 - src/browser/webapi/Node.zig | 1 - src/browser/webapi/NodeFilter.zig | 1 - src/browser/webapi/cdata/Comment.zig | 1 - .../webapi/cdata/ProcessingInstruction.zig | 1 - src/browser/webapi/cdata/Text.zig | 1 - .../webapi/collections/DOMTokenList.zig | 1 - .../webapi/collections/HTMLCollection.zig | 1 - src/browser/webapi/collections/NodeList.zig | 1 - src/browser/webapi/element/Attribute.zig | 1 - src/browser/webapi/event/CustomEvent.zig | 1 - 24 files changed, 27 insertions(+), 44 deletions(-) diff --git a/src/browser/js/Snapshot.zig b/src/browser/js/Snapshot.zig index a1cfd9d2e..27338ac1a 100644 --- a/src/browser/js/Snapshot.zig +++ b/src/browser/js/Snapshot.zig @@ -314,13 +314,8 @@ fn createSnapshotContext( const name = JsApi.Meta.name; const v8_class_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len)); var maybe_result: v8.MaybeBool = undefined; - // Web IDL: interface objects on the global are non-enumerable - // by default. Opt back in via JsApi.Meta.enumerable = true. - var properties: v8.PropertyAttribute = v8.DontEnum; - if (@hasDecl(JsApi.Meta, "enumerable") and JsApi.Meta.enumerable == true) { - properties = v8.None; - } - v8.v8__Object__DefineOwnProperty(global_obj, context, v8_class_name, func, properties, &maybe_result); + // Web IDL: interface objects on the global are non-enumerable. + v8.v8__Object__DefineOwnProperty(global_obj, context, v8_class_name, func, v8.DontEnum, &maybe_result); } } @@ -759,12 +754,13 @@ fn attachClass(comptime JsApi: type, comptime flatten: bool, isolate: *v8.Isolat if (value.static) { v8.v8__Template__SetAccessorProperty(@ptrCast(template), js_name, getter_callback, setter_callback, attribute); } else { - const accessor_attr = if (own_properties) attribute else attribute | v8.DontEnum; + // Web IDL: attributes on the interface prototype object + // (and mirrored onto [Global] instances) are enumerable. v8.v8__ObjectTemplate__SetAccessorProperty__Config(define_on orelse prototype, &.{ .key = js_name, .getter = getter_callback, .setter = setter_callback, - .attribute = accessor_attr, + .attribute = attribute, }); } }, @@ -785,8 +781,9 @@ fn attachClass(comptime JsApi: type, comptime flatten: bool, isolate: *v8.Isolat if (value.static and !own_properties) { v8.v8__Template__Set(@ptrCast(template), js_name, @ptrCast(function_template), v8.None); } else { - const fn_attr: v8.PropertyAttribute = if (own_properties) v8.None else v8.DontEnum; - v8.v8__Template__Set(@ptrCast(define_on orelse member_template), js_name, @ptrCast(function_template), fn_attr); + // Web IDL: operations on the interface prototype object + // (and mirrored onto [Global] instances) are enumerable. + v8.v8__Template__Set(@ptrCast(define_on orelse member_template), js_name, @ptrCast(function_template), v8.None); } }, bridge.Indexed => { @@ -825,7 +822,8 @@ fn attachClass(comptime JsApi: type, comptime flatten: bool, isolate: *v8.Isolat v8.v8__Symbol__GetAsyncIterator(isolate) else v8.v8__Symbol__GetIterator(isolate); - v8.v8__Template__Set(@ptrCast(prototype), js_name, @ptrCast(function_template), v8.None); + // Web IDL: @@iterator is { writable, enumerable: false, configurable }. + v8.v8__Template__Set(@ptrCast(prototype), js_name, @ptrCast(function_template), v8.DontEnum); }, bridge.Property => { const js_value = switch (value.value) { diff --git a/src/browser/tests/net/url_search_params.html b/src/browser/tests/net/url_search_params.html index 08d28ec04..48442d221 100644 --- a/src/browser/tests/net/url_search_params.html +++ b/src/browser/tests/net/url_search_params.html @@ -489,23 +489,30 @@ } - diff --git a/src/browser/webapi/AbortController.zig b/src/browser/webapi/AbortController.zig index 6e0b62251..3d6a30040 100644 --- a/src/browser/webapi/AbortController.zig +++ b/src/browser/webapi/AbortController.zig @@ -49,7 +49,6 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const enumerable = false; }; pub const constructor = bridge.constructor(AbortController.init, .{}); diff --git a/src/browser/webapi/AbortSignal.zig b/src/browser/webapi/AbortSignal.zig index 88bc4ee46..ab92efe0d 100644 --- a/src/browser/webapi/AbortSignal.zig +++ b/src/browser/webapi/AbortSignal.zig @@ -233,7 +233,6 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const enumerable = false; }; pub const Prototype = EventTarget; diff --git a/src/browser/webapi/CData.zig b/src/browser/webapi/CData.zig index eaa44120c..129b52d60 100644 --- a/src/browser/webapi/CData.zig +++ b/src/browser/webapi/CData.zig @@ -420,7 +420,6 @@ pub const JsApi = struct { pub const name = "CharacterData"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const enumerable = false; }; pub const data = bridge.accessor(CData.getData, CData._setData, .{ .ce_reactions = true }); diff --git a/src/browser/webapi/DOMImplementation.zig b/src/browser/webapi/DOMImplementation.zig index 23eac9c98..a6e7a7a30 100644 --- a/src/browser/webapi/DOMImplementation.zig +++ b/src/browser/webapi/DOMImplementation.zig @@ -99,7 +99,6 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const empty_with_no_proto = true; - pub const enumerable = false; }; pub const createDocumentType = bridge.function(DOMImplementation.createDocumentType, .{ .dom_exception = true }); diff --git a/src/browser/webapi/DOMNodeIterator.zig b/src/browser/webapi/DOMNodeIterator.zig index da46691f2..4ef60f678 100644 --- a/src/browser/webapi/DOMNodeIterator.zig +++ b/src/browser/webapi/DOMNodeIterator.zig @@ -191,7 +191,6 @@ pub const JsApi = struct { pub const name = "NodeIterator"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const enumerable = false; }; pub const root = bridge.accessor(DOMNodeIterator.getRoot, null, .{}); diff --git a/src/browser/webapi/DOMTreeWalker.zig b/src/browser/webapi/DOMTreeWalker.zig index 7fd4b8502..29779fa68 100644 --- a/src/browser/webapi/DOMTreeWalker.zig +++ b/src/browser/webapi/DOMTreeWalker.zig @@ -343,7 +343,6 @@ pub const JsApi = struct { pub const name = "TreeWalker"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const enumerable = false; }; pub const root = bridge.accessor(DOMTreeWalker.getRoot, null, .{}); diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 9f02ce08e..c70e134a1 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -1254,7 +1254,6 @@ pub const JsApi = struct { pub const name = "Document"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const enumerable = false; }; pub const constructor = bridge.constructor(_constructor, .{}); diff --git a/src/browser/webapi/DocumentFragment.zig b/src/browser/webapi/DocumentFragment.zig index da09e9bbc..75af0de1f 100644 --- a/src/browser/webapi/DocumentFragment.zig +++ b/src/browser/webapi/DocumentFragment.zig @@ -192,7 +192,6 @@ pub const JsApi = struct { pub const name = "DocumentFragment"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const enumerable = false; }; pub const constructor = bridge.constructor(DocumentFragment.init, .{}); diff --git a/src/browser/webapi/DocumentType.zig b/src/browser/webapi/DocumentType.zig index 1a7bb30a5..670ca01d0 100644 --- a/src/browser/webapi/DocumentType.zig +++ b/src/browser/webapi/DocumentType.zig @@ -87,7 +87,6 @@ pub const JsApi = struct { pub const name = "DocumentType"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const enumerable = false; }; pub const name = bridge.accessor(DocumentType.getName, null, .{}); diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index f4c16641d..ee50ca610 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -1913,7 +1913,6 @@ pub const JsApi = struct { pub const name = "Element"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const enumerable = false; }; pub const tagName = bridge.accessor(_tagName, null, .{}); diff --git a/src/browser/webapi/Event.zig b/src/browser/webapi/Event.zig index 8ac412b3d..d47a54b48 100644 --- a/src/browser/webapi/Event.zig +++ b/src/browser/webapi/Event.zig @@ -465,7 +465,6 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const enumerable = false; }; pub const constructor = bridge.constructor(Event.init, .{}); diff --git a/src/browser/webapi/EventTarget.zig b/src/browser/webapi/EventTarget.zig index cf5238646..e3aca0cf1 100644 --- a/src/browser/webapi/EventTarget.zig +++ b/src/browser/webapi/EventTarget.zig @@ -201,7 +201,6 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const enumerable = false; }; pub const constructor = bridge.constructor(EventTarget.init, .{}); diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index e56209dd6..076d633e0 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -1276,7 +1276,6 @@ pub const JsApi = struct { pub const name = "Node"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const enumerable = false; }; pub const ELEMENT_NODE = bridge.property(1, .{ .template = true }); diff --git a/src/browser/webapi/NodeFilter.zig b/src/browser/webapi/NodeFilter.zig index bd485ec87..a2519e718 100644 --- a/src/browser/webapi/NodeFilter.zig +++ b/src/browser/webapi/NodeFilter.zig @@ -87,7 +87,6 @@ pub const JsApi = struct { pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; pub const empty_with_no_proto = true; - pub const enumerable = false; }; pub const FILTER_ACCEPT = bridge.property(NodeFilter.FILTER_ACCEPT, .{ .template = true }); diff --git a/src/browser/webapi/cdata/Comment.zig b/src/browser/webapi/cdata/Comment.zig index 3c9f7db3b..a77a1c57f 100644 --- a/src/browser/webapi/cdata/Comment.zig +++ b/src/browser/webapi/cdata/Comment.zig @@ -37,7 +37,6 @@ pub const JsApi = struct { pub const name = "Comment"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const enumerable = false; }; pub const constructor = bridge.constructor(Comment.init, .{}); diff --git a/src/browser/webapi/cdata/ProcessingInstruction.zig b/src/browser/webapi/cdata/ProcessingInstruction.zig index 84cc711b9..97024d4ed 100644 --- a/src/browser/webapi/cdata/ProcessingInstruction.zig +++ b/src/browser/webapi/cdata/ProcessingInstruction.zig @@ -36,7 +36,6 @@ pub const JsApi = struct { pub const name = "ProcessingInstruction"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const enumerable = false; }; pub const target = bridge.accessor(ProcessingInstruction.getTarget, null, .{}); diff --git a/src/browser/webapi/cdata/Text.zig b/src/browser/webapi/cdata/Text.zig index e358813b7..2c3a3e7d8 100644 --- a/src/browser/webapi/cdata/Text.zig +++ b/src/browser/webapi/cdata/Text.zig @@ -78,7 +78,6 @@ pub const JsApi = struct { pub const name = "Text"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const enumerable = false; }; pub const constructor = bridge.constructor(Text.init, .{}); diff --git a/src/browser/webapi/collections/DOMTokenList.zig b/src/browser/webapi/collections/DOMTokenList.zig index 88acb1667..7cf7f85ba 100644 --- a/src/browser/webapi/collections/DOMTokenList.zig +++ b/src/browser/webapi/collections/DOMTokenList.zig @@ -298,7 +298,6 @@ pub const JsApi = struct { pub const name = "DOMTokenList"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const enumerable = false; }; pub const length = bridge.accessor(DOMTokenList.length, null, .{}); diff --git a/src/browser/webapi/collections/HTMLCollection.zig b/src/browser/webapi/collections/HTMLCollection.zig index fc534bed1..509580289 100644 --- a/src/browser/webapi/collections/HTMLCollection.zig +++ b/src/browser/webapi/collections/HTMLCollection.zig @@ -143,7 +143,6 @@ pub const JsApi = struct { pub const name = "HTMLCollection"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const enumerable = false; }; pub const length = bridge.accessor(HTMLCollection.length, null, .{}); diff --git a/src/browser/webapi/collections/NodeList.zig b/src/browser/webapi/collections/NodeList.zig index 17610d286..6a768a555 100644 --- a/src/browser/webapi/collections/NodeList.zig +++ b/src/browser/webapi/collections/NodeList.zig @@ -142,7 +142,6 @@ pub const JsApi = struct { pub const name = "NodeList"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const enumerable = false; }; pub const length = bridge.accessor(NodeList.length, null, .{}); diff --git a/src/browser/webapi/element/Attribute.zig b/src/browser/webapi/element/Attribute.zig index d8623ba53..a1fc9683e 100644 --- a/src/browser/webapi/element/Attribute.zig +++ b/src/browser/webapi/element/Attribute.zig @@ -99,7 +99,6 @@ pub const JsApi = struct { pub const name = "Attr"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const enumerable = false; }; pub const name = bridge.accessor(Attribute.getName, null, .{}); diff --git a/src/browser/webapi/event/CustomEvent.zig b/src/browser/webapi/event/CustomEvent.zig index 02c9b8a03..338451811 100644 --- a/src/browser/webapi/event/CustomEvent.zig +++ b/src/browser/webapi/event/CustomEvent.zig @@ -105,7 +105,6 @@ pub const JsApi = struct { pub const name = "CustomEvent"; pub const prototype_chain = bridge.prototypeChain(); pub var class_id: bridge.ClassId = undefined; - pub const enumerable = false; }; pub const constructor = bridge.constructor(CustomEvent.init, .{}); From 25d8b7d5c7a56dd67a675ff412526d1a4fb4c0ba Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Wed, 1 Jul 2026 18:12:03 +0800 Subject: [PATCH 15/49] mem: add local_arena The `call_arena` is currently our shortest-lived arena. Its promise is that it will be valid for at least 1 v8 -> zig -> v8 function call, which makes it ideal for getting values from v8 into zig, temporary work, and getting values from zig to v8. VBut `call_arena`'s lifetime is actually much longer. It's only reset when the call_depth reaches 0. And the reason for that are Zig functions that invoke v8 callbacks. If you just do it when a function ends, then if you allocate in ZigA and then call CallbackA which calls ZigB, then when ZigB ends, your ZigA allocation is cleared. This is something we could solve by reaching into Zig's ArenaAllocator to capture a position to rollback from. So, `call_arena` can end up living relatively long and accumulating quite a bit of memory. But most calls _don't_ invoke callbacks. Hence, `local_arena` IS reset at the end of every function. Using `local_arena` _is_ dangerous, mostly for the case where some of our APIs receive a *Frame (or *Execution) and thus might use a `local_arena` because they know they aren't invoking a JS callback. BUT, those APIs might not know that they're also invoked by some other Zig code that _could_ be invoking JS. Dangerous? Sure. But, github has code that looks like: ```js function onDelegatedClick(e) { for (const el of document.querySelectorAll('[data-action]')) { if (el.matches('.menu > .item:not(.disabled) a[href]')) { handle(el); } el.closest('.panel'); } } ``` Anything allocated in the `call_arena` will only be freed when the caller of `onDelegatedClick` ends. The `querySelectorAll` returns hundreds of elements and thus builds hundreds of parsed CSS and other scrap (x2 for `matches` and closest`). `call_arena` peaks at 15MB. With the local_arena? 1MB. If 10x more elements were returned, the peak would be 10x higher. With local_arena, it stays 1MB. --- src/browser/Frame.zig | 15 +++++++++++++-- src/browser/Runner.zig | 4 ++-- src/browser/ScriptManagerBase.zig | 2 +- src/browser/frame/node_factory.zig | 2 +- src/browser/js/Caller.zig | 11 +++++++++++ src/browser/js/Context.zig | 8 ++++++-- src/browser/js/Env.zig | 3 +++ src/browser/js/Execution.zig | 1 + src/browser/markdown.zig | 2 +- src/browser/webapi/CSS.zig | 2 +- src/browser/webapi/CryptoKey.zig | 2 +- src/browser/webapi/DOMMatrixReadOnly.zig | 8 ++++---- src/browser/webapi/DataTransfer.zig | 10 +++++----- src/browser/webapi/Document.zig | 10 +++++----- src/browser/webapi/DocumentFragment.zig | 2 +- src/browser/webapi/Element.zig | 12 ++++++++---- src/browser/webapi/HTMLDocument.zig | 4 ++-- src/browser/webapi/IntersectionObserver.zig | 2 +- src/browser/webapi/MutationObserver.zig | 2 +- src/browser/webapi/Node.zig | 6 ++++-- src/browser/webapi/Performance.zig | 4 ++-- src/browser/webapi/PerformanceObserver.zig | 4 ++-- src/browser/webapi/Range.zig | 2 +- src/browser/webapi/SubtleCrypto.zig | 8 ++++---- src/browser/webapi/URL.zig | 6 +++--- src/browser/webapi/Window.zig | 4 ++-- src/browser/webapi/WorkerGlobalScope.zig | 7 +++++++ src/browser/webapi/XMLSerializer.zig | 2 +- .../canvas/OffscreenCanvasRenderingContext2D.zig | 2 +- src/browser/webapi/collections/DOMTokenList.zig | 12 ++++++------ src/browser/webapi/crypto/AES.zig | 8 ++++---- src/browser/webapi/crypto/EC.zig | 2 +- src/browser/webapi/crypto/HMAC.zig | 3 +-- src/browser/webapi/crypto/KDF.zig | 4 ++-- src/browser/webapi/crypto/X25519.zig | 4 ++-- src/browser/webapi/css/CSSStyleDeclaration.zig | 4 ++-- src/browser/webapi/css/CSSStyleRule.zig | 2 +- src/browser/webapi/css/CSSStyleSheet.zig | 2 +- src/browser/webapi/element/DOMStringMap.zig | 6 +++--- src/browser/webapi/element/Html.zig | 4 ++-- src/browser/webapi/element/html/Anchor.zig | 2 +- src/browser/webapi/element/html/Area.zig | 2 +- src/browser/webapi/element/html/Base.zig | 2 +- src/browser/webapi/element/html/Canvas.zig | 4 ++-- src/browser/webapi/element/html/Input.zig | 2 +- src/cdp/CDP.zig | 7 +++++++ 46 files changed, 133 insertions(+), 84 deletions(-) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index f903f3e1d..7cde1c6c8 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -251,10 +251,15 @@ js: *JS.Context, // An arena for the lifetime of the frame. arena: Allocator, -// An arena with a lifetime guaranteed to be for 1 invoking of a Zig function -// from JS. Best arena to use, when possible. +// An arena with a lifetime for at least the scope of one Zig invocation from +// JS. Prefer local_arena where possible. Use call_arena when allocations may +// need to call back into JS (event dispatch, forEach callback, ....) call_arena: Allocator, +// An arena with a lifetime guaranteed to be for exactly 1 invoking of a Zig +// function from JS. Best arena to use, when possible. +local_arena: Allocator, + parent: ?*Frame, window: *Window, document: *Document, @@ -306,6 +311,9 @@ pub fn init(self: *Frame, frame_id: u32, page: *Page, opts: InitOpts) !void { const call_arena = try session.getArena(.medium, "call_arena"); errdefer session.releaseArena(call_arena); + const local_arena = try session.getArena(.medium, "local_arena"); + errdefer session.releaseArena(local_arena); + const factory = &page.factory; const document = (try factory.document(Node.Document.HTMLDocument{ ._proto = undefined, @@ -320,6 +328,7 @@ pub fn init(self: *Frame, frame_id: u32, page: *Page, opts: InitOpts) !void { .document = document, .window = undefined, .call_arena = call_arena, + .local_arena = local_arena, ._frame_id = frame_id, ._page = page, ._session = session, @@ -382,6 +391,7 @@ pub fn init(self: *Frame, frame_id: u32, page: *Page, opts: InitOpts) !void { .identity = &page.identity, .identity_arena = arena, .call_arena = self.call_arena, + .local_arena = self.local_arena, }); errdefer browser.env.destroyContext(self.js); @@ -480,6 +490,7 @@ pub fn deinit(self: *Frame) void { self._style_manager.deinit(); page.releaseArena(self.call_arena); + page.releaseArena(self.local_arena); } pub fn trackWorker(self: *Frame, worker: *Worker) !void { diff --git a/src/browser/Runner.zig b/src/browser/Runner.zig index d07c1b31f..f00bdbd7c 100644 --- a/src/browser/Runner.zig +++ b/src/browser/Runner.zig @@ -368,7 +368,7 @@ pub fn waitForScript(self: *Runner, frame_id: u32, src: [:0]const u8, timeout_ms defer try_catch.deinit(); const s = ls.local.compile(src, "wait_script") catch |err| { - const caught = try_catch.caughtOrError(frame.call_arena, err); + const caught = try_catch.caughtOrError(frame.local_arena, err); log.err(.app, "wait script error", .{ .err = caught }); return error.ScriptError; }; @@ -396,7 +396,7 @@ pub fn waitForScript(self: *Runner, frame_id: u32, src: [:0]const u8, timeout_ms const script = compiled.get(ls.local.isolate).bindToCurrentContext(&ls.local); const value = script.run() catch |err| { - const caught = try_catch.caughtOrError(frame.call_arena, err); + const caught = try_catch.caughtOrError(frame.local_arena, err); log.err(.app, "wait script error", .{ .err = caught }); return error.ScriptError; }; diff --git a/src/browser/ScriptManagerBase.zig b/src/browser/ScriptManagerBase.zig index 07d9e16bb..509b4523f 100644 --- a/src/browser/ScriptManagerBase.zig +++ b/src/browser/ScriptManagerBase.zig @@ -935,7 +935,7 @@ pub const Script = struct { return; } - const caught = try_catch.caughtOrError(frame.call_arena, error.Unknown); + const caught = try_catch.caughtOrError(frame.local_arena, error.Unknown); log.warn(.js, "eval script", .{ .url = url, .caught = caught, diff --git a/src/browser/frame/node_factory.zig b/src/browser/frame/node_factory.zig index 43412acb8..5665efcff 100644 --- a/src/browser/frame/node_factory.zig +++ b/src/browser/frame/node_factory.zig @@ -356,7 +356,7 @@ pub fn createElementNS(frame: *Frame, namespace: Element.Namespace, name: []cons defer try_catch.deinit(); ls.local.eval(inject_script, "inject_script") catch |err| { - const caught = try_catch.caughtOrError(frame.call_arena, err); + const caught = try_catch.caughtOrError(frame.local_arena, err); log.err(.app, "inject script error", .{ .err = caught }); }; } diff --git a/src/browser/js/Caller.zig b/src/browser/js/Caller.zig index 55daf7c32..7cda1646e 100644 --- a/src/browser/js/Caller.zig +++ b/src/browser/js/Caller.zig @@ -33,6 +33,7 @@ const v8 = js.v8; const log = lp.log; const ArenaAllocator = std.heap.ArenaAllocator; const CALL_ARENA_RETAIN = 1024 * 16; +const LOCAL_ARENA_RETAIN = 1024 * 16; const IS_DEBUG = @import("builtin").mode == .Debug; const Caller = @This(); @@ -101,6 +102,16 @@ pub fn deinit(self: *Caller) void { _ = arena.reset(.{ .retain_with_limit = CALL_ARENA_RETAIN }); } + // Unlike call_arena, local_arena is reset on _every_ return, since its + // users promise not to hold data across a nested call. In debug, free + // back to the backing allocator so a stale pointer trips the + // DebugAllocator's use-after-free detection; in release, retain a buffer + // to avoid realloc churn. + { + const local_arena: *ArenaAllocator = @ptrCast(@alignCast(ctx.local_arena.ptr)); + _ = local_arena.reset(if (comptime IS_DEBUG) .free_all else .{ .retain_with_limit = LOCAL_ARENA_RETAIN }); + } + ctx.call_depth = call_depth; ctx.local = self.prev_local; ctx.global.setJs(self.prev_context); diff --git a/src/browser/js/Context.zig b/src/browser/js/Context.zig index 7cc777f23..c0945742e 100644 --- a/src/browser/js/Context.zig +++ b/src/browser/js/Context.zig @@ -100,6 +100,10 @@ arena: Allocator, // owned by the IsolatedWorld. call_arena: Allocator, +// Like call_arena, but reset on _every_ Caller.deinit rather than only at +// call_depth 0. +local_arena: Allocator, + // Because calls can be nested (i.e.a function calling a callback), // we can only reset the call_arena when call_depth == 0. If we were // to reset it within a callback, it would invalidate the data of @@ -700,7 +704,7 @@ fn importMetaResolveCallback(callback_handle: ?*const v8.FunctionCallbackInfo) c return; }; - const resolved = ctx.script_manager.resolveSpecifier(ctx.call_arena, data.base, specifier) catch { + const resolved = ctx.script_manager.resolveSpecifier(ctx.local_arena, data.base, specifier) catch { _ = isolate.throwException(isolate.createTypeError("failed to resolve module specifier")); return; }; @@ -904,7 +908,7 @@ fn dynamicModuleSourceCallback(ctx: *anyopaque, module_source_: anyerror!ScriptM defer try_catch.deinit(); break :blk self.module(true, local, ms.src(), state.specifier, true) catch |err| { - const caught = try_catch.caughtOrError(self.call_arena, err); + const caught = try_catch.caughtOrError(self.local_arena, err); log.err(.js, "module compilation failed", .{ .caught = caught, .specifier = state.specifier, diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 3a37e8d4a..299038ad5 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -229,6 +229,7 @@ pub const ContextParams = struct { identity: *js.Identity, identity_arena: Allocator, call_arena: Allocator, + local_arena: Allocator, debug_name: []const u8 = "Context", }; @@ -315,6 +316,7 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context .handle = context_global, .templates = self.templates, .call_arena = params.call_arena, + .local_arena = params.local_arena, .microtask_queue = microtask_queue, .script_manager = if (comptime is_frame) &global._script_manager.base else &global._script_manager, .scheduler = .init(context_arena), @@ -332,6 +334,7 @@ fn _createContext(self: *Env, global: anytype, params: ContextParams) !*Context .page = context.page, .session = page.session, .call_arena = params.call_arena, + .local_arena = params.local_arena, ._factory = global._factory, ._scheduler = &context.scheduler, }; diff --git a/src/browser/js/Execution.zig b/src/browser/js/Execution.zig index b4fbebd8a..ee3abadf6 100644 --- a/src/browser/js/Execution.zig +++ b/src/browser/js/Execution.zig @@ -51,6 +51,7 @@ js: *Context, buf: []u8, arena: Allocator, call_arena: Allocator, +local_arena: Allocator, page: *Page, session: *Session, diff --git a/src/browser/markdown.zig b/src/browser/markdown.zig index 50c0e26cc..b56d25bb3 100644 --- a/src/browser/markdown.zig +++ b/src/browser/markdown.zig @@ -381,7 +381,7 @@ const Context = struct { if (!info.has_visible and label == null and href_raw == null) return; - const href = if (href_raw) |h| URL.resolve(frame.call_arena, frame.base(), h, .{ .encoding = frame.charset }) catch h else null; + const href = if (href_raw) |h| URL.resolve(frame.local_arena, frame.base(), h, .{ .encoding = frame.charset }) catch h else null; if (info.has_block) { try self.renderChildren(el.asNode()); diff --git a/src/browser/webapi/CSS.zig b/src/browser/webapi/CSS.zig index 6b560a5a4..00bd1a952 100644 --- a/src/browser/webapi/CSS.zig +++ b/src/browser/webapi/CSS.zig @@ -65,7 +65,7 @@ pub fn escape(value: []const u8, frame: *Frame) ![]const u8 { return value; } - const result = try frame.call_arena.alloc(u8, out_len); + const result = try frame.local_arena.alloc(u8, out_len); var pos: usize = 0; if (needsEscape(true, first)) { diff --git a/src/browser/webapi/CryptoKey.zig b/src/browser/webapi/CryptoKey.zig index 3874b3fb9..f85c7ce20 100644 --- a/src/browser/webapi/CryptoKey.zig +++ b/src/browser/webapi/CryptoKey.zig @@ -190,7 +190,7 @@ pub fn getUsages(self: *const CryptoKey, exec: *const Execution) ![]const []cons n += 1; } } - return exec.call_arena.dupe([]const u8, buf[0..n]); + return exec.local_arena.dupe([]const u8, buf[0..n]); } pub const JsApi = struct { diff --git a/src/browser/webapi/DOMMatrixReadOnly.zig b/src/browser/webapi/DOMMatrixReadOnly.zig index ca39901a7..9da9975ca 100644 --- a/src/browser/webapi/DOMMatrixReadOnly.zig +++ b/src/browser/webapi/DOMMatrixReadOnly.zig @@ -627,7 +627,7 @@ pub fn inverse(self: *const DOMMatrixReadOnly, page: *Page) !*DOMMatrix { } pub fn toFloat32Array(self: *const DOMMatrixReadOnly, exec: *const js.Execution) !js.TypedArray(f32) { - const out = try exec.call_arena.alloc(f32, 16); + const out = try exec.local_arena.alloc(f32, 16); for (0..16) |i| { out[i] = @floatCast(self._m[i]); } @@ -635,7 +635,7 @@ pub fn toFloat32Array(self: *const DOMMatrixReadOnly, exec: *const js.Execution) } pub fn toFloat64Array(self: *const DOMMatrixReadOnly, exec: *const js.Execution) !js.TypedArray(f64) { - const out = try exec.call_arena.dupe(f64, &self._m); + const out = try exec.local_arena.dupe(f64, &self._m); return .{ .values = out }; } @@ -648,7 +648,7 @@ pub fn toString(self: *const DOMMatrixReadOnly, exec: *const js.Execution) ![]co return error.InvalidStateError; } } - return std.fmt.allocPrint(exec.call_arena, "matrix({d}, {d}, {d}, {d}, {d}, {d})", .{ + return std.fmt.allocPrint(exec.local_arena, "matrix({d}, {d}, {d}, {d}, {d}, {d})", .{ m[0], m[1], m[4], m[5], m[12], m[13], }); } @@ -657,7 +657,7 @@ pub fn toString(self: *const DOMMatrixReadOnly, exec: *const js.Execution) ![]co return error.InvalidStateError; } } - return std.fmt.allocPrint(exec.call_arena, "matrix3d({d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d})", .{ + return std.fmt.allocPrint(exec.local_arena, "matrix3d({d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d}, {d})", .{ m[0], m[1], m[2], m[3], m[4], m[5], m[6], m[7], m[8], m[9], m[10], m[11], diff --git a/src/browser/webapi/DataTransfer.zig b/src/browser/webapi/DataTransfer.zig index d53eb07d5..b8505d42c 100644 --- a/src/browser/webapi/DataTransfer.zig +++ b/src/browser/webapi/DataTransfer.zig @@ -102,7 +102,7 @@ fn normalizeFormat(arena: Allocator, format: []const u8) ![]const u8 { } pub fn getData(self: *const DataTransfer, format: []const u8, frame: *Frame) ![]const u8 { - const norm = try normalizeFormat(frame.call_arena, format); + const norm = try normalizeFormat(frame.local_arena, format); for (self._items.items) |it| { if (it._kind == .string and std.mem.eql(u8, it._type, norm)) { return it._payload.string; @@ -127,7 +127,7 @@ pub fn setData(self: *DataTransfer, format: []const u8, data: []const u8) !void pub fn clearData(self: *DataTransfer, format_: ?[]const u8, frame: *Frame) !void { if (format_) |format| { - const norm = try normalizeFormat(frame.call_arena, format); + const norm = try normalizeFormat(frame.local_arena, format); var i: usize = 0; while (i < self._items.items.len) { const it = self._items.items[i]; @@ -222,14 +222,14 @@ pub fn getTypes(self: *DataTransfer, frame: *Frame) ![][]const u8 { var has_files = false; for (self._items.items) |it| { switch (it._kind) { - .string => try out.append(frame.call_arena, it._type), + .string => try out.append(frame.local_arena, it._type), .file => has_files = true, } } if (has_files) { - try out.append(frame.call_arena, "Files"); + try out.append(frame.local_arena, "Files"); } - return out.toOwnedSlice(frame.call_arena); + return out.toOwnedSlice(frame.local_arena); } pub fn getDropEffect(self: *const DataTransfer) []const u8 { diff --git a/src/browser/webapi/Document.zig b/src/browser/webapi/Document.zig index 9f02ce08e..1913fd55f 100644 --- a/src/browser/webapi/Document.zig +++ b/src/browser/webapi/Document.zig @@ -171,7 +171,7 @@ pub fn setDomain(self: *Document, value: []const u8) !void { const doc_frame = self._frame orelse return error.SecurityError; const origin = doc_frame.origin orelse return error.SecurityError; - const arena = doc_frame.call_arena; + const arena = doc_frame.local_arena; const requested = if (idna.needsAscii(value)) try idna.toAscii(arena, value) else value; // Validate against the current effective domain. Once relaxed, @@ -193,7 +193,7 @@ pub fn setDomain(self: *Document, value: []const u8) !void { pub fn getCookie(_: *Document, frame: *Frame) ![]const u8 { var buf: std.ArrayList(u8) = .empty; - try frame._session.cookie_jar.forRequest(frame.url, buf.writer(frame.call_arena), .{ + try frame._session.cookie_jar.forRequest(frame.url, buf.writer(frame.local_arena), .{ .is_http = false, .is_navigation = true, }); @@ -738,7 +738,7 @@ pub fn elementFromPoint(self: *Document, x: f64, y: f64, frame: *Frame) !?*Eleme const root = self.asNode(); var stack: std.ArrayList(*Node) = .empty; - try stack.append(frame.call_arena, root); + try stack.append(frame.local_arena, root); var visibility_cache: Element.VisibilityCache = .{}; var preorder_index: f64 = 0; @@ -770,7 +770,7 @@ pub fn elementFromPoint(self: *Document, x: f64, y: f64, frame: *Frame) !?*Eleme // Add children to stack in reverse order so we process them in document order var child = node.lastChild(); while (child) |c| { - try stack.append(frame.call_arena, c); + try stack.append(frame.local_arena, c); child = c.previousSibling(); } } @@ -783,7 +783,7 @@ pub fn elementsFromPoint(self: *Document, x: f64, y: f64, frame: *Frame) ![]cons var current: ?*Element = (try self.elementFromPoint(x, y, frame)) orelse return &.{}; var result: std.ArrayList(*Element) = .empty; while (current) |el| { - try result.append(frame.call_arena, el); + try result.append(frame.local_arena, el); current = el.parentElement(); } return result.items; diff --git a/src/browser/webapi/DocumentFragment.zig b/src/browser/webapi/DocumentFragment.zig index da09e9bbc..ccb53f50a 100644 --- a/src/browser/webapi/DocumentFragment.zig +++ b/src/browser/webapi/DocumentFragment.zig @@ -222,7 +222,7 @@ pub const JsApi = struct { pub const innerHTML = bridge.accessor(_getInnerHTML, _setInnerHTML, .{ .ce_reactions = true }); fn _getInnerHTML(self: *DocumentFragment, frame: *Frame) ![]const u8 { - var buf = std.Io.Writer.Allocating.init(frame.call_arena); + var buf = std.Io.Writer.Allocating.init(frame.local_arena); try self.getInnerHTML(&buf.writer, frame); return buf.written(); } diff --git a/src/browser/webapi/Element.zig b/src/browser/webapi/Element.zig index f4c16641d..4530872a3 100644 --- a/src/browser/webapi/Element.zig +++ b/src/browser/webapi/Element.zig @@ -1280,7 +1280,7 @@ pub fn getClientRects(self: *Element, frame: *Frame) ![]DOMRect { if (!self.checkVisibilityCached(null, frame)) { return &.{}; } - const rects = try frame.call_arena.alloc(DOMRect, 1); + const rects = try frame.local_arena.alloc(DOMRect, 1); rects[0] = self.getBoundingClientRectForVisible(frame); return rects; } @@ -1924,14 +1924,16 @@ pub const JsApi = struct { pub const innerText = bridge.accessor(_innerText, Element.setInnerText, .{ .ce_reactions = true }); fn _innerText(self: *Element, frame: *Frame) ![]const u8 { - var buf = std.Io.Writer.Allocating.init(frame.call_arena); + var buf = std.Io.Writer.Allocating.init(frame.local_arena); try self.getInnerText(&buf.writer, frame); return buf.written(); } pub const outerHTML = bridge.accessor(_getOuterHTML, _setOuterHTML, .{ .ce_reactions = true }); fn _getOuterHTML(self: *Element, frame: *Frame) ![]const u8 { - var buf = std.Io.Writer.Allocating.init(frame.call_arena); + // local_arena: serialization is read-only and the returned string is + // converted to v8 before this call returns. No JS runs in between. + var buf = std.Io.Writer.Allocating.init(frame.local_arena); try self.getOuterHTML(&buf.writer, frame); return buf.written(); } @@ -1942,7 +1944,9 @@ pub const JsApi = struct { pub const innerHTML = bridge.accessor(_getInnerHTML, _setInnerHTML, .{ .ce_reactions = true }); fn _getInnerHTML(self: *Element, frame: *Frame) ![]const u8 { - var buf = std.Io.Writer.Allocating.init(frame.call_arena); + // local_arena: read-only serialization, result converted to v8 before + // returning; no JS runs in between. + var buf = std.Io.Writer.Allocating.init(frame.local_arena); try self.getInnerHTML(&buf.writer, frame); return buf.written(); } diff --git a/src/browser/webapi/HTMLDocument.zig b/src/browser/webapi/HTMLDocument.zig index 4d2ccb9c2..ed3f6282b 100644 --- a/src/browser/webapi/HTMLDocument.zig +++ b/src/browser/webapi/HTMLDocument.zig @@ -103,7 +103,7 @@ pub fn getTitle(self: *HTMLDocument, frame: *Frame) ![]const u8 { return ""; }; - var buf = std.Io.Writer.Allocating.init(frame.call_arena); + var buf = std.Io.Writer.Allocating.init(frame.local_arena); try title_element.asNode().getTextContent(&buf.writer); const text = buf.written(); @@ -114,7 +114,7 @@ pub fn getTitle(self: *HTMLDocument, frame: *Frame) ![]const u8 { var started = false; var in_whitespace = false; var result: std.ArrayList(u8) = .empty; - try result.ensureTotalCapacity(frame.call_arena, text.len); + try result.ensureTotalCapacity(frame.local_arena, text.len); for (text) |c| { const is_ascii_ws = c == ' ' or c == '\t' or c == '\n' or c == '\r' or c == '\x0C'; diff --git a/src/browser/webapi/IntersectionObserver.zig b/src/browser/webapi/IntersectionObserver.zig index 579a4192e..69bdfcd64 100644 --- a/src/browser/webapi/IntersectionObserver.zig +++ b/src/browser/webapi/IntersectionObserver.zig @@ -191,7 +191,7 @@ pub fn disconnect(self: *IntersectionObserver, frame: *Frame) void { } pub fn takeRecords(self: *IntersectionObserver, frame: *Frame) ![]*IntersectionObserverEntry { - const entries = try frame.call_arena.dupe(*IntersectionObserverEntry, self._pending_entries.items); + const entries = try frame.local_arena.dupe(*IntersectionObserverEntry, self._pending_entries.items); self._pending_entries.clearRetainingCapacity(); return entries; } diff --git a/src/browser/webapi/MutationObserver.zig b/src/browser/webapi/MutationObserver.zig index 5b696cc97..6d3dab74e 100644 --- a/src/browser/webapi/MutationObserver.zig +++ b/src/browser/webapi/MutationObserver.zig @@ -188,7 +188,7 @@ pub fn disconnect(self: *MutationObserver, frame: *Frame) void { } pub fn takeRecords(self: *MutationObserver, frame: *Frame) ![]*MutationRecord { - const records = try frame.call_arena.dupe(*MutationRecord, self._pending_records.items); + const records = try frame.local_arena.dupe(*MutationRecord, self._pending_records.items); self._pending_records.clearRetainingCapacity(); return records; } diff --git a/src/browser/webapi/Node.zig b/src/browser/webapi/Node.zig index e56209dd6..3cb562342 100644 --- a/src/browser/webapi/Node.zig +++ b/src/browser/webapi/Node.zig @@ -908,7 +908,7 @@ pub fn setData(self: *Node, data: []const u8, frame: *Frame) !void { pub fn normalize(self: *Node, frame: *Frame) !void { var buffer: std.ArrayList(u8) = .empty; - return self._normalize(frame.call_arena, &buffer, frame); + return self._normalize(frame.local_arena, &buffer, frame); } const CloneError = error{ @@ -1311,7 +1311,9 @@ pub const JsApi = struct { // cdata and attributes can return value directly, avoiding the copy switch (self._type) { .element, .document_fragment => { - var buf = std.Io.Writer.Allocating.init(frame.call_arena); + // local_arena: read-only text collection, result converted to + // v8 before returning; no JS runs in between. + var buf = std.Io.Writer.Allocating.init(frame.local_arena); try self.getTextContent(&buf.writer); return buf.written(); }, diff --git a/src/browser/webapi/Performance.zig b/src/browser/webapi/Performance.zig index 796376c04..b718f5aa5 100644 --- a/src/browser/webapi/Performance.zig +++ b/src/browser/webapi/Performance.zig @@ -215,11 +215,11 @@ pub fn getEntries(self: *const Performance) []*Entry { } pub fn getEntriesByType(self: *const Performance, entry_type: []const u8, exec: *const Execution) ![]const *Entry { - return filterEntriesByType(exec.call_arena, self._entries.items, entry_type); + return filterEntriesByType(exec.local_arena, self._entries.items, entry_type); } pub fn getEntriesByName(self: *const Performance, name: []const u8, entry_type: ?[]const u8, exec: *const Execution) ![]const *Entry { - return filterEntriesByName(exec.call_arena, self._entries.items, name, entry_type); + return filterEntriesByName(exec.local_arena, self._entries.items, name, entry_type); } // Also used by PerformanceObserver diff --git a/src/browser/webapi/PerformanceObserver.zig b/src/browser/webapi/PerformanceObserver.zig index 1c879e38a..c5f017e0b 100644 --- a/src/browser/webapi/PerformanceObserver.zig +++ b/src/browser/webapi/PerformanceObserver.zig @@ -216,11 +216,11 @@ pub const EntryList = struct { } pub fn getEntriesByType(self: *const EntryList, entry_type: []const u8, exec: *Execution) ![]const *Performance.Entry { - return Performance.filterEntriesByType(exec.call_arena, self._entries, entry_type); + return Performance.filterEntriesByType(exec.local_arena, self._entries, entry_type); } pub fn getEntriesByName(self: *const EntryList, name: []const u8, entry_type: ?[]const u8, exec: *Execution) ![]const *Performance.Entry { - return Performance.filterEntriesByName(exec.call_arena, self._entries, name, entry_type); + return Performance.filterEntriesByName(exec.local_arena, self._entries, name, entry_type); } pub const JsApi = struct { diff --git a/src/browser/webapi/Range.zig b/src/browser/webapi/Range.zig index a617125e3..03994dfc5 100644 --- a/src/browser/webapi/Range.zig +++ b/src/browser/webapi/Range.zig @@ -569,7 +569,7 @@ pub fn createContextualFragment(self: *const Range, html: []const u8, frame: *Fr pub fn toString(self: *const Range, frame: *Frame) ![]const u8 { // Simplified implementation: just extract text content - var buf = std.Io.Writer.Allocating.init(frame.call_arena); + var buf = std.Io.Writer.Allocating.init(frame.local_arena); try self.writeTextContent(&buf.writer); return buf.written(); } diff --git a/src/browser/webapi/SubtleCrypto.zig b/src/browser/webapi/SubtleCrypto.zig index 4e6f48ef6..662a525cd 100644 --- a/src/browser/webapi/SubtleCrypto.zig +++ b/src/browser/webapi/SubtleCrypto.zig @@ -185,7 +185,7 @@ pub fn importKey( const k = jwk.k orelse { return local.rejectPromise(.{ .dom_exception = .{ .err = error.DataError } }); }; - break :blk common.base64Decode(exec.call_arena, k) catch |err| switch (err) { + break :blk common.base64Decode(exec.local_arena, k) catch |err| switch (err) { error.DataError => return local.rejectPromise(.{ .dom_exception = .{ .err = error.DataError } }), else => |e| return e, }; @@ -309,13 +309,13 @@ fn exportJwk(key: *CryptoKey, exec: *const Execution) !js.Promise { // The `alg` registry value depends on the algorithm and key length. const alg: []const u8 = switch (key._type) { - .aes => try std.fmt.allocPrint(exec.call_arena, "A{d}{s}", .{ + .aes => try std.fmt.allocPrint(exec.local_arena, "A{d}{s}", .{ key._key.len * 8, key._algorithm.name[4..], // strip "AES-" }), .hmac => blk: { const hash: []const u8 = key._algorithm.hash orelse "SHA-"; - break :blk try std.fmt.allocPrint(exec.call_arena, "HS{s}", .{hash[4..]}); // strip "SHA-" + break :blk try std.fmt.allocPrint(exec.local_arena, "HS{s}", .{hash[4..]}); // strip "SHA-" }, else => { log.warn(.not_implemented, "SubtleCrypto.exportKey", .{ .format = "jwk", .type = key._type }); @@ -324,7 +324,7 @@ fn exportJwk(key: *CryptoKey, exec: *const Execution) !js.Promise { }; return local.resolvePromise(JwkSecret{ - .k = try common.base64Encode(exec.call_arena, key._key), + .k = try common.base64Encode(exec.local_arena, key._key), .alg = alg, .ext = key._extractable, .key_ops = try key.getUsages(exec), diff --git a/src/browser/webapi/URL.zig b/src/browser/webapi/URL.zig index dbffa97f6..ace7d6796 100644 --- a/src/browser/webapi/URL.zig +++ b/src/browser/webapi/URL.zig @@ -174,7 +174,7 @@ pub fn getSearch(self: *const URL, exec: *const Execution) ![]const u8 { return ""; } - var buf = std.Io.Writer.Allocating.init(exec.call_arena); + var buf = std.Io.Writer.Allocating.init(exec.local_arena); try buf.writer.writeByte('?'); try search_params.toString(&buf.writer); return buf.written(); @@ -262,7 +262,7 @@ pub fn getOrigin(self: *const URL, exec: *const Execution) ![]const u8 { const origin = U.url_get_origin(self._url); defer origin.deinit(); - return exec.call_arena.dupe(u8, origin.slice()); + return exec.local_arena.dupe(u8, origin.slice()); } pub fn setHref(self: *URL, value: []const u8, exec: *const Execution) !void { @@ -288,7 +288,7 @@ pub fn toString(self: *const URL, exec: *const Execution) ![]const u8 { if (search_params.getSize() == 0) { U.url_set_query_to_null(self._url); } else { - var buf = std.Io.Writer.Allocating.init(exec.call_arena); + var buf = std.Io.Writer.Allocating.init(exec.local_arena); defer buf.deinit(); try search_params.toString(&buf.writer); const query = buf.written(); diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index c1616f5b0..31f79c0c7 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -696,11 +696,11 @@ pub fn postMessage(self: *Window, message: js.Value, target_origin: ?[]const u8, const base64 = @import("encoding/base64.zig"); pub fn btoa(_: *const Window, input: base64.BinInput, frame: *Frame) ![]const u8 { - return base64.encode(frame.call_arena, input); + return base64.encode(frame.local_arena, input); } pub fn atob(_: *const Window, input: base64.BinInput, frame: *Frame) !js.String.OneByte { - const decoded = try base64.decode(frame.call_arena, input); + const decoded = try base64.decode(frame.local_arena, input); return .{ .bytes = decoded }; } diff --git a/src/browser/webapi/WorkerGlobalScope.zig b/src/browser/webapi/WorkerGlobalScope.zig index b860996b4..ab965648e 100644 --- a/src/browser/webapi/WorkerGlobalScope.zig +++ b/src/browser/webapi/WorkerGlobalScope.zig @@ -70,6 +70,7 @@ _http_owner: HttpClient.Owner = .{}, arena: Allocator, call_arena: Allocator, +local_arena: Allocator, url: [:0]const u8, // Same-origin constraint: a worker's origin is inherited from its parent frame. origin: ?[]const u8 = null, @@ -129,6 +130,9 @@ pub fn init( const call_arena = try session.getArena(.small, "WorkerGlobalScope.call_arena"); errdefer session.releaseArena(call_arena); + const local_arena = try session.getArena(.small, "WorkerGlobalScope.local_arena"); + errdefer session.releaseArena(local_arena); + const factory = frame._factory; const self = try factory.eventTargetWithAllocator(arena, WorkerGlobalScope{ .url = url, @@ -136,6 +140,7 @@ pub fn init( .origin = frame.origin, .js = undefined, .call_arena = call_arena, + .local_arena = local_arena, ._frame = frame, ._page = frame._page, ._session = session, @@ -162,6 +167,7 @@ pub fn init( self.js = try session.browser.env.createWorkerContext(self, .{ .call_arena = call_arena, + .local_arena = local_arena, .identity_arena = arena, .identity = &self._identity, }); @@ -191,6 +197,7 @@ pub fn deinit(self: *WorkerGlobalScope) void { } browser.env.destroyContext(self.js); session.releaseArena(self.call_arena); + session.releaseArena(self.local_arena); } pub fn base(self: *const WorkerGlobalScope) [:0]const u8 { diff --git a/src/browser/webapi/XMLSerializer.zig b/src/browser/webapi/XMLSerializer.zig index b7f955aec..683afa4ac 100644 --- a/src/browser/webapi/XMLSerializer.zig +++ b/src/browser/webapi/XMLSerializer.zig @@ -34,7 +34,7 @@ pub fn init() XMLSerializer { pub fn serializeToString(self: *const XMLSerializer, node: *Node, frame: *Frame) ![]const u8 { _ = self; - var buf = std.Io.Writer.Allocating.init(frame.call_arena); + var buf = std.Io.Writer.Allocating.init(frame.local_arena); if (node.is(Node.Document)) |doc| { try dump.root(doc, .{ .shadow = .skip }, &buf.writer, frame); } else { diff --git a/src/browser/webapi/canvas/OffscreenCanvasRenderingContext2D.zig b/src/browser/webapi/canvas/OffscreenCanvasRenderingContext2D.zig index de59a2e13..46ff54ebc 100644 --- a/src/browser/webapi/canvas/OffscreenCanvasRenderingContext2D.zig +++ b/src/browser/webapi/canvas/OffscreenCanvasRenderingContext2D.zig @@ -34,7 +34,7 @@ const OffscreenCanvasRenderingContext2D = @This(); _fill_style: color.RGBA = color.RGBA.Named.black, pub fn getFillStyle(self: *const OffscreenCanvasRenderingContext2D, exec: *Execution) ![]const u8 { - var w = std.Io.Writer.Allocating.init(exec.call_arena); + var w = std.Io.Writer.Allocating.init(exec.local_arena); try self._fill_style.format(&w.writer); return w.written(); } diff --git a/src/browser/webapi/collections/DOMTokenList.zig b/src/browser/webapi/collections/DOMTokenList.zig index 88acb1667..ad1114f6e 100644 --- a/src/browser/webapi/collections/DOMTokenList.zig +++ b/src/browser/webapi/collections/DOMTokenList.zig @@ -45,7 +45,7 @@ const Lookup = std.StringArrayHashMapUnmanaged(void); const WHITESPACE = " \t\n\r\x0C"; pub fn length(self: *const DOMTokenList, frame: *Frame) !u32 { - const tokens = try self.getTokens(frame.call_arena); + const tokens = try self.getTokens(frame.local_arena); return @intCast(tokens.count()); } @@ -53,7 +53,7 @@ pub fn length(self: *const DOMTokenList, frame: *Frame) !u32 { pub fn item(self: *const DOMTokenList, index: usize, frame: *Frame) !?[]const u8 { var i: usize = 0; - const allocator = frame.call_arena; + const allocator = frame.local_arena; var seen: std.StringArrayHashMapUnmanaged(void) = .empty; var it = std.mem.tokenizeAny(u8, self.getValue(), WHITESPACE); @@ -84,7 +84,7 @@ pub fn add(self: *DOMTokenList, tokens: []const []const u8, frame: *Frame) !void try validateToken(token); } - const allocator = frame.call_arena; + const allocator = frame.local_arena; var lookup = try self.getTokens(allocator); try lookup.ensureUnusedCapacity(allocator, tokens.len); @@ -100,7 +100,7 @@ pub fn remove(self: *DOMTokenList, tokens: []const []const u8, frame: *Frame) !v try validateToken(token); } - var lookup = try self.getTokens(frame.call_arena); + var lookup = try self.getTokens(frame.local_arena); for (tokens) |token| { _ = lookup.orderedRemove(token); } @@ -151,8 +151,8 @@ pub fn replace(self: *DOMTokenList, old_token: []const u8, new_token: []const u8 return error.InvalidCharacterError; } - const allocator = frame.call_arena; - var lookup = try self.getTokens(frame.call_arena); + const allocator = frame.local_arena; + var lookup = try self.getTokens(allocator); // Check if old_token exists if (!lookup.contains(old_token)) { diff --git a/src/browser/webapi/crypto/AES.zig b/src/browser/webapi/crypto/AES.zig index 0edc4c22c..3114671ef 100644 --- a/src/browser/webapi/crypto/AES.zig +++ b/src/browser/webapi/crypto/AES.zig @@ -260,7 +260,7 @@ fn ctr( std.mem.writeInt(u128, &wrapped, readBlock(counter) & ~mask, .big); const second = try cbcOrCtr(cipher, key, &wrapped, data[split..], encrypting, false, exec); - const out = try exec.call_arena.alloc(u8, data.len); + const out = try exec.local_arena.alloc(u8, data.len); @memcpy(out[0..split], first); @memcpy(out[split..], second); return out; @@ -308,7 +308,7 @@ fn cbcOrCtr( } // Block ciphers may emit up to one extra block on top of the input. - const out = try exec.call_arena.alloc(u8, data.len + 16); + const out = try exec.local_arena.alloc(u8, data.len + 16); var out_len: c_int = 0; if (cipherUpdate(ctx, out.ptr, &out_len, @ptrCast(data.ptr), @intCast(data.len), encrypting) != 1) { return error.OperationError; @@ -365,7 +365,7 @@ fn gcm( } if (encrypting) { - const out = try exec.call_arena.alloc(u8, data.len + tag_len); + const out = try exec.local_arena.alloc(u8, data.len + tag_len); var out_len: c_int = 0; if (data.len > 0 and cipherUpdate(ctx, out.ptr, &out_len, @ptrCast(data.ptr), @intCast(data.len), true) != 1) { return error.OperationError; @@ -391,7 +391,7 @@ fn gcm( return error.OperationError; } - const out = try exec.call_arena.alloc(u8, ct.len + 16); + const out = try exec.local_arena.alloc(u8, ct.len + 16); var out_len: c_int = 0; if (ct.len > 0 and cipherUpdate(ctx, out.ptr, &out_len, @ptrCast(ct.ptr), @intCast(ct.len), false) != 1) { return error.OperationError; diff --git a/src/browser/webapi/crypto/EC.zig b/src/browser/webapi/crypto/EC.zig index afa38259b..ab1f753ba 100644 --- a/src/browser/webapi/crypto/EC.zig +++ b/src/browser/webapi/crypto/EC.zig @@ -236,7 +236,7 @@ pub fn deriveBits( if (crypto.EVP_PKEY_derive(ctx, null, &secret_len) != 1 or secret_len == 0) { return error.OperationError; } - const secret = try exec.call_arena.alloc(u8, secret_len); + const secret = try exec.local_arena.alloc(u8, secret_len); if (crypto.EVP_PKEY_derive(ctx, secret.ptr, &secret_len) != 1) { return error.OperationError; } diff --git a/src/browser/webapi/crypto/HMAC.zig b/src/browser/webapi/crypto/HMAC.zig index a897a02db..625ff7cbf 100644 --- a/src/browser/webapi/crypto/HMAC.zig +++ b/src/browser/webapi/crypto/HMAC.zig @@ -151,7 +151,7 @@ pub fn sign( return resolver.promise(); } - const buffer = try exec.call_arena.alloc(u8, crypto.EVP_MD_size(crypto_key.getDigest())); + const buffer = try exec.local_arena.alloc(u8, crypto.EVP_MD_size(crypto_key.getDigest())); var out_len: u32 = 0; // Try to sign. _ = crypto.HMAC( @@ -163,7 +163,6 @@ pub fn sign( buffer.ptr, &out_len, ) orelse { - exec.call_arena.free(buffer); // Failure. resolver.rejectError("HMAC.sign", .{ .dom_exception = .{ .err = error.InvalidAccessError } }); return resolver.promise(); diff --git a/src/browser/webapi/crypto/KDF.zig b/src/browser/webapi/crypto/KDF.zig index 98aa87b63..5f312f1df 100644 --- a/src/browser/webapi/crypto/KDF.zig +++ b/src/browser/webapi/crypto/KDF.zig @@ -64,7 +64,7 @@ pub fn pbkdf2( ) Error![]const u8 { try checkBaseKey(base_key, "PBKDF2", usage_ok); const digest = crypto.findDigest(params.hash.name()) catch return error.NotSupported; - const out = try exec.call_arena.alloc(u8, try outputLen(length)); + const out = try exec.local_arena.alloc(u8, try outputLen(length)); // A zero-length derivation is valid and yields an empty buffer; the C // routines reject a zero output length, so short-circuit here. if (out.len == 0) { @@ -97,7 +97,7 @@ pub fn hkdf( ) Error![]const u8 { try checkBaseKey(base_key, "HKDF", usage_ok); const digest = crypto.findDigest(params.hash.name()) catch return error.NotSupported; - const out = try exec.call_arena.alloc(u8, try outputLen(length)); + const out = try exec.local_arena.alloc(u8, try outputLen(length)); // A zero-length derivation is valid and yields an empty buffer; the C // routines reject a zero output length, so short-circuit here. if (out.len == 0) { diff --git a/src/browser/webapi/crypto/X25519.zig b/src/browser/webapi/crypto/X25519.zig index 4b8b74e17..631b6687c 100644 --- a/src/browser/webapi/crypto/X25519.zig +++ b/src/browser/webapi/crypto/X25519.zig @@ -138,8 +138,8 @@ pub fn deriveBits( return error.Internal; } - const derived_key = try exec.call_arena.alloc(u8, 32); - errdefer exec.call_arena.free(derived_key); + const derived_key = try exec.local_arena.alloc(u8, 32); + errdefer exec.local_arena.free(derived_key); var out_key_len: usize = derived_key.len; const result = crypto.EVP_PKEY_derive(ctx, derived_key.ptr, &out_key_len); diff --git a/src/browser/webapi/css/CSSStyleDeclaration.zig b/src/browser/webapi/css/CSSStyleDeclaration.zig index 393243187..c0f2f7f49 100644 --- a/src/browser/webapi/css/CSSStyleDeclaration.zig +++ b/src/browser/webapi/css/CSSStyleDeclaration.zig @@ -151,7 +151,7 @@ fn setPropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, value: const normalized = normalizePropertyName(property_name, &frame.buf); // Normalize the value for canonical serialization - const normalized_value = try normalizePropertyValue(frame.call_arena, normalized, value); + const normalized_value = try normalizePropertyValue(frame.local_arena, normalized, value); // Find existing property if (self.findProperty(.wrap(normalized))) |existing| { @@ -206,7 +206,7 @@ pub fn setFloat(self: *CSSStyleDeclaration, value_: ?[]const u8, frame: *Frame) } pub fn getCssText(self: *const CSSStyleDeclaration, frame: *Frame) ![]const u8 { - var buf = std.Io.Writer.Allocating.init(frame.call_arena); + var buf = std.Io.Writer.Allocating.init(frame.local_arena); try self.format(&buf.writer); return buf.written(); } diff --git a/src/browser/webapi/css/CSSStyleRule.zig b/src/browser/webapi/css/CSSStyleRule.zig index e3450b2b4..f28f0d011 100644 --- a/src/browser/webapi/css/CSSStyleRule.zig +++ b/src/browser/webapi/css/CSSStyleRule.zig @@ -38,7 +38,7 @@ pub fn getStyle(self: *CSSStyleRule, frame: *Frame) !*CSSStyleProperties { pub fn getCssText(self: *CSSStyleRule, frame: *Frame) ![]const u8 { const style_props = try self.getStyle(frame); const style = style_props.asCSSStyleDeclaration(); - var buf = std.Io.Writer.Allocating.init(frame.call_arena); + var buf = std.Io.Writer.Allocating.init(frame.local_arena); try buf.writer.print("{s} {{ ", .{self._selector_text}); try style.format(&buf.writer); try buf.writer.writeAll(" }"); diff --git a/src/browser/webapi/css/CSSStyleSheet.zig b/src/browser/webapi/css/CSSStyleSheet.zig index c21c5d000..dd08118ef 100644 --- a/src/browser/webapi/css/CSSStyleSheet.zig +++ b/src/browser/webapi/css/CSSStyleSheet.zig @@ -66,7 +66,7 @@ pub fn getCssRules(self: *CSSStyleSheet, frame: *Frame) !*CSSRuleList { if (self.getOwnerNode()) |owner| { if (owner.is(Element.Html.Style)) |style| { - const text = try style.asNode().getTextContentAlloc(frame.call_arena); + const text = try style.asNode().getTextContentAlloc(frame.local_arena); try self.replaceSync(text, frame); } } diff --git a/src/browser/webapi/element/DOMStringMap.zig b/src/browser/webapi/element/DOMStringMap.zig index 7fd23f34d..a7fff6551 100644 --- a/src/browser/webapi/element/DOMStringMap.zig +++ b/src/browser/webapi/element/DOMStringMap.zig @@ -32,17 +32,17 @@ const DOMStringMap = @This(); _element: *Element, fn getProperty(self: *DOMStringMap, name: String, frame: *Frame) !?String { - const attr_name = try camelToKebab(frame.call_arena, name); + const attr_name = try camelToKebab(frame.local_arena, name); return try self._element.getAttribute(attr_name, frame); } fn setProperty(self: *DOMStringMap, name: String, value: String, frame: *Frame) !void { - const attr_name = try camelToKebab(frame.call_arena, name); + const attr_name = try camelToKebab(frame.local_arena, name); return self._element.setAttributeSafe(attr_name, value, frame); } fn deleteProperty(self: *DOMStringMap, name: String, frame: *Frame) !void { - const attr_name = try camelToKebab(frame.call_arena, name); + const attr_name = try camelToKebab(frame.local_arena, name); try self._element.removeAttribute(attr_name, frame); } diff --git a/src/browser/webapi/element/Html.zig b/src/browser/webapi/element/Html.zig index eb55b4bc6..d643a43c4 100644 --- a/src/browser/webapi/element/Html.zig +++ b/src/browser/webapi/element/Html.zig @@ -1598,7 +1598,7 @@ fn mergeTextNodes(left_node: *Node, right_node: *Node, frame: *Frame) !bool { // both nodes are Text nodes - const merged = try std.mem.concat(frame.call_arena, u8, &.{ left.getData().str(), right.getData().str() }); + const merged = try std.mem.concat(frame.local_arena, u8, &.{ left.getData().str(), right.getData().str() }); // set the left node to the merged value try left.setData(merged, frame); @@ -1610,7 +1610,7 @@ fn mergeTextNodes(left_node: *Node, right_node: *Node, frame: *Frame) !bool { } fn renderedTextFragment(value: []const u8, frame: *Frame) ![]Node.NodeOrText { - const arena = frame.call_arena; + const arena = frame.local_arena; var nodes: std.ArrayList(Node.NodeOrText) = .empty; var rest = value; diff --git a/src/browser/webapi/element/html/Anchor.zig b/src/browser/webapi/element/html/Anchor.zig index 0a55c457f..8d36299d5 100644 --- a/src/browser/webapi/element/html/Anchor.zig +++ b/src/browser/webapi/element/html/Anchor.zig @@ -64,7 +64,7 @@ pub fn setTarget(self: *Anchor, value: []const u8, frame: *Frame) !void { pub fn getOrigin(self: *Anchor, frame: *Frame) ![]const u8 { const href = try getResolvedHref(self, frame) orelse return ""; - return (try URL.getOrigin(frame.call_arena, href)) orelse "null"; + return (try URL.getOrigin(frame.local_arena, href)) orelse "null"; } pub fn getHost(self: *Anchor, frame: *Frame) ![]const u8 { diff --git a/src/browser/webapi/element/html/Area.zig b/src/browser/webapi/element/html/Area.zig index b58f5fe44..0deaa70ad 100644 --- a/src/browser/webapi/element/html/Area.zig +++ b/src/browser/webapi/element/html/Area.zig @@ -51,7 +51,7 @@ pub fn setHref(self: *Area, value: []const u8, frame: *Frame) !void { pub fn getOrigin(self: *Area, frame: *Frame) ![]const u8 { const href = try getResolvedHref(self, frame) orelse return ""; - return (try URL.getOrigin(frame.call_arena, href)) orelse "null"; + return (try URL.getOrigin(frame.local_arena, href)) orelse "null"; } pub fn getHost(self: *Area, frame: *Frame) ![]const u8 { diff --git a/src/browser/webapi/element/html/Base.zig b/src/browser/webapi/element/html/Base.zig index ee47c0453..3e25dc8f4 100644 --- a/src/browser/webapi/element/html/Base.zig +++ b/src/browser/webapi/element/html/Base.zig @@ -32,7 +32,7 @@ pub fn getHref(self: *Base, frame: *Frame) ![]const u8 { return ""; } const owner = element.asConstNode().ownerFrame(frame); - return URL.resolve(frame.call_arena, owner.url, href, .{}); + return URL.resolve(frame.local_arena, owner.url, href, .{}); } pub fn setHref(self: *Base, value: []const u8, frame: *Frame) !void { diff --git a/src/browser/webapi/element/html/Canvas.zig b/src/browser/webapi/element/html/Canvas.zig index 05acdee2f..775afa65f 100644 --- a/src/browser/webapi/element/html/Canvas.zig +++ b/src/browser/webapi/element/html/Canvas.zig @@ -49,7 +49,7 @@ pub fn getWidth(self: *const Canvas) u32 { } pub fn setWidth(self: *Canvas, value: u32, frame: *Frame) !void { - const str = try std.fmt.allocPrint(frame.call_arena, "{d}", .{value}); + const str = try std.fmt.allocPrint(frame.local_arena, "{d}", .{value}); try self.asElement().setAttributeSafe(comptime .wrap("width"), .wrap(str), frame); } @@ -59,7 +59,7 @@ pub fn getHeight(self: *const Canvas) u32 { } pub fn setHeight(self: *Canvas, value: u32, frame: *Frame) !void { - const str = try std.fmt.allocPrint(frame.call_arena, "{d}", .{value}); + const str = try std.fmt.allocPrint(frame.local_arena, "{d}", .{value}); try self.asElement().setAttributeSafe(comptime .wrap("height"), .wrap(str), frame); } diff --git a/src/browser/webapi/element/html/Input.zig b/src/browser/webapi/element/html/Input.zig index c665daa96..3518f0d2a 100644 --- a/src/browser/webapi/element/html/Input.zig +++ b/src/browser/webapi/element/html/Input.zig @@ -302,7 +302,7 @@ pub fn getValueForJS(self: *const Input, frame: *Frame) ![]const u8 { if (fl._files.len == 0) { return ""; } - return try std.fmt.allocPrint(frame.call_arena, "C:\\fakepath\\{s}", .{fl._files[0]._name}); + return try std.fmt.allocPrint(frame.local_arena, "C:\\fakepath\\{s}", .{fl._files[0]._name}); } pub fn getValidationMessage(self: *Input, frame: *Frame) []const u8 { diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index 0c818caa8..1f76c9ab6 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -691,10 +691,14 @@ pub const BrowserContext = struct { const call_arena = try browser.arena_pool.acquire(.tiny, "IsolatedWorld.call_arena"); errdefer browser.arena_pool.release(call_arena); + const local_arena = try browser.arena_pool.acquire(.tiny, "IsolatedWorld.local_arena"); + errdefer browser.arena_pool.release(local_arena); + const world = try arena.create(IsolatedWorld); world.* = .{ .arena = arena, .call_arena = call_arena, + .local_arena = local_arena, .context = null, .browser = browser, .name = try arena.dupe(u8, world_name), @@ -1118,6 +1122,7 @@ const ScriptOnNewDocument = struct { const IsolatedWorld = struct { arena: Allocator, call_arena: Allocator, + local_arena: Allocator, browser: *Browser, name: []const u8, context: ?*js.Context = null, @@ -1130,6 +1135,7 @@ const IsolatedWorld = struct { pub fn deinit(self: *IsolatedWorld) void { self.removeContext(); self.browser.arena_pool.release(self.call_arena); + self.browser.arena_pool.release(self.local_arena); self.browser.arena_pool.release(self.arena); } @@ -1155,6 +1161,7 @@ const IsolatedWorld = struct { .identity = &self.identity, .identity_arena = self.arena, .call_arena = self.call_arena, + .local_arena = self.local_arena, .debug_name = "IsolatedContext", }); self.context = ctx; From 7e353927b995d11b20411989076c1c5e0f66b081 Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Thu, 2 Jul 2026 16:33:58 +0200 Subject: [PATCH 16/49] cache the computed-style object per element window.getComputedStyle allocated a CSSStyleProperties + CSSStyleDeclaration pair per call; pages polling it grew the page arena without bound. The computed variant is a stateless lazy view, so hand out one per element from a frame-level map. This also matches Chrome, where repeated calls return the identical object. Pseudo-element requests keep the fresh-object path. --- src/browser/Frame.zig | 4 ++++ src/browser/tests/element/styles.html | 8 ++++++++ src/browser/webapi/Window.zig | 9 ++++++++- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index f903f3e1d..4b44f1576 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -119,6 +119,10 @@ _attribute_named_node_map_lookup: std.AutoHashMapUnmanaged(usize, *Element.Attri // Lazily-created style, classList, and dataset objects. Only stored for elements // that actually access these features via JavaScript, saving 24 bytes per element. _element_styles: Element.StyleLookup = .empty, +// Computed-style views handed out by window.getComputedStyle. The computed +// variant is a stateless lazy view, so one per element suffices — and Chrome +// returns the same object for repeated calls, so identity is also conformance. +_element_computed_styles: Element.StyleLookup = .empty, _element_datasets: Element.DatasetLookup = .empty, _element_class_lists: Element.ClassListLookup = .empty, _element_rel_lists: Element.RelListLookup = .empty, diff --git a/src/browser/tests/element/styles.html b/src/browser/tests/element/styles.html index b4e81aa2b..7418d0ef3 100644 --- a/src/browser/tests/element/styles.html +++ b/src/browser/tests/element/styles.html @@ -216,6 +216,14 @@ document.body.appendChild(plainDiv); testing.expectEqual('block', window.getComputedStyle(plainDiv).getPropertyValue('display')); + // Repeated calls return the same object (as in Chrome); distinct per element. + testing.expectTrue(window.getComputedStyle(div) === window.getComputedStyle(div)); + testing.expectTrue(window.getComputedStyle(div) === cs); + testing.expectFalse(window.getComputedStyle(div) === window.getComputedStyle(plainDiv)); + // The cached object stays a live view. + div.style.setProperty('text-transform', 'lowercase'); + testing.expectEqual('lowercase', window.getComputedStyle(div).getPropertyValue('text-transform')); + // A normal declaration must not override an earlier !important one, and the // computed and inline (el.style) paths must agree on the resolved value. const impDiv = document.createElement('div'); diff --git a/src/browser/webapi/Window.zig b/src/browser/webapi/Window.zig index 7a488fac7..e74077d00 100644 --- a/src/browser/webapi/Window.zig +++ b/src/browser/webapi/Window.zig @@ -506,9 +506,16 @@ pub fn getComputedStyle(_: *const Window, element: *Element, pseudo_element: ?[] if (pseudo_element) |pe| { if (pe.len != 0) { log.warn(.not_implemented, "window.GetComputedStyle", .{ .pseudo_element = pe }); + // Chrome hands out a distinct object per pseudo-element, so these + // can't share the per-element cache entry. + return CSSStyleProperties.init(element, true, frame); } } - return CSSStyleProperties.init(element, true, frame); + const gop = try frame._element_computed_styles.getOrPut(frame.arena, element); + if (!gop.found_existing) { + gop.value_ptr.* = try CSSStyleProperties.init(element, true, frame); + } + return gop.value_ptr.*; } // window.open(url?, target?, features?) — v1 scope: From 656e2ee495f3c132e81c501a72ad8f5544b2b12d Mon Sep 17 00:00:00 2001 From: Pierre Tachoire Date: Thu, 2 Jul 2026 17:05:12 +0200 Subject: [PATCH 17/49] frame: emit networkIdle lifecycle events for child frames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Puppeteer's networkidle0 requires the networkIdle/networkAlmostIdle lifecycle events on every frame that started loading, like Chrome emits them — not just the root frame. Run the idle-notification checks recursively over the frame tree in Runner._tick. Fixes goto({waitUntil: 'networkidle0'}) timing out on pages with iframes (e.g. reddit.com post pages). --- src/browser/Frame.zig | 21 +++++++++++++++ src/browser/Runner.zig | 31 ++++++++++++++++++----- src/browser/tests/runner/iframe_idle.html | 4 +++ 3 files changed, 50 insertions(+), 6 deletions(-) create mode 100644 src/browser/tests/runner/iframe_idle.html diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index f903f3e1d..a4e5abd04 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -2188,6 +2188,27 @@ pub fn scheduleCustomElementBackupDrain(self: *Frame) !void { try self.js.queueCustomElementBackupDrain(); } +// Run the network-idle notification checks for this frame and, recursively, +// its child frames. CDP clients (e.g. puppeteer's networkidle0) expect the +// networkIdle/networkAlmostIdle lifecycle events on every frame, like Chrome +// emits them, not just on the root frame. +pub fn checkIdleNotifications(self: *Frame, total_http_activity: usize) void { + switch (self._parse_state) { + .html, .complete => { + if (self._notified_network_almost_idle.check(total_http_activity <= 2)) { + self.notifyNetworkAlmostIdle(); + } + if (self._notified_network_idle.check(total_http_activity == 0)) { + self.notifyNetworkIdle(); + } + }, + else => {}, + } + for (self.child_frames.items) |child| { + child.checkIdleNotifications(total_http_activity); + } +} + pub fn notifyNetworkIdle(self: *Frame) void { lp.assert(self._notified_network_idle == .done, "Frame.notifyNetworkIdle", .{}); self._session.notification.dispatch(.frame_network_idle, &.{ diff --git a/src/browser/Runner.zig b/src/browser/Runner.zig index d07c1b31f..bcc749db9 100644 --- a/src/browser/Runner.zig +++ b/src/browser/Runner.zig @@ -263,12 +263,7 @@ fn _tick(self: *Runner, comptime is_cdp: bool, timeout_ms: u32, conditions: []Wa } }, .html, .complete => { - if (frame._notified_network_almost_idle.check(total_http_activity <= 2)) { - frame.notifyNetworkAlmostIdle(); - } - if (frame._notified_network_idle.check(total_http_activity == 0)) { - frame.notifyNetworkIdle(); - } + frame.checkIdleNotifications(total_http_activity); const met = switch (condition.until) { .done => is_done, @@ -473,3 +468,27 @@ test "Runner: waitForScript" { var runner = page.session.runner(.{}); try runner.waitForScript(page.frame_id, "document.querySelector('#sel1')", 10); } + +test "Runner: networkidle notifies child frames" { + const page = try testing.pageTest("runner/iframe_idle.html", .{}); + defer page.close(); + + var runner = page.session.runner(.{}); + const frame = page.frame().?; + try testing.expectEqual(2, frame.child_frames.items.len); + + // A `.networkidle` wait resolves via `is_done` once the page is fully + // idle, which can happen before the 500ms idle-notification hold. Keep + // ticking (like the CDP serve loop does) until the notifications fire. + var attempts: usize = 0; + while (frame._notified_network_idle != .done and attempts < 50) : (attempts += 1) { + _ = try runner.tickForFrame(page.frame_id, 20, .{ .until = .networkidle }); + std.Thread.sleep(25 * std.time.ns_per_ms); + } + + try testing.expectEqual(true, frame._notified_network_idle == .done); + for (frame.child_frames.items) |child| { + try testing.expectEqual(true, child._notified_network_almost_idle == .done); + try testing.expectEqual(true, child._notified_network_idle == .done); + } +} diff --git a/src/browser/tests/runner/iframe_idle.html b/src/browser/tests/runner/iframe_idle.html new file mode 100644 index 000000000..d35072d95 --- /dev/null +++ b/src/browser/tests/runner/iframe_idle.html @@ -0,0 +1,4 @@ + + + + From f8934f575b8e219484ded2afd89ce07004ab6270 Mon Sep 17 00:00:00 2001 From: Dustin Persek Date: Thu, 2 Jul 2026 17:43:04 -0400 Subject: [PATCH 18/49] Fix charset unicode HTML prescan fallback --- src/browser/Frame.zig | 9 ++++++- src/browser/tests/page/encoding.html | 24 +++++++++++++++++++ .../tests/page/encoding/unicode_label.html | 5 ++++ 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 src/browser/tests/page/encoding/unicode_label.html diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 7cde1c6c8..51f6f869c 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -1340,6 +1340,10 @@ fn urlBasename(arena: Allocator, url: []const u8) !?[]const u8 { return try arena.dupe(u8, name); } +fn isUtf16Encoding(charset: []const u8) bool { + return std.mem.eql(u8, charset, "UTF-16LE") or std.mem.eql(u8, charset, "UTF-16BE"); +} + fn frameDataCallback(response: HttpClient.Response, data: []const u8) !void { var self: *Frame = @ptrCast(@alignCast(response.ctx)); @@ -1355,8 +1359,10 @@ fn frameDataCallback(response: HttpClient.Response, data: []const u8) !void { // If the HTTP Content-Type header didn't specify a charset and this is HTML, // prescan the first 1024 bytes for a declaration. + var html_prescan_found_charset = false; if (mime.content_type == .text_html and mime.is_default_charset) { if (Mime.prescanCharset(data)) |charset| { + html_prescan_found_charset = true; if (charset.len <= 40) { @memcpy(mime.charset[0..charset.len], charset); mime.charset[charset.len] = 0; @@ -1380,7 +1386,8 @@ fn frameDataCallback(response: HttpClient.Response, data: []const u8) !void { const charset_str = mime.charsetString(); const info = h5e.encoding_for_label(charset_str.ptr, charset_str.len); if (info.isValid()) { - self.charset = info.name(); + const name = info.name(); + self.charset = if (html_prescan_found_charset and isUtf16Encoding(name)) "UTF-8" else name; } self._parse_state = .{ .html = .{ .buffer = .empty, diff --git a/src/browser/tests/page/encoding.html b/src/browser/tests/page/encoding.html index a07869329..1d86abad7 100644 --- a/src/browser/tests/page/encoding.html +++ b/src/browser/tests/page/encoding.html @@ -69,6 +69,30 @@ } + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/browser/webapi/DOMMatrix.zig b/src/browser/webapi/DOMMatrix.zig index 7e7c13846..d1661a090 100644 --- a/src/browser/webapi/DOMMatrix.zig +++ b/src/browser/webapi/DOMMatrix.zig @@ -58,9 +58,12 @@ pub fn fromFloat64Array(array: js.TypedArray(f64), page: *Page) !*DOMMatrix { return create(parsed.m, parsed.is_2d, page); } -// The base already exposes read-only getters, but a redeclared accessor's -// getter must be typed to this (owner) struct, so we provide DOMMatrix-typed -// getters that read through `_proto`. +// DOMMatrix redeclares a/b/.../m44 as writable, so DOMMatrix.prototype needs +// its own read-write accessors, distinct from the read-only ones on +// DOMMatrixReadOnly.prototype. The setters are the point; each accessor bundles +// a getter too, so we pair them with DOMMatrix-typed getters that read through +// `_proto` (they could reuse the base getters — the receiver unwraps down the +// prototype chain either way — but keeping the pair symmetric reads cleaner). pub fn getA(self: *const DOMMatrix) f64 { return self._proto._m[0]; diff --git a/src/browser/webapi/DOMPoint.zig b/src/browser/webapi/DOMPoint.zig new file mode 100644 index 000000000..0df456604 --- /dev/null +++ b/src/browser/webapi/DOMPoint.zig @@ -0,0 +1,108 @@ +// 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 js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); +const RO = @import("DOMPointReadOnly.zig"); + +const DOMPoint = @This(); + +_proto: *RO, + +pub fn init(x_: ?f64, y_: ?f64, z_: ?f64, w_: ?f64, exec: *const js.Execution) !*DOMPoint { + return create(x_ orelse 0, y_ orelse 0, z_ orelse 0, w_ orelse 1, exec.page); +} + +pub fn create(x: f64, y: f64, z: f64, w: f64, page: *Page) !*DOMPoint { + const proto = try RO.createBare(x, y, z, w, page); + errdefer proto.deinit(page); + + const self = try proto._arena.create(DOMPoint); + self.* = .{ ._proto = proto }; + proto._type = .{ .mutable = self }; + return self; +} + +pub fn fromPoint(other_: ?RO.DOMPointInit, page: *Page) !*DOMPoint { + const other: RO.DOMPointInit = other_ orelse .{}; + return create(other.x, other.y, other.z, other.w, page); +} + +pub fn structuredSerialize(self: *const DOMPoint, writer: *js.StructuredWriter) !void { + try self._proto.structuredSerialize(writer); +} + +pub fn structuredDeserialize(reader: *js.StructuredReader, page: *Page) !*DOMPoint { + const proto = try RO.structuredDeserialize(reader, page); + errdefer proto.deinit(page); + + const self = try proto._arena.create(DOMPoint); + self.* = .{ ._proto = proto }; + proto._type = .{ .mutable = self }; + return self; +} + +// DOMPoint redeclares x/y/z/w as writable (`inherit attribute` in the IDL), so +// DOMPoint.prototype needs its own read-write accessors, distinct from the +// read-only ones on DOMPointReadOnly.prototype. The setters are the point; each +// accessor bundles a getter too, so we pair them with DOMPoint-typed getters +// that read through `_proto` (they could reuse the base getters — the receiver +// unwraps down the prototype chain either way — but keeping the pair symmetric +// mirrors DOMMatrix). +pub fn getX(self: *const DOMPoint) f64 { + return self._proto._x; +} +pub fn getY(self: *const DOMPoint) f64 { + return self._proto._y; +} +pub fn getZ(self: *const DOMPoint) f64 { + return self._proto._z; +} +pub fn getW(self: *const DOMPoint) f64 { + return self._proto._w; +} + +pub fn setX(self: *DOMPoint, v: f64) void { + self._proto._x = v; +} +pub fn setY(self: *DOMPoint, v: f64) void { + self._proto._y = v; +} +pub fn setZ(self: *DOMPoint, v: f64) void { + self._proto._z = v; +} +pub fn setW(self: *DOMPoint, v: f64) void { + self._proto._w = v; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(DOMPoint); + + pub const Meta = struct { + pub const name = "DOMPoint"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(DOMPoint.init, .{}); + pub const fromPoint = bridge.function(DOMPoint.fromPoint, .{ .static = true }); + pub const x = bridge.accessor(DOMPoint.getX, DOMPoint.setX, .{}); + pub const y = bridge.accessor(DOMPoint.getY, DOMPoint.setY, .{}); + pub const z = bridge.accessor(DOMPoint.getZ, DOMPoint.setZ, .{}); + pub const w = bridge.accessor(DOMPoint.getW, DOMPoint.setW, .{}); +}; diff --git a/src/browser/webapi/DOMPointReadOnly.zig b/src/browser/webapi/DOMPointReadOnly.zig new file mode 100644 index 000000000..dccb9770a --- /dev/null +++ b/src/browser/webapi/DOMPointReadOnly.zig @@ -0,0 +1,167 @@ +// 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 js = @import("../js/js.zig"); +const Page = @import("../Page.zig"); +const DOMPoint = @import("DOMPoint.zig"); +const Matrix = @import("DOMMatrixReadOnly.zig"); + +const Allocator = std.mem.Allocator; + +const DOMPointReadOnly = @This(); + +pub const _prototype_root = true; + +_type: Type, +_rc: lp.RC(u8), +_arena: Allocator, + +_x: f64, +_y: f64, +_z: f64, +_w: f64, + +pub const Type = union(enum) { + generic, + mutable: *DOMPoint, +}; + +pub const DOMPointInit = struct { + x: f64 = 0, + y: f64 = 0, + z: f64 = 0, + w: f64 = 1, +}; + +pub fn init(x_: ?f64, y_: ?f64, z_: ?f64, w_: ?f64, exec: *const js.Execution) !*DOMPointReadOnly { + return createBare(x_ orelse 0, y_ orelse 0, z_ orelse 0, w_ orelse 1, exec.page); +} + +pub fn deinit(self: *DOMPointReadOnly, page: *Page) void { + page.releaseArena(self._arena); +} + +pub fn acquireRef(self: *DOMPointReadOnly) void { + self._rc.acquire(); +} + +pub fn releaseRef(self: *DOMPointReadOnly, page: *Page) void { + self._rc.release(self, page); +} + +pub fn createBare(x: f64, y: f64, z: f64, w: f64, page: *Page) !*DOMPointReadOnly { + const arena = try page.getArena(.tiny, "DOMPoint"); + errdefer page.releaseArena(arena); + + const self = try arena.create(DOMPointReadOnly); + self.* = .{ + ._rc = .{}, + ._arena = arena, + ._type = .generic, + ._x = x, + ._y = y, + ._z = z, + ._w = w, + }; + return self; +} + +pub fn fromPoint(other_: ?DOMPointInit, page: *Page) !*DOMPointReadOnly { + const other: DOMPointInit = other_ orelse .{}; + return createBare(other.x, other.y, other.z, other.w, page); +} + +pub fn structuredSerialize(self: *const DOMPointReadOnly, writer: *js.StructuredWriter) !void { + writer.writeUint64(@bitCast(self._x)); + writer.writeUint64(@bitCast(self._y)); + writer.writeUint64(@bitCast(self._z)); + writer.writeUint64(@bitCast(self._w)); +} + +pub fn structuredDeserialize(reader: *js.StructuredReader, page: *Page) !*DOMPointReadOnly { + const x: f64 = @bitCast(try reader.readUint64()); + const y: f64 = @bitCast(try reader.readUint64()); + const z: f64 = @bitCast(try reader.readUint64()); + const w: f64 = @bitCast(try reader.readUint64()); + return createBare(x, y, z, w, page); +} + +pub fn matrixTransform(self: *const DOMPointReadOnly, matrix_: ?Matrix.DOMMatrixInit, page: *Page) !*DOMPoint { + const m = (try Matrix.fixupDict(matrix_ orelse .{})).m; + const x = self._x; + const y = self._y; + const z = self._z; + const w = self._w; + return DOMPoint.create( + m[0] * x + m[4] * y + m[8] * z + m[12] * w, + m[1] * x + m[5] * y + m[9] * z + m[13] * w, + m[2] * x + m[6] * y + m[10] * z + m[14] * w, + m[3] * x + m[7] * y + m[11] * z + m[15] * w, + page, + ); +} + +pub fn getX(self: *const DOMPointReadOnly) f64 { + return self._x; +} +pub fn getY(self: *const DOMPointReadOnly) f64 { + return self._y; +} +pub fn getZ(self: *const DOMPointReadOnly) f64 { + return self._z; +} +pub fn getW(self: *const DOMPointReadOnly) f64 { + return self._w; +} + +pub fn toJSON(self: *const DOMPointReadOnly) struct { + x: f64, + y: f64, + z: f64, + w: f64, +} { + return .{ .x = self._x, .y = self._y, .z = self._z, .w = self._w }; +} + +pub const JsApi = struct { + pub const bridge = js.Bridge(DOMPointReadOnly); + + pub const Meta = struct { + pub const name = "DOMPointReadOnly"; + pub const prototype_chain = bridge.prototypeChain(); + pub var class_id: bridge.ClassId = undefined; + }; + + pub const constructor = bridge.constructor(DOMPointReadOnly.init, .{}); + pub const fromPoint = bridge.function(DOMPointReadOnly.fromPoint, .{ .static = true }); + pub const x = bridge.accessor(DOMPointReadOnly.getX, null, .{}); + pub const y = bridge.accessor(DOMPointReadOnly.getY, null, .{}); + pub const z = bridge.accessor(DOMPointReadOnly.getZ, null, .{}); + pub const w = bridge.accessor(DOMPointReadOnly.getW, null, .{}); + + pub const matrixTransform = bridge.function(DOMPointReadOnly.matrixTransform, .{}); + pub const toJSON = bridge.function(DOMPointReadOnly.toJSON, .{}); +}; + +const testing = @import("../../testing.zig"); +test "WebApi: DOMPoint" { + try testing.htmlRunner("dompoint.html", .{}); +} From a8a10b9ba429ed28fe5eefb84548aaa13d7718b5 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Thu, 2 Jul 2026 18:19:13 +0300 Subject: [PATCH 32/49] `libcurl`: refactor `curl_easy_setopt` Removes lying dead code here and simplifies `curl_easy_setopt` type checking. Also marks passed callbacks as with C calling convention in order to get away from additional function wrapping. --- src/sys/libcurl.zig | 175 ++++++++------------------------------------ 1 file changed, 29 insertions(+), 146 deletions(-) diff --git a/src/sys/libcurl.zig b/src/sys/libcurl.zig index 3774857fa..1bee2d2e7 100644 --- a/src/sys/libcurl.zig +++ b/src/sys/libcurl.zig @@ -19,6 +19,8 @@ const std = @import("std"); const builtin = @import("builtin"); +const crypto = @import("libcrypto.zig"); + const c = @cImport({ @cInclude("curl/curl.h"); }); @@ -31,9 +33,7 @@ pub const CurlCode = c.CURLcode; pub const CurlMCode = c.CURLMcode; pub const CurlSList = c.curl_slist; pub const CurlHeader = c.curl_header; -pub const CurlHttpPost = c.curl_httppost; pub const CurlSocket = c.curl_socket_t; -pub const CurlBlob = c.curl_blob; pub const CurlOffT = c.curl_off_t; pub const CURLE = struct { @@ -41,12 +41,14 @@ pub const CURLE = struct { pub const ABORTED_BY_CALLBACK = c.CURLE_ABORTED_BY_CALLBACK; }; -pub const CurlDebugFunction = fn (*Curl, CurlInfoType, [*c]u8, usize, *anyopaque) c_int; -pub const CurlHeaderFunction = fn ([*]const u8, usize, usize, *anyopaque) usize; -pub const CurlWriteFunction = fn ([*]const u8, usize, usize, *anyopaque) usize; +pub const HeaderFunction = *const fn ([*]const u8, usize, usize, *anyopaque) callconv(.c) usize; +pub const WriteFunction = *const fn ([*]const u8, usize, usize, *anyopaque) callconv(.c) usize; pub const curl_writefunc_error: usize = c.CURL_WRITEFUNC_ERROR; pub const curl_readfunc_pause: usize = c.CURL_READFUNC_PAUSE; -pub const CurlReadFunction = fn ([*]u8, usize, usize, *anyopaque) usize; +pub const ReadFunction = *const fn ([*]u8, usize, usize, *anyopaque) callconv(.c) usize; +pub const OpenSocketFunction = *const fn (?*anyopaque, c_uint, [*c]CurlSockAddr) callconv(.c) CurlSocket; +pub const SslCtxFunction = *const fn (*Curl, *anyopaque, *anyopaque) callconv(.c) CurlCode; +pub const DebugFunction = *const fn (*Curl, CurlInfoType, [*c]u8, usize, ?*anyopaque) callconv(.c) c_int; pub const CurlSockType = enum(c.curlsocktype) { ipcxn = c.CURLSOCKTYPE_IPCXN, @@ -200,12 +202,8 @@ pub const CurlOption = enum(c.CURLoption) { url = c.CURLOPT_URL, timeout_ms = c.CURLOPT_TIMEOUT_MS, connect_timeout_ms = c.CURLOPT_CONNECTTIMEOUT_MS, - max_redirs = c.CURLOPT_MAXREDIRS, follow_location = c.CURLOPT_FOLLOWLOCATION, - redir_protocols_str = c.CURLOPT_REDIR_PROTOCOLS_STR, proxy = c.CURLOPT_PROXY, - ca_info_blob = c.CURLOPT_CAINFO_BLOB, - proxy_ca_info_blob = c.CURLOPT_PROXY_CAINFO_BLOB, ssl_verify_host = c.CURLOPT_SSL_VERIFYHOST, ssl_verify_peer = c.CURLOPT_SSL_VERIFYPEER, proxy_ssl_verify_host = c.CURLOPT_PROXY_SSL_VERIFYHOST, @@ -215,7 +213,6 @@ pub const CurlOption = enum(c.CURLoption) { debug_function = c.CURLOPT_DEBUGFUNCTION, custom_request = c.CURLOPT_CUSTOMREQUEST, post = c.CURLOPT_POST, - http_post = c.CURLOPT_HTTPPOST, post_field_size = c.CURLOPT_POSTFIELDSIZE, copy_post_fields = c.CURLOPT_COPYPOSTFIELDS, http_get = c.CURLOPT_HTTPGET, @@ -595,43 +592,29 @@ pub fn curl_easy_perform(easy: *Curl) Error!void { } pub fn curl_easy_setopt(easy: *Curl, comptime option: CurlOption, value: anytype) Error!void { - const opt: c.CURLoption = @intFromEnum(option); - const code = switch (option) { + const v = switch (comptime option) { .verbose, .post, .upload, .http_get, - .ssl_verify_host, .ssl_verify_peer, - .proxy_ssl_verify_host, .proxy_ssl_verify_peer, - => blk: { - const n: c_long = switch (@typeInfo(@TypeOf(value))) { - .bool => switch (option) { - .ssl_verify_host, .proxy_ssl_verify_host => if (value) 2 else 0, - else => if (value) 1 else 0, - }, - else => @compileError("expected bool|integer for " ++ @tagName(option) ++ ", got " ++ @typeName(@TypeOf(value))), - }; - break :blk c.curl_easy_setopt(easy, opt, n); - }, + => @as(c_long, @intFromBool(value)), + + // VERIFYHOST takes 0 or 2 (1 is rejected since curl 7.66). + // https://curl.se/libcurl/c/CURLOPT_SSL_VERIFYHOST.html + .ssl_verify_host, + .proxy_ssl_verify_host, + => @as(c_long, if (value) 2 else 0), .timeout_ms, .connect_timeout_ms, - .max_redirs, .follow_location, .post_field_size, .connect_only, - => blk: { - const n: c_long = switch (@typeInfo(@TypeOf(value))) { - .comptime_int, .int => @intCast(value), - else => @compileError("expected integer for " ++ @tagName(option) ++ ", got " ++ @typeName(@TypeOf(value))), - }; - break :blk c.curl_easy_setopt(easy, opt, n); - }, + => @as(c_long, @intCast(value)), .url, - .redir_protocols_str, .proxy, .accept_encoding, .custom_request, @@ -639,128 +622,28 @@ pub fn curl_easy_setopt(easy: *Curl, comptime option: CurlOption, value: anytype .user_pwd, .proxy_user_pwd, .copy_post_fields, - => blk: { - const s: ?[*]const u8 = value; - break :blk c.curl_easy_setopt(easy, opt, s); - }, + => @as(?[*]const u8, value), - .ca_info_blob, - .proxy_ca_info_blob, - => blk: { - const blob: CurlBlob = value; - break :blk c.curl_easy_setopt(easy, opt, blob); - }, - - .http_post => blk: { - // CURLOPT_HTTPPOST expects ?*curl_httppost (multipart formdata) - const ptr: ?*CurlHttpPost = value; - break :blk c.curl_easy_setopt(easy, opt, ptr); - }, - - .http_header => blk: { - const list: ?*CurlSList = value; - break :blk c.curl_easy_setopt(easy, opt, list); - }, + .http_header => @as(?*CurlSList, value), .private, .header_data, .read_data, .write_data, .opensocket_data, - => blk: { - const ptr: ?*anyopaque = switch (@typeInfo(@TypeOf(value))) { - .null => null, - else => @ptrCast(value), - }; - break :blk c.curl_easy_setopt(easy, opt, ptr); - }, + => @as(?*anyopaque, @ptrCast(value)), - .debug_function => blk: { - const cb: c.curl_debug_callback = switch (@typeInfo(@TypeOf(value))) { - .null => null, - .@"fn" => struct { - fn cb(handle: ?*Curl, msg_type: c.curl_infotype, raw: [*c]u8, len: usize, user: ?*anyopaque) callconv(.c) c_int { - const h = handle orelse unreachable; - const u = user orelse unreachable; - return value(h, @enumFromInt(@intFromEnum(msg_type)), raw, len, u); - } - }.cb, - else => @compileError("expected Zig function or null for " ++ @tagName(option) ++ ", got " ++ @typeName(@TypeOf(value))), - }; - break :blk c.curl_easy_setopt(easy, opt, cb); - }, + .ssl_ctx_data => @as(*crypto.X509_STORE, value), - .opensocket_function => blk: { - const cb: c.curl_opensocket_callback = switch (@typeInfo(@TypeOf(value))) { - .null => null, - .@"fn" => struct { - fn cb(clientp: ?*anyopaque, purpose: c.curlsocktype, address: [*c]c.curl_sockaddr) callconv(.c) c.curl_socket_t { - const addr: *CurlSockAddr = @ptrCast(address orelse return CURL_SOCKET_BAD); - return value(@enumFromInt(purpose), addr, clientp); - } - }.cb, - else => @compileError("expected Zig function or null for " ++ @tagName(option) ++ ", got " ++ @typeName(@TypeOf(value))), - }; - break :blk c.curl_easy_setopt(easy, opt, cb); - }, - - .header_function => blk: { - const cb: c.curl_write_callback = switch (@typeInfo(@TypeOf(value))) { - .null => null, - .@"fn" => struct { - fn cb(buffer: [*c]u8, count: usize, len: usize, user: ?*anyopaque) callconv(.c) usize { - const u = user orelse unreachable; - return value(@ptrCast(buffer), count, len, u); - } - }.cb, - else => @compileError("expected Zig function or null for " ++ @tagName(option) ++ ", got " ++ @typeName(@TypeOf(value))), - }; - break :blk c.curl_easy_setopt(easy, opt, cb); - }, - - .read_function => blk: { - const cb: c.curl_write_callback = switch (@typeInfo(@TypeOf(value))) { - .null => null, - .@"fn" => |info| struct { - fn cb(buffer: [*c]u8, count: usize, len: usize, user: ?*anyopaque) callconv(.c) usize { - const user_arg = if (@typeInfo(info.params[3].type.?) == .optional) - user - else - user orelse unreachable; - return value(@ptrCast(buffer), count, len, user_arg); - } - }.cb, - else => @compileError("expected Zig function or null for " ++ @tagName(option) ++ ", got " ++ @typeName(@TypeOf(value))), - }; - break :blk c.curl_easy_setopt(easy, opt, cb); - }, - .write_function => blk: { - const cb: c.curl_write_callback = switch (@typeInfo(@TypeOf(value))) { - .null => null, - .@"fn" => |info| struct { - fn cb(buffer: [*c]u8, count: usize, len: usize, user: ?*anyopaque) callconv(.c) usize { - const user_arg = if (@typeInfo(info.params[3].type.?) == .optional) - user - else - user orelse unreachable; - return value(@ptrCast(buffer), count, len, user_arg); - } - }.cb, - else => @compileError("expected Zig function or null for " ++ @tagName(option) ++ ", got " ++ @typeName(@TypeOf(value))), - }; - break :blk c.curl_easy_setopt(easy, opt, cb); - }, - .ssl_ctx_function => blk: { - const cb: c.curl_ssl_ctx_callback = @ptrCast(value); - break :blk c.curl_easy_setopt(easy, opt, cb); - }, - .ssl_ctx_data => blk: { - // We can make sure that passed data is always X509_STORE since we - // don't require anything else throughout project. - break :blk c.curl_easy_setopt(easy, opt, value); - }, + .debug_function => @as(DebugFunction, value), + .opensocket_function => @as(OpenSocketFunction, value), + .header_function => @as(HeaderFunction, value), + .read_function => @as(ReadFunction, value), + .write_function => @as(WriteFunction, value), + .ssl_ctx_function => @as(SslCtxFunction, value), }; - try errorCheck(code); + const code = c.curl_easy_setopt(easy, @intFromEnum(option), v); + return errorCheck(code); } pub fn curl_easy_getinfo(easy: *Curl, comptime info: CurlInfo, out: anytype) Error!void { From f2fce9310c136e39efaab0f1feb0330af6f87c3b Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Thu, 2 Jul 2026 18:20:18 +0300 Subject: [PATCH 33/49] update all call-sites that are using `curl_easy_setopt` --- src/browser/HttpClient.zig | 7 ++++++- src/browser/webapi/net/WebSocket.zig | 23 +++++++++++++++++---- src/network/http.zig | 30 +++++++++++++++------------- 3 files changed, 41 insertions(+), 19 deletions(-) diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig index 7fb4864b0..b64c55f89 100644 --- a/src/browser/HttpClient.zig +++ b/src/browser/HttpClient.zig @@ -2098,7 +2098,12 @@ pub const Transfer = struct { return result; } - fn dataCallback(buffer: [*]const u8, chunk_count: usize, chunk_len: usize, data: *anyopaque) usize { + fn dataCallback( + buffer: [*]const u8, + chunk_count: usize, + chunk_len: usize, + data: *anyopaque, + ) callconv(.c) usize { // libcurl should only ever emit 1 chunk at a time if (comptime IS_DEBUG) { std.debug.assert(chunk_count == 1); diff --git a/src/browser/webapi/net/WebSocket.zig b/src/browser/webapi/net/WebSocket.zig index a98efc591..dd33d99a8 100644 --- a/src/browser/webapi/net/WebSocket.zig +++ b/src/browser/webapi/net/WebSocket.zig @@ -545,11 +545,16 @@ fn dispatchCloseEvent(self: *WebSocket, code: u16, reason: []const u8, was_clean } } -fn sendDataCallback(buffer: [*]u8, buf_count: usize, buf_len: usize, data: *anyopaque) usize { +fn sendDataCallback( + buffer: [*c]u8, + buf_count: usize, + buf_len: usize, + raw_connection: ?*anyopaque, +) callconv(.c) usize { if (comptime IS_DEBUG) { std.debug.assert(buf_count == 1); } - const conn: *http.Connection = @ptrCast(@alignCast(data)); + const conn: *http.Connection = @ptrCast(@alignCast(raw_connection)); return _sendDataCallback(conn, buffer[0..buf_len]) catch |err| { log.warn(.websocket, "send callback", .{ .err = err }); return http.readfunc_pause; @@ -624,7 +629,12 @@ fn writeContent(self: *WebSocket, conn: *http.Connection, buf: []u8, byte_msg: M return to_copy; } -fn receivedDataCallback(buffer: [*]const u8, buf_count: usize, buf_len: usize, data: *anyopaque) usize { +fn receivedDataCallback( + buffer: [*]const u8, + buf_count: usize, + buf_len: usize, + data: *anyopaque, +) callconv(.c) usize { if (comptime IS_DEBUG) { std.debug.assert(buf_count == 1); } @@ -695,7 +705,12 @@ fn _receivedDataCallback(conn: *http.Connection, data: []const u8) !void { // libcurl has no mechanism to signal that the connection is established. The // best option I could come up with was looking for an upgrade header response. -fn receivedHeaderCallback(buffer: [*]const u8, header_count: usize, buf_len: usize, data: *anyopaque) usize { +fn receivedHeaderCallback( + buffer: [*]const u8, + header_count: usize, + buf_len: usize, + data: *anyopaque, +) callconv(.c) usize { if (comptime IS_DEBUG) { std.debug.assert(header_count == 1); } diff --git a/src/network/http.zig b/src/network/http.zig index ace8fa270..337df8711 100644 --- a/src/network/http.zig +++ b/src/network/http.zig @@ -328,11 +328,13 @@ pub const ResponseHead = struct { /// Returns CURL_SOCKET_BAD to block; otherwise creates and returns a real socket fd. /// clientp is a *const IpFilter passed via CURLOPT_OPENSOCKETDATA. fn opensocketCallback( - purpose: libcurl.CurlSockType, - address: *libcurl.CurlSockAddr, - clientp: ?*anyopaque, -) libcurl.CurlSocket { - const filter: *const IpFilter = @ptrCast(@alignCast(clientp orelse return libcurl.CURL_SOCKET_BAD)); + raw_ip_filter: ?*anyopaque, + _: c_uint, + addr: [*c]libcurl.CurlSockAddr, +) callconv(.c) libcurl.CurlSocket { + const address: *libcurl.CurlSockAddr = @ptrCast(addr); + const filter: *const IpFilter = @ptrCast(@alignCast(raw_ip_filter orelse return libcurl.CURL_SOCKET_BAD)); + if (filter.isBlockedSockaddr(address)) { if (address.family == posix.AF.INET or address.family == posix.AF.INET6) { const ip = std.net.Address.initPosix(@ptrCast(&address.addr)); @@ -342,7 +344,7 @@ fn opensocketCallback( } return libcurl.CURL_SOCKET_BAD; } - _ = purpose; // purpose is informational; we always open the same socket type + const fd = posix.socket( @intCast(address.family), @intCast(address.socktype), @@ -464,7 +466,7 @@ pub const Connection = struct { pub fn setWriteCallback( self: *Connection, - comptime data_cb: libcurl.CurlWriteFunction, + comptime data_cb: libcurl.WriteFunction, ) !void { try libcurl.curl_easy_setopt(self._easy, .write_data, self); try libcurl.curl_easy_setopt(self._easy, .write_function, data_cb); @@ -472,7 +474,7 @@ pub const Connection = struct { pub fn setReadCallback( self: *Connection, - comptime data_cb: libcurl.CurlReadFunction, + comptime data_cb: libcurl.ReadFunction, upload: bool, ) !void { try libcurl.curl_easy_setopt(self._easy, .read_data, self); @@ -484,7 +486,7 @@ pub const Connection = struct { pub fn setHeaderCallback( self: *Connection, - comptime data_cb: libcurl.CurlHeaderFunction, + comptime data_cb: libcurl.HeaderFunction, ) !void { try libcurl.curl_easy_setopt(self._easy, .header_data, self); try libcurl.curl_easy_setopt(self._easy, .header_function, data_cb); @@ -576,7 +578,7 @@ pub const Connection = struct { } } - fn discardBody(_: [*]const u8, count: usize, len: usize, _: ?*anyopaque) usize { + fn discardBody(_: [*]const u8, count: usize, len: usize, _: ?*anyopaque) callconv(.c) usize { return count * len; } @@ -864,7 +866,7 @@ test "opensocketCallback: private IPv4 returns CURL_SOCKET_BAD" { const filter = IpFilter.init(true, null); var sa = makeSockAddrV4(.{ 127, 0, 0, 1 }); - const result = opensocketCallback(.ipcxn, &sa, @ptrCast(@constCast(&filter))); + const result = opensocketCallback(@ptrCast(@constCast(&filter)), @intFromEnum(libcurl.CurlSockType.ipcxn), &sa); try testing.expectEqual(libcurl.CURL_SOCKET_BAD, result); } @@ -873,7 +875,7 @@ test "opensocketCallback: public IPv4 opens a real socket" { const filter = IpFilter.init(true, null); var sa = makeSockAddrV4(.{ 8, 8, 8, 8 }); - const fd = opensocketCallback(.ipcxn, &sa, @ptrCast(@constCast(&filter))); + const fd = opensocketCallback(@ptrCast(@constCast(&filter)), @intFromEnum(libcurl.CurlSockType.ipcxn), &sa); defer posix.close(fd); // A real fd is always >= 0 @@ -882,7 +884,7 @@ test "opensocketCallback: public IPv4 opens a real socket" { test "opensocketCallback: null clientp returns CURL_SOCKET_BAD (fail-closed)" { var sa = makeSockAddrV4(.{ 8, 8, 8, 8 }); - const result = opensocketCallback(.ipcxn, &sa, null); + const result = opensocketCallback(null, @intFromEnum(libcurl.CurlSockType.ipcxn), &sa); try testing.expectEqual(libcurl.CURL_SOCKET_BAD, result); } @@ -890,7 +892,7 @@ test "opensocketCallback: block_private=false allows private IP" { // When block_private is false the filter blocks nothing const filter = IpFilter.init(false, null); var sa = makeSockAddrV4(.{ 127, 0, 0, 1 }); - const fd = opensocketCallback(.ipcxn, &sa, @ptrCast(@constCast(&filter))); + const fd = opensocketCallback(@ptrCast(@constCast(&filter)), @intFromEnum(libcurl.CurlSockType.ipcxn), &sa); defer posix.close(fd); try testing.expect(fd >= 0); From 0011f87d82d1963cb23386416695109be1de9605 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Sat, 4 Jul 2026 09:50:31 +0300 Subject: [PATCH 34/49] `libcurl`: fix VERIFYHOST comment --- src/sys/libcurl.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/sys/libcurl.zig b/src/sys/libcurl.zig index 1bee2d2e7..4924b994f 100644 --- a/src/sys/libcurl.zig +++ b/src/sys/libcurl.zig @@ -601,7 +601,7 @@ pub fn curl_easy_setopt(easy: *Curl, comptime option: CurlOption, value: anytype .proxy_ssl_verify_peer, => @as(c_long, @intFromBool(value)), - // VERIFYHOST takes 0 or 2 (1 is rejected since curl 7.66). + // VERIFYHOST expects 0 or 2 (since 7.66.0, 1 is treated the same as 2). // https://curl.se/libcurl/c/CURLOPT_SSL_VERIFYHOST.html .ssl_verify_host, .proxy_ssl_verify_host, From f17569f6137da16ee49fa654241cf1562b080d3a Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Sat, 4 Jul 2026 09:59:54 +0300 Subject: [PATCH 35/49] revert changes in `HttpClient` and `WebSocket` --- src/browser/HttpClient.zig | 7 +------ src/browser/webapi/net/WebSocket.zig | 23 ++++------------------- 2 files changed, 5 insertions(+), 25 deletions(-) diff --git a/src/browser/HttpClient.zig b/src/browser/HttpClient.zig index b64c55f89..06e2b22aa 100644 --- a/src/browser/HttpClient.zig +++ b/src/browser/HttpClient.zig @@ -2098,12 +2098,7 @@ pub const Transfer = struct { return result; } - fn dataCallback( - buffer: [*]const u8, - chunk_count: usize, - chunk_len: usize, - data: *anyopaque, - ) callconv(.c) usize { + fn dataCallback(buffer: [*]const u8, chunk_count: usize, chunk_len: usize, data: *anyopaque) callconv(.c) usize { // libcurl should only ever emit 1 chunk at a time if (comptime IS_DEBUG) { std.debug.assert(chunk_count == 1); diff --git a/src/browser/webapi/net/WebSocket.zig b/src/browser/webapi/net/WebSocket.zig index dd33d99a8..c94ae9235 100644 --- a/src/browser/webapi/net/WebSocket.zig +++ b/src/browser/webapi/net/WebSocket.zig @@ -545,16 +545,11 @@ fn dispatchCloseEvent(self: *WebSocket, code: u16, reason: []const u8, was_clean } } -fn sendDataCallback( - buffer: [*c]u8, - buf_count: usize, - buf_len: usize, - raw_connection: ?*anyopaque, -) callconv(.c) usize { +fn sendDataCallback(buffer: [*]u8, buf_count: usize, buf_len: usize, data: *anyopaque) callconv(.c) usize { if (comptime IS_DEBUG) { std.debug.assert(buf_count == 1); } - const conn: *http.Connection = @ptrCast(@alignCast(raw_connection)); + const conn: *http.Connection = @ptrCast(@alignCast(data)); return _sendDataCallback(conn, buffer[0..buf_len]) catch |err| { log.warn(.websocket, "send callback", .{ .err = err }); return http.readfunc_pause; @@ -629,12 +624,7 @@ fn writeContent(self: *WebSocket, conn: *http.Connection, buf: []u8, byte_msg: M return to_copy; } -fn receivedDataCallback( - buffer: [*]const u8, - buf_count: usize, - buf_len: usize, - data: *anyopaque, -) callconv(.c) usize { +fn receivedDataCallback(buffer: [*]const u8, buf_count: usize, buf_len: usize, data: *anyopaque) callconv(.c) usize { if (comptime IS_DEBUG) { std.debug.assert(buf_count == 1); } @@ -705,12 +695,7 @@ fn _receivedDataCallback(conn: *http.Connection, data: []const u8) !void { // libcurl has no mechanism to signal that the connection is established. The // best option I could come up with was looking for an upgrade header response. -fn receivedHeaderCallback( - buffer: [*]const u8, - header_count: usize, - buf_len: usize, - data: *anyopaque, -) callconv(.c) usize { +fn receivedHeaderCallback(buffer: [*]const u8, header_count: usize, buf_len: usize, data: *anyopaque) callconv(.c) usize { if (comptime IS_DEBUG) { std.debug.assert(header_count == 1); } From ca1b625d51d054b3c20cb4fa9251003b24b0f1d3 Mon Sep 17 00:00:00 2001 From: Halil Durak Date: Sat, 4 Jul 2026 10:00:46 +0300 Subject: [PATCH 36/49] `libcurl`: reintroduce `Curl*` prefix for function pointer types --- src/network/http.zig | 12 ++++++------ src/sys/libcurl.zig | 24 ++++++++++++------------ 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/src/network/http.zig b/src/network/http.zig index 337df8711..8baeaf24a 100644 --- a/src/network/http.zig +++ b/src/network/http.zig @@ -328,12 +328,12 @@ pub const ResponseHead = struct { /// Returns CURL_SOCKET_BAD to block; otherwise creates and returns a real socket fd. /// clientp is a *const IpFilter passed via CURLOPT_OPENSOCKETDATA. fn opensocketCallback( - raw_ip_filter: ?*anyopaque, + clientp: ?*anyopaque, _: c_uint, addr: [*c]libcurl.CurlSockAddr, ) callconv(.c) libcurl.CurlSocket { const address: *libcurl.CurlSockAddr = @ptrCast(addr); - const filter: *const IpFilter = @ptrCast(@alignCast(raw_ip_filter orelse return libcurl.CURL_SOCKET_BAD)); + const filter: *const IpFilter = @ptrCast(@alignCast(clientp orelse return libcurl.CURL_SOCKET_BAD)); if (filter.isBlockedSockaddr(address)) { if (address.family == posix.AF.INET or address.family == posix.AF.INET6) { @@ -466,7 +466,7 @@ pub const Connection = struct { pub fn setWriteCallback( self: *Connection, - comptime data_cb: libcurl.WriteFunction, + comptime data_cb: libcurl.CurlWriteFunction, ) !void { try libcurl.curl_easy_setopt(self._easy, .write_data, self); try libcurl.curl_easy_setopt(self._easy, .write_function, data_cb); @@ -474,7 +474,7 @@ pub const Connection = struct { pub fn setReadCallback( self: *Connection, - comptime data_cb: libcurl.ReadFunction, + comptime data_cb: libcurl.CurlReadFunction, upload: bool, ) !void { try libcurl.curl_easy_setopt(self._easy, .read_data, self); @@ -486,7 +486,7 @@ pub const Connection = struct { pub fn setHeaderCallback( self: *Connection, - comptime data_cb: libcurl.HeaderFunction, + comptime data_cb: libcurl.CurlHeaderFunction, ) !void { try libcurl.curl_easy_setopt(self._easy, .header_data, self); try libcurl.curl_easy_setopt(self._easy, .header_function, data_cb); @@ -762,7 +762,7 @@ pub const Handles = struct { } }; -fn debugCallback(_: *libcurl.Curl, msg_type: libcurl.CurlInfoType, raw: [*c]u8, len: usize, _: *anyopaque) c_int { +fn debugCallback(_: *libcurl.Curl, msg_type: libcurl.CurlInfoType, raw: [*c]u8, len: usize, _: ?*anyopaque) callconv(.c) c_int { const data = raw[0..len]; switch (msg_type) { .text => std.debug.print("libcurl [text]: {s}\n", .{data}), diff --git a/src/sys/libcurl.zig b/src/sys/libcurl.zig index 4924b994f..e473d5896 100644 --- a/src/sys/libcurl.zig +++ b/src/sys/libcurl.zig @@ -41,14 +41,14 @@ pub const CURLE = struct { pub const ABORTED_BY_CALLBACK = c.CURLE_ABORTED_BY_CALLBACK; }; -pub const HeaderFunction = *const fn ([*]const u8, usize, usize, *anyopaque) callconv(.c) usize; -pub const WriteFunction = *const fn ([*]const u8, usize, usize, *anyopaque) callconv(.c) usize; +pub const CurlHeaderFunction = *const fn ([*]const u8, usize, usize, *anyopaque) callconv(.c) usize; +pub const CurlWriteFunction = *const fn ([*]const u8, usize, usize, *anyopaque) callconv(.c) usize; pub const curl_writefunc_error: usize = c.CURL_WRITEFUNC_ERROR; pub const curl_readfunc_pause: usize = c.CURL_READFUNC_PAUSE; -pub const ReadFunction = *const fn ([*]u8, usize, usize, *anyopaque) callconv(.c) usize; -pub const OpenSocketFunction = *const fn (?*anyopaque, c_uint, [*c]CurlSockAddr) callconv(.c) CurlSocket; -pub const SslCtxFunction = *const fn (*Curl, *anyopaque, *anyopaque) callconv(.c) CurlCode; -pub const DebugFunction = *const fn (*Curl, CurlInfoType, [*c]u8, usize, ?*anyopaque) callconv(.c) c_int; +pub const CurlReadFunction = *const fn ([*]u8, usize, usize, *anyopaque) callconv(.c) usize; +pub const CurlOpenSocketFunction = *const fn (?*anyopaque, c_uint, [*c]CurlSockAddr) callconv(.c) CurlSocket; +pub const CurlSslCtxFunction = *const fn (*Curl, *anyopaque, *anyopaque) callconv(.c) CurlCode; +pub const CurlDebugFunction = *const fn (*Curl, CurlInfoType, [*c]u8, usize, ?*anyopaque) callconv(.c) c_int; pub const CurlSockType = enum(c.curlsocktype) { ipcxn = c.CURLSOCKTYPE_IPCXN, @@ -635,12 +635,12 @@ pub fn curl_easy_setopt(easy: *Curl, comptime option: CurlOption, value: anytype .ssl_ctx_data => @as(*crypto.X509_STORE, value), - .debug_function => @as(DebugFunction, value), - .opensocket_function => @as(OpenSocketFunction, value), - .header_function => @as(HeaderFunction, value), - .read_function => @as(ReadFunction, value), - .write_function => @as(WriteFunction, value), - .ssl_ctx_function => @as(SslCtxFunction, value), + .debug_function => @as(CurlDebugFunction, value), + .opensocket_function => @as(CurlOpenSocketFunction, value), + .header_function => @as(CurlHeaderFunction, value), + .read_function => @as(CurlReadFunction, value), + .write_function => @as(CurlWriteFunction, value), + .ssl_ctx_function => @as(CurlSslCtxFunction, value), }; const code = c.curl_easy_setopt(easy, @intFromEnum(option), v); return errorCheck(code); From a25bdc4d9bdbf01718f0210023dbd5f4fcc93a8d Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Sat, 4 Jul 2026 15:39:30 +0800 Subject: [PATCH 37/49] minor: standardize help options Places the options in alphabetic order and applies the same indentation for all commands (agent was formatted quite differently than the others). While I would also like "more common" commands to be listed first, since they can only be sorted one way, I find alphabetic to be the most generally useful and the easier for us to maintain. Feel free to reject this if you disagree. --- src/help.zon | 410 ++++++++++++++++++++++++--------------------------- 1 file changed, 195 insertions(+), 215 deletions(-) diff --git a/src/help.zon b/src/help.zon index 718561ef8..9867f737a 100644 --- a/src/help.zon +++ b/src/help.zon @@ -4,12 +4,12 @@ \\usage: {0s} [arguments] \\ \\The commands are: - \\ serve starts a WebSocket CDP server - \\ fetch fetches the specified URL - \\ mcp starts an MCP (Model Context Protocol) server over stdio \\ agent starts an interactive AI agent that can browse the web - \\ version displays the version of {0s} + \\ fetch fetches the specified URL \\ help displays this message + \\ mcp starts an MCP (Model Context Protocol) server over stdio + \\ serve starts a WebSocket CDP server + \\ version displays the version of {0s} \\ \\Use "{0s} help " for more information about a command. , @@ -19,12 +19,6 @@ \\Starts a WebSocket CDP server. \\ \\options: - \\ --host - \\ Host of the CDP server. - \\ Defaults to "127.0.0.1". - \\ --port - \\ Port of the CDP server. - \\ Defaults to 9222. \\ --advertise-host \\ The host to advertise, e.g. in the /json/version response. Useful, \\ for example, when --host is 0.0.0.0. @@ -32,18 +26,24 @@ \\ --cdp-max-connections \\ Maximum number of simultaneous CDP connections. \\ Defaults to 16. - \\ --cdp-max-pending-connections - \\ Maximum pending connections in the accept queue. - \\ Defaults to 128. - \\ --cdp-max-message-size - \\ Maximum allowed incoming websocket message size. - \\ Defaults to 1048576 (1MB) \\ --cdp-max-http-message-size \\ Maximum allowed HTTP request size \\ Defaults to 4096 (maximum allowed: 16383) + \\ --cdp-max-message-size + \\ Maximum allowed incoming websocket message size. + \\ Defaults to 1048576 (1MB) + \\ --cdp-max-pending-connections + \\ Maximum pending connections in the accept queue. + \\ Defaults to 128. \\ --cookie \\ Path to a JSON file to load cookies from (read-only). \\ Defaults to no cookie loading. + \\ --host + \\ Host of the CDP server. + \\ Defaults to "127.0.0.1". + \\ --port + \\ Port of the CDP server. + \\ Defaults to 9222. , .fetch = \\usage: {0s} fetch ... [OPTIONS] [COMMON_OPTIONS] @@ -52,6 +52,12 @@ \\than one URL requires --json. \\ \\options: + \\ --cookie + \\ Path to a JSON file to load cookies from (read-only). + \\ Defaults to no cookie loading. + \\ --cookie-jar + \\ Path to a JSON file to save cookies to on exit (write-only). + \\ Defaults to no cookie saving. \\ --dump \\ Dumps the document to stdout. \\ Defaults to no dump. @@ -60,6 +66,18 @@ \\ markdown Converts content to Markdown. \\ semantic_tree JSON-serialized semantic tree. \\ semantic_tree_text Pruned plain-text semantic tree. + \\ --inject-script + \\ JavaScript to execute as the document's is parsed, before any + \\ other scripts in the page run. Can be passed multiple times; scripts + \\ run in order. + \\ --inject-script-file + \\ Like --inject-script, but reads the script from a file. Can be passed + \\ multiple times; can be mixed with --inject-script and runs in CLI order. + \\ --json + \\ Capture and print the status of the fetch as JSON. A single URL + \\ prints one object; multiple URLs print {{"results": [ ... ]}} with + \\ one object per URL. When used with --dump the dumped + \\ content is wrapped within each object. \\ --strip-mode \\ Comma-separated list of tag groups to remove from dump. \\ Defaults to no-strip. @@ -69,53 +87,35 @@ \\ css Includes style and link[rel=stylesheet]. \\ invisible Best-effort (e.g. display:none) hidden elements \\ full Strip everything. - \\ --json - \\ Capture and print the status of the fetch as JSON. A single URL - \\ prints one object; multiple URLs print {{"results": [ ... ]}} with - \\ one object per URL. When used with --dump the dumped - \\ content is wrapped within each object. - \\ --with-base - \\ Add a tag in dump. - \\ Defaults to false. - \\ --with-frames - \\ Includes the contents of iframes. - \\ Defaults to false. - \\ --wait-ms - \\ Wait time in milliseconds. Supersedes all other --wait parameters. - \\ Defaults to 5000. - \\ --wait-until - \\ Wait until the specified event. Checked before other --wait-* options. - \\ Defaults to 'done'. If --wait-selector, --wait-script or - \\ --wait-script-file specified, defaults to none. - \\ Allowed values: "load", "domcontentloaded", "networkalmostidle", - \\ "networkidle", "done". - \\ --wait-selector - \\ Wait for an element matching the CSS selector to appear. Checked after - \\ --wait-until condition is met. - \\ --wait-script - \\ Wait for a JavaScript expression to return truthy. Checked after - \\ --wait-until condition is met. - \\ --wait-script-file - \\ Like --wait-script, but reads the script from a file. - \\ --inject-script - \\ JavaScript to execute as the document's is parsed, before any - \\ other scripts in the page run. Can be passed multiple times; scripts - \\ run in order. - \\ --inject-script-file - \\ Like --inject-script, but reads the script from a file. Can be passed - \\ multiple times; can be mixed with --inject-script and runs in CLI order. \\ --terminate-ms \\ Hard deadline in milliseconds. After this time elapses, JavaScript \\ execution is forcibly terminated (e.g. for pages with endless scripts). \\ Unlike --wait-ms, which only stops waiting, --terminate-ms aborts the \\ page. \\ Defaults to no terminate. - \\ --cookie - \\ Path to a JSON file to load cookies from (read-only). - \\ Defaults to no cookie loading. - \\ --cookie-jar - \\ Path to a JSON file to save cookies to on exit (write-only). - \\ Defaults to no cookie saving. + \\ --wait-ms + \\ Wait time in milliseconds. Supersedes all other --wait parameters. + \\ Defaults to 5000. + \\ --wait-script + \\ Wait for a JavaScript expression to return truthy. Checked after + \\ --wait-until condition is met. + \\ --wait-script-file + \\ Like --wait-script, but reads the script from a file. + \\ --wait-selector + \\ Wait for an element matching the CSS selector to appear. Checked after + \\ --wait-until condition is met. + \\ --wait-until + \\ Wait until the specified event. Checked before other --wait-* options. + \\ Defaults to 'done'. If --wait-selector, --wait-script or + \\ --wait-script-file specified, defaults to none. + \\ Allowed values: "load", "domcontentloaded", "networkalmostidle", + \\ "networkidle", "done". + \\ --with-base + \\ Add a tag in dump. + \\ Defaults to false. + \\ --with-frames + \\ Includes the contents of iframes. + \\ Defaults to false. , .mcp = \\usage: {0s} mcp [OPTIONS] [COMMON_OPTIONS] @@ -147,107 +147,87 @@ \\ {0s} agent --task "..." --save out.js (synthesize a replayable script) \\ \\Arguments: - \\[SCRIPT] Optional path to a .js script. Runs the script - \\ (no LLM calls) and exits. With no script and no - \\ --task, the REPL starts; from there /load runs a - \\ script and /save exports the session to a file. - \\ Caution: .js files can contain evaluate(...) calls - \\ that run arbitrary JavaScript in the page. Only run - \\ scripts you trust, the same way you would a shell - \\ script. + \\[SCRIPT] + \\ Optional path to a .js script. Runs the script (no LLM calls) and + \\ exits. With no script and no --task, the REPL starts; from there + \\ /load runs a script and /save exports the session to a file. + \\ Caution: .js files can contain evaluate(...) calls that run + \\ arbitrary JavaScript in the page. Only run scripts you trust, the + \\ same way you would a shell script. \\ \\Options: - \\--provider The AI provider. - \\ When omitted, lightpanda auto-detects an API key - \\ from your environment (ANTHROPIC_API_KEY, - \\ OPENAI_API_KEY, GOOGLE_API_KEY/GEMINI_API_KEY, - \\ HF_TOKEN, AI_GATEWAY_API_KEY, MISTRAL_API_KEY). - \\ With exactly one key set: that provider is used. - \\ With multiple keys on a TTY: you'll be prompted - \\ to pick; in non-interactive contexts, pass - \\ --provider explicitly. With no keys set: falls - \\ back to the basic REPL (slash commands only, no - \\ natural-language input, no LOGIN / - \\ ACCEPT_COOKIES keywords). + \\ -a, --attach + \\ Feed a local file to the model alongside --task. Repeatable, one + \\ file per flag. Text files are inlined (max 512 KiB each); + \\ images/audio/pdf are base64-encoded (max 20 MiB each). + \\ Requires --task. + \\ --base-url + \\ Override the API base URL for the provider. Defaults to the + \\ provider's standard endpoint. + \\ Ollama default: http://localhost:11434/v1. + \\ llama.cpp default: http://localhost:8080/v1. + \\ Hugging Face default is the serverless router + \\ (https://router.huggingface.co/v1); + \\ point this at a dedicated Inference Endpoint to use one. + \\ --effort + \\ Per-turn reasoning budget, mapped to each provider's native + \\ thinking/reasoning knob. Default: low in the REPL (snappy turns), + \\ medium in one-shot --task mode, unless the provider sets its own + \\ default (Mistral defaults to none, as its default model rejects + \\ effort). In the REPL, use /effort to change it. + \\ Allowed values: none, minimal, low, medium, high, xhigh. + \\ --list-models + \\ Print the model IDs usable with `agent` for --provider, one per + \\ line, sorted, and exit. Auto-detects the provider from env when + \\ --provider is omitted. + \\ --model + \\ The model name to use. Defaults to a sensible default per + \\ provider. In the REPL, use /model to list and change models for + \\ the active provider. + \\ --no-llm + \\ Force the basic REPL even when an API key is present or + \\ --provider is set. Useful for testing slash commands without + \\ burning tokens, or for disabling the LLM in a saved command + \\ without editing the existing flags. Wins over --provider. + \\ --provider + \\ The AI provider. When omitted, lightpanda auto-detects an API + \\ key from your environment (ANTHROPIC_API_KEY, OPENAI_API_KEY, + \\ GOOGLE_API_KEY/GEMINI_API_KEY, HF_TOKEN, AI_GATEWAY_API_KEY, + \\ MISTRAL_API_KEY). With exactly one key set: that provider is + \\ used. With multiple keys on a TTY: you'll be prompted to pick; + \\ in non-interactive contexts, pass --provider explicitly. With + \\ no keys set: falls back to the basic REPL (slash commands only, + \\ no natural-language input, no LOGIN / ACCEPT_COOKIES keywords). \\ - \\ Local servers (ollama, llama_cpp) are never - \\ auto-detected (they need no key); select them - \\ explicitly with --provider ollama / --provider - \\ llama_cpp. + \\ Local servers (ollama, llama_cpp) are never auto-detected (they + \\ need no key); select them explicitly with --provider ollama / + \\ --provider llama_cpp. \\ - \\ Allowed values: - \\ "anthropic", "openai", "gemini", - \\ "huggingface", "vercel", "mistral", - \\ "ollama", "llama_cpp". - \\ In the REPL, use /provider to list and change - \\ providers. - \\ - \\--no-llm Force the basic REPL even when an API key is - \\ present or --provider is set. Useful for testing - \\ slash commands without burning tokens, or for - \\ disabling the LLM in a saved command without - \\ editing the existing flags. Wins over --provider. - \\ - \\--model The model name to use. - \\ Defaults to a sensible default per provider. - \\ In the REPL, use /model to list and change - \\ models for the active provider. - \\ - \\--base-url Override the API base URL for the provider. - \\ Defaults to the provider's standard endpoint. - \\ Ollama default: http://localhost:11434/v1. - \\ llama.cpp default: http://localhost:8080/v1. - \\ Hugging Face default is the serverless router - \\ (https://router.huggingface.co/v1); point this - \\ at a dedicated Inference Endpoint to use one. - \\ - \\--system-prompt Override the default system prompt. - \\ - \\--task One-shot mode: run a single user turn, print the - \\ final answer to stdout, and exit. Conflicts with - \\ the positional script. With --save, the answer is - \\ suppressed and a script is written instead. - \\ - \\--save Synthesize a replayable .js script from the --task - \\ run and write it to PATH, instead of printing the - \\ answer. Replay it later with `agent PATH` (no LLM - \\ calls). Overwrites PATH if it exists. Requires - \\ --task. - \\ - \\-a, --attach Feed a local file to the model alongside --task. - \\ Repeatable, one file per flag. Text files are - \\ inlined (max 512 KiB each); images/audio/pdf are - \\ base64-encoded (max 20 MiB each). Requires --task. - \\ - \\--list-models Print the model IDs usable with `agent` for - \\ --provider, one per line, sorted, and exit. - \\ Auto-detects the provider from env when - \\ --provider is omitted. - \\ - \\--verbosity Stderr chatter level. - \\ Default: high when --task captures stderr to a - \\ pipe or file; low otherwise. low/medium also - \\ raise --log-level to err (mutes page-side - \\ console.error spam) unless --log-level is set - \\ explicitly. - \\ - \\ Allowed values: - \\ low Silent in --task mode (final answer to - \\ stdout only); spinner + summary in REPL. - \\ medium + one `● [tool: ...]` line per call. - \\ high + the matching `[result: ...]` body - \\ (required by the benchmarks harness). - \\ - \\--effort Per-turn reasoning budget, mapped to each - \\ provider's native thinking/reasoning knob. - \\ Default: low in the REPL (snappy turns), - \\ medium in one-shot --task mode, unless the - \\ provider sets its own default (Mistral defaults - \\ to none, as its default model rejects effort). - \\ In the REPL, use /effort to change it. - \\ - \\ Allowed values: - \\ none, minimal, low, medium, high, xhigh. + \\ Allowed values: "anthropic", "openai", "gemini", "huggingface", + \\ "vercel", "mistral", "ollama", "llama_cpp". + \\ In the REPL, use /provider to list and change providers. + \\ --save + \\ Synthesize a replayable .js script from the --task run and write + \\ it to PATH, instead of printing the answer. Replay it later with + \\ `agent PATH` (no LLM calls). Overwrites PATH if it exists. + \\ Requires --task. + \\ --system-prompt + \\ Override the default system prompt. + \\ --task + \\ One-shot mode: run a single user turn, print the final answer + \\ to stdout, and exit. Conflicts with the positional script. With + \\ --save, the answer is suppressed and a script is written instead. + \\ --verbosity + \\ Stderr chatter level. Default: high when --task captures stderr + \\ to a pipe or file; low otherwise. low/medium also raise + \\ --log-level to err (mutes page-side console.error spam) unless + \\ --log-level is set explicitly. + \\ Allowed values: + \\ low silent in --task mode (final answer to stdout only); + \\ spinner + summary in REPL. + \\ medium + one `● [tool: ...]` line per call. + \\ high + the matching `[result: ...]` body (required by the + \\ benchmarks harness). \\ \\The provider, model, effort, and verbosity you choose in the REPL are \\remembered per-directory in .lp-agent.zon and reused on the next run. @@ -269,12 +249,21 @@ , .common_options = \\common options: - \\ --insecure-disable-tls-host-verification - \\ Disables host verification on all HTTP requests. - \\ Only set this if you understand and accept the risk. - \\ --obey-robots - \\ Fetches and obeys robots.txt of the target page. + \\ --block-cidrs + \\ Additional CIDR ranges to block, comma-separated. + \\ Prefix with '-' to allow (exempt from blocking). + \\ e.g. --block-cidrs 10.0.0.0/8,-10.0.0.42/32 + \\ Can be combined with --block-private-networks. + \\ --block-private-networks + \\ Block HTTP requests to private/internal IP addresses after DNS + \\ resolution. \\ Defaults to false. + \\ --cookie + \\ Path to a JSON file to load cookies from (read-only). + \\ Defaults to no cookie loading. + \\ --cookie-jar + \\ Path to a JSON file to save cookies to on exit (write-only). + \\ Defaults to no cookie saving. \\ --disable-subframes \\ Skip loading