Skip to content

Commit e58d57b

Browse files
authored
Merge pull request #1248 from microsoft/dev/andarno/fixDynamicAssemblies_main
Create dynamic proxies in the same ALC as the interface they implement
2 parents 766617b + 075531e commit e58d57b

File tree

12 files changed

+252
-34
lines changed

12 files changed

+252
-34
lines changed

StreamJsonRpc.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,5 +32,6 @@
3232
<Project Path="test/StreamJsonRpc.Tests.ExternalAssembly/StreamJsonRpc.Tests.ExternalAssembly.csproj" />
3333
<Project Path="test/StreamJsonRpc.Tests.NoInterceptors/StreamJsonRpc.Tests.NoInterceptors.csproj" />
3434
<Project Path="test/StreamJsonRpc.Tests/StreamJsonRpc.Tests.csproj" />
35+
<Project Path="test/UnreachableAssembly/UnreachableAssembly.csproj" />
3536
</Folder>
3637
</Solution>

docfx/docs/proxies.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,32 @@ In which case, you can declare your server methods to also return @System.Thread
6161
Sometimes a client may need to block its caller until a response to a JSON-RPC request comes back.
6262
The proxy maintains the same async-only contract that is exposed by the <xref:StreamJsonRpc.JsonRpc> class itself.
6363
[Learn more about sending requests](sendrequest.md), particularly under the heading about async responses.
64+
65+
## Dynamic proxies
66+
67+
The following concerns are related specifically to dynamically generated proxies and do not apply to source generated proxies.
68+
69+
### AssemblyLoadContext considerations
70+
71+
When in a .NET process with multiple <xref:System.Runtime.Loader.AssemblyLoadContext> (ALC) instances, you should consider whether StreamJsonRpc is loaded in an ALC that can load all the types required by the proxy interface.
72+
73+
By default, StreamJsonRpc will generate dynamic proxies in the ALC that the (first) interface requested for the proxy is loaded within.
74+
This is usually the right choice because the interface should be in an ALC that can resolve all the interface's type references.
75+
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.
76+
The need to control this may manifest as an <xref:System.MissingMethodException> or <xref:System.InvalidCastException> due to types loading into multiple ALC instances.
77+
78+
In such cases, you may control the ALC used to generate the proxy by surrounding your proxy request with a call to <xref:System.Runtime.Loader.AssemblyLoadContext.EnterContextualReflection*> (and disposal of its result).
79+
80+
For example, you might use the following code when StreamJsonRpc is loaded into a different ALC from your own code:
81+
82+
```cs
83+
// Whatever ALC can resolve *all* type references in *all* proxy interfaces.
84+
AssemblyLoadContext alc = AssemblyLoadContext.GetLoadContext(MethodBase.GetCurrentMethod()!.DeclaringType!.Assembly);
85+
IFoo proxy;
86+
using (AssemblyLoadContext.EnterContextualReflection(alc))
87+
{
88+
proxy = (IFoo)jsonRpc.Attach([typeof(IFoo), typeof(IFoo2)]);
89+
}
90+
```
91+
92+
This initializes the `proxy` local variable with a proxy that will be able to load all types that your own <xref:System.Runtime.Loader.AssemblyLoadContext> can load.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
3+
using System.Reflection;
4+
5+
namespace StreamJsonRpc;
6+
7+
internal class AssemblyNameEqualityComparer : IEqualityComparer<AssemblyName>
8+
{
9+
internal static readonly IEqualityComparer<AssemblyName> Instance = new AssemblyNameEqualityComparer();
10+
11+
private AssemblyNameEqualityComparer()
12+
{
13+
}
14+
15+
public bool Equals(AssemblyName? x, AssemblyName? y)
16+
{
17+
if (x is null && y is null)
18+
{
19+
return true;
20+
}
21+
22+
if (x is null || y is null)
23+
{
24+
return false;
25+
}
26+
27+
return string.Equals(x.FullName, y.FullName, StringComparison.OrdinalIgnoreCase);
28+
}
29+
30+
public int GetHashCode(AssemblyName obj)
31+
{
32+
Requires.NotNull(obj, nameof(obj));
33+
34+
return StringComparer.OrdinalIgnoreCase.GetHashCode(obj.FullName);
35+
}
36+
}

src/StreamJsonRpc/ProxyGeneration.cs

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
using System.Globalization;
77
using System.Reflection;
88
using System.Reflection.Emit;
9+
#if NET
10+
using System.Runtime.Loader;
11+
#endif
912
using Microsoft.VisualStudio.Threading;
1013
using CodeGenHelpers = StreamJsonRpc.Reflection.CodeGenHelpers;
1114

