Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[API Proposal]: some way (attribute?) to mark types as constructed when used in casts #112287

Closed
Sergio0694 opened this issue Feb 7, 2025 · 18 comments
Labels
api-needs-work API needs work before it is approved, it is NOT ready for implementation api-suggestion Early API idea and discussion, it is NOT ready for implementation area-Tools-ILLink .NET linker development as well as trimming analyzers linkable-framework Issues associated with delivering a linker friendly framework partner-impact This issue impacts a partner who needs to be kept updated

Comments

@Sergio0694
Copy link
Contributor

Sergio0694 commented Feb 7, 2025

Background and motivation

We have a trimming issue in CsWinRT which users (both internal and not) keep hitting. The problem is that we have projected types (concrete classes) used in a cast expression, and never directly instantiated. This type is instantiated earlier, when we create an RCW for a native object, if the runtime class name that object returns matches the type name of a projected class that CsWinRT generated. If that's the case, we then lookup [WinRTImplementationTypeRcwFactoryAttribute] on that type, and we use that to construct the actual RCW instance, via its factory method. If there is no referenced projected class matching the returned runtime class name, then we just return a generic IInspectable object, which is not relevant here.

The problem is that the type is only constructed via the attribute, which is on the type itself, and the type is never directly instantiated. So ILC sees the cast, assumes the type can't be constructed (as it ignores that cycle), and the cast always fails, even when it should succeed (because the wrapped native object is that desired projected type). We don't want to root all types with the factory, as that'd be terrible for trimming. But we do need a way to make it so that such types are marked as constructed, when they're used in a cast. Basically, casts should succeed if we have a native object matching a referenced projected class.

Here's some examples:

Concrete example

The scenario is basically this:

using System.Reflection;

IntPtr nativeObject = 0; // Get an object from somewhere
object rcw = WinRTStuff.CreateRcw(nativeObject); // Get the typed RCW for it

if (rcw is MyObject myObject)
{
    Console.WriteLine("We have 'MyObject'");
}
else
{
    Console.WriteLine("'MyObject' was incorrectly trimmed");
}

static class WinRTStuff
{
    public static object CreateRcw(IntPtr obj)
    {
        // Assume we invoke 'IInspectable::GetRuntimeClassName' on 'obj' here.
        // In this scenario, assume the returned class name matches 'MyObject'.
        string runtimeClassName = string.Concat("", "MyObject");

        // See if we reference a projected type matching the runtime class name
        if (Type.GetType(runtimeClassName) is { } type)
        {
            // If so, get the attribute from it with the factory
            if (type.GetCustomAttribute<ObjectFactoryAttribute>() is { } attribute)
            {
                // We manage to construct the correct projected type for the input native object
                return attribute.CreateInstance(obj);
            }
        }

        // Otherwise, we return a generic wrapper, doesn't matter here
        return new IInspectable();
    }
}

class IInspectable;

abstract class ObjectFactoryAttribute : Attribute
{
    public abstract object CreateInstance(IntPtr obj);
}

class MyObjectFactoryAttribute : ObjectFactoryAttribute
{
    public override object CreateInstance(IntPtr obj) => new MyObject(obj);
}

[MyObjectFactory]
class MyObject
{
    internal MyObject(IntPtr obj)
    {        
    }
}

We need a way to tell the linker/ILC:

"If there is a concrete type that participates in a cast anywhere in the application, mark it as constructed.

API Proposal

namespace System.Diagnostics.CodeAnalysis;

[AttributeUsage(AttributeTargets.Class, Inherited = false)]
public sealed class DynamicallyConstructedWhenCastTargetAttribute : Attribute;

Note

The exact attribute name is a placeholder, it doesn't matter. Happy to update the proposal with any suggestions.

API Usage

In the example above, we'd simply add the attribute on MyObject, and things would work as expected.

In CsWinRT, we'll update our projections to include the attribute for any projected class also using our factory attribute.

Alternative Designs

