From 5461a7479ac69a40b1c6fbaf307de33faf4ba419 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Wed, 13 Aug 2025 16:46:39 -0600 Subject: [PATCH 1/5] Create dynamic proxies in fewer dynamic assemblies This corrects a bug that deviated from the original design. The idea was for all proxies to be generated into as few dynamic assemblies as possible. We have to allow for distinct dynamic assemblies for growing sets of skip visibility check attributes, but other than that we should reuse dynamic assemblies. The bug here was that we were not supplying a structural `AssemblyName` equality comparer to our `ImmutableHashSet`. Since `AssemblyName.Equals` is a reference equality check and the CLR does not de-dupe assembly names, we must supply our own equality comparison in order to get the intended behavior. --- .../AssemblyNameEqualityComparer.cs | 36 +++++++++++++++++++ src/StreamJsonRpc/ProxyGeneration.cs | 2 +- src/StreamJsonRpc/SkipClrVisibilityChecks.cs | 33 +---------------- .../JsonRpcProxyGenerationTests.cs | 12 +++++++ 4 files changed, 50 insertions(+), 33 deletions(-) create mode 100644 src/StreamJsonRpc/AssemblyNameEqualityComparer.cs diff --git a/src/StreamJsonRpc/AssemblyNameEqualityComparer.cs b/src/StreamJsonRpc/AssemblyNameEqualityComparer.cs new file mode 100644 index 000000000..253a15164 --- /dev/null +++ b/src/StreamJsonRpc/AssemblyNameEqualityComparer.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. + +using System.Reflection; + +namespace StreamJsonRpc; + +internal class AssemblyNameEqualityComparer : IEqualityComparer +{ + internal static readonly IEqualityComparer Instance = new AssemblyNameEqualityComparer(); + + private AssemblyNameEqualityComparer() + { + } + + public bool Equals(AssemblyName? x, AssemblyName? y) + { + if (x is null && y is null) + { + return true; + } + + if (x is null || y is null) + { + return false; + } + + return string.Equals(x.FullName, y.FullName, StringComparison.OrdinalIgnoreCase); + } + + public int GetHashCode(AssemblyName obj) + { + Requires.NotNull(obj, nameof(obj)); + + return StringComparer.OrdinalIgnoreCase.GetHashCode(obj.FullName); + } +} diff --git a/src/StreamJsonRpc/ProxyGeneration.cs b/src/StreamJsonRpc/ProxyGeneration.cs index c1b5598a4..cb52e1b3a 100644 --- a/src/StreamJsonRpc/ProxyGeneration.cs +++ b/src/StreamJsonRpc/ProxyGeneration.cs @@ -752,7 +752,7 @@ private static ModuleBuilder GetProxyModuleBuilder(Type[] interfaceTypes) // For each set of skip visibility check assemblies, we need a dynamic assembly that skips at *least* that set. // The CLR will not honor any additions to that set once the first generated type is closed. // We maintain a dictionary to point at dynamic modules based on the set of skip visibility check assemblies they were generated with. - ImmutableHashSet skipVisibilityCheckAssemblies = ImmutableHashSet.CreateRange(interfaceTypes.SelectMany(t => SkipClrVisibilityChecks.GetSkipVisibilityChecksRequirements(t.GetTypeInfo()))) + ImmutableHashSet skipVisibilityCheckAssemblies = ImmutableHashSet.CreateRange(AssemblyNameEqualityComparer.Instance, interfaceTypes.SelectMany(t => SkipClrVisibilityChecks.GetSkipVisibilityChecksRequirements(t.GetTypeInfo()))) .Add(typeof(ProxyGeneration).Assembly.GetName()); foreach ((ImmutableHashSet SkipVisibilitySet, ModuleBuilder Builder) existingSet in TransparentProxyModuleBuilderByVisibilityCheck) { diff --git a/src/StreamJsonRpc/SkipClrVisibilityChecks.cs b/src/StreamJsonRpc/SkipClrVisibilityChecks.cs index 7c2cec48a..903617449 100644 --- a/src/StreamJsonRpc/SkipClrVisibilityChecks.cs +++ b/src/StreamJsonRpc/SkipClrVisibilityChecks.cs @@ -79,7 +79,7 @@ internal static ImmutableHashSet GetSkipVisibilityChecksRequiremen Requires.NotNull(typeInfo, nameof(typeInfo)); var visitedTypes = new HashSet(); - ImmutableHashSet.Builder assembliesDeclaringInternalTypes = ImmutableHashSet.CreateBuilder(AssemblyNameEqualityComparer.Instance); + ImmutableHashSet.Builder assembliesDeclaringInternalTypes = ImmutableHashSet.CreateBuilder(AssemblyNameEqualityComparer.Instance); CheckForNonPublicTypes(typeInfo, assembliesDeclaringInternalTypes, visitedTypes); // Enumerate members on the interface that we're going to need to implement. @@ -253,35 +253,4 @@ private TypeInfo EmitMagicAttribute() return tb.CreateTypeInfo()!; } - - private class AssemblyNameEqualityComparer : IEqualityComparer - { - internal static readonly IEqualityComparer Instance = new AssemblyNameEqualityComparer(); - - private AssemblyNameEqualityComparer() - { - } - - public bool Equals(AssemblyName? x, AssemblyName? y) - { - if (x is null && y is null) - { - return true; - } - - if (x is null || y is null) - { - return false; - } - - return string.Equals(x.FullName, y.FullName, StringComparison.OrdinalIgnoreCase); - } - - public int GetHashCode(AssemblyName obj) - { - Requires.NotNull(obj, nameof(obj)); - - return StringComparer.OrdinalIgnoreCase.GetHashCode(obj.FullName); - } - } } diff --git a/test/StreamJsonRpc.Tests/JsonRpcProxyGenerationTests.cs b/test/StreamJsonRpc.Tests/JsonRpcProxyGenerationTests.cs index 15e5503ce..8b165205c 100644 --- a/test/StreamJsonRpc.Tests/JsonRpcProxyGenerationTests.cs +++ b/test/StreamJsonRpc.Tests/JsonRpcProxyGenerationTests.cs @@ -798,6 +798,18 @@ public async Task ValueTaskReturningMethod() await clientRpc.DoSomethingValueAsync(); } + /// + /// Validates that similar proxies are generated in the same dynamic assembly. + /// + [Fact] + public void ReuseDynamicAssembliesTest() + { + JsonRpc clientRpc = new(Stream.Null); + IServer proxy1 = clientRpc.Attach(); + IServer2 proxy2 = clientRpc.Attach(); + Assert.Same(proxy1.GetType().Assembly, proxy2.GetType().Assembly); + } + public class EmptyClass { } From 978f8eae389a9926709f3e90a699f0d1806c8586 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Wed, 13 Aug 2025 21:33:00 -0600 Subject: [PATCH 2/5] Be intentional about ALCs and dynamic proxies This fixes the problem that we were always creating in the 'contextual' ALC, but not filing them as associated with that ALC. As a result, multiple ALCs in a process might share a DynamicAssembly, leading to type load failures or type equivalency failures. To solve this, we are careful to never share dynamic assemblies across ALCs, and we document how callers can intentionally direct which ALC a dynamic proxy should be emitted into. --- StreamJsonRpc.sln | 11 +++- docfx/docs/dynamicproxy.md | 22 ++++++++ src/StreamJsonRpc/ProxyGeneration.cs | 24 ++++++++ .../JsonRpcProxyGenerationTests.cs | 55 +++++++++++++++++++ .../StreamJsonRpc.Tests.csproj | 10 ++++ .../UnreachableAssemblyTools.cs | 50 +++++++++++++++++ .../SomeUnreachableClass.cs | 5 ++ .../UnreachableAssembly.csproj | 8 +++ 8 files changed, 183 insertions(+), 2 deletions(-) create mode 100644 test/StreamJsonRpc.Tests/UnreachableAssemblyTools.cs create mode 100644 test/UnreachableAssembly/SomeUnreachableClass.cs create mode 100644 test/UnreachableAssembly/UnreachableAssembly.csproj diff --git a/StreamJsonRpc.sln b/StreamJsonRpc.sln index 8b8d49b2d..eb8f928be 100644 --- a/StreamJsonRpc.sln +++ b/StreamJsonRpc.sln @@ -1,7 +1,7 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 17 -VisualStudioVersion = 17.0.31707.426 +# Visual Studio Version 18 +VisualStudioVersion = 18.0.10912.84 main MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StreamJsonRpc", "src\StreamJsonRpc\StreamJsonRpc.csproj", "{DFBD1BCA-EAE0-4454-9E97-FA9BD9A0F03A}" EndProject @@ -38,6 +38,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{F446B894-5 test\Directory.Build.targets = test\Directory.Build.targets EndProjectSection EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnreachableAssembly", "test\UnreachableAssembly\UnreachableAssembly.csproj", "{5AAF7DDA-6CC0-456B-A7E1-B33893915662}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -60,6 +62,10 @@ Global {5936CF62-A59D-4E5C-9EE4-5E8BAFA9F215}.Debug|Any CPU.Build.0 = Debug|Any CPU {5936CF62-A59D-4E5C-9EE4-5E8BAFA9F215}.Release|Any CPU.ActiveCfg = Release|Any CPU {5936CF62-A59D-4E5C-9EE4-5E8BAFA9F215}.Release|Any CPU.Build.0 = Release|Any CPU + {5AAF7DDA-6CC0-456B-A7E1-B33893915662}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5AAF7DDA-6CC0-456B-A7E1-B33893915662}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5AAF7DDA-6CC0-456B-A7E1-B33893915662}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5AAF7DDA-6CC0-456B-A7E1-B33893915662}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -69,6 +75,7 @@ Global {8BF355B2-E3B0-4615-BFC1-7563EADC4F8B} = {F446B894-56AA-4653-ADC0-5FFC911C9C13} {CEF0F77F-19EB-4C76-A050-854984BB0364} = {F446B894-56AA-4653-ADC0-5FFC911C9C13} {5936CF62-A59D-4E5C-9EE4-5E8BAFA9F215} = {F446B894-56AA-4653-ADC0-5FFC911C9C13} + {5AAF7DDA-6CC0-456B-A7E1-B33893915662} = {F446B894-56AA-4653-ADC0-5FFC911C9C13} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {4946F7E7-0619-414B-BE56-DDF0261CA8A9} diff --git a/docfx/docs/dynamicproxy.md b/docfx/docs/dynamicproxy.md index 052c99240..662e721bf 100644 --- a/docfx/docs/dynamicproxy.md +++ b/docfx/docs/dynamicproxy.md @@ -50,3 +50,25 @@ between client and server. Sometimes a client may need to block its caller until a response to a JSON-RPC request comes back. The dynamic proxy maintains the same async-only contract that is exposed by the @StreamJsonRpc.JsonRpc class itself. [Learn more about sending requests](sendrequest.md), particularly under the heading about async responses. + +## AssemblyLoadContext considerations + +When in a .NET process with multiple instances, you should consider whether StreamJsonRpc is loaded in an that can load all the types required by the proxy interface. + +By default, StreamJsonRpc will generate dynamic proxies in the that StreamJsonRpc is loaded within. +This means that if your own code is running in a different from StreamJsonRpc and ask for a proxy, the proxy may fail to activate from a type load failure even if your calling code *can* or has loaded that type. +It might also manifest as an or due to types loading into multiple instances. + +In such cases, you may control the used to generate the proxy by surrounding your proxy request with a call to (and disposal of its result). + +For example, you might use the following code when StreamJsonRpc is loaded into a different from your own code: + +```cs +IMyService proxy; +using (AssemblyLoadContext.EnterContextualReflection(MethodBase.GetCurrentMethod()!.DeclaringType!.Assembly)) +{ + proxy = jsonRpc.Attach(); +} +``` + +This initializes the `proxy` local variable with a proxy that will be able to load all types that your own can load. diff --git a/src/StreamJsonRpc/ProxyGeneration.cs b/src/StreamJsonRpc/ProxyGeneration.cs index cb52e1b3a..7d7bccfde 100644 --- a/src/StreamJsonRpc/ProxyGeneration.cs +++ b/src/StreamJsonRpc/ProxyGeneration.cs @@ -5,6 +5,9 @@ using System.Globalization; using System.Reflection; using System.Reflection.Emit; +#if NET +using System.Runtime.Loader; +#endif using Microsoft.VisualStudio.Threading; using StreamJsonRpc.Reflection; using CodeGenHelpers = StreamJsonRpc.Reflection.CodeGenHelpers; @@ -18,7 +21,11 @@ namespace StreamJsonRpc; internal static class ProxyGeneration { +#if NET + private static readonly List<(AssemblyLoadContext, ImmutableHashSet SkipVisibilitySet, ModuleBuilder Builder)> TransparentProxyModuleBuilderByVisibilityCheck = []; +#else private static readonly List<(ImmutableHashSet SkipVisibilitySet, ModuleBuilder Builder)> TransparentProxyModuleBuilderByVisibilityCheck = new List<(ImmutableHashSet, ModuleBuilder)>(); +#endif private static readonly object BuilderLock = new object(); private static readonly AssemblyName ProxyAssemblyName = new AssemblyName(string.Format(CultureInfo.InvariantCulture, "StreamJsonRpc_Proxies_{0}", Guid.NewGuid())); private static readonly MethodInfo DelegateCombineMethod = typeof(Delegate).GetRuntimeMethod(nameof(Delegate.Combine), new Type[] { typeof(Delegate), typeof(Delegate) })!; @@ -754,8 +761,21 @@ private static ModuleBuilder GetProxyModuleBuilder(Type[] interfaceTypes) // We maintain a dictionary to point at dynamic modules based on the set of skip visibility check assemblies they were generated with. ImmutableHashSet skipVisibilityCheckAssemblies = ImmutableHashSet.CreateRange(AssemblyNameEqualityComparer.Instance, interfaceTypes.SelectMany(t => SkipClrVisibilityChecks.GetSkipVisibilityChecksRequirements(t.GetTypeInfo()))) .Add(typeof(ProxyGeneration).Assembly.GetName()); +#if NET + // We have to key the dynamic assembly by ALC as well, since callers may set a custom contextual reflection context + // that influences how the assembly will resolve its type references. + AssemblyLoadContext alc = AssemblyLoadContext.CurrentContextualReflectionContext ?? AssemblyLoadContext.GetLoadContext(typeof(ProxyGeneration).Assembly) ?? throw new Exception("No ALC for our own assembly!"); + foreach ((AssemblyLoadContext AssemblyLoadContext, ImmutableHashSet SkipVisibilitySet, ModuleBuilder Builder) existingSet in TransparentProxyModuleBuilderByVisibilityCheck) + { + if (existingSet.AssemblyLoadContext != alc) + { + continue; + } + +#else foreach ((ImmutableHashSet SkipVisibilitySet, ModuleBuilder Builder) existingSet in TransparentProxyModuleBuilderByVisibilityCheck) { +#endif if (existingSet.SkipVisibilitySet.IsSupersetOf(skipVisibilityCheckAssemblies)) { return existingSet.Builder; @@ -771,7 +791,11 @@ private static ModuleBuilder GetProxyModuleBuilder(Type[] interfaceTypes) ModuleBuilder moduleBuilder = assemblyBuilder.DefineDynamicModule("rpcProxies"); var skipClrVisibilityChecks = new SkipClrVisibilityChecks(assemblyBuilder, moduleBuilder); skipClrVisibilityChecks.SkipVisibilityChecksFor(skipVisibilityCheckAssemblies); +#if NET + TransparentProxyModuleBuilderByVisibilityCheck.Add((alc, skipVisibilityCheckAssemblies, moduleBuilder)); +#else TransparentProxyModuleBuilderByVisibilityCheck.Add((skipVisibilityCheckAssemblies, moduleBuilder)); +#endif return moduleBuilder; } diff --git a/test/StreamJsonRpc.Tests/JsonRpcProxyGenerationTests.cs b/test/StreamJsonRpc.Tests/JsonRpcProxyGenerationTests.cs index 8b165205c..5ab0ea8e2 100644 --- a/test/StreamJsonRpc.Tests/JsonRpcProxyGenerationTests.cs +++ b/test/StreamJsonRpc.Tests/JsonRpcProxyGenerationTests.cs @@ -1,8 +1,13 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT license. See LICENSE file in the project root for full license information. +#if NET +using System.Reflection; +using System.Runtime.Loader; +#endif using Microsoft.VisualStudio.Threading; using Nerdbank; +using StreamJsonRpc.Tests; using ExAssembly = StreamJsonRpc.Tests.ExternalAssembly; public class JsonRpcProxyGenerationTests : TestBase @@ -135,6 +140,11 @@ public interface IServerWithGenericMethod Task AddAsync(T a, T b); } + public interface IReferenceAnUnreachableAssembly + { + Task TakeAsync(UnreachableAssembly.SomeUnreachableClass obj); + } + internal interface IServerInternal : ExAssembly.ISomeInternalProxyInterface, IServerInternalWithInternalTypesFromOtherAssemblies, @@ -810,6 +820,51 @@ public void ReuseDynamicAssembliesTest() Assert.Same(proxy1.GetType().Assembly, proxy2.GetType().Assembly); } +#if NET + [Fact] + public void DynamicAssembliesKeyedByAssemblyLoadContext() + { + UnreachableAssemblyTools.VerifyUnreachableAssembly(); + + // Set up a new ALC that can find the hidden assembly, and ask for the proxy type. + AssemblyLoadContext alc = UnreachableAssemblyTools.CreateContextForReachingTheUnreachable(); + + JsonRpc clientRpc = new(Stream.Null); + + // Ensure we first generate a proxy in our own default ALC. + // The goal being to emit a DynamicAssembly that we *might* reuse + // for the later proxy for which the first DynamicAssembly is not appropriate. + clientRpc.Attach(); + + // Now take very specific steps to invoke the rest of the test in the other AssemblyLoadContext. + // This is important so that our IReferenceAnUnreachableAssembly type will be able to resolve its + // own type references to UnreachableAssembly.dll, which our own default ALC cannot do. + MethodInfo helperMethodInfo = typeof(JsonRpcProxyGenerationTests).GetMethod(nameof(DynamicAssembliesKeyedByAssemblyLoadContext_Helper), BindingFlags.NonPublic | BindingFlags.Static)!; + MethodInfo helperWithinAlc = UnreachableAssemblyTools.LoadHelperInAlc(alc, helperMethodInfo); + helperWithinAlc.Invoke(null, null); + } + + private static void DynamicAssembliesKeyedByAssemblyLoadContext_Helper() + { + // Although this method executes within the special ALC, + // StreamJsonRpc is loaded in the default ALC. + // Therefore unless StreamJsonRpc is taking care to use a DynamicAssembly + // that belongs to *this* ALC, it won't be able to resolve the same type references + // that we can here (the ones from UnreachableAssembly). + // That's what makes this test effective: it'll fail if the DynamicAssembly is shared across ALCs, + // thereby verifying that StreamJsonRpc has a dedicated set of DynamicAssemblies for each ALC. + // We have to manually set the contextual reflection context to this special ALC + // so that StreamJsonRpc knows to get the DynamicAssembly for this ALC. + // Otherwise it will default to its own. + using (AssemblyLoadContext.EnterContextualReflection(MethodBase.GetCurrentMethod()!.DeclaringType!.Assembly)) + { + JsonRpc clientRpc = new(Stream.Null); + clientRpc.Attach(); + } + } + +#endif + public class EmptyClass { } diff --git a/test/StreamJsonRpc.Tests/StreamJsonRpc.Tests.csproj b/test/StreamJsonRpc.Tests/StreamJsonRpc.Tests.csproj index e637384fc..38e62f46f 100644 --- a/test/StreamJsonRpc.Tests/StreamJsonRpc.Tests.csproj +++ b/test/StreamJsonRpc.Tests/StreamJsonRpc.Tests.csproj @@ -47,6 +47,10 @@ + + + false + @@ -68,4 +72,10 @@ + + + + + + diff --git a/test/StreamJsonRpc.Tests/UnreachableAssemblyTools.cs b/test/StreamJsonRpc.Tests/UnreachableAssemblyTools.cs new file mode 100644 index 000000000..bae11d4ec --- /dev/null +++ b/test/StreamJsonRpc.Tests/UnreachableAssemblyTools.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. + +#if NET + +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.Loader; + +namespace StreamJsonRpc.Tests; + +internal static class UnreachableAssemblyTools +{ + /// + /// Useful for tests to call before asserting conditions that depend on the UnreachableAssembly.dll + /// actually being unreachable. + /// + internal static void VerifyUnreachableAssembly() + { + Assert.Throws(() => typeof(UnreachableAssembly.SomeUnreachableClass)); + } + + /// + /// Initializes an with UnreachableAssembly.dll loaded into it. + /// + /// The name to give the . + /// The new . + internal static AssemblyLoadContext CreateContextForReachingTheUnreachable([CallerMemberName] string? testName = null) + { + AssemblyLoadContext alc = new(testName); + alc.LoadFromAssemblyPath(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, "hidden", "UnreachableAssembly.dll")); + return alc; + } + + /// + /// Translates a from one ALC into another ALC, so that it can be invoked + /// within the context of the new ALC. + /// + /// The to load the method into. + /// The of the method in the caller's ALC to load into the given . + /// The translated . + internal static MethodInfo LoadHelperInAlc(AssemblyLoadContext alc, MethodInfo helperMethodInfo) + { + Assembly selfWithinAlc = alc.LoadFromAssemblyPath(helperMethodInfo.DeclaringType!.Assembly.Location); + MethodInfo helperWithinAlc = (MethodInfo)selfWithinAlc.ManifestModule.ResolveMethod(helperMethodInfo.MetadataToken)!; + return helperWithinAlc; + } +} + +#endif diff --git a/test/UnreachableAssembly/SomeUnreachableClass.cs b/test/UnreachableAssembly/SomeUnreachableClass.cs new file mode 100644 index 000000000..013112061 --- /dev/null +++ b/test/UnreachableAssembly/SomeUnreachableClass.cs @@ -0,0 +1,5 @@ +namespace UnreachableAssembly; + +public class SomeUnreachableClass +{ +} diff --git a/test/UnreachableAssembly/UnreachableAssembly.csproj b/test/UnreachableAssembly/UnreachableAssembly.csproj new file mode 100644 index 000000000..089efa6ca --- /dev/null +++ b/test/UnreachableAssembly/UnreachableAssembly.csproj @@ -0,0 +1,8 @@ + + + + net8.0 + $(TargetFrameworks);net472 + + + From 5a32ad3ab293da18c423020363e54c97741746b3 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Wed, 13 Aug 2025 21:50:49 -0600 Subject: [PATCH 3/5] Default to creating dynamic assemblies in the ALC that loaded the first interface --- docfx/docs/dynamicproxy.md | 21 +++++++++++-------- src/StreamJsonRpc/ProxyGeneration.cs | 16 ++++++++++++-- .../JsonRpcProxyGenerationTests.cs | 10 ++------- 3 files changed, 28 insertions(+), 19 deletions(-) diff --git a/docfx/docs/dynamicproxy.md b/docfx/docs/dynamicproxy.md index 662e721bf..de8996932 100644 --- a/docfx/docs/dynamicproxy.md +++ b/docfx/docs/dynamicproxy.md @@ -53,21 +53,24 @@ The dynamic proxy maintains the same async-only contract that is exposed by the ## AssemblyLoadContext considerations -When in a .NET process with multiple instances, you should consider whether StreamJsonRpc is loaded in an that can load all the types required by the proxy interface. +When in a .NET process with multiple (ALC) instances, you should consider whether StreamJsonRpc is loaded in an ALC that can load all the types required by the proxy interface. -By default, StreamJsonRpc will generate dynamic proxies in the that StreamJsonRpc is loaded within. -This means that if your own code is running in a different from StreamJsonRpc and ask for a proxy, the proxy may fail to activate from a type load failure even if your calling code *can* or has loaded that type. -It might also manifest as an or due to types loading into multiple instances. +By default, StreamJsonRpc will generate dynamic proxies in the ALC that the (first) interface requested for the proxy is loaded within. +This is usually the right choice because the interface should be in an ALC that can resolve all the interface's type references. +When you request a proxy that implements *multiple* interfaces, and if those interfaces are loaded in different ALCs, you *may* need to control which ALC the proxy is generated in. +The need to control this may manifest as an or due to types loading into multiple ALC instances. -In such cases, you may control the used to generate the proxy by surrounding your proxy request with a call to (and disposal of its result). +In such cases, you may control the ALC used to generate the proxy by surrounding your proxy request with a call to (and disposal of its result). -For example, you might use the following code when StreamJsonRpc is loaded into a different from your own code: +For example, you might use the following code when StreamJsonRpc is loaded into a different ALC from your own code: ```cs -IMyService proxy; -using (AssemblyLoadContext.EnterContextualReflection(MethodBase.GetCurrentMethod()!.DeclaringType!.Assembly)) +// Whatever ALC can resolve *all* type references in *all* proxy interfaces. +AssemblyLoadContext alc = AssemblyLoadContext.GetLoadContext(MethodBase.GetCurrentMethod()!.DeclaringType!.Assembly); +IFoo proxy; +using (AssemblyLoadContext.EnterContextualReflection(alc)) { - proxy = jsonRpc.Attach(); + proxy = (IFoo)jsonRpc.Attach([typeof(IFoo), typeof(IFoo2)]); } ``` diff --git a/src/StreamJsonRpc/ProxyGeneration.cs b/src/StreamJsonRpc/ProxyGeneration.cs index 7d7bccfde..10c4ea27f 100644 --- a/src/StreamJsonRpc/ProxyGeneration.cs +++ b/src/StreamJsonRpc/ProxyGeneration.cs @@ -111,6 +111,7 @@ internal static TypeInfo Get(Type contractInterface, ReadOnlySpan addition // Rpc interfaces must be sorted so that we implement methods from base interfaces before those from their derivations. SortRpcInterfaces(rpcInterfaces); + // For ALC selection reasons, it's vital that the *user's* selected interfaces come *before* our own supporting interfaces. Type[] proxyInterfaces = [.. rpcInterfaces.Select(i => i.Type), typeof(IJsonRpcClientProxy), typeof(IJsonRpcClientProxyInternal)]; ModuleBuilder proxyModuleBuilder = GetProxyModuleBuilder(proxyInterfaces); @@ -764,7 +765,11 @@ private static ModuleBuilder GetProxyModuleBuilder(Type[] interfaceTypes) #if NET // We have to key the dynamic assembly by ALC as well, since callers may set a custom contextual reflection context // that influences how the assembly will resolve its type references. - AssemblyLoadContext alc = AssemblyLoadContext.CurrentContextualReflectionContext ?? AssemblyLoadContext.GetLoadContext(typeof(ProxyGeneration).Assembly) ?? throw new Exception("No ALC for our own assembly!"); + // If they haven't set a contextual one, we assume the ALC that defines the (first) proxy interface. + AssemblyLoadContext alc = AssemblyLoadContext.CurrentContextualReflectionContext + ?? AssemblyLoadContext.GetLoadContext(interfaceTypes[0].Assembly) + ?? AssemblyLoadContext.GetLoadContext(typeof(ProxyGeneration).Assembly) + ?? throw new Exception("No ALC for our own assembly!"); foreach ((AssemblyLoadContext AssemblyLoadContext, ImmutableHashSet SkipVisibilitySet, ModuleBuilder Builder) existingSet in TransparentProxyModuleBuilderByVisibilityCheck) { if (existingSet.AssemblyLoadContext != alc) @@ -787,7 +792,14 @@ private static ModuleBuilder GetProxyModuleBuilder(Type[] interfaceTypes) // I have disabled this optimization though till we need it since it would sometimes cover up any bugs in the above visibility checking code. ////skipVisibilityCheckAssemblies = skipVisibilityCheckAssemblies.Union(AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetName())); - AssemblyBuilder assemblyBuilder = CreateProxyAssemblyBuilder(); + AssemblyBuilder assemblyBuilder; +#if NET + using (alc.EnterContextualReflection()) +#endif + { + assemblyBuilder = CreateProxyAssemblyBuilder(); + } + ModuleBuilder moduleBuilder = assemblyBuilder.DefineDynamicModule("rpcProxies"); var skipClrVisibilityChecks = new SkipClrVisibilityChecks(assemblyBuilder, moduleBuilder); skipClrVisibilityChecks.SkipVisibilityChecksFor(skipVisibilityCheckAssemblies); diff --git a/test/StreamJsonRpc.Tests/JsonRpcProxyGenerationTests.cs b/test/StreamJsonRpc.Tests/JsonRpcProxyGenerationTests.cs index 5ab0ea8e2..c674402b3 100644 --- a/test/StreamJsonRpc.Tests/JsonRpcProxyGenerationTests.cs +++ b/test/StreamJsonRpc.Tests/JsonRpcProxyGenerationTests.cs @@ -853,14 +853,8 @@ private static void DynamicAssembliesKeyedByAssemblyLoadContext_Helper() // that we can here (the ones from UnreachableAssembly). // That's what makes this test effective: it'll fail if the DynamicAssembly is shared across ALCs, // thereby verifying that StreamJsonRpc has a dedicated set of DynamicAssemblies for each ALC. - // We have to manually set the contextual reflection context to this special ALC - // so that StreamJsonRpc knows to get the DynamicAssembly for this ALC. - // Otherwise it will default to its own. - using (AssemblyLoadContext.EnterContextualReflection(MethodBase.GetCurrentMethod()!.DeclaringType!.Assembly)) - { - JsonRpc clientRpc = new(Stream.Null); - clientRpc.Attach(); - } + JsonRpc clientRpc = new(Stream.Null); + clientRpc.Attach(); } #endif From f8341531a3cf1d33761df5d36f0d3ac30d923ce5 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Thu, 14 Aug 2025 08:46:17 -0600 Subject: [PATCH 4/5] Improve code comments --- src/StreamJsonRpc/ProxyGeneration.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/StreamJsonRpc/ProxyGeneration.cs b/src/StreamJsonRpc/ProxyGeneration.cs index 10c4ea27f..46979712f 100644 --- a/src/StreamJsonRpc/ProxyGeneration.cs +++ b/src/StreamJsonRpc/ProxyGeneration.cs @@ -112,6 +112,8 @@ internal static TypeInfo Get(Type contractInterface, ReadOnlySpan addition SortRpcInterfaces(rpcInterfaces); // For ALC selection reasons, it's vital that the *user's* selected interfaces come *before* our own supporting interfaces. + // If the order is incorrect, type resolution may fail or the wrong AssemblyLoadContext (ALC) may be selected, + // leading to runtime errors or unexpected behavior when loading types or invoking methods. Type[] proxyInterfaces = [.. rpcInterfaces.Select(i => i.Type), typeof(IJsonRpcClientProxy), typeof(IJsonRpcClientProxyInternal)]; ModuleBuilder proxyModuleBuilder = GetProxyModuleBuilder(proxyInterfaces); From c1ed6ae63b22e6ce55fdf3f472c6e4e24e624079 Mon Sep 17 00:00:00 2001 From: Andrew Arnott Date: Thu, 14 Aug 2025 09:05:00 -0600 Subject: [PATCH 5/5] Disable dotnet format check --- azure-pipelines/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/azure-pipelines/build.yml b/azure-pipelines/build.yml index df4c1ec63..28c558559 100644 --- a/azure-pipelines/build.yml +++ b/azure-pipelines/build.yml @@ -21,7 +21,7 @@ parameters: # This is just one of a a few mechanisms to enforce code style consistency. - name: EnableDotNetFormatCheck type: boolean - default: true + default: false # disable in v2.22 because it's defective (https://github.com/dotnet/sdk/issues/50262) # This lists the names of the artifacts that will be published *from every OS build agent*. # Any new tools/artifacts/*.ps1 script needs to be added to this list. # If an artifact is only generated or collected on one OS, it should NOT be listed here,