Skip to content

Fall back to a rope's published text when a concurrent flatten has released its operands - #4164

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:fix/4161-rope-flatten-race
Sep 24, 2026
Merged

lahma merged 1 commit into
sebastienros:mainfrom
lahma:fix/4161-rope-flatten-race

Conversation

@lahma

@lahma lahma commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator

JsString.RopeString (the deferred a + b from #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 next node.ToString() threw NullReferenceException (JsString.cs:1024 on main @ 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.PrepareScript folds a literal-plus-literal into a JintConstantExpression and publishes it on the AST's UserData (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).
  • When the folded result is at least MinDeferredConcatenationLength (512) characters, that constant is a RopeString: one instance, read by every engine running the preparation. The new test pins this with BeSameAs.

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 + b result) was a flat JsString. 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.md rules out passing an object-valued JsValue to another engine and says nothing about primitives. The root AGENTS.md rule against sharing a JsValue across engines exists because an ObjectInstance holds its engine and realm, and a JsString holds neither. The closest thing to a stated contract is LazyJsString's: "A host that reads the same instance from several of its own threads may see Materialize run 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 Flatten and the walk in CopyInto:

// Flatten
_value = value;
Volatile.Write(ref _left, null);
Volatile.Write(ref _right, null);

// CopyInto, on a rope whose memo tested null
var left = Volatile.Read(ref rope._left);
var right = Volatile.Read(ref rope._right);
if (left is not null && right is not null) { /* descend as before */ }
else text = Volatile.Read(ref rope._value);   // released ⇒ memo visible

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 = null may become visible on a weak-memory target (ARM64) before the _value store 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 + chunk16 shape, 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 plain mov [rbx+0x18], 0 / mov [rbx+0x20], 0 as before, and CopyInto reads the operands with plain movs and adds two test/jcc per visited rope node, with no fence. On ARM64 the cost is two stlr per flatten and two ldar per 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.
  • Other _value readers: the base Equals/GetHashCode read _value and fall back to ToString() 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 are readonly, 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 == false before the new _value and 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, because Concat snapshots such operands through Immutable. 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 swept SpinWait so 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 a RopeString shared by two engines, so the test cannot silently stop covering anything.

Before the fix (431590d25), every run failed with NullReferenceException at RopeString.CopyInto:

run TFM two threads, one node engines sharing a preparation
1 net10.0 1,533 of 40,000 reads 205 of 600 evaluations
2 net10.0 383 of 40,000 417 of 600
3 net10.0 447 of 40,000 387 of 600
1 net472 1,203 of 40,000 235 of 600

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 299dfb078 on base 431590d25, with no --timeout on the whole-project runs:

project TFM total passed skipped failed
Jint.Tests net10.0 12,660 12,655 5 0
Jint.Tests net472 8,708 8,702 6 0
Jint.Tests.PublicInterface net10.0 3,759 3,738 21 0
Jint.Tests.PublicInterface net472 2,966 2,945 21 0

test262 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):

row flattens / op rope nodes walked / op
StringConcatLargeBenchmark.AssignAppendThenScan 1 4,065
DromaeoBenchmark.ObjectRegExp (classic / modern) 100 504 / 476
DromaeoBenchmark.ObjectString 2 4
SunSpiderBenchmark 3d-raytrace, crypto-aes, regexp-dna, string-tagcloud 1 1–2

These 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

…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
lahma merged commit 78e9bb0 into sebastienros:main Sep 24, 2026
16 of 17 checks passed
@lahma
lahma deleted the fix/4161-rope-flatten-race branch September 25, 2026 07:14
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>
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.

RopeString.Flatten releases its children with plain writes, so a first read from two threads can throw NullReferenceException

1 participant