Skip to content

Conversation

@jcouv
Copy link
Member

@jcouv jcouv commented Jul 31, 2025

Overview:
For extension blocks, we produce grouping and marker type names. They have content-based hashes.
There are two kinds of possible collisions:

  1. Those (non-cryptographic) hashes theoretically could suffer some collisions.
  2. They also have known collisions due to information that is intentionally left out (such as the assembly identity where the types originate).

This PR implements a symbol-level check that ensures that we only allow extension blocks with identical IL-level signatures into a grouping type, and those with identical C#-level signatures into a marker type.
It is useful to look at ComputeExtensionGroupingRawName() and ComputeExtensionMarkerRawName() to see what information counts in IL-level vs. C#-level signature.

The only quirk (by design, but keeps surprising me) is that we decided that refkind doesn't count as part of IL-level signature.

Addresses one of the metadata follow-ups in #78963
Relates to test plan #76130

@jcouv jcouv self-assigned this Jul 31, 2025
@jcouv jcouv added Area-Compilers Feature - Extension Everything The extension everything feature labels Jul 31, 2025
@jcouv jcouv force-pushed the extensions-collisions branch 3 times, most recently from 4e1d448 to 9e42d84 Compare August 1, 2025 02:59

/// <summary>
/// Reports a diagnostic when two extension blocks grouped into a single marker type have different C#-level signatures.
/// Note: It's fine for two extension blocks with different IL-level signatures to be grouped into the same grouping type, so we don't check for collisions there.
Copy link
Member Author

@jcouv jcouv Aug 1, 2025

Choose a reason for hiding this comment

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

📝 This is illustrated by Grouping_30 #Closed

@jcouv jcouv force-pushed the extensions-collisions branch 2 times, most recently from 2bd8055 to ea0615f Compare August 1, 2025 22:05
@jcouv jcouv changed the title Extensions: check for grouping type collisions Extensions: check for marker collisions Aug 1, 2025
@jcouv jcouv force-pushed the extensions-collisions branch from ea0615f to 7f543e0 Compare August 1, 2025 22:24
@jcouv jcouv marked this pull request as ready for review August 2, 2025 00:39
@jcouv jcouv requested a review from a team as a code owner August 2, 2025 00:39
@AlekseyTs
Copy link
Contributor

AlekseyTs commented Aug 2, 2025

        hash = Hash.Combine(GetHashCodeForNamedArguments(attr.NamedArguments), hash);

Are we certain this step doesn't need to pay attention to _considerNamedArgumentsOrder? #Closed


Refers to: src/Compilers/Core/Portable/Symbols/Attributes/CommonAttributeDataComparer.cs:52 in 7f543e0. [](commit_id = 7f543e0, deletion_comment = False)

attr1.IsConditionallyOmitted == attr2.IsConditionallyOmitted &&
attr1.CommonConstructorArguments.SequenceEqual(attr2.CommonConstructorArguments) &&
attr1.NamedArguments.SequenceEqual(attr2.NamedArguments);
(_considerNamedArgumentsOrder ? attr1.NamedArguments.SequenceEqual(attr2.NamedArguments) : attr1.NamedArguments.SetEquals(attr2.NamedArguments));
Copy link
Contributor

@AlekseyTs AlekseyTs Aug 2, 2025

Choose a reason for hiding this comment

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

attr1.NamedArguments

How are typeof values going to be compared? #Closed

Copy link
Member Author

@jcouv jcouv Aug 5, 2025

Choose a reason for hiding this comment

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

They were compared with Default/AllNullableIgnoreOptions. I updated this to use AllIgnore and showed the impact on assembly attributes with tuples in a test.
I considered filing an issue for the order of comparison. It seems strange that we would be order-dependent for this. But I don't think we'd get to fixing this (low-pri)

<value>Interpolated string handler arguments are not allowed in this context.</value>
</data>
<data name="ERR_ExtensionBlockCollision" xml:space="preserve">
<value>This extension block collides with another extension block. They have different signatures, but result in the same marker type identifier.</value>
Copy link
Contributor

@AlekseyTs AlekseyTs Aug 2, 2025

Choose a reason for hiding this comment

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

marker type

I think the reference to "marker type" is going to be confusing, it is not part of the language #Closed

