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));
}
This commit is contained in:
Karl Seguin
2026-07-24 17:17:33 +08:00
parent d75747fd20
commit 5a59743f13
3 changed files with 61 additions and 0 deletions

View File

@@ -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 <html> element; unwrap so its
// children land directly on `node`. See https://github.com/servo/html5ever/issues/583.

View File

@@ -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;
}

View File

@@ -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 <parsererror> element per spec.