Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 45 additions & 4 deletions src/cdp/client.zig
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,24 @@ pub const EventBuffer = struct {
return self.items.items.len;
}

pub fn push(self: *EventBuffer, owner: std.mem.Allocator, event: []const u8) void {
if (self.items.items.len >= 256) {
pub fn push(self: *EventBuffer, caller_alloc: std.mem.Allocator, event: []const u8) void {
// Dupe into the long-lived event_buf allocator. Request-scoped arenas
// passed by callers are freed when the HTTP request completes, which
// happens long before a later har/stop request drains and frees these
// events. Storing the arena pointer in `owner` produced dangling refs.
const owned = self.allocator.dupe(u8, event) catch {
caller_alloc.free(event);
return;
};
// Release the caller's copy — we now hold the canonical allocation.
caller_alloc.free(event);
if (self.items.items.len >= 4096) {
const oldest = self.items.items[0];
oldest.owner.free(oldest.data);
_ = self.items.orderedRemove(0);
}
self.items.append(self.allocator, .{ .data = event, .owner = owner }) catch {
owner.free(event);
self.items.append(self.allocator, .{ .data = owned, .owner = self.allocator }) catch {
self.allocator.free(owned);
};
}

Expand Down Expand Up @@ -292,3 +302,34 @@ test "EventBuffer drain frees all" {
buf.drain();
try std.testing.expectEqual(@as(usize, 0), buf.len());
}

test "EventBuffer push dupes into internal allocator - cross-arena safety" {
// Regression test for #124: events buffered during a navigate request
// (arena A) must remain valid when consumed by a later har/stop request
// (arena B). push() must dupe into self.allocator, not keep the caller's ptr.
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();

var buf = EventBuffer.init(gpa.allocator());
defer buf.deinit();

// Simulate arena A (navigate request) — allocate and push an event
var arena_a = std.heap.ArenaAllocator.init(gpa.allocator());
{
const event = try arena_a.allocator().dupe(u8, "{\"method\":\"Network.requestWillBeSent\",\"params\":{}}");
buf.push(arena_a.allocator(), event);
// event pointer is now freed inside push; arena_a can be safely torn down
}
arena_a.deinit(); // arena A is gone — dangling ptr if push stored arena_a ptr

// Simulate arena B (har/stop request) — consume events; must not crash/corrupt
var arena_b = std.heap.ArenaAllocator.init(gpa.allocator());
defer arena_b.deinit();
const drained = try buf.drainTo(arena_b.allocator());
defer arena_b.allocator().free(drained);

try std.testing.expectEqual(@as(usize, 1), drained.len);
try std.testing.expect(std.mem.indexOf(u8, drained[0].data, "Network.requestWillBeSent") != null);
// Free event data — must use the internal allocator (gpa), not arena_a
for (drained) |item| item.owner.free(item.data);
}
97 changes: 92 additions & 5 deletions src/server/router.zig
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,13 @@ fn handleSnapshot(request: *std.http.Server.Request, arena: std.mem.Allocator, b
return;
};

// Opportunistically flush buffered network events to HAR recorder
if (bridge.getHarRecorder(tab_id)) |rec| {
if (rec.isRecording()) {
flushEventsToHar(arena, client, rec);
}
}

// If format=raw, return the raw CDP response
if (format) |f| {
if (std.mem.eql(u8, f, "raw")) {
Expand Down Expand Up @@ -628,11 +635,41 @@ fn handleAction(request: *std.http.Server.Request, arena: std.mem.Allocator, bri
resp.sendError(request, 400, "Missing value parameter for press");
return;
};
const params = std.fmt.allocPrint(arena, "{{\"expression\":\"document.dispatchEvent(new KeyboardEvent('keydown', {{key: '{s}'}})) || 'pressed'\",\"returnByValue\":true}}", .{v}) catch {
// Focus the target element first so key events reach the right element
if (node_id) |bid| {
const resolve_params = std.fmt.allocPrint(arena, "{{\"backendNodeId\":{d}}}", .{bid}) catch {
resp.sendError(request, 500, "Internal Server Error");
return;
};
const resolve_response = client.send(arena, protocol.Methods.dom_resolve_node, resolve_params) catch {
resp.sendError(request, 502, "DOM.resolveNode failed");
return;
};
if (extractSimpleJsonString(resolve_response, 0, "\"objectId\"")) |obj_id| {
const focus_params = std.fmt.allocPrint(arena, "{{\"objectId\":\"{s}\",\"functionDeclaration\":\"function() {{ this.focus(); }}\",\"returnByValue\":true}}", .{obj_id}) catch {
resp.sendError(request, 500, "Internal Server Error");
return;
};
_ = client.send(arena, protocol.Methods.runtime_call_function_on, focus_params) catch {};
}
}
// Use CDP Input.dispatchKeyEvent for native key processing
// (Enter submits forms, Escape closes dialogs, Tab moves focus, etc.)
// Only include "text" for single printable characters; special keys use empty text.
const key_text = if (v.len == 1 and v[0] >= 0x20 and v[0] < 0x7f) v else "";
const down_params = std.fmt.allocPrint(arena, "{{\"type\":\"keyDown\",\"key\":\"{s}\",\"text\":\"{s}\",\"unmodifiedText\":\"{s}\"}}", .{ v, key_text, key_text }) catch {
resp.sendError(request, 500, "Internal Server Error");
return;
};
const response = client.send(arena, protocol.Methods.runtime_evaluate, params) catch {
_ = client.send(arena, protocol.Methods.input_dispatch_key_event, down_params) catch {
resp.sendError(request, 502, "CDP command failed");
return;
};
const up_params = std.fmt.allocPrint(arena, "{{\"type\":\"keyUp\",\"key\":\"{s}\"}}", .{v}) catch {
resp.sendError(request, 500, "Internal Server Error");
return;
};
const response = client.send(arena, protocol.Methods.input_dispatch_key_event, up_params) catch {
resp.sendError(request, 502, "CDP command failed");
return;
};
Expand Down Expand Up @@ -709,10 +746,18 @@ fn handleAction(request: *std.http.Server.Request, arena: std.mem.Allocator, bri
resp.sendError(request, 502, "Runtime.callFunctionOn failed");
return;
};
// Flush network events from the fill action
if (bridge.getHarRecorder(tab_id)) |rec| {
if (rec.isRecording()) {
flushEventsToHar(arena, client, rec);
}
}
resp.sendJson(request, change_response);
return;
}
const fn_str = std.fmt.allocPrint(arena, "function() {{ this.focus(); this.value = '{s}'; this.dispatchEvent(new Event('input', {{bubbles:true}})); return 'filled'; }}", .{v}) catch {
// Use nativeInputValueSetter to bypass React/Vue controlled input overrides,
// then dispatch input + change events so frameworks detect the mutation.
const fn_str = std.fmt.allocPrint(arena, "function() {{ this.focus(); var nativeSetter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value') || Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value'); if (nativeSetter && nativeSetter.set) {{ nativeSetter.set.call(this, '{s}'); }} else {{ this.value = '{s}'; }} this.dispatchEvent(new Event('input', {{bubbles:true}})); this.dispatchEvent(new Event('change', {{bubbles:true}})); return 'filled'; }}", .{ v, v }) catch {
resp.sendError(request, 500, "Internal Server Error");
return;
};
Expand All @@ -723,7 +768,7 @@ fn handleAction(request: *std.http.Server.Request, arena: std.mem.Allocator, bri
resp.sendError(request, 400, "Missing value parameter for select");
return;
};
const fn_str = std.fmt.allocPrint(arena, "function() {{ this.value = '{s}'; this.dispatchEvent(new Event('change', {{bubbles:true}})); return 'selected'; }}", .{v}) catch {
const fn_str = std.fmt.allocPrint(arena, "function() {{ this.value = '{s}'; this.dispatchEvent(new Event('input', {{bubbles:true}})); this.dispatchEvent(new Event('change', {{bubbles:true}})); return 'selected'; }}", .{v}) catch {
resp.sendError(request, 500, "Internal Server Error");
return;
};
Expand All @@ -741,6 +786,14 @@ fn handleAction(request: *std.http.Server.Request, arena: std.mem.Allocator, bri
resp.sendError(request, 502, "Runtime.callFunctionOn failed");
return;
};

// Flush network events triggered by the action (clicks often trigger XHR/fetch)
if (bridge.getHarRecorder(tab_id)) |rec| {
if (rec.isRecording()) {
flushEventsToHar(arena, client, rec);
}
}

resp.sendJson(request, call_response);
}

Expand Down Expand Up @@ -836,6 +889,14 @@ fn handleEvaluate(request: *std.http.Server.Request, arena: std.mem.Allocator, b
resp.sendError(request, 502, "CDP command failed");
return;
};

// Flush any network events generated by the evaluated expression
if (bridge.getHarRecorder(tab_id)) |rec| {
if (rec.isRecording()) {
flushEventsToHar(arena, client, rec);
}
}

resp.sendJson(request, response);
}

Expand Down Expand Up @@ -4164,7 +4225,9 @@ fn handleAuthExtract(request: *std.http.Server.Request, arena: std.mem.Allocator

var child = std.process.Child.init(&.{ "/bin/sh", "-c", query }, arena);
child.stdout_behavior = .Pipe;
child.stderr_behavior = .Pipe;
// Ignore stderr — if sqlite3 writes errors to stderr and nothing reads it,
// the pipe buffer fills up and the child blocks, causing a deadlock/120s hang.
child.stderr_behavior = .Ignore;
child.spawn() catch {
resp.sendError(request, 500, "Failed to run sqlite3 — is it installed?");
return;
Expand Down Expand Up @@ -4505,6 +4568,30 @@ test "action check/uncheck routes" {
try std.testing.expectEqualStrings("uncheck", getQueryParam(uncheck_target, "action").?);
}

test "action fill route params" {
const target = "/action?tab_id=t1&action=fill&ref=e2&value=tomsmith";
try std.testing.expectEqualStrings("fill", getQueryParam(target, "action").?);
const decoded = getDecodedQueryParamAlloc(std.testing.allocator, target, "value").?;
defer std.testing.allocator.free(decoded);
try std.testing.expectEqualStrings("tomsmith", decoded);
}

test "action press route params" {
const target = "/action?tab_id=t1&action=press&ref=e2&value=Enter";
try std.testing.expectEqualStrings("press", getQueryParam(target, "action").?);
try std.testing.expectEqualStrings("Enter", getQueryParam(target, "value").?);
}

test "press key_text special keys use empty text" {
// Verify special key names like "Enter" are not single printable chars
const enter_key = "Enter";
const is_single_printable = enter_key.len == 1 and enter_key[0] >= 0x20 and enter_key[0] < 0x7f;
try std.testing.expect(!is_single_printable);
const a_key = "a";
const a_is_printable = a_key.len == 1 and a_key[0] >= 0x20 and a_key[0] < 0x7f;
try std.testing.expect(a_is_printable);
}

test "total endpoint count" {
// Verify we have the expected number of routes
const routes = [_][]const u8{
Expand Down