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

Fix support for externally declared records #9104

Merged
merged 5 commits into from
Oct 1, 2024

Conversation

kzu
Copy link
Contributor

@kzu kzu commented Aug 6, 2024

Fixes improper codec codegen for records declared in referenced projects/assemblies. Roslyn does not guarantee the symbols contain the backing fields for generated properties (see dotnet/roslyn#72374 (comment)) and it also doesn't even report record struct symbols as records at all (see dotnet/roslyn#69326).

This makes for a very inconsistent experience when dealing with types defined in external assemblies that don't use the Orleans SDK themselves.

We implement a heuristics here to determine primary constructors that can be relied upon to detect them consistently:

  1. A ctor with non-zero parameters
  2. All parameters match by name exactly with corresponding properties
  3. All matching properties have getter AND setter annotated with [CompilerGenerated].

In addition, since the backing field isn't available at all in these records, and the corresponding property isn't settable (it's generated as init set), we leverage unsafe accessors (see https://learn.microsoft.com/en-us/dotnet/api/system.runtime.compilerservices.unsafeaccessorattribute?view=net-8.0) instead. The code checks whether the FieldAccessorDescription has an initializer syntax or not to determine whether to generate the original code or the new accessor version.

The signature of the accessor matches the delegate that is generated for the regular backing field case, so there is no need to modify other call sites.

Fixes #9092

Microsoft Reviewers: Open in CodeFlow

@kzu
Copy link
Contributor Author

kzu commented Aug 6, 2024

There's a bit of code duplication in the PR, but I tried to keep it in line with that was already there (i.e. the field accessor generation is also duplicated for the original use case too), and I didn't find a generic "SymbolExtensions" to centralize the primary constructor lookup, for example.

Let me know if I should seek to unify that and the existing code a bit too.

@kzu kzu force-pushed the external-record-serializer branch from f85d424 to d2fb2f0 Compare August 6, 2024 19:25
@kzu
Copy link
Contributor Author

kzu commented Aug 6, 2024

Failed test seems unrelated:

[xUnit.net 00:00:09.23]     UnitTests.StuckGrainTests.StuckGrainTests.StuckGrainTest_StuckDetectionOnDeactivation [FAIL]

It runs fine on my machine 🤔

Perhaps use dotnet-retest 😉

@kzu kzu force-pushed the external-record-serializer branch 2 times, most recently from 8527186 to 58a972b Compare August 17, 2024 22:50
@kzu
Copy link
Contributor Author

kzu commented Aug 17, 2024

Any updates on this @ReubenBond? This is blocking a release of my cloud actors thingy :)

@ReubenBond
Copy link
Member

ReubenBond commented Aug 20, 2024

I'll review this asap and see when we can get a release out

@ReubenBond ReubenBond self-assigned this Aug 21, 2024
@ReubenBond
Copy link
Member

I pushed a failing (#ifdef'd out) test for the generic case, which I believe is fixed in 9.0: dotnet/runtime#99468

.Where(m => m.MethodKind == MethodKind.Constructor && m.Parameters.Length > 0)
// Check for a ctor where all parameters have a corresponding compiler-generated prop.
.FirstOrDefault(ctor => ctor.Parameters.All(prm =>
properties.Any(prop => prop.Name.Equals(prm.Name, StringComparison.Ordinal) && prop.IsCompilerGenerated())));
Copy link
Member

Choose a reason for hiding this comment

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

How reliable is this? Could some language/compiler change in the future break it and cause a wire compatibility issue for users?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I couldn't find anything more reliable than that :(

Copy link
Contributor Author

Choose a reason for hiding this comment

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

As sharplab shows, there's nothing else we can depend on, unless the runtime adds something: https://sharplab.io/#v2:EYLgtghglgdgNAExAagD4AEDMACATgUwGMB7XBbAMWOIAp0BGABmwCEJcBKAbiA=. The property itself isn't [CompilerGenerated], and I doubt anyone would manually write an accessor annotated with that.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

FWIW, I think the proposed heuristics is as specific and detailed as possible to detect the situation given the current compiler output.

FIxes improper codec codegen for records declared in referenced projects/assemblies. Roslyn does not guarantee the symbols contain the backing fields for generated properties (see dotnet/roslyn#72374 (comment)) and it also doesn't even report `record struct` symbols as records at all (see dotnet/roslyn#69326).

This makes for a very inconsistent experience when dealing with types defined in external assemblies that don't use the Orleans SDK themselves.

We implement a heuristics here to determine primary constructors that can be relied upon to detect them consistently:
1. A ctor with non-zero parameters
2. All parameters match by name exactly with corresponding properties
3. All matching properties have getter AND setter annotated with [CompilerGenerated].

In addition, since the backing field isn't available at all in these records, and the corresponding property isn't settable (it's generated as `init set`), we leverage unsafe accessors (see https://learn.microsoft.com/en-us/dotnet/api/system.runtime.compilerservices.unsafeaccessorattribute?view=net-8.0) instead. The code checks whether the `FieldAccessorDescription` has an initializer syntax or not to determine whether to generate the original code or the new accessor version.

The signature of the accessor matches the delegate that is generated for the regular backing field case, so there is no need to modify other call sites.

Fixes #9092
In externally defined symbols, we need to also check for `[CompilerGenerated]`.
This allows running F5 on the generator project and troubleshoot issues. By checking for an already attached debugger, we can debug also scenarios of code generation where the roslyn component starts it with DTB=true.
@codymullins
Copy link

@ReubenBond do you foresee this PR being an issue to merge?

.OfType<IMethodSymbol>()
.Where(m => m.MethodKind == MethodKind.Constructor && m.Parameters.Length > 0)
// Check for a ctor where all parameters have a corresponding compiler-generated prop.
.FirstOrDefault(ctor => ctor.Parameters.All(prm =>
Copy link
Member

Choose a reason for hiding this comment

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

I find this variation easier to read, and possibly a little faster.

    .FirstOrDefault(ctor =>
        ctor.Parameters
            .Join(properties,
                  prm => prm.Name,
                  prop => prop.Name,
                  (prm, prop) => prop)
            .Count(prop => prop.IsCompilerGenerated) == ctor.Parameters.Length
    );

@ReubenBond
Copy link
Member

@ReubenBond do you foresee this PR being an issue to merge?

@codymullins no, I just need to run more tests to give myself confidence that this will not accidentally break backwards compatibility. I'll try to get to that today.

@kzu
Copy link
Contributor Author

kzu commented Sep 10, 2024

To add to the argument for merging this PR: the externally defined records that are now considered for generation would have previously been entirely omitted, causing runtime errors. So I think the risk is virtually non existent?

@alrz
Copy link
Member

alrz commented Sep 16, 2024

I hit this today, it occurred to me that GenerateSerializer already validates the type if it's applicable (if I'm not mistaken, that means either Id is present or it's a record). Why is there a need to validate externally declared types as well? Furthermore, my impression was that GenerateSerializer will actually do the generation as well in the current assembly and then a client will use those?

@kzu
Copy link
Contributor Author

kzu commented Sep 17, 2024

If the type is externally defined only, and the client is generic and does not do codegen itself, you would face this issue, since the serializers won't exist in the assembly where the records are defined.

@alrz
Copy link
Member

alrz commented Sep 17, 2024

client is generic and does not do codegen itself

Since it already references orleans bits for GenerateSerializer attribute, wouldn't it make sense to also do the codegen right there? That is, include the generator inside Abstractions package.

See https://github.com/dotnet/extensions/blob/main/src/Libraries/Microsoft.Extensions.Telemetry.Abstractions/Microsoft.Extensions.Telemetry.Abstractions.csproj for another instance of this pattern.

If that's too much of a change I'd argue at least the validation should happen via an analyzer once the attribute is applied so you don't need to validate externally - the presence of the attribute is effectively a guarantee.

(This is also generally how language features expose their contract for example for NRT, the compiler validates on declaration only, then reads the medatada on the client side, there's no concept of external validation because you wouldn't have enough info to do it after the fact which I think is the core issue here.)

@kzu
Copy link
Contributor Author

kzu commented Sep 20, 2024

That's a different issue. The [GenerateSerializer] attribute exists in the abstractions assembly. It does not require that users reference the orleans SDK. This a nice thing in my scenario (https://github.com/devlooped/CloudActors/) since I want the business logic assembly to remain Orleans agnostic (save for that attribute, which I even source-generate to a partial class so users don't even have to use it, ideally).

If that was not an explicit goal/supported scenario, then there would be no reason for the attribute to be separate from the Orleans SDK/generator itself, since using one would require using the other.

@ReubenBond
Copy link
Member

ReubenBond commented Sep 30, 2024

Apologies for the hiatus, my newborn arrived early, so I've had my hands full. I pushed a couple of additional changes:

  1. Add a test for an external readonly record struct
  2. Remove the Where(x => x.IsImplicitlyDeclared) on the ctor. I diff'd the codegen output and this one stuck out: it caused some default-constructable types to no longer be default-constructable. Tests all pass with this deleted

I'm ready to merge when CI completes.

@kzu
Copy link
Contributor Author

kzu commented Sep 30, 2024

Congrats!! 👶 rocks ❤️

@ReubenBond ReubenBond merged commit ae262c8 into dotnet:main Oct 1, 2024
22 checks passed
@ReubenBond
Copy link
Member

Many thanks, @kzu!

@kzu kzu deleted the external-record-serializer branch October 3, 2024 19:01
@kzu
Copy link
Contributor Author

kzu commented Oct 3, 2024

Would be awesome to know the milestone this will ship with, if you happen to know, to track when I can start using it 🙏

@github-actions github-actions bot locked and limited conversation to collaborators Nov 3, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Issue with generated codec for referenced project record message type
4 participants