Skip to content

Strings: a lazily materialised host string has a base class, instead of passing null to a string parameter - #3340

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:v5/lazy-host-string
Aug 24, 2026
Merged

lahma merged 1 commit into
sebastienros:mainfrom
lahma:v5/lazy-host-string

Conversation

@lahma

@lahma lahma commented Aug 24, 2026

Copy link
Copy Markdown
Collaborator

A host whose string text is expensive to produce — a database blob, a projected document field, a payload behind a native handle — had exactly one way to say so: derive from JsString and pass null to a constructor whose parameter is typed string.

// Jint.Tests.PublicInterface/LazyHostStringTests.cs, before this PR
private sealed class LazyHostString : JsString
{
    public LazyHostString(string value) : base(null) { … }

    public override string ToString() { if (_materialized is null) { … } return _materialized; }
    public override int Length => _encoded.Length;
    public override char this[int index] => (char) _encoded[index];
}

Everything that follows from that null — which members must be overridden, which ones only avoid materialising, that the host owns its own memoization — lived in JsString's <remarks> and in that test's own <summary>. Nothing checked any of it. Jint.Tests.PublicInterface does not enable nullable reference types, which is why base(null) compiles there at all; a host on a modern project template writes base(null!), suppressing a warning about a contract nobody wrote down. RavenDB has the same shape in its own tree (RavenApiUsageTests.CustomString).

What this adds

public abstract class LazyJsString : JsString
{
    protected LazyJsString(int length);
    protected abstract string Materialize();
    public override int Length { get; }           // the constructor's value; never materialises
    public override char this[int index] { get; } // ToString()[index] by default
    public sealed override string ToString();
}
sealed class DocumentField : LazyJsString
{
    private readonly byte[] _utf8;

    public DocumentField(byte[] utf8, int length) : base(length) => _utf8 = utf8;

    protected override string Materialize() => Encoding.UTF8.GetString(_utf8);
}

Four things move from prose into the type:

  • The base memoizes, and ToString() is sealed so the memoization cannot be bypassed. "Materialize() runs at most once" is a property of this class rather than something every host re-implements.
  • Length never materialises, because it is the value handed to the constructor. That is the whole point: str.length, truthiness, and the length comparison JsString.Equals(JsString) performs first all resolve from it, so field === 'yes' against a 40 KB value costs nothing.
  • Materialize() returning null is refused — an InvalidOperationException naming the type, thrown where the mistake is, rather than a null reaching the engine and surfacing as a NullReferenceException somewhere else entirely.
  • A length that is not a string length is refused at construction — ArgumentOutOfRangeException for a negative one, and for one longer than a JavaScript string may be.

Length and the indexer stay virtual. The indexer is the one worth overriding: a store that can produce a single character without decoding the whole value should, and then character access joins the list of things that never materialise — which is exactly what the rewritten LazyHostString does, and why its counts below are unchanged.

Memoization is a plain unsynchronised field write, deliberately. An Engine rejects concurrent use, so every read the engine makes is on one thread. A host reading the same instance from several of its own threads may see Materialize() run twice and different callers receive different (but equal) string instances; nothing tears, because the field is only ever written with a complete value. A lock would buy a guarantee no supported usage needs, on a type whose whole purpose is to be cheap.

The member set

member before: a base(null) subclass after: a LazyJsString subclass
ToString() mandatory — the base returns the null field sealed; supply Materialize() instead
Length mandatory — the base is _value.Length, i.e. an NRE not needed; the constructor takes it
this[int] mandatory for any character access — the base is _value[index] optional, and worth it
memoization by hand, in every host the base class
Equals(JsString) / Equals(string) / GetHashCode() optional, only to stay allocation-free unchanged — same trade

So three mandatory overrides plus a hand-written memo become one method.

Host-contract verification

The one obligation a lazy string takes on in exchange for being asked nothing until it is read is that the length it declares is the length it produces. The engine trusts the declared length on every path that can answer from it alone and never re-checks, so a mismatch is silent and consequential: a value equal to 'abc' compares unequal to it, and an index the host considers in range reads past the end.

LazyJsString therefore checks the two against each other at materialisation, where both answers exist for the first time, under the usual if (HostContractVerification.Enabled) gate. It is one integer comparison on the path that has just done the expensive part, and it needs no out-of-assembly gate — nothing in Jint derives from this class, so every instance reaching it is a host type by construction. LazyHostStringTests pins both sides of the gate: with verification on the disagreement throws, naming the type and both lengths; with it off the check is not merely skipped but not emitted, the lie goes through, and the host sees the broken behaviour the message describes.

