Skip to content

Fix marshaling list collection types used for NotifyCollectionChangedEventArgs - #2525

Open
Manodasan Wignarajah (manodasanW) wants to merge 6 commits into
staging/3.0from
manodasanw/fixReadOnlyListMarshalling
Open

Fix marshaling list collection types used for NotifyCollectionChangedEventArgs#2525
Manodasan Wignarajah (manodasanW) wants to merge 6 commits into
staging/3.0from
manodasanw/fixReadOnlyListMarshalling

Conversation

@manodasanW

Copy link
Copy Markdown
Member

NotifyCollectionChangedEventArgs exposes collection types which are implemented as .NET internal types. Even though cswinrtinteropgen does an entire app analysis, it doesn't see these BCL types. But this is a common scenario someone may pass across the ABI, so we add special handling to add the two types to the proxy map table explicitly. If they aren't used, they should still get trimmed.

'NotifyCollectionChangedEventArgs' stores a single changed item in
'SingleItemReadOnlyList', an internal BCL type from 'System.ObjectModel' that
implements 'IList'. Any managed collection raising a single item change, such as
'ObservableCollection<T>' bound from XAML, therefore marshals that type across the
ABI as 'IList'. The CCW built for it does not expose 'IBindableVector', so that
throws an 'InvalidCastException' before the change reaches the native side.

The test covers the CCW directly, through a projected API taking a bindable vector,
and through the event args themselves, which is the path the failure was reported
from. It also asserts the runtime type name, so that it fails loudly rather than
silently covering nothing if the BCL stops using that type.

Checked in with the '[TestMethod]' attribute commented out, since it fails today.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 02be022a-e62f-48f5-bacc-805a2a6b40ae
'NotifyCollectionChangedEventArgs' stores its changed items in one of two internal
types from 'System.ObjectModel': 'SingleItemReadOnlyList' for a single item, and
'ReadOnlyList' for several. Any collection raising a change notification, such as an
'ObservableCollection<T>' bound from XAML, therefore marshals one of them across the
ABI as 'IList', which needs a CCW exposing 'IBindableVector'. Neither type got one, so
that threw an 'InvalidCastException' before the change reached the native side, and
there was nothing the developer could do about it: the objects come from BCL code.

Neither type can be discovered the usual way. The generator is given the framework
reference assemblies, which carry public API surface only, so these types are absent
from its input entirely, in every marshalling mode. They are therefore registered by
name, with the interfaces they implement stated explicitly, and everything downstream
is the same machinery any other user-defined type goes through.

The names are resolved when the type map is built, so they are a compatibility
contract: if one of these types is ever renamed or removed, Native AOT publishing
fails with a type load error from ILC, while under the JIT the entry simply never
matches. That is a loud and early failure rather than a silent one, which is the
intended trade-off, and it is called out where the types are listed.

Registering a type the generator cannot resolve also means the few places that assumed
every user-defined type resolves now handle that: the proxy builder takes the interfaces
it is given rather than reading them back off the type, component interface entries skip
types that cannot be resolved (which are never component types), and looking up the
Windows Runtime metadata name yields none for them (they are not projected types).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 02be022a-e62f-48f5-bacc-805a2a6b40ae
Keep the proxy builder's original three branch shape rather than restructuring it
around early returns, list the type names through a stack allocated span like the
other fixed lists in the generator do, and align the wording of the new error message
and doc comments with the ones next to them.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 02be022a-e62f-48f5-bacc-805a2a6b40ae
The metadata name lookup had grown three near identical ternaries, one per branch of
the switch. Have the switch select the type to resolve instead, so there is a single
resolve and a single 'no metadata name' path. Fold the component value type guard into
one check using a property pattern, matching the style used elsewhere.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 02be022a-e62f-48f5-bacc-805a2a6b40ae
The vtables for the list types that 'NotifyCollectionChangedEventArgs' stores its
changed items in are keyed on names that are resolved when the type map is built, so if
one of those types is ever renamed or removed in a future .NET version, Native AOT
publishing fails with a type load error from ILC. Add
'CsWinRTGenerateCollectionChangedListVtables' so that can be turned off from a project,
without waiting for a new CsWinRT release. Marshalling those objects then fails at
runtime, which is the same behaviour as before they were added.

The property is plumbed through the same way as 'CsWinRTMarshallingMode', including
being part of the generator inputs hash, so toggling it regenerates the interop assembly
rather than leaving a stale one behind.

Name everything after the scenario these types are optimized for, rather than after
where they happen to come from or how they behave, so the property and the code that
backs it use the same vocabulary.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 02be022a-e62f-48f5-bacc-805a2a6b40ae
The component interface entries only ever get a type that was discovered from an actual
assembly, so it always resolves. Guarding that with a 'TryResolve' turned a genuine
failure into silently skipping the entries, which would hide the problem rather than
report it. The types registered by name never reach this path: the interface entries are
built once per vtable set, from a single representative type, which they are not.

Also drop the note about the span, which explained the language construct rather than
anything about this code.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 02be022a-e62f-48f5-bacc-805a2a6b40ae
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

// 'NotifyCollectionChangedEventArgs', which are registered by name, as they are absent from the reference
// assemblies the generator sees (see the discovery of those types). Those carry none of the attributes
// checked below, so they just take the same path as any other non-authored type.
_ = userDefinedType.TryResolve(module.RuntimeContext, out TypeDefinition? userDefinedTypeDefinition);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like that this will potentially hide legitimate type resolution errors for other types, and also regardless of whether that flag is even set. Can we do this:

  • Let's define a centralized place that exposes type signatures for all our special-cased internal types (right now it would just be these two)
  • Here call TryResolve first. If it fails, check if the type is in that set of special-cased internal types (that class could expose a helper or a set of all of them, so it can also automatically use the right comparer, and here we can just do Contains)
  • If it matches, continue
  • Otherwise, just call Resolve to let it throw the original exception as it would've done before

Then we can also reuse that helper (e.g. InternalInteropReferences or something) in DiscoverCollectionChangedListTypes, by just looping over these two like:

foreach (TypeSignature typeSignature in (ReadOnlySpan<TypeSignature>)[
    InternalInteropReferences.SingleItemReadOnlyList,
    InternalInteropReferences.ReadOnlyList])
{
    // Code here
}

Something like this, so we have fewer special-cases hardcoded around. What do you think?

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.

2 participants