Skip to content

Interop: a collection exposed as IList<T> or IReadOnlyList<T> gets the wrapper that contract names - #3480

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:interop-exposed-collection-contract
Aug 27, 2026
Merged

lahma merged 1 commit into
sebastienros:mainfrom
lahma:interop-exposed-collection-contract

Conversation

@lahma

@lahma lahma commented Aug 27, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #3421.

ResolveArrayLikeWrapperFactoryType decides which wrapper an exposed type gets by scanning
t.GetInterfaces() for IList<> and IReadOnlyList<>. An interface is not among its own
GetInterfaces()
, so when t is one of those two the scan finds nothing:

typeof(IList<int>).GetInterfaces()          // ICollection<int>, IEnumerable<int>, IEnumerable
typeof(IReadOnlyList<int>).GetInterfaces()  // IReadOnlyCollection<int>, IEnumerable<int>, IEnumerable

Exposing a collection as one of the two contracts the method is written to recognize therefore found
nothing, and the engine fell back to a wrapper the exposure had not named. That exposure is public API
(ObjectWrapper.Create(engine, target, type)), it is what a WrapObjectDelegate supplies, and it is what the
member lane passes for a property whose declared type is a collection interface — so it is reachable under
a plain JIT, with no trimming and no Native AOT anywhere near it.

exposure main this PR
a host IList<T> (not a non-generic IList) as IList<T> plain ObjectWrapper GenericListWrapper<T>
a List<T> as IList<T> untyped ListWrapper (element type object) GenericListWrapper<T>
a List<T> or T[] as IReadOnlyList<T> ListWrapper, writability read from the target ReadOnlyListWrapper<T>
a host IReadOnlyList<T> (not an IList) as IReadOnlyList<T> plain ObjectWrapper ReadOnlyListWrapper<T>
as ICollection<T> / IEnumerable<T> plain ObjectWrapper unchanged — those contracts name no view

What the first row means for an embedder:

// engine.SetValue("host", ObjectWrapper.Create(engine, items, typeof(IList<int>)))
Object.keys(host)                     // main: ["IndexOf","Insert","RemoveAt"]   here: ["0","1","2"]
host[3] = 9                           // main: ArgumentOutOfRangeException        here: grows to 1,2,3,9
Array.prototype.push.call(host, 4)    // main: TypeError (§4.37)                  here: 4
Array.prototype.pop.call(host)        // main: TypeError                          here: removes the last

Reads are unchanged in every row — host.length, host[1], the generics, Array.from, spread and
JSON.stringify all answered correctly through the plain wrapper's reflection lane already, and
ReadsThroughAnExposedContractAreUnchanged is the ten rows that say so.

One guard comes with it. Every typed factory casts the target to the contract the exposure named, and
until the exposed type itself was consulted a factory could only be found by scanning the target's own
interfaces — so the cast could not fail. TryBuildArrayLikeWrapper now requires type.IsInstanceOfType(target)
before using the factory, so a host handing Create a type its target does not implement keeps the answer it
has always had rather than getting an InvalidCastException out of a wrapper creation.

Proof

Jint.Tests.PublicInterface/HostExposedCollectionTypeTests.cs — the file #3425 added for this exposure — is
rewritten around the contract rather than around the wrapper it accidentally got. Against unmodified main,
9 of its 33 cases fail:

Failed TheExposedContractDecidesTheWrapper("IndexedItems","IList<int>","GenericListWrapper`1")
Failed TheExposedContractDecidesTheWrapper("List<int>","IList<int>","GenericListWrapper`1")
Failed TheExposedContractDecidesTheWrapper("int[]","IList<int>","GenericListWrapper`1")
Failed TheExposedContractDecidesTheWrapper("ReadOnlyItems","IReadOnlyList<int>","ReadOnlyListWrapper`1")
Failed TheExposedContractDecidesTheWrapper("List<int>","IReadOnlyList<int>","ReadOnlyListWrapper`1")
Failed TheExposedContractDecidesTheWrapper("int[]","IReadOnlyList<int>","ReadOnlyListWrapper`1")

Failed AnExposedCollectionEnumeratesItsPositions
   Expected string to be the same string, but they differ at index 1
   (["IndexOf","Insert","RemoveAt"] for a target script sees as a list)

Failed AWritableExposedContractIsWritableAndGrowable
   Jint.Runtime.JavaScriptException : Cannot assign to read only property '3' of object '#<Object>'

Failed NoOperationOnAnExposedCollectionLetsAClrExceptionPastScript("IndexedItems","IList<int>")
   ... but found System.ArgumentOutOfRangeException: Index was out of range.

Failed!  - Failed: 9, Passed: 24, Skipped: 0, Total: 33

The contrast cases pass on main and still pass: the exposures under ICollection<T> / IEnumerable<T> that
must stay plain, the ten read rows, and the exposed type the target does not implement.

What this narrows

ArrayOperations.IndexWrappedOperations is once again reachable only under Native AOT. The plain-JIT route
#3394 found — and that
§4.37 documents — was this defect,
not a shape of the engine: the exposure that reached the lane now gets a typed wrapper. The refusal #3425
added to that lane is still what a runtime with no code for the typed factory's value-type instantiation
gives, and Jint.AotExample's IList<int> probe is again the only coverage it has — the comments in
ArrayOperations.cs and Jint.AotExample/Program.cs say so, and the latter says not to delete the probe for
want of a sibling test.

docs/v5-migration.md §4.57 carries the embedder-facing form and narrows §4.37.

Merge last of the three (#3464, #3472, then this): all three touch Jint/Runtime/Interop/ObjectWrapper*.cs
and docs/v5-migration.md, and this one is the behaviour change the other two should be in place for.

@lahma
lahma force-pushed the interop-exposed-collection-contract branch 11 times, most recently from b6ce6aa to 1dcd6d4 Compare August 27, 2026 20:09
…e wrapper that contract names

Fixes sebastienros#3421.

ResolveArrayLikeWrapperFactoryType scanned the exposed type's GetInterfaces() for IList<>
and IReadOnlyList<> and nowhere else. An interface is not among its own -
typeof(IList<int>).GetInterfaces() yields ICollection<int>, IEnumerable<int> and IEnumerable
- so exposing a collection *as* one of the two contracts the method is written to recognize
found nothing, and the engine fell back to a wrapper the exposure had not named: a plain
ObjectWrapper when the target is not a non-generic IList, and a target-writable ListWrapper
when it is.

The exposed type is now considered before its interfaces, through one shared helper so the
two answer identically. A host IList<T> exposed as IList<T> gets the typed writable growable
view instead of a plain wrapper whose keys were reflected member names; a List<T> or T[]
exposed as IReadOnlyList<T> gets the read-only view its contract names instead of one that
read its writability from the target.

The factory's cast is now guarded by IsInstanceOfType: until the exposed type itself was
considered, a factory could only be found by scanning the target's own interfaces, so the
cast could not fail. A host exposing a target under a type it does not implement keeps the
answer it has always had.

Two consequences elsewhere. ArrayOperations.IndexWrappedOperations is again reachable only
under Native AOT - the JIT route sebastienros#3394 documented was this defect, not a shape of the engine
- and the comments in ArrayOperations.cs and Jint.AotExample/Program.cs say so.
docs/v5-migration.md §4.57 carries the embedder-facing form and narrows §4.37.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S
@lahma
lahma force-pushed the interop-exposed-collection-contract branch from 1dcd6d4 to 65e17f3 Compare August 27, 2026 20:25
@lahma
lahma merged commit de63591 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.

Interop: the array-like wrapper resolution scans the exposed type's interfaces and never the type itself

1 participant