JSON.stringify: give the DynamicBuffer fast path a depth limit so cyclic values fail fast

RO
2mo ago
+113-4

Problem

  • JSON.stringify on a cyclic value is pathologically slow in Bun (oven-sh/bun#40974). Flat: ~3 ms per call, about 640x Node. With an indent: ~1.5 s and ~2 GB of RSS per call, about 100,000x Node. Acyclic values are unaffected.
  • FastStringifier has no recursion or cycle check. The design counts on the buffer limit to catch cycles. In DynamicBuffer mode that limit is the 2 GB string length cap, so a cyclic value with a gap doubles the buffer up to 2 GB before the attempt fails. Only then does the general Stringifier run and throw the TypeError.

Fix

  • Values nested deeper than 512 now fail the DynamicBuffer fast path with a new DepthLimit failure reason. The general Stringifier handles them: it detects cycles at once and serializes any depth iteratively.

  • m_depth is now also tracked in gap-less DynamicBuffer mode (it was gap-only). StaticBuffer mode is unchanged, its 8 KB buffer already bounds the waste.

  • jsc shell, Linux x64 release, min of 9, 8 keys of 50 bytes plus a self reference:

    casebeforeafter
    cyclic flat3.15 ms/op0.13 ms/op
    cyclic indent 21464 ms/op1.5 ms/op
    acyclic 8 keys0.0027 ms/op0.0027 ms/op
    200k-object array, flat25.8 ms26.8 ms
    200k-object array, indent 224.5 ms23.8 ms
  • JSTests/stress/json-stringify-cyclic-depth-limit.js covers the new bail path: cycles throw TypeError flat and with gaps, through objects and arrays, direct and indirect, and 1000-deep acyclic values round-trip through the general path with 8-bit and 16-bit content. Note: fork CI does not run JSTests. The file runs under run-jsc-stress-tests locally, and the Bun PR runs it in CI as a jsc-stress fixture.

Notes

  • Known residual: a cycle that carries several MB of payload per revolution still reaches the 2 GB cap within 512 levels (a 3 MB payload cycle takes ~1.5 s). I measured exact cycle detection to fix this: a visit stack (WTF::Vector, then a flat array, then stack-frame-linked chain nodes gated behind a depth threshold). Every design cost 7-53% on large acyclic documents (for example 24.5 ms to 37 ms on a 19 MB indented stringify, on both GCC and Clang), because any per-container work in append() disturbs the hot path. The depth limit is free, and it fully fixes the reported shapes, which carry bytes per revolution in the hundreds, not millions.
  • Upstream WebKit main has the identical DynamicBuffer pathology. This patch applies there as is.