@@ -19,7 +22,11 @@ namespace StreamJsonRpc;
1922
[RequiresDynamicCode(RuntimeReasons.RefEmit), RequiresUnreferencedCode(RuntimeReasons.RefEmit)]
2023
internal static class ProxyGeneration
2124
{
25+
#if NET
26+
private static readonly List<(AssemblyLoadContext, ImmutableHashSet<AssemblyName> SkipVisibilitySet, ModuleBuilder Builder)> TransparentProxyModuleBuilderByVisibilityCheck = [];
27+
#else
2228
private static readonly List<(ImmutableHashSet<AssemblyName> SkipVisibilitySet, ModuleBuilder Builder)> TransparentProxyModuleBuilderByVisibilityCheck = new List<(ImmutableHashSet<AssemblyName>, ModuleBuilder)>();
29+
#endif
2330
private static readonly object BuilderLock = new object();
2431
private static readonly AssemblyName ProxyAssemblyName = new AssemblyName(string.Format(CultureInfo.InvariantCulture, "StreamJsonRpc_Proxies_{0}", Guid.NewGuid()));
2532
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<Type> addition
110117
// Rpc interfaces must be sorted so that we implement methods from base interfaces before those from their derivations.
111118
SortRpcInterfaces(rpcInterfaces);
112119

120+
// For ALC selection reasons, it's vital that the *user's* selected interfaces come *before* our own supporting interfaces.
121+
// If the order is incorrect, type resolution may fail or the wrong AssemblyLoadContext (ALC) may be selected,
122+
// leading to runtime errors or unexpected behavior when loading types or invoking methods.
113123
Type[] proxyInterfaces = [.. rpcInterfaces.Select(i => i.Type), typeof(IJsonRpcClientProxy), typeof(IJsonRpcClientProxyInternal)];
114124
ModuleBuilder proxyModuleBuilder = GetProxyModuleBuilder(proxyInterfaces);
115125

@@ -783,10 +793,27 @@ private static ModuleBuilder GetProxyModuleBuilder(Type[] interfaceTypes)
783793
// For each set of skip visibility check assemblies, we need a dynamic assembly that skips at *least* that set.
784794
// The CLR will not honor any additions to that set once the first generated type is closed.
785795
// We maintain a dictionary to point at dynamic modules based on the set of skip visibility check assemblies they were generated with.
786-
ImmutableHashSet<AssemblyName> skipVisibilityCheckAssemblies = ImmutableHashSet.CreateRange(interfaceTypes.SelectMany(t => SkipClrVisibilityChecks.GetSkipVisibilityChecksRequirements(t.GetTypeInfo())))
796+
ImmutableHashSet<AssemblyName> skipVisibilityCheckAssemblies = ImmutableHashSet.CreateRange(AssemblyNameEqualityComparer.Instance, interfaceTypes.SelectMany(t => SkipClrVisibilityChecks.GetSkipVisibilityChecksRequirements(t.GetTypeInfo())))
787797
.Add(typeof(ProxyGeneration).Assembly.GetName());
798+
#if NET
799+
// We have to key the dynamic assembly by ALC as well, since callers may set a custom contextual reflection context
800+
// that influences how the assembly will resolve its type references.
801+
// If they haven't set a contextual one, we assume the ALC that defines the (first) proxy interface.
802+
AssemblyLoadContext alc = AssemblyLoadContext.CurrentContextualReflectionContext
803+
?? AssemblyLoadContext.GetLoadContext(interfaceTypes[0].Assembly)
804+
?? AssemblyLoadContext.GetLoadContext(typeof(ProxyGeneration).Assembly)
805+
?? throw new Exception("No ALC for our own assembly!");
806+
foreach ((AssemblyLoadContext AssemblyLoadContext, ImmutableHashSet<AssemblyName> SkipVisibilitySet, ModuleBuilder Builder) existingSet in TransparentProxyModuleBuilderByVisibilityCheck)
807+
{
808+
if (existingSet.AssemblyLoadContext != alc)
809+
{
810+
continue;
811+
}
812+
813+
#else
788814
foreach ((ImmutableHashSet<AssemblyName> SkipVisibilitySet, ModuleBuilder Builder) existingSet in TransparentProxyModuleBuilderByVisibilityCheck)
789815
{
816+
#endif
790817
if (existingSet.SkipVisibilitySet.IsSupersetOf(skipVisibilityCheckAssemblies))
791818
{
792819
return existingSet.Builder;
@@ -798,11 +825,22 @@ private static ModuleBuilder GetProxyModuleBuilder(Type[] interfaceTypes)
798825
// I have disabled this optimization though till we need it since it would sometimes cover up any bugs in the above visibility checking code.
799826
////skipVisibilityCheckAssemblies = skipVisibilityCheckAssemblies.Union(AppDomain.CurrentDomain.GetAssemblies().Select(a => a.GetName()));
800827

801-
AssemblyBuilder assemblyBuilder = CreateProxyAssemblyBuilder();
828+
AssemblyBuilder assemblyBuilder;
829+
#if NET
830+
using (alc.EnterContextualReflection())
831+
#endif
832+
{
833+
assemblyBuilder = CreateProxyAssemblyBuilder();
834+
}
835+
802836
ModuleBuilder moduleBuilder = assemblyBuilder.DefineDynamicModule("rpcProxies");
803837
var skipClrVisibilityChecks = new SkipClrVisibilityChecks(assemblyBuilder, moduleBuilder);
804838
skipClrVisibilityChecks.SkipVisibilityChecksFor(skipVisibilityCheckAssemblies);
839+
#if NET
840+
TransparentProxyModuleBuilderByVisibilityCheck.Add((alc, skipVisibilityCheckAssemblies, moduleBuilder));
841+
#else
805842
TransparentProxyModuleBuilderByVisibilityCheck.Add((skipVisibilityCheckAssemblies, moduleBuilder));
843+
#endif
806844

807845
return moduleBuilder;
808846
}

src/StreamJsonRpc/SkipClrVisibilityChecks.cs

Lines changed: 1 addition & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ internal static ImmutableHashSet<AssemblyName> GetSkipVisibilityChecksRequiremen
8282
Requires.NotNull(typeInfo, nameof(typeInfo));
8383

8484
var visitedTypes = new HashSet<TypeInfo>();
85-
ImmutableHashSet<AssemblyName>.Builder assembliesDeclaringInternalTypes = ImmutableHashSet.CreateBuilder<AssemblyName>(AssemblyNameEqualityComparer.Instance);
85+
ImmutableHashSet<AssemblyName>.Builder assembliesDeclaringInternalTypes = ImmutableHashSet.CreateBuilder(AssemblyNameEqualityComparer.Instance);
8686
CheckForNonPublicTypes(typeInfo, assembliesDeclaringInternalTypes, visitedTypes);
8787

8888
// Enumerate members on the interface that we're going to need to implement.
@@ -256,35 +256,4 @@ private TypeInfo EmitMagicAttribute()
256256

257257
return tb.CreateTypeInfo()!;
258258
}
259-
260-
private class AssemblyNameEqualityComparer : IEqualityComparer<AssemblyName>
261-
{
262-
internal static readonly IEqualityComparer<AssemblyName> Instance = new AssemblyNameEqualityComparer();
263-
264-
private AssemblyNameEqualityComparer()
265-
{
266-
}
267-
268-
public bool Equals(AssemblyName? x, AssemblyName? y)
269-
{
270-
if (x is null && y is null)
271-
{
272-
return true;
273-
}
274-
275-
if (x is null || y is null)
276-
{
277-
return false;
278-
}
279-
280-
return string.Equals(x.FullName, y.FullName, StringComparison.OrdinalIgnoreCase);
281-
}
282-
283-
public int GetHashCode(AssemblyName obj)
284-
{
285-
Requires.NotNull(obj, nameof(obj));
286-
287-
return StringComparer.OrdinalIgnoreCase.GetHashCode(obj.FullName);
288-
}
289-
}
290259
}

test/StreamJsonRpc.Tests.NoInterceptors/StreamJsonRpc.Tests.NoInterceptors.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515

1616
<ItemGroup>
1717
<Compile Include="..\StreamJsonRpc.Tests\JsonRpcProxyGenerationTests.cs" Link="JsonRpcProxyGenerationTests.cs" />
18+
<Compile Include="..\StreamJsonRpc.Tests\UnreachableAssemblyTools.cs" Link="UnreachableAssemblyTools.cs" />
1819
<Compile Include="..\StreamJsonRpc.Tests\Usings.cs" Link="Usings.cs" />
1920
</ItemGroup>
2021
<ItemGroup>
@@ -41,6 +42,7 @@
4142
<ItemGroup>
4243
<Reference Include="Microsoft.CSharp" Condition=" '$(TargetFramework)' == 'net472' " />
4344
</ItemGroup>
45+
<Import Project="..\UnreachableAssembly.targets" />
4446
<Import Project="$(RepoRootPath)src\AnalyzerUser.targets" />
4547
<PropertyGroup>
4648
<EnableStreamJsonRpcInterceptors>false</EnableStreamJsonRpcInterceptors>

test/StreamJsonRpc.Tests/JsonRpcProxyGenerationTests.cs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@
55

66
using System.Diagnostics;
77
using System.Reflection;
8+
#if NET
9+
using System.Runtime.Loader;
10+
#endif
811
using Microsoft.VisualStudio.Threading;
912
using Nerdbank;
1013
using StreamJsonRpc.Reflection;
14+
using StreamJsonRpc.Tests;
1115
using ExAssembly = StreamJsonRpc.Tests.ExternalAssembly;
1216

1317
public abstract partial class JsonRpcProxyGenerationTests : TestBase
@@ -162,6 +166,12 @@ public interface IServerWithGenericMethod
162166
Task AddAsync<T>(T a, T b);
163167
}
164168

