Skip to content

Commit 6237a30

Browse files
authored
Cache MEF composition in OOP and VS Code (#12041)
Fixes #11830 Code mostly stolen from Todd who stole it from Joey. Mistakes are all me though 😛
2 parents aa3cf97 + 827b5d7 commit 6237a30

File tree

13 files changed

+351
-16
lines changed

13 files changed

+351
-16
lines changed

eng/targets/Services.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
<ServiceHubService Include="Microsoft.VisualStudio.Razor.SemanticTokens" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteSemanticTokensService+Factory" />
2020
<ServiceHubService Include="Microsoft.VisualStudio.Razor.HtmlDocument" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteHtmlDocumentService+Factory" />
2121
<ServiceHubService Include="Microsoft.VisualStudio.Razor.TagHelperProvider" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteTagHelperProviderService+Factory"/>
22+
<ServiceHubService Include="Microsoft.VisualStudio.Razor.MEFInitialization" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteMEFInitializationService+Factory" />
2223
<ServiceHubService Include="Microsoft.VisualStudio.Razor.ClientInitialization" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteClientInitializationService+Factory" />
2324
<ServiceHubService Include="Microsoft.VisualStudio.Razor.UriPresentation" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteUriPresentationService+Factory" />
2425
<ServiceHubService Include="Microsoft.VisualStudio.Razor.FoldingRange" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteFoldingRangeService+Factory" />
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
7+
namespace Microsoft.CodeAnalysis.Razor.Remote;
8+
9+
internal interface IRemoteMEFInitializationService : IRemoteJsonService
10+
{
11+
ValueTask InitializeAsync(string cacheDirectory, CancellationToken cancellationToken);
12+
}

src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/RazorServices.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ internal static class RazorServices
4343
(typeof(IRemoteCompletionService), null),
4444
(typeof(IRemoteCodeActionsService), null),
4545
(typeof(IRemoteFindAllReferencesService), null),
46+
(typeof(IRemoteMEFInitializationService), null),
4647
];
4748

4849
private const string ComponentName = "Razor";
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Licensed to the .NET Foundation under one or more agreements.
2+
// The .NET Foundation licenses this file to you under the MIT license.
3+
4+
using System;
5+
using System.Diagnostics;
6+
using System.IO;
7+
using System.Threading;
8+
using System.Threading.Tasks;
9+
using Microsoft.AspNetCore.Razor;
10+
using Microsoft.CodeAnalysis.ExternalAccess.Razor.Api;
11+
using Microsoft.CodeAnalysis.Razor.Remote;
12+
using Microsoft.ServiceHub.Framework;
13+
using static Microsoft.CodeAnalysis.Remote.Razor.RazorBrokeredServiceBase;
14+
15+
namespace Microsoft.CodeAnalysis.Remote.Razor;
16+
17+
/// <summary>
18+
/// A special service that is used to initialize the MEF composition for Razor in the remote host.
19+
/// </summary>
20+
/// <remarks>
21+
/// It's special because it doesn't use MEF. Nor can it use anything else really.
22+
/// </remarks>
23+
internal sealed class RemoteMEFInitializationService : IRemoteMEFInitializationService
24+
{
25+
internal sealed class Factory : FactoryBase<IRemoteMEFInitializationService>
26+
{
27+
protected override Task<object> CreateInternalAsync(Stream? stream, IServiceProvider hostProvidedServices, IServiceBroker? serviceBroker)
28+
{
29+
var traceSource = (TraceSource?)hostProvidedServices.GetService(typeof(TraceSource));
30+
31+
var service = new RemoteMEFInitializationService();
32+
if (stream is not null)
33+
{
34+
var serverConnection = CreateServerConnection(stream, traceSource);
35+
ConnectService(serverConnection, service);
36+
}
37+
38+
return Task.FromResult<object>(service);
39+
}
40+
41+
protected override IRemoteMEFInitializationService CreateService(in ServiceArgs args)
42+
=> Assumed.Unreachable<IRemoteMEFInitializationService>("This service overrides CreateInternalAsync to avoid MEF instatiation, so the CreateService method should never be called.");
43+
}
44+
45+
public ValueTask InitializeAsync(string cacheDirectory, CancellationToken cancellationToken)
46+
{
47+
return RazorBrokeredServiceImplementation.RunServiceAsync(_ =>
48+
{
49+
RemoteMefComposition.CacheDirectory = cacheDirectory;
50+
return new();
51+
}, cancellationToken);
52+
}
53+
}

src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/RazorBrokeredServiceBase.FactoryBase`1.cs

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ private Task<object> CreateAsync(Stream stream, IServiceProvider hostProvidedSer
6060
#endif
6161
}
6262

63-
protected async Task<object> CreateInternalAsync(
63+
protected virtual async Task<object> CreateInternalAsync(
6464
Stream? stream,
6565
IServiceProvider hostProvidedServices,
6666
IServiceBroker? serviceBroker)
@@ -103,21 +103,31 @@ protected async Task<object> CreateInternalAsync(
103103
return CreateService(in inProcArgs);
104104
}
105105

106+
var serverConnection = CreateServerConnection(stream, traceSource);
107+
108+
var args = new ServiceArgs(serviceBroker.AssumeNotNull(), exportProvider, targetLoggerFactory, workspaceProvider, serverConnection, brokeredServiceData?.Interceptor);
109+
var service = CreateService(in args);
110+
111+
ConnectService(serverConnection, service);
112+
113+
return service;
114+
}
115+
116+
protected static ServiceRpcDescriptor.RpcConnection CreateServerConnection(Stream stream, TraceSource? traceSource)
117+
{
106118
var pipe = stream.UsePipe();
107119

108120
var descriptor = typeof(IRemoteJsonService).IsAssignableFrom(typeof(TService))
109121
? RazorServices.JsonDescriptors.GetDescriptorForServiceFactory(typeof(TService))
110122
: RazorServices.Descriptors.GetDescriptorForServiceFactory(typeof(TService));
111123
var serverConnection = descriptor.WithTraceSource(traceSource).ConstructRpcConnection(pipe);
124+
return serverConnection;
125+
}
112126

113-
var args = new ServiceArgs(serviceBroker.AssumeNotNull(), exportProvider, targetLoggerFactory, workspaceProvider, serverConnection, brokeredServiceData?.Interceptor);
114-
115-
var service = CreateService(in args);
116-
127+
protected static void ConnectService(ServiceRpcDescriptor.RpcConnection serverConnection, TService service)
128+
{
117129
serverConnection.AddLocalRpcTarget(service);
118130
serverConnection.StartListening();
119-
120-
return service;
121131
}
122132
}
123133
}

src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/RemoteMefComposition.cs

Lines changed: 134 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using System;
45
using System.Collections.Immutable;
6+
using System.Diagnostics.CodeAnalysis;
7+
using System.IO;
58
using System.Reflection;
69
using System.Threading;
710
using System.Threading.Tasks;
11+
using Microsoft.AspNetCore.Razor;
12+
using Microsoft.AspNetCore.Razor.Utilities;
813
using Microsoft.VisualStudio.Composition;
914
using Microsoft.VisualStudio.Threading;
1015

@@ -21,9 +26,13 @@ internal sealed class RemoteMefComposition
2126
joinableTaskFactory: null);
2227

2328
private static readonly AsyncLazy<ExportProvider> s_lazyExportProvider = new(
24-
static () => CreateExportProviderAsync(CancellationToken.None),
29+
static () => CreateExportProviderAsync(CacheDirectory, CancellationToken.None),
2530
joinableTaskFactory: null);
2631

32+
private static Task? s_saveCacheFileTask;
33+
34+
public static string? CacheDirectory { get; set; }
35+
2736
/// <summary>
2837
/// Gets a <see cref="CompositionConfiguration"/> built from <see cref="Assemblies"/>. Note that the
2938
/// same <see cref="CompositionConfiguration"/> instance is returned for subsequent calls to this method.
@@ -52,17 +61,125 @@ private static async Task<CompositionConfiguration> CreateConfigurationAsync(Can
5261
/// Creates a new MEF composition and returns an <see cref="ExportProvider"/>. The catalog and configuration
5362
/// are reused for subsequent calls to this method.
5463
/// </summary>
55-
public static async Task<ExportProvider> CreateExportProviderAsync(CancellationToken cancellationToken)
64+
public static async Task<ExportProvider> CreateExportProviderAsync(string? cacheDirectory, CancellationToken cancellationToken)
5665
{
66+
var cache = new CachedComposition();
67+
var compositionCacheFile = GetCompositionCacheFile(cacheDirectory);
68+
if (await TryLoadCachedExportProviderAsync(cache, compositionCacheFile, cancellationToken).ConfigureAwait(false) is { } cachedProvider)
69+
{
70+
return cachedProvider;
71+
}
72+
5773
var configuration = await s_lazyConfiguration.GetValueAsync(cancellationToken).ConfigureAwait(false);
5874
cancellationToken.ThrowIfCancellationRequested();
5975

6076
var runtimeComposition = RuntimeComposition.CreateRuntimeComposition(configuration);
6177
var exportProviderFactory = runtimeComposition.CreateExportProviderFactory();
6278

79+
// We don't need to block on saving the cache, because if it fails or is corrupt, we'll just try again next time, but
80+
// we capture the task just so that tests can verify things.
81+
s_saveCacheFileTask = TrySaveCachedExportProviderAsync(cache, compositionCacheFile, runtimeComposition, cancellationToken);
82+
6383
return exportProviderFactory.CreateExportProvider();
6484
}
6585

86+
private static async Task<ExportProvider?> TryLoadCachedExportProviderAsync(CachedComposition cache, string? compositionCacheFile, CancellationToken cancellationToken)
87+
{
88+
if (compositionCacheFile is null)
89+
{
90+
return null;
91+
}
92+
93+
try
94+
{
95+
if (File.Exists(compositionCacheFile))
96+
{
97+
var resolver = new Resolver(SimpleAssemblyLoader.Instance);
98+
using var cacheStream = new FileStream(compositionCacheFile, FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize: 4096, useAsync: true);
99+
var cachedFactory = await cache.LoadExportProviderFactoryAsync(cacheStream, resolver, cancellationToken).ConfigureAwait(false);
100+
return cachedFactory.CreateExportProvider();
101+
}
102+
}
103+
catch (Exception)
104+
{
105+
// We ignore all errors when loading the cache, because if the cache is corrupt we will just create a new export provider.
106+
}
107+
108+
return null;
109+
}
110+
111+
private static async Task TrySaveCachedExportProviderAsync(CachedComposition cache, string? compositionCacheFile, RuntimeComposition runtimeComposition, CancellationToken cancellationToken)
112+
{
113+
if (compositionCacheFile is null)
114+
{
115+
return;
116+
}
117+
118+
try
119+
{
120+
var cacheDirectory = Path.GetDirectoryName(compositionCacheFile).AssumeNotNull();
121+
var directoryInfo = Directory.CreateDirectory(cacheDirectory);
122+
123+
CleanCacheDirectory(directoryInfo, cancellationToken);
124+
125+
var tempFilePath = Path.Combine(cacheDirectory, Path.GetRandomFileName());
126+
using (var cacheStream = new FileStream(compositionCacheFile, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 4096, useAsync: true))
127+
{
128+
await cache.SaveAsync(runtimeComposition, cacheStream, cancellationToken).ConfigureAwait(false);
129+
}
130+
131+
File.Move(tempFilePath, compositionCacheFile);
132+
}
133+
catch (Exception)
134+
{
135+
// We ignore all errors when saving the cache, because if something goes wrong, the next run will just create a new export provider.
136+
}
137+
}
138+
139+
private static void CleanCacheDirectory(DirectoryInfo directoryInfo, CancellationToken cancellationToken)
140+
{
141+
try
142+
{
143+
// Delete any existing cached files.
144+
foreach (var fileInfo in directoryInfo.EnumerateFiles())
145+
{
146+
// Failing to delete any file is fine, we'll just try again the next VS session in which we attempt
147+
// to write a new cache
148+
fileInfo.Delete();
149+
cancellationToken.ThrowIfCancellationRequested();
150+
}
151+
}
152+
catch (Exception)
153+
{
154+
// We ignore all errors when cleaning the cache directory, because we'll try again if the cache is corrupt.
155+
}
156+
}
157+
158+
[return: NotNullIfNotNull(nameof(cacheDirectory))]
159+
private static string? GetCompositionCacheFile(string? cacheDirectory)
160+
{
161+
if (cacheDirectory is null)
162+
{
163+
return null;
164+
}
165+
166+
var checksum = new Checksum.Builder();
167+
foreach (var assembly in Assemblies)
168+
{
169+
var assemblyPath = assembly.Location.AssumeNotNull();
170+
checksum.AppendData(Path.GetFileName(assemblyPath));
171+
checksum.AppendData(File.GetLastWriteTimeUtc(assemblyPath).ToString("F"));
172+
}
173+
174+
// Create base64 string of the hash.
175+
var hashAsBase64String = checksum.FreeAndGetChecksum().ToBase64String();
176+
177+
// Convert to filename safe base64 string.
178+
hashAsBase64String = hashAsBase64String.Replace('+', '-').Replace('/', '_').TrimEnd('=');
179+
180+
return Path.Combine(cacheDirectory, $"razor.mef.{hashAsBase64String}.cache");
181+
}
182+
66183
private sealed class SimpleAssemblyLoader : IAssemblyLoader
67184
{
68185
public static readonly IAssemblyLoader Instance = new SimpleAssemblyLoader();
@@ -83,4 +200,19 @@ public Assembly LoadAssembly(string assemblyFullName, string? codeBasePath)
83200
return LoadAssembly(assemblyName);
84201
}
85202
}
203+
204+
public static class TestAccessor
205+
{
206+
public static Task? SaveCacheFileTask => s_saveCacheFileTask;
207+
208+
public static void ClearSaveCacheFileTask()
209+
{
210+
s_saveCacheFileTask = null;
211+
}
212+
213+
public static string GetCacheCompositionFile(string cacheDirectory)
214+
{
215+
return GetCompositionCacheFile(cacheDirectory);
216+
}
217+
}
86218
}

src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/Remote/RemoteServiceInvoker.cs

Lines changed: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@
1616
using Microsoft.CodeAnalysis.Razor.SemanticTokens;
1717
using Microsoft.CodeAnalysis.Razor.Telemetry;
1818
using Microsoft.CodeAnalysis.Razor.Workspaces;
19+
using Microsoft.VisualStudio.Settings;
20+
using Microsoft.VisualStudio.Shell;
21+
using Microsoft.VisualStudio.Shell.Settings;
1922

2023
namespace Microsoft.VisualStudio.Razor.Remote;
2124

@@ -26,12 +29,14 @@ internal sealed class RemoteServiceInvoker(
2629
LanguageServerFeatureOptions languageServerFeatureOptions,
2730
IClientCapabilitiesService clientCapabilitiesService,
2831
ISemanticTokensLegendService semanticTokensLegendService,
32+
SVsServiceProvider serviceProvider,
2933
ITelemetryReporter telemetryReporter,
3034
ILoggerFactory loggerFactory) : IRemoteServiceInvoker, IDisposable
3135
{
3236
private readonly LanguageServerFeatureOptions _languageServerFeatureOptions = languageServerFeatureOptions;
3337
private readonly IClientCapabilitiesService _clientCapabilitiesService = clientCapabilitiesService;
3438
private readonly ISemanticTokensLegendService _semanticTokensLegendService = semanticTokensLegendService;
39+
private readonly IServiceProvider _serviceProvider = serviceProvider;
3540
private readonly ITelemetryReporter _telemetryReporter = telemetryReporter;
3641
private readonly ILogger _logger = loggerFactory.GetOrCreateLogger<RemoteServiceInvoker>();
3742

@@ -163,8 +168,15 @@ async Task InitializeCoreAsync(bool oopInitialized, bool lspInitialized)
163168
await _initializeLspTask.ConfigureAwait(false);
164169
}
165170

166-
Task InitializeOOPAsync(RazorRemoteHostClient remoteClient)
171+
async Task InitializeOOPAsync(RazorRemoteHostClient remoteClient)
167172
{
173+
// The first call to OOP must be to initialize the MEF services, because everything after that relies on MEF.
174+
var localSettingsDirectory = new ShellSettingsManager(_serviceProvider).GetApplicationDataFolder(ApplicationDataFolder.LocalSettings);
175+
var cacheDirectory = Path.Combine(localSettingsDirectory, "Razor", "RemoteMEFCache");
176+
await remoteClient.TryInvokeAsync<IRemoteMEFInitializationService>(
177+
(s, ct) => s.InitializeAsync(cacheDirectory, ct),
178+
_disposeTokenSource.Token).ConfigureAwait(false);
179+
168180
var initParams = new RemoteClientInitializationOptions
169181
{
170182
UseRazorCohostServer = _languageServerFeatureOptions.UseRazorCohostServer,
@@ -179,11 +191,10 @@ Task InitializeOOPAsync(RazorRemoteHostClient remoteClient)
179191

180192
_logger.LogDebug($"First OOP call, so initializing OOP service.");
181193

182-
return remoteClient
194+
await remoteClient
183195
.TryInvokeAsync<IRemoteClientInitializationService>(
184196
(s, ct) => s.InitializeAsync(initParams, ct),
185-
_disposeTokenSource.Token)
186-
.AsTask();
197+
_disposeTokenSource.Token).ConfigureAwait(false);
187198
}
188199

189200
Task InitializeLspAsync(RazorRemoteHostClient remoteClient)

src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/VSCodeRemoteServicesInitializer.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,15 @@
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
using System.Composition;
5+
using System.IO;
56
using System.Threading;
67
using System.Threading.Tasks;
78
using Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost;
89
using Microsoft.CodeAnalysis.Razor.Logging;
910
using Microsoft.CodeAnalysis.Razor.Remote;
1011
using Microsoft.CodeAnalysis.Razor.SemanticTokens;
1112
using Microsoft.CodeAnalysis.Razor.Workspaces;
13+
using Microsoft.CodeAnalysis.Remote.Razor;
1214
using Microsoft.VisualStudio.Razor.LanguageClient.Cohost;
1315

1416
namespace Microsoft.VisualStudioCode.RazorExtension.Services;
@@ -41,6 +43,9 @@ public async Task StartupAsync(VSInternalClientCapabilities clientCapabilities,
4143
// we know this is VS Code specific, its all just smoke and mirrors anyway. We can avoid the smoke :)
4244
var serviceInterceptor = new VSCodeBrokeredServiceInterceptor();
4345

46+
// First things first, set the cache directory for the MEF composition.
47+
RemoteMefComposition.CacheDirectory = Path.Combine(Path.GetDirectoryName(this.GetType().Assembly.Location)!, "cache");
48+
4449
var logger = _loggerFactory.GetOrCreateLogger<VSCodeRemoteServicesInitializer>();
4550
logger.LogDebug("Initializing remote services.");
4651
var service = await InProcServiceFactory.CreateServiceAsync<IRemoteClientInitializationService>(serviceInterceptor, _workspaceProvider, _loggerFactory).ConfigureAwait(false);

src/Razor/test/Microsoft.CodeAnalysis.Remote.Razor.Test/RazorServicesTest.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,6 @@ public void JsonServicesHaveTheRightParameters(Type serviceType, Type? _)
5454
{
5555
Assert.Fail($"Method {method.Name} in a Json service has a pinned solution info wrapper parameter that isn't Json serializable");
5656
}
57-
5857
}
5958
}
6059
}

0 commit comments

Comments
 (0)