Parent: #933 (adversarial complexity) Related: #936, whose review found this; that fix covers only the stack-exhaustion case
Confirmed by execution
A valid program whose classic for heads nest function expressions makes the parser do exponential work: time and memory double with each level. Each program below is wrapped in function __never(){ … }, so only parsing runs. Measured with test262 --eval built at 480516b (ReleaseFast), with /usr/bin/time -l for peak memory:
head form (N levels) | depth 12 | 14 | 16 | 18 |
|---|---|---|---|---|
for (a[function(){ … }()]; 0;); member | 18 MB | 37 MB | 116 MB | 431 MB |
for ([function(){ … }()]; 0;); array literal | 18 MB | 39 MB | 124 MB | 463 MB |
for (f(function(){ … }); 0;); call | 18 MB | 39 MB | 124 MB | 463 MB |
for ({a: function(){ … }}; 0;); object literal | 18 MB | 37 MB | 116 MB | 431 MB |
for (let [a = function(){ … }] = []; 0;); lexical pattern | 21 MB | 51 MB | 171 MB | 651 MB |
for (x = function(){ … }; 0;); control | 11 MB | 11 MB | 11 MB | 11 MB |
for (a[function(){ … }()] in {}); control | 11 MB | 11 MB | 11 MB | 11 MB |
The member form keeps doubling on the parent of #936 (d91ae16): depth 20 took 821 MB and 0.75 s, and at depth 22 the process passed 3 GB before the memory guard killed it. node validates every shape and parses depth 200 of the member form in 0.2 ms.
Cause
parseFor (src/parser.zig:2478) first tries the head as a for-in/of target through tryForTarget (2577). For the forms above, that attempt parses the whole head, including every nested function body and the for statements inside it. When no in/of follows, it rewinds (self.pos = save, 2523) and parses the same tokens again as a classic initializer. Every nested for inside does the same during both passes, so N levels cost about 2^N parses. The discarded ASTs are arena-owned and never freed, so memory doubles too.
The two controls stay flat because their target attempt stops before any function body: an identifier target is one token, and a for-in commits to the first parse.
Direction
The first parse should be reused, not repeated. Either continue the classic initializer from the already-parsed left-hand side (it is the start of the same expression), or decide the head form before parsing it. A token scan for in/of at bracket depth zero would keep one parse per level, though it would still scan each head's tokens once per enclosing head. Pin the member and lexical-pattern forms at a depth that is instant with the fix and hangs or exhausts memory without it.