From c901928e29464403dd9cd83efc8a550bc036cafd Mon Sep 17 00:00:00 2001 From: Dominique Schuppli Date: Fri, 2 Jan 2026 00:00:47 +0100 Subject: [PATCH 1/4] Add failing tests for `record class` hooks --- ...xyTypeCachingWithDifferentHooksTestCase.cs | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/src/Castle.Core.Tests/DynamicProxy.Tests/ProxyTypeCachingWithDifferentHooksTestCase.cs b/src/Castle.Core.Tests/DynamicProxy.Tests/ProxyTypeCachingWithDifferentHooksTestCase.cs index 5709acdb68..0acc06177a 100644 --- a/src/Castle.Core.Tests/DynamicProxy.Tests/ProxyTypeCachingWithDifferentHooksTestCase.cs +++ b/src/Castle.Core.Tests/DynamicProxy.Tests/ProxyTypeCachingWithDifferentHooksTestCase.cs @@ -15,6 +15,7 @@ namespace Castle.DynamicProxy.Tests { using System; + using System.Reflection; using Castle.DynamicProxy.Tests.Interceptors; using Castle.DynamicProxy.Tests.Interfaces; @@ -29,6 +30,23 @@ public class ProxyTypeCachingWithDifferentHooksTestCase : BasePEVerifyTestCase #endif public class CustomHook : AllMethodsHook { } +#if FEATURE_SERIALIZATION + [Serializable] +#endif + public record class RecordClassHook : IProxyGenerationHook + { + public RecordClassHook(string id) + { + Id = id; + } + + public string Id { get; } + + public void MethodsInspected() { } + public void NonProxyableMemberNotification(Type type, MemberInfo memberInfo) { } + public bool ShouldInterceptMethod(Type type, MethodInfo methodInfo) => false; + } + [Test] public void Proxies_with_same_hook_will_use_cached_proxy_type() { @@ -45,9 +63,42 @@ public void Proxies_with_different_hooks_will_use_different_proxy_types() Assert.AreNotEqual(first.GetType(), second.GetType()); } + [Test] + public void Proxies_with_different_but_equal_record_class_hooks_will_use_cached_proxy_type() + { + var firstHook = new RecordClassHook("1"); + var secondHook = new RecordClassHook("1"); + Assume.That(firstHook, Is.Not.SameAs(secondHook)); + Assume.That(firstHook, Is.EqualTo(secondHook)); + + var first = CreateProxyWithHook(firstHook); + var second = CreateProxyWithHook(secondHook); + + Assert.AreEqual(first.GetType(), second.GetType()); + } + + [Test] + public void Proxies_with_different_and_unequal_record_class_hooks_will_use_different_proxy_types() + { + var firstHook = new RecordClassHook("1"); + var secondHook = new RecordClassHook("2"); + Assume.That(firstHook, Is.Not.SameAs(secondHook)); + Assume.That(firstHook, Is.Not.EqualTo(secondHook)); + + var first = CreateProxyWithHook(firstHook); + var second = CreateProxyWithHook(secondHook); + + Assert.AreNotEqual(first.GetType(), second.GetType()); + } + private object CreateProxyWithHook() where THook : IProxyGenerationHook, new() { - return generator.CreateInterfaceProxyWithoutTarget(typeof(IEmpty), new ProxyGenerationOptions(new THook()), new DoNothingInterceptor()); + return CreateProxyWithHook(new THook()); + } + + private object CreateProxyWithHook(IProxyGenerationHook hook) + { + return generator.CreateInterfaceProxyWithoutTarget(typeof(IEmpty), new ProxyGenerationOptions(hook), new DoNothingInterceptor()); } } } \ No newline at end of file From 55e37f4dc631670ee47ab2ec5392d574bcb41cbe Mon Sep 17 00:00:00 2001 From: Dominique Schuppli Date: Fri, 2 Jan 2026 00:36:36 +0100 Subject: [PATCH 2/4] Add another failing test for `IEquatable<>` hooks --- ...xyTypeCachingWithDifferentHooksTestCase.cs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/Castle.Core.Tests/DynamicProxy.Tests/ProxyTypeCachingWithDifferentHooksTestCase.cs b/src/Castle.Core.Tests/DynamicProxy.Tests/ProxyTypeCachingWithDifferentHooksTestCase.cs index 0acc06177a..e12f30a977 100644 --- a/src/Castle.Core.Tests/DynamicProxy.Tests/ProxyTypeCachingWithDifferentHooksTestCase.cs +++ b/src/Castle.Core.Tests/DynamicProxy.Tests/ProxyTypeCachingWithDifferentHooksTestCase.cs @@ -30,6 +30,21 @@ public class ProxyTypeCachingWithDifferentHooksTestCase : BasePEVerifyTestCase #endif public class CustomHook : AllMethodsHook { } +#if FEATURE_SERIALIZATION + [Serializable] +#endif + public class EquatableHook : IProxyGenerationHook, IEquatable + { + public override bool Equals(object obj) => Equals(obj as EquatableHook); + public override int GetHashCode() => GetType().GetHashCode(); + + public bool Equals(EquatableHook other) => other is EquatableHook; + + public void MethodsInspected() { } + public void NonProxyableMemberNotification(Type type, MemberInfo memberInfo) { } + public bool ShouldInterceptMethod(Type type, MethodInfo methodInfo) => false; + } + #if FEATURE_SERIALIZATION [Serializable] #endif @@ -47,6 +62,15 @@ public void NonProxyableMemberNotification(Type type, MemberInfo memberInfo) { } public bool ShouldInterceptMethod(Type type, MethodInfo methodInfo) => false; } + // `IEquatable<>` can lead to there being more than one `Equals` method. + // This is relevant for hook classes because DynamicProxy checks whether they + // override the inherited `Equals` method for proper equality semantics. + [Test] + public void Can_use_hook_that_implements_IEquatable() + { + _ = CreateProxyWithHook(); + } + [Test] public void Proxies_with_same_hook_will_use_cached_proxy_type() { From ff7b97b8b7a3f1a195b8f39e46173706ae119f2d Mon Sep 17 00:00:00 2001 From: Dominique Schuppli Date: Fri, 2 Jan 2026 00:03:52 +0100 Subject: [PATCH 3/4] Fix `Equals` and `GetHashCode` override check logic --- src/Castle.Core/DynamicProxy/Generators/BaseProxyGenerator.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Castle.Core/DynamicProxy/Generators/BaseProxyGenerator.cs b/src/Castle.Core/DynamicProxy/Generators/BaseProxyGenerator.cs index ec1a150ac6..1ca96a2d22 100644 --- a/src/Castle.Core/DynamicProxy/Generators/BaseProxyGenerator.cs +++ b/src/Castle.Core/DynamicProxy/Generators/BaseProxyGenerator.cs @@ -393,7 +393,7 @@ protected void InitializeStaticFields(Type builtType) private bool OverridesEqualsAndGetHashCode(Type type) { - var equalsMethod = type.GetMethod("Equals", BindingFlags.Public | BindingFlags.Instance); + var equalsMethod = type.GetMethod("Equals", BindingFlags.Public | BindingFlags.Instance, null, [ typeof(object) ], null); if (equalsMethod == null || equalsMethod.DeclaringType == typeof(object) || equalsMethod.IsAbstract) { return false; From 5f32f3435573ff87eb4bfbbcc994c9111e983830 Mon Sep 17 00:00:00 2001 From: Dominique Schuppli Date: Fri, 2 Jan 2026 00:05:55 +0100 Subject: [PATCH 4/4] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bd51636c16..2be2b6c032 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ Bugfixes: - `InvalidProgramException` when proxying `MemoryStream` with .NET 7 (@stakx, #651) - `invocation.MethodInvocationTarget` throws `ArgumentNullException` for default interface method (@stakx, #684) - `DynamicProxyException` ("duplicate element") when type to proxy contains members whose names differ only in case (@stakx, #691) +- `AmbiguousMatchException` when using a proxy generation hook that is implemented as a `record class` (@stakx, #720) ## 5.2.1 (2025-03-09)