<value>Interpolated string handler arguments are not allowed in this context.</value>
</data>
<data name="ERR_ExtensionBlockCollision" xml:space="preserve">
<value>This extension block collides with another extension block. They have different signatures, but result in the same marker type identifier.</value>
Copy link
Contributor

@AlekseyTs AlekseyTs Aug 2, 2025

Choose a reason for hiding this comment

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

different signatures

Is there such a concept as an extension signature in the language? #Closed

ImmutableArray<TypeParameterSymbol> typeParams1 = member1.GetMemberTypeParameters();
ImmutableArray<TypeParameterSymbol> typeParams2 = member2.GetMemberTypeParameters();

if (_considerParameterNames && typeParams1.Length > 0 && !haveSameTypeParameterNames(typeParams1, typeParams2))
Copy link
Contributor

@AlekseyTs AlekseyTs Aug 2, 2025

Choose a reason for hiding this comment

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

typeParams1.Length > 0

Can this be false? #Closed

Copy link
Contributor

Choose a reason for hiding this comment

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

The thread is marked as resolved, but I do not see any response to the question. It was about typeParams1.Length > 0 condition, BTW.

Copy link
Member Author

Choose a reason for hiding this comment

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

You commented on commit 3, but this was fixed later on

bool considerDefaultValues = false,
TypeCompareKind typeComparison = TypeCompareKind.IgnoreDynamic | TypeCompareKind.IgnoreNativeIntegers)
TypeCompareKind typeComparison = TypeCompareKind.IgnoreDynamic | TypeCompareKind.IgnoreNativeIntegers,
bool considerParameterNames = false,
Copy link
Contributor

@AlekseyTs AlekseyTs Aug 2, 2025

Choose a reason for hiding this comment

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

considerParameterNames

It is not obvious from this name, in fact it is unexpected, that this value has any impact on type parameter comparison #Closed

Copy link
Contributor

Choose a reason for hiding this comment

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

Consider renaming

}

return !_considerTypeConstraints || HaveSameConstraints(member1, typeMap1, member2, typeMap2);
return !_considerTypeConstraints || HaveSameConstraints(member1, typeMap1, member2, typeMap2, ignoreNullability: (_typeComparison & TypeCompareKind.IgnoreNullableModifiersForReferenceTypes) != 0);
Copy link
Contributor

@AlekseyTs AlekseyTs Aug 2, 2025

Choose a reason for hiding this comment

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

(_typeComparison & TypeCompareKind.IgnoreNullableModifiersForReferenceTypes) != 0

This value makes sense, however, I am curious whether any of the existing comparers might change behavior now. #Closed

Copy link
Member Author

Choose a reason for hiding this comment

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

None of the existing comparers used considerTypeConstraints (they all use false)

return false;
}

var comparer = CommonAttributeDataComparer.InstanceIgnoringNamedArgumentOrder;
Copy link
Contributor

@AlekseyTs AlekseyTs Aug 2, 2025

Choose a reason for hiding this comment

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

InstanceIgnoringNamedArgumentOrder

This makes sense for the extensions scenario, but is not obvious from the API perspective. It might be good to add a comment for considerAttributes parameter. #Closed

}

private static bool HaveSameConstraints(Symbol member1, TypeMap? typeMap1, Symbol member2, TypeMap? typeMap2)
private static bool HaveSameConstraints(Symbol member1, TypeMap? typeMap1, Symbol member2, TypeMap? typeMap2, bool ignoreNullability = true)
Copy link
Contributor

@AlekseyTs AlekseyTs Aug 2, 2025

Choose a reason for hiding this comment

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

bool ignoreNullability = true

I think it would be better to make this parameter required here and in other signature where we add it. #Closed

@AlekseyTs
Copy link
Contributor

AlekseyTs commented Aug 2, 2025

        return HaveSameTypeConstraints(typeParameter1, typeMap1, typeParameter2, typeMap2, SymbolEqualityComparer.IgnoringDynamicTupleNamesAndNullability);

Shouldn't this value be adjusted? #Closed


Refers to: src/Compilers/CSharp/Portable/Symbols/MemberSignatureComparer.cs:792 in 7f543e0. [](commit_id = 7f543e0, deletion_comment = False)

@AlekseyTs
Copy link
Contributor

AlekseyTs commented Aug 2, 2025

Are grouping collisions going to be handled in a separate PR? #Closed

return ((PropertySymbol)member).Parameters;
case SymbolKind.Event:
return ImmutableArray<ParameterSymbol>.Empty;
case SymbolKind.NamedType:
Copy link
Contributor

