ReviewOS

zig-utils/zig-js

Compatibility: Array.prototype.join throws RangeError on cyclic arrays where JavaScriptCore and V8 skip the cycle

#941
Closed glennmichael123 opened this 9 days ago ·
9 days ago

Related: #938 (its review found this while checking print of a self-referential array)

Confirmed by execution

A cyclic array is converted to a string with a nested reference skipped, in JavaScriptCore and V8 alike. zig-js throws RangeError instead.

expressionJavaScriptCore (home-tool)node v24.4.1zig-js (b895723)
String(a) where a = [1]; a.push(a)"1,""1,"RangeError: Maximum call stack size exceeded.
b.join("-") where b = [1, [2]]; b[1].push(b)"1-2,""1-2,"RangeError
c.toLocaleString() where c = [o]; c.push(c) and o.toString returns "O""O,""O,"RangeError

ECMA-262's Array.prototype.join has no cycle detection, so zig-js follows the letter of the spec: the recursion runs until the stack guard throws. But every major engine keeps a set of arrays currently being joined and treats a re-entered array as the empty string. Code written for browsers or Bun relies on that, including logging of self-referential structures, and test262 does not cover it. This is a JavaScriptCore compatibility gap, not a crash.

Direction

Keep a per-realm (or per-interpreter) stack of receivers whose join is in progress, as JavaScriptCore does. On re-entry, return the empty string, and pop on every exit path, including a throw from a user toString. Array.prototype.toString and toLocaleString both go through join. print in the test runner does too, since #938 made it use ECMAScript ToString.

9 days ago

Fixed in 8f0947d.

Reproduced first on the current engine (f2b655d, ReleaseFast) — all three shapes still threw RangeError: Maximum call stack size exceeded. where the JavaScriptCore oracle (home-tool) and node v24.4.1 return "1,", "1-2," and "O,".

Fix: join and toLocaleString share a per-interpreter set of receivers whose join is in progress, keyed by relocation-stable object identity (value.RuntimeObjectIdentity), so a collection during an element's ToString cannot alias a moved receiver. Entries are popped with defer, on every exit path including a throw from a user toString. Array.prototype.toString and the runner's print reach it through join.

One behavior the issue did not specify, taken from the oracle: JavaScriptCore checks the cycle before coercing the separator, V8 after. Re-entering a.join(sep) from an element's toString therefore coerces sep once in JSC and twice in V8 — both still return "1-". zig-js follows JSC.

Ten-value probe, before → after:

#expressionbeforeafterJSCnode
1String(a), a = [1]; a.push(a)RangeError"1,""1,""1,"
2b.join("-"), b = [1,[2]]; b[1].push(b)RangeError"1-2,""1-2,""1-2,"
3c.toLocaleString(), cyclicRangeError"O,""O,""O,"
4d.join(sep) + separator coercionsRangeError"1-" / 1"1-" / 1"1-" / 2
5throwing element toStringboomboomboomboom
6same array joined again after that throwRangeError"1,""1,""1,"
7`[sib, sib].join("")` (not a cycle)`"1,21,2"``"1,2
8cycle re-entered via a plain object's toStringRangeError"1,""1,""1,"
9two-array cycle i=[1]; j=[i]; i.push(j)RangeError"1,""1,""1,"
1040-deep acyclic nesting, join(",").length109109109109

Row 6 is the popped-on-throw check, and row 7 guards against over-eager suppression: the same array appearing twice in one join is not a cycle and must render both times.

No conformance change. test/built-ins/Array 3,081, TypedArray 1,446, String 1,223, Proxy 311, JSON 165, test/language/expressions 11,038 — all 0 fail and identical before and after. Flips 0 cases. Focused suites: runtime 15 (new 10-check case pinned against the JSC oracle), vm 4, frontend 26, jit 393.

9 days ago

Landed in 8f0947d with the evidence above.

Sign in to comment on this issue.