Skip to content

Stop a chain of adjacent host object wrappers from killing the process on a member miss - #4125

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:fix/4087-wrapper-chain-probe
Sep 25, 2026
Merged

lahma merged 1 commit into
sebastienros:mainfrom
lahma:fix/4087-wrapper-chain-probe

Conversation

@lahma

@lahma lahma commented Sep 19, 2026

Copy link
Copy Markdown
Collaborator

Closes #4087 (follow-up to #4076 / #4078).

Base: c3bb84209 on main.

The shape that was left

ObjectWrapper.Get answers a member miss by forwarding the read to its prototype and then
inspecting what came back for Options.Interop.ThrowOnUnresolvedMember. The 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;
a hop to another wrapper is a native frame with no probe between the two. ObjectWrapper does
not 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 the
engine does and what turns a native stack overflow no catch sees into a catchable RangeError
while there is still stack left to unwind on.

The rest of the wrapper family: what has the shape and what does not

ObjectWrapper.Get is the only site of it. Reported rather than changed:

Lane Why it needs nothing
ObjectWrapper.Set its miss ends in base.Set, i.e. the ordinary [[Set]], which walks the chain in a loop and probes its own hand-overs
ObjectWrapper.HasProperty same — ends in base.HasProperty
ObjectWrapper.GetOwnProperty / RemoveOwnProperty / DefineOwnProperty own-property questions; they walk no chain (the one arrayPrototype.GetOwnProperty probe is a single named prototype, not a walk)
ArrayLikeWrapper and the other specialized wrappers every lane ends in base.<op>, so they inherit the probe above
TypeReference forwards no property operation to its prototype: Delete/DefineOwnProperty refuse, Set writes its own descriptor, GetOwnProperty ends in base.GetOwnProperty, and it does not override Get
NamespaceReference.Get resolves a dotted path and returns a new reference; it never touches a prototype

Evidence

Jint.Tests.PublicInterface/HostNativeRecursionGuardTests (the project without
InternalsVisibleTo), placed at the top of the class body so #4078's appended cases rebase
cleanly under them:

  • AChainOfAdjacentHostWrappersRaisesACatchableErrorAndTheEngineRecovers — 20 000 wrapped host
    objects chained from script, w[0].missing, on a 1 MB DedicatedThread, as two rows:
    ThrowOnUnresolvedMember off and on.
  • AShortChainOfAdjacentWrappersAnswersExactlyAsItDid — the same chain three links long, which
    every stack holds: a read resolves on the last link (so the chain really is a chain),
    and a name no link carries is undefined with the option off and the host-facing
    MissingMemberException with it on. That is what says the probe did not buy the bound by
    changing an answer.

Against the unfixed tree (--filter FullyQualifiedName~AChainOfAdjacentHostWrappers, net8.0),
the run did not fail — it died:

D:\...\Jint.Tests.PublicInterface.dll (net8.0|x64) Zero tests ran (704ms)
Exit code: -1073741571
  Error output: Stack overflow.
     at Jint.Runtime.Interop.ObjectWrapper.Get(Jint.Native.JsValue, Jint.Native.JsValue)
     at Jint.Runtime.Interop.ObjectWrapper.Get(Jint.Native.JsValue, Jint.Native.JsValue)
     ... (3197 such frames in the dump)
Test run summary: Zero tests ran

-1073741571 is 0xC00000FD, STATUS_STACK_OVERFLOW. With the fix the same filter is green, and
the outcome is RangeError:Maximum call stack size exceeded, caught by the script's own
try/catch, with the engine still evaluating afterwards.

The IL pin