@AlekseyTs AlekseyTs Aug 2, 2025

Choose a reason for hiding this comment

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

case SymbolKind.NamedType:

It looks like change is done for the benefit of one call site, that can conditionally special case extensions (based on explicit request). It is, however, not obvious that all other call sites would want this behavior. I suggest leaving this general helper unchanged. #Closed

case SymbolKind.Event:
case SymbolKind.Field:
return 0;
case SymbolKind.NamedType:
Copy link
Contributor

@AlekseyTs AlekseyTs Aug 2, 2025

Choose a reason for hiding this comment

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

case SymbolKind.NamedType:

Same comment as above #Closed


/// <summary>
/// Reports a diagnostic when two extension blocks grouped into a single marker type have different C#-level signatures.
/// Note: It's fine for two extension blocks with different IL-level signatures to be grouped into the same grouping type, so we don't check for collisions there.
Copy link
Contributor

@AlekseyTs AlekseyTs Aug 2, 2025

Choose a reason for hiding this comment

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

so we don't check for collisions there

It is not clear whether a statement is made that collisions in grouping type are impossible to occur. If it is, I think it is incorrect and collisions checks for grouping types are also necessary. #Closed

var stringBuilder = PooledStringBuilder.GetInstance();
appendAttribute(attribute, stringBuilder.Builder);
attributesBuilder.Add(stringBuilder.ToStringAndFree());
if (!attribute.IsConditionallyOmitted)
Copy link
Contributor

@AlekseyTs AlekseyTs Aug 2, 2025

Choose a reason for hiding this comment

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

if (!attribute.IsConditionallyOmitted)

Is this taken into consideration when we check for collisions? #Closed

Copy link
Member Author

Choose a reason for hiding this comment

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