That the base's Length itself never materialises is true by construction rather than by verification, and is pinned by NothingThatOnlyNeedsTheLengthMaterializes.

The counter, before and after

The rewritten LazyHostString counts Materialize() calls. The counts were measured against the current base(null) shape first, and are identical after — which is the result to want: the base class is a better way to write the same thing, not a behaviour change.

script materialisations
host.length, for (…; i < host.length; …) 0
typeof host, !!host, host ? 1 : 0 0
host[0], host.charAt(1), host.charCodeAt(2) 0 (this host overrides the indexer)
host === host 0
host === 'abcd', host !== 'ab', host == 'abcd' — lengths differ 0
two lazy strings of different lengths compared to each other 0 and 0
host in obj, obj[host] = 1, ({ [host]: 1 }), new Map().set(host, 1) 1
JSON.stringify(host) / { value: host } / [host] 1
host + '', `${host}`, String(host), host.toUpperCase() 1
host === 'abd' — same length, different text 1
all of the above on one instance, in one script 1

Two entries were on the "check, do not assume" list, and the answer is that they do need the text, so they are asserted as exactly 1 rather than 0:

  • Using the value as a property key. A key is a string plus its ordinal hash and neither can be answered from a length, so host in obj, obj[host] = … and a computed key in an object literal all materialise. This is the one that surprises, and the README and the test now both say so.
  • JSON.stringify of a containing object. It writes the characters out; there is nothing to defer.

The counter reaches 1 on the first read that needs the text and stays there for every subsequent read — which is the memoization, now the base class's job rather than each host's.

Compatibility

Nothing is removed and nothing changes for existing code. public JsString(string) still accepts null, the old shape still works, and Jint's own SlicedString / ConcatenatedString are still built on it. RavenDB's CustomString compiles and passes unchanged — RavenApiUsageTests keeps it deliberately and now says why, so a later cleanup does not remove the only thing proving the old spelling still works. LazyJsString is the supported spelling from v5 on; the migration note is docs/v5-migration.md §5.

JsString._value stays declared non-nullable. Its nullability is an internal matter with every read site already documented and commented; the dishonesty this PR is about is the public one.

Verification

  • dotnet build -c Release — clean, with TreatWarningsAsErrors on.
  • Jint.Tests (net472 / net8.0 / net10.0) and Jint.Tests.PublicInterface (net472 / net10.0), each run twice — once plain, once with JINT_HOST_CONTRACT_VERIFICATION=1. All green.
  • Jint.Tests.Test262: 102,495 passed, 0 failed, 189 skipped.
  • The five API baselines were regenerated by running the suite and copying .received.txt over .verified.txt. The diff is the new type and nothing else, on all five.

No benchmarks were run, and no string fast path was touched: the verifier lives inside LazyJsString's own cold materialisation path, not in JsString.Equals, Length, or any lane a flat string reaches.

🤖 Generated with Claude Code

https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S

@lahma
lahma force-pushed the v5/lazy-host-string branch from d216937 to ec9957a Compare August 24, 2026 18:37
…of passing null to a string parameter

A host whose string text is expensive to produce - a database blob, a projected
document field, a payload behind a native handle - had one way to express that:
derive from JsString and pass null to a constructor whose parameter is typed
string. The whole contract that follows from doing so lived in one test's own
<summary>: override ToString(), Length and the indexer, and memoize by hand.
Nothing checked any of it, the parameter said the opposite of what the call
meant, and a host on a modern project template wrote base(null!) - suppressing a
warning about a rule nobody had written down.

LazyJsString is that rule, spelled as a type. A subclass declares its length to
the constructor and implements one method:

    sealed class DocumentField : LazyJsString
    {
        private readonly byte[] _utf8;
        public DocumentField(byte[] utf8, int length) : base(length) => _utf8 = utf8;
        protected override string Materialize() => Encoding.UTF8.GetString(_utf8);
    }

The base class memoizes what Materialize() returns and seals ToString() so the
memoization cannot be bypassed, so "at most once" is a property of this class
rather than something every host re-implements. Length is answered from the
constructor's value and therefore never materialises - which is the whole point,
since str.length, truthiness and the length comparison string equality performs
first all resolve from it. A null result is refused with a message naming the
type, instead of reaching the engine and surfacing as a NullReferenceException
somewhere else. The indexer and Length stay virtual: a store that can produce one
character without decoding the whole value should override the indexer, and then
character access joins the list of things that never materialise.

Memoization is a plain unsynchronised field write. An Engine rejects concurrent
use, so every read the engine makes is on one thread; a host reading the same
instance from several of its own threads may see Materialize() run twice and get
two equal strings, and nothing tears because the field is only ever written whole.

The one obligation a lazy string takes on is that the length it declares is the
length it produces, and the engine trusts it everywhere without re-checking. So
host-contract verification checks the two against each other at materialisation,
where both answers exist for the first time - one integer comparison on the path
that has just done the expensive part. It needs no out-of-assembly gate: nothing
in Jint derives from this class.

Nothing is taken away. public JsString(string) still accepts null and the old
shape still works - RavenApiUsageTests keeps it, deliberately, and says so - so
an embedder migrates when it wants to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S
@lahma
lahma force-pushed the v5/lazy-host-string branch from ec9957a to 704a67a Compare August 24, 2026 20:09
@lahma
lahma merged commit a217055 into sebastienros:main Aug 24, 2026
6 checks passed
@sebastienros

Copy link
Copy Markdown
Owner

What about other JsString sub-classes for concatenation. One could have the prefix as another JsString. There are lots of options for optimizations based on the operator.

@lahma

lahma commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator Author

You are right, and chasing this turned up something worse than the case you were pointing at — filed as #3350.

Taking the operator question first, because the answer is measurable. s += x and s = s + x mean the same thing and do not cost the same thing:

shape 20,000 iters 40,000 iters scaling
s += x 25 ms 13 ms flat
s = s + x 463 ms 1,626 ms 3.5× for 2× the work — quadratic
s = x + s 485 ms — quadratic

+= lands in JintAssignmentExpression.cs:260-273 and gets ConcatenatedString + Append, so it is StringBuilder-backed and linear. Plain + goes through ApplyAdditionToPrimitives (JintBinaryExpression.cs:1494), which does TypeConverter.ToString on both sides and string.Concat — no deferral, so each iteration copies the whole accumulated left operand.

We evaluated a rope in July and recorded NO-GO. That verdict was correct for what it measured and wrong as a general conclusion: every accumulating lane in StringConcatLargeBenchmark uses s += chunk, which was already linear, and ConcatLargePair rebuilds a fresh pair each iteration so its left operand never grows. The quadratic shape was never in the suite.

On your actual suggestion — holding the prefix as another JsString — that is the mechanism this needs, and the reason is not just deferral. + cannot reuse ConcatenatedString at all, because it is mutable; the class says so itself at JsString.cs:656 (growing the buffer and returning this would let a caller append into a value still reachable from where it was read). += only gets away with it because the assignment replaces the receiver, which is what wasMutatedInPlace is tracking. An immutable two-child node is shareable, which is exactly the property + requires — and it is the only thing that helps prepend, which the += lane can never reach.

Suggested order in the issue is to add the missing s = s + x / s = x + s lanes first so this is judged on a gate-mode number rather than my REPL timings, then decide on the node. The cost July identified still stands and is the real thing to weigh: every JsString consumer that does not route through ToString() has to handle a two-child node, and Length is virtual there, so a flattening bug surfaces as a wrong length rather than a crash.

On LazyJsString specifically: it composes with this rather than competing. It is a leaf like any other today, and concatenating one forces Materialize() — a host's database read — even when the result is only ever measured. An immutable node would defer that too.

lahma added a commit to lahma/jint that referenced this pull request Oct 5, 2026
…y, so `s = s + x` is linear like `s += x`

Backport of PR sebastienros#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 sebastienros#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 sebastienros#3036); the guard asks
  TryGetAllocatedBytesForCurrentThread instead. The file was already xUnit on
  main at sebastienros#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
lahma added a commit to lahma/jint that referenced this pull request Oct 5, 2026
…y, so `s = s + x` is linear like `s += x`

Backport of PR sebastienros#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 sebastienros#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 sebastienros#3036); the guard asks
  TryGetAllocatedBytesForCurrentThread instead. The file was already xUnit on
  main at sebastienros#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
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.

2 participants