From 0f64ea56c614eaca4e329b4085262f972c5cb9af Mon Sep 17 00:00:00 2001 From: Dominique Schuppli Date: Thu, 25 Dec 2025 17:47:31 +0100 Subject: [PATCH 1/5] Specify cloning behavior for proxied record types 1. Cloning a record proxy should yield another proxy of the same type. 2. User code should be able to intercept the clone method. 3. "Proceeding to target" for an intercepted clone method results in the default cloning behavior described in (1). --- .../DynamicProxy.Tests/RecordsTestCase.cs | 72 +++++++++++++++++-- 1 file changed, 67 insertions(+), 5 deletions(-) diff --git a/src/Castle.Core.Tests/DynamicProxy.Tests/RecordsTestCase.cs b/src/Castle.Core.Tests/DynamicProxy.Tests/RecordsTestCase.cs index 77302a88e1..c2d817471f 100644 --- a/src/Castle.Core.Tests/DynamicProxy.Tests/RecordsTestCase.cs +++ b/src/Castle.Core.Tests/DynamicProxy.Tests/RecordsTestCase.cs @@ -1,4 +1,4 @@ -// Copyright 2004-2022 Castle Project - http://www.castleproject.org/ +// Copyright 2004-2026 Castle Project - http://www.castleproject.org/ // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -12,12 +12,16 @@ // See the License for the specific language governing permissions and // limitations under the License. -using Castle.DynamicProxy.Tests.Records; - -using NUnit.Framework; - namespace Castle.DynamicProxy.Tests { + using System; + using System.Reflection; + + using Castle.DynamicProxy.Tests.Interceptors; + using Castle.DynamicProxy.Tests.Records; + + using NUnit.Framework; + [TestFixture] public class RecordsTestCase : BasePEVerifyTestCase { @@ -44,5 +48,63 @@ public void Can_proxy_record_derived_from_empty_generic_record() { _ = generator.CreateClassProxy(new StandardInterceptor()); } + + [Test] + public void Cloning_proxied_record_preserves_proxy_type() + { + var proxy = generator.CreateClassProxy(); + var clonedProxy = proxy with { }; + Assert.True(ProxyUtil.IsProxy(clonedProxy)); + Assert.AreSame(proxy.GetType(), clonedProxy.GetType()); + } + + [Test] + public void Cloning_proxied_record_preserves_proxy_type_even_when_not_intercepting() + { + var proxy = generator.CreateClassProxy(new ProxyGenerationOptions(new InterceptNothingHook())); + var clonedProxy = proxy with { }; + Assert.True(ProxyUtil.IsProxy(clonedProxy)); + Assert.AreSame(proxy.GetType(), clonedProxy.GetType()); + } + + [Test] + public void Can_intercept_clone_method() + { + var expectedClone = new DerivedFromEmptyRecord(); + var interceptor = new WithCallbackInterceptor(invocation => + { + if (invocation.Method.Name == "$") + { + invocation.ReturnValue = expectedClone; + } + }); + var proxy = generator.CreateClassProxy(interceptor); + var actualClone = proxy with { }; + Assert.AreSame(expectedClone, actualClone); + } + + [Test] + public void Can_proceed_for_intercepted_clone_method() + { + var interceptor = new WithCallbackInterceptor(invocation => + { + if (invocation.Method.Name == "$") + { + invocation.Proceed(); + } + }); + var proxy = generator.CreateClassProxy(interceptor); + var clonedProxy = proxy with { }; + Assert.AreSame(proxy.GetType(), clonedProxy.GetType()); + } + + [Serializable] + public record class InterceptNothingHook : IProxyGenerationHook + { + public bool ShouldInterceptMethod(Type type, MethodInfo methodInfo) => false; + + void IProxyGenerationHook.MethodsInspected() { } + void IProxyGenerationHook.NonProxyableMemberNotification(Type type, MemberInfo memberInfo) { } + } } } From b678c0f2554861ad45aa3a3f3b886c3b939058d3 Mon Sep 17 00:00:00 2001 From: Dominique Schuppli Date: Thu, 25 Dec 2025 18:00:53 +0100 Subject: [PATCH 2/5] Synthesize `$` method --- .../Contributors/RecordCloningContributor.cs | 160 ++++++++++++++++++ .../Generators/BaseClassProxyGenerator.cs | 4 +- .../Emitters/SimpleAST/FieldReference.cs | 24 +-- 3 files changed, 176 insertions(+), 12 deletions(-) create mode 100644 src/Castle.Core/DynamicProxy/Contributors/RecordCloningContributor.cs diff --git a/src/Castle.Core/DynamicProxy/Contributors/RecordCloningContributor.cs b/src/Castle.Core/DynamicProxy/Contributors/RecordCloningContributor.cs new file mode 100644 index 0000000000..ae831e4921 --- /dev/null +++ b/src/Castle.Core/DynamicProxy/Contributors/RecordCloningContributor.cs @@ -0,0 +1,160 @@ +// Copyright 2004-2026 Castle Project - http://www.castleproject.org/ +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#nullable enable + +namespace Castle.DynamicProxy.Contributors +{ + using System; + using System.Reflection; + + using Castle.DynamicProxy.Generators; + using Castle.DynamicProxy.Generators.Emitters; + using Castle.DynamicProxy.Generators.Emitters.SimpleAST; + + internal sealed class RecordCloningContributor : ITypeContributor + { + private readonly Type targetType; + private readonly INamingScope namingScope; + + private MethodInfo? baseCloneMethod; + private bool overridable; + private bool shouldIntercept; + + public RecordCloningContributor(Type targetType, INamingScope namingScope) + { + this.targetType = targetType; + this.namingScope = namingScope; + } + + public void CollectElementsToProxy(IProxyGenerationHook hook, MetaType model) + { + baseCloneMethod = targetType.GetMethod("$", BindingFlags.Public | BindingFlags.Instance); + if (baseCloneMethod == null) + { + return; + } + + var cloneMetaMethod = model.FindMethod(baseCloneMethod); + if (cloneMetaMethod != null) + { + // The target contributor may have chosen to generate interception code for this method. + // We override that decision here. This effectively renders `$` uninterceptable, + // in favor of some default behavior provided by DynamicProxy. This may be a bad idea. + cloneMetaMethod.Ignore = true; + } + + overridable = baseCloneMethod.IsVirtual && !baseCloneMethod.IsFinal; + shouldIntercept = overridable && hook.ShouldInterceptMethod(targetType, baseCloneMethod); + } + + public void Generate(ClassEmitter @class) + { + if (baseCloneMethod == null) return; + + ImplementCopyConstructor(@class, out var copyCtor); + ImplementCloneMethod(@class, copyCtor); + } + + private void ImplementCopyConstructor(ClassEmitter @class, out ConstructorInfo copyCtor) + { + var other = new ArgumentReference(@class.TypeBuilder); + var copyCtorEmitter = @class.CreateConstructor(other); + var baseCopyCtor = targetType.GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, null, [targetType], null); + + copyCtorEmitter.CodeBuilder.AddStatement( + new ConstructorInvocationStatement( + baseCopyCtor, + other)); + + foreach (var field in @class.GetAllFields()) + { + if (field.Reference.IsStatic) continue; + + copyCtorEmitter.CodeBuilder.AddStatement( + new AssignStatement( + field, + new FieldReference( + field.Reference, + other))); + } + + copyCtorEmitter.CodeBuilder.AddStatement(ReturnStatement.Instance); + + copyCtor = copyCtorEmitter.ConstructorBuilder; + } + + private void ImplementCloneMethod(ClassEmitter @class, ConstructorInfo copyCtor) + { + if (shouldIntercept) + { + var cloneCallbackMethod = CreateCallbackMethod(@class, copyCtor); + var cloneMetaMethod = new MetaMethod(baseCloneMethod!, cloneCallbackMethod, true, true, true); + var invocationType = CreateInvocationType(@class, cloneMetaMethod, cloneCallbackMethod); + + var cloneMethodGenerator = new MethodWithInvocationGenerator( + cloneMetaMethod, + @class.GetField("__interceptors"), + invocationType, + (c, m) => new TypeTokenExpression(@class.TypeBuilder), + @class.CreateMethod, + null); + + cloneMethodGenerator.Generate(@class, namingScope); + } + else if (overridable) + { + var cloneMethodEmitter = @class.CreateMethod( + name: baseCloneMethod!.Name, + attrs: (baseCloneMethod.Attributes & MethodAttributes.MemberAccessMask) | MethodAttributes.ReuseSlot | MethodAttributes.HideBySig | MethodAttributes.Virtual, + returnType: baseCloneMethod.ReturnType, // no need to use covariant return type + argumentTypes: []); + + cloneMethodEmitter.CodeBuilder.AddStatement( + new ReturnStatement( + new NewInstanceExpression( + copyCtor, + ThisExpression.Instance))); + } + } + + private MethodInfo CreateCallbackMethod(ClassEmitter @class, ConstructorInfo copyCtor) + { + var callbackMethod = @class.CreateMethod( + name: baseCloneMethod!.Name + "_callback", + attrs: MethodAttributes.Public | MethodAttributes.HideBySig, + returnType: copyCtor.DeclaringType, + argumentTypes: Type.EmptyTypes); + + callbackMethod.CodeBuilder.AddStatement( + new ReturnStatement( + new NewInstanceExpression( + copyCtor, + ThisExpression.Instance))); + + return callbackMethod.MethodBuilder; + } + + private Type CreateInvocationType(ClassEmitter @class, MetaMethod cloneMetaMethod, MethodInfo cloneCallbackMethod) + { + var generator = new InheritanceInvocationTypeGenerator( + targetType, + cloneMetaMethod, + cloneCallbackMethod, + null); + + return generator.Generate(@class, namingScope).BuildType(); + } + } +} diff --git a/src/Castle.Core/DynamicProxy/Generators/BaseClassProxyGenerator.cs b/src/Castle.Core/DynamicProxy/Generators/BaseClassProxyGenerator.cs index dc43594896..6909228076 100644 --- a/src/Castle.Core/DynamicProxy/Generators/BaseClassProxyGenerator.cs +++ b/src/Castle.Core/DynamicProxy/Generators/BaseClassProxyGenerator.cs @@ -1,4 +1,4 @@ -// Copyright 2004-2021 Castle Project - http://www.castleproject.org/ +// Copyright 2004-2026 Castle Project - http://www.castleproject.org/ // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -192,6 +192,8 @@ private IEnumerable GetTypeImplementerMapping(out IEnumerable Date: Wed, 4 Feb 2026 23:01:06 +0100 Subject: [PATCH 3/5] Add tests for cloning record proxies with target --- .../Compatibility/IsExternalInit.cs | 25 +++++++++++++++++++ .../Records/IdentifiableRecord.cs | 23 +++++++++++++++++ .../DynamicProxy.Tests/RecordsTestCase.cs | 22 ++++++++++++++++ 3 files changed, 70 insertions(+) create mode 100644 src/Castle.Core.Tests/Compatibility/IsExternalInit.cs create mode 100644 src/Castle.Core.Tests/DynamicProxy.Tests/Records/IdentifiableRecord.cs diff --git a/src/Castle.Core.Tests/Compatibility/IsExternalInit.cs b/src/Castle.Core.Tests/Compatibility/IsExternalInit.cs new file mode 100644 index 0000000000..98d422985a --- /dev/null +++ b/src/Castle.Core.Tests/Compatibility/IsExternalInit.cs @@ -0,0 +1,25 @@ +// Copyright 2004-2026 Castle Project - http://www.castleproject.org/ +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#if !NET5_0_OR_GREATER + +namespace System.Runtime.CompilerServices +{ + // required for records with primary constructors and/or `init` property accessors: + internal class IsExternalInit + { + } +} + +#endif diff --git a/src/Castle.Core.Tests/DynamicProxy.Tests/Records/IdentifiableRecord.cs b/src/Castle.Core.Tests/DynamicProxy.Tests/Records/IdentifiableRecord.cs new file mode 100644 index 0000000000..86fb1f1469 --- /dev/null +++ b/src/Castle.Core.Tests/DynamicProxy.Tests/Records/IdentifiableRecord.cs @@ -0,0 +1,23 @@ +// Copyright 2004-2026 Castle Project - http://www.castleproject.org/ +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#nullable enable + +namespace Castle.DynamicProxy.Tests.Records +{ + internal record class IdentifiableRecord + { + public string? Id { get; init; } + } +} diff --git a/src/Castle.Core.Tests/DynamicProxy.Tests/RecordsTestCase.cs b/src/Castle.Core.Tests/DynamicProxy.Tests/RecordsTestCase.cs index c2d817471f..5e13e1e837 100644 --- a/src/Castle.Core.Tests/DynamicProxy.Tests/RecordsTestCase.cs +++ b/src/Castle.Core.Tests/DynamicProxy.Tests/RecordsTestCase.cs @@ -98,6 +98,28 @@ public void Can_proceed_for_intercepted_clone_method() Assert.AreSame(proxy.GetType(), clonedProxy.GetType()); } + [Test] + public void Can_proceed_to_record_target() + { + var target = new IdentifiableRecord { Id = "target" }; + var proxy = generator.CreateClassProxyWithTarget(target, new StandardInterceptor()); + var clonedProxy = proxy with { }; + Assert.False(clonedProxy == proxy); + Assert.False(ProxyUtil.IsProxy(clonedProxy)); + Assert.True(clonedProxy == target); + } + + [Test] + public void Can_proceed_to_record_target_proxy() + { + var target = generator.CreateClassProxy() with { Id = "target" }; + var proxy = generator.CreateClassProxyWithTarget(target, new StandardInterceptor()); + var clonedProxy = proxy with { }; + Assert.False(clonedProxy == proxy); + Assert.True(ProxyUtil.IsProxy(clonedProxy)); + Assert.True(clonedProxy == target); + } + [Serializable] public record class InterceptNothingHook : IProxyGenerationHook { From a20058f0ba179d54e8a08bb57590731d6ae3c67a Mon Sep 17 00:00:00 2001 From: Dominique Schuppli Date: Wed, 4 Feb 2026 23:01:28 +0100 Subject: [PATCH 4/5] Only synthesize `$` for record proxies w/o target --- .../Generators/BaseClassProxyGenerator.cs | 14 +++++++++++--- .../Generators/ClassProxyGenerator.cs | 13 ++++++++++--- .../Generators/ClassProxyWithTargetGenerator.cs | 15 +++++++++++---- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/Castle.Core/DynamicProxy/Generators/BaseClassProxyGenerator.cs b/src/Castle.Core/DynamicProxy/Generators/BaseClassProxyGenerator.cs index 6909228076..0dbb858f83 100644 --- a/src/Castle.Core/DynamicProxy/Generators/BaseClassProxyGenerator.cs +++ b/src/Castle.Core/DynamicProxy/Generators/BaseClassProxyGenerator.cs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +#nullable enable + namespace Castle.DynamicProxy.Generators { using System; @@ -25,13 +27,13 @@ namespace Castle.DynamicProxy.Generators internal abstract class BaseClassProxyGenerator : BaseProxyGenerator { - protected BaseClassProxyGenerator(ModuleScope scope, Type targetType, Type[] interfaces, ProxyGenerationOptions options) + protected BaseClassProxyGenerator(ModuleScope scope, Type targetType, Type[]? interfaces, ProxyGenerationOptions options) : base(scope, targetType, interfaces, options) { EnsureDoesNotImplementIProxyTargetAccessor(targetType, nameof(targetType)); } - protected abstract FieldReference TargetField { get; } + protected abstract FieldReference? TargetField { get; } #if FEATURE_SERIALIZATION protected abstract SerializableContributor GetSerializableContributor(); @@ -41,6 +43,8 @@ protected BaseClassProxyGenerator(ModuleScope scope, Type targetType, Type[] int protected abstract ProxyTargetAccessorContributor GetProxyTargetAccessorContributor(); + protected abstract RecordCloningContributor? GetRecordCloningContributor(INamingScope namingScope); + protected sealed override Type GenerateType(string name, INamingScope namingScope) { IEnumerable contributors; @@ -192,7 +196,11 @@ private IEnumerable GetTypeImplementerMapping(out IEnumerable null; + protected override FieldReference? TargetField => null; protected override CacheKey GetCacheKey() { @@ -54,5 +56,10 @@ protected override ProxyTargetAccessorContributor GetProxyTargetAccessorContribu getTarget: () => ThisExpression.Instance, targetType); } + + protected override RecordCloningContributor? GetRecordCloningContributor(INamingScope namingScope) + { + return new RecordCloningContributor(targetType, namingScope); + } } } diff --git a/src/Castle.Core/DynamicProxy/Generators/ClassProxyWithTargetGenerator.cs b/src/Castle.Core/DynamicProxy/Generators/ClassProxyWithTargetGenerator.cs index bf8299d64a..006d127833 100644 --- a/src/Castle.Core/DynamicProxy/Generators/ClassProxyWithTargetGenerator.cs +++ b/src/Castle.Core/DynamicProxy/Generators/ClassProxyWithTargetGenerator.cs @@ -1,4 +1,4 @@ -// Copyright 2004-2025 Castle Project - http://www.castleproject.org/ +// Copyright 2004-2026 Castle Project - http://www.castleproject.org/ // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +#nullable enable + namespace Castle.DynamicProxy.Generators { using System; @@ -29,15 +31,15 @@ namespace Castle.DynamicProxy.Generators internal sealed class ClassProxyWithTargetGenerator : BaseClassProxyGenerator { - private FieldReference targetField; + private FieldReference? targetField; - public ClassProxyWithTargetGenerator(ModuleScope scope, Type targetType, Type[] interfaces, + public ClassProxyWithTargetGenerator(ModuleScope scope, Type targetType, Type[]? interfaces, ProxyGenerationOptions options) : base(scope, targetType, interfaces, options) { } - protected override FieldReference TargetField => targetField; + protected override FieldReference? TargetField => targetField; protected override CacheKey GetCacheKey() { @@ -69,6 +71,11 @@ protected override ProxyTargetAccessorContributor GetProxyTargetAccessorContribu targetType); } + protected override RecordCloningContributor? GetRecordCloningContributor(INamingScope namingScope) + { + return null; + } + private void CreateTargetField(ClassEmitter emitter) { targetField = emitter.CreateField("__target", targetType); From c512079b5042bf4376066cd665f08084ecce4da9 Mon Sep 17 00:00:00 2001 From: Dominique Schuppli Date: Sun, 1 Feb 2026 20:32:18 +0100 Subject: [PATCH 5/5] Update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5796e2b20..736cdc7763 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ Enhancements: - Minimally improved support for methods having `ref struct` parameter and return types, such as `Span`: Intercepting such methods caused the runtime to throw `InvalidProgramException` and `NullReferenceException` due to forbidden conversions of `ref struct` values when transferring them into & out of `IInvocation` instances. To prevent these exceptions from being thrown, such values now get replaced with `null` in `IInvocation`, and with `default` values in return values and `out` arguments. When proceeding to a target, the target methods likewise receive such nullified values. (@stakx, #665) - Restore ability on .NET 9 and later to save dynamic assemblies to disk using `PersistentProxyBuilder` (@stakx, #718) - Configure SourceLink & `.snupkg` symbols package format (@Romfos, #722) +- Support for C# `with { ... }` expressions. Cloning a record proxy using `with` now produces another proxy of the same type (instead of an instance of the proxied type, as before). The cloning process can still be changed by intercepting the record class' `$` method. (@stakx, #733) - Dependencies were updated Bugfixes: