Skip to content

Interop: the IndexWrappedOperations lane is not AOT-only, and a generic that must grow refuses instead of leaking - #3425

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:issue-3394-index-wrapped-lane-not-aot-only
Aug 27, 2026
Merged

lahma merged 1 commit into
sebastienros:mainfrom
lahma:issue-3394-index-wrapped-lane-not-aot-only

Conversation

@lahma

@lahma lahma commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Fixes #3394.

ArrayOperations.IndexWrappedOperations was believed to be reachable only under Native AOT. Its own comment
said so, #3362 said so, and the probe
#3393 added pins it there. It is entered under a plain JIT
whenever a host object is exposed as IList<T>:

var host = new HostList(1, 2, 3);   // IList<int> and ICollection, NOT the non-generic IList
engine.SetValue("host", ObjectWrapper.Create(engine, host, typeof(IList<int>)));

engine.Evaluate("Array.prototype.join.call(host, '-')");   // "1-2-3", served by IndexWrappedOperations

ObjectWrapper.Create(Engine, object, Type?) is public API and the exposed-type parameter is exactly what a
WrapObjectDelegate supplies. ResolveArrayLikeWrapperFactoryType looks for IList<> among the exposed
type's GetInterfaces(), and an interface is not among its own, so no typed wrapper is built; the target is
not a non-generic IList, so there is no ListWrapper to fall back to; and the plain ObjectWrapper that
results still reports an integer index — the IList<> branch of TypeDescriptor.AnalyzeType reads
IList<int>.Item — which is what admits it to the lane.

The coverage

Jint.Tests.PublicInterface/HostExposedCollectionTypeTests.cs, the only suite without InternalsVisibleTo,
so it proves the route is reachable by a third party. It runs on every leg and every target framework, and
needs no [DynamicDependency]: the AOT probe needs one because ILC trims IList<int>.Item's metadata, which
is its own finding and stays in Jint.AotExample.

It asserts the wrapper first — a future change that gave this exposure a typed wrapper would leave every
read below answering correctly from a different lane, and the file would pin nothing — then the two repairs
#3381 made blind:

  • the read. join, indexOf, filter, map and slice each come back through the lane's reflection
    fallback, PropertyInfo.GetValue over IList<int>.Item, whose receiver must be the CLR collection. The
    contrast is host[1], which resolves the type's own indexer and never used that lane — which is exactly
    why nothing caught the receiver bug.
  • the write. splice(0, 0) is the shortest generic that reaches SetLength and nothing else: a delete
    count of zero with no items performs no element write and no delete, just Set(O, "length", len, true).

Failing first, with both repairs reverted and only the new file added:

Failed!  - Failed:    13, Passed:    10, Skipped:     0, Total:    23 - Jint.Tests.PublicInterface.dll (net10.0)

  Failed ArrayGenericsReadTheExposedCollectionThroughItsClrTarget("Array.prototype.join.call(host, '-')","1-2-3")
   System.Reflection.TargetException : Object type System.Collections.Generic.IList`1[System.Int32] does not
   match target type Jint.Runtime.Interop.ObjectWrapper.
  Failed AGenericThatWouldResizeTheExposedCollectionThrowsACatchableTypeError("Array.prototype.splice.call(host, 0, 0)")
   Expected type to be Jint.Runtime.JavaScriptException, but found System.NotSupportedException.

A third bug on the same lane, live on both runtimes

Writing that coverage found one the issue did not know about:

Array.prototype.push.call(host, 4)      // System.ArgumentOutOfRangeException out of Evaluate
Array.prototype.unshift.call(host, 0)   // same

IndexWrappedOperations.Set grew through _list when it had one, and otherwise wrote through
_target.Set(...), which resolves the reflected indexer and takes the out-of-range index straight to the
collection. Both are Set(O, k, v, true) over a position the view cannot hold, and the answer they are owed
is the TypeError a false [[Set]] raises — which is the same answer SetLength already gives for this
shape, arrived at the same way: there is no IList to grow, so the view's length is read-only and so is
every position past its end. The collection is left untouched.

That is the fourth time this campaign has closed the same defect class — a CLR exception reaching script
where the specification wants a JavaScript error — after
#3302,
#3381 and
#3385, and the fourth time it turned out wider than
reported.

Failing first, with the two repairs in place and only this fix reverted:

Failed!  - Failed:     2, Passed:    21, Skipped:     0, Total:    23 - Jint.Tests.PublicInterface.dll (net10.0)

  Failed AGenericThatWouldResizeTheExposedCollectionThrowsACatchableTypeError("Array.prototype.push.call(host, 4)")
   Expected type to be Jint.Runtime.JavaScriptException, but found System.ArgumentOutOfRangeException.
  Failed NoOperationOnAnExposedCollectionLetsAClrExceptionPastScript("Array.prototype.unshift.call(host, 0)")
   … but found System.ArgumentOutOfRangeException: Index was out of range. …

pop, shift, fill, reverse, sort, splice(0, 1), Array.from, spread, delete host[3],
host.length = 5 and JSON.stringify were all measured too and were all already correct.

The two comments

Both said the lane is AOT-only. The one on IndexWrappedOperations' constructor now says what actually
reaches a null _list and points at the new test file; the one in Jint.AotExample/Program.cs keeps the
probe's own reason to exist, which is the value-type degradation only a published native binary produces,
and stops claiming the lane is unreachable elsewhere.

What is not in this PR

Two findings from the same measurement, filed rather than widened in:

  • #3421 — the scan gap itself.
    ResolveArrayLikeWrapperFactoryType never considers the exposed type when it is IList<> or
    IReadOnlyList<>. Closing it changes which wrapper several exposures get, including making this lane
    unreachable for IList<T> again, and deserves its own matrix.
  • #3422 — host[3] = 9 on the same exposure still
    reaches the reflected indexer, in both spellings. That guard cannot go in this lane: an ObjectWrapper's
    indexer path also serves Dictionary<int, string>, where a write past the end is a legitimate add.
    NoOperationOnAnExposedCollectionLetsAClrExceptionPastScript carries a comment naming the two rows it
    leaves out for that reason.

Verification

  • dotnet build -c Release — 0 warnings, 0 errors.
  • Jint.Tests — 10,752 passed on net10.0 and net8.0, 7,376 on net472, 0 failed.
  • Jint.Tests.PublicInterface — 3,100 passed on net10.0, 3,090 on net8.0, 2,471 on net472, 0 failed.
  • Both again with JINT_HOST_CONTRACT_VERIFICATION=1 — 0 failed.
  • Jint.Tests.Test262 — 102,495 passed, 0 failed, 189 skipped (102,684 total).
  • Jint.AotExample under dotnet run -c Release — all 27 probes pass, 5 known AOT gaps. The native leg is
    unaffected: splice(0, 0) on the degraded wrapper still reaches SetLength, which the probe already pins.
  • No public API baseline moved and UndocumentedPublicApi.txt is unchanged: IndexWrappedOperations is a
    private nested type.
  • docs/v5-migration.md §4.32 carries the embedder-facing form of the push/unshift change.

…ic that must grow refuses instead of leaking

Fixes sebastienros#3394.

`ArrayOperations.IndexWrappedOperations` was believed to be reachable only under Native AOT — its own
comment said so, sebastienros#3362 said so, and the probe sebastienros#3393 added pins it there. It is entered under a plain JIT
whenever a host object is **exposed** as `IList<T>`:

    engine.SetValue("host", ObjectWrapper.Create(engine, hostList, typeof(IList<int>)));

`ObjectWrapper.Create(Engine, object, Type?)` is public API and the exposed-type parameter is exactly what a
`WrapObjectDelegate` supplies. `ResolveArrayLikeWrapperFactoryType` looks for `IList<>` among the exposed
type's `GetInterfaces()`, and an interface is not among its own, so no typed wrapper is built; the target is
not a non-generic `IList`, so there is no `ListWrapper` to fall back to; and the plain `ObjectWrapper` that
results still reports an integer index, which is what admits it to the lane.

`Jint.Tests.PublicInterface/HostExposedCollectionTypeTests.cs` covers that route on every leg and every
target framework, with no `[DynamicDependency]` — the AOT probe needs one because ILC trims
`IList<int>.Item`'s metadata, which is its own finding. It pins the two repairs sebastienros#3381 made blind: the
reflection fallback handing `PropertyInfo.GetValue` the wrapper instead of the CLR target, and a `SetLength`
that threw a bare `NotSupportedException` where a read-only `length` owes script a `TypeError`.

Writing that coverage found a third defect on the same lane, live on both runtimes:
`Array.prototype.push.call(host, 4)` and `unshift` raised the collection's own
`ArgumentOutOfRangeException` out of `Evaluate`. `IndexWrappedOperations.Set` grew through `_list` when it
had one and otherwise wrote through the reflected indexer, which takes the out-of-range index straight to
the collection. A position the view cannot hold is now refused the way its read-only `length` already is —
the `TypeError` a `Set(O, k, v, true)` raises on a false `[[Set]]` — and the collection is left untouched.

The two comments that call the lane AOT-only are corrected, in `ArrayOperations.cs` and in
`Jint.AotExample/Program.cs`; the probe keeps its own reason to exist, which is the value-type degradation
only a published native binary produces. `docs/v5-migration.md` §4.32 carries the embedder-facing form.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S
@lahma
lahma force-pushed the issue-3394-index-wrapped-lane-not-aot-only branch from cb73989 to cc21a5b Compare August 27, 2026 00:47
@lahma
lahma merged commit 84d8c95 into sebastienros:main Aug 27, 2026
7 checks passed
lahma added a commit to lahma/jint that referenced this pull request Sep 1, 2026
… filter that hides the indexer hides it

Backport of eight main pull requests that together decide one question — what an index-shaped key means on a
wrapped CLR collection — plus the fix for the containment hole the seventh of them opened. They are one unit:
each of the first seven moves the answer, and taking any of them alone leaves the lanes disagreeing with each
other.

  sebastienros#3356  a host collection with a count is not a host collection with an index
  sebastienros#3381  a degraded array view still refuses a resize
  sebastienros#3425  the IndexWrappedOperations lane is not AOT-only, and a generic that must grow refuses
  sebastienros#3416  an index on a host collection is one property, however script spelled it
  sebastienros#3464  hasOwnProperty and "in" give one answer about an index on a wrapped collection
  sebastienros#3472  an index outside a wrapped collection is refused, not handed to the collection
  sebastienros#3480  a collection exposed as IList<T> or IReadOnlyList<T> gets the wrapper that contract names
  sebastienros#3561  a member filter that hides an indexer hides a wrapped collection's elements (fixes sebastienros#3558)

sebastienros#3385 - a read-only host collection refuses script with a JavaScript error - is the ninth member of the
cluster and is already on this branch as sebastienros#3556, so its hunks are not here. Its suite,
HostReadOnlyCollectionTests, is 68 of 68 green both before and after this change, which is what says so.

What script sees. An index-shaped key is now the view's own property, whichever way it is spelled and whether
or not the position exists. A read outside the range is undefined rather than the collection's own
ArgumentOutOfRangeException out of Evaluate; a write at the end grows a growable target exactly as a "length"
write of the same size does; "in", hasOwnProperty, propertyIsEnumerable and getOwnPropertyDescriptor give one
answer, because OrdinaryHasProperty is defined in terms of [[GetOwnProperty]] and may not disagree with it; a
delete of an absent position succeeds without reaching the collection; and a countable-but-not-indexable
target - Queue<T>, Stack<T>, LinkedList<T>, SortedSet<T> - is array-like with no element at index 0 rather
than an InvalidCastException from a lane that cast it to IList.

The containment half is why sebastienros#3561 is in the same change. Options.Interop.TypeResolver.MemberFilter is how a
host says which members script may reach, and an ArrayLikeWrapper answers every index-shaped key itself, so
the filter's decision about the indexer never reached the element lanes. On this branch that matters more than
it does on main: Interop.AllowWrite ships on here, so a filter that hid the indexer stopped nothing. Measured
on this branch, with the cluster applied and sebastienros#3561 held back, three refusals had become writes
(list[0] = 42, list['0'] = 42 and growth list[3] = 42) and reads, "in", delete, push and sort had never been
covered at all - and the pre-existing
HostIndexerFilterTests.AMemberFilterExcludingTheIndexerBlocksIndexedWrites, which passes on stock 4.x, fails.
The whole element contract is closed rather than only the write half, and containment is asked before the
read-only and fixed-size refusals of sebastienros#3382/sebastienros#3385 so the two compose: a fixed-size array whose indexer is
hidden reports "no such property" rather than the TypeError naming its bounds, which would answer a question
the host never granted.

Evidence, on net10.0 and net472 alike (identical counts on both). Against stock 4.x with the suites in place:
HostNonIndexedCollectionTests 33 of 45 failed, HostExposedCollectionTypeTests 21 of 33,
HostCollectionIndexWriteTests 56 of 63, HostCollectionIndexAgreementTests 11 of 18,
HostCollectionIndexBoundsTests 28 of 35, HostIndexerFilterTests 13 of 18, and 6 of the 7 new
InteropTests.ClrArrayLiveView cases. All of them pass now. The containment tests run in both write
configurations, because on this branch the default is the interesting one: the elements leak under
AllowWrite = true and the reads leak under AllowWrite = false, and both are pinned.

Deliberate divergences from main. sebastienros#3054 - which is what makes Interop.AllowWrite default to false there - is
a v5 default change and stays out, so this branch keeps its Delete and ArrayOperations.Set guards and the two
suites spell the write switch out where main could leave it to the default. Jint.AotExample's probes from
sebastienros#3381/sebastienros#3425/sebastienros#3480 are not ported: this branch's AotExample is a 22-line stub with none of the AOT probe
harness those hunks extend. docs/v5-migration.md and Jint/Runtime/Interop/AGENTS.md do not exist here, so
their hunks are carried into the XML docs and comments beside the code instead. The suites are xUnit v3 here
rather than the NUnit main moved to in sebastienros#3409.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S
lahma added a commit that referenced this pull request Sep 1, 2026
… filter that hides the indexer hides it (#3562)

Backport of eight main pull requests that together decide one question — what an index-shaped key means on a
wrapped CLR collection — plus the fix for the containment hole the seventh of them opened. They are one unit:
each of the first seven moves the answer, and taking any of them alone leaves the lanes disagreeing with each
other.

  #3356  a host collection with a count is not a host collection with an index
  #3381  a degraded array view still refuses a resize
  #3425  the IndexWrappedOperations lane is not AOT-only, and a generic that must grow refuses
  #3416  an index on a host collection is one property, however script spelled it
  #3464  hasOwnProperty and "in" give one answer about an index on a wrapped collection
  #3472  an index outside a wrapped collection is refused, not handed to the collection
  #3480  a collection exposed as IList<T> or IReadOnlyList<T> gets the wrapper that contract names
  #3561  a member filter that hides an indexer hides a wrapped collection's elements (fixes #3558)

#3385 - a read-only host collection refuses script with a JavaScript error - is the ninth member of the
cluster and is already on this branch as #3556, so its hunks are not here. Its suite,
HostReadOnlyCollectionTests, is 68 of 68 green both before and after this change, which is what says so.

What script sees. An index-shaped key is now the view's own property, whichever way it is spelled and whether
or not the position exists. A read outside the range is undefined rather than the collection's own
ArgumentOutOfRangeException out of Evaluate; a write at the end grows a growable target exactly as a "length"
write of the same size does; "in", hasOwnProperty, propertyIsEnumerable and getOwnPropertyDescriptor give one
answer, because OrdinaryHasProperty is defined in terms of [[GetOwnProperty]] and may not disagree with it; a
delete of an absent position succeeds without reaching the collection; and a countable-but-not-indexable
target - Queue<T>, Stack<T>, LinkedList<T>, SortedSet<T> - is array-like with no element at index 0 rather
than an InvalidCastException from a lane that cast it to IList.

The containment half is why #3561 is in the same change. Options.Interop.TypeResolver.MemberFilter is how a
host says which members script may reach, and an ArrayLikeWrapper answers every index-shaped key itself, so
the filter's decision about the indexer never reached the element lanes. On this branch that matters more than
it does on main: Interop.AllowWrite ships on here, so a filter that hid the indexer stopped nothing. Measured
on this branch, with the cluster applied and #3561 held back, three refusals had become writes
(list[0] = 42, list['0'] = 42 and growth list[3] = 42) and reads, "in", delete, push and sort had never been
covered at all - and the pre-existing
HostIndexerFilterTests.AMemberFilterExcludingTheIndexerBlocksIndexedWrites, which passes on stock 4.x, fails.
The whole element contract is closed rather than only the write half, and containment is asked before the
read-only and fixed-size refusals of #3382/#3385 so the two compose: a fixed-size array whose indexer is
hidden reports "no such property" rather than the TypeError naming its bounds, which would answer a question
the host never granted.

Evidence, on net10.0 and net472 alike (identical counts on both). Against stock 4.x with the suites in place:
HostNonIndexedCollectionTests 33 of 45 failed, HostExposedCollectionTypeTests 21 of 33,
HostCollectionIndexWriteTests 56 of 63, HostCollectionIndexAgreementTests 11 of 18,
HostCollectionIndexBoundsTests 28 of 35, HostIndexerFilterTests 13 of 18, and 6 of the 7 new
InteropTests.ClrArrayLiveView cases. All of them pass now. The containment tests run in both write
configurations, because on this branch the default is the interesting one: the elements leak under
AllowWrite = true and the reads leak under AllowWrite = false, and both are pinned.

Deliberate divergences from main. #3054 - which is what makes Interop.AllowWrite default to false there - is
a v5 default change and stays out, so this branch keeps its Delete and ArrayOperations.Set guards and the two
suites spell the write switch out where main could leave it to the default. Jint.AotExample's probes from
#3381/#3425/#3480 are not ported: this branch's AotExample is a 22-line stub with none of the AOT probe
harness those hunks extend. docs/v5-migration.md and Jint/Runtime/Interop/AGENTS.md do not exist here, so
their hunks are carried into the XML docs and comments beside the code instead. The suites are xUnit v3 here
rather than the NUnit main moved to in #3409.


Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S

Co-authored-by: Claude Fable 5 <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.

The IndexWrappedOperations lane is not AOT-only: exposing a host object as IList<T> reaches it under a JIT

1 participant