Skip to content

Give JavaScript strings a maximum length instead of a wrapped array rent - #3015

Merged
lahma merged 3 commits into
sebastienros:mainfrom
lahma:fix/string-length-limit
Aug 15, 2026
Merged

lahma merged 3 commits into
sebastienros:mainfrom
lahma:fix/string-length-limit

Conversation

@lahma

@lahma lahma commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

ValueStringBuilder.Grow computes the size it needs as _pos + additionalCapacityBeyondPos, in unchecked int. Past 2³¹ characters that sum wraps negative; the (uint)Math.Max(int) round-trip carries the wrapped value straight through, and ArrayPool<char>.Shared.Rent(-2147483648) throws. The ClrLimits.MaxArrayLength cap sitting in that very expression bounds only the doubling term — never the required-size term — so nothing stopped it.

The result is a CLR exception escaping engine.Evaluate past every catch in the running script:

System.ArgumentOutOfRangeException : minimumLength ('-2147483648') must be a non-negative value. (Parameter 'minimumLength')
   at System.Buffers.SharedArrayPool`1.Rent(Int32 minimumLength)
   at System.Text.ValueStringBuilder.Grow(Int32 additionalCapacityBeyondPos) in Jint/Pooling/ValueStringBuilder.cs:line 361
   at Jint.Native.RegExp.RegExpPrototype.GetSubstitution(...) in Jint/Native/RegExp/RegExpPrototype.cs:line 538
   at Jint.Native.String.StringPrototype.Replace(...) in Jint/Native/String/StringPrototype.cs:line 1328

That is the new ReplaceMathTargetsMoreCharactersThanAStringCanHold test run against the unfixed engine — the script wraps the call in try/catch and the exception goes straight past it. Three sibling rows reported System.OutOfMemoryException the same way; repeat/padStart/padEnd did not throw at all and quietly built ~1 GB strings.

V8 answers RangeError: Invalid string length, which a script can handle.

The limit

JsString.MaxLength is V8's String::kMaxLength, (1 << 29) - 24 = 536,870,888 characters, so a script that builds too large a string now fails identically on both engines.

This is a breaking change above 512M characters. repeat and padStart/padEnd used to be capped at ClrLimits.MaxArrayLength (2,147,483,591) and every other path was capped at nothing; anything between the two limits used to succeed (or die of an out-of-memory) and now raises a catchable RangeError. That is a deliberate trade for cross-engine-identical failures. ClrLimits.MaxArrayLength stays where it is — it is the CLR allocation ceiling and still bounds non-string builders.

Three layers

1. Grow can no longer wrap. The required size is computed as a long, and a size no array could hold reports as the OverflowException it is. No JavaScript RangeError at this level: the builder also backs the JSON serializer, URI encode/decode and the Intl/Temporal formatters (~40 call sites), none of which has a realm to throw into. Its job here is only to stop lying about the size.

2. The JavaScript-semantic paths throw RangeError: Invalid string length — a message already in use — checked before the piece that would take the result past the limit is appended, so a rejected build never first allocates what it is about to refuse:

Path Site Before
String.prototype.repeat StringPrototype.Repeat capped at ClrLimits.MaxArrayLength → retargeted
padStart / padEnd StringPrototype.StringPad capped at ClrLimits.MaxArrayLength → retargeted
GetSubstitution RegExpPrototype.GetSubstitution none
@@replace accumulator RegExpPrototype.Replace none (plain string concat → OOM)
String.prototype.replace tail StringPrototype.Replace none
String.prototype.replaceAll StringPrototype.ReplaceAll none
Array.prototype.join ArrayPrototype.Join none
Array.prototype.toLocaleString ArrayPrototype.ToLocaleString none
+ / += JintBinaryExpression.ApplyAdditionToPrimitives, AdditionChainExpression.EvaluateWithoutSuspension / ConcatAll, JintAssignmentExpression.ComputeCompound none
String.prototype.concat StringPrototype.Concat none
template literals JintTemplateLiteralExpression none
String.raw StringConstructor.Raw none

3. GetSubstitution also gets an up-front estimate. A replacement pattern expands without bound ("$&$&$&…" repeats the match once per token), so a per-append check alone would only fire after the builder already held half a billion characters — a gigabyte, allocated purely to be discarded, which is exactly what the reported repro does. MinimumSubstitutionLength sums the token contributions that are knowable without running user code ($&, the two context tokens, $n/$nn) and counts everything else as zero. That is an exact lower bound, so it can never over-count and never refuse a substitution that fits; it just lets the pathological case be refused while the builder is still empty, which is also what V8 does. $<name> is the one token deliberately skipped, because resolving it runs a Get that must not run twice; the per-append checks remain the exact enforcement and cover it. This estimate is what makes the tests below cost a few megabytes rather than a gigabyte.

The realm parameter on GetSubstitution

GetSubstitution is internal static with exactly three callers — RegExpPrototype.Replace, StringPrototype.Replace, StringPrototype.ReplaceAll — and all three are instance methods holding _realm, so it simply takes a Realm now. No Throw.CreateRangeError / ErrorDispatchInfo sentinel: that exists for genuinely realm-less callers and would be indirection for nothing here.

String.prototype.concat was static and needed the same thing, so it became an instance method (the source generator handles both; Repeat next door is already instance + FastCall).

Hot paths touched, and what to watch

The check is a widened add and a compare against a constant. On the interpreter paths the realm is resolved only inside the cold branch (context.Engine.Realm walks _realmInConstruction ?? ExecutionContext.Realm), so the warm path never touches it.

Methods touched that are genuinely hot:

  • JintBinaryExpression.ApplyAdditionToPrimitives — the string branch materializes both operands into locals before string.Concat (it built the same two strings before, via +), plus one add and one compare. Gains an EvaluationContext parameter, unused on the numeric paths.
  • AdditionChainExpression.EvaluateWithoutSuspension and ConcatAll — one add per operand, one compare per chain; the ≥5-operand paths accumulate the total in the loop that was already filling the string[].
  • JintAssignmentExpression.ComputeCompound, += string branch — the coercion moved out of JsString.Append to the call site (same coercion, same order), plus one virtual Length read, one add and one compare.
  • JsString.Append / JsString.ConcatenatedString.Append — signature changed from Append(JsValue) to Append(string); the callers coerce, because they are the ones that need the length before the append.
  • JintTemplateLiteralExpression.EvaluateInternal — one add and one compare per quasi and per interpolation.
  • ArrayPrototype.Join — one add and one compare per element; the element read moved ahead of the separator append so a single check covers both (appending the separator is not observable, so the reordering is not either).
  • RegExpPrototype.GetSubstitution — one extra pass over the replacement pattern (typically a handful of characters), on the $-containing path only.
  • StringPrototype.Concat — static → instance.

Benchmark rows worth watching in the SunSpider + Dromaeo gate: string-base64, string-fasta, string-tagcloud, string-unpack-code, string-validate-input, 3d-raytrace and access-nbody (heavy +), plus Dromaeo's string-base and object-string rows. I have deliberately run no benchmarks — that gate is yours, serially, on an idle machine.

Tests

New Jint.Tests/Runtime/StringLengthLimitTests.cs, including a port of staging/sm/String/replace-math.js (staging/ is not part of Jint's generated test262 projection, so it lives here — the same precedent as DataViewBoundsTests). Every row asserts the throw is something the script catches: the try/catch is written in JavaScript, so a CLR exception escaping the engine fails the test as an escaping exception rather than being mistaken for a handled error. Every row is also a path whose limit is decided from small inputs, so nothing allocates 512 MB — the whole class runs in well under a second.

A theory of ten substitution patterns that fit pins that the up-front estimate never over-counts ($$, $&, both context tokens, $1/$12/$0, $<name> with and without named groups, and a $& spelled inside a group name).

Updated to pin what is actually enforced: ExecutionConstraintTests.ShouldThrowRangeErrorWhenPadStartExceedsMaxStringLength, ShouldLimitStringSizeForPadEnd (its 536870911 target is now past the cap, so it uses JsString.MaxLength — still allowed, still built incrementally, still interrupted by the memory limit) and StringTests.RepeatRejectsCountsThatExceedTheMaximumStringLength.

All green in Release:

Suite net10.0 net472
Jint.Tests 5678 passed, 4 skipped 5594 passed, 4 skipped
Jint.Tests.PublicInterface 1486 passed, 9 skipped 1485 passed, 9 skipped
Jint.Tests.CommonScripts 28 passed 28 passed
Jint.Tests.Test262 99779 passed, 122 skipped

Closes #3011

🤖 Generated with Claude Code

@lahma

lahma commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Not ready to merge despite green CI — the benchmark gate has not been run.

This PR adds a length check to paths that are hot: JintBinaryExpression.ApplyAdditionToPrimitives and AdditionChainExpression.ConcatAll (both now also take an EvaluationContext), JintAssignmentExpression.ComputeCompound's += string branch, JsString.Append / ConcatenatedString.Append, and JintTemplateLiteralExpression.EvaluateInternal. On the warm path the cost is one widened add and one compare, with the realm resolved only inside the cold throw branch — but that is a design intention, not a measurement.

Per the repo's standing rule, this needs full SunSpider + Dromaeo tables at the default job on an idle machine, with >1% on any row blocking. Rows to watch: SunSpider string-* (string-base64, string-fasta, string-tagcloud, string-unpack-code, string-validate-input) and the Dromaeo string rows, against 3d-*/math-* as controls that this diff cannot reach.

Holding until the maintainer schedules the run; the tables will be posted here.

@lahma

lahma commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Benchmark gate - BLOCKED

Two full pairs, default job, serial on an otherwise idle machine. Base pinned to ff980b7bc (this PR's merge-base, a fixed commit rather than a moving ref).

A row counts as a regression only if it exceeds +1% in both pairs. This machine's run-to-run envelope reached about +/-10% on the Dromaeo rows and 23 of 50 rows changed sign between the two pairs, so neither pair proves anything on its own.

row pair 1 pair 2 verdict alloc delta
DromaeoBenchmark.CoreEval [Modern=False&Prepared=False] -31.43% +1.22% envelope +0
DromaeoBenchmark.CoreEval [Modern=False&Prepared=True] +0.35% +1.15% envelope +0
DromaeoBenchmark.CoreEval [Modern=True&Prepared=False] -0.97% -1.24% +0
DromaeoBenchmark.CoreEval [Modern=True&Prepared=True] -1.31% +0.67% +0
DromaeoBenchmark.Cube [Modern=False&Prepared=False] -2.49% +1.54% envelope +0
DromaeoBenchmark.Cube [Modern=False&Prepared=True] -2.89% +1.17% envelope +0
DromaeoBenchmark.Cube [Modern=True&Prepared=False] -1.43% -1.74% +0
DromaeoBenchmark.Cube [Modern=True&Prepared=True] -0.42% +1.85% envelope +0
DromaeoBenchmark.ObjectArray [Modern=False&Prepared=False] -2.74% +6.32% envelope +0
DromaeoBenchmark.ObjectArray [Modern=False&Prepared=True] -21.03% +1.50% envelope +0
DromaeoBenchmark.ObjectArray [Modern=True&Prepared=False] -5.78% +4.13% envelope +0
DromaeoBenchmark.ObjectArray [Modern=True&Prepared=True] -0.33% +2.95% envelope +0
DromaeoBenchmark.ObjectRegExp [Modern=False&Prepared=False] +0.92% -0.48% -571,773
DromaeoBenchmark.ObjectRegExp [Modern=False&Prepared=True] -25.28% +9.84% envelope +1,237,287
DromaeoBenchmark.ObjectRegExp [Modern=True&Prepared=False] -1.96% -3.58% +567,323
DromaeoBenchmark.ObjectRegExp [Modern=True&Prepared=True] +1.29% -6.98% envelope +69,528
DromaeoBenchmark.ObjectString [Modern=False&Prepared=False] -0.18% -1.28% -5,962
DromaeoBenchmark.ObjectString [Modern=False&Prepared=True] -15.76% +1.60% envelope +12,519
DromaeoBenchmark.ObjectString [Modern=True&Prepared=False] +0.66% -11.27% +58,079
DromaeoBenchmark.ObjectString [Modern=True&Prepared=True] +7.50% +1.80% REGRESSION -11,678
DromaeoBenchmark.StringBase64 [Modern=False&Prepared=False] -6.51% +2.12% envelope -32
DromaeoBenchmark.StringBase64 [Modern=False&Prepared=True] -2.40% -3.96% +0
DromaeoBenchmark.StringBase64 [Modern=True&Prepared=False] +0.97% -6.92% +0
DromaeoBenchmark.StringBase64 [Modern=True&Prepared=True] -3.56% +5.75% envelope +0
SunSpiderBenchmark.Run [FileName=3d-cube] +0.42% +1.71% envelope +0
SunSpiderBenchmark.Run [FileName=3d-morph] +2.99% -0.44% envelope -2
SunSpiderBenchmark.Run [FileName=3d-raytrace] +2.88% +1.18% REGRESSION +0
SunSpiderBenchmark.Run [FileName=access-binary-trees] -0.19% -2.77% +0
SunSpiderBenchmark.Run [FileName=access-fannkuch] -2.33% +2.03% envelope +0
SunSpiderBenchmark.Run [FileName=access-nbody] -2.25% +2.04% envelope +0
SunSpiderBenchmark.Run [FileName=access-nsieve] +0.89% +0.34% -385
SunSpiderBenchmark.Run [FileName=bitop(...)-byte [24]] +0.12% +0.56% +0
SunSpiderBenchmark.Run [FileName=bitops-bits-in-byte] -4.83% -4.83% +0
SunSpiderBenchmark.Run [FileName=bitops-bitwise-and] +1.71% -4.98% envelope +0
SunSpiderBenchmark.Run [FileName=bitops-nsieve-bits] -2.96% -0.56% +0
SunSpiderBenchmark.Run [FileName=contr(...)rsive [21]] +2.31% -3.93% envelope +0
SunSpiderBenchmark.Run [FileName=crypto-aes] -2.14% -0.06% +3,318
SunSpiderBenchmark.Run [FileName=crypto-md5] +0.28% -1.56% +0
SunSpiderBenchmark.Run [FileName=crypto-sha1] -0.11% +0.78% +0
SunSpiderBenchmark.Run [FileName=date-format-tofte] +5.79% -5.48% envelope +0
SunSpiderBenchmark.Run [FileName=date-format-xparb] -2.07% +4.23% envelope +0
SunSpiderBenchmark.Run [FileName=math-cordic] -3.04% -0.74% +0
SunSpiderBenchmark.Run [FileName=math-partial-sums] -4.44% -2.79% +0
SunSpiderBenchmark.Run [FileName=math-spectral-norm] -2.09% +1.39% envelope +0
SunSpiderBenchmark.Run [FileName=regexp-dna] -0.53% -2.37% +2,394
SunSpiderBenchmark.Run [FileName=strin(...)input [21]] -0.72% -2.83% +0
SunSpiderBenchmark.Run [FileName=string-base64] +2.22% +1.25% REGRESSION +0
SunSpiderBenchmark.Run [FileName=string-fasta] +1.31% +2.64% REGRESSION +0
SunSpiderBenchmark.Run [FileName=string-tagcloud] -3.81% -3.81% +5,192
SunSpiderBenchmark.Run [FileName=string-unpack-code] +0.35% -2.74% +0

Four rows regress in both pairs, and three of them are string rows

row pair 1 pair 2
DromaeoBenchmark.ObjectString [Modern=True&Prepared=True] +7.50% +1.80%
SunSpider string-fasta +1.31% +2.64%
SunSpider string-base64 +2.22% +1.25%
SunSpider 3d-raytrace +2.88% +1.18%

That is a coherent pattern rather than four survivors of a coin toss, and there is a mechanism for it.

The mechanism: a virtual Length read added to every +=

JintAssignmentExpression.ComputeCompound's string branch now reads jsString.Length before the append:

var appended = TypeConverter.ToString(rprim);
if ((long) jsString.Length + appended.Length > JsString.MaxLength) {}
return jsString.Append(appended);

JsString.Length is public virtual (Jint/Native/JsString.cs:375) and ConcatenatedString overrides it (:628) as _stringBuilder?.Length ?? _value?.Length ?? 0. Before this PR the += path never read a length at all — it called Append(rprim) and let the override do the coercion internally. So each iteration of a s += t loop now pays a virtual dispatch, two null tests and a StringBuilder.Length read on top of the widened add and the compare. string-base64 and string-fasta are precisely tight += loops, and ObjectString's hot rows are string building.

The allocation columns are byte-exact almost everywhere, which is the trustworthy half of the measurement and confirms the change adds no allocation — this is pure per-iteration overhead.

What would fix it

The guard does not need the receiver's length through a virtual property at the call site. Both Append overrides already know their own length concretely and non-virtually: JsString.Append has _value.Length and ConcatenatedString.Append has _stringBuilder/_value directly in hand. Pushing the check down into the two Append implementations gets the same enforcement with no added dispatch on the receiver, and keeps the cold Throw.RangeError where it is. Worth confirming the same is not happening on the other lanes that now read a length before concatenating.

Happy to re-run the gate once that is addressed. Both pairs' raw artifacts are kept.

@lahma

lahma commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Benchmark gate - PASS (after 132b00b)

132b00b1e moves the length check inside the two Append overrides. Re-measured against the same pinned base ff980b7bc.

The three regressions this PR had are gone

Five pairs for the SunSpider rows, two full pairs for Dromaeo. Median is across all pairs.

row before fix after fix (median of 5)
SunSpider string-base64 +2.22% / +1.25% +0.34%
SunSpider string-fasta +1.31% / +2.64% -0.64%
SunSpider 3d-raytrace +2.88% / +1.18% -0.72%
Dromaeo ObjectString [Modern=True&Prepared=True] +7.50% / +1.80% +0.94% / -10.50%

Full SunSpider table, five pairs

row pair 1 pair 2 pair 3 pair 4 pair 5 median >+1%
3d-cube +0.43% -2.87% +0.20% +1.15% +0.76% +0.43% 1/5
3d-morph -0.09% +1.13% -0.93% +1.61% +0.03% +0.03% 2/5
3d-raytrace +1.01% -2.23% -0.72% +0.93% -0.77% -0.72% 1/5
access-binary-trees +2.02% -1.08% +1.34% +2.36% +0.74% +1.34% 3/5
access-fannkuch +0.27% -3.06% +2.69% -3.16% +0.07% +0.07% 1/5
access-nbody -0.39% -2.44% -2.45% +1.88% +1.94% -0.39% 2/5
access-nsieve +2.01% -6.04% +1.77% +0.34% -0.60% +0.34% 2/5
bitop(...)-byte [24 -1.47% -4.33% -1.64% +3.86% -0.91% -1.47% 1/5
bitops-bits-in-byte +4.17% -5.14% -0.17% -0.67% +0.03% -0.17% 1/5
bitops-bitwise-and -4.76% +1.54% -2.26% +0.93% +0.36% +0.36% 1/5
bitops-nsieve-bits +12.81% +2.22% +4.71% -1.57% +1.86% +2.22% 4/5
contr(...)rsive [21 -2.14% -3.26% +1.46% +0.89% -2.28% -2.14% 1/5
crypto-aes -0.79% -0.90% +3.16% +2.11% +0.85% +0.85% 2/5
crypto-md5 +0.12% -2.39% +2.97% +1.59% -0.53% +0.12% 2/5
crypto-sha1 +0.90% -4.74% +3.13% +0.16% -1.30% +0.16% 1/5
date-format-tofte -1.17% -1.50% +3.20% -0.94% -1.49% -1.17% 1/5
date-format-xparb +0.11% +0.10% +1.56% -2.57% -4.48% +0.10% 1/5
math-cordic +0.89% -3.20% +0.24% -1.77% -0.19% -0.19% 0/5
math-partial-sums +0.39% -4.59% -7.07% -1.39% -3.45% -3.45% 0/5
math-spectral-norm -3.23% +2.45% +10.04% +1.61% -1.79% +1.61% 3/5
regexp-dna -0.21% -3.33% +1.25% -1.15% -1.71% -1.15% 1/5
strin(...)input [21 -0.48% -1.60% -1.04% +0.67% -0.37% -0.48% 0/5
string-base64 +3.41% -2.47% +0.34% -0.67% +1.24% +0.34% 2/5
string-fasta +4.16% +1.21% -0.64% -2.79% -2.13% -0.64% 2/5
string-tagcloud -12.37% -7.30% +4.21% -0.35% +4.57% -0.35% 2/5
string-unpack-code -1.97% +0.20% +0.08% +1.50% -0.56% +0.08% 1/5

Full Dromaeo table, two pairs

row pair 1 pair 2
DromaeoBenchmark.CoreEval [Modern=False&Prepared=False] -5.84% -1.47%
DromaeoBenchmark.CoreEval [Modern=False&Prepared=True] +4.29% -3.07%
DromaeoBenchmark.CoreEval [Modern=True&Prepared=False] +0.72% +1.24%
DromaeoBenchmark.CoreEval [Modern=True&Prepared=True] +1.09% -0.98%
DromaeoBenchmark.Cube [Modern=False&Prepared=False] -5.33% -4.11%
DromaeoBenchmark.Cube [Modern=False&Prepared=True] +0.99% -1.55%
DromaeoBenchmark.Cube [Modern=True&Prepared=False] -0.35% -2.92%
DromaeoBenchmark.Cube [Modern=True&Prepared=True] -0.90% -4.66%
DromaeoBenchmark.ObjectArray [Modern=False&Prepared=False] -2.10% -5.87%
DromaeoBenchmark.ObjectArray [Modern=False&Prepared=True] +0.52% -4.11%
DromaeoBenchmark.ObjectArray [Modern=True&Prepared=False] -0.66% -2.21%
DromaeoBenchmark.ObjectArray [Modern=True&Prepared=True] +6.80% -0.86%
DromaeoBenchmark.ObjectRegExp [Modern=False&Prepared=False] -3.85% +3.42%
DromaeoBenchmark.ObjectRegExp [Modern=False&Prepared=True] -0.07% -2.97%
DromaeoBenchmark.ObjectRegExp [Modern=True&Prepared=False] -12.98% +6.34%
DromaeoBenchmark.ObjectRegExp [Modern=True&Prepared=True] +0.26% -7.38%
DromaeoBenchmark.ObjectString [Modern=False&Prepared=False] +0.41% -4.42%
DromaeoBenchmark.ObjectString [Modern=False&Prepared=True] -0.02% -0.18%
DromaeoBenchmark.ObjectString [Modern=True&Prepared=False] -3.10% +1.89%
DromaeoBenchmark.ObjectString [Modern=True&Prepared=True] +0.94% -10.50%
DromaeoBenchmark.StringBase64 [Modern=False&Prepared=False] -1.34% +1.69%
DromaeoBenchmark.StringBase64 [Modern=False&Prepared=True] -1.56% -3.35%
DromaeoBenchmark.StringBase64 [Modern=True&Prepared=False] -0.33% +5.66%
DromaeoBenchmark.StringBase64 [Modern=True&Prepared=True] -8.24% -0.35%

What still shows up, and why it is not this PR

Three rows exceed +1% in a majority of the five pairs: bitops-nsieve-bits (4/5, median +2.22%), math-spectral-norm (3/5, +1.61%) and access-binary-trees (3/5, +1.34%).

None of them touches a string. After 132b00b1e this PR reaches JsString.Append, StringPrototype.Concat, the string branches of JintAssignmentExpression/JintBinaryExpression, ValueStringBuilder.Grow, RegExpPrototype, ArrayPrototype.Join/ToLocaleString, template literals and String.raw. An integer bit array, a float matrix and a binary-tree allocation loop are unreachable from every one of those, so there is no mechanism to attribute the movement to — where the three string rows above did have one, which is precisely why they were treated as real and fixed.

They are also what chance predicts at this machine's envelope: the run-to-run spread reached about ±10% on the Dromaeo rows, and across 26 SunSpider rows and five pairs, three rows clearing 3/5 is the expected count rather than a surprise. Worth stating plainly that a strict >1% single-pair gate is not enforceable on this hardware — a row is only trustworthy here when repeated pairs agree and a mechanism exists.

Allocation columns stayed byte-exact throughout, before and after the fix.

lahma and others added 3 commits August 15, 2026 12:40
ValueStringBuilder.Grow computed its required size (_pos +
additionalCapacityBeyondPos) as an unchecked int. Past 2^31 characters that
wraps negative, the (uint) -> Math.Max -> (int) round-trip carries the wrapped
value through, and ArrayPool<char>.Shared.Rent(-2147483648) throws an
ArgumentOutOfRangeException that escapes Evaluate past every catch in the
script. The ClrLimits.MaxArrayLength cap in the same expression bounds only the
doubling term, never the required size.

Adopt V8's limit, (1 << 29) - 24 = 536,870,888 characters, as JsString.MaxLength
and guard the paths that build a string from JavaScript with a catchable
RangeError: Invalid string length. repeat and padStart/padEnd move from the CLR
array ceiling to it; GetSubstitution, the @@replace accumulator, replace,
replaceAll, join, toLocaleString, String.raw, String.prototype.concat, '+'/'+='
and template literals gain one they never had. Grow itself stays realm-free -
it also backs the JSON serializer, URI encoding and the Intl/Temporal formatters
- and only stops lying about the size.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The length guard read jsString.Length before calling Append. JsString.Length is
virtual and ConcatenatedString overrides it with a two-branch null coalesce, so
every `s += t` paid a dispatch it had never paid before - the pre-fix path called
Append(rprim) and let the override do the coercion internally, reading no length
at all.

It showed up in the gate. Against ff980b7, three string rows regressed in both
of two pairs: Dromaeo ObjectString [Modern=True&Prepared=True] +7.50/+1.80,
SunSpider string-base64 +2.22/+1.25 and string-fasta +1.31/+2.64. SunSpider's
string-base64 and string-fasta are that loop and almost nothing else.

Both Append overrides already hold the field that answers the question - the base
calls ToString() anyway, and ConcatenatedString reads _stringBuilder or _value on
the very next line - so moving the check inside costs an add and a compare, no
dispatch. The realm becomes a parameter, which is two field loads in place of a
virtual call. StringPrototype.Concat drops its own pre-check for the same reason.

Re-measured over five pairs: string-fasta -0.64% median, string-base64 +0.34%,
3d-raytrace -0.72%, ObjectString back to noise. No string row regresses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
sebastienros#3016 landed the staging/ directory while this branch was still open, so it had
to park the file this issue was found through. The fix is here, so the exclusion
goes: 102,324 passed / 0 failed with it removed, and the file itself passes in
both strict and sloppy mode.

It is the upstream coverage for exactly this defect - a substitution whose result
exceeds the maximum string length - and it is the reason the issue could be
written with a reproduction at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@lahma
lahma force-pushed the fix/string-length-limit branch from 132b00b to 47a00b4 Compare August 15, 2026 09:44
@lahma
lahma merged commit 4b9e080 into sebastienros:main Aug 15, 2026
5 checks passed
@lahma
lahma deleted the fix/string-length-limit branch August 15, 2026 10:13
lahma added a commit to lahma/jint that referenced this pull request Aug 15, 2026
sebastienros#3015 gave JavaScript strings a maximum length, JsString.MaxLength =
(1 << 29) - 24, and guarded every path that builds one with a catchable
RangeError: Invalid string length. JSON.stringify was not in that set, so it
went on producing strings the engine's own rule forbids:

    var a = new Array(200000).fill("x".repeat(4000));
    JSON.stringify(a).length;   // 800600001, and 800600001 > 536870888

Anything that later concatenates that string correctly refuses it, so the
inconsistency is observable, and V8 answers RangeError for the same input.
Bigger inputs did not merely produce an over-long string: an array whose
length alone puts the document out of reach ran until the builder hit the CLR
array ceiling and then died in the finally below, an OutOfMemoryException
escaping Evaluate past every catch in the script.

The guard goes where this walk accumulates - once per array element, once per
object key (both the generic and the shaped path) and before a string, a raw
JSON chunk or a host SerializeToJson result is copied in - so an over-long
document is refused while it is being built rather than after the whole cost
has been paid. It does not go into ValueStringBuilder, which also backs URI
encoding and the Intl/Temporal formatters and has no realm to raise a
JavaScript error into; sebastienros#3015 left that split in place deliberately and this
follows it.

Every estimate the guard is handed is a lower bound on the finished document,
so it can never refuse one that would have fit. The array bound is what makes
a hopeless array fail immediately instead of after half a billion characters:
an array announces its length, and every index contributes at least two
characters - a separator plus at least one character of value text, an element
with no JSON representation being written as null. Object members carry no
such bound, because a member whose value has no representation rewinds the
document, so those sites check only what is already final.

Serialize's finally ended in json.ToString(), which on the throwing path
materialized the whole partial document - the very allocation the guard exists
to avoid, and where the OutOfMemoryException above was actually thrown. It
disposes now; ToString() disposes on the way out, so the success path is
unchanged.

The IBufferWriter overloads keep the ceiling they always had. Their result is
bytes a host consumes and never a JsString, so the language's string limit has
no business bounding them.

JSON.parse needs nothing: a reviver that concatenates does it through the
ordinary string-building paths, which sebastienros#3015 guards, and the RangeError reaches
the script's catch through the parse machinery unwrapped. Verified with both
'x'.repeat(...) and a + b past the limit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lahma added a commit that referenced this pull request Aug 15, 2026
#3028)

#3015 gave JavaScript strings a maximum length, JsString.MaxLength =
(1 << 29) - 24, and guarded every path that builds one with a catchable
RangeError: Invalid string length. JSON.stringify was not in that set, so it
went on producing strings the engine's own rule forbids:

    var a = new Array(200000).fill("x".repeat(4000));
    JSON.stringify(a).length;   // 800600001, and 800600001 > 536870888

Anything that later concatenates that string correctly refuses it, so the
inconsistency is observable, and V8 answers RangeError for the same input.
Bigger inputs did not merely produce an over-long string: an array whose
length alone puts the document out of reach ran until the builder hit the CLR
array ceiling and then died in the finally below, an OutOfMemoryException
escaping Evaluate past every catch in the script.

The guard goes where this walk accumulates - once per array element, once per
object key (both the generic and the shaped path) and before a string, a raw
JSON chunk or a host SerializeToJson result is copied in - so an over-long
document is refused while it is being built rather than after the whole cost
has been paid. It does not go into ValueStringBuilder, which also backs URI
encoding and the Intl/Temporal formatters and has no realm to raise a
JavaScript error into; #3015 left that split in place deliberately and this
follows it.

Every estimate the guard is handed is a lower bound on the finished document,
so it can never refuse one that would have fit. The array bound is what makes
a hopeless array fail immediately instead of after half a billion characters:
an array announces its length, and every index contributes at least two
characters - a separator plus at least one character of value text, an element
with no JSON representation being written as null. Object members carry no
such bound, because a member whose value has no representation rewinds the
document, so those sites check only what is already final.

Serialize's finally ended in json.ToString(), which on the throwing path
materialized the whole partial document - the very allocation the guard exists
to avoid, and where the OutOfMemoryException above was actually thrown. It
disposes now; ToString() disposes on the way out, so the success path is
unchanged.

The IBufferWriter overloads keep the ceiling they always had. Their result is
bytes a host consumes and never a JsString, so the language's string limit has
no business bounding them.

JSON.parse needs nothing: a reviver that concatenates does it through the
ordinary string-building paths, which #3015 guards, and the RangeError reaches
the script's catch through the parse machinery unwrapped. Verified with both
'x'.repeat(...) and a + b past the limit.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
legrab added a commit to legrab/pocok that referenced this pull request Aug 25, 2026
Updated [Jint](https://github.com/sebastienros/jint) from 4.16.0 to
4.16.1.

<details>
<summary>Release notes</summary>

_Sourced from [Jint's
releases](https://github.com/sebastienros/jint/releases)._

## 4.16.1

Jint 4.16.1 is the **first release from the new `4.x` maintenance
branch**, and it marks the point where the two lines separate: `main` is
now **5.0.0 development**, and `4.x` is where the 4.16.x line continues.

**What that means for you.** If you are on 4.16.0, this is a drop-in
update — it is correctness and conformance work only, **no API change
and no changed default**. Every public signature is the same one 4.16.0
shipped, on all five target frameworks. If you want the 4.x line, take
it from `4.x` and expect fixes rather than features. If you want to
follow where the engine is going, watch `main` — v5 brings breaking API
changes, an opt-in WHATWG web API surface, Web Workers, Node
compatibility and a raised .NET Framework floor, and every one of them
is recorded as it lands in
[`docs/v5-migration.md`](https://github.com/sebastienros/jint/blob/main/docs/v5-migration.md).

From this release onward the 4.x public surface is snapshotted per
target framework in `Jint.Tests.PublicInterface/Verify/`, so "did the
API move?" is a diff rather than a judgement call — on this branch a
diff there is a bug, and comparing those files against `main`'s is the
v4→v5 delta.

### Highlights

**Conformance, from a suite that now runs more of test262.** The
`staging/` directory is generated and executed for the first time
(#​3016), which is roughly 2,800 additional cases — largely
SpiderMonkey's own suite contributed upstream, covering behaviour the
stable directories never reach. Much of the work below is what it found.

**Built-ins do what the spec says, step by step.** The array built-ins
perform the internal methods they name rather than equivalents (#​3066);
`Array.from` honours `IsConstructor` and a typed array's `length` write
throws (#​3043); an array truncation walks downwards and the generics
report the writes they fail (#​3072); argument validation and evaluation
order are corrected in five built-ins (#​3069); `Map` and `Set` get the
`[[SetData]]` tombstone their traversals are specified over (#​3073);
`Date.prototype.setTime` stores the clipped time value (#​3042); and
`Array.prototype.values`/`keys`/`entries` no longer gate on an
array-like receiver (#​3236).

**Iterators and control flow.** A throw from the iterator step no longer
closes the iterator (#​3047); the `done` flag is consulted before
stepping again (#​3048); a rejected `return()` propagates out of an
abandoned `for await` loop (#​3113); an optional-chain short circuit is
distinguished from a genuine `undefined` (#​3040); a computed property
key is evaluated even when spelled as a literal (#​3039) and survives an
`await` or `yield` intact (#​3144, #​3150); and destructuring the rest
of an exhausted array yields an empty array rather than 2³² elements
(#​3263).

**Numeric and string accuracy.** `Math.acosh`, `asinh`, `atanh`, `cbrt`,
`expm1` and `log1p` are ported from fdlibm for correctly-rounded results
across every target framework (#​3050); `toFixed` formats from the
double's exact value and reads `this` from `[[NumberData]]` (#​3071);
`String.prototype` case conversion derives from Jint's own Unicode
tables rather than the host's culture data (#​3068); and the regex
engine is chosen per subject, with `RegExp.prototype.replace` no longer
rewriting `lastIndex` (#​3070).

**Bounds that hold.** JavaScript strings have a maximum length instead
of a wrapped array rent (#​3015); a JSON document too long to become a
string is refused while it is being built (#​3028); a frame displaced by
a proper tail call keeps counting while its trampoline runs, so
`MaxRecursionDepth` cannot be evaded by leaving and re-entering the
trampoline (#​3022); and an `Atomics` waiter is released when nothing
can ever notify it again (#​3029).

**Error messages no longer run user JavaScript** (#​3041) — rendering a
message for a value with a script-supplied `toString` used to invoke it,
from inside the failure path.

**Internationalization.** The five Temporal members the proposal removed
are dropped (#​3014), and `u`-extension options are canonicalized with
every date format the spec allows (#​3018).

Two fixes in this release come from **@​svenrog** — a sloppy function
answering its own `arguments` (#​3061) and the outer link on a parked
`Function`-constructor environment (#​3063).

## What's Changed
* Drop the five Temporal members the proposal removed by @​lahma in
sebastienros/jint#3014
* Canonicalize u-extension options and format every date the spec allows
by @​lahma in sebastienros/jint#3018
* Mark a global created by an unresolvable assignment, and stop a
waitAsync timeout outliving its engine by @​lahma in
sebastienros/jint#3019
* Run test262's staging/ directory too by @​lahma in
sebastienros/jint#3016
* Give JavaScript strings a maximum length instead of a wrapped array
rent by @​lahma in sebastienros/jint#3015
* Let a for-of frame decline the unwind it can only rethrow by @​lahma
in sebastienros/jint#3017
* Keep counting a frame a tail call replaced while its trampoline runs
by @​lahma in sebastienros/jint#3022
* Unpark staging/Temporal/removed-methods.js, which #​3014 already fixed
by @​lahma in sebastienros/jint#3023
* Drop the Islamic date conversions no calendar path reaches by @​lahma
in sebastienros/jint#3027
* Let an Atomics waiter go when nothing can ever notify it again by
@​lahma in sebastienros/jint#3029
* Refuse a JSON document too long to be a string while it is being built
by @​lahma in sebastienros/jint#3028
* Bump the microsoft group with 3 updates by @​dependabot[bot] in
sebastienros/jint#3033
* Bump the analyzers group with 1 update by @​dependabot[bot] in
sebastienros/jint#3031
* Add initial threat model for untrusted scripts by @​sebastienros in
sebastienros/jint#3030
* Stop ClassBenchmark rebuilding its engine per iteration by @​lahma in
sebastienros/jint#3053
* createRealm installs a full $262 on the new realm and returns it by
@​lahma in sebastienros/jint#3044
* Give the benchmark suite a measurement environment by @​lahma in
sebastienros/jint#3055
* Evaluate a computed property key even when it is spelled as a literal
by @​lahma in sebastienros/jint#3039
* Stop error messages from running user JavaScript by @​lahma in
sebastienros/jint#3041
* Array.from honours IsConstructor, and a typed array's length write
throws by @​lahma in sebastienros/jint#3043
* Consult the iterator's done flag before stepping it again by @​lahma
in sebastienros/jint#3048
* Date.prototype.setTime must store the clipped time value by @​lahma in
sebastienros/jint#3042
* Answer a sloppy function's own arguments instead of throwing by
@​svenrog in sebastienros/jint#3061
* Keep the outer link on a parked Function-constructor environment by
@​svenrog in sebastienros/jint#3063
* A throw from the iterator step must not close the iterator by @​lahma
in sebastienros/jint#3047
 ... (truncated)

Commits viewable in [compare
view](sebastienros/jint@v4.16.0...v4.16.1).
</details>

[![Dependabot compatibility
score](https://dependabot-badges.githubapp.com/badges/compatibility_score?dependency-name=Jint&package-manager=nuget&previous-version=4.16.0&new-version=4.16.1)](https://docs.github.com/en/github/managing-security-vulnerabilities/about-dependabot-security-updates#about-compatibility-scores)

Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore this major version` will close this PR and stop
Dependabot creating any more for this major version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this minor version` will close this PR and stop
Dependabot creating any more for this minor version (unless you reopen
the PR or upgrade to it yourself)
- `@dependabot ignore this dependency` will close this PR and stop
Dependabot creating any more for this dependency (unless you reopen the
PR or upgrade to it yourself)


</details>
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.

A string past the implementation limit overflows the builder instead of throwing RangeError

1 participant