diff --git a/.github/actions/install/action.yml b/.github/actions/install/action.yml index 2c393821c..aa139a4f6 100644 --- a/.github/actions/install/action.yml +++ b/.github/actions/install/action.yml @@ -13,7 +13,7 @@ inputs: zig-v8: description: 'zig v8 version to install' required: false - default: 'v0.5.0' + default: 'v0.5.1' v8: description: 'v8 version to install' required: false diff --git a/.github/actions/v8-snapshot/action.yml b/.github/actions/v8-snapshot/action.yml index cbb998f1c..1fca406e7 100644 --- a/.github/actions/v8-snapshot/action.yml +++ b/.github/actions/v8-snapshot/action.yml @@ -13,7 +13,7 @@ inputs: zig-v8: description: 'zig-v8 release tag the prebuilt lib came from' required: false - default: 'v0.5.0' + default: 'v0.5.1' runs: using: "composite" diff --git a/Dockerfile b/Dockerfile index cef60fd8d..70b5cf5db 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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.0 +ARG ZIG_V8=v0.5.1 ARG TARGETPLATFORM RUN apt-get update -yq && \ diff --git a/build.zig.zon b/build.zig.zon index 81d2eb67e..92f960358 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -5,8 +5,8 @@ .minimum_zig_version = "0.15.2", .dependencies = .{ .v8 = .{ - .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.5.0.tar.gz", - .hash = "v8-0.0.0-xddH613pAgDFIxC9Xq1zCPVj2n-8Dxsm-TVMfSRVzq6Z", + .url = "https://github.com/lightpanda-io/zig-v8-fork/archive/refs/tags/v0.5.1.tar.gz", + .hash = "v8-0.0.0-xddH6_vtAgAdI8KeSyuBCgVOEFDN_0BfMekjdP6fkRqk", }, // .v8 = .{ .path = "../zig-v8-fork" }, .brotli = .{ diff --git a/src/App.zig b/src/App.zig index db456b569..acb06a10b 100644 --- a/src/App.zig +++ b/src/App.zig @@ -44,7 +44,7 @@ arena_pool: ArenaPool, app_dir_path: ?[]const u8, pub fn init(allocator: Allocator, config: *const Config) !*App { - const platform = try Platform.init(); + const platform = try Platform.init(config.v8Flags()); errdefer platform.deinit(); const snapshot = try Snapshot.load(); diff --git a/src/Config.zig b/src/Config.zig index 11a89fb6a..c5c574f8b 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -114,6 +114,8 @@ const CommonOptions = .{ .{ .name = "disable_subframes", .type = bool }, .{ .name = "disable_workers", .type = bool }, .{ .name = "enable_external_stylesheets", .type = bool }, + .{ .name = "v8_flags_unsafe", .type = ?[]const u8 }, + .{ .name = "v8_max_heap_mb", .type = ?u32 }, }; fn dumpValidator(_: Allocator, args: *std.process.ArgIterator) !?DumpFormat { @@ -337,6 +339,20 @@ pub fn enableExternalStylesheets(self: *const Config) bool { }; } +pub fn v8Flags(self: *const Config) ?[]const u8 { + return switch (self.mode) { + inline .serve, .fetch, .mcp, .agent => |opts| opts.v8_flags_unsafe, + else => unreachable, + }; +} + +pub fn v8MaxHeapMb(self: *const Config) ?u32 { + return switch (self.mode) { + inline .serve, .fetch, .mcp, .agent => |opts| opts.v8_max_heap_mb, + else => unreachable, + }; +} + pub fn httpProxy(self: *const Config) ?[:0]const u8 { return switch (self.mode) { inline .serve, .fetch, .mcp, .agent => |opts| opts.http_proxy, diff --git a/src/browser/Browser.zig b/src/browser/Browser.zig index 23926ccf6..59c4867d7 100644 --- a/src/browser/Browser.zig +++ b/src/browser/Browser.zig @@ -115,6 +115,7 @@ pub fn init(self: *Browser, app: *App, opts: InitOpts, cdp: ?*CDP) !void { .fc_identity_pool = .init(allocator), .selector_cache = .init(allocator), }; + self.env.protectHeapLimit(); try self.http_client.init(allocator, &app.network, cdp); } diff --git a/src/browser/js/Env.zig b/src/browser/js/Env.zig index 299038ad5..6edda76bd 100644 --- a/src/browser/js/Env.zig +++ b/src/browser/js/Env.zig @@ -132,6 +132,14 @@ pub fn init(app: *App, opts: InitOpts) !Env { params.external_references = &snapshot.external_references; + if (app.config.v8MaxHeapMb()) |mb| { + v8.v8__ResourceConstraints__ConfigureDefaultsFromHeapSize( + ¶ms.constraints, + 0, + @as(usize, mb) * 1024 * 1024, + ); + } + var isolate = js.Isolate.init(params); errdefer isolate.deinit(); const isolate_handle = isolate.handle; @@ -391,7 +399,7 @@ pub fn runMicrotasks(self: *Env) void { const v8_isolate = self.isolate.handle; - if (v8.v8__Isolate__IsExecutionTerminating(v8_isolate)) { + if (v8.v8__Isolate__IsExecutionTerminating(v8_isolate) or self.terminatePending()) { return; } @@ -409,7 +417,7 @@ pub fn runMicrotasks(self: *Env) void { } pub fn runMacrotasks(self: *Env) !void { - if (v8.v8__Isolate__IsExecutionTerminating(self.isolate.handle)) { + if (v8.v8__Isolate__IsExecutionTerminating(self.isolate.handle) or self.terminatePending()) { return; } @@ -542,6 +550,34 @@ pub fn terminate(self: *Env) void { v8.v8__Isolate__TerminateExecution(self.isolate.handle); } +// We need a stable pointer for *Env, so can't be setup in init. +pub fn protectHeapLimit(self: *Env) void { + v8.v8__Isolate__AddNearHeapLimitCallback(self.isolate.handle, nearHeapLimit, self); +} + +// v8 is telling us it's about to run out of memory for this isolate. We'll +// do two things: +// 1 - Terminate the execution (attempting to prevent v8 from OOM'ing the process) +// 2 - Tell v8 that it can use 8MB more memory, hopefully giving it enough memory +// to properly shutdown +// +// The terminate must go through requestTerminate (RequestInterrupt), NOT a +// direct TerminateExecution: we're mid-GC, which is an arbitrary point — +// possibly inside a microtask run — and flagging termination there lets the +// run complete with a result while the isolate is terminating, tripping +// V8's DCHECK(maybe_result.is_null()) in MicrotaskQueue::RunMicrotasks. The +// interrupt lands at a stack-guard check, where termination unwinds the way +// V8 expects. +fn nearHeapLimit(data: ?*anyopaque, current_limit: usize, initial_limit: usize) callconv(.c) usize { + const self: *Env = @ptrCast(@alignCast(data.?)); + log.err(.app, "JS heap limit reached", .{ + .initial_limit = initial_limit, + .current_limit = current_limit, + }); + self.requestTerminate(); + return current_limit + 8 * 1024 * 1024; +} + // Called from the network thread, caused v8 to eventually call terminateInterrupt pub fn requestTerminate(self: *Env) void { self.terminate_requested.store(true, .release); diff --git a/src/browser/js/Platform.zig b/src/browser/js/Platform.zig index 0a1733311..ab9a60c08 100644 --- a/src/browser/js/Platform.zig +++ b/src/browser/js/Platform.zig @@ -22,7 +22,11 @@ const v8 = js.v8; const Platform = @This(); handle: *v8.Platform, -pub fn init() !Platform { +pub fn init(v8_flags: ?[]const u8) !Platform { + if (v8_flags) |flags| { + v8.v8__V8__SetFlagsFromString(flags.ptr, flags.len); + } + if (v8.v8__V8__InitializeICU() == false) { return error.FailedToInitializeICU; } diff --git a/src/cdp/CDP.zig b/src/cdp/CDP.zig index 1f76c9ab6..ba3875bc8 100644 --- a/src/cdp/CDP.zig +++ b/src/cdp/CDP.zig @@ -239,6 +239,14 @@ pub fn sendJSON(self: *CDP, message: anytype) !void { } pub fn tick(self: *CDP) !bool { + // terminatePending means someone decided this browser must die (e.g. the + // heap limit was reached). Nothing in the CDP path ever calls cancelTerminate + // so the flag can't be a stale leftover here. Exit. + if (self.browser.env.terminatePending()) { + log.warn(.cdp, "closing connection", .{ .reason = "pending terminate" }); + return false; + } + // Liveness is enforced by TCP keepalive configured in // Server.configureSocket; the wakeup lets V8 run or terminate. const wait_ms: u32 = 1000; // 1s diff --git a/src/help.zon b/src/help.zon index 9867f737a..2be7e812f 100644 --- a/src/help.zon +++ b/src/help.zon @@ -338,6 +338,16 @@ \\ still sends Sec-Ch-Ua. Incompatible with --user-agent-suffix. \\ --user-agent-suffix \\ Suffix appended to the Lightpanda/X.Y User-Agent. + \\ --v8-flags-unsafe + \\ Flags passed as-is to the V8 JavaScript engine, space-separated. + \\ e.g. --v8-flags-unsafe "--expose-gc --stack-size 1000". + \\ Unsupported escape hatch: V8 does not validate flags against the + \\ prebuilt snapshot, so an incompatible flag can misbehave or + \\ crash at any point. + \\ --v8-max-heap-mb + \\ Maximum V8 heap size in megabytes, per browser instance. Values + \\ below ~16 are clamped by V8 and all behave the same. + \\ Defaults to the V8 default (based on available memory). \\ --web-bot-auth-domain \\ Your domain, e.g. yourdomain.com. \\ --web-bot-auth-key-file diff --git a/src/main_snapshot_creator.zig b/src/main_snapshot_creator.zig index 57500fca8..56ebfd228 100644 --- a/src/main_snapshot_creator.zig +++ b/src/main_snapshot_creator.zig @@ -22,7 +22,26 @@ const lp = @import("lightpanda"); pub fn main() !void { const allocator = std.heap.c_allocator; - var platform = try lp.js.Platform.init(); + // usage: snapshot_creator [--v8-flags-unsafe ""] [outfile] + // A snapshot only deserializes correctly under the flags it was created + // with, so a runtime using --v8-flags-unsafe needs a snapshot built with + // the same value. + var v8_flags: ?[]const u8 = null; + var out_path: ?[]const u8 = null; + var args = try std.process.argsWithAllocator(allocator); + _ = args.next(); // executable name + while (args.next()) |arg| { + if (std.mem.eql(u8, arg, "--v8-flags-unsafe")) { + v8_flags = args.next() orelse { + std.debug.print("--v8-flags-unsafe requires a value\n", .{}); + return error.MissingArgument; + }; + } else { + out_path = arg; + } + } + + var platform = try lp.js.Platform.init(v8_flags); defer platform.deinit(); const snapshot = try lp.js.Snapshot.create(); @@ -30,9 +49,7 @@ pub fn main() !void { var is_stdout = true; var file = std.fs.File.stdout(); - var args = try std.process.argsWithAllocator(allocator); - _ = args.next(); // executable name - if (args.next()) |n| { + if (out_path) |n| { is_stdout = false; file = try std.fs.cwd().createFile(n, .{}); } diff --git a/src/script/Runtime.zig b/src/script/Runtime.zig index df51f2a6b..ec8d1e476 100644 --- a/src/script/Runtime.zig +++ b/src/script/Runtime.zig @@ -143,6 +143,7 @@ pub fn init( // + terminate/microtask carrier; the agent context is bare (no WebAPIs). self.env = lp.js.Env.init(app, .{}) catch return error.RuntimeInitFailed; errdefer self.env.deinit(); + self.env.protectHeapLimit(); try self.createContext(); errdefer self.resetContext();