Merge branch 'main' into agent

This commit is contained in:
Adrià Arrufat
2026-05-08 08:08:39 +02:00
26 changed files with 257 additions and 91 deletions

View File

@@ -50,29 +50,11 @@ jobs:
- name: Run zig fmt
id: fmt
run: |
zig fmt --check ./*.zig ./**/*.zig 2> zig-fmt.err > zig-fmt.err2 || echo "Failed"
delimiter="$(openssl rand -hex 8)"
echo "zig_fmt_errs<<${delimiter}" >> "${GITHUB_OUTPUT}"
if [ -s zig-fmt.err ]; then
echo "// The following errors occurred:" >> "${GITHUB_OUTPUT}"
cat zig-fmt.err >> "${GITHUB_OUTPUT}"
fi
if [ -s zig-fmt.err2 ]; then
echo "// The following files were not formatted:" >> "${GITHUB_OUTPUT}"
cat zig-fmt.err2 >> "${GITHUB_OUTPUT}"
fi
echo "${delimiter}" >> "${GITHUB_OUTPUT}"
- name: Fail the job
if: steps.fmt.outputs.zig_fmt_errs != ''
run: exit 1
run: zig fmt --check ./
zig-test-debug:
name: zig test using v8 in debug mode
needs: zig-fmt
runs-on: ubuntu-latest
timeout-minutes: 15
@@ -93,6 +75,7 @@ jobs:
zig-test-release:
name: zig test
needs: zig-fmt
runs-on: ubuntu-latest
timeout-minutes: 15

View File

