diff --git a/StreamJsonRpc.slnx b/StreamJsonRpc.slnx index f7ef07249..9cce2c878 100644 --- a/StreamJsonRpc.slnx +++ b/StreamJsonRpc.slnx @@ -32,5 +32,6 @@ + diff --git a/docfx/docs/proxies.md b/docfx/docs/proxies.md index d0116335c..704656ea4 100644 --- a/docfx/docs/proxies.md +++ b/docfx/docs/proxies.md @@ -61,3 +61,32 @@ In which case, you can declare your server methods to also return @System.Thread Sometimes a client may need to block its caller until a response to a JSON-RPC request comes back. The proxy maintains the same async-only contract that is exposed by the class itself. [Learn more about sending requests](sendrequest.md), particularly under the heading about async responses. + +## Dynamic proxies + +The following concerns are related specifically to dynamically generated proxies and do not apply to source generated proxies. + +### AssemblyLoadContext considerations + +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 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 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 ALC from your own code: + +```cs +// 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 = (IFoo)jsonRpc.Attach([typeof(IFoo), typeof(IFoo2)]); +} +``` + +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/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 7b4cae738..f0025c975 100644 --- a/src/StreamJsonRpc/ProxyGeneration.cs +++ b/src/StreamJsonRpc/ProxyGeneration.cs @@ -6,6 +6,9 @@ using System.Globalization; using System.Reflection; using System.Reflection.Emit; +#if NET +using System.Runtime.Loader; +#endif using Microsoft.VisualStudio.Threading; using CodeGenHelpers = StreamJsonRpc.Reflection.CodeGenHelpers; @@ -19,7 +22,11 @@ namespace StreamJsonRpc; [RequiresDynamicCode(RuntimeReasons.RefEmit), RequiresUnreferencedCode(RuntimeReasons.RefEmit)] 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) })!; @@ -110,6 +117,9 @@ 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. + // 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); @@ -783,10 +793,27 @@ 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()); +#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. + // 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) + { + continue; + } + +#else foreach ((ImmutableHashSet SkipVisibilitySet, ModuleBuilder Builder) existingSet in TransparentProxyModuleBuilderByVisibilityCheck) { +#endif if (existingSet.SkipVisibilitySet.IsSupersetOf(skipVisibilityCheckAssemblies)) { return existingSet.Builder; @@ -798,11 +825,22 @@ 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); +#if NET + TransparentProxyModuleBuilderByVisibilityCheck.Add((alc, skipVisibilityCheckAssemblies, moduleBuilder)); +#else TransparentProxyModuleBuilderByVisibilityCheck.Add((skipVisibilityCheckAssemblies, moduleBuilder)); +#endif return moduleBuilder; } diff --git a/src/StreamJsonRpc/SkipClrVisibilityChecks.cs b/src/StreamJsonRpc/SkipClrVisibilityChecks.cs index 6f094d9ab..491d11b20 100644 --- a/src/StreamJsonRpc/SkipClrVisibilityChecks.cs +++ b/src/StreamJsonRpc/SkipClrVisibilityChecks.cs @@ -82,7 +82,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. @@ -256,35 +256,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.NoInterceptors/StreamJsonRpc.Tests.NoInterceptors.csproj b/test/StreamJsonRpc.Tests.NoInterceptors/StreamJsonRpc.Tests.NoInterceptors.csproj index 6b480336d..ae705836b 100644 --- a/test/StreamJsonRpc.Tests.NoInterceptors/StreamJsonRpc.Tests.NoInterceptors.csproj +++ b/test/StreamJsonRpc.Tests.NoInterceptors/StreamJsonRpc.Tests.NoInterceptors.csproj @@ -15,6 +15,7 @@ + @@ -41,6 +42,7 @@ + false diff --git a/test/StreamJsonRpc.Tests/JsonRpcProxyGenerationTests.cs b/test/StreamJsonRpc.Tests/JsonRpcProxyGenerationTests.cs index 3cbfa22fd..b8938e7d9 100644 --- a/test/StreamJsonRpc.Tests/JsonRpcProxyGenerationTests.cs +++ b/test/StreamJsonRpc.Tests/JsonRpcProxyGenerationTests.cs @@ -5,9 +5,13 @@ using System.Diagnostics; using System.Reflection; +#if NET +using System.Runtime.Loader; +#endif using Microsoft.VisualStudio.Threading; using Nerdbank; using StreamJsonRpc.Reflection; +using StreamJsonRpc.Tests; using ExAssembly = StreamJsonRpc.Tests.ExternalAssembly; public abstract partial class JsonRpcProxyGenerationTests : TestBase @@ -162,6 +166,12 @@ public interface IServerWithGenericMethod Task AddAsync(T a, T b); } + [JsonRpcContract] + public partial interface IReferenceAnUnreachableAssembly + { + Task TakeAsync(UnreachableAssembly.SomeUnreachableClass obj); + } + [JsonRpcContract] internal partial interface IServerInternal : ExAssembly.ISomeInternalProxyInterface, @@ -880,6 +890,44 @@ 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(this.DefaultProxyOptions); + IServer2 proxy2 = clientRpc.Attach(this.DefaultProxyOptions); + 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(this.DefaultProxyOptions); + + // 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, [this.DefaultProxyOptions]); + } + +#endif + protected T AttachJsonRpc(Stream stream) where T : class { @@ -889,6 +937,23 @@ protected T AttachJsonRpc(Stream stream) return proxy; } +#if NET + + private static void DynamicAssembliesKeyedByAssemblyLoadContext_Helper(JsonRpcProxyOptions options) + { + // 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. + JsonRpc clientRpc = new(Stream.Null); + clientRpc.Attach(options); + } + +#endif + #if NO_INTERCEPTORS public class Dynamic(ITestOutputHelper logger) : JsonRpcProxyGenerationTests(logger, JsonRpcProxyOptions.ProxyImplementation.AlwaysDynamic); #else diff --git a/test/StreamJsonRpc.Tests/StreamJsonRpc.Tests.csproj b/test/StreamJsonRpc.Tests/StreamJsonRpc.Tests.csproj index 80dd6c217..3fb23614a 100644 --- a/test/StreamJsonRpc.Tests/StreamJsonRpc.Tests.csproj +++ b/test/StreamJsonRpc.Tests/StreamJsonRpc.Tests.csproj @@ -81,5 +81,6 @@ + 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.targets b/test/UnreachableAssembly.targets new file mode 100644 index 000000000..ae09558fb --- /dev/null +++ b/test/UnreachableAssembly.targets @@ -0,0 +1,14 @@ + + + + + false + + + + + + + + + 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 + + +