StackOverflowGuardTests.ExactlyTheInteropAndForwardingFunctionsProbeTheNativeStack enumerates
Function subclasses' Call(JsValue, JsValue[]). ObjectWrapper is not a Function and the probe
is not in a Call, so the pin neither sees this site nor needed updating — it stays at
BindFunction, ClrFunction, DelegateWrapper, HostFunction and 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 HostNativeRecursionGuardTests ending the host without it. Said in ObjectWrapper.Get's
own comment and in the new Jint/Runtime/Interop/AGENTS.md gotcha (that file now sits at 32 095 of
its 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/toString take during implicit coercion of any host object, so the cost wants a paired
measurement rather than an assertion. Jint.Benchmark/HostAccessorReadBenchmark and
HostPrototypeShapeBenchmark are the rows; the maintainer runs them before merge. No benchmark was
run for this PR.

Interaction with #4078

The comment on the new probe describes ObjectInstance's [[Get]]/[[Set]]/[[HasProperty]] as
walking a chain in a loop, which is #4078's change; this branch is cut from main and touches none
of its files (ObjectInstance.cs, InternalTypes.cs, SharedShapeObject.cs, JsProxy.cs,
Jint/Constraints/AGENTS.md, Jint/Native/Object/AGENTS.md are all untouched here), so it merges
after #4078 with no conflict. On this branch's own base the Set/HasProperty rows of the table
above 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-existing MSB3277 in Jint.Tests.CommonScripts (net472,
unrelated, present on main).

Project TFM total / failed / succeeded / skipped
Jint.Tests net8.0 12505 / 0 / 12500 / 5
Jint.Tests net10.0 12505 / 0 / 12500 / 5
Jint.Tests net472 8588 / 0 / 8584 / 4
Jint.Tests.PublicInterface net8.0 3742 / 0 / 3721 / 21
Jint.Tests.PublicInterface net10.0 3752 / 0 / 3731 / 21
Jint.Tests.PublicInterface net472 2969 / 0 / 2948 / 21
Jint.Tests (JINT_HOST_CONTRACT_VERIFICATION=1) net8.0 12505 / 0 / 12500 / 5
Jint.Tests (JINT_HOST_CONTRACT_VERIFICATION=1) net10.0 12505 / 0 / 12500 / 5
Jint.Tests (JINT_HOST_CONTRACT_VERIFICATION=1) net472 8588 / 0 / 8584 / 4
Jint.Tests.PublicInterface (JINT_HOST_CONTRACT_VERIFICATION=1) net8.0 3742 / 0 / 3734 / 8
Jint.Tests.PublicInterface (JINT_HOST_CONTRACT_VERIFICATION=1) net10.0 3752 / 0 / 3744 / 8
Jint.Tests.PublicInterface (JINT_HOST_CONTRACT_VERIFICATION=1) net472 2969 / 0 / 2961 / 8
Jint.Tests.Test262 net10.0 102710 / 0 / 102601 / 109

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

@lahma

lahma commented Sep 25, 2026

Copy link
Copy Markdown
Collaborator Author

Gate: clean

Paired, alternating order, DefaultJob, 6 rounds, verified-idle box (CPU 0–2%, Defender real-time off), base c3bb84209 vs this PR's 02bed3ef1 — measured against the shared base directly, so this isolates #4125 alone, without #4078.

row median 95% CI sign verdict
HostAccessorReadBenchmark.AccessorRead −3.24% [−3.69, −2.07] 0/6 faster
HostAccessorReadBenchmark.DataPropertyRead +0.09% [−0.95, +1.71] 3/6 no change
HostAccessorReadBenchmark.MethodCall +0.16% [−0.92, +1.12] 4/6 no change
HostPrototypeShapeBenchmark (10 rows) −1.39% … +0.52% every CI straddles zero no change

The probe sits only before ObjectWrapper.Get's forward to its prototype, which none of the HostPrototypeShape rows reach, and none moved. AccessorRead reading faster is not something an added probe does by itself; most likely a code-layout effect of the change. Either way it is not a regression, and nothing here argues against merging.

🤖 Generated with Claude Code

https://claude.ai/code/session_01PanPJbBD7pQC9fRiTpHxvs

…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
lahma force-pushed the fix/4087-wrapper-chain-probe branch from 02bed3e to af2b70b Compare September 25, 2026 01:33
@lahma
lahma merged commit b630bf4 into sebastienros:main Sep 25, 2026
9 checks passed
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>
@lahma
lahma deleted the fix/4087-wrapper-chain-probe branch September 25, 2026 07:14
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.

A chain of adjacent ObjectWrappers still recurses one native frame per link

1 participant