From bdb5962af3c915fb417cc3d84d2ebf09f3b316e7 Mon Sep 17 00:00:00 2001 From: Dominique Schuppli Date: Fri, 19 Dec 2025 00:44:23 +0100 Subject: [PATCH 1/7] Specify that byref-like args should be preserved ... in `IInvocation.Arguments` using a `ByRefLikeArgument` wrapper type and some unmanaged pointer magic. --- buildscripts/common.props | 1 + .../DynamicProxy.Tests/ByRefLikeTestCase.cs | 72 ++++++++++++++++--- .../DynamicProxy/ByRefLikeArgument.cs | 54 ++++++++++++++ 3 files changed, 119 insertions(+), 8 deletions(-) create mode 100644 src/Castle.Core/DynamicProxy/ByRefLikeArgument.cs diff --git a/buildscripts/common.props b/buildscripts/common.props index b2989c095..4e62669ce 100644 --- a/buildscripts/common.props +++ b/buildscripts/common.props @@ -4,6 +4,7 @@ 14.0 $(NoWarn);CS1591;CS3014;CS3003;CS3001;CS3021 $(NoWarn);CS0612;CS0618 + true git https://github.com/castleproject/Core 0.0.0 diff --git a/src/Castle.Core.Tests/DynamicProxy.Tests/ByRefLikeTestCase.cs b/src/Castle.Core.Tests/DynamicProxy.Tests/ByRefLikeTestCase.cs index 0c1a1195e..1304da686 100644 --- a/src/Castle.Core.Tests/DynamicProxy.Tests/ByRefLikeTestCase.cs +++ b/src/Castle.Core.Tests/DynamicProxy.Tests/ByRefLikeTestCase.cs @@ -15,6 +15,7 @@ #if FEATURE_BYREFLIKE #nullable enable +#pragma warning disable CS8500 namespace Castle.DynamicProxy.Tests { @@ -198,46 +199,99 @@ public virtual ByRefLike Method() #region What values do interceptors see for by-ref-like arguments? [Test] - public void By_ref_like_arguments_are_replaced_with_null_in_invocation() + public void By_ref_like_arguments_are_wrapped_as_ByRefLikeArgument_in_invocation() { var interceptor = new ObservingInterceptor(); var proxy = generator.CreateClassProxy(interceptor); var arg = "original".AsSpan(); proxy.Method(arg); - Assert.IsNull(interceptor.ObservedArg); + Assert.IsInstanceOf(interceptor.ObservedArg); } [Test] - public void By_ref_like_in_arguments_are_replaced_with_null_in_invocation() + public unsafe void By_ref_like_arguments_can_be_restored_from_ByRefLikeArgument_GetPointer_in_invocation() + { + var interceptor = new ObservingInterceptor(); + var proxy = generator.CreateClassProxy(interceptor); + var arg = "original".AsSpan(); + proxy.Method(arg); + Assume.That(interceptor.ObservedArg is ByRefLikeArgument); + var wrappedArg = (ByRefLikeArgument)interceptor.ObservedArg!; + var unwrappedArg = *(ReadOnlySpan*)wrappedArg.GetPointer(); + Assert.AreEqual("original", unwrappedArg.ToString()); + } + + [Test] + public void By_ref_like_in_arguments_are_wrapped_as_ByRefLikeArgument_in_invocation() { var interceptor = new ObservingInterceptor(); var proxy = generator.CreateClassProxy(interceptor); var arg = "original".AsSpan(); proxy.Method(in arg); - Assert.IsNull(interceptor.ObservedArg); + Assert.IsInstanceOf(interceptor.ObservedArg); } [Test] - public void By_ref_like_ref_arguments_are_replaced_with_null_in_invocation() + public unsafe void By_ref_like_in_arguments_can_be_restored_from_ByRefLikeArgument_GetPointer_in_invocation() + { + var interceptor = new ObservingInterceptor(); + var proxy = generator.CreateClassProxy(interceptor); + var arg = "original".AsSpan(); + proxy.Method(in arg); + Assume.That(interceptor.ObservedArg is ByRefLikeArgument); + var wrappedArg = (ByRefLikeArgument)interceptor.ObservedArg!; + var unwrappedArg = *(ReadOnlySpan*)wrappedArg.GetPointer(); + Assert.AreEqual("original", unwrappedArg.ToString()); + } + + [Test] + public void By_ref_like_ref_arguments_are_wrapped_as_ByRefLikeArgument_in_invocation() { var interceptor = new ObservingInterceptor(); var proxy = generator.CreateClassProxy(interceptor); var arg = "original".AsSpan(); proxy.Method(ref arg); - Assert.IsNull(interceptor.ObservedArg); + Assert.IsInstanceOf(interceptor.ObservedArg); + } + + [Test] + public unsafe void By_ref_like_ref_arguments_can_be_restored_from_ByRefLikeArgument_GetPointer_in_invocation() + { + var interceptor = new ObservingInterceptor(); + var proxy = generator.CreateClassProxy(interceptor); + var arg = "original".AsSpan(); + proxy.Method(ref arg); + Assume.That(interceptor.ObservedArg is ByRefLikeArgument); + var wrappedArg = (ByRefLikeArgument)interceptor.ObservedArg!; + var unwrappedArg = *(ReadOnlySpan*)wrappedArg.GetPointer(); + Assert.AreEqual("original", unwrappedArg.ToString()); } // Note the somewhat weird semantics of this test: DynamicProxy allows you to read the incoming values // of `out` arguments, which would be illegal in plain C# ("use of unassigned out parameter"). // DynamicProxy does not distinguish between `ref` and `out` in this regard. [Test] - public void By_ref_like_out_arguments_are_replaced_with_null_in_invocation() + public void By_ref_like_out_arguments_are_wrapped_as_ByRefLikeArgument_in_invocation() + { + var interceptor = new ObservingInterceptor(); + var proxy = generator.CreateClassProxy(interceptor); + var arg = "original".AsSpan(); + proxy.Method(out arg); + Assert.IsInstanceOf(interceptor.ObservedArg); + } + + // as above + [Test] + public unsafe void By_ref_like_out_arguments_can_be_restored_from_ByRefLikeArgument_GetPointer_in_invocation() { var interceptor = new ObservingInterceptor(); var proxy = generator.CreateClassProxy(interceptor); var arg = "original".AsSpan(); proxy.Method(out arg); - Assert.IsNull(interceptor.ObservedArg); + Assume.That(interceptor.ObservedArg is ByRefLikeArgument); + var wrappedArg = (ByRefLikeArgument)interceptor.ObservedArg!; + var unwrappedArg = *(ReadOnlySpan*)wrappedArg.GetPointer(); + Assert.AreEqual("original", unwrappedArg.ToString()); } #endregion @@ -418,4 +472,6 @@ public void Intercept(IInvocation invocation) } } +#pragma warning restore CS8500 + #endif diff --git a/src/Castle.Core/DynamicProxy/ByRefLikeArgument.cs b/src/Castle.Core/DynamicProxy/ByRefLikeArgument.cs new file mode 100644 index 000000000..8e5cb3c14 --- /dev/null +++ b/src/Castle.Core/DynamicProxy/ByRefLikeArgument.cs @@ -0,0 +1,54 @@ +// Copyright 2004-2025 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 FEATURE_BYREFLIKE + +#nullable enable + +namespace Castle.DynamicProxy +{ + using System; + using System.ComponentModel; + + /// + /// Wraps a byref-like (ref struct) method argument + /// such that it can be placed in the array during interception. + /// + public unsafe class ByRefLikeArgument + { + protected void* ptr; + + /// + /// Do not use this! Only generated proxies should construct instances this type. + /// + [CLSCompliant(false)] + [EditorBrowsable(EditorBrowsableState.Never)] + public ByRefLikeArgument(void* ptr) + { + this.ptr = ptr; + } + + /// + /// Gets an unmanaged pointer to the byref-like (ref struct) argument. + /// + [CLSCompliant(false)] + [EditorBrowsable(EditorBrowsableState.Advanced)] + public void* GetPointer() + { + return ptr; + } + } +} + +#endif From 025510668489c29783fb60b336a82192e8792da8 Mon Sep 17 00:00:00 2001 From: Dominique Schuppli Date: Fri, 19 Dec 2025 00:46:42 +0100 Subject: [PATCH 2/7] Wrap byref-like args in `IInvocation.Arguments` ... as `ByRefLikeArgument` objects instead of nulling them out. --- .../DynamicProxy.Tests/ByRefLikeTestCase.cs | 3 +- .../Emitters/SimpleAST/ArgumentReference.cs | 7 ++++- .../ReferencesToObjectArrayExpression.cs | 11 ++++--- .../Tokens/ByRefLikeArgumentMethods.cs | 30 +++++++++++++++++++ 4 files changed, 43 insertions(+), 8 deletions(-) create mode 100644 src/Castle.Core/DynamicProxy/Tokens/ByRefLikeArgumentMethods.cs diff --git a/src/Castle.Core.Tests/DynamicProxy.Tests/ByRefLikeTestCase.cs b/src/Castle.Core.Tests/DynamicProxy.Tests/ByRefLikeTestCase.cs index 1304da686..2954b6ce1 100644 --- a/src/Castle.Core.Tests/DynamicProxy.Tests/ByRefLikeTestCase.cs +++ b/src/Castle.Core.Tests/DynamicProxy.Tests/ByRefLikeTestCase.cs @@ -280,8 +280,9 @@ public void By_ref_like_out_arguments_are_wrapped_as_ByRefLikeArgument_in_invoca Assert.IsInstanceOf(interceptor.ObservedArg); } - // as above + // Should theoretically be as above (read comment there), but isn't. To be revisited later! [Test] + [Ignore("Have not yet found out why byref-like `out` arguments are initially set to their default values unlike `ref` ones.")] public unsafe void By_ref_like_out_arguments_can_be_restored_from_ByRefLikeArgument_GetPointer_in_invocation() { var interceptor = new ObservingInterceptor(); diff --git a/src/Castle.Core/DynamicProxy/Generators/Emitters/SimpleAST/ArgumentReference.cs b/src/Castle.Core/DynamicProxy/Generators/Emitters/SimpleAST/ArgumentReference.cs index e4ab715a3..e6aea8c5f 100644 --- a/src/Castle.Core/DynamicProxy/Generators/Emitters/SimpleAST/ArgumentReference.cs +++ b/src/Castle.Core/DynamicProxy/Generators/Emitters/SimpleAST/ArgumentReference.cs @@ -39,7 +39,12 @@ public ArgumentReference(Type argumentType, int position) public override void EmitAddress(ILGenerator gen) { - throw new NotSupportedException(); + if (Position == -1) + { + throw new InvalidOperationException("ArgumentReference uninitialized"); + } + + gen.Emit(OpCodes.Ldarga_S, Position); } public override void Emit(ILGenerator gen) diff --git a/src/Castle.Core/DynamicProxy/Generators/Emitters/SimpleAST/ReferencesToObjectArrayExpression.cs b/src/Castle.Core/DynamicProxy/Generators/Emitters/SimpleAST/ReferencesToObjectArrayExpression.cs index 67cb7b714..169cf1a8e 100644 --- a/src/Castle.Core/DynamicProxy/Generators/Emitters/SimpleAST/ReferencesToObjectArrayExpression.cs +++ b/src/Castle.Core/DynamicProxy/Generators/Emitters/SimpleAST/ReferencesToObjectArrayExpression.cs @@ -21,6 +21,7 @@ namespace Castle.DynamicProxy.Generators.Emitters.SimpleAST using System.Reflection.Emit; using Castle.DynamicProxy.Internal; + using Castle.DynamicProxy.Tokens; internal class ReferencesToObjectArrayExpression : IExpression { @@ -49,13 +50,11 @@ public void Emit(ILGenerator gen) #if FEATURE_BYREFLIKE if (reference.Type.IsByRefLikeSafe()) { - // The by-ref-like argument value cannot be put into the `object[]` array, - // because it cannot be boxed. We need to replace it with some other value. - - // For now, we just erase it by substituting `null`: - gen.Emit(OpCodes.Ldnull); + // We cannot box a byref-like argument directly, so we wrap it as + // `BoxedByRefLikeArgument` instead (which references it using a pointer). + reference.EmitAddress(gen); + gen.Emit(OpCodes.Newobj, ByRefLikeArgumentMethods.Constructor); gen.Emit(OpCodes.Stelem_Ref); - continue; } #endif diff --git a/src/Castle.Core/DynamicProxy/Tokens/ByRefLikeArgumentMethods.cs b/src/Castle.Core/DynamicProxy/Tokens/ByRefLikeArgumentMethods.cs new file mode 100644 index 000000000..3ec003b7d --- /dev/null +++ b/src/Castle.Core/DynamicProxy/Tokens/ByRefLikeArgumentMethods.cs @@ -0,0 +1,30 @@ +// Copyright 2004-2025 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 FEATURE_BYREFLIKE + +#nullable enable + +namespace Castle.DynamicProxy.Tokens +{ + using System.Reflection; + + internal static class ByRefLikeArgumentMethods + { + public static readonly ConstructorInfo Constructor = + typeof(ByRefLikeArgument).GetConstructor(new[] { typeof(void*) })!; + } +} + +#endif From 7b4d4e3622fdbf4d4fd71ab221d38827623ffb87 Mon Sep 17 00:00:00 2001 From: Dominique Schuppli Date: Fri, 19 Dec 2025 01:28:45 +0100 Subject: [PATCH 3/7] Add .NET 9 TFM & more type-safe `ByRefLikeArgument<>` (.NET 9 is needed for that generic version of the wrapper type because older runtimes did not allow using `ref struct`s as generic type args.) --- README.md | 16 ++--- buildscripts/common.props | 4 ++ .../DynamicProxy.Tests/ByRefLikeTestCase.cs | 62 +++++++++++++++++++ src/Castle.Core/Castle.Core.csproj | 4 +- .../DynamicProxy/ByRefLikeArgument.cs | 31 ++++++++++ 5 files changed, 108 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 52b0b5006..ad0c30cd5 100644 --- a/README.md +++ b/README.md @@ -50,16 +50,18 @@ build.cmd The following conditional compilation symbols (vertical) are currently defined for each of the build configurations (horizontal): -Symbol | .NET 4.6.2 | .NET Standard 2.0 | .NET 8 ------------------------------------ | ------------------ | ----------------- | ------------------ -`FEATURE_APPDOMAIN` | :white_check_mark: | :no_entry_sign: | :no_entry_sign: -`FEATURE_ASSEMBLYBUILDER_SAVE` | :white_check_mark: | :no_entry_sign: | :no_entry_sign: -`FEATURE_BYREFLIKE` | :no_entry_sign: | :no_entry_sign: | :white_check_mark: -`FEATURE_SERIALIZATION` | :white_check_mark: | :no_entry_sign: | :no_entry_sign: -`FEATURE_SYSTEM_CONFIGURATION` | :white_check_mark: | :no_entry_sign: | :no_entry_sign: +Symbol | .NET 4.6.2 | .NET Standard 2.0 | .NET 8 | .NET 9 +------------------------------------------- | ------------------ | ----------------- | ------------------ | ------------ +`FEATURE_APPDOMAIN` | :white_check_mark: | :no_entry_sign: | :no_entry_sign: | :no_entry_sign: +`FEATURE_ASSEMBLYBUILDER_SAVE` | :white_check_mark: | :no_entry_sign: | :no_entry_sign: | :no_entry_sign: +`FEATURE_BYREFLIKE` | :no_entry_sign: | :no_entry_sign: | :white_check_mark: | :white_check_mark: +`FEATURE_ALLOWS_REF_STRUCT_ANTI_CONSTRAINT` | :no_entry_sign: | :no_entry_sign: | :no_entry_sign: | :white_check_mark: +`FEATURE_SERIALIZATION` | :white_check_mark: | :no_entry_sign: | :no_entry_sign: | :no_entry_sign: +`FEATURE_SYSTEM_CONFIGURATION` | :white_check_mark: | :no_entry_sign: | :no_entry_sign: | :no_entry_sign: * `FEATURE_APPDOMAIN` - enables support for features that make use of an AppDomain in the host. * `FEATURE_ASSEMBLYBUILDER_SAVE` - enabled support for saving the dynamically generated proxy assembly. +* `FEATURE_ALLOWS_REF_STRUCT_ANTI_CONSTRAINT` - enables support for by-ref-like (`ref struct`) types being used as generic type arguments. * `FEATURE_BYREFLIKE` - enables support for by-ref-like (`ref struct`) types such as `Span` and `ReadOnlySpan`. * `FEATURE_SERIALIZATION` - enables support for serialization of dynamic proxies and other types. * `FEATURE_SYSTEM_CONFIGURATION` - enables features that use System.Configuration and the ConfigurationManager. diff --git a/buildscripts/common.props b/buildscripts/common.props index 4e62669ce..4dd0647b4 100644 --- a/buildscripts/common.props +++ b/buildscripts/common.props @@ -71,6 +71,10 @@ $(DefineConstants);FEATURE_BYREFLIKE + + $(DefineConstants);FEATURE_ALLOWS_REF_STRUCT_ANTI_CONSTRAINT + + diff --git a/src/Castle.Core.Tests/DynamicProxy.Tests/ByRefLikeTestCase.cs b/src/Castle.Core.Tests/DynamicProxy.Tests/ByRefLikeTestCase.cs index 2954b6ce1..775d7a0f5 100644 --- a/src/Castle.Core.Tests/DynamicProxy.Tests/ByRefLikeTestCase.cs +++ b/src/Castle.Core.Tests/DynamicProxy.Tests/ByRefLikeTestCase.cs @@ -221,6 +221,21 @@ public unsafe void By_ref_like_arguments_can_be_restored_from_ByRefLikeArgument_ Assert.AreEqual("original", unwrappedArg.ToString()); } +#if FEATURE_ALLOWS_REF_STRUCT_ANTI_CONSTRAINT + [Test] + public void By_ref_like_arguments_can_be_restored_from_ByRefLikeArgument_Get_in_invocation() + { + var interceptor = new ObservingInterceptor(); + var proxy = generator.CreateClassProxy(interceptor); + var arg = "original".AsSpan(); + proxy.Method(arg); + Assume.That(interceptor.ObservedArg is ByRefLikeArgument>); + var wrappedArg = (ByRefLikeArgument>)interceptor.ObservedArg!; + var unwrappedArg = wrappedArg.Get(); + Assert.AreEqual("original", unwrappedArg.ToString()); + } +#endif + [Test] public void By_ref_like_in_arguments_are_wrapped_as_ByRefLikeArgument_in_invocation() { @@ -244,6 +259,21 @@ public unsafe void By_ref_like_in_arguments_can_be_restored_from_ByRefLikeArgume Assert.AreEqual("original", unwrappedArg.ToString()); } +#if FEATURE_ALLOWS_REF_STRUCT_ANTI_CONSTRAINT + [Test] + public void By_ref_like_in_arguments_can_be_restored_from_ByRefLikeArgument_Get_in_invocation() + { + var interceptor = new ObservingInterceptor(); + var proxy = generator.CreateClassProxy(interceptor); + var arg = "original".AsSpan(); + proxy.Method(in arg); + Assume.That(interceptor.ObservedArg is ByRefLikeArgument>); + var wrappedArg = (ByRefLikeArgument>)interceptor.ObservedArg!; + var unwrappedArg = wrappedArg.Get(); + Assert.AreEqual("original", unwrappedArg.ToString()); + } +#endif + [Test] public void By_ref_like_ref_arguments_are_wrapped_as_ByRefLikeArgument_in_invocation() { @@ -267,6 +297,21 @@ public unsafe void By_ref_like_ref_arguments_can_be_restored_from_ByRefLikeArgum Assert.AreEqual("original", unwrappedArg.ToString()); } +#if FEATURE_ALLOWS_REF_STRUCT_ANTI_CONSTRAINT + [Test] + public void By_ref_like_ref_arguments_can_be_restored_from_ByRefLikeArgument_Get_in_invocation() + { + var interceptor = new ObservingInterceptor(); + var proxy = generator.CreateClassProxy(interceptor); + var arg = "original".AsSpan(); + proxy.Method(ref arg); + Assume.That(interceptor.ObservedArg is ByRefLikeArgument>); + var wrappedArg = (ByRefLikeArgument>)interceptor.ObservedArg!; + var unwrappedArg = wrappedArg.Get(); + Assert.AreEqual("original", unwrappedArg.ToString()); + } +#endif + // Note the somewhat weird semantics of this test: DynamicProxy allows you to read the incoming values // of `out` arguments, which would be illegal in plain C# ("use of unassigned out parameter"). // DynamicProxy does not distinguish between `ref` and `out` in this regard. @@ -295,6 +340,23 @@ public unsafe void By_ref_like_out_arguments_can_be_restored_from_ByRefLikeArgum Assert.AreEqual("original", unwrappedArg.ToString()); } +#if FEATURE_ALLOWS_REF_STRUCT_ANTI_CONSTRAINT + // Should theoretically be as above (read comment there), but isn't. To be revisited later! + [Test] + [Ignore("Have not yet found out why byref-like `out` arguments are initially set to their default values unlike `ref` ones.")] + public void By_ref_like_out_arguments_can_be_restored_from_ByRefLikeArgument_Get_in_invocation() + { + var interceptor = new ObservingInterceptor(); + var proxy = generator.CreateClassProxy(interceptor); + var arg = "original".AsSpan(); + proxy.Method(out arg); + Assume.That(interceptor.ObservedArg is ByRefLikeArgument>); + var wrappedArg = (ByRefLikeArgument>)interceptor.ObservedArg!; + var unwrappedArg = wrappedArg.Get(); + Assert.AreEqual("original", unwrappedArg.ToString()); + } +#endif + #endregion #region What values do proceeded-to targets see for by-ref-like arguments? diff --git a/src/Castle.Core/Castle.Core.csproj b/src/Castle.Core/Castle.Core.csproj index fa705deaf..80765867c 100644 --- a/src/Castle.Core/Castle.Core.csproj +++ b/src/Castle.Core/Castle.Core.csproj @@ -3,7 +3,7 @@ - net8.0;net462;netstandard2.0 + net9.0;net8.0;net462;netstandard2.0 @@ -37,7 +37,7 @@ - + diff --git a/src/Castle.Core/DynamicProxy/ByRefLikeArgument.cs b/src/Castle.Core/DynamicProxy/ByRefLikeArgument.cs index 8e5cb3c14..91914acbe 100644 --- a/src/Castle.Core/DynamicProxy/ByRefLikeArgument.cs +++ b/src/Castle.Core/DynamicProxy/ByRefLikeArgument.cs @@ -20,6 +20,7 @@ namespace Castle.DynamicProxy { using System; using System.ComponentModel; + using System.Runtime.CompilerServices; /// /// Wraps a byref-like (ref struct) method argument @@ -49,6 +50,36 @@ public ByRefLikeArgument(void* ptr) return ptr; } } + +#if FEATURE_ALLOWS_REF_STRUCT_ANTI_CONSTRAINT + + /// + /// Wraps a byref-like (ref struct) method argument + /// such that it can be placed in the array during interception. + /// + public unsafe class ByRefLikeArgument : ByRefLikeArgument where TByRefLike : allows ref struct + { + /// + /// Do not use this! Only generated proxies should construct instances this type. + /// + [CLSCompliant(false)] + [EditorBrowsable(EditorBrowsableState.Never)] + public ByRefLikeArgument(void* ptr) + : base(ptr) + { + } + + /// + /// Gets the byref-like (ref struct) argument. + /// + public ref TByRefLike Get() + { + return ref Unsafe.AsRef(ptr); + } + } + +#endif + } #endif From 49f019af11ec6b31554143684ec9339260261f2c Mon Sep 17 00:00:00 2001 From: Dominique Schuppli Date: Fri, 19 Dec 2025 01:32:07 +0100 Subject: [PATCH 4/7] Make generic `ByRefLikeArgument<>` work --- .../DynamicProxy/ByRefLikeArgument.cs | 23 ++++++++++++++ .../ReferencesToObjectArrayExpression.cs | 2 +- .../Tokens/ByRefLikeArgumentMethods.cs | 30 ------------------- 3 files changed, 24 insertions(+), 31 deletions(-) delete mode 100644 src/Castle.Core/DynamicProxy/Tokens/ByRefLikeArgumentMethods.cs diff --git a/src/Castle.Core/DynamicProxy/ByRefLikeArgument.cs b/src/Castle.Core/DynamicProxy/ByRefLikeArgument.cs index 91914acbe..2bca77604 100644 --- a/src/Castle.Core/DynamicProxy/ByRefLikeArgument.cs +++ b/src/Castle.Core/DynamicProxy/ByRefLikeArgument.cs @@ -19,7 +19,9 @@ namespace Castle.DynamicProxy { using System; + using System.Collections.Concurrent; using System.ComponentModel; + using System.Reflection; using System.Runtime.CompilerServices; /// @@ -28,6 +30,27 @@ namespace Castle.DynamicProxy /// public unsafe class ByRefLikeArgument { +#if FEATURE_ALLOWS_REF_STRUCT_ANTI_CONSTRAINT + private static readonly ConcurrentDictionary constructorMap = new(); + + internal static ConstructorInfo GetConstructorFor(Type byRefLikeType) + { + return constructorMap.GetOrAdd(byRefLikeType, static byRefLikeType => + { + var type = typeof(ByRefLikeArgument<>).MakeGenericType(byRefLikeType); + return type.GetConstructor([ typeof(void*) ])!; + }); + } +#else + private static readonly ConstructorInfo constructor = + typeof(ByRefLikeArgument).GetConstructor([ typeof(void*) ])!; + + internal static ConstructorInfo GetConstructorFor(Type byRefLikeType) + { + return constructor; + } +#endif + protected void* ptr; /// diff --git a/src/Castle.Core/DynamicProxy/Generators/Emitters/SimpleAST/ReferencesToObjectArrayExpression.cs b/src/Castle.Core/DynamicProxy/Generators/Emitters/SimpleAST/ReferencesToObjectArrayExpression.cs index 169cf1a8e..547c7b6db 100644 --- a/src/Castle.Core/DynamicProxy/Generators/Emitters/SimpleAST/ReferencesToObjectArrayExpression.cs +++ b/src/Castle.Core/DynamicProxy/Generators/Emitters/SimpleAST/ReferencesToObjectArrayExpression.cs @@ -53,7 +53,7 @@ public void Emit(ILGenerator gen) // We cannot box a byref-like argument directly, so we wrap it as // `BoxedByRefLikeArgument` instead (which references it using a pointer). reference.EmitAddress(gen); - gen.Emit(OpCodes.Newobj, ByRefLikeArgumentMethods.Constructor); + gen.Emit(OpCodes.Newobj, ByRefLikeArgument.GetConstructorFor(reference.Type)); gen.Emit(OpCodes.Stelem_Ref); continue; } diff --git a/src/Castle.Core/DynamicProxy/Tokens/ByRefLikeArgumentMethods.cs b/src/Castle.Core/DynamicProxy/Tokens/ByRefLikeArgumentMethods.cs deleted file mode 100644 index 3ec003b7d..000000000 --- a/src/Castle.Core/DynamicProxy/Tokens/ByRefLikeArgumentMethods.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright 2004-2025 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 FEATURE_BYREFLIKE - -#nullable enable - -namespace Castle.DynamicProxy.Tokens -{ - using System.Reflection; - - internal static class ByRefLikeArgumentMethods - { - public static readonly ConstructorInfo Constructor = - typeof(ByRefLikeArgument).GetConstructor(new[] { typeof(void*) })!; - } -} - -#endif From a01b85f4361655e408f170bd92e9938c1075d478 Mon Sep 17 00:00:00 2001 From: Dominique Schuppli Date: Fri, 19 Dec 2025 01:54:15 +0100 Subject: [PATCH 5/7] Introduce type-safe `[ReadOnly]SpanArgument` for .NET 8 This yields the following `ByRefLikeArgument` type hierarchy on .NET 8: ByRefLikeArgument ^ | +-- SpanArgument | | +-- ReadOnlySpanArgument While on .NET 9, there is an intermediate generic type (which wouldn't be allowed on earlier runtimes): ByRefLikeArgument ^ | +-- ByRefLikeArgument ^ | +-- SpanArgument | (with TByRefLike = Span) | | +-- ReadOnlySpanArgument (with TByRefLike = ReadOnlySpan) The intention behind those different hiearchies is to give .NET 8 users a type-safe argument wrapper for spans, too (because those have become commonly encountered types), while allowing .NET 9 code to ignore those specialized span variants and testing only for the more generic inter- mediate type. Hopefully this won't bite us in the future. --- .../DynamicProxy.Tests/ByRefLikeTestCase.cs | 16 +++ .../DynamicProxy/ByRefLikeArgument.cs | 109 ++++++++++++++++-- 2 files changed, 114 insertions(+), 11 deletions(-) diff --git a/src/Castle.Core.Tests/DynamicProxy.Tests/ByRefLikeTestCase.cs b/src/Castle.Core.Tests/DynamicProxy.Tests/ByRefLikeTestCase.cs index 775d7a0f5..3a34a30b5 100644 --- a/src/Castle.Core.Tests/DynamicProxy.Tests/ByRefLikeTestCase.cs +++ b/src/Castle.Core.Tests/DynamicProxy.Tests/ByRefLikeTestCase.cs @@ -206,6 +206,10 @@ public void By_ref_like_arguments_are_wrapped_as_ByRefLikeArgument_in_invocation var arg = "original".AsSpan(); proxy.Method(arg); Assert.IsInstanceOf(interceptor.ObservedArg); + Assert.IsInstanceOf>(interceptor.ObservedArg); +#if FEATURE_ALLOWS_REF_STRUCT_ANTI_CONSTRAINT + Assert.IsInstanceOf>>(interceptor.ObservedArg); +#endif } [Test] @@ -244,6 +248,10 @@ public void By_ref_like_in_arguments_are_wrapped_as_ByRefLikeArgument_in_invocat var arg = "original".AsSpan(); proxy.Method(in arg); Assert.IsInstanceOf(interceptor.ObservedArg); + Assert.IsInstanceOf>(interceptor.ObservedArg); +#if FEATURE_ALLOWS_REF_STRUCT_ANTI_CONSTRAINT + Assert.IsInstanceOf>>(interceptor.ObservedArg); +#endif } [Test] @@ -282,6 +290,10 @@ public void By_ref_like_ref_arguments_are_wrapped_as_ByRefLikeArgument_in_invoca var arg = "original".AsSpan(); proxy.Method(ref arg); Assert.IsInstanceOf(interceptor.ObservedArg); + Assert.IsInstanceOf>(interceptor.ObservedArg); +#if FEATURE_ALLOWS_REF_STRUCT_ANTI_CONSTRAINT + Assert.IsInstanceOf>>(interceptor.ObservedArg); +#endif } [Test] @@ -323,6 +335,10 @@ public void By_ref_like_out_arguments_are_wrapped_as_ByRefLikeArgument_in_invoca var arg = "original".AsSpan(); proxy.Method(out arg); Assert.IsInstanceOf(interceptor.ObservedArg); + Assert.IsInstanceOf>(interceptor.ObservedArg); +#if FEATURE_ALLOWS_REF_STRUCT_ANTI_CONSTRAINT + Assert.IsInstanceOf>>(interceptor.ObservedArg); +#endif } // Should theoretically be as above (read comment there), but isn't. To be revisited later! diff --git a/src/Castle.Core/DynamicProxy/ByRefLikeArgument.cs b/src/Castle.Core/DynamicProxy/ByRefLikeArgument.cs index 2bca77604..1e86317ef 100644 --- a/src/Castle.Core/DynamicProxy/ByRefLikeArgument.cs +++ b/src/Castle.Core/DynamicProxy/ByRefLikeArgument.cs @@ -30,26 +30,38 @@ namespace Castle.DynamicProxy /// public unsafe class ByRefLikeArgument { -#if FEATURE_ALLOWS_REF_STRUCT_ANTI_CONSTRAINT private static readonly ConcurrentDictionary constructorMap = new(); internal static ConstructorInfo GetConstructorFor(Type byRefLikeType) { return constructorMap.GetOrAdd(byRefLikeType, static byRefLikeType => { - var type = typeof(ByRefLikeArgument<>).MakeGenericType(byRefLikeType); - return type.GetConstructor([ typeof(void*) ])!; - }); - } + Type? type = null; + + if (byRefLikeType.IsConstructedGenericType) + { + var typeDef = byRefLikeType.GetGenericTypeDefinition(); + if (typeDef == typeof(Span<>)) + { + var typeArg = byRefLikeType.GetGenericArguments()[0]; + type = typeof(SpanArgument<>).MakeGenericType(typeArg); + } + else if (typeDef == typeof(ReadOnlySpan<>)) + { + var typeArg = byRefLikeType.GetGenericArguments()[0]; + type = typeof(ReadOnlySpanArgument<>).MakeGenericType(typeArg); + } + } + +#if FEATURE_ALLOWS_REF_STRUCT_ANTI_CONSTRAINT + type ??= typeof(ByRefLikeArgument<>).MakeGenericType(byRefLikeType); #else - private static readonly ConstructorInfo constructor = - typeof(ByRefLikeArgument).GetConstructor([ typeof(void*) ])!; + type ??= typeof(ByRefLikeArgument); +#endif - internal static ConstructorInfo GetConstructorFor(Type byRefLikeType) - { - return constructor; + return type.GetConstructor([ typeof(void*) ])!; + }); } -#endif protected void* ptr; @@ -103,6 +115,81 @@ public ref TByRefLike Get() #endif + // The following two specializations for `Span` and `ReadOnlySpan` are provided + // because those two types have become so common in the Framework Class Library, and + // dealing with them through unmanaged pointers all the time would be cumbersome. + // We can provide a type-safe wrapper for them even on .NET 8. And we keep the types + // for .NET 9 (even though they're redundant) so downstream code can expect to always + // encounter a `[ReadOnly]SpanArgument<>` for `[ReadOnly]Span<>` regardless of + // whether they target .NET 8 or 9. + + /// + /// Wraps a method argument + /// such that it can be placed in the array during interception. + /// + public unsafe class ReadOnlySpanArgument +#if FEATURE_ALLOWS_REF_STRUCT_ANTI_CONSTRAINT + : ByRefLikeArgument> +#else + : ByRefLikeArgument +#endif + { + /// + /// Do not use this! Only generated proxies should construct instances this type. + /// + [CLSCompliant(false)] + [EditorBrowsable(EditorBrowsableState.Never)] + public ReadOnlySpanArgument(void* ptr) + : base(ptr) + { + } + +#if !FEATURE_ALLOWS_REF_STRUCT_ANTI_CONSTRAINT + /// + /// Gets the byref-like (ref struct) argument. + /// + public ref ReadOnlySpan Get() + { +#pragma warning disable CS8500 + return ref *(ReadOnlySpan*)ptr; +#pragma warning restore CS8500 + } +#endif + } + + /// + /// Wraps a method argument + /// such that it can be placed in the array during interception. + /// + public unsafe class SpanArgument +#if FEATURE_ALLOWS_REF_STRUCT_ANTI_CONSTRAINT + : ByRefLikeArgument> +#else + : ByRefLikeArgument +#endif + { + /// + /// Do not use this! Only generated proxies should construct instances this type. + /// + [CLSCompliant(false)] + [EditorBrowsable(EditorBrowsableState.Never)] + public SpanArgument(void* ptr) + : base(ptr) + { + } + +#if !FEATURE_ALLOWS_REF_STRUCT_ANTI_CONSTRAINT + /// + /// Gets the byref-like (ref struct) argument. + /// + public ref Span Get() + { +#pragma warning disable CS8500 + return ref *(Span*)ptr; +#pragma warning restore CS8500 + } +#endif + } } #endif From fa2dd589f25715f33602ffe649a7d6961200b109 Mon Sep 17 00:00:00 2001 From: Dominique Schuppli Date: Fri, 19 Dec 2025 11:20:11 +0100 Subject: [PATCH 6/7] Specify lifetime of `ByRefLikeArgument`s --- .../DynamicProxy.Tests/ByRefLikeTestCase.cs | 39 +++++++++++++++++++ .../DynamicProxy/ByRefLikeArgument.cs | 17 ++++++++ 2 files changed, 56 insertions(+) diff --git a/src/Castle.Core.Tests/DynamicProxy.Tests/ByRefLikeTestCase.cs b/src/Castle.Core.Tests/DynamicProxy.Tests/ByRefLikeTestCase.cs index 3a34a30b5..ecb9ad096 100644 --- a/src/Castle.Core.Tests/DynamicProxy.Tests/ByRefLikeTestCase.cs +++ b/src/Castle.Core.Tests/DynamicProxy.Tests/ByRefLikeTestCase.cs @@ -500,6 +500,43 @@ public void By_ref_like_out_arguments_cannot_be_set_by_target() #endregion + #region Memory safety + + // Byref-like arguments live exclusively on the evaluation stack. + // We need to make sure that references to them (such as those in `IInvocation.Arguments`) + // do not have a longer lifetime than the arguments themselves. + + [Test] + public unsafe void Cannot_use_ByRefLikeArgument_GetPointer_after_invocation() + { + var interceptor = new ObservingInterceptor(); + var proxy = generator.CreateClassProxy(interceptor); + proxy.Method(default); + var byRefLikeArg = (ByRefLikeArgument)interceptor.ObservedArg!; + Assert.Throws(() => _ = byRefLikeArg.GetPointer()); + } + + [Test] + public void Cannot_use_ByRefLikeArgument_Get_after_invocation() + { + var interceptor = new ObservingInterceptor(); + var proxy = generator.CreateClassProxy(interceptor); + proxy.Method(default); + var byRefLikeArg = (ReadOnlySpanArgument)interceptor.ObservedArg!; + Assert.Throws(() => _ = byRefLikeArg.Get()); + } + + [Test] + public void ByRefLikeArguments_are_erased_from_invocation_Arguments_after_invocation() + { + var interceptor = new ObservingInterceptor(); + var proxy = generator.CreateClassProxy(interceptor); + proxy.Method(default); + Assert.IsNull(interceptor.AllArguments![0]); + } + + #endregion + public class HasMethodWithSpanParameter { public string? RecordedArg; @@ -541,10 +578,12 @@ public virtual void Method(out ReadOnlySpan arg) public class ObservingInterceptor : IInterceptor { + public object?[]? AllArguments; public object? ObservedArg; public void Intercept(IInvocation invocation) { + AllArguments = invocation.Arguments; ObservedArg = invocation.Arguments[0]; } } diff --git a/src/Castle.Core/DynamicProxy/ByRefLikeArgument.cs b/src/Castle.Core/DynamicProxy/ByRefLikeArgument.cs index 1e86317ef..516ced8b7 100644 --- a/src/Castle.Core/DynamicProxy/ByRefLikeArgument.cs +++ b/src/Castle.Core/DynamicProxy/ByRefLikeArgument.cs @@ -78,6 +78,23 @@ public ByRefLikeArgument(void* ptr) /// /// Gets an unmanaged pointer to the byref-like (ref struct) argument. /// + /// + /// + /// You may only use the returned pointer during the first run through the interception pipeline. + /// After that, it will be invalid, because the argument that it referred to will be gone + /// from the evaluation stack. + /// + /// + /// In particular, if you intercept an method and make use of + /// to proceed through the pipeline again + /// after an , you may no longer access any byref-like arguments. + /// (.NET compilers would forbid any such attempts, too.) + /// + /// + /// Using the returned pointer beyond the lifetime of the byref-like argument + /// will cause undefined behavior, or an at best. + /// + /// [CLSCompliant(false)] [EditorBrowsable(EditorBrowsableState.Advanced)] public void* GetPointer() From 34dde0e94b7fe9a5a43df4dd61d4adddd1adbb9a Mon Sep 17 00:00:00 2001 From: Dominique Schuppli Date: Fri, 19 Dec 2025 11:32:08 +0100 Subject: [PATCH 7/7] Invalidate `ByRefLikeArgument`s at end method interception ... and this appears to already catch possible usage mistakes in some unit tests. Those need to be fixed next. --- .../DynamicProxy/AbstractInvocation.cs | 20 +++++++++++++ .../DynamicProxy/ByRefLikeArgument.cs | 30 ++++++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/src/Castle.Core/DynamicProxy/AbstractInvocation.cs b/src/Castle.Core/DynamicProxy/AbstractInvocation.cs index 6c0490640..ef8fe1f4c 100644 --- a/src/Castle.Core/DynamicProxy/AbstractInvocation.cs +++ b/src/Castle.Core/DynamicProxy/AbstractInvocation.cs @@ -129,6 +129,26 @@ public void Proceed() finally { currentInterceptorIndex--; + +#if FEATURE_BYREFLIKE + if (currentInterceptorIndex < 0) + { + // TODO: This will require further optimization; + // we should not iterate through the arguments array on a hot code path! + for (int i = 0; i < arguments.Length; ++i) + { + if (arguments[i] is ByRefLikeArgument byRefLikeArg) + { + // Invalidate the `ByRefLikeArgument` in case someone copied it away + // (i. e. let it escape the lifetime of the invocation and its arguments on the stack): + byRefLikeArg.Dispose(); + + // Reset the argument so the `ByRefLikeArgument` becomes eligible for GC sooner: + arguments[i] = null; + } + } + } +#endif } } diff --git a/src/Castle.Core/DynamicProxy/ByRefLikeArgument.cs b/src/Castle.Core/DynamicProxy/ByRefLikeArgument.cs index 516ced8b7..2fa011b23 100644 --- a/src/Castle.Core/DynamicProxy/ByRefLikeArgument.cs +++ b/src/Castle.Core/DynamicProxy/ByRefLikeArgument.cs @@ -28,7 +28,7 @@ namespace Castle.DynamicProxy /// Wraps a byref-like (ref struct) method argument /// such that it can be placed in the array during interception. /// - public unsafe class ByRefLikeArgument + public unsafe class ByRefLikeArgument : IDisposable { private static readonly ConcurrentDictionary constructorMap = new(); @@ -95,12 +95,31 @@ public ByRefLikeArgument(void* ptr) /// will cause undefined behavior, or an at best. /// /// + /// [CLSCompliant(false)] [EditorBrowsable(EditorBrowsableState.Advanced)] public void* GetPointer() { + EnsureNotDisposed(); + return ptr; } + + public void Dispose() + { + ptr = null; + } + + protected void EnsureNotDisposed() + { + if (ptr == null) + { + throw new ObjectDisposedException( + message: "Byref-like method arguments are only available during the method call. " + + "This reference has been invalidated to prevent potentially unsafe access.", + objectName: null); + } + } } #if FEATURE_ALLOWS_REF_STRUCT_ANTI_CONSTRAINT @@ -124,8 +143,11 @@ public ByRefLikeArgument(void* ptr) /// /// Gets the byref-like (ref struct) argument. /// + /// public ref TByRefLike Get() { + EnsureNotDisposed(); + return ref Unsafe.AsRef(ptr); } } @@ -165,8 +187,11 @@ public ReadOnlySpanArgument(void* ptr) /// /// Gets the byref-like (ref struct) argument. /// + /// public ref ReadOnlySpan Get() { + EnsureNotDisposed(); + #pragma warning disable CS8500 return ref *(ReadOnlySpan*)ptr; #pragma warning restore CS8500 @@ -199,8 +224,11 @@ public SpanArgument(void* ptr) /// /// Gets the byref-like (ref struct) argument. /// + /// public ref Span Get() { + EnsureNotDisposed(); + #pragma warning disable CS8500 return ref *(Span*)ptr; #pragma warning restore CS8500