Skip to content

Commit 978f8ea

Browse files
committed
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.
1 parent 5461a74 commit 978f8ea

File tree

8 files changed

+183
-2
lines changed

8 files changed

+183
-2
lines changed

StreamJsonRpc.sln

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
Microsoft Visual Studio Solution File, Format Version 12.00
3-
# Visual Studio Version 17
4-
VisualStudioVersion = 17.0.31707.426
3+
# Visual Studio Version 18
4+
VisualStudioVersion = 18.0.10912.84 main
55
MinimumVisualStudioVersion = 10.0.40219.1
66
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "StreamJsonRpc", "src\StreamJsonRpc\StreamJsonRpc.csproj", "{DFBD1BCA-EAE0-4454-9E97-FA9BD9A0F03A}"
77
EndProject
@@ -38,6 +38,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{F446B894-5
3838
test\Directory.Build.targets = test\Directory.Build.targets
3939
EndProjectSection
4040
EndProject
41+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "UnreachableAssembly", "test\UnreachableAssembly\UnreachableAssembly.csproj", "{5AAF7DDA-6CC0-456B-A7E1-B33893915662}"
42+
EndProject
4143
Global
4244
GlobalSection(SolutionConfigurationPlatforms) = preSolution
4345
Debug|Any CPU = Debug|Any CPU
@@ -60,6 +62,10 @@ Global
6062
{5936CF62-A59D-4E5C-9EE4-5E8BAFA9F215}.Debug|Any CPU.Build.0 = Debug|Any CPU
6163
{5936CF62-A59D-4E5C-9EE4-5E8BAFA9F215}.Release|Any CPU.ActiveCfg = Release|Any CPU
6264
{5936CF62-A59D-4E5C-9EE4-5E8BAFA9F215}.Release|Any CPU.Build.0 = Release|Any CPU
65+
{5AAF7DDA-6CC0-456B-A7E1-B33893915662}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
66+
{5AAF7DDA-6CC0-456B-A7E1-B33893915662}.Debug|Any CPU.Build.0 = Debug|Any CPU
67+
{5AAF7DDA-6CC0-456B-A7E1-B33893915662}.Release|Any CPU.ActiveCfg = Release|Any CPU
68+
{5AAF7DDA-6CC0-456B-A7E1-B33893915662}.Release|Any CPU.Build.0 = Release|Any CPU
6369
EndGlobalSection
6470
GlobalSection(SolutionProperties) = preSolution
6571
HideSolutionNode = FALSE
@@ -69,6 +75,7 @@ Global
6975
{8BF355B2-E3B0-4615-BFC1-7563EADC4F8B} = {F446B894-56AA-4653-ADC0-5FFC911C9C13}
7076
{CEF0F77F-19EB-4C76-A050-854984BB0364} = {F446B894-56AA-4653-ADC0-5FFC911C9C13}
7177
{5936CF62-A59D-4E5C-9EE4-5E8BAFA9F215} = {F446B894-56AA-4653-ADC0-5FFC911C9C13}
78+
{5AAF7DDA-6CC0-456B-A7E1-B33893915662} = {F446B894-56AA-4653-ADC0-5FFC911C9C13}
7279
EndGlobalSection
7380
GlobalSection(ExtensibilityGlobals) = postSolution
7481
SolutionGuid = {4946F7E7-0619-414B-BE56-DDF0261CA8A9}

docfx/docs/dynamicproxy.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,3 +50,25 @@ between client and server.
5050
Sometimes a client may need to block its caller until a response to a JSON-RPC request comes back.
5151
The dynamic proxy maintains the same async-only contract that is exposed by the @StreamJsonRpc.JsonRpc class itself.
5252
[Learn more about sending requests](sendrequest.md), particularly under the heading about async responses.
53+
54+
## AssemblyLoadContext considerations
55+
56+
When in a .NET process with multiple <xref:System.Runtime.Loader.AssemblyLoadContext> instances, you should consider whether StreamJsonRpc is loaded in an <xref:System.Runtime.Loader.AssemblyLoadContext> that can load all the types required by the proxy interface.
57+
58+
By default, StreamJsonRpc will generate dynamic proxies in the <xref:System.Runtime.Loader.AssemblyLoadContext> that StreamJsonRpc is loaded within.
59+
This means that if your own code is running in a different <xref:System.Runtime.Loader.AssemblyLoadContext> 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.
60+
It might also manifest as an <xref:System.MissingMethodException> or <xref:System.InvalidCastException> due to types loading into multiple <xref:System.Runtime.Loader.AssemblyLoadContext> instances.
61+
62+
In such cases, you may control the <xref:System.Runtime.Loader.AssemblyLoadContext> 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).
63+
64+
For example, you might use the following code when StreamJsonRpc is loaded into a different <xref:System.Runtime.Loader.AssemblyLoadContext> from your own code:
65+
66+
```cs
67+
IMyService proxy;
68+
using (AssemblyLoadContext.EnterContextualReflection(MethodBase.GetCurrentMethod()!.DeclaringType!.Assembly))
69+
{
70+
proxy = jsonRpc.Attach<IMyService>();
71+
}
72+
```
73+
74+
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.