169+
[JsonRpcContract]
170+
public partial interface IReferenceAnUnreachableAssembly
171+
{
172+
Task TakeAsync(UnreachableAssembly.SomeUnreachableClass obj);
173+
}
174+
165175
[JsonRpcContract]
166176
internal partial interface IServerInternal :
167177
ExAssembly.ISomeInternalProxyInterface,
@@ -880,6 +890,44 @@ public async Task ValueTaskReturningMethod()
880890
await clientRpc.DoSomethingValueAsync();
881891
}
882892

893+
/// <summary>
894+
/// Validates that similar proxies are generated in the same dynamic assembly.
895+
/// </summary>
896+
[Fact]
897+
public void ReuseDynamicAssembliesTest()
898+
{
899+
JsonRpc clientRpc = new(Stream.Null);
900+
IServer proxy1 = clientRpc.Attach<IServer>(this.DefaultProxyOptions);
901+
IServer2 proxy2 = clientRpc.Attach<IServer2>(this.DefaultProxyOptions);
902+
Assert.Same(proxy1.GetType().Assembly, proxy2.GetType().Assembly);
903+
}
904+
905+
#if NET
906+
[Fact]
907+
public void DynamicAssembliesKeyedByAssemblyLoadContext()
908+
{
909+
UnreachableAssemblyTools.VerifyUnreachableAssembly();
910+
911+
// Set up a new ALC that can find the hidden assembly, and ask for the proxy type.
912+
AssemblyLoadContext alc = UnreachableAssemblyTools.CreateContextForReachingTheUnreachable();
913+
914+
JsonRpc clientRpc = new(Stream.Null);
915+
916+
// Ensure we first generate a proxy in our own default ALC.
917+
// The goal being to emit a DynamicAssembly that we *might* reuse
918+
// for the later proxy for which the first DynamicAssembly is not appropriate.
919+
clientRpc.Attach<IServer>(this.DefaultProxyOptions);
920+
921+
// Now take very specific steps to invoke the rest of the test in the other AssemblyLoadContext.
922+
// This is important so that our IReferenceAnUnreachableAssembly type will be able to resolve its
923+
// own type references to UnreachableAssembly.dll, which our own default ALC cannot do.
924+
MethodInfo helperMethodInfo = typeof(JsonRpcProxyGenerationTests).GetMethod(nameof(DynamicAssembliesKeyedByAssemblyLoadContext_Helper), BindingFlags.NonPublic | BindingFlags.Static)!;
925+
MethodInfo helperWithinAlc = UnreachableAssemblyTools.LoadHelperInAlc(alc, helperMethodInfo);
926+
helperWithinAlc.Invoke(null, [this.DefaultProxyOptions]);
927+
}
928+
929+
#endif
930+
883931
protected T AttachJsonRpc<T>(Stream stream)
884932
where T : class
885933
{
@@ -889,6 +937,23 @@ protected T AttachJsonRpc<T>(Stream stream)
889937
return proxy;
890938
}
891939

