Repository navigation
Fall back to a rope's published text when a concurrent flatten has released its operands - #4164
Merged
Merged
Conversation
…leased its operands RopeString.Flatten memoized its text and then released both operands with plain writes, while CopyInto tested the memo and then dereferenced the operands. A second thread finishing its own flatten of the same node between those two reads left the walk holding a null operand, and the next ToString() threw NullReferenceException. That interleaving needs no host misuse: preparation folds a literal-plus-literal into one constant on the shared Prepared<Script>, so every engine running a preparation reads the same RopeString, and Prepared is documented as safe to run on several engines at once. Flatten now releases the operands with volatile writes after storing the memo, and CopyInto reads them with volatile reads and copies the node from its memo when either is gone. The memo read in ToString() stays a plain load. Closes sebastienros#4161 Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
lahma
added a commit
to lahma/jint
that referenced
this pull request
Oct 5, 2026
…xt when a concurrent flatten has released its operands RopeString.Flatten memoized its text and then released both operands with plain writes, while CopyInto tested the memo and then dereferenced the operands. A second thread finishing its own flatten of the same node between those two reads left the walk holding a null operand, and the next ToString() threw NullReferenceException. On 4.x, too, preparation folds a literal-plus-literal into one constant on the shared Prepared<Script>, so every engine running a preparation reads the same RopeString. The engine change applies as is. The tests are transcribed from NUnit to xUnit v3: the two tests are async and wait on a local two-minute wedge ceiling through Task.WhenAny (4.x has no TestBudgets, and xUnit1031 rejects a blocking Task.WaitAll). (cherry picked from commit 78e9bb0) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
lahma
added a commit
to lahma/jint
that referenced
this pull request
Oct 5, 2026
…xt when a concurrent flatten has released its operands RopeString.Flatten memoized its text and then released both operands with plain writes, while CopyInto tested the memo and then dereferenced the operands. A second thread finishing its own flatten of the same node between those two reads left the walk holding a null operand, and the next ToString() threw NullReferenceException. On 4.x, too, preparation folds a literal-plus-literal into one constant on the shared Prepared<Script>, so every engine running a preparation reads the same RopeString. The engine change applies as is. The tests are transcribed from NUnit to xUnit v3: the two tests are async and wait on a local two-minute wedge ceiling through Task.WhenAny (4.x has no TestBudgets, and xUnit1031 rejects a blocking Task.WaitAll). (cherry picked from commit 78e9bb0) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
lahma
added a commit
that referenced
this pull request
Oct 5, 2026
…uilding, safe across engines and charged to LimitMemory (#4160) * Backport #3386 to 4.x: Strings: a long `+` defers its copy, so `s = s + x` is linear like `s += x` Backport of PR #3386 (commit 9d80990) from main. `s += x` and `s = s + x` mean the same thing and did not cost the same thing. The compound form builds into `JsString.ConcatenatedString`, which is `StringBuilder`-backed and amortised linear; a plain `+` coerced both operands to `string` and returned `JsString.Create(string.Concat(left, right))`, so every iteration of an accumulator loop copied the whole left operand, and prepending (`s = x + s`) had no fast path at all. `JsString.RopeString` is an immutable deferred form: two operands and a total length, flattened once on the first read that needs characters, memoized, and with both operand references released at that point. `JsString.Concat` builds one when the result reaches 512 characters and concatenates flat below that. `Length` - and so truthiness and the length comparison string equality performs first - is answered from the node; everything else flattens, `this[int]` included, since descending per character would be a new quadratic. The flatten walk is iterative over a heap array and descends right-first, so depth is not capped and cannot overflow the stack. The flattened `a + b + c` chain gets the same decision, so `s = s + a + b` is linear too, and the MaxLength guard still runs on the summed lengths before anything is built. `+=` is untouched. Also adds the four Assign* lanes to StringConcatLargeBenchmark. Adapted for 4.x: - JsString.cs, class remarks (conflict): main rewrote the subclassing paragraphs around LazyJsString, which is v5's #3340 and absent here. Kept 4.x's two paragraphs and their contract; only the list of Jint's own representations gains the deferred one. - JsValueExtensions.cs (conflict; the file is Jint/JsValueExtensions.cs on this branch): the "InternalTypes.String is set only by" list gains RopeString, without main's LazyJsString; 4.x's `IsString` cref is kept. - LazyJsString.cs (absent): main's hunk there is a comment. It adds RopeString to the list of Jint's own lazy strings that are built on JsString directly rather than on LazyJsString, which is what lets LazyJsString's declared- length verification run with no out-of-assembly gate. The invariant behind it is one a node depends on: it answers its own Length as the sum of its operands' Lengths, sizes the flatten buffer from that sum and writes each operand's text at an offset computed from it, so every operand's Length has to be the length of its text. On main a host string is a LazyJsString whose declared length host-contract verification checks against what it produces. On 4.x the same role is played by a host's direct JsString subclass - the documented subclassing contract, pinned by LazyHostStringTests and by RavenApiUsageTests' CustomString - whose Length is its own unverified claim. So main's `Immutable` (snapshot a ConcatenatedString, hold everything else) becomes `IsRetainable`: a node holds only Jint's own immutable representations - an exact JsString, a SlicedString or a RopeString - and a ConcatenatedString or a host subclass is snapshotted at the `+`, with the node's length summed from what it actually holds. That keeps a host's ToString() at the `+`, where 4.x has always called it, and keeps a host whose Length disagrees with its text producing that text. The two tests added to Jint.Tests.PublicInterface's LazyHostStringTests pin both; run against main's rule verbatim, all three cases fail - the host is not materialized at the `+` (count 0, expected 1), a host reporting 5 for "ab" flattens to "\0\0\0ab...", and one reporting 3 for "abcd" throws ArgumentOutOfRangeException out of RopeString.CopyInto. The accumulator a loop builds is always Jint's own after its first deferred `+`, so the snapshot does not reintroduce a copy per iteration. - README.md (conflict): main's hunk edits the "Lazy strings" section, which this branch's README does not have, and this README never described `s = s + x` as quadratic or recommended `+=` over `+`; dropped. - docs/v5-migration.md: absent on this branch; dropped. - StringConcatenationTests: this branch's GCPolyfills has no AllocatedBytesForCurrentThreadIsSupported (main's #3036); the guard asks TryGetAllocatedBytesForCurrentThread instead. The file was already xUnit on main at #3386. Evidence - the ported tests against unfixed 4.x (engine files at upstream/4.x plus a never-committed compile stub declaring RopeString and the cutoff constant), net10.0 and net472 alike: in StringConcatenationTests, StringRepresentationKeyTests and StringLengthLimitTests 32 fail and 55 pass. Three of the failures are the asymptotic claim itself - AccumulatingWithPlusIsNoLongerQuadratic allocates 640,758,656 B (`s = s + chunk`), 640,744,128 B (`s = chunk + s`) and 1,281,226,960 B (`s = s + chunk + chunk`) for 8,000 iterations against a 16 MB bound; the other 29 stop at the "is a RopeString" premise assertion. The 55 passes pin what must not change (characters, order, keys, the existing rows of both older classes). AVeryDeepTreeFlattensWithoutRecursing (4x10^10 characters copied at 200,000 iterations without the fix) and ConcatenationPastTheLimitIsACatchableRangeError (about 1.3 GB of flat strings without the fix) were not run against unfixed code on this shared machine. With the package all of them pass on both frameworks. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PanPJbBD7pQC9fRiTpHxvs * Backport #3571 to 4.x: Strings: a short `+` chain no longer pays for a deferred representation it never takes Backport of PR #3571 (commit ef706d7) from main. #3386 cost SunSpider's date-format-xparb 3.9% on main (#3527), and not because of the deferral: xparb's chains are ten to thirty-five characters, far below the 512-character cutoff. What moved onto the eager path was its shape. The chain lane coerced every operand with TypeConverter.ToJsString before the cutoff was read, allocating a JsString wrapper per non-string operand, and ConcatMany built a string[] on top of the JsString[] the operands already lived in. An operand is now carried in whichever form its coercion produced - the JsString it already was, or the plain text a non-string primitive coerced to - both of which answer their length without materializing anything, and below the cutoff the result is joined through a ValueStringBuilder over a cutoff-sized stack buffer: no second array and no wrapper. Above the cutoff the fold through JsString.Concat is unchanged. The pairwise `+` gets the same treatment: two string operands take a branch that coerces nothing, and the mixed pair (`n + "x"`) builds the wrapper only if it goes on to defer. TypeConverter.ToStringNonString becomes internal for that. `+=` and String.prototype.concat are untouched. Adapted for 4.x: - Engine hunks (JsString.cs, JintBinaryExpression.cs, TypeConverter.cs) apply verbatim; ValueStringBuilder and the assembly's SkipLocalsInit are already on this branch. - StringConcatenationTests: transcribed from NUnit to xUnit v3 ([Test] to [Fact], [TestCase] to [Theory] with [InlineData]); the allocation guard asks TryGetAllocatedBytesForCurrentThread, as in the previous commit. Evidence: bytes per evaluation of the eleven-operand short chain (y + '-' + m + '-' + d + ' ' + y + ':' + m + ':' + d), measured as a delta of the thread-local allocation counter over 20,000 evaluations - unfixed 4.x 272 on net10.0 and 408 on net472; with this package 272 and 296, the same after-figures main measured. So a short chain now costs no more than it did on 4.x before #3386, and 27% less on net472. The ported tests against unfixed 4.x: the eleven new cases fail 3 on net10.0 and 4 on net472 - AccumulatingWithPlusIsNoLongerQuadratic's two five-operand rows (2,562,225,160 and 2,562,134,112 B for 8,000 iterations against a 16 MB bound), APairWithOneCoercedOperandTakesBothSidesOfTheCutoff at its "is a RopeString" premise, and on net472 AShortChainDoesNotPayForTheDeferredRepresentation at 408 B against its 400 B ceiling. The seven AChainMixingCoercedAndStringOperandsProducesTheSameCharacters rows pass, pinning characters that must not change. With the package all pass on both frameworks. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PanPJbBD7pQC9fRiTpHxvs * Backport #4130 to 4.x: Build a bound function's name as a rope so a chain of binds stays linear Backport of PR #4130 (commit 5eac242) from main. SetFunctionName composed step 4's "prefix name" with a CLR string concatenation, which flattens whatever it is handed. Binding an already-bound function therefore materialized "bound bound ... f" afresh at every level and left that copy in the level's own name descriptor, so N binds retained about 3*N^2 characters (#4129). The prefixed name is now a JsString.Concat node over the level below - O(1) to build, flattened once if something ever reads the text - and every observable name is byte-identical. Adapted for 4.x: - On this branch BindFunction derives from ObjectInstance, not Function (main made it a Function in #3658, which is not here), so Function.prototype.bind names the function it creates through ObjectInstance.SetFunctionName, which main's hunk does not touch and which still did `prefix + " " + name`. With main's Function.cs hunk alone the bind chain stayed exactly as quadratic (measured: 9.5, 36.9, 145.3, 576.7 MB allocated for 1,250, 2,500, 5,000 and 10,000 levels). Function.PrefixName becomes internal rather than private and ObjectInstance.SetFunctionName calls it, so both overloads defer. Main's Function.cs hunk applies verbatim otherwise; it still covers the get/set accessor names and ShadowRealm's wrapped functions, which are Functions. - BoundFunctionNameTests: transcribed from NUnit to xUnit v3. The wedge ceiling is 256 MB instead of main's 768 MB: with the fix the whole script allocates 53.0 MB on net10.0 and 55.3 MB on net472, and against the defect the per-level copies are all retained, so main's ceiling would let a run against unfixed code hold about 770 MB of live text before it tripped. 256 MB trips at level 6,649 (net10.0) / 6,648 (net472) with a peak working set of 291 / 284 MB. Added ABoundNameLongEnoughToDeferIsANodeOverTheLevelBelow, which asserts the premise directly - a 100-level bound name is a RopeString - because on this branch the route bind takes is the one main's hunk misses. Evidence - the bind chain, measured as allocated and retained bytes around `for (i < N) f = f.bind(null)` on net10.0: depth unfixed 4.x allocated / retained with this package 1,250 9.5 MB / 9.4 MB 0.6 MB / 0.5 MB 2,500 36.9 MB / 36.7 MB 1.1 MB / 1.0 MB 5,000 145.3 MB / 145.0 MB 2.2 MB / 1.9 MB 100,000 (not run: about 60 GB) 45.5 MB / 36.7 MB, 98 ms Unfixed quadruples per doubling; fixed doubles. net472 unfixed: 9.7, 37.1, 145.6 MB for the same three depths. The ported tests against unfixed 4.x fail 2 of 8 on both frameworks - ADeepChainOfBoundFunctionsDoesNotCopyTheNameAtEveryLevel with MemoryLimitExceededException at 256 MB, and the new premise test at "is a RopeString" - and the six APrefixedNameIsTheSameTextItAlwaysWas rows pass, pinning the names that must not change. With the package all 8 pass on both. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PanPJbBD7pQC9fRiTpHxvs * Backport #4164 to 4.x: Fall back to a rope's published text when a concurrent flatten has released its operands RopeString.Flatten memoized its text and then released both operands with plain writes, while CopyInto tested the memo and then dereferenced the operands. A second thread finishing its own flatten of the same node between those two reads left the walk holding a null operand, and the next ToString() threw NullReferenceException. On 4.x, too, preparation folds a literal-plus-literal into one constant on the shared Prepared<Script>, so every engine running a preparation reads the same RopeString. The engine change applies as is. The tests are transcribed from NUnit to xUnit v3: the two tests are async and wait on a local two-minute wedge ceiling through Task.WhenAny (4.x has no TestBudgets, and xUnit1031 rejects a blocking Task.WaitAll). (cherry picked from commit 78e9bb0) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Backport #4173 to 4.x: Charge a deferred string concatenation to LimitMemory when it is built A long `a + b` returns a RopeString, whose characters are allocated by whoever flattens it, and that can be a host's ToString() after every constraint is disarmed. JsString.Concat now charges the memory limit on its deferring branches: the shorter operand, which keeps `s = s + x` linear, or the whole length, refused before the node exists, when the flat form alone exceeds the budget. A constant fold evaluates with an engine-less context and is not charged. Adapted for 4.x's MemoryLimitConstraint, which predates main's operation state and async segments: it measures the thread's allocation counter against a per-entry baseline. The charge is kept in a _deferredBytes total that Reset() clears with the baseline and Check() adds to the measured allocation. A result whose flat form alone exceeds the budget calls Check() and then throws regardless, because Check() measures nothing on a thread other than the one the entry started on. The engine finds its constraint once at construction (Engine._memoryLimitConstraint). 4.x's ConcatSnapshotting branch, which main does not have, is charged too, for the lengths the node actually holds. Function.PrefixName is called from ObjectInstance.SetFunctionName on 4.x as well, and both pass the engine's evaluation context. Not ported: main's AllocatedBytes doc line and the TheCharactersADeferredConcatenationAppendsAreReportedAsAllocated test (MemoryLimitConstraint.AllocatedBytes is not public API on 4.x), the docs/guide and migration-guide hunks (absent on 4.x; the README's constraints section gets the paragraph instead) and the Jint/Constraints/AGENTS.md hunk (absent on 4.x). Tests transcribed from NUnit to xUnit v3. Adapted from 7d9cdcc Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
JsString.RopeString(the deferreda + bfrom #3386) memoizes its text on first read and then releases both operands, so a flattened value stops retaining the tree it was built from. The release was two plain writes after the memo, and the walk that builds the text tested the memo and then dereferenced the operands. A second thread finishing its own flatten of the same node between those two reads left the walk holding a null operand, and the nextnode.ToString()threwNullReferenceException(JsString.cs:1024onmain@431590d25).It needs no host sharing anything
The issue frames this as a host handing one result to several threads. There is a more direct route, and it is a documented one:
Engine.PrepareScriptfolds a literal-plus-literal into aJintConstantExpressionand publishes it on the AST'sUserData(Engine.Ast.cs,FoldConstants, on by default).Prepared<Script>is documented as reusable, thread-safe and runnable concurrently on separate engines (Engine.PrepareScript's XML doc,docs/guide/thread-safety.md).MinDeferredConcatenationLength(512) characters, that constant is aRopeString: one instance, read by every engine running the preparation. The new test pins this withBeSameAs.So two engines running one preparation at the same time race the first read of that node, which is the crash above. Before #3386 the same constant (and every
a + bresult) was a flatJsString. Its text is assigned in the constructor and never written again, so concurrent reads were safe.On the host-sharing question itself:
docs/guide/thread-safety.mdrules out passing an object-valuedJsValueto another engine and says nothing about primitives. The rootAGENTS.mdrule against sharing aJsValueacross engines exists because anObjectInstanceholds its engine and realm, and aJsStringholds neither. The closest thing to a stated contract isLazyJsString's: "A host that reads the same instance from several of its own threads may seeMaterializerun more than once … nothing tears." In other words, a concurrent first read may waste work but must not fail. The rope did not meet that standard.The change
The issue's remedy B, confined to the tail of
Flattenand the walk inCopyInto:Why both sides need to be volatile. Under the .NET memory model, "the effects of ordinary reads and writes can be reordered as long as that preserves single-thread consistency". Object assignment is a release only "with respect to accesses to the instance's fields". So a plain
_left = nullmay become visible on a weak-memory target (ARM64) before the_valuestore it follows, and a reader that falls back to the memo on a released operand would find neither. The volatile writes release the memo ahead of each operand's release, and the volatile reads acquire it before the fallback reads it. Two flattens racing each other stay harmless: each builds the same text, and the last store wins.Why not remedy A (never release). The release is what keeps a flattened accumulator from retaining one node per iteration plus every operand string. For the 4,096-iteration
s = s + chunk16shape, that is 4,065 nodes of 56 bytes each (about 228 KB) kept alongside the flat text. When the operands are distinct strings, the text is also kept a second time, in pieces. Keeping the release keeps #3386's memory profile. Memory accounting is untouched (see #4162, which works in the same file).Hot path.
ToString() => _value ?? Flatten()is unchanged: it is still a plain load, and it needs nothing more because storing the memo reference already publishes the string's contents. Checked in the net10.0 x64 FullOpts disassembly:Flatten's two releases are the same plainmov [rbx+0x18], 0/mov [rbx+0x20], 0as before, andCopyIntoreads the operands with plainmovs and adds twotest/jccper visited rope node, with no fence. On ARM64 the cost is twostlrper flatten and twoldarper rope node visited during a flatten. It is paid only while a node is being flattened, never on a read of an already-flat value.Neighbours checked
CopyInto's traversal: the only reader of_left/_right, and all of its reads changed. An outer flatten never releases an inner node's operands (only the node being flattened releases its own), so the race is always "this node is being flattened elsewhere". That covers the root and any shared inner node._valuereaders: the baseEquals/GetHashCoderead_valueand fall back toToString()on null. A non-null memo on a rope is always the complete text, so they are correct as they stand.SlicedString:_value ??= _source.Substring(...)is benign. It is idempotent, its source fields arereadonly, and it releases nothing.ConcatenatedString: the issue calls its memo race benign. On x86/x64 it is, because both threads build the same text and nothing is released. On ARM64 it is not strictly so:_value = …; _dirty = false;are ordinary writes, so a reader can see_dirty == falsebefore the new_valueand return the pre-append text (a wrong value, not an exception). It is reachable only if a host shares a dirty+=result across threads. A preparation never produces one, becauseConcatsnapshots such operands throughImmutable. It is not changed here because it sits on the+=hot path; I am noting it as a follow-up.Evidence
New fixture
Jint.Tests/Runtime/RopeStringConcurrencyTests.cs:TwoThreadsReadingANodeForTheFirstTimeBothGetItsText: 20,000 fresh ropes and two dedicated threads, released together per rope by a spin gate. One thread is held back by a sweptSpinWaitso its start crosses the other's flatten. The held-back thread alternates between attempts.EnginesRunningOnePreparedScriptAtOnceAllReadItsFoldedConcatenations: four engines each run 150 fresh preparations of 64 folded 600-character constants. It first asserts that the folded constant is aRopeStringshared by two engines, so the test cannot silently stop covering anything.Before the fix (
431590d25), every run failed withNullReferenceExceptionatRopeString.CopyInto:After the fix: 0 failures in 5 further net10.0 runs of the fixture and in the full suites below. A local, uncommitted counter on the new fallback branch showed it taken 12–25 times per run in the two-thread test and 1–23 times in the prepared-script test on net10.0 (24 and 1 on net472). The race is still being hit, and it now resolves to the memo.
Full suites at
299dfb078on base431590d25, with no--timeouton the whole-project runs:Jint.TestsJint.TestsJint.Tests.PublicInterfaceJint.Tests.PublicInterfacetest262 was not run: the change alters no spec-visible behaviour, only whether a concurrent first read throws.
Benchmarks: not run
Measurement is a separate serial phase on an idle machine. These are the rows that execute the changed code, with per-operation counts from a local instrumented census (a counter, not a timing):
StringConcatLargeBenchmark.AssignAppendThenScanDromaeoBenchmark.ObjectRegExp(classic / modern)DromaeoBenchmark.ObjectStringSunSpiderBenchmark3d-raytrace,crypto-aes,regexp-dna,string-tagcloudThese rows build ropes but never flatten them, so they are controls that run none of the changed code:
AssignAppendSmallChunks,AssignPrependSmallChunks,AssignChainThree,ConcatLargePair,ChainLargeThree.Closes #4161
🤖 Generated with Claude Code