Cache assemblies and wasm using content hashes (#18859)

This commit is contained in:
Steve Sanderson 2020-02-17 17:17:44 +00:00 committed by GitHub
parent e0fe30ce56
commit 4628dfb005
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 815 additions and 206 deletions

View File

@ -2,12 +2,14 @@
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.Serialization.Json;
using System.Text;
using Microsoft.Build.Framework;
using Microsoft.Build.Utilities;
using ResourceHashesByNameDictionary = System.Collections.Generic.Dictionary<string, string>;
namespace Microsoft.AspNetCore.Blazor.Build
{
@ -17,53 +19,107 @@ namespace Microsoft.AspNetCore.Blazor.Build
public string AssemblyPath { get; set; }
[Required]
public ITaskItem[] References { get; set; }
public ITaskItem[] Resources { get; set; }
[Required]
public bool DebugBuild { get; set; }
[Required]
public bool LinkerEnabled { get; set; }
[Required]
public bool CacheBootResources { get; set; }
[Required]
public string OutputPath { get; set; }
public override bool Execute()
{
var entryAssemblyName = AssemblyName.GetAssemblyName(AssemblyPath).Name;
var assemblies = References.Select(GetUriPath).OrderBy(c => c, StringComparer.Ordinal).ToArray();
using var fileStream = File.Create(OutputPath);
WriteBootJson(fileStream, entryAssemblyName, assemblies, LinkerEnabled);
var entryAssemblyName = AssemblyName.GetAssemblyName(AssemblyPath).Name;
return true;
static string GetUriPath(ITaskItem item)
try
{
var outputPath = item.GetMetadata("RelativeOutputPath");
if (string.IsNullOrEmpty(outputPath))
{
outputPath = Path.GetFileName(item.ItemSpec);
}
return outputPath.Replace('\\', '/');
WriteBootJson(fileStream, entryAssemblyName);
}
catch (Exception ex)
{
Log.LogErrorFromException(ex);
}
return !Log.HasLoggedErrors;
}
internal static void WriteBootJson(Stream stream, string entryAssemblyName, string[] assemblies, bool linkerEnabled)
// Internal for tests
internal void WriteBootJson(Stream output, string entryAssemblyName)
{
var data = new BootJsonData
var result = new BootJsonData
{
entryAssembly = entryAssemblyName,
assemblies = assemblies,
linkerEnabled = linkerEnabled,
cacheBootResources = CacheBootResources,
debugBuild = DebugBuild,
linkerEnabled = LinkerEnabled,
resources = new Dictionary<ResourceType, ResourceHashesByNameDictionary>()
};
var serializer = new DataContractJsonSerializer(typeof(BootJsonData));
serializer.WriteObject(stream, data);
// Build a two-level dictionary of the form:
// - BootResourceType (e.g., "assembly")
// - UriPath (e.g., "System.Text.Json.dll")
// - ContentHash (e.g., "4548fa2e9cf52986")
if (Resources != null)
{
foreach (var resource in Resources)
{
var resourceTypeMetadata = resource.GetMetadata("BootResourceType");
if (!Enum.TryParse<ResourceType>(resourceTypeMetadata, out var resourceType))
{
throw new NotSupportedException($"Unsupported BootResourceType metadata value: {resourceTypeMetadata}");
}
if (!result.resources.TryGetValue(resourceType, out var resourceList))
{
resourceList = new ResourceHashesByNameDictionary();
result.resources.Add(resourceType, resourceList);
}
var resourceFileRelativePath = GetResourceFileRelativePath(resource);
if (!resourceList.ContainsKey(resourceFileRelativePath))
{
resourceList.Add(resourceFileRelativePath, $"sha256-{resource.GetMetadata("FileHash")}");
}
}
}
var serializer = new DataContractJsonSerializer(typeof(BootJsonData), new DataContractJsonSerializerSettings
{
UseSimpleDictionaryFormat = true
});
using var writer = JsonReaderWriterFactory.CreateJsonWriter(output, Encoding.UTF8, ownsStream: false, indent: true);
serializer.WriteObject(writer, result);
}
private static string GetResourceFileRelativePath(ITaskItem item)
{
// The build targets use RelativeOutputPath in the case of satellite assemblies, which
// will have relative paths like "fr\\SomeAssembly.resources.dll". If RelativeOutputPath
// is specified, we want to use all of it.
var outputPath = item.GetMetadata("RelativeOutputPath");
if (string.IsNullOrEmpty(outputPath))
{
// If RelativeOutputPath was not specified, we assume the item will be placed at the
// root of whatever directory is used for its resource type (e.g., assemblies go in _bin)
outputPath = Path.GetFileName(item.ItemSpec);
}
return outputPath.Replace('\\', '/');
}
#pragma warning disable IDE1006 // Naming Styles
/// <summary>
/// Defines the structure of a Blazor boot JSON file
/// </summary>
#pragma warning disable IDE1006 // Naming Styles
public class BootJsonData
{
/// <summary>
@ -72,15 +128,39 @@ namespace Microsoft.AspNetCore.Blazor.Build
public string entryAssembly { get; set; }
/// <summary>
/// Gets the closure of assemblies to be loaded by Blazor WASM. This includes the application entry assembly.
/// Gets the set of resources needed to boot the application. This includes the transitive
/// closure of .NET assemblies (including the entrypoint assembly), the dotnet.wasm file,
/// and any PDBs to be loaded.
///
/// Within <see cref="ResourceHashesByNameDictionary"/>, dictionary keys are resource names,
/// and values are SHA-256 hashes formatted in prefixed base-64 style (e.g., 'sha256-abcdefg...')
/// as used for subresource integrity checking.
/// </summary>
public string[] assemblies { get; set; }
public Dictionary<ResourceType, ResourceHashesByNameDictionary> resources { get; set; }
/// <summary>
/// Gets a value that determines whether to enable caching of the <see cref="resources"/>
/// inside a CacheStorage instance within the browser.
/// </summary>
public bool cacheBootResources { get; set; }
/// <summary>
/// Gets a value that determines if this is a debug build.
/// </summary>
public bool debugBuild { get; set; }
/// <summary>
/// Gets a value that determines if the linker is enabled.
/// </summary>
public bool linkerEnabled { get; set; }
}
public enum ResourceType
{
assembly,
pdb,
wasm
}
#pragma warning restore IDE1006 // Naming Styles
}
}