@@ -243,9 +243,6 @@ child_frames: std.ArrayList(*Frame) = .{},
// Workers created by this frame. Cleaned up when frame is destroyed.
workers: std.ArrayList(*Worker) = .{},
// DOM version used to invalidate cached state of "live" collections
version: usize = 0,
// This is maybe not great. It's a counter on the number of events that we're
// waiting on before triggering the "load" event. Essentially, we need all
// synchronous scripts and all iframes to be loaded. Scripts are handled by the
@@ -1441,7 +1438,7 @@ pub fn openPopup(self: *Frame, opts: OpenPopupOpts) !*Frame {
}
pub fn domChanged(self: *Frame) void {
self.version += 1;
self._page.dom_version += 1;
if (self._intersection_check_scheduled) {
return;
@@ -3008,7 +3005,7 @@ pub fn appendNode(self: *Frame, parent: *Node, child: *Node, opts: InsertNodeOpt
}
pub fn appendAllChildren(self: *Frame, parent: *Node, target: *Node) !void {
target.bumpDomVersion(self);
self.domChanged();
const dest_connected = target.isConnected();
// Use firstChild() instead of iterator to handle cases where callbacks
@@ -3023,7 +3020,7 @@ pub fn appendAllChildren(self: *Frame, parent: *Node, target: *Node) !void {
}
pub fn insertAllChildrenBefore(self: *Frame, fragment: *Node, parent: *Node, ref_node: *Node) !void {
parent.bumpDomVersion(self);
self.domChanged();
const dest_connected = parent.isConnected();
// Use firstChild() instead of iterator to handle cases where callbacks

View File

@@ -205,7 +205,9 @@ pub fn init(self: *Client, allocator: Allocator, network: *Network, cdp_client:
next = layerWith(&self.cache_layer, next);
}
next = layerWith(&self.interception_layer, next);
if (network.config.mode == .serve) {
next = layerWith(&self.interception_layer, next);
}
if (network.config.webBotAuth() != null) {
next = layerWith(&self.web_bot_auth_layer, next);

View File

@@ -45,6 +45,19 @@ const Page = @This();
session: *Session,
// DOM version used to invalidate cached state of "live" collections. Ideally
// this would be on the Frame (and that's where it used to be). But getting the
// frame from a DOM mutation call is [relatively] expensive. You can't use
// the bridge-injected *Frame, because that's the frame where the JS is being
// executed, which might not be the *Frame that owns the node. We don't store
// *Frame in node (think of the memory!), so we have to iterate through its
// parents, find the Document, which has the frame.
// So the choice is between making every DOM mutation (which has to increase
// the dom_version) + every read (which has to check the version) slow, or
// putting this on the Page, and having an DOM mutation in Frame 1 invalidate
// a cached lookup on Frame 2. We picked the latter.
dom_version: usize = 0,
// DOM object factory scoped to this Page's documents.
factory: Factory,

View File

@@ -233,7 +233,6 @@ pub fn addFromElement(self: *ScriptManager, comptime from_parser: bool, script_e
defer self.base.is_evaluating = was_evaluating;
const headers = try self.getHeaders();
errdefer headers.deinit();
if (is_blocking) {
const response = try self.base.client.syncRequest(arena, .{

View File

@@ -181,7 +181,7 @@ fn getThis(self: *const Function) js.Object {
}
pub fn src(self: *const Function) ![]const u8 {
return self.local.valueToString(.{ .local = self.local, .handle = @ptrCast(self.handle) }, .{});
return js.Value.toStringSlice(.{ .local = self.local, .handle = @ptrCast(self.handle) });
}
pub fn getPropertyValue(self: *const Function, name: []const u8) !?js.Value {

View File

@@ -516,7 +516,25 @@ fn countInternalFields(comptime JsApi: type) u8 {
// Shared illegal constructor callback for types without explicit constructors
fn illegalConstructorCallback(raw_info: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
const isolate = v8.v8__FunctionCallbackInfo__GetIsolate(raw_info);
log.warn(.js, "Illegal constructor call", .{});
// Recover the constructor's name via NewTarget, whose .name property was
// set via SetClassName when the FunctionTemplate was built. Lets us tell
// `new DOMException()` apart from `new MutationRecord()` in the warning.
var name_buf: [128]u8 = undefined;
var name: []const u8 = "<unknown>";
if (v8.v8__FunctionCallbackInfo__NewTarget(raw_info)) |new_target| {
if (v8.v8__Value__IsFunction(new_target)) {
const func: *const v8.Function = @ptrCast(new_target);
if (v8.v8__Function__GetName(func)) |name_value| {
if (v8.v8__Value__IsString(name_value)) {
const str: *const v8.String = @ptrCast(name_value);
const n = v8.v8__String__WriteUtf8(str, isolate, &name_buf, name_buf.len, v8.NO_NULL_TERMINATION | v8.REPLACE_INVALID_UTF8);
name = name_buf[0..@intCast(n)];
}
}
}
}
log.info(.js, "Illegal constructor call", .{ .name = name });
const message = v8.v8__String__NewFromUtf8(isolate, "Illegal Constructor", v8.kNormal, 19);
const js_exception = v8.v8__Exception__TypeError(message);
@@ -633,6 +651,7 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.F
const cb = v8.v8__FunctionTemplate__New__Config(isolate, &.{
.callback = setter,
.signature = getter_signature,
.length = 1,
}).?;
const setter_name_str = "set " ++ name;
const setter_name_v8 = v8.v8__String__NewFromUtf8(isolate, setter_name_str.ptr, v8.kNormal, @intCast(setter_name_str.len));
@@ -673,9 +692,12 @@ fn attachClass(comptime JsApi: type, isolate: *v8.Isolate, template: *const v8.F
}).?;
const js_name = v8.v8__String__NewFromUtf8(isolate, name.ptr, v8.kNormal, @intCast(name.len));
v8.v8__FunctionTemplate__SetClassName(function_template, js_name);
if (value.static) {
if (value.static and !own_properties) {
v8.v8__Template__Set(@ptrCast(template), js_name, @ptrCast(function_template), v8.None);
} else {
// For own_properties namespaces, static methods still belong
// on the instance — `CSS` is exposed as an instance via
// `window.CSS`, not as a constructor.
v8.v8__Template__Set(@ptrCast(member_template), js_name, @ptrCast(function_template), v8.None);
}
},

View File

@@ -153,7 +153,9 @@ pub const Function = struct {
.cache = opts.cache,
.static = opts.static,
.wpt_only = opts.wpt_only,
.arity = getArity(@TypeOf(func), 1),
// Non-static methods receive `self` as their first param; static
// methods don't, so don't skip the first param for them.
.arity = getArity(@TypeOf(func), if (opts.static) 0 else 1),
.func = if (opts.noop) noopFunction else struct {
fn wrap(handle: ?*const v8.FunctionCallbackInfo) callconv(.c) void {
Caller.Function.call(T, handle.?, func, opts);

View File

@@ -82,3 +82,18 @@
testing.expectEqual('\uFFFDabc', CSS.escape('\x00abc'));
}
</script>
<script id="static_no_this_binding">
// react-jss extracts CSS.escape and calls it with no `this`. Per spec these
// are static namespace methods, so the call must not throw Illegal invocation.
{
const escape = CSS.escape;
testing.expectEqual('foo\\#bar', escape('foo#bar'));
testing.expectEqual('foo\\#bar', escape.call(null, 'foo#bar'));
testing.expectEqual('foo\\#bar', escape.call(undefined, 'foo#bar'));
testing.expectEqual('foo\\#bar', escape.call({}, 'foo#bar'));
const supports = CSS.supports;
testing.expectEqual(true, supports('display', 'block'));
}
</script>

View File

@@ -40,7 +40,7 @@ pub fn parseDimension(value: []const u8) ?f64 {
/// Escapes a CSS identifier string
/// https://drafts.csswg.org/cssom/#the-css.escape()-method
pub fn escape(_: *const CSS, value: []const u8, frame: *Frame) ![]const u8 {
pub fn escape(value: []const u8, frame: *Frame) ![]const u8 {
if (value.len == 0) {
return "";
}
@@ -92,7 +92,7 @@ pub fn escape(_: *const CSS, value: []const u8, frame: *Frame) ![]const u8 {
return result;
}
pub fn supports(_: *const CSS, property_or_condition: []const u8, value: ?[]const u8) bool {
pub fn supports(property_or_condition: []const u8, value: ?[]const u8) bool {
_ = property_or_condition;
_ = value;
return true;
@@ -175,8 +175,8 @@ pub const JsApi = struct {
pub const empty_with_no_proto = true;
};
pub const escape = bridge.function(CSS.escape, .{});
pub const supports = bridge.function(CSS.supports, .{});
pub const escape = bridge.function(CSS.escape, .{ .static = true });
pub const supports = bridge.function(CSS.supports, .{ .static = true });
};
const testing = @import("../../testing.zig");

View File

@@ -489,7 +489,7 @@ pub fn append(self: *Document, nodes: []const Node.NodeOrText, frame: *Frame) !v
try validateDocumentNodes(self, nodes, false);
const parent = self.asNode();
parent.bumpDomVersion(frame);
frame.domChanged();
const parent_is_connected = parent.isConnected();
for (nodes) |node_or_text| {
@@ -514,7 +514,7 @@ pub fn prepend(self: *Document, nodes: []const Node.NodeOrText, frame: *Frame) !
try validateDocumentNodes(self, nodes, false);
const parent = self.asNode();
parent.bumpDomVersion(frame);
frame.domChanged();
const parent_is_connected = parent.isConnected();
var i = nodes.len;
@@ -736,7 +736,7 @@ fn writeInternal(self: *Document, text: []const []const u8, append_newline: bool
insert_after = child;
}
parent.bumpDomVersion(frame);
frame.domChanged();
self._write_insertion_point = children_to_insert.getLast();
}

View File

@@ -154,7 +154,7 @@ pub fn getInnerHTML(self: *DocumentFragment, writer: *std.Io.Writer, frame: *Fra
pub fn setInnerHTML(self: *DocumentFragment, html: []const u8, frame: *Frame) !void {
const parent = self.asNode();
parent.bumpDomVersion(frame);
frame.domChanged();
var it = parent.childrenIterator();
while (it.next()) |child| {
frame.removeNode(parent, child, .{ .will_be_reconnected = false });

View File

@@ -467,7 +467,7 @@ pub fn setOuterHTML(self: *Element, html: []const u8, frame: *Frame) !void {
const node = self.asNode();
const parent = node._parent orelse return;
parent.bumpDomVersion(frame);
frame.domChanged();
if (html.len > 0) {
const fragment = (try Node.DocumentFragment.init(frame)).asNode();
try frame.parseHtmlAsChildren(fragment, html);
@@ -493,7 +493,7 @@ pub fn setInnerHTML(self: *Element, html: []const u8, frame: *Frame) !void {
// fires disconnectedCallback for custom elements, which can mutate
// the child list and dangle any cached next-pointer the iterator
// would otherwise hold.
parent.bumpDomVersion(frame);
frame.domChanged();
while (parent.firstChild()) |child| {
frame.removeNode(parent, child, .{ .will_be_reconnected = false });
}
@@ -881,7 +881,7 @@ pub fn replaceChildren(self: *Element, nodes: []const Node.NodeOrText, frame: *F
pub fn replaceWith(self: *Element, nodes: []const Node.NodeOrText, frame: *Frame) !void {
const ref_node = self.asNode();
const parent = ref_node._parent orelse return;
parent.bumpDomVersion(frame);
frame.domChanged();
const parent_is_connected = parent.isConnected();
@@ -920,7 +920,7 @@ pub fn replaceWith(self: *Element, nodes: []const Node.NodeOrText, frame: *Frame
pub fn remove(self: *Element, frame: *Frame) void {
const node = self.asNode();
const parent = node._parent orelse return;
parent.bumpDomVersion(frame);
frame.domChanged();
frame.removeNode(parent, node, .{ .will_be_reconnected = false });
}

View File

@@ -203,6 +203,9 @@ pub const JsApi = struct {
const testing = @import("../../testing.zig");
test "WebApi: EventTarget" {
const filter: testing.LogFilter = .init(&.{ .js, .event });
defer filter.deinit();
// we create thousands of these per frame. Nothing should bloat it.
try testing.expectEqual(16, @sizeOf(EventTarget));
try testing.htmlRunner("events.html", .{});

View File

@@ -226,7 +226,7 @@ pub fn appendChild(self: *Node, child: *Node, frame: *Frame) !*Node {
try validateNodeInsertion(self, child);
self.bumpDomVersion(frame);
frame.domChanged();
// If the child is currently connected, and if its new parent is connected,
// then we can remove + add a bit more efficiently (we don't have to fully
@@ -532,15 +532,6 @@ pub fn ownerFrame(self: *const Node, default: *Frame) *Frame {
return doc._frame orelse default;
}
// Tells the owning frame that this node's subtree has changed. Use this from
// mutation paths instead of `frame.domChanged()` so cross-realm mutations
// (e.g., parent JS mutating an iframe's DOM) bump the iframe's version, not
// the caller's. Otherwise, live collections that key off the owning frame's
// version won't see the change and will return stale cached state.
pub fn bumpDomVersion(self: *const Node, default: *Frame) void {
self.ownerFrame(default).domChanged();
}
pub const ResolveURLOpts = struct {
allocator: ?Allocator = null,
};
@@ -572,7 +563,7 @@ pub fn removeChild(self: *Node, child: *Node, frame: *Frame) !*Node {
var it = self.childrenIterator();
while (it.next()) |n| {
if (n == child) {
self.bumpDomVersion(frame);
frame.domChanged();
frame.removeNode(self, child, .{ .will_be_reconnected = false });
return child;
}
@@ -587,7 +578,7 @@ pub fn insertBefore(self: *Node, new_node: *Node, ref_node_: ?*Node, frame: *Fra
// special case: if nodes are the same, ignore the change.
if (new_node == ref_node_) {
self.bumpDomVersion(frame);
frame.domChanged();
if (frame.hasMutationObservers()) {
const parent = new_node._parent.?;
@@ -618,7 +609,7 @@ pub fn insertBefore(self: *Node, new_node: *Node, ref_node_: ?*Node, frame: *Fra
const parent_owner = self.ownerDocument(frame) orelse self.as(Document);
const adopting_to_new_document = child_owner != null and child_owner.? != parent_owner;
self.bumpDomVersion(frame);
frame.domChanged();
const will_be_reconnected = self.isConnected() and !adopting_to_new_document;
if (new_node._parent) |parent| {
frame.removeNode(parent, new_node, .{ .will_be_reconnected = will_be_reconnected });
@@ -1089,7 +1080,7 @@ pub fn replaceChildren(self: *Node, nodes: []const NodeOrText, frame: *Frame) !v
}
}
self.bumpDomVersion(frame);
frame.domChanged();
// Remove all existing children
var it = self.childrenIterator();

View File

@@ -374,7 +374,7 @@ pub fn deleteContents(self: *Range, frame: *Frame) !void {
if (self._proto.getCollapsed()) {
return;
}
self._proto._start_container.bumpDomVersion(frame);
frame.domChanged();
// Simple case: same container
if (self._proto._start_container == self._proto._end_container) {

View File

@@ -34,11 +34,8 @@ _arena: std.mem.Allocator,
_last_index: usize,
_last_length: ?u32,
_last_node: ?*std.DoublyLinkedList.Node,
// Version observed on `_owning_frame` the last time we refreshed our cache.
// Compare against `_owning_frame.version` to detect DOM mutations.
_cached_version: usize,
_node: *Node,
_owning_frame: *Frame,
pub const KeyIterator = GenericIterator(Iterator, "0");
pub const ValueIterator = GenericIterator(Iterator, "1");
@@ -48,8 +45,6 @@ pub fn init(node: *Node, frame: *Frame) !*ChildNodes {
const arena = try frame.getArena(.small, "ChildNodes");
errdefer frame.releaseArena(arena);
const owning_frame = node.ownerFrame(frame);
const self = try arena.create(ChildNodes);
self.* = .{
._node = node,
@@ -57,8 +52,7 @@ pub fn init(node: *Node, frame: *Frame) !*ChildNodes {
._last_index = 0,
._last_node = null,
._last_length = null,
._cached_version = owning_frame.version,
._owning_frame = owning_frame,
._cached_version = frame._page.dom_version,
};
return self;
}
@@ -67,8 +61,8 @@ pub fn deinit(self: *const ChildNodes, page: *Page) void {
page.releaseArena(self._arena);
}
pub fn length(self: *ChildNodes, _: *Frame) !u32 {
if (self.versionCheck()) {
pub fn length(self: *ChildNodes, frame: *const Frame) !u32 {
if (self.versionCheck(frame)) {
if (self._last_length) |cached_length| {
return cached_length;
}
@@ -81,8 +75,8 @@ pub fn length(self: *ChildNodes, _: *Frame) !u32 {
return len;
}
pub fn getAtIndex(self: *ChildNodes, index: usize, _: *Frame) !?*Node {
_ = self.versionCheck();
pub fn getAtIndex(self: *ChildNodes, index: usize, frame: *const Frame) !?*Node {
_ = self.versionCheck(frame);
var current = self._last_index;
var node: ?*std.DoublyLinkedList.Node = null;
@@ -122,8 +116,8 @@ pub fn entries(self: *ChildNodes, frame: *Frame) !*EntryIterator {
return .init(.{ .list = self }, frame);
}
fn versionCheck(self: *ChildNodes) bool {
const current = self._owning_frame.version;
fn versionCheck(self: *ChildNodes, frame: *const Frame) bool {
const current = frame._page.dom_version;
if (current == self._cached_version) {
return true;
}

View File

@@ -32,21 +32,18 @@ _tw: TreeWalker.FullExcludeSelf,
_last_index: usize,
_last_length: ?u32,
_cached_version: usize,
_owning_frame: *Frame,
pub fn init(root: *Node, frame: *Frame) HTMLAllCollection {
const owning_frame = root.ownerFrame(frame);
return .{
._last_index = 0,
._last_length = null,
._tw = TreeWalker.FullExcludeSelf.init(root, .{}),
._cached_version = owning_frame.version,
._owning_frame = owning_frame,
._cached_version = frame._page.dom_version,
};
}
fn versionCheck(self: *HTMLAllCollection, _: *const Frame) bool {
const current = self._owning_frame.version;
fn versionCheck(self: *HTMLAllCollection, frame: *const Frame) bool {
const current = frame._page.dom_version;
if (self._cached_version != current) {
self._cached_version = current;
self._last_index = 0;

View File

@@ -79,7 +79,7 @@ const Filters = union(Mode) {
// But, if the version hasn't changed, then we can leverage other stateful data
// to improve performance. For example, we cache the length property. So once
// we've walked the tree to figure the length, we can re-use the cached property
// if the DOM is unchanged (i.e. if our _cached_version == frame.version).
// if the DOM is unchanged (i.e. if our _cached_version == page.dom_version).
//
// We do something similar for indexed getter (e.g. coll[4]), by preserving the
// last node visited in the tree (implicitly by not resetting the TreeWalker).
@@ -99,19 +99,16 @@ pub fn NodeLive(comptime mode: Mode) type {
_last_index: usize,
_last_length: ?u32,
_cached_version: usize,
_owning_frame: *Frame,
const Self = @This();
pub fn init(root: *Node, filter: Filter, frame: *Frame) Self {
const owning_frame = root.ownerFrame(frame);
return .{
._last_index = 0,
._last_length = null,
._filter = filter,
._tw = TW.init(root, .{}),
._cached_version = owning_frame.version,
._owning_frame = owning_frame,
._cached_version = frame._page.dom_version,
};
}
@@ -345,8 +342,8 @@ pub fn NodeLive(comptime mode: Mode) type {
};
}
fn versionCheck(self: *Self, _: *const Frame) bool {
const current = self._owning_frame.version;
fn versionCheck(self: *Self, frame: *const Frame) bool {
const current = frame._page.dom_version;
if (current == self._cached_version) {
return true;
}

View File

@@ -229,7 +229,7 @@ pub const List = struct {
};
try frame.addElementId(parent, element, entry._value.str());
}
element.asNode().bumpDomVersion(frame);
frame.domChanged();
frame.attributeChange(element, result.normalized, entry._value, old_value);
return entry;
}
@@ -292,7 +292,7 @@ pub const List = struct {
frame.removeElementId(element, entry._value.str());
}
element.asNode().bumpDomVersion(frame);
frame.domChanged();
frame.attributeRemove(element, result.normalized, old_value);
_ = frame._attribute_lookup.remove(@intFromPtr(entry));
self._list.remove(&entry._node);

View File

@@ -273,7 +273,7 @@ pub fn setInnerText(self: *HtmlElement, text: []const u8, frame: *Frame) !void {
const parent = self.asElement().asNode();
// Remove all existing children
parent.bumpDomVersion(frame);
frame.domChanged();
var it = parent.childrenIterator();
while (it.next()) |child| {
frame.removeNode(parent, child, .{ .will_be_reconnected = false });

View File

@@ -80,7 +80,7 @@ pub fn setSelected(self: *Option, selected: bool, frame: *Frame) !void {
// TODO: When setting selected=true, may need to unselect other options
// in the parent <select> if it doesn't have multiple attribute
self._selected = selected;
self.asElement().asNode().bumpDomVersion(frame);
frame.domChanged();
}
pub fn getDefaultSelected(self: *const Option) bool {

View File

@@ -153,10 +153,14 @@ pub fn updateEntries(
pub fn commitNavigation(self: *Navigation, frame: *Frame) !void {
const url = frame.url;
const kind: NavigationKind = self._current_navigation_kind orelse .{ .push = null };
var kind: NavigationKind = self._current_navigation_kind orelse .{ .push = null };
defer self._current_navigation_kind = null;
const from_entry = self.getCurrentEntryOrNull();
if (from_entry == null) {
kind = .{ .push = null };
}
try self.updateEntries(url, kind, frame, false);
self._activation = NavigationActivation{
@@ -517,3 +521,31 @@ test "Navigation: about:blank commits entry" {
);
try testing.expect(result.isTrue());
}
test "Navigation: reload on empty stack seeds an entry" {
const frame = try testing.test_session.createPage();
defer testing.test_session.removePage();
testing.test_session.navigation._entries.clearRetainingCapacity();
testing.test_session.navigation._index = 0;
// Mirrors CDP Page.reload arriving on a fresh session: doReload reads
// frame.url (default "about:blank") and routes through the synchronous
// about:blank commit with kind=.reload. updateEntries is a no-op for
// .reload, so without the empty-stack guard this would assert
// `len: 0` in getCurrentEntry.
try frame.navigate("about:blank", .{ .kind = .reload });
var runner = try testing.test_session.runner(.{});
try runner.wait(.{ .ms = 1000 });
var ls: js.Local.Scope = undefined;
frame.js.localScope(&ls);
defer ls.deinit();
const result = try ls.local.exec(
"navigation.currentEntry.url === 'about:blank'",
"Navigation.test",
);
try testing.expect(result.isTrue());
}

View File

@@ -145,8 +145,8 @@ fn releaseSelfRef(self: *XMLHttpRequest) void {
if (self._active_request == false) {
return;
}
self.releaseRef(self._exec.context.page);
self._active_request = false;
self.releaseRef(self._exec.context.page);
}
pub fn releaseRef(self: *XMLHttpRequest, page: *Page) void {

View File

@@ -49,6 +49,12 @@ pub fn put(self: *Cache, metadata: CachedMetadata, body: []const u8) !void {
};
}
pub fn clear(self: *Cache) !void {
return switch (self.kind) {
inline else => |*c| c.clear(),
};
}
pub const CacheControl = struct {
max_age: u64,

View File

@@ -264,6 +264,22 @@ pub fn put(self: *FsCache, meta: CachedMetadata, body: []const u8) !void {
log.debug(.cache, "put", .{ .url = meta.url, .hash = &hashed_key, .body_len = body.len });
}
pub fn clear(self: *FsCache) !void {
for (&self.locks) |*lock| lock.lock();
defer for (&self.locks) |*lock| lock.unlock();
var iter = self.dir.iterate();
while (try iter.next()) |entry| {
if (entry.kind != .file) continue;
if (!std.mem.endsWith(u8, entry.name, ".cache") and
!std.mem.endsWith(u8, entry.name, ".cache.tmp")) continue;
self.dir.deleteFile(entry.name) catch |e| {
log.err(.cache, "clear delete fail", .{ .file = entry.name, .err = e });
};
}
}
const testing = std.testing;
fn setupCache() !struct { tmp: testing.TmpDir, cache: Cache } {
@@ -617,3 +633,100 @@ test "FsCache: vary multiple headers" {
},
}));
}
test "FsCache: clear removes all entries" {
var setup = try setupCache();
defer {
setup.cache.deinit();
setup.tmp.cleanup();
}
const cache = &setup.cache;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const now = std.time.timestamp();
const base_meta_a = CachedMetadata{
.url = "https://example.com/a",
.status = 200,
.stored_at = now,
.age_at_store = 0,
.cache_control = .{ .max_age = 600 },
.headers = &.{},
.vary_headers = &.{},
.content_type = "text/html",
};
const base_meta_b = CachedMetadata{
.url = "https://example.com/b",
.status = 200,
.stored_at = now,
.age_at_store = 0,
.cache_control = .{ .max_age = 600 },
.headers = &.{},
.vary_headers = &.{},
.content_type = "text/html",
};
try cache.put(base_meta_a, "body a");
try cache.put(base_meta_b, "body b");
// Sanity check: both are cached
const r1 = cache.get(arena.allocator(), .{ .url = "https://example.com/a", .timestamp = now, .request_headers = &.{} });
try testing.expect(r1 != null);
r1.?.data.file.file.close();
const r2 = cache.get(arena.allocator(), .{ .url = "https://example.com/b", .timestamp = now, .request_headers = &.{} });
try testing.expect(r2 != null);
r2.?.data.file.file.close();
try cache.clear();
try testing.expectEqual(null, cache.get(arena.allocator(), .{ .url = "https://example.com/a", .timestamp = now, .request_headers = &.{} }));
try testing.expectEqual(null, cache.get(arena.allocator(), .{ .url = "https://example.com/b", .timestamp = now, .request_headers = &.{} }));
}
test "FsCache: put after clear works" {
var setup = try setupCache();
defer {
setup.cache.deinit();
setup.tmp.cleanup();
}
const cache = &setup.cache;
var arena = std.heap.ArenaAllocator.init(testing.allocator);
defer arena.deinit();
const now = std.time.timestamp();
const meta = CachedMetadata{
.url = "https://example.com",
.content_type = "text/html",
.status = 200,
.stored_at = now,
.age_at_store = 0,
.cache_control = .{ .max_age = 600 },
.headers = &.{},
.vary_headers = &.{},
};
try cache.put(meta, "before clear");
try cache.clear();
// Should be a miss after clear
try testing.expectEqual(null, cache.get(arena.allocator(), .{ .url = "https://example.com", .timestamp = now, .request_headers = &.{} }));
// Put again after clear — should work normally
try cache.put(meta, "after clear");
const result = cache.get(arena.allocator(), .{ .url = "https://example.com", .timestamp = now, .request_headers = &.{} }) orelse return error.CacheMiss;
const f = result.data.file;
defer f.file.close();
var buf: [64]u8 = undefined;
var file_reader = f.file.reader(&buf);
try file_reader.seekTo(f.offset);
const read_buf = try file_reader.interface.readAlloc(testing.allocator, f.len);
defer testing.allocator.free(read_buf);
try testing.expectEqualStrings("after clear", read_buf);
}