Stop a chain of adjacent host object wrappers from killing the process on a member miss - #4125
Merged
Merged
Conversation
This was referenced Sep 19, 2026
Collaborator
Author
Gate: cleanPaired, alternating order,
The probe sits only before 🤖 Generated with Claude Code |
…ember miss `ObjectWrapper.Get` answers a member miss by forwarding the read to its prototype and then inspecting what came back for `Options.Interop.ThrowOnUnresolvedMember`. That post-check is what stops the forward being a tail call, so the link cannot join the loop `ObjectInstance` walks a prototype chain with: a hop to an *ordinary* link re-enters that loop and costs nothing further, but a hop to **another wrapper** is a native frame with no probe between the two. A wrapper does not override `SetPrototypeOf`, so script builds a chain of adjacent wrappers itself and its depth is an input - twenty thousand links ended the test host with `Stack overflow.` and zero tests run, which no `catch` on either side of the boundary can see. The forward now probes the native stack first, which is what every other hand-over in the engine does and what turns the overflow into a catchable `RangeError` while there is still stack left to unwind on. Nothing else in this directory has the shape: `Set` and `HasProperty` end in `base.<op>`, which is that loop; `Delete` and `GetOwnProperty` ask own-property questions and walk nothing; `TypeReference` forwards no operation to its prototype and `NamespaceReference.Get` resolves a path rather than a chain. The depth cases are in `Jint.Tests.PublicInterface`, over a twenty-thousand wrapper chain with `ThrowOnUnresolvedMember` off and on, beside a three-link pair that says the probe did not change what a wrapper answers. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
lahma
force-pushed
the
fix/4087-wrapper-chain-probe
branch
from
September 25, 2026 01:33
02bed3e to
af2b70b
Compare
lahma
added a commit
that referenced
this pull request
Sep 25, 2026
…a wrapper's forward (#4170) * Backport #4078 to 4.x: Walk the prototype chain in a loop instead of one native frame per link Backport of PR #4078 (head b506e62, not yet merged on main) from main. A prototype chain is built by script, so its depth is an input. ObjectInstance's [[Get]], [[Set]] and [[HasProperty]] each resolved it by calling the same method on Prototype, one native frame per link, so let x = {}; for (let i = 0; i < 20000; i++) x = { __proto__: x }; x.missing ended the process with a native stack overflow no catch could see (#4076). The write side was reached through `x.missing = 1`, the existence side through `'missing' in x`, and both through every identifier resolved inside `with (x) { ... }`. A chain of trapless proxies had the same defect one level up: `return target.Get(property, receiver)` with nothing in between. All four walks are loops now (GetFromPrototypeChain, the private receiver-threading TryGetValue, SetOnPrototypeChain, HasProperty inline), so an ordinary chain of any depth resolves. A walk hands the rest of the algorithm to the first link it may not walk -- on the read side a link carrying InternalTypes.ExoticGet | OwnValueHook, on the write and existence sides any link without the positive InternalTypes.PlainObject claim -- and probes the native stack at that hand-over, off the ordinary path. A trapless JsProxy forwards through ForwardToTarget(target), which probes; a trapped proxy's trap already probes as a callee. SharedShapeObject (what every JsObjectShape.Instantiate returns) takes PlainObject, which main's paired gate required after shaped host prototypes declined the walk. PrototypeChainWalkTests pins the flag's claim over every reachable object, in both directions. Adapted for 4.x: - JsProxy.cs, three conflicts, all context: 4.x's [[IsArray]] hook is IsArray() where main's is IsSpecArray(), and its [[IsExtensible]] is the virtual Extensible getter where main's is IsExtensible(). ForwardToTarget goes on the same forwards. The probe set otherwise matches main's: the 24 trapless forwards (11 internal methods x trapless arm and CLR-declined arm, plus IsArray and ToObject) take ForwardToTarget, and [[Call]]/[[Construct]] keep the entry probes #4007 gave them, byte-identical. Their forwards (callable.Call, constructor.Construct) do not go through ForwardToTarget, so no route probes twice and none lost a probe. - ObjectInstance.cs: 4.x's SetUnlikely still inlines OrdinarySetWithOwnDescriptor, which main extracted in #3944 (not on 4.x). SetOnPrototypeChain resolves a found link with that algorithm, so the same extraction is made here, private and with its body unchanged; SetUnlikely delegates to it as on main. - StackOverflowGuard is opt-in on 4.x, so every engine in the new depth cases asks for it (Guarded()), as #4007's did. A default 4.x engine gets the loops -- an ordinary chain resolves at any depth either way -- but not the hand-over probes, which are gated on the guard. - Tests transcribed from NUnit to xUnit v3. Main's b506e62 hunk on the existing forwarding-chain rows is not taken: #4007's backport already settled those rows for 4.x (256 KiB stack, proxy and bound call accepting either answer), and this change does not touch those routes. - The census allowlist is main's, unchanged: emptied on 4.x, the converse names exactly the same 19 types. Both directions were broken on purpose on 4.x (flag dropped from SharedShapeObject; flag added to ArrayInstance) and each named its offender. - Jint.Benchmark/PrototypeChainReadBenchmark.cs did not exist on 4.x (main added it with #4048, which 4.x does not carry); it is added whole, rows unchanged, with its prose saying that on this branch the member cache only serves a direct-prototype holder. Not run. - Co-located AGENTS.md edits dropped (the files do not exist on 4.x); the two doc comments citing Jint/Constraints/AGENTS.md say it is main's. Evidence (Windows x64, Release): - Unfixed (the engine files as on 4.x, the ported tests), each depth row run in its own process: on net10.0, 17 of 23 rows end the test host (exit 0xC00000FD, "Stack overflow.", ObjectInstance.Get x3058 for the plain chain, x3047 shaped, JsProxy.Get x7119 for the proxy chain): all 9 plain rows, 5 shaped (read miss, write, in miss, with hit, with miss) and the 3 trapless proxy rows. The 6 that pass are the shaped hits (every level declares the name, so the first link answers) and the 3 trapped-proxy rows (the trap's callee already probed). On net472 ("Process is terminated due to StackOverflowException.") 10 rows die (plain read hit, read miss, inherited getter, write, inherited setter, with hit; shaped read miss, write, with hit; trapless write) and trapless has fails its assertion ("false" for the RangeError): the .NET Framework JIT turns the unfixed HasProperty and trapless-read forwards into tail calls, so plain in hit/in miss/with miss, shaped in miss/with miss and trapless read complete there even unfixed. The converse census fails naming SharedShapeObject on both. - Fixed: HostNativeRecursionGuardTests 34/34 and PrototypeChainWalkTests 5/5 on net10.0 and net472. - Depth: on a 1 MB thread the 10,000-proxy rows probe from ~6,330 hops (read, has) and ~2,720 (write) on net10.0, ~5,370 (has) and ~1,890 (write) on net472, where the read completes -- the carve-out main already has. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PanPJbBD7pQC9fRiTpHxvs * Backport #4125 to 4.x: Stop a chain of adjacent host object wrappers from killing the process on a member miss Backport of PR #4125 (head 02bed3e, not yet merged on main) from main. ObjectWrapper.Get answers a member miss by forwarding the read to its prototype and then inspecting the result for Options.Interop.ThrowOnUnresolvedMember. That post-check keeps the forward out of tail position, so the link cannot join the loop ObjectInstance now walks a chain with: a hop to an ordinary link re-enters that loop, but a hop to another wrapper is a native frame with no probe between the two. A wrapper does not override SetPrototypeOf, so script builds such a chain itself (`Object.setPrototypeOf(w[i - 1], w[i])`) and its depth is an input; twenty thousand links ended the process (#4087). The forward now probes the native stack first, as every other hand-over does, which turns that into a catchable RangeError. It is the only site of that shape: Set and HasProperty end in base.<op>, which is the loop; GetOwnProperty and RemoveOwnProperty walk nothing; TypeReference and NamespaceReference forward nothing to a prototype. Adapted for 4.x: - The comment on the probe drops main's reference to the IL pin StackOverflowGuardTests.ExactlyTheInteropAndForwardingFunctionsProbeTheNativeStack, which 4.x does not have; the depth cases are what hold the probe in place. - The new tests are xUnit v3 (TheoryData/MemberData, Fact), at the top of the class as on main, and ask for StackOverflowGuard, which is opt-in here: on a default 4.x engine the probe is inert, as every #4007 probe is. - Jint/Runtime/Interop/AGENTS.md does not exist on 4.x; that edit is dropped. Evidence (Windows x64, Release): - With #4078 applied and ObjectWrapper.cs as on 4.x, both rows of AChainOfAdjacentHostWrappersRaisesACatchableErrorAndTheEngineRecovers end the test host, each run alone, on net10.0 ("Stack overflow.", ObjectWrapper.Get x2781, exit 0xC00000FD) and on net472 ("Process is terminated due to StackOverflowException."). The three-link AShortChainOfAdjacentWrappersAnswersExactlyAsItDid passes unfixed, which is what says the probe did not change an answer. - Fixed: HostNativeRecursionGuardTests 37/37 on net10.0 and net472. - Both commits, `dotnet test -c Release`: Jint.Tests 7655 + 7570, Jint.Tests.PublicInterface 1903 + 1895 (net10.0 + net472), CommonScripts 28 + 28, SourceGenerators 52, test262 102,509 passed / 0 failed / 175 skipped; 0 failures anywhere. JINT_HOST_CONTRACT_VERIFICATION=1: Jint.Tests 7655 + 7570, Jint.Tests.PublicInterface 1907 + 1899, 0 failures. Public API snapshots unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PanPJbBD7pQC9fRiTpHxvs --------- 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.
Closes #4087 (follow-up to #4076 / #4078).
Base:
c3bb84209onmain.The shape that was left
ObjectWrapper.Getanswers a member miss by forwarding the read to its prototype and theninspecting what came back for
Options.Interop.ThrowOnUnresolvedMember. The post-check iswhat stops the forward being a tail call, so the link cannot join the loop
ObjectInstancewalksa prototype chain with. A hop to an ordinary link re-enters that loop and costs nothing further;
a hop to another wrapper is a native frame with no probe between the two.
ObjectWrapperdoesnot override
SetPrototypeOf, so script builds a chain of adjacent wrappers itself(
Object.setPrototypeOf(w[i - 1], w[i])) and its depth is an input.Option 1 from the issue: the forward probes the native stack first
(
_engine._stackGuard.EnsureNativeStackHeadroom()), which is what every other hand-over in theengine does and what turns a native stack overflow no
catchsees into a catchableRangeErrorwhile there is still stack left to unwind on.
The rest of the wrapper family: what has the shape and what does not
ObjectWrapper.Getis the only site of it. Reported rather than changed:ObjectWrapper.Setbase.Set, i.e. the ordinary[[Set]], which walks the chain in a loop and probes its own hand-oversObjectWrapper.HasPropertybase.HasPropertyObjectWrapper.GetOwnProperty/RemoveOwnProperty/DefineOwnPropertyarrayPrototype.GetOwnPropertyprobe is a single named prototype, not a walk)ArrayLikeWrapperand the other specialized wrappersbase.<op>, so they inherit the probe aboveTypeReferenceDelete/DefineOwnPropertyrefuse,Setwrites its own descriptor,GetOwnPropertyends inbase.GetOwnProperty, and it does not overrideGetNamespaceReference.GetEvidence
Jint.Tests.PublicInterface/HostNativeRecursionGuardTests(the project withoutInternalsVisibleTo), placed at the top of the class body so #4078's appended cases rebasecleanly under them:
AChainOfAdjacentHostWrappersRaisesACatchableErrorAndTheEngineRecovers— 20 000 wrapped hostobjects chained from script,
w[0].missing, on a 1 MBDedicatedThread, as two rows:ThrowOnUnresolvedMemberoff and on.AShortChainOfAdjacentWrappersAnswersExactlyAsItDid— the same chain three links long, whichevery stack holds: a read resolves on the last link (so the chain really is a chain),
and a name no link carries is
undefinedwith the option off and the host-facingMissingMemberExceptionwith it on. That is what says the probe did not buy the bound bychanging an answer.
Against the unfixed tree (
--filter FullyQualifiedName~AChainOfAdjacentHostWrappers, net8.0),the run did not fail — it died:
-1073741571is0xC00000FD,STATUS_STACK_OVERFLOW. With the fix the same filter is green, andthe outcome is
RangeError:Maximum call stack size exceeded, caught by the script's owntry/catch, with the engine still evaluating afterwards.The IL pin
StackOverflowGuardTests.ExactlyTheInteropAndForwardingFunctionsProbeTheNativeStackenumeratesFunctionsubclasses'Call(JsValue, JsValue[]).ObjectWrapperis not aFunctionand the probeis not in a
Call, so the pin neither sees this site nor needed updating — it stays atBindFunction,ClrFunction,DelegateWrapper,HostFunctionand is green. That matches what#4078 writes down for hand-over probes: they are deliberately outside that pin, and what holds one
in place is
HostNativeRecursionGuardTestsending the host without it. Said inObjectWrapper.Get'sown comment and in the new
Jint/Runtime/Interop/AGENTS.mdgotcha (that file now sits at 32 095 ofits 32 768-byte budget).
Measurement is owed, and this PR does not claim the probe is free
The probe sits on a wrapper's member-miss lane. That is not the member-hit lane, but it is the
lane
valueOf/toStringtake during implicit coercion of any host object, so the cost wants a pairedmeasurement rather than an assertion.
Jint.Benchmark/HostAccessorReadBenchmarkandHostPrototypeShapeBenchmarkare the rows; the maintainer runs them before merge. No benchmark wasrun for this PR.
Interaction with #4078
The comment on the new probe describes
ObjectInstance's[[Get]]/[[Set]]/[[HasProperty]]aswalking a chain in a loop, which is #4078's change; this branch is cut from
mainand touches noneof its files (
ObjectInstance.cs,InternalTypes.cs,SharedShapeObject.cs,JsProxy.cs,Jint/Constraints/AGENTS.md,Jint/Native/Object/AGENTS.mdare all untouched here), so it mergesafter #4078 with no conflict. On this branch's own base the
Set/HasPropertyrows of the tableabove are still recursive — that is exactly what #4078 fixes, and it is why this change deliberately
does not duplicate a probe there.
Verification
dotnet build -c Release— 0 errors, 1 pre-existingMSB3277inJint.Tests.CommonScripts(net472,unrelated, present on
main).JINT_HOST_CONTRACT_VERIFICATION=1)JINT_HOST_CONTRACT_VERIFICATION=1)JINT_HOST_CONTRACT_VERIFICATION=1)JINT_HOST_CONTRACT_VERIFICATION=1)JINT_HOST_CONTRACT_VERIFICATION=1)JINT_HOST_CONTRACT_VERIFICATION=1)test262 matches the control exactly (102 601 pass / 0 fail / 109 skipped); neither documented
timeout flake appeared, so nothing needed re-running in isolation.
🤖 Generated with Claude Code