View File

@ -52,26 +52,6 @@
<Target
Name="PrepareBlazorOutputs"
DependsOnTargets="_ResolveBlazorInputs;_ResolveBlazorOutputs;_GenerateBlazorBootJson">
<ItemGroup>
<MonoWasmFile Include="$(DotNetWebAssemblyRuntimePath)*" />
<BlazorJSFile Include="$(BlazorJSPath)" />
<BlazorJSFile Include="$(BlazorJSMapPath)" Condition="Exists('$(BlazorJSMapPath)')" />
<BlazorOutputWithTargetPath Include="@(MonoWasmFile)">
<TargetOutputPath>$(BlazorRuntimeWasmOutputPath)%(FileName)%(Extension)</TargetOutputPath>
</BlazorOutputWithTargetPath>
<BlazorOutputWithTargetPath Include="@(BlazorJSFile)">
<TargetOutputPath>$(BaseBlazorRuntimeOutputPath)%(FileName)%(Extension)</TargetOutputPath>
</BlazorOutputWithTargetPath>
</ItemGroup>
<ItemGroup Label="Static content supplied by NuGet packages">
<_BlazorPackageContentOutput Include="@(BlazorPackageContentFile)" Condition="%(SourcePackage) != ''">
<TargetOutputPath>$(BaseBlazorPackageContentOutputPath)%(SourcePackage)\%(RecursiveDir)\%(Filename)%(Extension)</TargetOutputPath>
</_BlazorPackageContentOutput>
<BlazorOutputWithTargetPath Include="@(_BlazorPackageContentOutput)" />
</ItemGroup>
</Target>
<Target Name="_ResolveBlazorInputs" DependsOnTargets="ResolveReferences;ResolveRuntimePackAssets">
@ -128,6 +108,11 @@
Message="Unrecongnized value for BlazorLinkOnBuild: '$(BlazorLinkOnBuild)'. Valid values are 'true' or 'false'."
Condition="'$(BlazorLinkOnBuild)' != 'true' AND '$(BlazorLinkOnBuild)' != 'false'" />
<!--
These are the items calculated as the closure of the runtime assemblies, either by calling the linker
or by calling our custom ResolveBlazorRuntimeDependencies task if the linker was disabled. Other than
satellite assemblies, this should include all assemblies needed to run the application.
-->
<ItemGroup>
<!--
ReferenceCopyLocalPaths includes all files that are part of the build out with CopyLocalLockFileAssemblies on.
@ -146,6 +131,49 @@
<BlazorRuntimeFile>true</BlazorRuntimeFile>
<TargetOutputPath>$(BlazorRuntimeBinOutputPath)%(FileName)%(Extension)</TargetOutputPath>
<RelativeOutputPath>%(FileName)%(Extension)</RelativeOutputPath>
</BlazorOutputWithTargetPath>
</ItemGroup>
<!--
We need to know at build time (not publish time) whether or not to include pdbs in the
blazor.boot.json file, so this is controlled by the BlazorEnableDebugging flag, whose
default value is determined by the build configuration.
-->
<ItemGroup Condition="'$(BlazorEnableDebugging)' != 'true'">
<BlazorOutputWithTargetPath Remove="@(BlazorOutputWithTargetPath)" Condition="'%(Extension)' == '.pdb'" />
</ItemGroup>
<!--
The following itemgroup attempts to extend the set to include satellite assemblies.
The mechanism behind this (or whether it's correct) is a bit unclear so
https://github.com/dotnet/aspnetcore/issues/18951 tracks the need for follow-up.
-->
<ItemGroup>
<!--
ReferenceCopyLocalPaths includes all files that are part of the build out with CopyLocalLockFileAssemblies on.
Remove assemblies that are inputs to calculating the assembly closure. Instead use the resolved outputs, since it is the minimal set.
-->
<_BlazorCopyLocalPaths Include="@(ReferenceCopyLocalPaths)" Condition="'%(Extension)' == '.dll'" />
<_BlazorCopyLocalPaths Remove="@(_BlazorManagedRuntimeAssemby)" Condition="'%(Extension)' == '.dll'" />
<BlazorOutputWithTargetPath Include="@(_BlazorCopyLocalPaths)">
<BlazorRuntimeFile>true</BlazorRuntimeFile>
<TargetOutputPath>$(BlazorRuntimeBinOutputPath)%(_BlazorCopyLocalPaths.DestinationSubDirectory)%(FileName)%(Extension)</TargetOutputPath>
<RelativeOutputPath>%(_BlazorCopyLocalPaths.DestinationSubDirectory)%(FileName)%(Extension)</RelativeOutputPath>
</BlazorOutputWithTargetPath>
</ItemGroup>
<ItemGroup>
<MonoWasmFile Include="$(DotNetWebAssemblyRuntimePath)*" />
<BlazorJSFile Include="$(BlazorJSPath)" />
<BlazorJSFile Include="$(BlazorJSMapPath)" Condition="Exists('$(BlazorJSMapPath)')" />
<BlazorOutputWithTargetPath Include="@(MonoWasmFile)">
<TargetOutputPath>$(BlazorRuntimeWasmOutputPath)%(FileName)%(Extension)</TargetOutputPath>
<BlazorRuntimeFile>true</BlazorRuntimeFile>
</BlazorOutputWithTargetPath>
<BlazorOutputWithTargetPath Include="@(BlazorJSFile)">
<TargetOutputPath>$(BaseBlazorRuntimeOutputPath)%(FileName)%(Extension)</TargetOutputPath>
</BlazorOutputWithTargetPath>
</ItemGroup>
</Target>
@ -267,7 +295,7 @@
<ItemGroup>
<_LinkerResult Include="$(BlazorIntermediateLinkerOutputPath)*.dll" />
<_LinkerResult Include="$(BlazorIntermediateLinkerOutputPath)*.pdb" Condition="'$(BlazorEnableDebugging)' == 'true'" />
<_LinkerResult Include="$(BlazorIntermediateLinkerOutputPath)*.pdb" />
</ItemGroup>
<WriteLinesToFile File="$(_BlazorLinkerOutputCache)" Lines="@(_LinkerResult)" Overwrite="true" />
@ -318,13 +346,27 @@
Inputs="$(MSBuildAllProjects);@(BlazorOutputWithTargetPath)"
Outputs="$(BlazorBootJsonIntermediateOutputPath)">
<ItemGroup>
<_BlazorRuntimeFile Include="@(BlazorOutputWithTargetPath->WithMetadataValue('BlazorRuntimeFile', 'true'))" />
<_BlazorBootResource Include="@(BlazorOutputWithTargetPath->WithMetadataValue('BlazorRuntimeFile', 'true'))" />
<_BlazorBootResource BootResourceType="assembly" Condition="'%(Extension)' == '.dll'" />
<_BlazorBootResource BootResourceType="pdb" Condition="'%(Extension)' == '.pdb'" />
<_BlazorBootResource BootResourceType="wasm" Condition="'%(Extension)' == '.wasm'" />
</ItemGroup>
<GetFileHash Files="@(_BlazorBootResource->HasMetadata('BootResourceType'))" Algorithm="SHA256" HashEncoding="base64">
<Output TaskParameter="Items" ItemName="_BlazorBootResourceWithHash" />
</GetFileHash>
<PropertyGroup>
<_IsDebugBuild>false</_IsDebugBuild>
<_IsDebugBuild Condition="'$(Configuration)' == 'Debug'">true</_IsDebugBuild>
<BlazorCacheBootResources Condition="'$(BlazorCacheBootResources)' == ''">true</BlazorCacheBootResources>
</PropertyGroup>
<GenerateBlazorBootJson
AssemblyPath="@(IntermediateAssembly)"
References="@(_BlazorRuntimeFile)"
Resources="@(_BlazorBootResourceWithHash)"
DebugBuild="$(_IsDebugBuild)"
LinkerEnabled="$(BlazorLinkOnBuild)"
CacheBootResources="$(BlazorCacheBootResources)"
OutputPath="$(BlazorBootJsonIntermediateOutputPath)" />
<ItemGroup>

View File

@ -1,41 +0,0 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.IO;
using System.Text.Json;
using System.Threading.Tasks;
using Xunit;
namespace Microsoft.AspNetCore.Blazor.Build
{
public class BootJsonWriterTest
{
[Fact]
public async Task ProducesJsonReferencingAssemblyAndDependencies()
{
// Arrange/Act
var assemblyReferences = new string[] { "MyApp.EntryPoint.dll", "System.Abc.dll", "MyApp.ClassLib.dll", };
using var stream = new MemoryStream();
// Act
GenerateBlazorBootJson.WriteBootJson(
stream,
"MyApp.Entrypoint.dll",
assemblyReferences,
linkerEnabled: true);
// Assert
stream.Position = 0;
using var parsedContent = await JsonDocument.ParseAsync(stream);
var rootElement = parsedContent.RootElement;
Assert.Equal("MyApp.Entrypoint.dll", rootElement.GetProperty("entryAssembly").GetString());
var assembliesElement = rootElement.GetProperty("assemblies");
Assert.Equal(assemblyReferences.Length, assembliesElement.GetArrayLength());
for (var i = 0; i < assemblyReferences.Length; i++)
{
Assert.Equal(assemblyReferences[i], assembliesElement[i].GetString());
}
Assert.True(rootElement.GetProperty("linkerEnabled").GetBoolean());
}
}
}

View File

@ -0,0 +1,154 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.IO;
using System.Runtime.Serialization.Json;
using Microsoft.Build.Framework;
using Moq;
using Xunit;
using BootJsonData = Microsoft.AspNetCore.Blazor.Build.GenerateBlazorBootJson.BootJsonData;
using ResourceType = Microsoft.AspNetCore.Blazor.Build.GenerateBlazorBootJson.ResourceType;
namespace Microsoft.AspNetCore.Blazor.Build
{
public class GenerateBlazorBootJsonTest
{
[Fact]
public void GroupsResourcesByType()
{
// Arrange
var taskInstance = new GenerateBlazorBootJson
{
AssemblyPath = "MyApp.Entrypoint.dll",
Resources = new[]
{
CreateResourceTaskItem(
ResourceType.assembly,
itemSpec: Path.Combine("dir", "My.Assembly1.ext"), // Can specify item spec
relativeOutputPath: null,
fileHash: "abcdefghikjlmnopqrstuvwxyz"),
CreateResourceTaskItem(
ResourceType.assembly,
itemSpec: "Ignored",
relativeOutputPath: Path.Combine("dir", "My.Assembly2.ext2"), // Can specify relative path
fileHash: "012345678901234567890123456789"),
CreateResourceTaskItem(
ResourceType.pdb,
itemSpec: "SomePdb.pdb",
relativeOutputPath: null,
fileHash: "pdbhashpdbhashpdbhash"),
CreateResourceTaskItem(
ResourceType.wasm,
itemSpec: "some-wasm-file",
relativeOutputPath: null,
fileHash: "wasmhashwasmhashwasmhash")
}
};
using var stream = new MemoryStream();
// Act
taskInstance.WriteBootJson(stream, "MyEntrypointAssembly");
// Assert
var parsedContent = ParseBootData(stream);
Assert.Equal("MyEntrypointAssembly", parsedContent.entryAssembly);
Assert.Collection(parsedContent.resources.Keys,
resourceListKey =>
{
var resources = parsedContent.resources[resourceListKey];
Assert.Equal(ResourceType.assembly, resourceListKey);
Assert.Equal(2, resources.Count);
Assert.Equal("sha256-abcdefghikjlmnopqrstuvwxyz", resources["My.Assembly1.ext"]);
Assert.Equal("sha256-012345678901234567890123456789", resources["dir/My.Assembly2.ext2"]); // For relative paths, we preserve the whole relative path, but use URL-style separators
},
resourceListKey =>
{
var resources = parsedContent.resources[resourceListKey];
Assert.Equal(ResourceType.pdb, resourceListKey);
Assert.Single(resources);
Assert.Equal("sha256-pdbhashpdbhashpdbhash", resources["SomePdb.pdb"]);
},
resourceListKey =>
{
var resources = parsedContent.resources[resourceListKey];
Assert.Equal(ResourceType.wasm, resourceListKey);
Assert.Single(resources);
Assert.Equal("sha256-wasmhashwasmhashwasmhash", resources["some-wasm-file"]);
});
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void CanSpecifyCacheBootResources(bool flagValue)
{
// Arrange
var taskInstance = new GenerateBlazorBootJson { CacheBootResources = flagValue };
using var stream = new MemoryStream();
// Act
taskInstance.WriteBootJson(stream, "MyEntrypointAssembly");
// Assert
var parsedContent = ParseBootData(stream);
Assert.Equal(flagValue, parsedContent.cacheBootResources);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void CanSpecifyDebugBuild(bool flagValue)
{
// Arrange
var taskInstance = new GenerateBlazorBootJson { DebugBuild = flagValue };
using var stream = new MemoryStream();
// Act
taskInstance.WriteBootJson(stream, "MyEntrypointAssembly");
// Assert
var parsedContent = ParseBootData(stream);
Assert.Equal(flagValue, parsedContent.debugBuild);
}
[Theory]
[InlineData(false)]
[InlineData(true)]
public void CanSpecifyLinkerEnabled(bool flagValue)
{
// Arrange
var taskInstance = new GenerateBlazorBootJson { LinkerEnabled = flagValue };
using var stream = new MemoryStream();
// Act
taskInstance.WriteBootJson(stream, "MyEntrypointAssembly");
// Assert
var parsedContent = ParseBootData(stream);
Assert.Equal(flagValue, parsedContent.linkerEnabled);
}
private static BootJsonData ParseBootData(Stream stream)
{
stream.Position = 0;
var serializer = new DataContractJsonSerializer(
typeof(BootJsonData),
new DataContractJsonSerializerSettings { UseSimpleDictionaryFormat = true });
return (BootJsonData)serializer.ReadObject(stream);
}
private static ITaskItem CreateResourceTaskItem(ResourceType type, string itemSpec, string relativeOutputPath, string fileHash)
{
var mock = new Mock<ITaskItem>();
mock.Setup(m => m.ItemSpec).Returns(itemSpec);
mock.Setup(m => m.GetMetadata("BootResourceType")).Returns(type.ToString());
mock.Setup(m => m.GetMetadata("RelativeOutputPath")).Returns(relativeOutputPath);
mock.Setup(m => m.GetMetadata("FileHash")).Returns(fileHash);
return mock.Object;
}
}
}

View File

@ -0,0 +1,25 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Collections.Generic;
using Microsoft.AspNetCore.Http;
namespace HostedInAspNet.Server
{
public class RequestLog
{
private List<string> _requestPaths = new List<string>();
public IReadOnlyCollection<string> RequestPaths => _requestPaths;
public void AddRequest(HttpRequest request)
{
_requestPaths.Add(request.Path);
}
public void Clear()
{
_requestPaths.Clear();
}
}
}

View File

@ -14,11 +14,19 @@ namespace HostedInAspNet.Server
// For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<RequestLog>();
}
// This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
public void Configure(IApplicationBuilder app, IWebHostEnvironment env, RequestLog requestLog)
{
app.Use((context, next) =>
{
// This is used by E2E tests to verify that the correct resources were fetched
requestLog.AddRequest(context.Request);
return next();
});
if (env.IsDevelopment())
{
app.UseDeveloperExceptionPage();

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -7,6 +7,7 @@ import { SharedMemoryRenderBatch } from './Rendering/RenderBatch/SharedMemoryRen
import { Pointer } from './Platform/Platform';
import { shouldAutoStart } from './BootCommon';
import { setEventDispatcher } from './Rendering/RendererEventDispatcher';
import { WebAssemblyResourceLoader } from './Platform/WebAssemblyResourceLoader';
let started = false;
@ -36,39 +37,16 @@ async function boot(options?: any): Promise<void> {
);
});
// Fetch the boot JSON file
const bootConfig = await fetchBootConfigAsync();
if (!bootConfig.linkerEnabled) {
console.info('Blazor is running in dev mode without IL stripping. To make the bundle size significantly smaller, publish the application or see https://go.microsoft.com/fwlink/?linkid=870414');
}
// Determine the URLs of the assemblies we want to load, then begin fetching them all
const loadAssemblyUrls = bootConfig.assemblies
.map(filename => `_framework/_bin/${filename}`);
// Fetch the resources and prepare the Mono runtime
const resourceLoader = await WebAssemblyResourceLoader.initAsync();
try {
await platform.start(loadAssemblyUrls);
await platform.start(resourceLoader);
} catch (ex) {
throw new Error(`Failed to start platform. Reason: ${ex}`);
}
// Start up the application
platform.callEntryPoint(bootConfig.entryAssembly);
}
async function fetchBootConfigAsync() {
// Later we might make the location of this configurable (e.g., as an attribute on the <script>
// element that's importing this file), but currently there isn't a use case for that.
const bootConfigResponse = await fetch('_framework/blazor.boot.json', { method: 'Get', credentials: 'include' });
return bootConfigResponse.json() as Promise<BootJsonData>;
}
// Keep in sync with BootJsonData in Microsoft.AspNetCore.Blazor.Build
interface BootJsonData {
entryAssembly: string;
assemblies: string[];
linkerEnabled: boolean;
platform.callEntryPoint(resourceLoader.bootConfig.entryAssembly);
}
window['Blazor'].start = boot;

View File

@ -1,4 +1,4 @@
import { getAssemblyNameFromUrl, getFileNameFromUrl } from '../Url';
import { WebAssemblyResourceLoader } from '../WebAssemblyResourceLoader';
const currentBrowserIsChrome = (window as any).chrome
&& navigator.userAgent.indexOf('Edge') < 0; // Edge pretends to be Chrome
@ -9,10 +9,7 @@ export function hasDebuggingEnabled() {
return hasReferencedPdbs && currentBrowserIsChrome;
}
export function attachDebuggerHotkey(loadAssemblyUrls: string[]) {
hasReferencedPdbs = loadAssemblyUrls
.some(url => /\.pdb$/.test(getFileNameFromUrl(url)));
export function attachDebuggerHotkey(resourceLoader: WebAssemblyResourceLoader) {
// Use the combination shift+alt+D because it isn't used by the major browsers
// for anything else by default
const altKeyName = navigator.platform.match(/^Mac/i) ? 'Cmd' : 'Alt';
@ -20,6 +17,8 @@ export function attachDebuggerHotkey(loadAssemblyUrls: string[]) {
console.info(`Debugging hotkey: Shift+${altKeyName}+D (when application has focus)`);
}
hasReferencedPdbs = !!resourceLoader.bootConfig.resources.pdb;
// Even if debugging isn't enabled, we register the hotkey so we can report why it's not enabled
document.addEventListener('keydown', evt => {
if (evt.shiftKey && (evt.metaKey || evt.altKey) && evt.code === 'KeyD') {

View File

@ -1,17 +1,18 @@
import { System_Object, System_String, System_Array, Pointer, Platform } from '../Platform';
import { getFileNameFromUrl } from '../Url';
import { attachDebuggerHotkey, hasDebuggingEnabled } from './MonoDebugger';
import { showErrorNotification } from '../../BootErrors';
import { WebAssemblyResourceLoader, LoadingResource } from '../WebAssemblyResourceLoader';
let mono_string_get_utf8: (managedString: System_String) => Mono.Utf8Ptr;
let mono_wasm_add_assembly: (name: string, heapAddress: number, length: number) => void;
const appBinDirName = 'appBinDir';
const uint64HighOrderShift = Math.pow(2, 32);
const maxSafeNumberHighPart = Math.pow(2, 21) - 1; // The high-order int32 from Number.MAX_SAFE_INTEGER
export const monoPlatform: Platform = {
start: function start(loadAssemblyUrls: string[]) {
start: function start(resourceLoader: WebAssemblyResourceLoader) {
return new Promise<void>((resolve, reject) => {
attachDebuggerHotkey(loadAssemblyUrls);
attachDebuggerHotkey(resourceLoader);
// dotnet.js assumes the existence of this
window['Browser'] = {
@ -22,7 +23,7 @@ export const monoPlatform: Platform = {
// For compatibility with macOS Catalina, we have to assign a temporary value to window.Module
// before we start loading the WebAssembly files
addGlobalModuleScriptTagsToDocument(() => {
window['Module'] = createEmscriptenModuleInstance(loadAssemblyUrls, resolve, reject);
window['Module'] = createEmscriptenModuleInstance(resourceLoader, resolve, reject);
addScriptTagsToDocument();
});
});
@ -140,9 +141,9 @@ function addGlobalModuleScriptTagsToDocument(callback: () => void) {
document.body.appendChild(scriptElem);
}
function createEmscriptenModuleInstance(loadAssemblyUrls: string[], onReady: () => void, onError: (reason?: any) => void) {
function createEmscriptenModuleInstance(resourceLoader: WebAssemblyResourceLoader, onReady: () => void, onError: (reason?: any) => void) {
const resources = resourceLoader.bootConfig.resources;
const module = {} as typeof Module;
const wasmBinaryFile = '_framework/wasm/dotnet.wasm';
const suppressMessages = ['DEBUGGING ENABLED'];
module.print = line => (suppressMessages.indexOf(line) < 0 && console.log(`WASM: ${line}`));
@ -155,55 +156,48 @@ function createEmscriptenModuleInstance(loadAssemblyUrls: string[], onReady: ()
module.postRun = [];
module.preloadPlugins = [];
module.locateFile = fileName => {
switch (fileName) {
case 'dotnet.wasm': return wasmBinaryFile;
default: return fileName;
}
// Override the mechanism for fetching the main wasm file so we can connect it to our cache
module.instantiateWasm = (imports, successCallback): WebAssembly.Exports => {
(async () => {
let compiledInstance: WebAssembly.Instance;
try {
const dotnetWasmResourceName = 'dotnet.wasm';
const dotnetWasmResource = await resourceLoader.loadResource(
/* name */ dotnetWasmResourceName,
/* url */ `_framework/wasm/${dotnetWasmResourceName}`,
/* hash */ resourceLoader.bootConfig.resources.wasm[dotnetWasmResourceName]);
compiledInstance = await compileWasmModule(dotnetWasmResource, imports);
} catch (ex) {
module.printErr(ex);
throw ex;
}
successCallback(compiledInstance);
})();
return []; // No exports
};
module.preRun.push(() => {
// By now, emscripten should be initialised enough that we can capture these methods for later use
const mono_wasm_add_assembly = Module.cwrap('mono_wasm_add_assembly', null, [
'string',
'number',
'number',
]);
mono_wasm_add_assembly = Module.cwrap('mono_wasm_add_assembly', null, ['string', 'number', 'number']);
mono_string_get_utf8 = Module.cwrap('mono_wasm_string_get_utf8', 'number', ['number']);
MONO.loaded_files = [];
loadAssemblyUrls.forEach(url => {
const filename = getFileNameFromUrl(url);
const runDependencyId = `blazor:${filename}`;
addRunDependency(runDependencyId);
asyncLoad(url).then(
data => {
const heapAddress = Module._malloc(data.length);
const heapMemory = new Uint8Array(Module.HEAPU8.buffer, heapAddress, data.length);
heapMemory.set(data);
mono_wasm_add_assembly(filename, heapAddress, data.length);
MONO.loaded_files.push(toAbsoluteUrl(url));
removeRunDependency(runDependencyId);
},
errorInfo => {
// If it's a 404 on a .pdb, we don't want to block the app from starting up.
// We'll just skip that file and continue (though the 404 is logged in the console).
// This happens if you build a Debug build but then run in Production environment.
const isPdb404 = errorInfo instanceof XMLHttpRequest
&& errorInfo.status === 404
&& filename.match(/\.pdb$/);
if (!isPdb404) {
onError(errorInfo);
}
removeRunDependency(runDependencyId);
}
);
});
// Fetch the assemblies and PDBs in the background, telling Mono to wait until they are loaded
resourceLoader.loadResources(resources.assembly, filename => `_framework/_bin/${filename}`)
.forEach(addResourceAsAssembly);
if (resources.pdb) {
resourceLoader.loadResources(resources.pdb, filename => `_framework/_bin/${filename}`)
.forEach(addResourceAsAssembly);
}
});
module.postRun.push(() => {
if (resourceLoader.bootConfig.debugBuild && resourceLoader.bootConfig.cacheBootResources) {
resourceLoader.logToConsole();
}
resourceLoader.purgeUnusedCacheEntriesAsync(); // Don't await - it's fine to run in background
MONO.mono_wasm_setenv("MONO_URI_DOTNETRELATIVEORABSOLUTE", "true");
const load_runtime = Module.cwrap('mono_wasm_load_runtime', null, ['string', 'number']);
load_runtime(appBinDirName, hasDebuggingEnabled() ? 1 : 0);
@ -213,6 +207,29 @@ function createEmscriptenModuleInstance(loadAssemblyUrls: string[], onReady: ()
});
return module;
async function addResourceAsAssembly(dependency: LoadingResource) {
const runDependencyId = `blazor:${dependency.name}`;
Module.addRunDependency(runDependencyId);
try {
// Wait for the data to be loaded and verified
const dataBuffer = await dependency.response.then(r => r.arrayBuffer());
// Load it into the Mono runtime
const data = new Uint8Array(dataBuffer);
const heapAddress = Module._malloc(data.length);
const heapMemory = new Uint8Array(Module.HEAPU8.buffer, heapAddress, data.length);
heapMemory.set(data);
mono_wasm_add_assembly(dependency.name, heapAddress, data.length);
MONO.loaded_files.push(toAbsoluteUrl(dependency.url));
} catch (errorInfo) {
onError(errorInfo);
return;
}
Module.removeRunDependency(runDependencyId);
}
}
const anchorTagForAbsoluteUrlConversions = document.createElement('a');
@ -221,24 +238,6 @@ function toAbsoluteUrl(possiblyRelativeUrl: string) {
return anchorTagForAbsoluteUrlConversions.href;
}
function asyncLoad(url: string) {
return new Promise<Uint8Array>((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('GET', url, /* async: */ true);
xhr.responseType = 'arraybuffer';
xhr.onload = function xhr_onload() {
if (xhr.status == 200 || xhr.status == 0 && xhr.response) {
const asm = new Uint8Array(xhr.response);
resolve(asm);
} else {
reject(xhr);
}
};
xhr.onerror = reject;
xhr.send(undefined);
});
}
function getArrayDataPointer<T>(array: System_Array<T>): number {
return <number><any>array + 12; // First byte from here is length, then following bytes are entries
}
@ -287,3 +286,25 @@ function attachInteropInvoker(): void {
},
});
}
async function compileWasmModule(wasmResource: LoadingResource, imports: any): Promise<WebAssembly.Instance> {
// This is the same logic as used in emscripten's generated js. We can't use emscripten's js because
// it doesn't provide any method for supplying a custom response provider, and we want to integrate
// with our resource loader cache.
if (typeof WebAssembly['instantiateStreaming'] === 'function') {
try {
const streamingResult = await WebAssembly['instantiateStreaming'](wasmResource.response, imports);
return streamingResult.instance;
}
catch (ex) {
console.info('Streaming compilation failed. Falling back to ArrayBuffer instantiation. ', ex);
}
}
// If that's not available or fails (e.g., due to incorrect content-type header),
// fall back to ArrayBuffer instantiation
const arrayBuffer = await wasmResource.response.then(r => r.arrayBuffer());
const arrayBufferResult = await WebAssembly.instantiate(arrayBuffer, imports);
return arrayBufferResult.instance;
}

View File

@ -13,10 +13,6 @@ declare namespace Module {
function mono_bind_static_method(fqn: string): BoundStaticMethod;
}
// Emscripten declares these globals
declare const addRunDependency: any;
declare const removeRunDependency: any;
declare namespace Mono {
interface Utf8Ptr { Utf8Ptr__DO_NOT_IMPLEMENT: any }
interface StackSaveHandle { StackSaveHandle__DO_NOT_IMPLEMENT: any }

View File

@ -1,5 +1,7 @@
import { WebAssemblyResourceLoader } from "./WebAssemblyResourceLoader";
export interface Platform {
start(loadAssemblyUrls: string[]): Promise<void>;
start(resourceLoader: WebAssemblyResourceLoader): Promise<void>;
callEntryPoint(assemblyName: string): void;

View File

@ -1,11 +0,0 @@
export function getFileNameFromUrl(url: string) {
// This could also be called "get last path segment from URL", but the primary
// use case is to extract things that look like filenames
const lastSegment = url.substring(url.lastIndexOf('/') + 1);
const queryStringStartPos = lastSegment.indexOf('?');
return queryStringStartPos < 0 ? lastSegment : lastSegment.substring(0, queryStringStartPos);
}
export function getAssemblyNameFromUrl(url: string) {
return getFileNameFromUrl(url).replace(/\.dll$/, '');
}

View File

@ -0,0 +1,171 @@
import { toAbsoluteUri } from '../Services/NavigationManager';
const networkFetchCacheMode = 'no-cache';
export class WebAssemblyResourceLoader {
private usedCacheKeys: { [key: string]: boolean } = {};
private networkLoads: { [name: string]: LoadLogEntry } = {};
private cacheLoads: { [name: string]: LoadLogEntry } = {};
static async initAsync(): Promise<WebAssemblyResourceLoader> {
const bootConfigResponse = await fetch('_framework/blazor.boot.json', {
method: 'GET',
credentials: 'include',
cache: networkFetchCacheMode
});
// Define a separate cache for each base href, so we're isolated from any other
// Blazor application running on the same origin. We need this so that we're free
// to purge from the cache anything we're not using and don't let it keep growing,
// since we don't want to be worst offenders for space usage.
const relativeBaseHref = document.baseURI.substring(document.location.origin.length);
const cacheName = `blazor-resources-${relativeBaseHref}`;
return new WebAssemblyResourceLoader(
await bootConfigResponse.json(),
await caches.open(cacheName));
}
constructor (public readonly bootConfig: BootJsonData, private cache: Cache)
{
}
loadResources(resources: ResourceList, url: (name: string) => string): LoadingResource[] {
return Object.keys(resources)
.map(name => this.loadResource(name, url(name), resources[name]));
}
loadResource(name: string, url: string, contentHash: string): LoadingResource {
// Setting 'cacheBootResources' to false bypasses the entire cache flow, including integrity checking.
// This gives developers an easy opt-out if they don't like anything about the default cache mechanism.
const response = this.bootConfig.cacheBootResources
? this.loadResourceWithCaching(name, url, contentHash)
: fetch(url, { cache: networkFetchCacheMode });
return { name, url, response };
}
logToConsole() {
const cacheLoadsEntries = Object.values(this.cacheLoads);
const networkLoadsEntries = Object.values(this.networkLoads);
const cacheResponseBytes = countTotalBytes(cacheLoadsEntries);
const networkResponseBytes = countTotalBytes(networkLoadsEntries);
const totalResponseBytes = cacheResponseBytes + networkResponseBytes;
const linkerDisabledWarning = this.bootConfig.linkerEnabled ? '%c' : '\n%cThis application was built with linking (tree shaking) disabled. Published applications will be significantly smaller.';
console.groupCollapsed(`%cblazor%c Loaded ${toDataSizeString(totalResponseBytes)} resources${linkerDisabledWarning}`, 'background: purple; color: white; padding: 1px 3px; border-radius: 3px;', 'font-weight: bold;', 'font-weight: normal;');
if (cacheLoadsEntries.length) {
console.groupCollapsed(`Loaded ${toDataSizeString(cacheResponseBytes)} resources from cache`);
console.table(this.cacheLoads);
console.groupEnd();
}
if (networkLoadsEntries.length) {
console.groupCollapsed(`Loaded ${toDataSizeString(networkResponseBytes)} resources from network`);
console.table(this.networkLoads);
console.groupEnd();
}
console.groupEnd();
}
async purgeUnusedCacheEntriesAsync() {
// We want to keep the cache small because, even though the browser will evict entries if it
// gets too big, we don't want to be considered problematic by the end user viewing storage stats
const cachedRequests = await this.cache.keys();
const deletionPromises = cachedRequests.map(async cachedRequest => {
if (!(cachedRequest.url in this.usedCacheKeys)) {
await this.cache.delete(cachedRequest);
}
});
return Promise.all(deletionPromises);
}
private async loadResourceWithCaching(name: string, url: string, contentHash: string) {
// Since we are going to cache the response, we require there to be a content hash for integrity
// checking. We don't want to cache bad responses. There should always be a hash, because the build
// process generates this data.
if (!contentHash || contentHash.length === 0) {
throw new Error('Content hash is required');
}
const cacheKey = toAbsoluteUri(`${url}.${contentHash}`);
this.usedCacheKeys[cacheKey] = true;
const cachedResponse = await this.cache.match(cacheKey);
if (cachedResponse) {
// It's in the cache.
const responseBytes = parseInt(cachedResponse.headers.get('content-length') || '0');
this.cacheLoads[name] = { responseBytes };
return cachedResponse;
} else {
// It's not in the cache. Fetch from network.
const networkResponse = await fetch(url, { cache: networkFetchCacheMode, integrity: contentHash });
this.addToCacheAsync(name, cacheKey, networkResponse); // Don't await - add to cache in background
return networkResponse;
}
}
private async addToCacheAsync(name: string, cacheKey: string, response: Response) {
// We have to clone in order to put this in the cache *and* not prevent other code from
// reading the original response stream.
const responseData = await response.clone().arrayBuffer();
// Now is an ideal moment to capture the performance stats for the request, since it
// only just completed and is most likely to still be in the buffer. However this is
// only done on a 'best effort' basis. Even if we do receive an entry, some of its
// properties may be blanked out if it was a CORS request.
const performanceEntry = getPerformanceEntry(response.url);
const responseBytes = (performanceEntry && performanceEntry.encodedBodySize) || undefined;
this.networkLoads[name] = { responseBytes };
// Add to cache as a custom response object so we can track extra data such as responseBytes
// We can't rely on the server sending content-length (ASP.NET Core doesn't by default)
await this.cache.put(cacheKey, new Response(responseData, {
headers: {
'content-type': response.headers.get('content-type') || '',
'content-length': (responseBytes || response.headers.get('content-length') || '').toString()
}
}));
}
}
function countTotalBytes(loads: LoadLogEntry[]) {
return loads.reduce((prev, item) => prev + (item.responseBytes || 0), 0);
}
function toDataSizeString(byteCount: number) {
return `${(byteCount / (1024*1024)).toFixed(2)} MB`;
}
function getPerformanceEntry(url: string): PerformanceResourceTiming | undefined {
if (typeof performance !== 'undefined') {
return performance.getEntriesByName(url)[0] as PerformanceResourceTiming;
}
}
// Keep in sync with bootJsonData in Microsoft.AspNetCore.Blazor.Build
interface BootJsonData {
readonly entryAssembly: string;
readonly resources: ResourceGroups;
readonly debugBuild: boolean;
readonly linkerEnabled: boolean;
readonly cacheBootResources: boolean;
}
interface ResourceGroups {
readonly wasm: ResourceList;
readonly assembly: ResourceList;
readonly pdb?: ResourceList;
}
interface LoadLogEntry {
responseBytes: number | undefined;
}
export interface LoadingResource {
name: string;
url: string;
response: Promise<Response>;
}
type ResourceList = { [name: string]: string };

View File

@ -110,7 +110,7 @@ async function notifyLocationChanged(interceptedLink: boolean) {
}
let testAnchor: HTMLAnchorElement;
function toAbsoluteUri(relativeUri: string) {
export function toAbsoluteUri(relativeUri: string) {
testAnchor = testAnchor || document.createElement('a');
testAnchor.href = relativeUri;
return testAnchor.href;

View File

@ -0,0 +1,152 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using HostedInAspNet.Server;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure;
using Microsoft.AspNetCore.Components.E2ETest.Infrastructure.ServerFixtures;
using Microsoft.AspNetCore.E2ETesting;
using Microsoft.Extensions.DependencyInjection;
using OpenQA.Selenium;
using OpenQA.Selenium.Support.UI;
using Xunit;
using Xunit.Abstractions;
namespace Microsoft.AspNetCore.Components.E2ETest.Tests
{
public class BootResourceCachingTest
: ServerTestBase<AspNetSiteServerFixture>
{
// The cache name is derived from the application's base href value (in this case, '/')
private const string CacheName = "blazor-resources-/";
public BootResourceCachingTest(
BrowserFixture browserFixture,
AspNetSiteServerFixture serverFixture,
ITestOutputHelper output)
: base(browserFixture, serverFixture, output)
{
serverFixture.BuildWebHostMethod = HostedInAspNet.Server.Program.BuildWebHost;
}
public override Task InitializeAsync()
{
return base.InitializeAsync(Guid.NewGuid().ToString());
}
[Fact]
public void CachesResourcesAfterFirstLoad()
{
// On the first load, we have to fetch everything
Navigate("/");
WaitUntilLoaded();
var initialResourcesRequested = GetAndClearRequestedPaths();
Assert.NotEmpty(initialResourcesRequested.Where(path => path.EndsWith("/blazor.boot.json")));
Assert.NotEmpty(initialResourcesRequested.Where(path => path.EndsWith("/dotnet.wasm")));
Assert.NotEmpty(initialResourcesRequested.Where(path => path.EndsWith(".js")));
Assert.NotEmpty(initialResourcesRequested.Where(path => path.EndsWith(".dll")));
// On subsequent loads, we skip the items referenced from blazor.boot.json
// which includes .dll files and dotnet.wasm
Navigate("about:blank");
Navigate("/");
WaitUntilLoaded();
var subsequentResourcesRequested = GetAndClearRequestedPaths();
Assert.NotEmpty(initialResourcesRequested.Where(path => path.EndsWith("/blazor.boot.json")));
Assert.Empty(subsequentResourcesRequested.Where(path => path.EndsWith("/dotnet.wasm")));
Assert.NotEmpty(subsequentResourcesRequested.Where(path => path.EndsWith(".js")));
Assert.Empty(subsequentResourcesRequested.Where(path => path.EndsWith(".dll")));
}
[Fact]
public void IncrementallyUpdatesCache()
{
// Perform a first load to populate the cache
Navigate("/");
WaitUntilLoaded();
var cacheEntryUrls1 = GetCacheEntryUrls();
var cacheEntryForMsCorLib = cacheEntryUrls1.Single(url => url.Contains("/mscorlib.dll"));
var cacheEntryForDotNetWasm = cacheEntryUrls1.Single(url => url.Contains("/dotnet.wasm"));
var cacheEntryForDotNetWasmWithChangedHash = cacheEntryForDotNetWasm.Replace(".sha256-", ".sha256-different");
// Remove some items we do need, and add an item we don't need
RemoveCacheEntry(cacheEntryForMsCorLib);
RemoveCacheEntry(cacheEntryForDotNetWasm);
AddCacheEntry(cacheEntryForDotNetWasmWithChangedHash, "ignored content");
var cacheEntryUrls2 = GetCacheEntryUrls();
Assert.DoesNotContain(cacheEntryForMsCorLib, cacheEntryUrls2);
Assert.DoesNotContain(cacheEntryForDotNetWasm, cacheEntryUrls2);
Assert.Contains(cacheEntryForDotNetWasmWithChangedHash, cacheEntryUrls2);
// On the next load, we'll fetch only the items we need (not things already cached)
GetAndClearRequestedPaths();
Navigate("about:blank");
Navigate("/");
WaitUntilLoaded();
var subsequentResourcesRequested = GetAndClearRequestedPaths();
Assert.Collection(subsequentResourcesRequested.Where(url => url.Contains(".dll")),
requestedDll => Assert.Contains("/mscorlib.dll", requestedDll));
Assert.Collection(subsequentResourcesRequested.Where(url => url.Contains(".wasm")),
requestedDll => Assert.Contains("/dotnet.wasm", requestedDll));
// We also update the cache (add new items, remove unnecessary items)
var cacheEntryUrls3 = GetCacheEntryUrls();
Assert.Contains(cacheEntryForMsCorLib, cacheEntryUrls3);
Assert.Contains(cacheEntryForDotNetWasm, cacheEntryUrls3);
Assert.DoesNotContain(cacheEntryForDotNetWasmWithChangedHash, cacheEntryUrls3);
}
private IReadOnlyCollection<string> GetCacheEntryUrls()
{
var js = @"
(async function(cacheName, completedCallback) {
const cache = await caches.open(cacheName);
const keys = await cache.keys();
const urls = keys.map(r => r.url);
completedCallback(urls);
}).apply(null, arguments)";
var jsExecutor = (IJavaScriptExecutor)Browser;
var result = (IEnumerable<object>)jsExecutor.ExecuteAsyncScript(js, CacheName);
return result.Cast<string>().ToList();
}
private void RemoveCacheEntry(string url)
{
var js = @"
(async function(cacheName, urlToRemove, completedCallback) {
const cache = await caches.open(cacheName);
await cache.delete(urlToRemove);
completedCallback();
}).apply(null, arguments)";
((IJavaScriptExecutor)Browser).ExecuteAsyncScript(js, CacheName, url);
}
private void AddCacheEntry(string url, string content)
{
var js = @"
(async function(cacheName, urlToAdd, contentToAdd, completedCallback) {
const cache = await caches.open(cacheName);
await cache.put(urlToAdd, new Response(contentToAdd));
completedCallback();
}).apply(null, arguments)";
((IJavaScriptExecutor)Browser).ExecuteAsyncScript(js, CacheName, url, content);
}
private IReadOnlyCollection<string> GetAndClearRequestedPaths()
{
var requestLog = _serverFixture.Host.Services.GetRequiredService<RequestLog>();
var result = requestLog.RequestPaths.ToList();
requestLog.Clear();
return result;
}
private void WaitUntilLoaded()
{
new WebDriverWait(Browser, TimeSpan.FromSeconds(30)).Until(
driver => driver.FindElement(By.TagName("h1")).Text == "Hello, world!");
}
}
}

View File

@ -70,12 +70,38 @@ namespace Microsoft.AspNetCore.E2ETesting
browser.Dispose();
}
await DeleteBrowserUserProfileDirectoriesAsync();
}
private async Task DeleteBrowserUserProfileDirectoriesAsync()
{
foreach (var context in _browsers.Keys)
{
var userProfileDirectory = UserProfileDirectory(context);
if (!string.IsNullOrEmpty(userProfileDirectory) && Directory.Exists(userProfileDirectory))
{
Directory.Delete(userProfileDirectory, recursive: true);
var attemptCount = 0;
while (true)
{
try
{
Directory.Delete(userProfileDirectory, recursive: true);
break;
}
catch (UnauthorizedAccessException ex)
{
attemptCount++;
if (attemptCount < 5)
{
Console.WriteLine($"Failed to delete browser profile directory '{userProfileDirectory}': '{ex}'. Will retry.");
await Task.Delay(2000);
}
else
{
throw;
}
}
}
}
}
}