940+
#if NET
941+
942+
private static void DynamicAssembliesKeyedByAssemblyLoadContext_Helper(JsonRpcProxyOptions options)
943+
{
944+
// Although this method executes within the special ALC,
945+
// StreamJsonRpc is loaded in the default ALC.
946+
// Therefore unless StreamJsonRpc is taking care to use a DynamicAssembly
947+
// that belongs to *this* ALC, it won't be able to resolve the same type references
948+
// that we can here (the ones from UnreachableAssembly).
949+
// That's what makes this test effective: it'll fail if the DynamicAssembly is shared across ALCs,
950+
// thereby verifying that StreamJsonRpc has a dedicated set of DynamicAssemblies for each ALC.
951+
JsonRpc clientRpc = new(Stream.Null);
952+
clientRpc.Attach<IReferenceAnUnreachableAssembly>(options);
953+
}
954+
955+
#endif
956+
892957
#if NO_INTERCEPTORS
893958
public class Dynamic(ITestOutputHelper logger) : JsonRpcProxyGenerationTests(logger, JsonRpcProxyOptions.ProxyImplementation.AlwaysDynamic);
894959
#else

test/StreamJsonRpc.Tests/StreamJsonRpc.Tests.csproj

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,5 +81,6 @@
8181
<ItemGroup>
8282
<Reference Include="Microsoft.CSharp" Condition=" '$(TargetFramework)' == 'net472' " />
8383
</ItemGroup>
84+
<Import Project="..\UnreachableAssembly.targets" />
8485
<Import Project="$(RepoRootPath)src\AnalyzerUser.targets" />
8586
</Project>
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
// Copyright (c) Microsoft Corporation. All rights reserved.
2+
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
3+
4+
#if NET
5+
6+
using System.Reflection;
7+
using System.Runtime.CompilerServices;
8+
using System.Runtime.Loader;
9+
10+
namespace StreamJsonRpc.Tests;
11+
12+
internal static class UnreachableAssemblyTools
13+
{
14+
/// <summary>
15+
/// Useful for tests to call before asserting conditions that depend on the UnreachableAssembly.dll
16+
/// actually being unreachable.
17+
/// </summary>
18+
internal static void VerifyUnreachableAssembly()
19+
{
20+
Assert.Throws<FileNotFoundException>(() => typeof(UnreachableAssembly.SomeUnreachableClass));
21+
}
22+
23+
/// <summary>
24+
/// Initializes an <see cref="AssemblyLoadContext"/> with UnreachableAssembly.dll loaded into it.
25+
/// </summary>
26+
/// <param name="testName">The name to give the <see cref="AssemblyLoadContext"/>.</param>
27+
/// <returns>The new <see cref="AssemblyLoadContext"/>.</returns>
28+
internal static AssemblyLoadContext CreateContextForReachingTheUnreachable([CallerMemberName] string? testName = null)
29+
{
30+
AssemblyLoadContext alc = new(testName);
31+
alc.LoadFromAssemblyPath(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location)!, "hidden", "UnreachableAssembly.dll"));
32+
return alc;
33+
}
34+
35+
/// <summary>
36+
/// Translates a <see cref="MethodInfo"/> from one ALC into another ALC, so that it can be invoked
37+
/// within the context of the new ALC.
38+
/// </summary>
39+
/// <param name="alc">The <see cref="AssemblyLoadContext"/> to load the method into.</param>
40+
/// <param name="helperMethodInfo">The <see cref="MethodInfo"/> of the method in the caller's ALC to load into the given <paramref name="alc"/>.</param>
41+
/// <returns>The translated <see cref="MethodInfo"/>.</returns>
42+
internal static MethodInfo LoadHelperInAlc(AssemblyLoadContext alc, MethodInfo helperMethodInfo)
43+
{
44+
Assembly selfWithinAlc = alc.LoadFromAssemblyPath(helperMethodInfo.DeclaringType!.Assembly.Location);
45+
MethodInfo helperWithinAlc = (MethodInfo)selfWithinAlc.ManifestModule.ResolveMethod(helperMethodInfo.MetadataToken)!;
46+
return helperWithinAlc;
47+
}
48+
}
49+
50+
#endif

test/UnreachableAssembly.targets

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<Project>
2+
<ItemGroup>
3+
<ProjectReference Include="..\UnreachableAssembly\UnreachableAssembly.csproj">
4+
<!-- We MUST NOT have this assembly in our test assembly's output directory so that it cannot be reached from the default ALC. -->
5+
<Private>false</Private>
6+
</ProjectReference>
7+
</ItemGroup>
8+
<Target Name="PlaceMissingAssembly" AfterTargets="ResolveReferences">
9+
<ItemGroup>
10+
<DivertedProjectReferenceOutputs Include="@(ReferencePath)" Condition=" '%(FileName)' == 'UnreachableAssembly' " />
11+
</ItemGroup>
12+
<Copy SourceFiles="@(DivertedProjectReferenceOutputs)" DestinationFolder="$(OutputPath)hidden" SkipUnchangedFiles="true" />
13+
</Target>
14+
</Project>

0 commit comments

Comments
 (0)