Skip to content

Commit 5e1be23

Browse files
Merge pull request #129 from SceneTech/fix/node-iterator-stress-isolation
Isolate the NodeIterator stress fixture
2 parents 8baff8a + d84a6a6 commit 5e1be23

8 files changed

Lines changed: 550 additions & 7 deletions

File tree

‎experiments/WebScene.NativeEngine.Probe/native/webscene_v8_runtime.cpp‎

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2508,6 +2508,125 @@ struct v8_dom_runtime::implementation final {
25082508
return next;
25092509
}
25102510
}
2511+
)JS",
2512+
R"JS(
2513+
class WebSceneNodeIterator {
2514+
constructor(root, whatToShow = nodeFilterConstants.SHOW_ALL, filter = null) {
2515+
if (!root || typeof root.nodeType !== 'number') {
2516+
throw new TypeError('NodeIterator root must be a Node');
2517+
}
2518+
if (filter != null && typeof filter !== 'function'
2519+
&& typeof filter.acceptNode !== 'function') {
2520+
throw new TypeError('NodeIterator filter must be callable');
2521+
}
2522+
Object.defineProperties(this, {
2523+
root: { value: root, enumerable: true },
2524+
whatToShow: {
2525+
value: Number(whatToShow) >>> 0, enumerable: true
2526+
},
2527+
filter: { value: filter ?? null, enumerable: true }
2528+
});
2529+
this._nodes = [];
2530+
this._referenceIndex = 0;
2531+
this._referenceNode = root;
2532+
this._pointerBeforeReferenceNode = true;
2533+
this._filterActive = false;
2534+
2535+
// Retain a bounded structural snapshot, but never run author
2536+
// code here. Filtering belongs to traversal so exceptions and
2537+
// recursive-filter errors occur at the specified call site.
2538+
const visit = node => {
2539+
this._nodes.push(node);
2540+
for (let child = node.firstChild; child; child = child.nextSibling) {
2541+
visit(child);
2542+
}
2543+
};
2544+
visit(root);
2545+
}
2546+
get referenceNode() { return this._referenceNode; }
2547+
get pointerBeforeReferenceNode() {
2548+
return this._pointerBeforeReferenceNode;
2549+
}
2550+
_isWithinRoot(node) {
2551+
for (let current = node; current; current = current.parentNode) {
2552+
if (current === this.root) return true;
2553+
}
2554+
return false;
2555+
}
2556+
_repairRemovedReference() {
2557+
if (this._isWithinRoot(this._referenceNode)) return;
2558+
for (let index = this._referenceIndex - 1; index >= 0; --index) {
2559+
const candidate = this._nodes[index];
2560+
if (!this._isWithinRoot(candidate)) continue;
2561+
this._referenceIndex = index;
2562+
this._referenceNode = candidate;
2563+
this._pointerBeforeReferenceNode = false;
2564+
return;
2565+
}
2566+
this._referenceIndex = 0;
2567+
this._referenceNode = this.root;
2568+
this._pointerBeforeReferenceNode = true;
2569+
}
2570+
_acceptNode(node) {
2571+
const mask = node.nodeType > 0 && node.nodeType <= 32
2572+
? (1 << (node.nodeType - 1)) >>> 0
2573+
: 0;
2574+
if ((this.whatToShow & mask) === 0) return false;
2575+
if (this.filter == null) return true;
2576+
const callback = typeof this.filter === 'function'
2577+
? this.filter
2578+
: this.filter.acceptNode;
2579+
return Number(callback.call(this.filter, node))
2580+
=== nodeFilterConstants.FILTER_ACCEPT;
2581+
}
2582+
_traverse(forward) {
2583+
if (this._filterActive) {
2584+
throw new DOMException(
2585+
'NodeIterator filter is already active', 'InvalidStateError');
2586+
}
2587+
this._repairRemovedReference();
2588+
let index = this._referenceIndex;
2589+
let before = this._pointerBeforeReferenceNode;
2590+
this._filterActive = true;
2591+
try {
2592+
while (true) {
2593+
if (forward) {
2594+
if (before) before = false;
2595+
else ++index;
2596+
if (index >= this._nodes.length) return null;
2597+
} else {
2598+
if (before) --index;
2599+
else before = true;
2600+
if (index < 0) return null;
2601+
}
2602+
const candidate = this._nodes[index];
2603+
if (!this._isWithinRoot(candidate)
2604+
|| !this._acceptNode(candidate)) {
2605+
continue;
2606+
}
2607+
// Removing the candidate from inside its own filter still
2608+
// returns that candidate. The pre-removal position remains
2609+
// on an attached node so later traversal cannot re-enter a
2610+
// detached subtree.
2611+
if (this._isWithinRoot(candidate)) {
2612+
this._referenceIndex = index;
2613+
this._referenceNode = candidate;
2614+
this._pointerBeforeReferenceNode = before;
2615+
}
2616+
return candidate;
2617+
}
2618+
} finally {
2619+
this._filterActive = false;
2620+
this._repairRemovedReference();
2621+
}
2622+
}
2623+
nextNode() { return this._traverse(true); }
2624+
previousNode() { return this._traverse(false); }
2625+
detach() {}
2626+
}
2627+
Object.defineProperty(WebSceneNodeIterator.prototype, Symbol.toStringTag, {
2628+
value: 'NodeIterator', configurable: true
2629+
});
25112630
25122631
const installTreeWalkerPlatform = () => {
25132632
const createTreeWalker = function(
@@ -2521,6 +2640,15 @@ struct v8_dom_runtime::implementation final {
25212640
Object.defineProperty(documentPrototype, 'createTreeWalker', {
25222641
value: createTreeWalker, writable: true, configurable: true
25232642
});
2643+
const createNodeIterator = function(
2644+
root,
2645+
whatToShow = nodeFilterConstants.SHOW_ALL,
2646+
filter = null) {
2647+
return new WebSceneNodeIterator(root, whatToShow, filter);
2648+
};
2649+
Object.defineProperty(documentPrototype, 'createNodeIterator', {
2650+
value: createNodeIterator, writable: true, configurable: true
2651+
});
25242652
};
25252653
)JS",
25262654
R"JS(
@@ -2863,6 +2991,9 @@ struct v8_dom_runtime::implementation final {
28632991
},
28642992
TreeWalker: {
28652993
value: WebSceneTreeWalker, writable: true, configurable: true
2994+
},
2995+
NodeIterator: {
2996+
value: WebSceneNodeIterator, writable: true, configurable: true
28662997
}
28672998
});
28682999
})();

