Interop: an index on a host collection is one property, however script spelled it - #3416
Merged
Merged
Conversation
lahma
force-pushed
the
issue-3384-growable-index-write
branch
11 times, most recently
from
August 27, 2026 02:36
5589df3 to
4f01d9c
Compare
…t spelled it Fixes sebastienros#3384. A wrapped growable `List<int>` answered `list.length = 5` by growing and `list[3] = 9` by raising the CLR's own `ArgumentOutOfRangeException` out of `Engine.Evaluate`. Building the matrix showed the cause is wider than the reported cell: an array-like view owned only the *number* spelling of an index. A string key — `list["3"]`, and therefore `Object.assign`, a `for..in` copy and every `Set(O, k, v, true)` over a string key — went to the reflected indexer, which parsed an index out of the key and handed it to the collection. `x[3]` and `x["3"]` are one property key, so they now take one lane: * a canonical index in range writes the element, whichever way it was spelled; * a canonical index at or past the end of a **growable** target makes room and writes, which is exactly what a `length` write of `index + 1` already did — the view is an extensible ordinary object, so `CreateDataProperty` succeeds; * a **fixed-size** target keeps sebastienros#3381's `TypeError` and a **read-only** one keeps sebastienros#3382's refusal, now for both spellings; * an index the view can never hold — negative, non-canonical (`"08"`, `"+3"`), or past what the target can address — is the ordinary `[[Set]]` refusal rather than something the collection is left to reject; * `delete` of an absent position returns `true`, and of a present one resets the slot, in both spellings. One further fact the matrix forced. `ListWrapper` took its writability from the target, so an array handed to script as `IReadOnlyList<T>` produced a writable view; the refusal came from that interface's get-only indexer, and therefore only for a string-spelled index. It now also treats an exposed contract with no writable indexer as read-only, which is what the typed `ReadOnlyListWrapper<T>` says and what `InteropTests.LiveViewHonorsNonArrayDeclaredType` has always asserted. `docs/v5-migration.md` §4.33 carries the embedder-facing form, including the two rows of `ClrWriteConfigurationTests` that were asserting the string spelling's answer: a frozen list's `list["0"] = 9` is now silent outside strict mode, as `list[0] = 9` always was, and `Reflect.set` reports the refusal as `false` rather than throwing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S
lahma
force-pushed
the
issue-3384-growable-index-write
branch
from
August 27, 2026 02:42
4f01d9c to
eb34db9
Compare
This was referenced Sep 1, 2026
lahma
added a commit
that referenced
this pull request
Sep 1, 2026
…tion's elements (#3561) Fixes #3558. `Options.Interop.TypeResolver.MemberFilter` is CLR-containment configuration — it is how a host says which members script may reach — and a member it rejects reads as `undefined` and cannot be written. That held for the indexer of an ordinary wrapped object, which resolves a member per access. It did not hold for a wrapped **collection**. `ArrayLikeWrapper` answers every index-shaped key itself, which is the whole point of #3416 and #3384: without it an out-of-range `list[3] = 9` was the collection's own `ArgumentOutOfRangeException` out of `Evaluate`. But that view was never told what the filter had decided, so the same filter, asked the same question, gave two answers depending on whether Jint happened to build a view. Three refusals had become writes since #3416 (`list[0] = 42`, `list['0'] = 42`, and growth `list[3] = 42`), and reads, `delete`, `push` and `sort` had bypassed the filter for longer than that. The existing `HostIndexerFilterTests.AMemberFilterExcludingTheIndexerBlocksIndexedWrites` asserted the contract and passed on `main` only because its engine left `Options.Interop.AllowWrite` at the `false` #3054 made it default to; with writes on, it fails. The whole element contract is closed rather than only the write half. Under a filter that hides the indexer, an array-like view now has no element properties at all: `Get` reads `undefined`, `in` is `false` (agreeing with `hasOwnProperty` and `Object.keys`, which already said so — `OrdinaryHasProperty` is defined in terms of `[[GetOwnProperty]]`, so they may not disagree), `Set` and `DefineOwnProperty` refuse, `delete` returns `true` without touching the slot, a `length` write neither grows nor truncates, and `ArrayOperations.For` routes every `Array.prototype` generic to `ObjectOperations` exactly as a countable-but-not-indexable `Queue<T>` is routed. Containment is asked **before** the read-only and fixed-size refusals of #3382/#3385 so the two compose rather than mask each other: 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. Three lanes are deliberately left out of the contract, and say so. `length` is produced from `Count`, a member the filter decides about separately. Iteration is `GetEnumerator`'s business, so `[...list]` still yields elements — the shape a `Queue<T>` has always had. And `ArrayConversionMode.Copy`, the default, turns a `T[]` into a JavaScript array before any member is accessed; that is a conversion of a value, not an access to a member. The decision is the one `IndexerAccessor.TryFindIndexer` would have made — the first integer-keyed indexer the exposed type declares, falling back to the descriptor's `IList.Item` for a `T[]`, which declares none of its own — memoized per resolver and per type behind `TypeResolver.ExposesIndexedElements` and dropped with the rest of the resolved state when the filter is reassigned. A resolver with the default filter returns from one bool field read and caches nothing; a filtered one pays one dictionary lookup per array-like wrapper construction, beside the `TypeDescriptor.Get` the base constructor already does, and every element access afterwards reads a `readonly bool` field. No per-operation allocation and no per-operation delegate invocation anywhere. `docs/v5-migration.md` §4.97 carries the embedder-facing form, including what an allow-list filter has to add to keep the elements it was reaching by accident. Claude-Session: https://claude.ai/code/session_014W5mbjGhyvgAS4pivXoc4S Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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>
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.
Fixes #3384.
A wrapped growable
List<int>answerslist.length = 5by growing and answeredlist[3] = 9with theCLR's own
ArgumentOutOfRangeExceptionout ofEngine.Evaluate. Building the matrix the issue asks forshowed the reported cell is one of forty-four, and that the cause is not "growth is missing" but something
narrower and worse: an array-like view owned only the number spelling of an index.
x[3]andx["3"]are one property key —ToPropertyKeymakes both the String"3"— and they wereanswered from two different places. A number key went to the view, which knows its own length. A string key
went to the reflected indexer, which parsed an index out of the key and handed it to the collection. So the
same request, written the two ways a script writes it, gave two different answers on almost every row, and
one of the two was a CLR exception no
catchon either side of the boundary can see.Reproduction, before the change
Measured on
main,net10.0, Release, JIT, from a program that does nothing butengine.SetValue("x", …)and
Execute, withInterop.AllowWrite = trueandArrayConversion = LiveView. Every collection holds1, 2, 3. A cell isoutcome + collection afterwards; where sloppy and strict differ it is writtensloppy → strict. AOORE isSystem.ArgumentOutOfRangeException, IOORESystem.IndexOutOfRangeException, AESystem.ArgumentException, all escapingExecute.The six growable shapes —
List<int>,List<string>,Collection<int>, a hostIList<int>,ArrayListand a host non-generic
IList— are byte-identical on every row apart from the default element a grownslot gets (
0forint,nullforstringand for the untyped lists), so they are one column here. Thesix span all three wrappers: the first four reach
GenericListWrapper<T>, the last twoListWrapper.int[]LiveViewArraySegment<int>ReadOnlyCollection<int>GenericListWrapper/ListWrapperArrayWrapperGenericListWrapperGenericListWrapperx[2] = 9x["2"] = 9x[3] = 9x["3"] = 9x[5] = 9x[100000] = 9x[-1] = 9x["-1"] = 9x["08"] = 9x[3.5] = 9x[2**31] = 9delete x[0]delete x["0"]delete x[3]delete x["3"]delete x[-1]x.length = 5x.push(9)x.foo = 1x[3] = 9,AllowWrite = falsepreventExtensions(x); x[3] = 9And the read side, which the issue did not mention at all and which is the same split:
int[]LiveViewArraySegment<int>ReadOnlyCollection<int>x[2]/x["2"]3/33/33/33/3x[3]undefinedundefinedundefinedundefinedx["3"]x["-1"],x["08"]Object.assign(x, {3: 9})3 in x,"3" in x,Object.keys(x),for..in,JSON.stringify(x)Object.assignis the row that says this is not a curiosity: it is the ordinary way a script copies into ahost list, it is specified over string keys, and it was a CLR exception on every array-like wrapper,
read-only ones included.
What each row should do, from the specification
The wrapper is an ordinary object carrying
Array.prototype, so the answer is per operation and perposition, never per spelling.
[[Set]],[[Get]],[[Delete]]and[[HasProperty]]all take a property key, andToPropertyKeyhas already turned3into"3"by the time any of them runs. Two lanes for one key istwo answers to one question.
own non-writable property there, so
OrdinarySetWithOwnDescriptorreachesCreateDataPropertyand itsucceeds — which for this object means making room, exactly what a
lengthwrite ofindex + 1alreadydid.
a[a.length] = vappends on a JavaScript array for the same reason.TypeErrorfor a write outside aT[]'s bounds and Interop: a read-only collection refuses to grow with a CLR exception where a fixed-size one refuses with a TypeError #3382's[[Set]]falsefor a read-only collection are unchanged; they now answerthe string spelling too.
x[-1]already readundefinedand-1 in xwas alreadyfalse, so there is no position to create and none to read awritten value back from. A
falsefrom[[Set]]is silent outside strict mode and aTypeErrorinsideit — the same rule Interop: a read-only host collection refuses script with a JavaScript error, not the CLR's own #3385 established, and "make everything throw" would be as wrong here as it was there.
deleteof an absent position returnstrue.OrdinaryDeletereturnstruewhen the descriptor isundefined, which is what an out-of-range or negative index is on this view.
Reproduction, after the change
Only the cells that moved; every other cell above is unchanged.
int[]LiveViewArraySegment<int>x["2"] = 9x[3] = 91,2,3,9x["3"] = 91,2,3,9x[5] = 91,2,3,0,0,9x[100000] = 9x[-1] = 9x["-1"] = 9,x["08"] = 9x[2**31] = 9delete x["0"]delete x[3],delete x[-1]delete x["3"]x["3"](read)undefinedundefinedundefinedObject.assign(x, {3: 9})1,2,3,9Two rows are worth calling out because they are not about growth at all. A
T[]live view refusedx["2"] = 9— a perfectly ordinary in-range element write — withArgumentException, because the reflectedindexer for an array is the
object-typedIListone and the write bypassed item-type coercion; the sourcecomment on the fixed-size lane predicted exactly that and the lane only covered number keys. And
delete x["0"]was refused wheredelete x[0]reset the slot.What changed
ArrayLikeWrapperclassifies a property key once —ClassifyElementKey— andGet,Set,HasPropertyandDeleteall read that answer instead of each re-deriving a different one. Three outcomes:a
Positionthe target could address, anOutOfBandkey that is index-shaped but can never be a position(negative, non-canonical, past
int), andNonefor everything else, which falls through tobaseexactlyas before. The
"08"/"-1"guard and the dictionary-shaped carve-out are not new —HasPropertyalreadyhad both, and this makes them the rule for the other three.
Setthen grows for aPositionat or past the end of a target that is not fixed-size, which isSetAt,which is
EnsureCapacity+ write — the same two callsx.length = nandx.push(v)already make.One further fact the matrix forced, and the only part of this that is not the classifier.
ListWrappertookits writability from the target, so an
int[]handed to script asIReadOnlyList<T>produced a writableview; the refusal came from that interface's get-only indexer, and therefore only for a string-spelled
index. It now also treats an exposed contract with no writable indexer as read-only — which is what the
typed
ReadOnlyListWrapper<T>says from its type argument, and whatInteropTests.LiveViewHonorsNonArrayDeclaredTypehas always asserted.ListWrapperis what such anexposure degrades to because
ResolveArrayLikeWrapperFactoryTypescans the exposed type'sGetInterfaces()and an interface is not among its own; that gap is filed separately rather than closed here, because closing
it changes which wrapper several exposures get and deserves its own matrix.
Tests
Jint.Tests.PublicInterface/HostCollectionIndexWriteTests.cs— the only suite withoutInternalsVisibleTo, which is what makes "an embedder cannot catch this" a statement a test can make. Sixgrowable shapes, both spellings of every index-shaped key, the growth-equals-
lengthequivalence, thefixed-size and exposed-read-only contrasts, and a blanket row asserting that what leaves
Executeis aJavaScript error or nothing.
Failing first, on unmodified
upstream/mainwith only the new test file added:The 8 that pass are the in-range writes, and the exposed-
IReadOnlyList<T>element writes the get-onlyindexer already refused by accident.
Two rows of
ClrWriteConfigurationTests.ArrayGenericsCannotMutateFrozenClrListsWhenWritesAreEnabledwereasserting the string spelling's answer and move:
Object.freeze(list); list["0"] = 9outside strict mode isnow silent, as
list[0] = 9always was and as a frozen JavaScript array is, andReflect.set(list, "0", 9)returnsfalserather than throwing, which is whatReflect.setis specifiedto do. Both are now pinned in both spellings, in two tests named for what they assert.
Verification
dotnet build -c Release— 0 warnings, 0 errors.Jint.Tests— 10,752 passed onnet10.0andnet8.0, 7,376 onnet472, 0 failed.Jint.Tests.PublicInterface— 3,145 passed onnet10.0, 3,135 onnet8.0, 2,516 onnet472, 0 failed.JINT_HOST_CONTRACT_VERIFICATION=1— 0 failed.Jint.Tests.Test262— 102,495 passed, 0 failed, 189 skipped (102,684 total).UndocumentedPublicApi.txtis unchanged: everything touched is a memberof an
internaltype.docs/v5-migration.md§4.33 carries the embedder-facing form. No benchmark: the read path gains onebranch for a key that is not a
JsNumberand loses the reflected-indexer resolution it used to take, andthe write path replaces one type test with one classification.