src/StreamJsonRpc/ProxyGeneration.cs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
using System.Globalization;
66
using System.Reflection;
77
using System.Reflection.Emit;
8+
#if NET
9+
using System.Runtime.Loader;
10+
#endif
811
using Microsoft.VisualStudio.Threading;
912
using StreamJsonRpc.Reflection;
1013
using CodeGenHelpers = StreamJsonRpc.Reflection.CodeGenHelpers;
@@ -18,7 +21,11 @@ namespace StreamJsonRpc;
1821

1922
internal static class ProxyGeneration
2023
{
24+
#if NET
25+
private static readonly List<(AssemblyLoadContext, ImmutableHashSet<AssemblyName> SkipVisibilitySet, ModuleBuilder Builder)> TransparentProxyModuleBuilderByVisibilityCheck = [];
26+
#else
2127
private static readonly List<(ImmutableHashSet<AssemblyName> SkipVisibilitySet, ModuleBuilder Builder)> TransparentProxyModuleBuilderByVisibilityCheck = new List<(ImmutableHashSet<AssemblyName>, ModuleBuilder)>();
28+
#endif
2229
private static readonly object BuilderLock = new object();
2330
private static readonly AssemblyName ProxyAssemblyName = new AssemblyName(string.Format(CultureInfo.InvariantCulture, "StreamJsonRpc_Proxies_{0}", Guid.NewGuid()));
2431
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)
754761
// We maintain a dictionary to point at dynamic modules based on the set of skip visibility check assemblies they were generated with.
755762
ImmutableHashSet<AssemblyName> skipVisibilityCheckAssemblies = ImmutableHashSet.CreateRange(AssemblyNameEqualityComparer.Instance, interfaceTypes.SelectMany(t => SkipClrVisibilityChecks.GetSkipVisibilityChecksRequirements(t.GetTypeInfo())))
756763
.Add(typeof(ProxyGeneration).Assembly.GetName());
764+
#if NET
765+
// We have to key the dynamic assembly by ALC as well, since callers may set a custom contextual reflection context
766+
// that influences how the assembly will resolve its type references.
767+
AssemblyLoadContext alc = AssemblyLoadContext.CurrentContextualReflectionContext ?? AssemblyLoadContext.GetLoadContext(typeof(ProxyGeneration).Assembly) ?? throw new Exception("No ALC for our own assembly!");
768+
foreach ((AssemblyLoadContext AssemblyLoadContext, ImmutableHashSet<AssemblyName> SkipVisibilitySet, ModuleBuilder Builder) existingSet in TransparentProxyModuleBuilderByVisibilityCheck)
769+
{
770+
if (existingSet.AssemblyLoadContext != alc)
771+
{
772+
continue;
773+
}
774+
775+
#else
757776
foreach ((ImmutableHashSet<AssemblyName> SkipVisibilitySet, ModuleBuilder Builder) existingSet in TransparentProxyModuleBuilderByVisibilityCheck)
758777
{
778+
#endif
759779
if (existingSet.SkipVisibilitySet.IsSupersetOf(skipVisibilityCheckAssemblies))
760780
{
761781
return existingSet.Builder;
@@ -771,7 +791,11 @@ private static ModuleBuilder GetProxyModuleBuilder(Type[] interfaceTypes)
771791
ModuleBuilder moduleBuilder = assemblyBuilder.DefineDynamicModule("rpcProxies");
772792
var skipClrVisibilityChecks = new SkipClrVisibilityChecks(assemblyBuilder, moduleBuilder);
773793
skipClrVisibilityChecks.SkipVisibilityChecksFor(skipVisibilityCheckAssemblies);
794+
#if NET
795+
TransparentProxyModuleBuilderByVisibilityCheck.Add((alc, skipVisibilityCheckAssemblies, moduleBuilder));
796+
#else
774797
TransparentProxyModuleBuilderByVisibilityCheck.Add((skipVisibilityCheckAssemblies, moduleBuilder));
798+
#endif
775799

776800
return moduleBuilder;
777801
}

test/StreamJsonRpc.Tests/JsonRpcProxyGenerationTests.cs

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
11
// Copyright (c) Microsoft Corporation. All rights reserved.
22
// Licensed under the MIT license. See LICENSE file in the project root for full license information.
33

4+
#if NET
5+
using System.Reflection;
6+
using System.Runtime.Loader;
7+
#endif
48
using Microsoft.VisualStudio.Threading;
59
using Nerdbank;
10+
using StreamJsonRpc.Tests;
611
using ExAssembly = StreamJsonRpc.Tests.ExternalAssembly;
712

813
public class JsonRpcProxyGenerationTests : TestBase
@@ -135,6 +140,11 @@ public interface IServerWithGenericMethod
135140
Task AddAsync<T>(T a, T b);
136141
}
137142

143+
public interface IReferenceAnUnreachableAssembly
144+
{
145+
Task TakeAsync(UnreachableAssembly.SomeUnreachableClass obj);
146+
}
147+
138148
internal interface IServerInternal :
139149
ExAssembly.ISomeInternalProxyInterface,
140150
IServerInternalWithInternalTypesFromOtherAssemblies,
@@ -810,6 +820,51 @@ public void ReuseDynamicAssembliesTest()
810820
Assert.Same(proxy1.GetType().Assembly, proxy2.GetType().Assembly);
811821
}
812822