Alternatives we considered:

  • Analyzer to warn on all casts with projected classes, telling users to either:
    • Generator emitting [DynamicDependency] in a module initializer. This is suboptimal for trimming, as it roots unconditionally.
    • Add [DynamicDependency] on the containing method. We don't want users to have to see this implementation detail.
    • Tell users to use some Cast<T> method instead, which happens to root the type. This is awful, we want people to be able to use idiomatic C#.

Risks

None that I can see. It adds a bit of cognitive overhead to users, but it's a super niche feature only needed by library authors.

cc. @MichalStrehovsky

@Sergio0694 Sergio0694 added api-needs-work API needs work before it is approved, it is NOT ready for implementation api-suggestion Early API idea and discussion, it is NOT ready for implementation linkable-framework Issues associated with delivering a linker friendly framework partner-impact This issue impacts a partner who needs to be kept updated labels Feb 7, 2025
@dotnet-issue-labeler dotnet-issue-labeler bot added the needs-area-label An area label is needed to ensure this gets routed to the appropriate area owners label Feb 7, 2025
@dotnet-policy-service dotnet-policy-service bot added the untriaged New issue has not been triaged by the area owner label Feb 7, 2025
@jkotas
Copy link
Member

jkotas commented Feb 7, 2025

Concrete example
The scenario is basically this:

This repro prints We have 'MyObject' (tried on .NET 9). Even if I change string runtimeClassName = "MyObject"; to string runtimeClassName = string.Concat("", "MyObject"); so that the compiler does not see the constant string, it still prints We have 'MyObject'.

Is the example missing some important detail?

@jkotas jkotas added the area-Tools-ILLink .NET linker development as well as trimming analyzers label Feb 7, 2025
@jkotas jkotas removed the needs-area-label An area label is needed to ensure this gets routed to the appropriate area owners label Feb 7, 2025
@Sergio0694
Copy link
Contributor Author

Mmh... Have you tried on NativeAOT? The issue shouldn't be specific to that, but I haven't personally tried with CoreCLR + trimming myself. @manodasanW the minimal example looks correct to you too, right? Trying to think about what difference could there be between this and what CsWinRT is doing exactly but it generally feels equivalent to me? 🤔

@jkotas
Copy link
Member

jkotas commented Feb 7, 2025

Yes, I have tried with native. This is the output with string runtimeClassName = string.Concat("", "MyObject");:

C:\repro>dotnet publish /p:PublishAot=true
  Determining projects to restore...
  Restored C:\repro\repro.csproj (in 483 ms).
C:\repro\Program.cs(24,13): warning IL2057: Unrecognized value passed to the parameter 'typeName' of method 'System.Typ
e.GetType(String)'. It's not possible to guarantee the availability of the target type. [C:\repro\repro.csproj]
  repro -> C:\repro\bin\Release\net9.0\win-x64\repro.dll
  Generating native code
C:\repro\Program.cs(24): Trim analysis warning IL2057: WinRTStuff.CreateRcw(IntPtr): Unrecognized value passed to the p
arameter 'typeName' of method 'System.Type.GetType(String)'. It's not possible to guarantee the availability of the tar
get type. [C:\repro\repro.csproj]
  repro -> C:\repro\bin\Release\net9.0\win-x64\publish\

C:\repro>C:\repro\bin\Release\net9.0\win-x64\publish\repro.exe
We have 'MyObject'

@Sergio0694
Copy link
Contributor Author

My bad, yeah the repro wasn't complete. I've updated it and I can also repro locally now 😅
I've also made the attributes match the exact shape of what we have in CsWinRT, if it helps.

@Sergio0694
Copy link
Contributor Author

Sergio0694 commented Feb 7, 2025

Narrowed it down to the abstract attribute. That is, this repros (lookup ObjectFactoryAttribute):

abstract class ObjectFactoryAttribute : Attribute
{
    public abstract object CreateInstance(IntPtr obj);
}

class MyObjectFactoryAttribute : ObjectFactoryAttribute
{
    public override object CreateInstance(IntPtr obj) => new MyObject();
}

