Skip to content

[JSC] Keep cycle detection in the array to string conversions (join, toString, toLocaleString) - #446

Closed
robobun wants to merge 1 commit into
mainfrom
farm/c48a6fe9/array-join-cycle-detection
Closed

robobun wants to merge 1 commit into
mainfrom
farm/c48a6fe9/array-join-cycle-detection

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • Since the sync to upstream 3722912ff800 (Upgrade to upstream WebKit 3722912ff800 #383), converting an array that contains itself (directly or through other arrays) throws RangeError: Maximum call stack size exceeded: with a = [1]; a[1] = a, a.join(), String(a), `${a}` and a.toLocaleString() all throw. Node.js (V8), SpiderMonkey and every JavaScriptCore before that sync return "1,": the nested occurrence of the array being converted becomes the empty string. Bun 1.3.14 returned "1,", the 1.4 builds throw (found by a minifier equivalence corpus, 2 of 600 programs; it also breaks join() on graph shaped data and logging helpers that relied on the old behavior).
  • Cause: upstream 318399@main ("[JSC] Remove StringRecursionChecker", https://bugs.webkit.org/show_bug.cgi?id=320820) deleted the visited set that arrayProtoFuncJoin, arrayProtoFuncToLocaleString and JSArray::fastToString consulted, because the specification has no cycle rule, and replaced it with a stack check in fastToString. The conversion now recurses natively (fastToString -> fastArrayJoin -> JSStringJoiner::append -> JSObject::toString -> fastToString) until isSafeToRecurseSoft() fails, which is why the error carries no JS frames.
  • The same sync added the DFG/FTL ArrayJoin intrinsic (operationArrayJoin in DFGOperations.cpp), which calls fastArrayJoin directly. Restoring only the three old sites would make a compiled join call site return "1-1," where the interpreter returns "1-" (verified with a build that has everything but the DFG hunk).

Fix

  • StringRecursionChecker.h (new, under USE(BUN_JSC_ADDITIONS)) is the cycle bookkeeping of the removed class and nothing else: the constructor registers the receiver in VM::stringRecursionCheckFirstObject (the non nested case, two stores) or VM::stringRecursionCheckVisitedObjects (nested conversions) and reports whether it was already registered, the destructor unregisters it. The stack overflow half of the old class is not restored: upstream's isSafeToRecurseSoft() check in fastToString, plus the stack checks on the call paths into the other entry points, already cover it, so a deep acyclic nesting still throws RangeError exactly as upstream.
  • The checker is consulted at the four places a conversion of an array (or array-like receiver) starts, each returning the empty string when re-entered: arrayProtoFuncJoin and arrayProtoFuncToLocaleString (where the removed checks were, after ToObject), JSArray::fastToString (the Array.prototype.toString fast path and the array case of JSObject::toString / toPrimitive, which covers String(), template literals and +), and arrayJoinWithStringSeparator, shared by operationArrayJoin / operationArrayJoinGeneric, which is what a DFG or FTL compiled join call runs.
  • Keyed on the receiver and limited to the array conversions, this is V8's model (CycleProtectedArrayJoin pushes the receiver on a per isolate join stack); every expected value in the test was checked against Node.js v26, which passes the test as is. Error.prototype.toString and RegExp.prototype.toString, which the removed class also tracked, stay untracked: V8 does not track them, and tracking them caused the cross conversion oddities upstream cited (an element whose toString calls Error.prototype.toString.call(array) used to make the join ""; it is now "Error", as in V8).
  • Cleanup is RAII and JSC unwinds exceptions by returning, so a conversion that throws (a throwing toString, or the stack overflow of a deep nesting) unregisters every array it registered, on the first object path and the hash set path alike; the test checks both.
  • JSTests/stress/string-conversion-recursion.js is upstream's test from the removal rewritten to the behavior this fork keeps: the self containing array through every entry point, fan out, mutual cycles converted from either side, cycles closed through user toString / toLocaleString / a replaced join, array-like receivers on the generic paths, acyclic reuse of an array, unregistration after throwing conversions, Error / RegExp cycles and a 100000 deep acyclic nesting still throwing RangeError (the shallowest overflowing depth measured is about 1100 arrays in the debug ASan build and 4800 in release), and a testLoopCount warm up on a contiguous acyclic array followed by the cyclic one, whose compiled tier results have to equal the interpreter's.
  • Verified on a local debug (assertions on) build of this branch: the test passes, with and without --useConcurrentJIT=0; on the unmodified autobuild-f0f60fd2 release jsc it fails at its first array assertion with the RangeError above; with only the DFGOperations.cpp hunk removed it fails in the tier section with expected "1-" but got "1-1,".
  • The existing JSTests/stress files about join / toString / toLocaleString of arrays, errors and regexps, and wasm/v8/regress/regress-769846.js, pass on the same build, except four that need ICU data the local shell does not have (details below). The modified translation units also compile clean (-fsyntax-only, -Wall -Wextra) with the flags of the f0f60fd2 release and debug ASan prebuilts. The companion Bun PR pins this PR's preview build and runs the same test as a jsc-stress fixture against the real engine and ICU.

Background

  • Array.prototype.toString calls join; ToString(array) (what String(), template literals and + do) goes through JSObject::toString / toPrimitive, which for an array with an untouched prototype chain calls JSArray::fastToString directly; and join has had a DFG/FTL intrinsic since the same sync. Those are the four places a conversion can start, and all of them convert elements through JSStringJoiner::append, which for an object element lands back in one of them, so a cycle always re-enters one of the four with the array still registered.
  • USE(BUN_JSC_ADDITIONS) marks the fork's own changes so they are recognizable when upstream is merged; the hunks here are purely additive next to upstream's code for the same reason. The two VM fields are the ones the removed class used; a VM belongs to one thread, which is what makes two plain fields a correct "conversions in progress" stack.
  • Upstream's string-conversion-recursion.js asserted the RangeError for arrays, so it had to change. It is the only test in JSTests that depended on the removal: regress-191731.js and wasm/v8/regress/regress-769846.js were made indifferent to it by the same upstream commit and pass on this branch.
Local shell and ICU

The local build links the libicudata.a from the prebuilt tarball, whose items are zstd compressed and decompressed by a hook that Bun provides and the jsc shell does not, so Number.prototype.toLocaleString and Intl throw in that shell. Affected, and unrelated to this change: array-toLocaleString.js, array-tolocalestring-empty-separator.js, array-tolocalestring-options.js and missing-exception-check-in-array-prototype-fastJoin.js fail there before reaching the changed code and pass on the unmodified prebuilt; the last one passes locally once Number.prototype.toLocaleString is stubbed, the other three compare locale output. The new test's own number toLocaleString calls were stubbed the same way (Number.prototype.toLocaleString = function() { return String(this); } prepended) for the local run; the jsc-stress fixture in the Bun PR runs it unstubbed. The other 25 related files (array-join-*.js, array-prototype-join-*.js, array-tolocalestring-*.js, array-toString-non-callable-join.js, empty-string-join.js, immutable-butterfly-to-string-cache-should-not-happen-for-generic-join.js, regress-191731.js, regexp-prototype-tostring.js, nativeerror-prototype-tostring.js, errors-with-simple-names-or-messages-should-not-crash-toString.js, object-to-string-and-to-this.js, bigint-to-string-in-array.js, json-fast-stringifier-on-cyclic-structure.js) pass as is.

Upstream 318399@main (https://bugs.webkit.org/show_bug.cgi?id=320820) removed
StringRecursionChecker, so converting an array that contains itself with
Array.prototype.join, Array.prototype.toString, Array.prototype.toLocaleString
or ToString / ToPrimitive now recurses until the stack check throws a
RangeError. V8 and SpiderMonkey detect the cycle and convert the nested
occurrence to the empty string, so `a = [1]; a[1] = a; a.join()` is "1," in
Node.js and in every browser, and it was "1," in every JavaScriptCore before
that change.

Bring the detection back for the array conversions only, under
USE(BUN_JSC_ADDITIONS): arrayProtoFuncJoin, arrayProtoFuncToLocaleString,
JSArray::fastToString (the Array.prototype.toString fast path and the array
case of JSObject::toString / toPrimitive), and the DFG / FTL ArrayJoin
operation, which did not exist when the checker was removed and would
otherwise make a compiled join site return "1-1," where the interpreter
returns "1-". The checker only does the cycle bookkeeping; stack overflow
stays with the checks upstream added. Error.prototype.toString and
RegExp.prototype.toString are not tracked, matching V8 and upstream.

JSTests/stress/string-conversion-recursion.js now describes this behavior:
the array cases through every entry point, array-likes, acyclic reuse of an
array, cleanup after a conversion throws, Error / RegExp cycles and deep
acyclic nesting still overflowing, and the cyclic results agreeing between
the interpreter and the optimizing tiers.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 1 minute

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: e2609e98-2884-4db6-ae22-26bea6ea11a3

📥 Commits

Reviewing files that changed from the base of the PR and between 592a255 and bd6abe5.

📒 Files selected for processing (6)
  • JSTests/stress/string-conversion-recursion.js
  • Source/JavaScriptCore/dfg/DFGOperations.cpp
  • Source/JavaScriptCore/runtime/ArrayPrototype.cpp
  • Source/JavaScriptCore/runtime/JSArray.cpp
  • Source/JavaScriptCore/runtime/StringRecursionChecker.h
  • Source/JavaScriptCore/runtime/VM.h

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it deliberately diverges from upstream JSC and the spec on hot-path array conversion semantics, and adds per-VM state that must stay consistent across all four entry points (interpreter, fastToString, and the DFG/FTL join intrinsic), a human look would still be worthwhile.

Checked the RAII register/unregister logic in StringRecursionChecker — the first-object fast path and hash-set path pair correctly, and JSC's return-based exception unwinding runs the destructors.
Checked arrayJoinWithStringSeparator: the checker sits after the JSOnlyStringsAndInt32sJoiner fast path, which cannot recurse (bails on non-string/non-int32 elements), so no cycle escapes there.
Checked that the raw JSObject* in the VM fields are always live on the C++ stack while registered, so conservative scanning keeps them rooted and JSC's non-moving GC keeps the pointers stable.

Extended reasoning...

Overview

This PR restores cycle detection for array-to-string conversions (join, toString, toLocaleString, and the ToString/ToPrimitive fast path) in Bun's JavaScriptCore fork, after upstream WebKit removed it in 318399@main. It adds a new StringRecursionChecker RAII helper (header-only, guarded by USE(BUN_JSC_ADDITIONS)), two fields on VM (stringRecursionCheckFirstObject and stringRecursionCheckVisitedObjects), and inserts the checker at four entry points: arrayProtoFuncJoin, arrayProtoFuncToLocaleString, JSArray::fastToString, and the DFG/FTL arrayJoinWithStringSeparator operation. The stress test is rewritten to assert V8-compatible cycle behavior instead of upstream's stack-overflow behavior.

Security risks

None identified. The change is purely additive under a fork-specific compile guard, stores raw pointers to objects that are already conservatively rooted on the C++ stack for the lifetime of the checker, and does not touch auth, crypto, permissions, or memory-layout-sensitive code. The hash set is per-VM (single-threaded) so there are no concurrency concerns.

Level of scrutiny

High. This modifies extremely hot runtime paths (Array.prototype.join / toString are among the most-called builtins) across the interpreter, the JSObject::toString fast path, and the DFG/FTL JIT operation, and it must keep those tiers observably identical. It is also a maintained, intentional divergence from both the ECMAScript spec and upstream WebKit — a design decision about which engine's behavior Bun tracks. A maintainer should confirm that this divergence is desired and that the four insertion points cover every route into fastArrayJoin going forward.

Other factors

The implementation itself looks correct: the first-object / hash-set bookkeeping mirrors the pre-removal upstream code, the destructor's branch order matches the constructor's, and the [[unlikely]] recursive branch keeps the common path cheap. The DFG placement after JSOnlyStringsAndInt32sJoiner::tryJoin is safe because that fast path bails on any object element and therefore cannot recurse. The PR description documents thorough local verification (debug + ASan, tier-consistency test, cross-check against Node.js v26). Test coverage in the rewritten stress file is comprehensive — direct/mutual cycles, user-code cycles, array-like receivers, throw-unwinding cleanup, deep acyclic overflow, and JIT warm-up. Given the scope, hot-path placement, and the long-term maintenance implication of diverging from upstream, deferring to a human reviewer rather than auto-approving.

@github-actions

Copy link
Copy Markdown

Preview Builds

Commit Release Date
bd6abe59 autobuild-preview-pr-446-bd6abe59 2026-08-15 18:08:21 UTC

@robobun

robobun commented Sep 4, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: superseded by #559, which restores the same cycle guard and is merged.

@robobun robobun closed this Sep 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants