ReviewOS

zig-utils/zig-js

String: padStart/padEnd and join skip the string length cap, growing until killed

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

Found in the post-#933 survey and re-executed today on a Debug build of main, with node v24.4.1 as the oracle.

zig-js caps a constructed string at max_string_bytes (2^26). It added that cap (7bedeab) so that a small input amplified by a number throws RangeError instead of materializing enormous output, and repeat, +, concat and template literals enforce it. Two builtins with the same amplification shape skip it:

programnodezig-js
'a'.padStart(2**53 - 1)RangeError: Invalid string lengthgrows past 1 GB until killed
'a'.padEnd(2**31)RangeErrorgrows past 1 GB until killed
'a'.padStart(2**26 + 10).length6710887467108874, though 'a'.repeat(2**26 + 1) throws RangeError
new Array(2**30).join('ab')RangeError, immediatelystill running after 90 s

node reads element 0 and then throws. With Object.defineProperty(Array.prototype, 0, { get(){ … } }) installed, the getter runs once before the RangeError.

Fix: padStart/padEnd check the target length against the cap before allocating the fill, as repeat does; a result of target code units needs at least target bytes. join and toLocaleString treat the separators still to come as a floor on the result and throw once that floor passes the cap. That reproduces V8's observable order: element 0 is read, then the error is thrown.

Not changed here: output proportional to the data rather than to a numeric argument. [s, s, 'x'].join(''), String.raw, JSON.stringify and Array.prototype.toString can already produce strings over 2^26 when their inputs are that large. Capping them would newly reject outputs that work today, and JavaScriptCore's limit is 2^31-1. Where zig-js's maximum string length should sit is a product decision (raised on #933), not something to settle inside a bug fix.

9 days ago

Taking this; fix written, gating now.

9 days ago

Fixed in 7bda007. padStart/padEnd now check the target length against the cap before building the fill. join and toLocaleString treat the separators still to come as a floor on the result, so they throw after element 0, as V8 does.

Debug before (b5fb0ec) and after:

programbeforeafter
'a'.padStart(2**53 - 1)past 1 GB, killedRangeError
'a'.padEnd(2**31)past 1 GB, killedRangeError
s.padEnd(2**26 + 1)length 67,108,865RangeError (same as repeat)
new Array(2**30).join('ab')over 120 sRangeError in 0.5 s
[s, s, 'x'].join('')length 67,108,865unchanged (data-proportional)

A pinned runtime case covers all of these. The corpus is identical, with 0 flips: String, Array join/toLocaleString/toString, RegExp and annexB, with the full list in the commit. The data-proportional outputs (join of large inputs, String.raw, JSON.stringify) are deliberately unchanged. Where the maximum string length should sit is a product question, and I'm raising it with the user.

Sign in to comment on this issue.