This doesn't (lookup MyObjectFactoryAttribute):

class MyObjectFactoryAttribute : Attribute
{
    public object CreateInstance(IntPtr obj) => new MyObject();
}

@Sergio0694
Copy link
Contributor Author

Also worth noting, we explicitly do not want to always preserve types having an attribute that is being used as type argument for GetCustomAttributes, or that have an attribute that is a derived type of an attribute type used for that. That would make all projected types be kept unconditionally (because they all have a factory attribute on them). We specifically only care about preserving those that are used in castclass or isinst operations somewhere in the application.

@jkotas
Copy link
Member

jkotas commented Feb 8, 2025

Castability can be observed by the application in many different ways. For example, through Type.IsAssignableFrom. How would that work? Would all these other situations be left broken?

@jkoritzinsky
Copy link
Member

Instead of limiting this to castability, could the feature instead more generally say "mentions of this type must result in a fully-constructed type symbol"?

This would effectively remove the "Necessary" type symbol optimization for these types. That would add their custom attributes to the dependency graph when the type is directly referenced elsewhere, rooting the constructor as part of rooting the custom attribute info.

That way it would cover castclass, isinst, and unbox.any, as well as all other cases in which casting could be mentioned. It would also cover more cases, but that may be acceptable and would ensure a consistent experience.

@Sergio0694
Copy link
Contributor Author

That seems reasonable to me. Would it make sense to make this a general feature?

Something like (placeholder name):

[AttributeUsage(AttributeTargets.All, Inherited = false)]
internal sealed class DynamicDependencyModifiersAttribute: Attribute
{
    public DynamicDependencyModifiersAttribute(DynamicDependencyModifier modifier);

    public DynamicDependencyModifier Modifier { get; }
}

[Flags]
public enum DynamicDependencyModifier
{
    None = 0,
    TypeReferenceImpliesConstructed = 1
}

So that in the future we could easily add more modifiers to this enum to support more scenarios, if needed, without the need for new attributes. And for this scenario in CsWinRT, we'd just use [DynamicDependencyModifiers(DynamicDependencyModifier.TypeReferenceImpliesConstructed)].

"That way it would cover castclass, isinst, and unbox.any, as well as all other cases in which casting could be mentioned. It would also cover more cases, but that may be acceptable and would ensure a consistent experience."

As long as this doesn't cause false positives resulting in all projection types being rooted, that would be fine for CsWinRT 🙂

@MichalStrehovsky
Copy link
Member

