From 03f542006d0ad1d784aec96b7b276d13fb7b854a Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 7 Jul 2026 12:40:23 +0800 Subject: [PATCH 1/2] v8, mem: Add v8 configuration option + heap limit protection The main addition in this commit is that we hook into the Isolate's AddNearHeapLimitCallback callback and try to force the isolate to shutdown rather than letting v8 hit an OOM which would take down the entire process. In support of this, we now support a `--v8-max-heap-mb` command line option to set an explicit heap limit. As a simple way to test this feature, load a relatively heavy JS page with `--v8-max-heap-mb 1`. There's also a `--v8-flags-unsafe` which is a mechanism to pass arbitrary flags to v8 via its `SetFlagsFromString`. The parameter is called `unsafe` because some [of the many] configurable flags could conflict with how the snapshot is built and result in crashes. The snapshot creator also gains a `--v8-flags-unsafe` flag, so advance users COULD create their snapshot and run lightpanda with the same set of flags. --- src/App.zig | 2 +- src/Config.zig | 16 ++++++++++++++ src/browser/Browser.zig | 1 + src/browser/js/Env.zig | 40 +++++++++++++++++++++++++++++++++-- src/browser/js/Platform.zig | 6 +++++- src/cdp/CDP.zig | 8 +++++++ src/help.zon | 10 +++++++++ src/main_snapshot_creator.zig | 25 ++++++++++++++++++---- src/script/Runtime.zig | 1 + 9 files changed, 101 insertions(+), 8 deletions(-) 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(); From 4253bd852d40f54474417ac499d99ca7eb99c359 Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Tue, 7 Jul 2026 20:41:12 +0800 Subject: [PATCH 2/2] update v8 dep --- .github/actions/install/action.yml | 2 +- .github/actions/v8-snapshot/action.yml | 2 +- Dockerfile | 2 +- build.zig.zon | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) 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 = .{