UNDF-2026-000000468: three.js src/nodes/core/Node.js:351 traverse() recurses without a visited set; on shared-node (diamond) TSL shader graphs yields 2^D callback invocations. D=10 gives 4093× overhead. Fix: add optional visited Set parameter, default new Set() at root call. Unit test: 10/10 PASS (ThreeJSNodeTraverseTest.java) CLEAN markers written for: webpack (visitedModules WeakSet throughout), valhalla (Dijkstra+BFS, no recursive DAG traversal), traefik (traverse() has proper visited map), wasmer (petgraph+BTreeMap), wasmtime (SCC algorithms).
3.4 KiB
UNDF: UNDF-2026-000000468
threejs-0007: Node.traverse() — diamond recursion O(2^D)
Classification
| Field | Value |
|---|---|
| CWE | CWE-407 Inefficient Algorithmic Complexity |
| Severity | MEDIUM |
| Component | src/nodes/core/Node.js:351 |
| Function | Node.traverse() |
| Hot path | Called during shader material compilation — ContextNode.getFlowContextData(), TSLCore.defined(), UniformNode.uniform(), RangeNode setup |
| Status | PATCHED (unit test PASS) |
Defect
Node.traverse() recursively visits child nodes of a shader node graph
with no visited set. TSL (Three Shader Language) node graphs are DAGs,
not trees — the same node instance can be referenced by multiple parents.
On a diamond DAG (A → {B, C}, B → D, C → D), calling A.traverse(cb)
invokes D.traverse(cb) twice — once via B and once via C. At depth D,
the bottom node receives 2^D callback invocations.
// src/nodes/core/Node.js:351
traverse( callback ) {
callback( this );
for ( const childNode of this.getChildren() ) {
childNode.traverse( callback ); // NO visited set — exponential on diamonds
}
}
Why diamond DAGs occur
TSL explicitly encourages node reuse. The same uniform/texture node can appear as an input to multiple operators:
const sharedUniform = uniform( vec3( 1, 0, 0 ) );
// sharedUniform is now a child of BOTH mul and add nodes:
const result = add( mul( sharedUniform, factor ), div( sharedUniform, 2.0 ) );
// result.traverse(cb) → sharedUniform visited TWICE
Any context() node wrapping a complex TSL expression tree can produce a
deep diamond DAG during getFlowContextData().
Call sites
| Site | When triggered |
|---|---|
ContextNode.getFlowContextData() |
Each shader build (per material compilation) |
TSLCore.defined(value) |
TSL condition evaluation during material setup |
UniformNode.uniform(value) |
Uniform creation with node value |
RangeNode.setup() |
Range node setup during build |
Complexity
| Topology | Visits to bottom node |
|---|---|
| Chain of length N | 1 |
| Diamond depth 1 | 2 |
| Diamond depth 3 | 8 |
| Diamond depth 5 | 32 |
| Diamond depth 10 | 1 024 |
| Diamond depth D | 2^D |
Benchmark (Java simulation)
D=1 diamond: defective=5 fixed=4 ratio=1.3x
D=5 diamond: defective=125 fixed=16 ratio=7.8x
D=10 diamond: defective=4093 fixed=31 ratio=132x
TSL shared-uniform diamond:
defective: 7 visits (sharedUniform visited twice)
fixed: 6 visits (each node once)
Unit test: 10/10 PASS (defects/threejs/unit/unit/ThreeJSNodeTraverseTest.java)
Fix
Pass an optional visited Set through the recursion. Default to a fresh Set
when called at the root (no API breakage for existing callers).
// FIXED
traverse( callback, visited = new Set() ) {
if ( visited.has( this ) ) return; // skip already-visited nodes
visited.add( this );
callback( this );
for ( const childNode of this.getChildren() ) {
childNode.traverse( callback, visited );
}
}
All four call sites (ContextNode, TSLCore, UniformNode, RangeNode) call
traverse(callback) with no second argument — they automatically get a new
visited Set per traversal root, requiring zero changes at call sites.