From 5a59743f135cb00f014eb12de905170e351d61da Mon Sep 17 00:00:00 2001 From: Karl Seguin Date: Fri, 24 Jul 2026 17:17:33 +0800 Subject: [PATCH] stability: Poll terminate state during large HTML parsing V8's terminate isn't pre-emptive. We can arm it, but it still hast to cross a boundary to fire. JavaScript that triggers a near endless Zig loop won't be able to interrupt the Zig code: ``` while (true) { el.innerHTML += el.innerHTML; } ``` Can generate a huge HTML input that the Parser will work on for ages. I don't have a general solution to this. I've made the Parser (and DOMParser) check the terminate state every 1024 appends. This only covers one specific case, but it's possibly common enough to guard again, specifically because we don't have rendering/dimensions, and many websites will append text until certain dimensions are reached. But it wouldn't for example, catch a similar: while (true) { el.appendChild(el.firstChild.cloneNode(true)); } --- src/browser/Frame.zig | 3 ++ src/browser/parser/Parser.zig | 52 ++++++++++++++++++++++++++++++++ src/browser/webapi/DOMParser.zig | 6 ++++ 3 files changed, 61 insertions(+) diff --git a/src/browser/Frame.zig b/src/browser/Frame.zig index 827aa3a3c..8a3c227b4 100644 --- a/src/browser/Frame.zig +++ b/src/browser/Frame.zig @@ -2910,6 +2910,9 @@ fn parseHtmlAsChildrenInner(self: *Frame, node: *Node, html: []const u8, opts: F var parser = Parser.init(self.call_arena, node, self, .{ .allow_declarative_shadow = opts.allow_declarative_shadow }); parser.parseFragment(html); + if (parser.terminated) { + return error.ExecutionTerminated; + } // html5ever wraps fragment output in an element; unwrap so its // children land directly on `node`. See https://github.com/servo/html5ever/issues/583. diff --git a/src/browser/parser/Parser.zig b/src/browser/parser/Parser.zig index d252aa9db..b95e4715b 100644 --- a/src/browser/parser/Parser.zig +++ b/src/browser/parser/Parser.zig @@ -28,6 +28,7 @@ const CData = @import("../webapi/CData.zig"); pub const AttributeIterator = h5e.AttributeIterator; const Allocator = std.mem.Allocator; +const TERMINATE_CHECK_INTERVAL = 1024; const IS_DEBUG = @import("builtin").mode == .Debug; pub const ParsedNode = struct { @@ -76,6 +77,9 @@ buf: std.ArrayList(u8), // innerHTML and DOMParser (per spec). Set from Options at init. allow_declarative_shadow: bool = false, +terminated: bool = false, +appends_until_terminate_check: u16 = TERMINATE_CHECK_INTERVAL, + pub const Options = struct { allow_declarative_shadow: bool = false, }; @@ -428,6 +432,9 @@ fn parseErrorCallback(ctx: *anyopaque, err: h5e.StringSlice) callconv(.c) void { fn popCallback(ctx: *anyopaque, node_ref: *anyopaque) callconv(.c) void { const self: *Parser = @ptrCast(@alignCast(ctx)); + if (self.terminated) { + return; + } const cp = self.frame._ce_reactions.push(); defer self.frame._ce_reactions.popAndInvoke(cp, self.frame); self._popCallback(getNode(node_ref)) catch |err| { @@ -535,6 +542,9 @@ fn _createProcessingInstruction(self: *Parser, target: []const u8, data: []const fn appendDoctypeToDocument(ctx: *anyopaque, name: h5e.StringSlice, public_id: h5e.StringSlice, system_id: h5e.StringSlice) callconv(.c) void { const self: *Parser = @ptrCast(@alignCast(ctx)); + if (self.terminated) { + return; + } const cp = self.frame._ce_reactions.push(); defer self.frame._ce_reactions.popAndInvoke(cp, self.frame); self._appendDoctypeToDocument(name.slice(), public_id.slice(), system_id.slice()) catch |err| { @@ -559,6 +569,9 @@ fn _appendDoctypeToDocument(self: *Parser, name: []const u8, public_id: []const fn addAttrsIfMissingCallback(ctx: *anyopaque, target_ref: *anyopaque, attributes: h5e.AttributeIterator) callconv(.c) void { const self: *Parser = @ptrCast(@alignCast(ctx)); + if (self.terminated) { + return; + } const cp = self.frame._ce_reactions.push(); defer self.frame._ce_reactions.popAndInvoke(cp, self.frame); self._addAttrsIfMissingCallback(getNode(target_ref), attributes) catch |err| { @@ -607,6 +620,7 @@ fn _getTemplateContentsCallback(self: *Parser, node: *Node) !*anyopaque { // fall back to inserting the template as a normal light-DOM element. fn attachDeclarativeShadowCallback(ctx: *anyopaque, host_ref: *anyopaque, template_ref: *anyopaque, mode_is_open: u8) callconv(.c) u8 { const self: *Parser = @ptrCast(@alignCast(ctx)); + if (self.terminated) return 0; return self._attachDeclarativeShadowCallback(getNode(host_ref), getNode(template_ref), mode_is_open != 0) catch |err| { self.err = .{ .err = err, .source = .attach_declarative_shadow }; return 0; @@ -643,6 +657,10 @@ fn getDataCallback(ctx: *anyopaque) callconv(.c) *anyopaque { fn appendCallback(ctx: *anyopaque, parent_ref: *anyopaque, node_or_text: h5e.NodeOrText) callconv(.c) void { const self: *Parser = @ptrCast(@alignCast(ctx)); + if (self.pollTerminate()) { + return; + } + const cp = self.frame._ce_reactions.push(); defer self.frame._ce_reactions.popAndInvoke(cp, self.frame); self._appendCallback(getNode(parent_ref), node_or_text) catch |err| { @@ -677,6 +695,9 @@ fn _appendCallback(self: *Parser, parent: *Node, node_or_text: h5e.NodeOrText) ! fn removeFromParentCallback(ctx: *anyopaque, target_ref: *anyopaque) callconv(.c) void { const self: *Parser = @ptrCast(@alignCast(ctx)); + if (self.terminated) { + return; + } const cp = self.frame._ce_reactions.push(); defer self.frame._ce_reactions.popAndInvoke(cp, self.frame); self._removeFromParentCallback(getNode(target_ref)) catch |err| { @@ -695,6 +716,9 @@ fn _removeFromParentCallback(self: *Parser, node: *Node) !void { fn reparentChildrenCallback(ctx: *anyopaque, node_ref: *anyopaque, new_parent_ref: *anyopaque) callconv(.c) void { const self: *Parser = @ptrCast(@alignCast(ctx)); + if (self.terminated) { + return; + } const cp = self.frame._ce_reactions.push(); defer self.frame._ce_reactions.popAndInvoke(cp, self.frame); self._reparentChildrenCallback(getNode(node_ref), getNode(new_parent_ref)) catch |err| { @@ -711,6 +735,10 @@ fn _reparentChildrenCallback(self: *Parser, node: *Node, new_parent: *Node) !voi fn appendBeforeSiblingCallback(ctx: *anyopaque, sibling_ref: *anyopaque, node_or_text: h5e.NodeOrText) callconv(.c) void { const self: *Parser = @ptrCast(@alignCast(ctx)); + if (self.pollTerminate()) { + return; + } + const cp = self.frame._ce_reactions.push(); defer self.frame._ce_reactions.popAndInvoke(cp, self.frame); self._appendBeforeSiblingCallback(getNode(sibling_ref), node_or_text) catch |err| { @@ -741,6 +769,10 @@ fn _appendBeforeSiblingCallback(self: *Parser, sibling: *Node, node_or_text: h5e fn appendBasedOnParentNodeCallback(ctx: *anyopaque, element_ref: *anyopaque, prev_element_ref: *anyopaque, node_or_text: h5e.NodeOrText) callconv(.c) void { const self: *Parser = @ptrCast(@alignCast(ctx)); + if (self.pollTerminate()) { + return; + } + const cp = self.frame._ce_reactions.push(); defer self.frame._ce_reactions.popAndInvoke(cp, self.frame); self._appendBasedOnParentNodeCallback(getNode(element_ref), getNode(prev_element_ref), node_or_text) catch |err| { @@ -772,3 +804,23 @@ fn asUint(comptime string: anytype) std.meta.Int( return @bitCast(@as(*const [byteLength]u8, string).*); } + +// v8's terminate isn't pre-emptive. A parse of unbounded input +// (this.innerHTML += this.innerHTML) has to poll the terminate flag itself. +// We'll poll this (atomic) variable every TERMINATE_CHECK_INTERVAL append. +fn pollTerminate(self: *Parser) bool { + if (self.terminated) { + return true; + } + + const next_check = self.appends_until_terminate_check - 1; + if (next_check > 0) { + self.appends_until_terminate_check = next_check; + return false; + } + self.appends_until_terminate_check = TERMINATE_CHECK_INTERVAL; + + const terminated = self.frame.js.env.terminatePending(); + self.terminated = terminated; + return terminated; +} diff --git a/src/browser/webapi/DOMParser.zig b/src/browser/webapi/DOMParser.zig index c85b497d2..57c0f4080 100644 --- a/src/browser/webapi/DOMParser.zig +++ b/src/browser/webapi/DOMParser.zig @@ -79,6 +79,9 @@ pub fn parseFromString( // Parse HTML into the document var parser = Parser.init(arena, doc.asNode(), frame, .{}); parser.parse(normalized); + if (parser.terminated) { + return error.ExecutionTerminated; + } if (parser.err) |pe| { return pe.err; @@ -96,6 +99,9 @@ pub fn parseFromString( const doc_node = doc.asNode(); var parser = Parser.init(arena, doc_node, frame, .{}); parser.parseXML(html); + if (parser.terminated) { + return error.ExecutionTerminated; + } if (parser.err != null or doc_node.firstChild() == null) { // Return a document with a element per spec.