823+
#if NET
824+
[Fact]
825+
public void DynamicAssembliesKeyedByAssemblyLoadContext()
826+
{
827+
UnreachableAssemblyTools.VerifyUnreachableAssembly();
828+
829+
// Set up a new ALC that can find the hidden assembly, and ask for the proxy type.
830+
AssemblyLoadContext alc = UnreachableAssemblyTools.CreateContextForReachingTheUnreachable();
831+
832+
JsonRpc clientRpc = new(Stream.Null);
833+
834+
// Ensure we first generate a proxy in our own default ALC.
835+
// The goal being to emit a DynamicAssembly that we *might* reuse
836+
// for the later proxy for which the first DynamicAssembly is not appropriate.
837+
clientRpc.Attach<IServer>();
838+
839+
// Now take very specific steps to invoke the rest of the test in the other AssemblyLoadContext.
840+
// This is important so that our IReferenceAnUnreachableAssembly type will be able to resolve its
841+
// own type references to UnreachableAssembly.dll, which our own default ALC cannot do.
842+
MethodInfo helperMethodInfo = typeof(JsonRpcProxyGenerationTests).GetMethod(nameof(DynamicAssembliesKeyedByAssemblyLoadContext_Helper), BindingFlags.NonPublic | BindingFlags.Static)!;
843+
MethodInfo helperWithinAlc = UnreachableAssemblyTools.LoadHelperInAlc(alc, helperMethodInfo);
844+
helperWithinAlc.Invoke(null, null);
845+
}
846+
847+
private static void DynamicAssembliesKeyedByAssemblyLoadContext_Helper()
848+
{
849+
// Although this method executes within the special ALC,
850+
// StreamJsonRpc is loaded in the default ALC.
851+
// Therefore unless StreamJsonRpc is taking care to use a DynamicAssembly
852+
// that belongs to *this* ALC, it won't be able to resolve the same type references
853+
// that we can here (the ones from UnreachableAssembly).
854+
// That's what makes this test effective: it'll fail if the DynamicAssembly is shared across ALCs,
855+
// thereby verifying that StreamJsonRpc has a dedicated set of DynamicAssemblies for each ALC.
856+
// We have to manually set the contextual reflection context to this special ALC
857+
// so that StreamJsonRpc knows to get the DynamicAssembly for this ALC.
858+
// Otherwise it will default to its own.
859+
using (AssemblyLoadContext.EnterContextualReflection(MethodBase.GetCurrentMethod()!.DeclaringType!.Assembly))
860+
{
861+
JsonRpc clientRpc = new(Stream.Null);
862+
clientRpc.Attach<IReferenceAnUnreachableAssembly>();
863+
}
864+
}
865+
866+
#endif
867+
813868
public class EmptyClass
814869
{
815870
}

test/StreamJsonRpc.Tests/StreamJsonRpc.Tests.csproj

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,10 @@
4747
</ItemGroup>
4848
<ItemGroup>
4949
<ProjectReference Include="..\StreamJsonRpc.Tests.ExternalAssembly\StreamJsonRpc.Tests.ExternalAssembly.csproj" />
50+
<ProjectReference Include="..\UnreachableAssembly\UnreachableAssembly.csproj">
51+
<!-- We MUST NOT have this assembly in our test assembly's output directory so that it cannot be reached from the default ALC. -->
52+
<Private>false</Private>
53+
</ProjectReference>
5054
<ProjectReference Include="..\..\src\StreamJsonRpc\StreamJsonRpc.csproj" />
5155
</ItemGroup>
5256
<ItemGroup>
@@ -68,4 +72,10 @@
6872
<ItemGroup>
6973
<Reference Include="Microsoft.CSharp" Condition=" '$(TargetFramework)' == 'net472' " />
7074
</ItemGroup>
75+
<Target Name="PlaceMissingAssembly" AfterTargets="ResolveReferences">
76+
<ItemGroup>
77+
<DivertedProjectReferenceOutputs Include="@(ReferencePath)" Condition=" '%(FileName)' == 'UnreachableAssembly' " />
78+
</ItemGroup>
79+
<Copy SourceFiles="@(DivertedProjectReferenceOutputs)" DestinationFolder="$(OutputPath)hidden" SkipUnchangedFiles="true" />
80+
</Target>
7181
</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
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
namespace UnreachableAssembly;
2+
3+
public class SomeUnreachableClass
4+
{
5+
}
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFrameworks>net8.0</TargetFrameworks>
5+
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('Windows'))">$(TargetFrameworks);net472</TargetFrameworks>
6+
</PropertyGroup>
7+
8+
</Project>

0 commit comments

Comments
 (0)