Yes, it is considered in CommonAttributeDataComparer.Equals (that's what brought this issue to my attention). Grouping_39 illustrates.

Copy link
Contributor

@AlekseyTs AlekseyTs Aug 3, 2025

Choose a reason for hiding this comment

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

Yes, it is considered in CommonAttributeDataComparer.Equals

It looks like it is considered as part of equality of the attribute. But for our purposes, it feels like conditionally omitted attributes should be out of collision check. Since they are not going to be emitted, no real difference that can come from them, or we should not be ignoring them here. I am leaning towards ignoring though.

@AlekseyTs
Copy link
Contributor

AlekseyTs commented Aug 2, 2025

Done with review pass (commit 1), tests are not reviewed #Closed

Comment on lines 293 to 296
if (parameter1 is null || parameter2 is null)
{
return parameter1 is null && parameter2 is null;
}
Copy link
Member

@333fred 333fred Aug 6, 2025

Choose a reason for hiding this comment

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

Nit, think this would be more clearly expressed as:

Suggested change
if (parameter1 is null || parameter2 is null)
{
return parameter1 is null && parameter2 is null;
}
if (parameter1 is null)
{
return parameter2 is null;
}
else if (parameter2 is null)
{
return false;
}
``` #Resolved

return parameter1 is null && parameter2 is null;
}

if (!MemberSignatureComparer.HaveSameParameterType(parameter1, typeMap1, parameter2, typeMap2,
Copy link
Member

@333fred 333fred Aug 6, 2025

Choose a reason for hiding this comment

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

Should we try and order this after the cheap checks, like name/declared scope? #Resolved

<value>Interpolated string handler arguments are not allowed in this context.</value>
</data>
<data name="ERR_ExtensionBlockCollision" xml:space="preserve">
<value>This extension block collides with another extension block. They result in conflicting content-based type names in metadata.</value>
Copy link
Member

@333fred 333fred Aug 6, 2025

Choose a reason for hiding this comment

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

I don't have an immediate suggestion for the wording, but is there something we can tell users to try in this case? #Resolved

[Fact]
public void Grouping_09()
{
// attribute on parameter vs. different attribute
Copy link
Member

@333fred 333fred Aug 6, 2025

Choose a reason for hiding this comment

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

Suggested change
// attribute on parameter vs. different attribute
// attribute on parameter vs. different parameter value
``` #Resolved

Copy link
Member

@333fred 333fred Aug 6, 2025

Choose a reason for hiding this comment

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

Consider testing object vs dynamic #Resolved

var typeMap2 = new TypeMap(typeParameters2, indexedTypeParameters, allowAlpha: true);

// Report any mismatched method constraints.
var compareKind = TypeCompareKind.IgnoreDynamicAndTupleNames | TypeCompareKind.IgnoreNullableModifiersForReferenceTypes;
Copy link
Contributor

@AlekseyTs AlekseyTs Aug 7, 2025

Choose a reason for hiding this comment

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

var

const? #Closed

@jcouv jcouv requested a review from 333fred August 7, 2025 02:37
var containingTypeMap = new TypeMap(containingTypeParameters, IndexedTypeParameterSymbol.Take(n), allowAlpha: false);
var nestedTypeMap = new TypeMap(nestedTypeParameters, IndexedTypeParameterSymbol.Take(nestedTypeParameters.Length), allowAlpha: false);

var compareKind = TypeCompareKind.IgnoreDynamicAndTupleNames | TypeCompareKind.IgnoreNullableModifiersForReferenceTypes;
Copy link
Contributor

@AlekseyTs AlekseyTs Aug 7, 2025

Choose a reason for hiding this comment

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

var

const #Closed


TypeMap? typeMap1 = MemberSignatureComparer.GetTypeMap(extension1);
TypeMap? typeMap2 = MemberSignatureComparer.GetTypeMap(extension2);
if (extension1.Arity != extension2.Arity)
Copy link
Contributor

@AlekseyTs AlekseyTs Aug 7, 2025

Choose a reason for hiding this comment

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

if (extension1.Arity != extension2.Arity)

Consider doing this check before allocating type maps #Closed


TypeMap? typeMap1 = MemberSignatureComparer.GetTypeMap(extension1);
TypeMap? typeMap2 = MemberSignatureComparer.GetTypeMap(extension2);
if (extension1.Arity != extension2.Arity)
Copy link
Contributor

@AlekseyTs AlekseyTs Aug 7, 2025

Choose a reason for hiding this comment

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

if (extension1.Arity != extension2.Arity)

Consider doing this check before allocating type maps #Closed

{
var stringBuilder = PooledStringBuilder.GetInstance();
appendTypeWithAnnotation(contraintTypes[i], stringBuilder.Builder);
appendType(constraintTypes[i].Type, stringBuilder.Builder);
Copy link
Contributor

@AlekseyTs AlekseyTs Aug 7, 2025

Choose a reason for hiding this comment

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

appendType(constraintTypes[i].Type, stringBuilder.Builder);

Do we care about nullability of this type? #Closed

Copy link
Member Author

Choose a reason for hiding this comment

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

No, we don't care about top-level nullability annotations on each type constraint.
We don't emit one annotation per type constraint, we only emit one for the whole type parameter.
Similarly MemberSignatureComparer.HaveSameConstraints performs a single top-level nullability check per type parameter.
And that's why I changed the raw marker name to use "notnull", "maybenull" (invented keyword) or nothing (for oblivious).

Copy link
Contributor

Choose a reason for hiding this comment

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

No, we don't care about top-level nullability annotations on each type constraint.

I am not sure about that. For example, given the following C# code:

class C<T> where T : C1?, I1, I2? {}
class C1{}
interface I1{}
interface I2{}

The following metadata are generated:

.class private auto ansi beforefieldinit C`1<(C1, I1, I2) T>
    extends [mscorlib]System.Object
{
    .param constraint T, C1
        .custom instance void System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = (
            01 00 02 00 00
        )
    .param constraint T, I1
        .custom instance void System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = (
            01 00 01 00 00
        )
    .param constraint T, I2
        .custom instance void System.Runtime.CompilerServices.NullableAttribute::.ctor(uint8) = (
            01 00 02 00 00
        )

It looks like top level nullability of type constraints affects metadata, therefore, we are supposed to care about it.

We also should care about notnull constraint, but that is a different thing and it has its own impact on metadata.

That said, I think a change to this line should be reverted unless you can demonstrate that the old behavior leads to an incorrect result.

var typeMap2 = new TypeMap(typeParameters2, indexedTypeParameters, allowAlpha: true);

// Report any mismatched method constraints.
var typeComparison = TypeCompareKind.IgnoreDynamicAndTupleNames | TypeCompareKind.IgnoreNullableModifiersForReferenceTypes;
Copy link
Contributor

@AlekseyTs AlekseyTs Aug 7, 2025

Choose a reason for hiding this comment

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

var

const? #Closed

var desiredTypeMap = new TypeMap(desiredTypeParameters, indexedTypeParameters, allowAlpha: true);

return MemberSignatureComparer.HaveSameConstraints(candidateTypeParameters, candidateTypeMap, desiredTypeParameters, desiredTypeMap);
var typeComparison = TypeCompareKind.IgnoreDynamicAndTupleNames | TypeCompareKind.IgnoreNullableModifiersForReferenceTypes;
Copy link
Contributor

@AlekseyTs AlekseyTs Aug 7, 2025

Choose a reason for hiding this comment

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

var

const? #Closed

@AlekseyTs
Copy link
Contributor

AlekseyTs commented Aug 7, 2025

Done with review pass (commit 10), tests are not reviewed #Closed

@jcouv jcouv requested a review from AlekseyTs August 7, 2025 06:11
{
if (x.Kind == TypedConstantKind.Type && y.Kind == TypedConstantKind.Type)
{
return x.ValueInternal is ISymbolInternal xType && y.ValueInternal is ISymbolInternal yType && xType.Equals(yType, TypeCompareKind.AllIgnoreOptions);
Copy link
Contributor

@AlekseyTs AlekseyTs Aug 7, 2025

Choose a reason for hiding this comment

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

AllIgnoreOptions

I do not remember whether we should worry about custom modifiers here (modopt/modreq). They are probably not supported in the attribute encoding, but it would be good to confirm. #Closed

Copy link
Member Author

Choose a reason for hiding this comment

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

Looked at TypeNameDecoder (the decoding counterpart to TypeNameSerializer) and didn't see any handle for modifiers. Thanks

}

public static bool HaveSameNullabilityInConstraints(TypeParameterSymbol typeParameter1, TypeMap typeMap1, TypeParameterSymbol typeParameter2, TypeMap typeMap2)
public static bool HaveSameNullabilityInConstraints(TypeParameterSymbol typeParameter1, TypeMap? typeMap1, TypeParameterSymbol typeParameter2, TypeMap? typeMap2, TypeCompareKind typeComparison)
Copy link
Contributor

@AlekseyTs AlekseyTs Aug 7, 2025

Choose a reason for hiding this comment

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

TypeCompareKind typeComparison

Looking at this method more, I realized that it is probably not appropriate to let user control the comparison type used by this method because the supplied value might not be suitable for the task that the method is supposed to perform. I suggest reverting changes to this method and not using it for the purpose of this PR. Instead the top level nullability comparison can be done explicitly in HaveSameCSharpSignature helper. It is already doing the other checks by calling HaveSameConstraints. #Closed

@AlekseyTs
Copy link
Contributor

AlekseyTs commented Aug 7, 2025

Done with review pass (commit 11), tests are not reviewed #Closed

Copy link
Contributor

@AlekseyTs AlekseyTs left a comment

Choose a reason for hiding this comment

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

LGTM (commit 13). I didn't do a thorough review of tests, relying on Fred's sign-off in this regard.

@jcouv jcouv merged commit 3e5a856 into dotnet:features/extensions Aug 7, 2025
28 checks passed
@jcouv jcouv deleted the extensions-collisions branch August 7, 2025 23:50
TypeMap? typeMap1 = MemberSignatureComparer.GetTypeMap(extension1);
TypeMap? typeMap2 = MemberSignatureComparer.GetTypeMap(extension2);
if (extension1.Arity > 0
&& !MemberSignatureComparer.HaveSameConstraints(extension1.TypeParameters, typeMap1, extension2.TypeParameters, typeMap2, TypeCompareKind.AllIgnoreOptions))
Copy link
Contributor

Choose a reason for hiding this comment

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

AllIgnoreOptions

CLRSignatureCompareOptions


if (!MemberSignatureComparer.HaveSameParameterType(parameter1, typeMap1, parameter2, typeMap2,
refKindCompareMode: MemberSignatureComparer.RefKindCompareMode.IgnoreRefKind,
considerDefaultValues: false, TypeCompareKind.AllIgnoreOptions))
Copy link
Contributor

@AlekseyTs AlekseyTs Aug 8, 2025

Choose a reason for hiding this comment

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

AllIgnoreOptions

CLRSignatureCompareOptions

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area-Compilers Feature - Extension Everything The extension everything feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants