Options: a group materialized while the freeze is running ends up frozen, and a cloned registry carries the freeze - #3453
Merged
lahma merged 1 commit intoAug 27, 2026
Conversation
lahma
force-pushed
the
issue-3444-freeze-materialization-race
branch
4 times, most recently
from
August 27, 2026 02:04
4d81a85 to
5ba5889
Compare
lahma
added a commit
to lahma/jint
that referenced
this pull request
Aug 27, 2026
sebastienros#3453's Windows leg was red with every assembly reporting `Passed!`: Passed! - Failed: 0, Passed: 10784 ... Jint.Tests.dll (net8.0) Passed! - Failed: 0, Passed: 10784 ... Jint.Tests.dll (net10.0) Passed! - Failed: 0, Passed: 102495, Skipped: 189 ... Test262 ##[error]Process completed with exit code 1. `Jint.Tests.dll (net472)` is absent from that list, because its run never started: vstest.console process failed to connect to testhost process after 90 seconds. This may occur due to machine slowness, please set environment variable VSTEST_CONNECTION_TIMEOUT to increase timeout. Test Run Aborted. No test failed. vstest gives a testhost 90 seconds to connect back and aborts the whole run when it does not, and these jobs run four test assemblies at once on a four-core runner. The net472 host is the one that loses that race: slowest start, and it goes last. The cost is not the retry, it is that the failure carries no information. A red leg whose log says every assembly passed sends whoever reads it looking for a test that does not exist -- twice today, on changes that could not have caused it. So the three workflows set `VSTEST_CONNECTION_TIMEOUT: 300`. Five minutes is startup contention rather than a hang; anything genuinely stuck is caught by the suite's own per-test budgets, which is where a hang should be reported and where it says which test hung. Related, same oversubscription: sebastienros#3452 found every test body ran at `ThreadPriority.Lowest`, worth 2.5 s against 0.14 s for a 0.1 ms body with only two competitors. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S
lahma
added a commit
that referenced
this pull request
Aug 27, 2026
#3453's Windows leg was red with every assembly reporting `Passed!`: Passed! - Failed: 0, Passed: 10784 ... Jint.Tests.dll (net8.0) Passed! - Failed: 0, Passed: 10784 ... Jint.Tests.dll (net10.0) Passed! - Failed: 0, Passed: 102495, Skipped: 189 ... Test262 ##[error]Process completed with exit code 1. `Jint.Tests.dll (net472)` is absent from that list, because its run never started: vstest.console process failed to connect to testhost process after 90 seconds. This may occur due to machine slowness, please set environment variable VSTEST_CONNECTION_TIMEOUT to increase timeout. Test Run Aborted. No test failed. vstest gives a testhost 90 seconds to connect back and aborts the whole run when it does not, and these jobs run four test assemblies at once on a four-core runner. The net472 host is the one that loses that race: slowest start, and it goes last. The cost is not the retry, it is that the failure carries no information. A red leg whose log says every assembly passed sends whoever reads it looking for a test that does not exist -- twice today, on changes that could not have caused it. So the three workflows set `VSTEST_CONNECTION_TIMEOUT: 300`. Five minutes is startup contention rather than a hang; anything genuinely stuck is caught by the suite's own per-test budgets, which is where a hang should be reported and where it says which test hung. Related, same oversubscription: #3452 found every test body ran at `ThreadPriority.Lowest`, worth 2.5 s against 0.14 s for a 0.1 ms body with only two competitors. Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
… cloned registry carries the freeze Closes sebastienros#3444. Closes sebastienros#3445. `Materialize` read the owner's `_readOnly` at the call site, before it allocated and published, so a `MakeReadOnly()` running on another thread could set the flag, cascade over a still-null backing field, and leave the accessor to publish an unfrozen group onto a frozen `Options` — permanently writable, and `Intl` and `Temporal` are read lazily long after construction, so a write to one still lands. It now takes the flag by reference and reads it again after the interlocked publication, and `SetReadOnly` publishes the flag with a full barrier before it reads the fields. Either the cascade finds the published group or the accessor finds the flag; freezing twice is a no-op. `OptionsList<T>.Clone()` was born writable while every group's `MemberwiseClone` carried the source's frozen state, so a group cloned from a frozen source came back frozen around writable registries. It now carries the flag, which makes `CloneWithPrivateWebApiOptions`' explicit re-freeze unnecessary rather than load-bearing; that line and the comment describing the asymmetry are gone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S
lahma
force-pushed
the
issue-3444-freeze-materialization-race
branch
from
August 27, 2026 03:23
5ba5889 to
7ea5bdb
Compare
lahma
added a commit
to lahma/jint
that referenced
this pull request
Aug 27, 2026
sebastienros#3453 landed OptionsCloneReadOnlyTests while this branch was open, and it constructs UntrustedCodeLimits through the positional constructor this change replaces: error CS1739: The best overload for 'UntrustedCodeLimits' does not have a parameter named 'timeoutInterval' Converted to the object initializer, with the same eight values. This is the migration the guide's 3.16 row describes, applied to the one call site that arrived after the rewrite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S
lahma
added a commit
to lahma/jint
that referenced
this pull request
Aug 27, 2026
sebastienros#3453 landed OptionsCloneReadOnlyTests while this branch was open, and it constructs UntrustedCodeLimits through the positional constructor this change replaces: error CS1739: The best overload for 'UntrustedCodeLimits' does not have a parameter named 'timeoutInterval' Converted to the object initializer, with the same eight values. This is the migration the guide's 3.16 row describes, applied to the one call site that arrived after the rewrite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S
lahma
added a commit
that referenced
this pull request
Aug 27, 2026
…u adjust (#3459) * Limits: the bounds are named properties, and a preset is something you adjust `UntrustedCodeLimits` took fifteen constructor parameters, eight of them required and positional, four of those adjacent `TimeSpan`s. Every call site was a column of unlabelled values in an order nobody remembers, and `regexTimeout` and `promiseTimeout` could be swapped without a diagnostic. The repository's own integrator suite had written a wrapper with eight nullable parameters to avoid it. `ResultLimits` had the same shape with five. Both are now records with `init` properties, which is the rule #3310 established for `Options` applied to the two bags that still ignored it. The eight that were required are still required, as C# `required` members: omitting one is CS9035 rather than a weaker limit nobody notices. The seven that had defaults keep byte-identical defaults. `UntrustedCodeLimits.Default` is new API, and a preset composes: `Default with { MaxStatements = 5_000 }` satisfies the required members and keeps every dimension it does not name. Validation moved from the constructor to the property, so it also runs for a dimension `with` changes, and names the property rather than the parameter. `JsonSerializer` takes its limits on the constructor, the way `JsonParser` already takes its depth. That deletes `SerializeWithLimits` and the three `Serialize` overloads whose trailing argument had to be repeated at every call site, collapsing eight serialization entry points to four. Two wrappers that existed only to avoid a positional constructor are deleted and replaced by a preset plus `with`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S * Docs: point the migration rows at the pull request number Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S * Docs: the section 2 headings do not carry the pull request link Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S * Tests: the freeze-clone test builds its limits the new way #3453 landed OptionsCloneReadOnlyTests while this branch was open, and it constructs UntrustedCodeLimits through the positional constructor this change replaces: error CS1739: The best overload for 'UntrustedCodeLimits' does not have a parameter named 'timeoutInterval' Converted to the object initializer, with the same eight values. This is the migration the guide's 3.16 row describes, applied to the one call site that arrived after the rewrite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S --------- Co-authored-by: Claude Opus 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.
Closes #3444. Closes #3445.
Two fixes, not one
They share a file and an invariant — everything reachable from a frozen
Optionsis frozen — and nothingelse. #3444 is a publication-ordering defect between two threads that is reachable today; #3445 is a
single-threaded asymmetry in cloning that is currently unreachable because both callers compensate for it.
They travel together only because a second PR in
Jint/Options.ReadOnly.cswould have to be rebased ontothis one anyway. The reasoning for each is below, separately.
#3444: the interleaving, as proved
Every group accessor was
public IntlOptions Intl => Materialize(ref _intl, _readOnly);, so the owner'sfrozen state was read at the call site, before
Materializeallocated and published.MakeReadOnlysets the flag and then cascades over the twelve backing fields. Both halves can therefore miss the same
group:
_readOnly == falseand entersMaterialize._readOnly = true, then reads_intl— stillnull, so the cascade covers nothing.Interlocked.CompareExchange, unfrozen, onto a frozenOptions.It stays writable for the life of the process, and the engine reads
IntlandTemporallazily long afterconstruction, so a write to one of them still lands on a live engine.
Optionsis documented as safe toshare between engines being constructed concurrently, so this is an ordinary embedding — one host thread
finishing with an instance while another is still touching a group — not a contrivance.
Jint.Tests.PublicInterface/HostOptionsShapeTests.AGroupMaterializedWhileTheOptionsAreBeingFrozenIsStillFrozenruns two threads released from one
Barrier, one materializing every group in the order the cascade walksthem and one calling
MakeReadOnly(), and then asserts that every group refuses a write. Againstunmodified
main, first run:It is a bounded sweep rather than a deterministic gate, and that is a real limitation of the seam. The
window is the few nanoseconds between the accessor's state read and its publication, and nothing
host-supplied runs inside it — no callback, no host object's constructor — so there is nothing outside the
assembly a
ManualResetEventSlimcould be wedged into. What makes the sweep sharp rather than a guess isalignment, not load: two threads and one barrier, the materializing one walking the groups in the cascade's
own order so that the freeze falls inside the first group's window as often as the scheduler allows, and
the clock resolved before the round starts so the freezing thread arrives at the freeze with nothing else to
do first. Measured against the unfixed engine, four runs of a thousand attempts each: 109, 62, 105 and 77
escapes, the earliest on attempt 0. It collects every escape and reports the count rather than stopping at
the first. After the fix, 20 000 attempts produce none.
The fix
Materializetakes the flag by reference and reads it again after the interlocked publication,freezing the winner if it is set.
Interlocked.CompareExchangeis a full fence, so a freeze that hasalready set the flag is visible to that second read whether or not its cascade saw the field.
SetReadOnlypublishes the flag withVolatile.WriteplusThread.MemoryBarrier()before it readsthe fields. It already set the flag first; what it lacked was the fence. A release write orders the
stores before it and not the loads after it, so without a StoreLoad barrier the two halves can still
miss simultaneously — the cascade reading a field the accessor has not published yet, while the accessor
reads a flag still sitting in the freezing thread's store buffer. That is Dekker's, and it is real on x86
as well as on ARM.
Between them one of the two always catches the group: the cascade finds it published, or the accessor finds
the flag. Freezing an already-frozen group is a no-op, so it does not matter which.
Options._readOnlyandWebApiOptions._readOnlyare the two flags that gate a publication and so the two that get the barrier;the other eighteen group flags gate value setters only, where a stale read costs one write that should have
been refused — which is what happens anyway when a setter passes
ThrowIfReadOnlya moment before thefreeze begins, and is not something a barrier can prevent. That distinction is written down on the field.
What it costs
Nothing that is not cold, and no lock.
_readOnly, pass by valueref _readOnly; the flag is not read at allOptions)SetReadOnlycascade of at most eight null checksMakeReadOnly()Thread.MemoryBarrier(), i.e. onelock or [rsp], 0on x64 with the line already hotThrowIfReadOnly/IsReadOnly/AddConfigurationmovon x64,ldaron ARM64) — host-side setters only, never an execution pathThe engine constructor calls
MakeReadOnly()twice (OptionsandsourceOptions, usually the sameobject), so an engine build gains two barriers, plus one more if it materializes
WebApi. I have not runBenchmarkDotNet for it and would rather say so than quote a number I have not measured — say the word if you
want the construction row measured rather than argued. The one thing worth noting in the other direction:
the flag read moved off the accessor's hit path, and
Options.Interopis read on hot interop paths.#3445: a cloned registry carries the freeze
OptionsList<T>.Clone()wasnew(_name, new List<T>(_items)), and a fresh registry is born with_readOnly == false. Every group's ownClone()isMemberwiseClone()and therefore does carry thesource's frozen state, so a group cloned from a frozen source came back frozen around writable registries —
IsReadOnlyansweringtrueonOptions.InteropandfalseonInterop.ObjectConvertersbeside it.Nothing shipped was broken by it because both callers compensated:
CloneWithPrivateWebApiOptionsre-frozethe subtree it had just copied, and
CreateEngineOptionsthaws the whole clone immediately.Clone()nowcarries the flag, which makes the first of those unnecessary rather than load-bearing — the explicit
re-freeze and the comment naming the asymmetry are both gone, since a comment describing something that is
no longer true is worse than none.
Two tests, because the defect is reachable from two distances:
Jint.Tests/Runtime/OptionsCloneReadOnlyTestsgoes atInteropOptions.CloneandConstraintOptions.Clonedirectly — the two the issue names, because neither of their callers compensates. This is the clean red,
needing no other change:
Its sibling
TheUntrustedProfilesPrivateCopyIsThawedThroughoutis the other half: the one caller thatwants a writable copy asks for it, and still gets it all the way down to the registries.
HostOptionsReadOnlyTests.TheEnginesPrivateWebApiCopyIsFrozenAroundFrozenRegistriesis the embedder'sview, through the only public door onto a cloned group —
Engine.WebApi.Enable's callback, withFetchmaterialized before the freeze so the copy comes from
Clonerather than from the accessor. It is redonce the compensation is removed, which is precisely the claim that the compensation was load-bearing:
Beside it, the existing
TheLiveWebApiDoorSuspendsTheGuardForTheGroupItIsConfiguringAndNothingElsestayedgreen in that same run — worth stating, because it asserts the same refusal for a sub-group nobody had
materialized before the freeze, which reaches the copy through
Materializerather than throughClone.Two paths, and only the clone one was broken.
The sibling audit
Materializeis not the only lazy publish onOptions, so every field written on read and every place a_readOnlyvalue is captured before the work it guards:Optionsgroup accessors + the eight onWebApiOptionsOptions.TimeSystem's_timeSystem ??=Interlocked.CompareExchangeand resolving it at freeze time, and has since merged. Fixing it here would have been a second edit to the same linesOptions.ResultLimits,ProfilingOptions.MaxEventsOptions._nodeBuiltinModulesUseNodeBuiltinModules, never on a read (and #3447 has since put a guard on that door)AddConfiguration—if (_readOnly) throw; _configurations.Add(...)ThrowIfReadOnly()-then-write setter (about ninety-five of them)CloneWithPrivateWebApiOptions'sSetReadOnly(webApi, _readOnly)_readOnlyflagsComposition with #3447
#3447 merged while this was being written, so this branch is rebased onto it rather than composed with
it. The two touched one hunk in common and it was textual rather than semantic: #3447 added
if (value) { _ = TimeSystem; }to the top ofSetReadOnly, this PR replaced the_readOnly = valuebelowit with the volatile write and the barrier. Resolved by keeping both in that order — resolve
TimeSystemfirst, then publish the flag with the fence, then cascade — and everything else (
Options.cs,Options.WebApi.cs,AGENTS.md, both test files) auto-merged. The full suite and test262 were re-run on therebased tree; the numbers below are from that run.
Not a public API change
Materializeis private,OptionsList<T>.CloneandSetReadOnlyare internal, and both_readOnlyfieldsstayed private. The five baselines are unchanged (the
net10.0leg that verifies them is green), so there isno
docs/v5-migration.mdrow and no baseline regeneration here.Verification
On the rebased tree:
dotnet build -c Release(solution) anddotnet test -c Release(solution) green, exit 0.Jint.Tests10 783 passed onnet8.0andnet10.0, 7 407 onnet472;Jint.Tests.PublicInterface3 132 / 3 122 / 2 501 on
net10.0/net8.0/net472;Jint.Tests.CommonScripts28;Jint.Tests.SourceGenerators71.Jint.Tests.PublicInterfacegreen again on all three target frameworks withJINT_HOST_CONTRACT_VERIFICATION=1(3 145 / 3 135 / 2 514).🤖 Generated with Claude Code
https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S