Skip to content

Native AOT: the degraded IList<T> lane is probed, and it is only reached when the interface's indexer metadata is rooted - #3393

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:fix/3362-aot-indexwrapped-probe
Aug 26, 2026
Merged

lahma merged 1 commit into
sebastienros:mainfrom
lahma:fix/3362-aot-indexwrapped-probe

Conversation

@lahma

@lahma lahma commented Aug 26, 2026 •

Copy link
Copy Markdown
Collaborator

Fixes #3362.

ArrayOperations.IndexWrappedOperations is, after #3356, reachable in one situation: a wrapped CLR
collection with an index to read but no IList to read it through — the Native AOT degradation of an
IList<T> that is not a non-generic IList. Nothing exercised it, so the two latent bugs repaired in
passing while fixing #3302 had nothing to fail against. They do now.

What the probes assert

Two rows in Jint.AotExample/Program.cs, a pair the way every generic site in that file is a pair.

  • IList<string> without IList — the reference-type sibling. GenericListWrapperFactory<string>
    shares canonical code, so this host keeps the typed wrapper on every runtime and its length stays
    writable. It is what makes the row below a boundary rather than an assertion about one type.
  • IList<int> without IList — the value-type row, which degrades to a plain ObjectWrapper under
    Native AOT and is served by IndexWrappedOperations. It asserts:
    • the reads that go through the reflection fallback — Array.prototype.join, indexOf and filter,
      every element of which arrives via PropertyInfo.GetValue over IList<int>.Item with the CLR
      collection as the receiver
      (the first repaired bug passed the wrapper);
    • host[1], the contrast: it resolves the type's own indexer, never used that lane, and worked all
      along — which is exactly why nothing ever caught the receiver bug;
    • Array.prototype.splice.call(host, 0, 0), the shortest generic that reaches SetLength and
      nothing else (a deleteCount of 0 with no items performs no element write and no delete, just
      Set(O, "length", len, true)). The degraded wrapper has no IList to resize, so that write meets
      the wrapper's read-only length and becomes a TypeError script can catch — where the second
      repaired bug threw a bare NotSupportedException out of Evaluate.

The host type carries a non-generic ICollection as well as IList<T>, and that is load-bearing
rather than decorative: ArrayOperations.For reads the count off ICollection, so an IList<T>
without it falls through to ObjectOperations and never reaches the lane.

How I established the lane is genuinely reached

Not from the code alone — the first version of this probe passed with both bugs put back, which is
how the real obstacle turned up.

  1. I instrumented ArrayOperations.For and TypeDescriptor.Analyze (temporarily, reverted) and
    published + ran the native binary. It printed
    IndexedItems`1 hasIdx=False arrayLike=True intIdx=False idxProp=<null> and
    IList`1 … item=<null> props=0: typeof(IList<int>).GetProperties() returns an empty array in
    the published binary.
    The lane needs the PropertyInfo for IList<int>.Item, which Jint obtains
    by reflecting over an interface it discovered at run time, so no signature preserves it and ILC
    trims it. TypeDescriptor therefore reported no integer index, HasIndexedElements was false, and
    the collection was served by ObjectOperations — the same answers, from a different lane, with no
    diagnostic anywhere. That is the same failure mode this project already roots its own assembly to
    avoid (the CompanyExtensions.Shout note in the csproj).

  2. Adding [DynamicDependency(PublicProperties, typeof(IList<int>))] to the host type flipped it:
    hasIdx=True intIdx=True idxProp=IList`1 and [lane] -> IndexWrappedOperations on every
    Array.prototype call in the probe.

  3. The instrumentation is gone; two things keep the probe honest in its place — an assertion that the
    wrapper actually is the degraded plain ObjectWrapper under AOT and the typed one under a JIT, and
    an assertion that typeof(IList<int>).GetProperty("Item") is non-null, so a lost root fails the run
    instead of quietly moving the probe onto ObjectOperations.

  4. Finally, the decisive check: published and ran the native binary with each repair reverted in
    turn.

    • receiver bug → FAIL … System.Reflection.TargetException: Object type System.Collections.Generic.IList`1[System.Int32] does not match target type Jint.Runtime.Interop.ObjectWrapper.
    • SetLength bug → FAIL … System.NotSupportedException: Specified method is not supported.

    Both restored, both green.

Probe counts

probes known AOT gaps
before, native run 25 5
after, native run 27 5

ALL PROBES PASSED and exit 0 in both directions, and the same 27/5 under a JIT (dotnet run).

Verification

dotnet publish Jint.AotExample -c Release, binary run natively ALL PROBES PASSED (27 probes, 5 known AOT gaps)
same binary, each repair reverted in turn 1 probe failed, with the two exceptions quoted above
dotnet build -c Release 0 errors; the one MSB3277 is the pre-existing System.Memory conflict in Jint.Tests.CommonScripts on net472
Jint.Tests 7369 (net472), 10745 (net8.0), 10745 (net10.0), 0 failed
Jint.Tests.PublicInterface 2369 (net472), 2998 (net10.0), 0 failed
Jint.Tests.Test262 not run — no engine code is touched; the diff is Jint.AotExample/Program.cs only
public API untouched, so no Verify snapshot and no UndocumentedPublicApi.txt movement

No docs/v5-migration.md row: nothing an embedder observes changed, and #3385 is editing that file.
Nothing under Jint/Runtime/Interop/AGENTS.md either, since that file is over budget and being fixed
separately. Nothing here contradicts #3385 — it does not touch IndexWrappedOperations, and the host
collection this probe uses is growable and not read-only, so its CanWrite/IsReadOnly change does
not reach it.

Deferred

The lane is not actually AOT-only, and that is worth a test that runs on every leg. While proving
the above I found that IndexWrappedOperations is entered under a plain JIT whenever the target is
exposed as IList<T> — ObjectWrapper.Create(engine, target, typeof(IList<int>)), which is public
API and what a WrapObjectDelegate does — because typeof(IList<int>).GetInterfaces() does not
include IList<> itself, so ResolveArrayLikeWrapperFactoryType finds no typed factory and the target
is not an IList to fall back on. Verified: [lane] -> IndexWrappedOperations,
Array.prototype.join.call(host, '-') === "1-2-3", wrapper ObjectWrapper. A
Jint.Tests.PublicInterface test for that shape would pin both repaired bugs in every test leg rather
than only in the AOT one, and would need no [DynamicDependency]. Filed as #3394 rather than folded
in, so this PR stays the probe #3362 asks for; #3394 also carries the two comments that call the lane
AOT-only and should be corrected with it.

🤖 Generated with Claude Code

https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S

…ached when the interface's indexer metadata is rooted

`ArrayOperations.IndexWrappedOperations` is, after sebastienros#3356, reachable in one situation: a wrapped CLR
collection with an index to read but no `IList` to read it through, which is the Native AOT
degradation of an `IList<T>` that is not a non-generic `IList`. Nothing exercised it, so the two
latent bugs repaired in passing while fixing sebastienros#3302 - the reflection fallback handing
`PropertyInfo.GetValue` the *wrapper* rather than the CLR target, and `SetLength` throwing a bare
`NotSupportedException` where the wrapper's read-only `length` owes script a `TypeError` - had
nothing to fail against.

Two probes in `Jint.AotExample/Program.cs` now pin it: a reference-type sibling that keeps the typed
`GenericListWrapper<string>` on every runtime, and the value-type row that degrades. The degraded one
asserts the reads that go through the reflection fallback (`join`, `indexOf`, `filter`), the direct
`host[1]` that never used the lane and is why nothing caught the receiver bug, and
`Array.prototype.splice.call(host, 0, 0)` - the shortest generic that reaches `SetLength` and nothing
else, and therefore the one whose refusal is the `TypeError` rather than the CLR exception.

Reaching the lane at all takes a root, and that is the part worth writing down. The lane needs the
`PropertyInfo` for `IList<int>.Item`, which Jint obtains by reflecting over an interface it discovered
at run time; no signature preserves it, so ILC trims it. Measured on the published binary:
`typeof(IList<int>).GetProperties()` returned an empty array, `TypeDescriptor` reported no integer
index, and the collection was served by `ObjectOperations` instead - the same answers from a different
lane, with no diagnostic anywhere. The probe carries a `[DynamicDependency]` for it and asserts the
precondition, so a lost root fails the run instead of quietly moving the probe off the lane.

Native run: 25 probes / 5 gaps before, 27 probes / 5 gaps after. Verified by publishing with the two
repairs reverted one at a time: the receiver bug fails the probe as
`System.Reflection.TargetException: Object type System.Collections.Generic.IList`1[System.Int32] does
not match target type Jint.Runtime.Interop.ObjectWrapper`, and the `SetLength` bug as
`System.NotSupportedException`.

Fixes sebastienros#3362.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S
@lahma
lahma merged commit 52f4028 into sebastienros:main Aug 26, 2026
7 checks passed
lahma added a commit to lahma/jint that referenced this pull request Aug 27, 2026
…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 added a commit to lahma/jint that referenced this pull request Aug 27, 2026
…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 added a commit to lahma/jint that referenced this pull request Aug 27, 2026
…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 added a commit that referenced this pull request Aug 27, 2026
…ic that must grow refuses instead of leaking (#3425)

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>`:

    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 #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.


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

Co-authored-by: Claude Opus 5 (1M context) <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 AOT-only IndexWrappedOperations lane has no probe, and two latent bugs on it were found by accident

1 participant