Type.GetType in a trimmed app is equivalent of dumpster diving - it may find a type that was used in a cast and therefore got protected by the DynamicDependencyModifiersAttribute amulet, or it may find a skeleton of something that was optimized away (wasn't used in a cast) but we still had to keep parts of it. What happens when CsWinRT runs into such skeleton here?

My main question for trimming related API proposals is: can this be used without trim warning suppressions?

Instead of limiting this to castability, could the feature instead more generally say "mentions of this type must result in a fully-constructed type symbol"?

It's difficult to find all mentions of a type. For example if a type was used as a field type: we don't (currently) trim fields so this is a "mention" no matter if the field is ever written/read. We only look at the field as part of calculating type layout - and type layout has no connection to dependency analysis right now. So we'd need extra dependency analysis passes to find "mentions" in all kinds of places. The compiler is currently not in the business of finding all IL constructs that were looked at, but in the business of figuring out what code/data structures need to be generated. An unused field type currently only contributes to vacated space in terms of output data structures and the compiler is not well equipped to track it right now. There will be lots of other "mentions" that we'll need to collect in unrelated places (e.g. type of a local variable, type used as generic argument, type used as part of a function pointer signature in a calli, etc.) and every place we forgot to collect will be a bug. We don't have a single choke point for "mentions".

@jkoritzinsky
Copy link
Member

The way to provide a scenario here without Type.GetType would be to use the Interop Type Mapping proposal, which would provide the trim-safe 1:1 type lookup. That proposal also depends on at least some level of trimming out unused types as the foreign type universe projections are expensive.

Maybe we could put the two together? Basically say "if this type is mapped in from a foreign type universe and is present in the AOT image, it's fully constructed".

@Sergio0694
Copy link
Contributor Author

Could you clarify how that would work in this case? As in, suppose we have the projected types Foo and Bar, and we also have an entry in the interop type mapping for each of them (as they're projected types). Now, we use Foo in an obj as Foo cast somewhere, and we don't use Bar anywhere. How can we make sure that Foo is kept, but Bar is fully trimmed, without special casing casts? Like, the fact that cast exists is the only thing here that's different between the two and that should mark Foo as constructed 🤔

@jkoritzinsky
Copy link
Member

Basically, if Bar is only part of the interop type mapping, it would be trimmed (this is a key design requirement for the interop type mapping proposal).

Then, when a "type symbol" is requested for Foo, the compiler asks "does this type need to be fully constructed". We'd add a check for "is this type in an interop type mapping, if so yes" in the method that answers that question.

@jkoritzinsky
Copy link
Member

A few of the ideas we have for the interop type mapping proposal have led us back to the idea of "call GetCustomAttribute on the type returned from the interop type mapping" as our guidance for getting auxiliary data, so we may actually need this for that anyway in some capacity.

@vitek-karas
Copy link
Member

I have to agree with @MichalStrehovsky here - whatever the solution is, it should not require warning suppression. Currently there's no solution (without warning suppression) to the problem of some external source (WinRT, Java, objC) effectively instantiating .NET types. This proposal doesn't fix that, since the code which calls the instantiation would still get warnings. There's no tie between the annotation, the site which creates the instance and the "type map"/"cast" which proves that the type is actually used and kept.

@MichalStrehovsky
Copy link
Member

We were talking about this a bit with the team. One thing that came up with this heuristic is this:

  • We get some object across interop boundary. IInspectable says it's a Button, but we never had a cast to a Button so we don't have such class, it got trimmed.
  • Then someone goes and casts this object to Control (that is a base class of Button)
  • Then someone goes and casts this object to IClickable (that Button implements, but Control doesn't)

Are these possible scenarios we need to worry about?

@Sergio0694
Copy link
Contributor Author

These scenarios are solved from the CsWinRT side, as follows:

"Then someone goes and casts this object to Control (that is a base class of Button)"

That will work (assuming Control isn't trimmed). CsWinRT generates a table of all base types for all projected types in each projection assembly. It looks like this (eg. this is from Windows.UI.Xaml projections):

Image

When we go to create an RCW for a given native object, we do the following:

  • Get the projected type for the runtime class name. If it exists, create that
  • If not, ask the base type table for the base type. Get that one, and if it exists, create that
  • [...]
  • If we exhausted all base types and they were all gone, just instantiate an IInspectable object (kinda like ComObject)

So basically, the actual managed object you get is always the most derived projection type that wasn't trimmed.
In your example, you'd just get a Control, so that cast would work fine (even though the wrapped native object is a Button).

"Then someone goes and casts this object to IClickable (that Button implements, but Control doesn't)"

That will still work, because all projected types implement IDynamicInterfaceCastable. So even if Button got trimmed and we ended up with a Control, the cast to that interface would go through the IDIC path and still work as expected.

@AaronRobinsonMSFT
Copy link
Member

Closing in deference to #110691

@AaronRobinsonMSFT AaronRobinsonMSFT closed this as not planned Won't fix, can't repro, duplicate, stale Feb 22, 2025
@dotnet-policy-service dotnet-policy-service bot removed the untriaged New issue has not been triaged by the area owner label Feb 22, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
api-needs-work API needs work before it is approved, it is NOT ready for implementation api-suggestion Early API idea and discussion, it is NOT ready for implementation area-Tools-ILLink .NET linker development as well as trimming analyzers linkable-framework Issues associated with delivering a linker friendly framework partner-impact This issue impacts a partner who needs to be kept updated
Projects
Status: No status
Development

No branches or pull requests

6 participants