mirror of
https://github.com/lightpanda-io/browser.git
synced 2026-09-17 08:27:11 -04:00
Merge branch 'main' into wasm-streaming
# Conflicts: # build.zig.zon
This commit is contained in:
commit
1541b3357c
25 files changed
+4417
-3737
No files matched your search
@@ -13,7 +13,7 @@ inputs:
|
||||
zig-v8:
|
||||
description: 'zig v8 version to install'
|
||||
required: false
|
||||
default: 'v0.5.5'
|
||||
default: 'v0.5.6'
|
||||
v8:
|
||||
description: 'v8 version to install'
|
||||
required: false
|
||||
|
||||
@@ -13,7 +13,7 @@ inputs:
|
||||
zig-v8:
|
||||
description: 'zig-v8 release tag the prebuilt lib came from'
|
||||
required: false
|
||||
default: 'v0.5.5'
|
||||
default: 'v0.5.6'
|
||||
|
||||
runs:
|
||||
using: "composite"
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ FROM debian:stable-slim
|
||||
ARG MINISIG=0.12
|
||||
ARG ZIG_MINISIG=RWSGOq2NVecA2UPNdBUZykf1CCb147pkmdtYxgb3Ti+JO/wCYvhbAb/U
|
||||
ARG V8=14.9.207.35
|
||||
ARG ZIG_V8=v0.5.5
|
||||
ARG ZIG_V8=v0.5.6
|
||||
ARG TARGETPLATFORM
|
||||
|
||||
RUN apt-get update -yq && \
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
<h1 align="center">Lightpanda Browser</h1>
|
||||
<p align="center">
|
||||
<strong>The headless browser built from scratch for AI agents and automation.</strong><br>
|
||||
Not a Chromium fork. Not a WebKit patch. A new browser, written in Zig.
|
||||
Not a Chromium fork. Not a WebKit patch. A new browser, written in Zig.</strong><br>
|
||||
16x lighter and 9x faster than Chromium.
|
||||
</p>
|
||||
|
||||
</div>
|
||||
@@ -192,6 +193,7 @@ reference.
|
||||
./lightpanda agent --task "top story on news.ycombinator.com?"
|
||||
./lightpanda agent --no-llm # basic REPL, no LLM
|
||||
./lightpanda run session.js # run a recorded script
|
||||
cat session.js | ./lightpanda run - # ...or pipe one in via stdin
|
||||
./lightpanda agent --provider gemini --task "..." # force a specific provider
|
||||
./lightpanda agent --list-models # models available for the detected provider
|
||||
VERTEX_API_KEY=... ./lightpanda agent --provider vertex # Vertex AI, express mode
|
||||
|
||||
+2
-2
@@ -5,8 +5,8 @@
|
||||
.minimum_zig_version = "0.16.0",
|
||||
.dependencies = .{
|
||||
.v8 = .{
|
||||
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/fb337c774a5313aa5e2dcb044ca723cf6e304ba2.tar.gz",
|
||||
.hash = "v8-0.0.0-xddH65ovAwBmolwHZrPk83tl-ZgdOMy2SYQ5ViaZ-P1f",
|
||||
.url = "https://github.com/lightpanda-io/zig-v8-fork/archive/d3d7b41677a0015fdfa55a8b1caa4f214de6d209.tar.gz",
|
||||
.hash = "v8-0.0.0-xddH624yAwC5_H_8T303uTxiSAnAu2zrxv6MHLhvLo6t",
|
||||
},
|
||||
// .v8 = .{ .path = "../zig-v8-fork" },
|
||||
.brotli = .{
|
||||
|
||||
+2319
-2349
File diff suppressed because it is too large.
Load diff
+1025
-1189
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -1200,7 +1200,7 @@ pub fn parseArgs(allocator: Allocator, proc_args: std.process.Args) !Config {
|
||||
if (command == .run) {
|
||||
const run = command.run;
|
||||
if (run.script_file == null) {
|
||||
log.fatal(.app, "missing script file", .{ .hint = "usage: lightpanda run <script.js>" });
|
||||
log.fatal(.app, "missing script file", .{ .hint = "usage: lightpanda run <script.js | ->" });
|
||||
return error.MissingArgument;
|
||||
}
|
||||
// run's fields are a strict subset of Agent's (compile error otherwise).
|
||||
|
||||
+22
-5
@@ -1527,12 +1527,20 @@ const ScriptOutput = struct {
|
||||
}
|
||||
};
|
||||
|
||||
/// Upper bound on script source, whether read from a file or piped in.
|
||||
const max_script_bytes = 10 * 1024 * 1024;
|
||||
|
||||
/// `lightpanda run -` reads the script from stdin.
|
||||
const stdin_script_path = "-";
|
||||
|
||||
fn runScript(self: *Agent, path: []const u8) bool {
|
||||
var script_arena: std.heap.ArenaAllocator = .init(self.allocator);
|
||||
defer script_arena.deinit();
|
||||
|
||||
const content = std.Io.Dir.cwd().readFileAlloc(lp.io, path, script_arena.allocator(), .limited(10 * 1024 * 1024)) catch |err| {
|
||||
self.terminal.printError("Failed to read script '{s}': {s}", .{ path, @errorName(err) });
|
||||
const from_stdin = std.mem.eql(u8, path, stdin_script_path);
|
||||
const name = if (from_stdin) "<stdin>" else path;
|
||||
const content = readScriptSource(script_arena.allocator(), path, from_stdin) catch |err| {
|
||||
self.terminal.printError("Failed to read script '{s}': {s}", .{ name, @errorName(err) });
|
||||
return false;
|
||||
};
|
||||
|
||||
@@ -1555,8 +1563,8 @@ fn runScript(self: *Agent, path: []const u8) bool {
|
||||
|
||||
var output: ScriptOutput = .{ .terminal = &self.terminal };
|
||||
runtime.console_observer = .{ .context = @ptrCast(&output), .notify = ScriptOutput.observe };
|
||||
self.terminal.beginTool("script", path);
|
||||
const result = runtime.runSource(content, path);
|
||||
self.terminal.beginTool("script", name);
|
||||
const result = runtime.runSource(content, name);
|
||||
self.terminal.endTool();
|
||||
|
||||
if (result catch |err| {
|
||||
@@ -1569,10 +1577,19 @@ fn runScript(self: *Agent, path: []const u8) bool {
|
||||
|
||||
// A script that printed nothing leaves no trace, so freeze the spinner into
|
||||
// a green bullet (like /goto); one that printed already showed its result.
|
||||
if (!output.emitted) self.terminal.printScriptDone("script", path);
|
||||
if (!output.emitted) self.terminal.printScriptDone("script", name);
|
||||
return true;
|
||||
}
|
||||
|
||||
fn readScriptSource(allocator: std.mem.Allocator, path: []const u8, from_stdin: bool) ![]u8 {
|
||||
if (!from_stdin) {
|
||||
return std.Io.Dir.cwd().readFileAlloc(lp.io, path, allocator, .limited(max_script_bytes));
|
||||
}
|
||||
var buf: [64 * 1024]u8 = undefined;
|
||||
var stdin = std.Io.File.stdin().readerStreaming(lp.io, &buf);
|
||||
return stdin.interface.allocRemaining(allocator, .limited(max_script_bytes));
|
||||
}
|
||||
|
||||
/// Mirror a user-typed slash command into `self.conversation.messages` as if the
|
||||
/// LLM had called the tool itself, so the next natural-language turn sees the
|
||||
/// same conversation shape either way.
|
||||
|
||||
@@ -1055,3 +1055,31 @@ test "Session: retiring a pending page destroys it once" {
|
||||
// Would deinit `pending` twice if it had been queued twice.
|
||||
session.processDestroyQueues();
|
||||
}
|
||||
|
||||
test "Session: console capture runs no page JS" {
|
||||
const js = @import("js/js.zig");
|
||||
|
||||
const session = testing.test_session;
|
||||
try session.enableConsoleCapture();
|
||||
defer {
|
||||
session.notification.unregister(.console_message, session);
|
||||
session._console_capture = false;
|
||||
session._console_messages.clearRetainingCapacity();
|
||||
}
|
||||
|
||||
const frame = try testing.createFrame();
|
||||
defer session.closeAllPages();
|
||||
|
||||
var ls: js.Local.Scope = undefined;
|
||||
frame.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
_ = try ls.local.exec(
|
||||
\\globalThis.probed = 0;
|
||||
\\const probe = { toString() { globalThis.probed++; console.log('inner'); return 'outer'; } };
|
||||
\\console.log('head', probe, 10n, Symbol('s'));
|
||||
, null);
|
||||
|
||||
try testing.expectEqualSlices(u8, "[log] head [object Object] 10n Symbol(s)\n", session.drainConsoleMessages());
|
||||
const probed = try ls.local.exec("globalThis.probed", null);
|
||||
try testing.expectEqual(0, try probed.toF64());
|
||||
}
|
||||
@@ -658,7 +658,7 @@ fn serializeFunctionArgs(local: *const Local, info: FunctionCallbackInfo) ![]con
|
||||
for (0..info.length()) |i| {
|
||||
try buf.writer.print("{s}{d} - ", .{ separator, i + 1 });
|
||||
const js_value = info.getArg(@intCast(i), local);
|
||||
try local.debugValue(js_value, &buf.writer);
|
||||
try js_value.format(&buf.writer);
|
||||
}
|
||||
return buf.written();
|
||||
}
|
||||
|
||||
@@ -1535,111 +1535,6 @@ pub fn createPromiseResolver(self: *const Local) js.PromiseResolver {
|
||||
return js.PromiseResolver.init(self);
|
||||
}
|
||||
|
||||
pub fn debugValue(self: *const Local, js_val: js.Value, writer: *std.Io.Writer) !void {
|
||||
// _debugValue walks arbitrary, caller-supplied object graphs (e.g. a
|
||||
// rejected promise's reason) via raw property gets. A getter or Proxy
|
||||
// trap encountered along the way can throw; without a TryCatch here,
|
||||
// that leaves the isolate's exception flag set after we return, and the
|
||||
// next unrelated JS entry point trips V8's has_exception() debug check.
|
||||
var try_catch: js.TryCatch = undefined;
|
||||
try_catch.init(self);
|
||||
defer try_catch.deinit();
|
||||
|
||||
var seen: std.AutoHashMapUnmanaged(u32, void) = .empty;
|
||||
return self._debugValue(js_val, &seen, 0, writer) catch error.WriteFailed;
|
||||
}
|
||||
|
||||
fn _debugValue(self: *const Local, js_val: js.Value, seen: *std.AutoHashMapUnmanaged(u32, void), depth: usize, writer: *std.Io.Writer) !void {
|
||||
if (js_val.isNull()) {
|
||||
// I think null can sometimes appear as an object, so check this and
|
||||
// handle it first.
|
||||
return writer.writeAll("null");
|
||||
}
|
||||
|
||||
if (!js_val.isObject()) {
|
||||
// handle these explicitly, so we don't include the type (we only want to include
|
||||
// it when there's some ambiguity, e.g. the string "true")
|
||||
if (js_val.isUndefined()) {
|
||||
return writer.writeAll("undefined");
|
||||
}
|
||||
if (js_val.isTrue()) {
|
||||
return writer.writeAll("true");
|
||||
}
|
||||
if (js_val.isFalse()) {
|
||||
return writer.writeAll("false");
|
||||
}
|
||||
|
||||
if (js_val.isSymbol()) {
|
||||
const symbol_handle = v8.v8__Symbol__Description(@ptrCast(js_val.handle), self.isolate.handle).?;
|
||||
if (v8.v8__Value__IsUndefined(symbol_handle)) {
|
||||
return writer.writeAll("undefined (symbol)");
|
||||
}
|
||||
return writer.print("{f} (symbol)", .{js.String{ .local = self, .handle = @ptrCast(symbol_handle) }});
|
||||
}
|
||||
const js_val_str = try js_val.toStringSlice();
|
||||
if (js_val_str.len > 2000) {
|
||||
try writer.writeAll(js_val_str[0..2000]);
|
||||
try writer.writeAll(" ... (truncated)");
|
||||
} else {
|
||||
try writer.writeAll(js_val_str);
|
||||
}
|
||||
return writer.print(" ({f})", .{js_val.typeOf()});
|
||||
}
|
||||
|
||||
const js_obj = js_val.toObject();
|
||||
{
|
||||
// explicit scope because gop will become invalid in recursive call
|
||||
const obj_id: u32 = @bitCast(v8.v8__Object__GetIdentityHash(js_obj.handle));
|
||||
const gop = try seen.getOrPut(self.call_arena, obj_id);
|
||||
if (gop.found_existing) {
|
||||
return writer.writeAll("<circular>\n");
|
||||
}
|
||||
gop.value_ptr.* = {};
|
||||
}
|
||||
|
||||
if (depth > 20) {
|
||||
return writer.writeAll("...deeply nested object...");
|
||||
}
|
||||
|
||||
const names_arr = js_obj.getOwnPropertyNames() catch {
|
||||
return writer.writeAll("...invalid object...");
|
||||
};
|
||||
const len = names_arr.len();
|
||||
|
||||
const own_len = blk: {
|
||||
const own_names = js_obj.getOwnPropertyNames() catch break :blk 0;
|
||||
break :blk own_names.len();
|
||||
};
|
||||
|
||||
if (own_len == 0) {
|
||||
const js_val_str = try js_val.toStringSlice();
|
||||
if (js_val_str.len > 2000) {
|
||||
try writer.writeAll(js_val_str[0..2000]);
|
||||
return writer.writeAll(" ... (truncated)");
|
||||
}
|
||||
return writer.writeAll(js_val_str);
|
||||
}
|
||||
|
||||
const all_len = js_obj.getPropertyNames().len();
|
||||
try writer.print("({d}/{d})", .{ own_len, all_len });
|
||||
for (0..len) |i| {
|
||||
if (i == 0) {
|
||||
try writer.writeByte('\n');
|
||||
}
|
||||
const field_name = try names_arr.get(@intCast(i));
|
||||
const name = try field_name.toStringSlice();
|
||||
try writer.splatByteAll(' ', depth);
|
||||
try writer.writeAll(name);
|
||||
try writer.writeAll(": ");
|
||||
|
||||
const field_val = try js_obj.get(name);
|
||||
try self._debugValue(field_val, seen, depth + 1, writer);
|
||||
if (i != len - 1) {
|
||||
try writer.writeByte('\n');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// == Misc ==
|
||||
pub fn parseJSON(self: *const Local, json: []const u8) !js.Value {
|
||||
const string_handle = self.isolate.initStringHandle(json);
|
||||
|
||||
@@ -91,11 +91,7 @@ pub fn toValue(self: Object) js.Value {
|
||||
}
|
||||
|
||||
pub fn format(self: Object, writer: *std.Io.Writer) !void {
|
||||
if (comptime lp.IS_DEBUG) {
|
||||
return self.local.ctx.debugValue(self.toValue(), writer);
|
||||
}
|
||||
const str = self.toString() catch return error.WriteFailed;
|
||||
return writer.writeAll(str);
|
||||
return self.toValue().format(writer);
|
||||
}
|
||||
|
||||
pub fn persist(self: Object) !Global {
|
||||
|
||||
+235
-5
@@ -718,13 +718,187 @@ pub fn toBigInt(self: Value) js.BigInt {
|
||||
}
|
||||
|
||||
pub fn format(self: Value, writer: *std.Io.Writer) !void {
|
||||
if (comptime lp.IS_DEBUG) {
|
||||
return self.local.debugValue(self, writer);
|
||||
}
|
||||
const js_str = self.toString() catch return error.WriteFailed;
|
||||
return js_str.format(writer);
|
||||
const inert: Inert = .{ .value = self };
|
||||
return inert.format(writer);
|
||||
}
|
||||
|
||||
// Stringify without running JS, avoiding potential side effects (e.g. getters,
|
||||
// proxies, ...).
|
||||
const Inert = struct {
|
||||
value: Value,
|
||||
|
||||
const max_array_depth = 32;
|
||||
const max_array_items = 1_000;
|
||||
|
||||
pub fn format(self: Inert, writer: *std.Io.Writer) !void {
|
||||
const local = self.value.local;
|
||||
// We might still end up calling an interceptor via
|
||||
// GetOwnPropertyDescriptor, which can throw.
|
||||
var try_catch: js.TryCatch = undefined;
|
||||
try_catch.init(local);
|
||||
defer try_catch.deinit();
|
||||
|
||||
var state: State = .{ .local = local };
|
||||
return state.write(self.value.handle, writer);
|
||||
}
|
||||
|
||||
const State = struct {
|
||||
depth: u32 = 0,
|
||||
local: *const js.Local,
|
||||
items_left: u32 = max_array_items,
|
||||
arrays: [max_array_depth]*const v8.Value = undefined,
|
||||
|
||||
fn write(self: *State, handle: *const v8.Value, writer: *std.Io.Writer) std.Io.Writer.Error!void {
|
||||
const local = self.local;
|
||||
const isolate = local.isolate.handle;
|
||||
|
||||
if (v8.v8__Value__IsString(handle)) {
|
||||
return self.writeString(@ptrCast(handle), writer);
|
||||
}
|
||||
if (v8.v8__Value__IsStringObject(handle)) {
|
||||
return self.writeString(v8.v8__StringObject__ValueOf(@ptrCast(handle)).?, writer);
|
||||
}
|
||||
if (v8.v8__Value__IsNumberObject(handle)) {
|
||||
return self.write(@ptrCast(v8.v8__Number__New(isolate, v8.v8__NumberObject__ValueOf(@ptrCast(handle))).?), writer);
|
||||
}
|
||||
if (v8.v8__Value__IsBooleanObject(handle)) {
|
||||
return writer.writeAll(if (v8.v8__BooleanObject__ValueOf(@ptrCast(handle))) "true" else "false");
|
||||
}
|
||||
if (v8.v8__Value__IsBigIntObject(handle)) {
|
||||
return self.write(@ptrCast(v8.v8__BigIntObject__ValueOf(@ptrCast(handle)).?), writer);
|
||||
}
|
||||
if (v8.v8__Value__IsSymbolObject(handle)) {
|
||||
return self.write(@ptrCast(v8.v8__SymbolObject__ValueOf(@ptrCast(handle)).?), writer);
|
||||
}
|
||||
if (v8.v8__Value__IsSymbol(handle)) {
|
||||
try writer.writeAll("Symbol(");
|
||||
const description = v8.v8__Symbol__Description(@ptrCast(handle), isolate).?;
|
||||
if (v8.v8__Value__IsString(description)) {
|
||||
try self.writeString(@ptrCast(description), writer);
|
||||
}
|
||||
return writer.writeByte(')');
|
||||
}
|
||||
if (v8.v8__Value__IsArray(handle)) {
|
||||
return self.writeArray(handle, writer);
|
||||
}
|
||||
if (v8.v8__Value__IsProxy(handle)) {
|
||||
return writer.writeAll("[object Proxy]");
|
||||
}
|
||||
if (v8.v8__Value__IsDate(handle)) {
|
||||
return self.writeString(v8.v8__Date__ToISOString(@ptrCast(handle)).?, writer);
|
||||
}
|
||||
if (v8.v8__Value__IsRegExp(handle)) {
|
||||
try writer.writeByte('/');
|
||||
try self.writeString(v8.v8__RegExp__GetSource(@ptrCast(handle)).?, writer);
|
||||
try writer.writeByte('/');
|
||||
const flags: u32 = @intCast(v8.v8__RegExp__GetFlags(@ptrCast(handle)));
|
||||
for (regexp_flags) |flag| {
|
||||
if (flags & flag[0] != 0) {
|
||||
try writer.writeByte(flag[1]);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (v8.v8__Value__IsFunction(handle)) {
|
||||
const source = v8.v8__Function__FunctionProtoToString(@ptrCast(handle), local.handle) orelse {
|
||||
return writer.writeAll("function");
|
||||
};
|
||||
return self.writeString(source, writer);
|
||||
}
|
||||
if (v8.v8__Value__IsNativeError(handle)) {
|
||||
try self.writeString(v8.v8__Object__GetConstructorName(@ptrCast(handle)).?, writer);
|
||||
const message = self.ownDataProperty(@ptrCast(handle), "message") orelse return;
|
||||
if (v8.v8__Value__IsUndefined(message)) {
|
||||
return;
|
||||
}
|
||||
if (v8.v8__Value__IsString(message) and v8.v8__String__Length(@ptrCast(message)) == 0) {
|
||||
return;
|
||||
}
|
||||
try writer.writeAll(": ");
|
||||
return self.write(message, writer);
|
||||
}
|
||||
if (v8.v8__Value__IsObject(handle)) {
|
||||
try writer.writeAll("[object ");
|
||||
try self.writeString(v8.v8__Object__GetConstructorName(@ptrCast(handle)).?, writer);
|
||||
return writer.writeByte(']');
|
||||
}
|
||||
|
||||
// number, bigint, boolean, null, undefined: converting a primitive runs no JS
|
||||
const str = v8.v8__Value__ToString(handle, local.handle) orelse return error.WriteFailed;
|
||||
try self.writeString(str, writer);
|
||||
if (v8.v8__Value__IsBigInt(handle)) {
|
||||
try writer.writeByte('n');
|
||||
}
|
||||
}
|
||||
|
||||
fn writeArray(self: *State, handle: *const v8.Value, writer: *std.Io.Writer) std.Io.Writer.Error!void {
|
||||
for (self.arrays[0..self.depth]) |seen| {
|
||||
if (v8.v8__Value__StrictEquals(seen, handle)) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const len = v8.v8__Array__Length(@ptrCast(handle));
|
||||
if (len > self.items_left or self.depth == max_array_depth) {
|
||||
// V8's builder drops the whole message here; a summary keeps the rest.
|
||||
return writer.print("Array({d})", .{len});
|
||||
}
|
||||
self.items_left -= len;
|
||||
self.arrays[self.depth] = handle;
|
||||
self.depth += 1;
|
||||
defer self.depth -= 1;
|
||||
|
||||
var key_buf: [10]u8 = undefined;
|
||||
for (0..len) |i| {
|
||||
if (i != 0) {
|
||||
try writer.writeByte(',');
|
||||
}
|
||||
const key = std.fmt.bufPrint(&key_buf, "{d}", .{i}) catch unreachable;
|
||||
const element = self.ownDataProperty(@ptrCast(handle), key) orelse continue;
|
||||
if (v8.v8__Value__IsNullOrUndefined(element)) {
|
||||
continue;
|
||||
}
|
||||
try self.write(element, writer);
|
||||
}
|
||||
}
|
||||
|
||||
fn writeString(self: *State, handle: *const v8.String, writer: *std.Io.Writer) std.Io.Writer.Error!void {
|
||||
const str: js.String = .{ .local = self.local, .handle = handle };
|
||||
return str.format(writer);
|
||||
}
|
||||
|
||||
fn ownDataProperty(self: *State, object: *const v8.Object, key: []const u8) ?*const v8.Value {
|
||||
const local = self.local;
|
||||
const descriptor = v8.v8__Object__GetOwnPropertyDescriptor(object, local.handle, local.isolate.initStringHandle(key)) orelse return null;
|
||||
if (v8.v8__Value__IsObject(descriptor) == false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// An accessor descriptor has no own `value`, and a Get would then reach Object.prototype.
|
||||
const value_key = local.isolate.initStringHandle("value");
|
||||
var has: v8.MaybeBool = undefined;
|
||||
v8.v8__Object__HasOwnProperty(@ptrCast(descriptor), local.handle, value_key, &has);
|
||||
if (has.has_value == false or has.value == false) {
|
||||
return null;
|
||||
}
|
||||
return v8.v8__Object__Get(@ptrCast(descriptor), local.handle, value_key);
|
||||
}
|
||||
};
|
||||
|
||||
// Same order as RegExp.prototype.flags, plus V8's `l`.
|
||||
const regexp_flags = [_]struct { u32, u8 }{
|
||||
.{ v8.kRegExpHasIndices, 'd' },
|
||||
.{ v8.kRegExpGlobal, 'g' },
|
||||
.{ v8.kRegExpIgnoreCase, 'i' },
|
||||
.{ v8.kRegExpLinear, 'l' },
|
||||
.{ v8.kRegExpMultiline, 'm' },
|
||||
.{ v8.kRegExpDotAll, 's' },
|
||||
.{ v8.kRegExpUnicode, 'u' },
|
||||
.{ v8.kRegExpUnicodeSets, 'v' },
|
||||
.{ v8.kRegExpSticky, 'y' },
|
||||
};
|
||||
};
|
||||
|
||||
// The JS iteration protocol (@@iterator)
|
||||
pub fn iterator(self: Value) !?Iterator {
|
||||
if (!self.isObject()) {
|
||||
@@ -791,6 +965,62 @@ pub const Global = struct {
|
||||
};
|
||||
|
||||
const testing = @import("../../testing.zig");
|
||||
test "Value: inert formatting runs no page JS" {
|
||||
const frame = try testing.createFrame();
|
||||
defer testing.test_session.closeAllPages();
|
||||
|
||||
var ls: js.Local.Scope = undefined;
|
||||
frame.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
|
||||
_ = try ls.local.exec(
|
||||
\\globalThis.probed = 0;
|
||||
\\globalThis.probe = function() { globalThis.probed++; return 'probed'; };
|
||||
, null);
|
||||
|
||||
const cases = [_]struct { expr: []const u8, expected: []const u8 }{
|
||||
.{ .expr = "'str'", .expected = "str" },
|
||||
.{ .expr = "1.5", .expected = "1.5" },
|
||||
.{ .expr = "-0", .expected = "0" },
|
||||
.{ .expr = "NaN", .expected = "NaN" },
|
||||
.{ .expr = "true", .expected = "true" },
|
||||
.{ .expr = "null", .expected = "null" },
|
||||
.{ .expr = "undefined", .expected = "undefined" },
|
||||
.{ .expr = "10n", .expected = "10n" },
|
||||
.{ .expr = "Symbol('s')", .expected = "Symbol(s)" },
|
||||
.{ .expr = "Symbol()", .expected = "Symbol()" },
|
||||
.{ .expr = "new Number(42)", .expected = "42" },
|
||||
.{ .expr = "new String('w')", .expected = "w" },
|
||||
.{ .expr = "new Boolean(false)", .expected = "false" },
|
||||
.{ .expr = "Object(5n)", .expected = "5n" },
|
||||
.{ .expr = "Object(Symbol('q'))", .expected = "Symbol(q)" },
|
||||
.{ .expr = "({ toString: probe, valueOf: probe, [Symbol.toPrimitive]: probe })", .expected = "[object Object]" },
|
||||
.{ .expr = "Object.defineProperty({}, Symbol.toStringTag, { get: probe })", .expected = "[object Object]" },
|
||||
.{ .expr = "(() => { const d = document.createElement('div'); Object.defineProperty(d, 'id', { get: probe }); return d; })()", .expected = "[object HTMLDivElement]" },
|
||||
.{ .expr = "new (class Foo {})()", .expected = "[object Foo]" },
|
||||
.{ .expr = "new Proxy({}, { get: probe, getOwnPropertyDescriptor: probe, getPrototypeOf: probe })", .expected = "[object Proxy]" },
|
||||
.{ .expr = "[1, null, undefined, 'a', [2, [3]]]", .expected = "1,,,a,2,3" },
|
||||
.{ .expr = "(() => { const a = [1]; a.push(a); return a; })()", .expected = "1," },
|
||||
.{ .expr = "Object.defineProperty([1], 1, { get: probe })", .expected = "1," },
|
||||
.{ .expr = "Object.assign(new TypeError('boom'), { toString: probe })", .expected = "TypeError: boom" },
|
||||
.{ .expr = "new RangeError()", .expected = "RangeError" },
|
||||
.{ .expr = "Object.defineProperty(new Error(), 'message', { get: probe })", .expected = "Error" },
|
||||
.{ .expr = "Object.defineProperty(Object.assign(new Date(0), { toString: probe }), Symbol.toPrimitive, { value: probe })", .expected = "1970-01-01T00:00:00.000Z" },
|
||||
.{ .expr = "new Date(NaN)", .expected = "Invalid Date" },
|
||||
.{ .expr = "Object.assign(/a+/gi, { toString: probe })", .expected = "/a+/gi" },
|
||||
.{ .expr = "Object.assign(function named() {}, { toString: probe })", .expected = "function named() {}" },
|
||||
};
|
||||
for (cases) |case| {
|
||||
const value = try ls.local.exec(case.expr, null);
|
||||
const out = try std.fmt.allocPrint(testing.allocator, "{f}", .{value});
|
||||
defer testing.allocator.free(out);
|
||||
try testing.expectEqualSlices(u8, case.expected, out);
|
||||
}
|
||||
|
||||
const probed = try ls.local.exec("globalThis.probed", null);
|
||||
try testing.expectEqual(0, try probed.toF64());
|
||||
}
|
||||
|
||||
test "Value: persisted handle early-release swap-removes and fixes up indices" {
|
||||
const frame = try testing.createFrame();
|
||||
defer testing.test_session.closeAllPages();
|
||||
|
||||
@@ -244,3 +244,23 @@
|
||||
testing.expectEqual('red', impDiv.style.getPropertyValue('color'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id="computedStyleReadOnly">
|
||||
{
|
||||
const div = document.createElement('div');
|
||||
div.setAttribute('style', 'color: red; margin: 1px');
|
||||
document.body.appendChild(div);
|
||||
|
||||
const cs = window.getComputedStyle(div);
|
||||
testing.expectError('NoModificationAllowedError', () => cs.setProperty('width', '10px'));
|
||||
testing.expectError('NoModificationAllowedError', () => cs.setProperty('width', '10px', 'bogus'));
|
||||
testing.expectError('NoModificationAllowedError', () => cs.removeProperty('color'));
|
||||
testing.expectError('NoModificationAllowedError', () => { cs.cssText = ''; });
|
||||
testing.expectError('NoModificationAllowedError', () => { cs.cssFloat = 'left'; });
|
||||
testing.expectError('NoModificationAllowedError', () => { cs.color = 'blue'; });
|
||||
|
||||
testing.expectEqual('color: red; margin: 1px', div.getAttribute('style'));
|
||||
div.style.width = '50px';
|
||||
testing.expectEqual('50px', cs.getPropertyValue('width'));
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,170 @@
|
||||
<!DOCTYPE html>
|
||||
<script src="../testing.js"></script>
|
||||
|
||||
<script id=absent_properties_do_not_mutate_style>
|
||||
{
|
||||
for (const initial of [null, '', 'color:red']) {
|
||||
const element = document.createElement('div');
|
||||
if (initial !== null) element.setAttribute('style', initial);
|
||||
const observer = new MutationObserver(() => {});
|
||||
observer.observe(element, { attributes: true });
|
||||
testing.expectEqual('', element.style.removeProperty('display'));
|
||||
element.style.display = '';
|
||||
element.style.setProperty('display', '');
|
||||
element.style.cssFloat = '';
|
||||
element.style.removeProperty('--absent');
|
||||
testing.expectEqual(0, observer.takeRecords().length);
|
||||
testing.expectEqual(initial, element.getAttribute('style'));
|
||||
observer.disconnect();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=identical_declarations_preserve_raw_style>
|
||||
{
|
||||
const element = document.createElement('div');
|
||||
const raw = 'color:red; margin-top:0px; float:left; --token:x';
|
||||
element.setAttribute('style', raw);
|
||||
const observer = new MutationObserver(() => {});
|
||||
observer.observe(element, { attributes: true });
|
||||
element.style.color = 'red';
|
||||
element.style.setProperty('COLOR', 'red');
|
||||
element.style.marginTop = '0';
|
||||
element.style.cssFloat = 'left';
|
||||
element.style.setProperty('--token', 'x');
|
||||
testing.expectEqual(0, observer.takeRecords().length);
|
||||
testing.expectEqual(raw, element.getAttribute('style'));
|
||||
observer.disconnect();
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=priority_changes_and_empty_values>
|
||||
{
|
||||
const element = document.createElement('div');
|
||||
element.style.setProperty('color', 'red', 'important');
|
||||
const observer = new MutationObserver(() => {});
|
||||
observer.observe(element, { attributes: true, attributeOldValue: true });
|
||||
element.style.setProperty('color', 'red', 'IMPORTANT');
|
||||
testing.expectEqual(0, observer.takeRecords().length);
|
||||
element.style.setProperty('color', 'blue', 'invalid');
|
||||
testing.expectEqual(0, observer.takeRecords().length);
|
||||
testing.expectEqual('red', element.style.color);
|
||||
element.style.setProperty('color', 'red');
|
||||
const priorityRecords = observer.takeRecords();
|
||||
testing.expectEqual(1, priorityRecords.length);
|
||||
testing.expectEqual('color: red !important;', priorityRecords[0].oldValue);
|
||||
testing.expectEqual('', element.style.getPropertyPriority('color'));
|
||||
element.style.setProperty('color', '', 'important');
|
||||
const removed = observer.takeRecords();
|
||||
testing.expectEqual(1, removed.length);
|
||||
testing.expectEqual('color: red;', removed[0].oldValue);
|
||||
testing.expectEqual('', element.getAttribute('style'));
|
||||
element.style.setProperty('color', '', 'important');
|
||||
testing.expectEqual(0, observer.takeRecords().length);
|
||||
observer.disconnect();
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=real_changes_and_explicit_assignments_still_notify>
|
||||
{
|
||||
const element = document.createElement('div');
|
||||
element.style.color = 'red';
|
||||
const observer = new MutationObserver(() => {});
|
||||
observer.observe(element, { attributes: true, attributeOldValue: true });
|
||||
element.style.color = 'blue';
|
||||
let records = observer.takeRecords();
|
||||
testing.expectEqual(1, records.length);
|
||||
testing.expectEqual('style', records[0].attributeName);
|
||||
testing.expectEqual('color: red;', records[0].oldValue);
|
||||
testing.expectEqual('blue', element.style.color);
|
||||
element.style.cssText = element.style.cssText;
|
||||
testing.expectEqual(1, observer.takeRecords().length);
|
||||
element.setAttribute('style', element.getAttribute('style'));
|
||||
testing.expectEqual(1, observer.takeRecords().length);
|
||||
testing.expectEqual('blue', element.style.removeProperty('COLOR'));
|
||||
records = observer.takeRecords();
|
||||
testing.expectEqual(1, records.length);
|
||||
testing.expectEqual('color: blue;', records[0].oldValue);
|
||||
testing.expectEqual('', element.style.removeProperty('color'));
|
||||
testing.expectEqual(0, observer.takeRecords().length);
|
||||
element.style.cssText = '';
|
||||
testing.expectEqual(1, observer.takeRecords().length);
|
||||
observer.disconnect();
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=css_float_priority_changes_notify>
|
||||
{
|
||||
const element = document.createElement('div');
|
||||
element.style.setProperty('float', 'left', 'important');
|
||||
const observer = new MutationObserver(() => {});
|
||||
observer.observe(element, { attributes: true });
|
||||
element.style.cssFloat = 'left';
|
||||
testing.expectEqual(1, observer.takeRecords().length);
|
||||
testing.expectEqual('', element.style.getPropertyPriority('float'));
|
||||
element.style.cssFloat = 'left';
|
||||
testing.expectEqual(0, observer.takeRecords().length);
|
||||
observer.disconnect();
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=custom_property_case_is_significant>
|
||||
{
|
||||
const element = document.createElement('div');
|
||||
element.style.setProperty('--Token', 'x');
|
||||
const observer = new MutationObserver(() => {});
|
||||
observer.observe(element, { attributes: true });
|
||||
element.style.setProperty('--token', 'x');
|
||||
testing.expectEqual(1, observer.takeRecords().length);
|
||||
element.style.setProperty('--Token', 'y');
|
||||
testing.expectEqual(1, observer.takeRecords().length);
|
||||
element.style.setProperty('--Token', 'y');
|
||||
testing.expectEqual(0, observer.takeRecords().length);
|
||||
testing.expectEqual('x', element.style.getPropertyValue('--token'));
|
||||
testing.expectEqual('y', element.style.getPropertyValue('--Token'));
|
||||
observer.disconnect();
|
||||
}
|
||||
</script>
|
||||
|
||||
<script id=observer_reapplying_style_converges>
|
||||
(async () => {
|
||||
const state = await testing.async();
|
||||
const element = document.createElement('div');
|
||||
let calls = 0;
|
||||
const observer = new MutationObserver(() => {
|
||||
if (++calls >= 4) observer.disconnect();
|
||||
element.style.color = 'red';
|
||||
element.style.removeProperty('display');
|
||||
element.style.cssFloat = '';
|
||||
});
|
||||
observer.observe(element, { attributes: true });
|
||||
element.style.color = 'red';
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
observer.disconnect();
|
||||
state.resolve();
|
||||
await state.done(() => {
|
||||
testing.expectEqual(1, calls);
|
||||
testing.expectEqual('red', element.style.color);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script id=healthy_batches_are_not_disconnected>
|
||||
(async () => {
|
||||
const state = await testing.async();
|
||||
const element = document.createElement('div');
|
||||
let delivered = 0;
|
||||
const observers = Array.from({ length: 64 }, () => {
|
||||
const observer = new MutationObserver(() => delivered++);
|
||||
observer.observe(element, { attributes: true });
|
||||
return observer;
|
||||
});
|
||||
for (let round = 0; round < 32; round++) {
|
||||
element.setAttribute('data-round', String(round));
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
observers.forEach(observer => observer.disconnect());
|
||||
state.resolve();
|
||||
await state.done(() => testing.expectEqual(2048, delivered));
|
||||
})();
|
||||
</script>
|
||||
@@ -153,6 +153,10 @@ pub fn getPropertyPriority(self: *const CSSStyleDeclaration, property_name: []co
|
||||
}
|
||||
|
||||
pub fn setProperty(self: *CSSStyleDeclaration, property_name: []const u8, value: []const u8, priority_: ?[]const u8, frame: *Frame) !void {
|
||||
if (self._is_computed) {
|
||||
return error.NoModificationAllowed;
|
||||
}
|
||||
|
||||
// Validate priority
|
||||
const priority = priority_ orelse "";
|
||||
const important = if (priority.len > 0) blk: {
|
||||
@@ -162,9 +166,9 @@ pub fn setProperty(self: *CSSStyleDeclaration, property_name: []const u8, value:
|
||||
break :blk true;
|
||||
} else false;
|
||||
|
||||
try self.setPropertyImpl(property_name, value, important, frame);
|
||||
|
||||
try self.syncStyleAttribute(frame);
|
||||
if (try self.setPropertyImpl(property_name, value, important, frame)) {
|
||||
try self.syncStyleAttribute(frame);
|
||||
}
|
||||
}
|
||||
|
||||
/// Apply one declaration parsed from a `style=` block. Unlike the imperative
|
||||
@@ -177,7 +181,7 @@ fn applyParsedDeclaration(self: *CSSStyleDeclaration, declaration: CssParser.Dec
|
||||
if (existing._important) return;
|
||||
}
|
||||
}
|
||||
try self.setPropertyImpl(declaration.name, declaration.value, declaration.important, frame);
|
||||
_ = try self.setPropertyImpl(declaration.name, declaration.value, declaration.important, frame);
|
||||
}
|
||||
|
||||
fn initOwnedString(allocator: Allocator, value: []const u8) !String {
|
||||
@@ -186,10 +190,9 @@ fn initOwnedString(allocator: Allocator, value: []const u8) !String {
|
||||
return String.wrap(try allocator.dupe(u8, value));
|
||||
}
|
||||
|
||||
fn setPropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, value: []const u8, important: bool, frame: *Frame) !void {
|
||||
fn setPropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, value: []const u8, important: bool, frame: *Frame) !bool {
|
||||
if (value.len == 0) {
|
||||
_ = try self.removePropertyImpl(property_name, frame);
|
||||
return;
|
||||
return (try self.removePropertyImpl(property_name, frame)) != null;
|
||||
}
|
||||
|
||||
const normalized = normalizePropertyName(property_name, &frame.buf);
|
||||
@@ -199,12 +202,13 @@ fn setPropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, value:
|
||||
|
||||
// Find existing property
|
||||
if (self.findProperty(.wrap(normalized))) |existing| {
|
||||
if (existing._value.eql(.wrap(normalized_value)) and existing._important == important) return false;
|
||||
const allocator = frame._factory.storageAllocator();
|
||||
const new_value = try initOwnedString(allocator, normalized_value);
|
||||
existing._value.deinit(allocator);
|
||||
existing._value = new_value;
|
||||
existing._important = important;
|
||||
return;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Create new property
|
||||
@@ -215,17 +219,21 @@ fn setPropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, value:
|
||||
._important = important,
|
||||
});
|
||||
self._properties.append(&prop._node);
|
||||
return true;
|
||||
}
|
||||
|
||||
pub fn removeProperty(self: *CSSStyleDeclaration, property_name: []const u8, frame: *Frame) ![]const u8 {
|
||||
const result = try self.removePropertyImpl(property_name, frame);
|
||||
if (self._is_computed) {
|
||||
return error.NoModificationAllowed;
|
||||
}
|
||||
const result = (try self.removePropertyImpl(property_name, frame)) orelse return "";
|
||||
try self.syncStyleAttribute(frame);
|
||||
return result;
|
||||
}
|
||||
|
||||
fn removePropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, frame: *Frame) ![]const u8 {
|
||||
fn removePropertyImpl(self: *CSSStyleDeclaration, property_name: []const u8, frame: *Frame) !?[]const u8 {
|
||||
const normalized = normalizePropertyName(property_name, &frame.buf);
|
||||
const prop = self.findProperty(.wrap(normalized)) orelse return "";
|
||||
const prop = self.findProperty(.wrap(normalized)) orelse return null;
|
||||
|
||||
// the value might not be on the heap (it could be inlined in the small string
|
||||
// optimization), so we need to dupe it.
|
||||
@@ -277,8 +285,12 @@ fn getFloat(self: *const CSSStyleDeclaration, frame: *Frame) []const u8 {
|
||||
}
|
||||
|
||||
fn setFloat(self: *CSSStyleDeclaration, value_: ?[]const u8, frame: *Frame) !void {
|
||||
try self.setPropertyImpl("float", value_ orelse "", false, frame);
|
||||
try self.syncStyleAttribute(frame);
|
||||
if (self._is_computed) {
|
||||
return error.NoModificationAllowed;
|
||||
}
|
||||
if (try self.setPropertyImpl("float", value_ orelse "", false, frame)) {
|
||||
try self.syncStyleAttribute(frame);
|
||||
}
|
||||
}
|
||||
|
||||
fn getCssText(self: *const CSSStyleDeclaration, frame: *Frame) ![]const u8 {
|
||||
@@ -288,6 +300,15 @@ fn getCssText(self: *const CSSStyleDeclaration, frame: *Frame) ![]const u8 {
|
||||
}
|
||||
|
||||
pub fn setCssText(self: *CSSStyleDeclaration, text: []const u8, frame: *Frame) !void {
|
||||
if (self._is_computed) {
|
||||
return error.NoModificationAllowed;
|
||||
}
|
||||
try self.replaceCssText(text, frame);
|
||||
}
|
||||
|
||||
// setCssText without the read-only check, for declarations that are never
|
||||
// computed (a CSSStyleRule's style).
|
||||
pub fn replaceCssText(self: *CSSStyleDeclaration, text: []const u8, frame: *Frame) !void {
|
||||
self.clearProperties(frame);
|
||||
|
||||
try self.applyDeclarations(text, frame);
|
||||
@@ -971,10 +992,10 @@ test "CSS property value storage is reused" {
|
||||
var style = CSSStyleDeclaration{};
|
||||
defer style.clearProperties(frame);
|
||||
|
||||
try style.setPropertyImpl("transform", "translate3d(1px,0,0)", false, frame);
|
||||
try testing.expect(try style.setPropertyImpl("transform", "translate3d(1px,0,0)", false, frame));
|
||||
const first_ptr = style.findProperty(comptime .wrap("transform")).?._value.suffix.ptr;
|
||||
try style.setPropertyImpl("transform", "translate3d(2px,0,0)", false, frame);
|
||||
try style.setPropertyImpl("transform", "translate3d(3px,0,0)", false, frame);
|
||||
try testing.expect(try style.setPropertyImpl("transform", "translate3d(2px,0,0)", false, frame));
|
||||
try testing.expect(try style.setPropertyImpl("transform", "translate3d(3px,0,0)", false, frame));
|
||||
|
||||
const property = style.findProperty(comptime .wrap("transform")).?;
|
||||
try testing.expectEqual(first_ptr, property._value.suffix.ptr);
|
||||
|
||||
@@ -93,7 +93,7 @@ pub fn insertRule(self: *CSSStyleSheet, rule: []const u8, maybe_index: ?u32, fra
|
||||
|
||||
const style_props = try style_rule.getStyle(frame);
|
||||
const style = style_props.asCSSStyleDeclaration();
|
||||
try style.setCssText(s.block, frame);
|
||||
try style.replaceCssText(s.block, frame);
|
||||
break :blk style_rule._proto;
|
||||
},
|
||||
// Opaque placeholder for at-rules. The CSS engine doesn't apply
|
||||
@@ -182,7 +182,7 @@ fn parseInto(self: *CSSStyleSheet, text: []const u8, frame: *Frame) CSSError!voi
|
||||
|
||||
const style_props = try style_rule.getStyle(frame);
|
||||
const style = style_props.asCSSStyleDeclaration();
|
||||
try style.setCssText(s.block, frame);
|
||||
try style.replaceCssText(s.block, frame);
|
||||
break :blk style_rule._proto;
|
||||
},
|
||||
.at_rule => |a| try CSSRule.initAtRule(atRuleTypeFor(a.keyword), a.text, frame),
|
||||
|
||||
+6
-5
@@ -18,14 +18,15 @@
|
||||
|
||||
//! Opt-in core-dump suppression.
|
||||
//!
|
||||
//! Lightpanda has no SIGSEGV handler, so a segfault (or the `abort()` in the
|
||||
//! panic path) falls through to the kernel and writes a core dump. When many
|
||||
//! On Linux, fatal signals are re-raised after nonblocking diagnostics.
|
||||
//! Core limits still apply, but the core captures the re-raise context;
|
||||
//! the diagnostic record holds the original fault PC. Other platforms keep
|
||||
//! their existing signal handling. Signals and panics can produce cores. When many
|
||||
//! instances run under a shared `core_pattern` crash reporter — e.g. a
|
||||
//! containerized crawl fleet — those dumps become pure storage and alert
|
||||
//! noise, and a browser core can capture the contents of arbitrary pages.
|
||||
//! Crashes are already reported via telemetry, so `LIGHTPANDA_DISABLE_CORE_DUMP`
|
||||
//! lets an operator drop the cores while leaving the default behavior
|
||||
//! (and local debugging) untouched.
|
||||
//! `LIGHTPANDA_DISABLE_CORE_DUMP` lets an operator drop the cores while
|
||||
//! leaving the default behavior (and local debugging) untouched.
|
||||
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
|
||||
+424
-11
@@ -163,18 +163,431 @@ fn report(reason: []const u8, begin_addr: usize, args: anytype) !void {
|
||||
}
|
||||
|
||||
fn curlPath(buf: []u8) ?usize {
|
||||
const path_z = std.c.getenv("PATH") orelse return null;
|
||||
var it = std.mem.tokenizeScalar(u8, std.mem.span(path_z), std.fs.path.delimiter);
|
||||
|
||||
var fba = std.heap.FixedBufferAllocator.init(buf);
|
||||
const allocator = fba.allocator();
|
||||
|
||||
const cwd = std.Io.Dir.cwd();
|
||||
while (it.next()) |p| {
|
||||
defer fba.reset();
|
||||
const full_path = std.fs.path.joinZ(allocator, &.{ p, "curl" }) catch continue;
|
||||
cwd.access(lp.io, full_path, .{}) catch continue;
|
||||
return full_path.len;
|
||||
|
||||
if (std.c.getenv("PATH")) |path_z| {
|
||||
var it = std.mem.tokenizeScalar(u8, std.mem.span(path_z), std.fs.path.delimiter);
|
||||
|
||||
var fba = std.heap.FixedBufferAllocator.init(buf);
|
||||
const allocator = fba.allocator();
|
||||
|
||||
while (it.next()) |p| {
|
||||
defer fba.reset();
|
||||
const full_path = std.fs.path.joinZ(allocator, &.{ p, "curl" }) catch continue;
|
||||
cwd.access(lp.io, full_path, .{}) catch continue;
|
||||
return full_path.len;
|
||||
}
|
||||
}
|
||||
|
||||
// A supervisor that replaces the environment rather than extending it
|
||||
// leaves us with no PATH at all, and every crash report with it.
|
||||
for ([_][]const u8{ "/usr/bin/curl", "/bin/curl", "/usr/local/bin/curl" }) |candidate| {
|
||||
if (candidate.len >= buf.len) continue;
|
||||
@memcpy(buf[0..candidate.len], candidate);
|
||||
buf[candidate.len] = 0;
|
||||
cwd.access(lp.io, buf[0..candidate.len :0], .{}) catch continue;
|
||||
return candidate.len;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const fatal_signals = [_]std.posix.SIG{ .SEGV, .BUS, .ILL, .FPE };
|
||||
const max_backtrace_frames = 32;
|
||||
// A frame further than a whole thread stack from its caller is not a frame.
|
||||
const max_frame_distance = 8 << 20;
|
||||
|
||||
// Initialized before threads start; owned until process exit.
|
||||
var signal_output_fd: std.c.fd_t = -1;
|
||||
var signal_handlers_attached = false;
|
||||
|
||||
// Best-effort record of a fatal signal, written before the process dies of
|
||||
// it. Unlike panics, the interrupted thread may hold any lock, so this path
|
||||
// never touches the panic mutex, lp.io, the unwinder, the allocator or
|
||||
// telemetry: fixed-buffer scalar formatting, nonblocking output, re-raise.
|
||||
//
|
||||
// V8's WebAssembly trap handler is not enabled; enabling it would require
|
||||
// giving it first chance at SIGSEGV/SIGBUS here.
|
||||
pub fn attachSignalHandlers() void {
|
||||
if (builtin.os.tag != .linux or signal_handlers_attached) return;
|
||||
signal_handlers_attached = true;
|
||||
signal_output_fd = openSignalOutput();
|
||||
var mask = std.posix.sigemptyset();
|
||||
std.posix.sigaddset(&mask, .PIPE);
|
||||
const act: std.posix.Sigaction = .{
|
||||
.handler = .{ .sigaction = handleFatalSignal },
|
||||
.mask = mask,
|
||||
.flags = std.posix.SA.SIGINFO | std.posix.SA.RESETHAND | std.posix.SA.NODEFER,
|
||||
};
|
||||
for (fatal_signals) |sig| std.posix.sigaction(sig, &act, null);
|
||||
}
|
||||
|
||||
fn openSignalOutput() std.c.fd_t {
|
||||
if (builtin.os.tag != .linux) return -1;
|
||||
const S = std.os.linux.S;
|
||||
|
||||
const raw_flags = std.c.fcntl(2, std.posix.F.GETFL);
|
||||
if (raw_flags < 0) return -1;
|
||||
const flags: std.posix.O = @bitCast(@as(u32, @intCast(raw_flags)));
|
||||
if (flags.ACCMODE == .RDONLY) return -1;
|
||||
|
||||
var original: std.os.linux.Statx = undefined;
|
||||
if (!statFd(2, &original)) return -1;
|
||||
const is_regular = S.ISREG(original.mode);
|
||||
if (!is_regular and !S.ISFIFO(original.mode) and !S.ISCHR(original.mode)) return -1;
|
||||
|
||||
// Unlike dup(), procfs gives us independent O_NONBLOCK flags. A regular
|
||||
// file additionally needs O_APPEND: the new description starts at offset
|
||||
// zero and would otherwise overwrite the head of the log.
|
||||
const fd = std.c.open("/proc/self/fd/2", .{
|
||||
.ACCMODE = .WRONLY,
|
||||
.NONBLOCK = true,
|
||||
.CLOEXEC = true,
|
||||
.APPEND = is_regular,
|
||||
});
|
||||
if (fd < 0) return -1;
|
||||
var reopened: std.os.linux.Statx = undefined;
|
||||
if (!statFd(fd, &reopened) or reopened.dev_major != original.dev_major or reopened.dev_minor != original.dev_minor or reopened.ino != original.ino) {
|
||||
_ = std.c.close(fd);
|
||||
return -1;
|
||||
}
|
||||
return fd;
|
||||
}
|
||||
|
||||
fn statFd(fd: std.c.fd_t, stat: *std.os.linux.Statx) bool {
|
||||
const linux = std.os.linux;
|
||||
if (linux.statx(fd, "", linux.AT.EMPTY_PATH, .{ .TYPE = true, .INO = true }, stat) != 0) {
|
||||
return false;
|
||||
}
|
||||
return stat.mask.TYPE and stat.mask.INO;
|
||||
}
|
||||
|
||||
fn handleFatalSignal(sig: std.posix.SIG, info: *const std.posix.siginfo_t, ctx_ptr: ?*anyopaque) callconv(.c) noreturn {
|
||||
// A secondary fault must not re-enter any reporting machinery.
|
||||
const default: std.posix.Sigaction = .{ .handler = .{ .handler = std.posix.SIG.DFL }, .mask = std.posix.sigemptyset(), .flags = 0 };
|
||||
for (fatal_signals) |fatal| _ = std.c.sigaction(fatal, &default, null);
|
||||
|
||||
const opt_context: ?std.debug.cpu_context.Native = if (ctx_ptr == null) null else std.debug.cpu_context.fromPosixSignalContext(ctx_ptr);
|
||||
const context: ?*const std.debug.cpu_context.Native = if (opt_context) |*ctx| ctx else null;
|
||||
|
||||
var buffer: [512]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buffer);
|
||||
writeSignalContext(&writer, sig, info, context) catch {};
|
||||
writeRecord(writer.buffered());
|
||||
|
||||
// Written separately because walking the frame chain reads memory the
|
||||
// fault may already have invalidated: the record above has to survive a
|
||||
// second fault in here.
|
||||
if (context) |ctx| {
|
||||
writeBacktrace(ctx);
|
||||
}
|
||||
|
||||
_ = std.c.raise(sig);
|
||||
std.c._exit(@intCast(128 + @intFromEnum(sig)));
|
||||
}
|
||||
|
||||
fn writeRecord(record: []const u8) void {
|
||||
if (record.len == 0) {
|
||||
return;
|
||||
}
|
||||
if (signal_output_fd >= 0) {
|
||||
_ = std.c.write(signal_output_fd, record.ptr, record.len);
|
||||
} else {
|
||||
// Per-call flags leave inherited stderr flags unchanged. Non-sockets
|
||||
// fail with ENOTSOCK: omit the record rather than risk blocking.
|
||||
_ = std.c.send(2, record.ptr, record.len, std.c.MSG.DONTWAIT | std.c.MSG.NOSIGNAL);
|
||||
}
|
||||
}
|
||||
|
||||
fn writeSignalContext(writer: *std.Io.Writer, sig: std.posix.SIG, info: *const std.posix.siginfo_t, context: ?*const std.debug.cpu_context.Native) !void {
|
||||
try writer.print("\nLightpanda fatal signal: {t} ({d})\nversion: {s}\nOS: {s}\nmode: {s}\ncode: {d}\n", .{
|
||||
sig, @intFromEnum(sig), lp.build_config.version, @tagName(builtin.os.tag), @tagName(builtin.mode), info.code,
|
||||
});
|
||||
if (faultAddress(info)) |address| {
|
||||
try writer.print("address: 0x{x}\n", .{address});
|
||||
} else {
|
||||
try writer.writeAll("address: unavailable\n");
|
||||
}
|
||||
// Runtime address of a known symbol for offline ASLR adjustment.
|
||||
try writer.print("crash_handler.handleFatalSignal: 0x{x}\n", .{@intFromPtr(&handleFatalSignal)});
|
||||
if (context) |ctx| {
|
||||
try writer.print("pc: 0x{x}\nfp: 0x{x}\n", .{ ctx.getPc(), ctx.getFp() });
|
||||
if (stackPointer(ctx)) |sp| try writer.print("sp: 0x{x}\n", .{sp});
|
||||
switch (builtin.cpu.arch) {
|
||||
.aarch64 => try writer.print("lr: 0x{x}\n", .{ctx.x[30]}),
|
||||
else => {},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn writeBacktrace(ctx: *const std.debug.cpu_context.Native) void {
|
||||
if (comptime builtin.omit_frame_pointer) {
|
||||
return;
|
||||
}
|
||||
|
||||
var buffer: [640]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buffer);
|
||||
writer.print("backtrace: 0x{x}", .{ctx.getPc()}) catch return;
|
||||
|
||||
// Each frame must sit above the last, close enough to be a real frame.
|
||||
var floor = stackPointer(ctx) orelse ctx.getFp();
|
||||
var fp = ctx.getFp();
|
||||
for (0..max_backtrace_frames) |_| {
|
||||
if (fp < floor or fp - floor > max_frame_distance or fp % @alignOf(usize) != 0) {
|
||||
break;
|
||||
}
|
||||
const frame: *const [2]usize = @ptrFromInt(fp);
|
||||
const return_address = frame[1];
|
||||
if (return_address == 0) {
|
||||
break;
|
||||
}
|
||||
writer.print(" 0x{x}", .{return_address}) catch break;
|
||||
floor = fp +| 1;
|
||||
fp = frame[0];
|
||||
}
|
||||
writer.writeByte('\n') catch {};
|
||||
writeRecord(writer.buffered());
|
||||
}
|
||||
|
||||
fn stackPointer(ctx: *const std.debug.cpu_context.Native) ?usize {
|
||||
return switch (builtin.cpu.arch) {
|
||||
.aarch64 => ctx.sp,
|
||||
.x86_64 => ctx.gprs.get(.rsp),
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
|
||||
fn faultAddress(info: *const std.posix.siginfo_t) ?usize {
|
||||
// SI_USER/SI_TKILL/SI_KERNEL do not supply si_addr.
|
||||
if (info.code <= 0 or info.code >= 128) return null;
|
||||
return @intFromPtr(info.fields.sigfault.addr);
|
||||
}
|
||||
|
||||
const testing = @import("testing.zig");
|
||||
|
||||
test "crash_handler: fatal signals preserve termination with unavailable stderr" {
|
||||
if (builtin.os.tag != .linux) return error.SkipZigTest;
|
||||
for (fatal_signals) |sig| {
|
||||
for ([_]SignalTestMode{ .normal, .pipe, .tty, .closed, .broken_pipe, .locked_panic, .regular_file, .read_only_pipe }) |mode| {
|
||||
try testSignal(sig, mode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test "crash_handler: full stderr must not delay termination" {
|
||||
if (builtin.os.tag != .linux) return error.SkipZigTest;
|
||||
for (fatal_signals) |sig| {
|
||||
try testSignal(sig, .full_pipe);
|
||||
try testSignal(sig, .full_socket);
|
||||
}
|
||||
}
|
||||
|
||||
test "crash_handler: hardware fault reports the interrupted context" {
|
||||
if (builtin.os.tag != .linux) return error.SkipZigTest;
|
||||
try testSignal(.SEGV, .hardware);
|
||||
}
|
||||
|
||||
test "crash_handler: fatal signal after fork from a non-main thread" {
|
||||
if (builtin.os.tag != .linux) return error.SkipZigTest;
|
||||
const Worker = struct {
|
||||
fn run(result: *?anyerror) void {
|
||||
testSignal(.SEGV, .hardware) catch |err| {
|
||||
result.* = err;
|
||||
};
|
||||
}
|
||||
};
|
||||
var result: ?anyerror = null;
|
||||
const thread = try std.Thread.spawn(.{}, Worker.run, .{&result});
|
||||
thread.join();
|
||||
if (result) |err| return err;
|
||||
}
|
||||
|
||||
test "crash_handler: unknown signal addresses are not read" {
|
||||
if (builtin.os.tag != .linux) return error.SkipZigTest;
|
||||
var info: std.posix.siginfo_t = undefined;
|
||||
for ([_]c_int{ 0, -1, -6, 128, 0x10001 }) |code| {
|
||||
info.code = code;
|
||||
try testing.expectEqual(@as(?usize, null), faultAddress(&info));
|
||||
}
|
||||
}
|
||||
|
||||
const SignalTestMode = enum { normal, pipe, tty, closed, broken_pipe, locked_panic, hardware, full_pipe, full_socket, regular_file, read_only_pipe };
|
||||
|
||||
extern "c" fn posix_openpt(oflag: c_int) c_int;
|
||||
extern "c" fn grantpt(fd: c_int) c_int;
|
||||
extern "c" fn unlockpt(fd: c_int) c_int;
|
||||
extern "c" fn ptsname_r(fd: c_int, buf: [*]u8, buflen: usize) c_int;
|
||||
|
||||
// fds[0] is the master the parent reads, fds[1] the slave the child gets as
|
||||
// its stderr: the same shape as pipe() and socketpair().
|
||||
fn openPty(fds: *[2]std.c.fd_t) c_int {
|
||||
const oflag: c_int = @bitCast(@as(u32, @bitCast(std.posix.O{ .ACCMODE = .RDWR, .NOCTTY = true })));
|
||||
const master = posix_openpt(oflag);
|
||||
if (master < 0) return -1;
|
||||
|
||||
if (grantpt(master) != 0 or unlockpt(master) != 0) {
|
||||
_ = std.c.close(master);
|
||||
return -1;
|
||||
}
|
||||
var name: [128]u8 = undefined;
|
||||
if (ptsname_r(master, &name, name.len) != 0) {
|
||||
_ = std.c.close(master);
|
||||
return -1;
|
||||
}
|
||||
const slave = std.c.open(@ptrCast(&name), .{ .ACCMODE = .WRONLY, .NOCTTY = true });
|
||||
if (slave < 0) {
|
||||
_ = std.c.close(master);
|
||||
return -1;
|
||||
}
|
||||
fds.* = .{ master, slave };
|
||||
return 0;
|
||||
}
|
||||
|
||||
fn testSignal(sig: std.posix.SIG, mode: SignalTestMode) !void {
|
||||
const guard = if (mode == .hardware) try std.posix.mmap(null, std.heap.pageSize(), .{}, .{ .TYPE = .PRIVATE, .ANONYMOUS = true }, -1, 0) else null;
|
||||
defer if (guard) |memory| std.posix.munmap(memory);
|
||||
const file = if (mode == .regular_file) std.c.memfd_create("fatal-signal-test", std.c.MFD.CLOEXEC) else -1;
|
||||
if (mode == .regular_file) {
|
||||
try testing.expectEqual(true, file >= 0);
|
||||
// O_APPEND or not is the whole question: a reopened description starts
|
||||
// at offset zero and would land on top of this.
|
||||
try testing.expectEqual(@as(isize, prior_log.len), std.c.write(file, prior_log, prior_log.len));
|
||||
}
|
||||
defer if (file >= 0) {
|
||||
_ = std.c.close(file);
|
||||
};
|
||||
var fds: [2]std.c.fd_t = undefined;
|
||||
const full = mode == .full_pipe or mode == .full_socket;
|
||||
const is_pipe = mode == .pipe or mode == .full_pipe or mode == .read_only_pipe;
|
||||
const result = if (mode == .tty)
|
||||
openPty(&fds)
|
||||
else if (is_pipe)
|
||||
std.c.pipe(&fds)
|
||||
else
|
||||
std.c.socketpair(std.posix.AF.UNIX, std.posix.SOCK.STREAM, 0, &fds);
|
||||
// A sandbox without /dev/ptmx leaves nothing to test here.
|
||||
if (mode == .tty and result != 0) return;
|
||||
try testing.expectEqual(@as(c_int, 0), result);
|
||||
defer _ = std.c.close(fds[0]);
|
||||
const pid = std.c.fork();
|
||||
if (pid == -1) {
|
||||
_ = std.c.close(fds[1]);
|
||||
return error.ForkFailed;
|
||||
}
|
||||
if (pid == 0) {
|
||||
// Keep a regression from hanging the runner or writing a large core.
|
||||
const limit: std.posix.rlimit = .{ .cur = 0, .max = 0 };
|
||||
_ = std.c.setrlimit(.CORE, &limit);
|
||||
const default: std.posix.Sigaction = .{ .handler = .{ .handler = std.posix.SIG.DFL }, .mask = std.posix.sigemptyset(), .flags = 0 };
|
||||
std.posix.sigaction(.ALRM, &default, null);
|
||||
std.posix.sigaction(.PIPE, &default, null);
|
||||
_ = std.c.alarm(3);
|
||||
_ = std.c.dup2(if (mode == .read_only_pipe) fds[0] else fds[1], 2);
|
||||
_ = std.c.close(fds[0]);
|
||||
_ = std.c.close(fds[1]);
|
||||
if (mode == .closed) _ = std.c.close(2);
|
||||
if (file >= 0) {
|
||||
_ = std.c.dup2(file, 2);
|
||||
_ = std.c.close(file);
|
||||
}
|
||||
if (mode == .broken_pipe) {
|
||||
var broken: [2]std.c.fd_t = undefined;
|
||||
if (std.c.pipe(&broken) != 0) std.c._exit(1);
|
||||
_ = std.c.close(broken[0]);
|
||||
_ = std.c.dup2(broken[1], 2);
|
||||
_ = std.c.close(broken[1]);
|
||||
}
|
||||
if (full) {
|
||||
const flags = std.c.fcntl(2, std.posix.F.GETFL);
|
||||
const nonblock: c_int = @bitCast(@as(u32, @bitCast(std.posix.O{ .NONBLOCK = true })));
|
||||
if (std.c.fcntl(2, std.posix.F.SETFL, flags | nonblock) < 0) std.c._exit(1);
|
||||
const fill = [_]u8{'x'} ** 1024;
|
||||
for ([_]usize{ fill.len, 1 }) |len| {
|
||||
while (true) {
|
||||
const written = std.c.write(2, &fill, len);
|
||||
if (written > 0) continue;
|
||||
if (std.posix.errno(written) != .AGAIN) std.c._exit(1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (std.c.fcntl(2, std.posix.F.SETFL, flags) < 0) std.c._exit(1);
|
||||
}
|
||||
if (mode == .locked_panic) panic_mutex.lockUncancelable(lp.io);
|
||||
const flags_before = std.c.fcntl(2, std.posix.F.GETFL);
|
||||
attachSignalHandlers();
|
||||
const first_output = signal_output_fd;
|
||||
attachSignalHandlers();
|
||||
if (signal_output_fd != first_output) std.c._exit(1);
|
||||
if (std.c.fcntl(2, std.posix.F.GETFL) != flags_before) std.c._exit(1);
|
||||
if (signal_output_fd >= 0) {
|
||||
const output_flags: std.posix.O = @bitCast(@as(u32, @intCast(std.c.fcntl(signal_output_fd, std.posix.F.GETFL))));
|
||||
if (!output_flags.NONBLOCK) std.c._exit(1);
|
||||
if (output_flags.APPEND != (mode == .regular_file)) std.c._exit(1);
|
||||
if (std.c.fcntl(signal_output_fd, std.posix.F.GETFD) & std.posix.FD_CLOEXEC == 0) std.c._exit(1);
|
||||
}
|
||||
if (guard) |memory| @as(*volatile u8, @ptrCast(memory.ptr)).* = 1;
|
||||
_ = std.c.raise(sig);
|
||||
std.c._exit(1);
|
||||
}
|
||||
_ = std.c.close(fds[1]);
|
||||
var status: c_int = 0;
|
||||
if (full) try testing.expectEqual(pid, std.c.waitpid(pid, &status, 0));
|
||||
var output: [4096]u8 = undefined;
|
||||
var len: usize = 0;
|
||||
while (len < output.len) {
|
||||
const count = std.c.read(fds[0], output[len..].ptr, output.len - len);
|
||||
if (count <= 0) break;
|
||||
len += @intCast(count);
|
||||
}
|
||||
if (!full) try testing.expectEqual(pid, std.c.waitpid(pid, &status, 0));
|
||||
const raw: u32 = @bitCast(status);
|
||||
errdefer std.debug.print("signal={t} mode={t} status=0x{x}\n", .{ sig, mode, raw });
|
||||
if (comptime builtin.sanitize_thread) {
|
||||
// ThreadSanitizer's sigaction wrapper keeps the signal blocked for the
|
||||
// duration of the handler whatever SA_NODEFER says, so the re-raise
|
||||
// only ever goes pending and the handler's fallback exit is what ends
|
||||
// the process. Everything before that point is unaffected.
|
||||
try testing.expectEqual(true, std.posix.W.IFEXITED(raw));
|
||||
try testing.expectEqual(@as(u8, @intCast(128 + @intFromEnum(sig))), std.posix.W.EXITSTATUS(raw));
|
||||
} else {
|
||||
try testing.expectEqual(true, std.posix.W.IFSIGNALED(raw));
|
||||
try testing.expectEqual(sig, std.posix.W.TERMSIG(raw));
|
||||
}
|
||||
|
||||
var text = output[0..len];
|
||||
if (mode == .regular_file) {
|
||||
// The record went to the file, not to the socketpair.
|
||||
try testing.expectEqual(@as(usize, 0), len);
|
||||
try testing.expectEqual(@as(std.c.off_t, 0), std.c.lseek(file, 0, std.c.SEEK.SET));
|
||||
const count = std.c.read(file, &output, output.len);
|
||||
try testing.expectEqual(true, count > 0);
|
||||
text = output[0..@intCast(count)];
|
||||
try testing.expectEqual(true, std.mem.startsWith(u8, text, prior_log));
|
||||
}
|
||||
var unwrapped: [output.len]u8 = undefined;
|
||||
if (mode == .tty) {
|
||||
// ONLCR turns every \n into \r\n on the way through the line discipline.
|
||||
const replaced = std.mem.replace(u8, text, "\r\n", "\n", &unwrapped);
|
||||
text = unwrapped[0 .. text.len - replaced];
|
||||
}
|
||||
if (mode == .read_only_pipe or mode == .closed or mode == .broken_pipe) try testing.expectEqual(@as(usize, 0), text.len);
|
||||
if (mode == .normal or mode == .locked_panic or mode == .hardware or mode == .pipe or mode == .tty or mode == .regular_file) {
|
||||
errdefer std.debug.print("signal={t} mode={t}\n{s}\n", .{ sig, mode, text });
|
||||
try testing.expectEqual(true, std.mem.containsAtLeast(u8, text, 1, "Lightpanda fatal signal:"));
|
||||
try testing.expectEqual(true, std.mem.containsAtLeast(u8, text, 1, "\npc: 0x"));
|
||||
try testing.expectEqual(true, std.mem.containsAtLeast(u8, text, 1, "\ncrash_handler.handleFatalSignal: 0x"));
|
||||
try testing.expectEqual(true, std.mem.containsAtLeast(u8, text, 1, "\nbacktrace: 0x"));
|
||||
if (mode == .hardware) {
|
||||
var address_buffer: [64]u8 = undefined;
|
||||
const address = try std.fmt.bufPrint(&address_buffer, "address: 0x{x}\n", .{@intFromPtr(guard.?.ptr)});
|
||||
try testing.expectEqual(true, std.mem.containsAtLeast(u8, text, 1, address));
|
||||
// The faulting pc alone is not a backtrace: the walk has to have
|
||||
// followed at least one link out of the frame that faulted.
|
||||
const line = text[std.mem.indexOf(u8, text, "\nbacktrace: ").? + 1 ..];
|
||||
try testing.expectEqual(true, std.mem.count(u8, line[0..std.mem.indexOfScalar(u8, line, '\n').?], " 0x") >= 2);
|
||||
} else {
|
||||
try testing.expectEqual(true, std.mem.containsAtLeast(u8, text, 1, "address: unavailable\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const prior_log = "a line that was already in the log\n";
|
||||
+27
-23
@@ -81,6 +81,8 @@
|
||||
\\ Allowed values:
|
||||
\\ html Serialized HTML of the DOM.
|
||||
\\ markdown Converts content to Markdown.
|
||||
\\ pdf Text-only rendering of the page as a
|
||||
\\ PDF file (base64 with --json).
|
||||
\\ png Text-only rendering of the page as a
|
||||
\\ PNG image (base64 with --json).
|
||||
\\ semantic_tree JSON-serialized semantic tree.
|
||||
@@ -207,10 +209,10 @@
|
||||
\\
|
||||
\\Arguments:
|
||||
\\[SCRIPT]
|
||||
\\ Optional path to a .js script. Runs the script (no LLM calls) and
|
||||
\\ exits; `{0s} run SCRIPT` is the preferred spelling. With no script
|
||||
\\ and no --task, the REPL starts; from there /load runs a script and
|
||||
\\ /save exports the session to a file.
|
||||
\\ Optional path to a .js script, or `-` for stdin. Runs the script
|
||||
\\ (no LLM calls) and exits; `{0s} run SCRIPT` is the preferred
|
||||
\\ spelling. 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.
|
||||
@@ -253,18 +255,28 @@
|
||||
\\ 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).
|
||||
\\ MISTRAL_API_KEY, VERTEX_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).
|
||||
\\
|
||||
\\ openai_compatible targets any OpenAI-style server via
|
||||
\\ OPENAI_BASE_URL + OPENAI_API_KEY; it is auto-detected when
|
||||
\\ OPENAI_BASE_URL is set.
|
||||
\\
|
||||
\\ Vertex project mode (GOOGLE_CLOUD_PROJECT + a gcloud token)
|
||||
\\ works with --provider vertex; it is only auto-detected when
|
||||
\\ GOOGLE_GENAI_USE_VERTEXAI=1 is set too.
|
||||
\\
|
||||
\\ 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".
|
||||
\\ "vercel", "mistral", "ollama", "llama_cpp",
|
||||
\\ "vertex", "codex", "openai_compatible".
|
||||
\\ In the REPL, use /provider to list and change providers.
|
||||
\\ --save <PATH>
|
||||
\\ Synthesize a replayable .js script from the --task run and write
|
||||
@@ -292,26 +304,25 @@
|
||||
\\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.
|
||||
\\
|
||||
\\API keys are read from the environment: ANTHROPIC_API_KEY, OPENAI_API_KEY,
|
||||
\\GOOGLE_API_KEY/GEMINI_API_KEY, HF_TOKEN, AI_GATEWAY_API_KEY, or
|
||||
\\MISTRAL_API_KEY. The local servers (Ollama, llama.cpp) do not require an
|
||||
\\API key.
|
||||
\\API keys are read from the environment; see --provider for the full list.
|
||||
\\The local servers (Ollama, llama.cpp) do not require an API key.
|
||||
,
|
||||
.run =
|
||||
\\run command
|
||||
\\Runs a saved script, then exits. No LLM calls, no API key needed.
|
||||
\\
|
||||
\\Usage:
|
||||
\\ {0s} run <SCRIPT> [COMMON_OPTIONS]
|
||||
\\ {0s} run <SCRIPT | -> [COMMON_OPTIONS]
|
||||
\\
|
||||
\\Examples:
|
||||
\\ {0s} run script.js (replay a saved script)
|
||||
\\ cat script.js | {0s} run - (read the script from stdin)
|
||||
\\
|
||||
\\Arguments:
|
||||
\\<SCRIPT>
|
||||
\\ Path to a .js script to run, then exit. Produce one with
|
||||
\\ `{0s} agent --task "..." --save script.js`, or `/save` from the
|
||||
\\ agent REPL.
|
||||
\\ agent REPL. Pass `-` to read the script from stdin instead.
|
||||
\\ 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.
|
||||
@@ -477,13 +488,6 @@
|
||||
\\ --proxy-bearer-token <TOKEN>
|
||||
\\ Token sent for bearer authentication with the proxy:
|
||||
\\ Proxy-Authorization: Bearer <token>.
|
||||
\\ --storage-engine <ENGINE>
|
||||
\\ The storage engine to use.
|
||||
\\ Defaults to none.
|
||||
\\ Allowed values: "none", "sqlite".
|
||||
\\ --storage-sqlite-path <PATH>
|
||||
\\ Path to the SQLite database file for persistent storage.
|
||||
\\ Use ":memory:" for in-memory storage.
|
||||
\\ --timezone <IANA>
|
||||
\\ Time zone used by Date and Intl, e.g. Europe/Paris or UTC.
|
||||
\\ Defaults to the host time zone.
|
||||
|
||||
@@ -61,6 +61,7 @@ pub fn main(init: std.process.Init) !void {
|
||||
|
||||
fn run(allocator: Allocator, main_arena: Allocator, proc_args: std.process.Args) !void {
|
||||
lp.core_dump.disableIfRequested();
|
||||
lp.crash_handler.attachSignalHandlers();
|
||||
|
||||
const args = try Config.parseArgs(main_arena, proc_args);
|
||||
defer args.deinit(main_arena);
|
||||
|
||||
@@ -3430,11 +3430,15 @@ pub const Transfer = struct {
|
||||
}
|
||||
|
||||
try transfer.updateURL(url);
|
||||
// 301, 302, 303 → change to GET, drop body.
|
||||
// 307, 308 → keep method and body.
|
||||
if (status == 301 or status == 302 or status == 303) {
|
||||
const rewrite_to_get = ((status == 301 or status == 302) and req.method == .POST) or
|
||||
(status == 303 and req.method != .GET and req.method != .HEAD);
|
||||
if (rewrite_to_get) {
|
||||
req.method = .GET;
|
||||
req.body = null;
|
||||
// Fetch's request-body headers must not outlive the body.
|
||||
inline for (.{ "Content-Encoding", "Content-Language", "Content-Location", "Content-Type" }) |name| {
|
||||
transfer.removeHeader(name);
|
||||
}
|
||||
}
|
||||
|
||||
if (req.referrer_policy) |policy| {
|
||||
@@ -5109,6 +5113,78 @@ test "HttpClient: aborting a robots-parked transfer unlinks it from the gate" {
|
||||
try testing.expectEqual(0, client.transfers.count());
|
||||
}
|
||||
|
||||
test "HttpClient: redirects drop body headers only when rewriting the method" {
|
||||
var pool = ArenaPool.init(testing.allocator, .{});
|
||||
defer pool.deinit();
|
||||
var client: Client = undefined;
|
||||
initTestClient(&client, &pool);
|
||||
|
||||
const cases = [_]struct { status: u16, method: Method, expected: Method }{
|
||||
.{ .status = 301, .method = .POST, .expected = .GET },
|
||||
.{ .status = 302, .method = .POST, .expected = .GET },
|
||||
.{ .status = 303, .method = .POST, .expected = .GET },
|
||||
.{ .status = 307, .method = .POST, .expected = .POST },
|
||||
.{ .status = 308, .method = .POST, .expected = .POST },
|
||||
.{ .status = 301, .method = .PUT, .expected = .PUT },
|
||||
.{ .status = 302, .method = .PUT, .expected = .PUT },
|
||||
.{ .status = 303, .method = .PUT, .expected = .GET },
|
||||
.{ .status = 303, .method = .PATCH, .expected = .GET },
|
||||
.{ .status = 303, .method = .DELETE, .expected = .GET },
|
||||
.{ .status = 301, .method = .HEAD, .expected = .HEAD },
|
||||
.{ .status = 302, .method = .HEAD, .expected = .HEAD },
|
||||
.{ .status = 303, .method = .HEAD, .expected = .HEAD },
|
||||
.{ .status = 301, .method = .GET, .expected = .GET },
|
||||
.{ .status = 302, .method = .GET, .expected = .GET },
|
||||
.{ .status = 303, .method = .GET, .expected = .GET },
|
||||
};
|
||||
for (cases) |case| {
|
||||
const arena = try pool.acquire(.small, "redirect test");
|
||||
defer arena.release();
|
||||
const body: ?[]const u8 = if (case.method == .GET or case.method == .HEAD) null else "payload";
|
||||
var transfer: Transfer = .{
|
||||
.arena = arena,
|
||||
.owner = null,
|
||||
.req = .{
|
||||
.method = case.method,
|
||||
.url = "http://example.com/start",
|
||||
.body = body,
|
||||
.origin = null,
|
||||
.credentials_mode = .omit,
|
||||
.request_mode = .no_cors,
|
||||
.resource_type = .document,
|
||||
.shutdown_callback = noopShutdown,
|
||||
.ctx = undefined,
|
||||
},
|
||||
.client = &client,
|
||||
.id = 1,
|
||||
.start_time = 0,
|
||||
};
|
||||
const body_headers = [_][]const u8{ "content-type", "Content-Encoding", "CONTENT-LANGUAGE", "Content-Location" };
|
||||
for (body_headers) |name| try transfer.setHeader(name, "body-value", .{});
|
||||
try transfer.setHeader("Accept", "text/html", .{});
|
||||
try transfer.setHeader("X-Keep", "yes", .{});
|
||||
|
||||
try transfer.applyRedirectTarget(transfer.req.url, "/end", case.status);
|
||||
try testing.expectEqual(case.expected, transfer.req.method);
|
||||
const rewritten = case.method != case.expected;
|
||||
if (rewritten or body == null) {
|
||||
try testing.expectEqual(null, transfer.req.body);
|
||||
} else {
|
||||
try testing.expectEqual(body.?, transfer.req.body.?);
|
||||
}
|
||||
for (body_headers) |name| {
|
||||
if (rewritten) {
|
||||
try testing.expectEqual(null, transfer.findRequestHeader(name));
|
||||
} else {
|
||||
try testing.expectEqual("body-value", transfer.findRequestHeader(name).?);
|
||||
}
|
||||
}
|
||||
try testing.expectEqual("text/html", transfer.findRequestHeader("accept").?);
|
||||
try testing.expectEqual("yes", transfer.findRequestHeader("x-keep").?);
|
||||
try testing.expectEqual("http://example.com/end", transfer.req.url);
|
||||
}
|
||||
}
|
||||
|
||||
test "HttpClient: fulfillIntercepted follows a 3xx redirect" {
|
||||
// Regression for #2828: a CDP Fetch.fulfillRequest with a 3xx status + a
|
||||
// Location header must be followed like a real network redirect (re-issued
|
||||
@@ -5158,6 +5234,7 @@ test "HttpClient: fulfillIntercepted follows a 3xx redirect" {
|
||||
};
|
||||
try client.transfers.putNoClobber(testing.allocator, transfer.id, transfer);
|
||||
|
||||
try transfer.setHeader("Content-Type", "multipart/form-data; boundary=test", .{});
|
||||
transfer.park(.intercept_request);
|
||||
client.intercepted += 1;
|
||||
|
||||
@@ -5170,6 +5247,7 @@ test "HttpClient: fulfillIntercepted follows a 3xx redirect" {
|
||||
try testing.expectEqual("http://example.com/end", transfer.req.url);
|
||||
try testing.expectEqual(.GET, transfer.req.method);
|
||||
try testing.expectEqual(null, transfer.req.body);
|
||||
try testing.expectEqual(null, transfer.findRequestHeader("content-type"));
|
||||
// Unparked exactly once; transfer is still alive.
|
||||
try testing.expectEqual(0, client.intercepted);
|
||||
try testing.expectEqual(1, client.transfers.count());
|
||||
@@ -5200,6 +5278,7 @@ test "HttpClient: fulfillIntercepted follows a 3xx redirect" {
|
||||
};
|
||||
try client.transfers.putNoClobber(testing.allocator, transfer.id, transfer);
|
||||
|
||||
try transfer.setHeader("Content-Type", "multipart/form-data; boundary=test", .{});
|
||||
transfer.park(.intercept_request);
|
||||
client.intercepted += 1;
|
||||
|
||||
@@ -5211,6 +5290,7 @@ test "HttpClient: fulfillIntercepted follows a 3xx redirect" {
|
||||
try testing.expectEqual("http://example.com/other", transfer.req.url);
|
||||
try testing.expectEqual(.POST, transfer.req.method);
|
||||
try testing.expectEqual("payload", transfer.req.body.?);
|
||||
try testing.expectEqual("multipart/form-data; boundary=test", transfer.findRequestHeader("content-type").?);
|
||||
try testing.expectEqual(0, client.intercepted);
|
||||
transfer.deinit();
|
||||
}
|
||||
|
||||
@@ -66,9 +66,7 @@ pub fn consoleMessage(arena: Allocator, bc: *CDP.BrowserContext, event: *const N
|
||||
const w = &aw.writer;
|
||||
for (event.values, 0..) |v, i| {
|
||||
if (i != 0) try w.writeByte(' ');
|
||||
|
||||
const js_str = try v.toString();
|
||||
try js_str.format(w);
|
||||
try v.format(w);
|
||||
}
|
||||
|
||||
return bc.cdp.sendEvent("Console.messageAdded", ConsoleMessage{
|
||||
|
||||
@@ -271,7 +271,7 @@ test "cdp.runtime: consoleAPICalled only carries values for primitives" {
|
||||
try testing.expectEqual(0, try probed.toF64());
|
||||
}
|
||||
|
||||
test "cdp.runtime: console calls made while a notification is being built" {
|
||||
test "cdp.runtime: console notifications run no page JS" {
|
||||
testing.silenceLog(&.{.js});
|
||||
|
||||
var ctx = try testing.context();
|
||||
@@ -285,19 +285,17 @@ test "cdp.runtime: console calls made while a notification is being built" {
|
||||
var ls: js.Local.Scope = undefined;
|
||||
frame.js.localScope(&ls);
|
||||
defer ls.deinit();
|
||||
// Legacy Console formatting can re-enter the notification handlers.
|
||||
_ = try ls.local.exec(
|
||||
\\const inner = 'x'.repeat(120);
|
||||
\\const probe = { toString() { console.log(inner); console.log(inner); return 'outer'; } };
|
||||
\\globalThis.probed = 0;
|
||||
\\const probe = { toString() { globalThis.probed++; console.log('inner'); return 'outer'; } };
|
||||
\\console.log('head-marker', probe, 'tail-marker-'.repeat(20));
|
||||
, null);
|
||||
|
||||
const inner = "x" ** 120;
|
||||
const tail = "tail-marker-" ** 20;
|
||||
try ctx.expectSentEvent("Console.messageAdded", .{ .level = "log", .text = inner }, .{});
|
||||
try ctx.expectSentEvent("Console.messageAdded", .{ .level = "log", .text = "head-marker outer " ++ tail }, .{});
|
||||
try ctx.expectSentEvent("Runtime.consoleAPICalled", .{ .type = "log", .args = .{.{ .type = "string", .value = inner }} }, .{});
|
||||
try ctx.expectSentEvent("Console.messageAdded", .{ .level = "log", .text = "head-marker [object Object] " ++ tail }, .{});
|
||||
try ctx.expectSentEvent("Runtime.consoleAPICalled", .{ .type = "log", .args = .{ .{ .type = "string", .value = "head-marker" }, .{ .type = "object", .className = "Object" }, .{ .type = "string", .value = tail } } }, .{});
|
||||
const probed = try ls.local.exec("globalThis.probed", null);
|
||||
try testing.expectEqual(0, try probed.toF64());
|
||||
try testing.expectEqual(0, ctx.cdp().notification_depth);
|
||||
try testing.expectEqual(0, ctx.cdp().link.send_depth);
|
||||
try ctx.processMessage(.{ .id = 62, .method = "Runtime.evaluate", .params = .{ .expression = "6 * 7" } });
|
||||
|
||||
Reference in new issue
Block a user