‎experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_browser_dom_tests.inc‎

Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,6 +1164,199 @@ void test_node_filter_tree_walker_focus_navigation(webscene_engine* engine)
11641164
"NodeFilter/TreeWalker focus navigation regressed: " + result);
11651165
}
11661166

1167+
void test_node_iterator_dompurify_and_filter_contracts(webscene_engine* engine)
1168+
{
1169+
const auto result = evaluate(engine, R"JS(
1170+
(() => {
1171+
document.body.innerHTML = '<main id="root">before<!--comment--><section><span>inside</span></section>after</main>';
1172+
const root = document.getElementById('root');
1173+
const mask = NodeFilter.SHOW_ELEMENT
1174+
| NodeFilter.SHOW_TEXT
1175+
| NodeFilter.SHOW_COMMENT;
1176+
let filterCalls = 0;
1177+
const iterator = document.createNodeIterator(root, mask, {
1178+
acceptNode(node) {
1179+
++filterCalls;
1180+
return node.nodeType === Node.ELEMENT_NODE
1181+
&& node.localName === 'section'
1182+
? NodeFilter.FILTER_REJECT
1183+
: NodeFilter.FILTER_ACCEPT;
1184+
}
1185+
});
1186+
const callsAfterConstruction = filterCalls;
1187+
const forward = [];
1188+
for (let node = iterator.nextNode(); node; node = iterator.nextNode()) {
1189+
forward.push(node.nodeType === Node.ELEMENT_NODE
1190+
? node.localName
1191+
: node.nodeType === Node.COMMENT_NODE
1192+
? `#comment:${node.data}`
1193+
: `#text:${node.data}`);
1194+
}
1195+
const forwardCalls = filterCalls;
1196+
const endState = [
1197+
iterator.referenceNode.nodeName,
1198+
iterator.pointerBeforeReferenceNode
1199+
];
1200+
const backwards = [
1201+
iterator.previousNode()?.nodeName ?? null,
1202+
iterator.previousNode()?.nodeName ?? null,
1203+
iterator.nextNode()?.nodeName ?? null
1204+
];
1205+
1206+
const thrown = { marker: 'filter failure' };
1207+
const throwing = document.createNodeIterator(
1208+
root, NodeFilter.SHOW_ELEMENT, () => { throw thrown; });
1209+
let constructorDeferredException = true;
1210+
let propagatedExactException = false;
1211+
try { throwing.nextNode(); } catch (error) {
1212+
propagatedExactException = error === thrown;
1213+
}
1214+
1215+
let recursive;
1216+
let depth = 0;
1217+
recursive = document.createNodeIterator(
1218+
root,
1219+
NodeFilter.SHOW_ELEMENT,
1220+
node => {
1221+
if (recursive.referenceNode !== root && depth === 0) {
1222+
++depth;
1223+
recursive.nextNode();
1224+
}
1225+
return NodeFilter.FILTER_ACCEPT;
1226+
});
1227+
recursive.nextNode();
1228+
recursive.nextNode();
1229+
let recursiveNext = null;
1230+
try { recursive.nextNode(); } catch (error) {
1231+
recursiveNext = error.name;
1232+
}
1233+
--depth;
1234+
let recursivePrevious = null;
1235+
try { recursive.previousNode(); } catch (error) {
1236+
recursivePrevious = error.name;
1237+
}
1238+
1239+
const functionFilter = document.createNodeIterator(
1240+
root,
1241+
NodeFilter.SHOW_ELEMENT,
1242+
node => node.localName === 'span'
1243+
? NodeFilter.FILTER_ACCEPT
1244+
: NodeFilter.FILTER_SKIP);
1245+
const functionFiltered = [];
1246+
for (let node = functionFilter.nextNode(); node;
1247+
node = functionFilter.nextNode()) {
1248+
functionFiltered.push(node.localName);
1249+
}
1250+
1251+
const fragment = document.createDocumentFragment();
1252+
const detached = document.createElement('strong');
1253+
fragment.appendChild(detached);
1254+
const detachedIterator = document.createNodeIterator(
1255+
fragment, NodeFilter.SHOW_ALL);
1256+
1257+
let invalidRoot = false;
1258+
let invalidFilter = false;
1259+
try { document.createNodeIterator(null); } catch (error) {
1260+
invalidRoot = error instanceof TypeError;
1261+
}
1262+
try { document.createNodeIterator(root, mask, {}); } catch (error) {
1263+
invalidFilter = error instanceof TypeError;
1264+
}
1265+
1266+
return {
1267+
instance: iterator instanceof NodeIterator,
1268+
brand: iterator.toString(),
1269+
root: iterator.root === root,
1270+
whatToShow: iterator.whatToShow,
1271+
callsAfterConstruction,
1272+
forwardCalls,
1273+
forward,
1274+
endState,
1275+
backwards,
1276+
constructorDeferredException,
1277+
propagatedExactException,
1278+
recursiveNext,
1279+
recursivePrevious,
1280+
functionFiltered,
1281+
detached: [
1282+
detachedIterator.nextNode() === fragment,
1283+
detachedIterator.nextNode() === detached,
1284+
detachedIterator.nextNode()
1285+
],
1286+
invalidRoot,
1287+
invalidFilter
1288+
};
1289+
})()
1290+
)JS", "native-node-iterator-dompurify.js");
1291+
require(
1292+
result == R"JSON({"instance":true,"brand":"[object NodeIterator]","root":true,"whatToShow":133,"callsAfterConstruction":0,"forwardCalls":7,"forward":["main","#text:before","#comment:comment","span","#text:inside","#text:after"],"endState":["#text",false],"backwards":["#text","#text","#text"],"constructorDeferredException":true,"propagatedExactException":true,"recursiveNext":"InvalidStateError","recursivePrevious":"InvalidStateError","functionFiltered":["span"],"detached":[true,true,null],"invalidRoot":true,"invalidFilter":true})JSON",
1293+
"NodeIterator DOMPurify/filter contract regressed: " + result);
1294+
}
1295+
1296+
void test_node_iterator_isolated_scaling_and_memory_gate()
1297+
{
1298+
auto engine = std::unique_ptr<webscene_engine, decltype(&webscene_engine_destroy)>{
1299+
webscene_engine_create(0), &webscene_engine_destroy};
1300+
require(engine != nullptr, "NodeIterator performance engine creation failed");
1301+
1302+
const auto result = evaluate(engine.get(), R"JS(
1303+
(() => {
1304+
const run = count => {
1305+
document.body.innerHTML = `<div id="iterator-root">${'<i></i>'.repeat(count)}</div>`;
1306+
const root = document.getElementById('iterator-root');
1307+
let filterCalls = 0;
1308+
const started = performance.now();
1309+
const iterator = document.createNodeIterator(
1310+
root,
1311+
NodeFilter.SHOW_ELEMENT,
1312+
() => {
1313+
++filterCalls;
1314+
return NodeFilter.FILTER_ACCEPT;
1315+
});
1316+
const callsAfterConstruction = filterCalls;
1317+
let visited = 0;
1318+
while (iterator.nextNode()) ++visited;
1319+
return {
1320+
count,
1321+
visited,
1322+
filterCalls,
1323+
callsAfterConstruction,
1324+
elapsed: performance.now() - started
1325+
};
1326+
};
1327+
1328+
const small = run(10000);
1329+
const large = run(50000);
1330+
return {
1331+
smallCounts: [small.visited, small.filterCalls, small.callsAfterConstruction],
1332+
largeCounts: [large.visited, large.filterCalls, large.callsAfterConstruction],
1333+
underFiveSeconds: large.elapsed < 5000,
1334+
scalesLinearly: large.elapsed <= small.elapsed * 8 + 250
1335+
};
1336+
})()
1337+
)JS", "native-node-iterator-isolated-performance.js");
1338+
1339+
webscene_engine_memory_metrics memory{
1340+
sizeof(webscene_engine_memory_metrics)};
1341+
require(
1342+
webscene_engine_get_memory_metrics(engine.get(), &memory) != 0,
1343+
"NodeIterator performance memory metrics were unavailable");
1344+
constexpr uint64_t maximum_fixture_nodes = 70'000U;
1345+
constexpr uint64_t maximum_fixture_heap_bytes = 128U * 1024U * 1024U;
1346+
const auto bounded_memory =
1347+
memory.native_dom_node_count <= maximum_fixture_nodes
1348+
&& memory.native_wrapper_handle_count <= maximum_fixture_nodes
1349+
&& memory.v8_used_heap_bytes <= maximum_fixture_heap_bytes;
1350+
1351+
engine.reset();
1352+
require(
1353+
result == R"JSON({"smallCounts":[10001,10001,0],"largeCounts":[50001,50001,0],"underFiveSeconds":true,"scalesLinearly":true})JSON",
1354+
"isolated NodeIterator scaling gate failed: " + result);
1355+
require(
1356+
bounded_memory,
1357+
"isolated NodeIterator memory gate exceeded its 70,000-node/handle or 128 MiB V8 bound");
1358+
}
1359+
11671360
void test_animation_frame_dispatch_is_attributed()
11681361
{
11691362
auto* engine = webscene_engine_create(0);

‎experiments/WebScene.NativeEngine.Probe/tests/native_v8_runtime_tests.cpp‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
#include <iostream>
2323
#include <limits>
2424
#include <locale>
25+
#include <memory>
2526
#include <mutex>
2627
#include <sstream>
2728
#include <string>
@@ -215,6 +216,17 @@ int main()
215216
webscene_engine_destroy(focused_engine);
216217
return 0;
217218
}
219+
if (selected == "node-iterator") {
220+
auto* focused_engine = webscene_engine_create(0);
221+
require(focused_engine != nullptr, "NodeIterator engine creation failed");
222+
test_node_iterator_dompurify_and_filter_contracts(focused_engine);
223+
webscene_engine_destroy(focused_engine);
224+
return 0;
225+
}
226+
if (selected == "node-iterator-performance") {
227+
test_node_iterator_isolated_scaling_and_memory_gate();
228+
return 0;
229+
}
218230
if (selected == "media-query-list") {
219231
auto* focused_engine = webscene_engine_create(0);
220232
require(focused_engine != nullptr, "focused engine creation failed");
@@ -837,6 +849,8 @@ int main()
837849
test_event_listener_options_reach_native_input_and_resize(engine);
838850
test_listener_added_during_dispatch_waits_for_next_event(engine);
839851
test_node_filter_tree_walker_focus_navigation(engine);
852+
test_node_iterator_dompurify_and_filter_contracts(engine);
853+
test_node_iterator_isolated_scaling_and_memory_gate();
840854
test_table_cell_click_copies_text_to_host();
841855
test_synthetic_window_resize_dispatch_uses_outer_listener_registry(engine);
842856
test_document_create_event_and_init_event(engine);

0 commit comments

Comments
 (0)