Skip to content

Commit

Permalink
[browser] Allow downloading WebAssembly resources without performing …
Browse files Browse the repository at this point in the history
…other WebAssembly runtime initalization (#102254)

* await dotnet.download();

* Added WBT.

* Rename.

* Feedback: add the new wbt to TestAppScenarios.

* Check for re-download instead of specific files.

---------

Co-authored-by: Ilona Tomkowicz <itomkowicz@microsoft.com>
  • Loading branch information
pavelsavara and ilonatommy committed Jun 7, 2024
1 parent ab604e9 commit 8fac5af
Show file tree
Hide file tree
Showing 12 changed files with 147 additions and 16 deletions.
1 change: 1 addition & 0 deletions eng/testing/scenarios/BuildWasmAppsJobsList.txt
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Wasm.Build.Tests.PInvokeTableGeneratorTests
Wasm.Build.Tests.RebuildTests
Wasm.Build.Tests.SatelliteAssembliesTests
Wasm.Build.Tests.TestAppScenarios.AppSettingsTests
Wasm.Build.Tests.TestAppScenarios.DownloadThenInitTests
Wasm.Build.Tests.TestAppScenarios.LazyLoadingTests
Wasm.Build.Tests.TestAppScenarios.LibraryInitializerTests
Wasm.Build.Tests.TestAppScenarios.SatelliteLoadingTests
Expand Down
4 changes: 4 additions & 0 deletions src/mono/browser/runtime/dotnet.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ interface DotnetHostBuilder {
* from a custom source, such as an external CDN.
*/
withResourceLoader(loadBootResource?: LoadBootResourceCallback): DotnetHostBuilder;
/**
* Downloads all the assets but doesn't create the runtime instance.
*/
download(): Promise<void>;
/**
* Starts the runtime and returns promise of the API object.
*/
Expand Down
18 changes: 18 additions & 0 deletions src/mono/browser/runtime/loader/assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,12 @@ export function resolve_single_asset_path (behavior: SingleAssetBehaviors): Asse
return asset;
}

let downloadAssetsStarted = false;
export async function mono_download_assets (): Promise<void> {
if (downloadAssetsStarted) {
return;
}
downloadAssetsStarted = true;
mono_log_debug("mono_download_assets");
try {
const promises_of_assets_core: Promise<AssetEntryInternal>[] = [];
Expand All @@ -177,6 +182,14 @@ export async function mono_download_assets (): Promise<void> {

loaderHelpers.allDownloadsQueued.promise_control.resolve();

Promise.all([...promises_of_assets_core, ...promises_of_assets_remaining]).then(() => {
loaderHelpers.allDownloadsFinished.promise_control.resolve();
}).catch(err => {
loaderHelpers.err("Error in mono_download_assets: " + err);
mono_exit(1, err);
throw err;
});

// continue after the dotnet.runtime.js was loaded
await loaderHelpers.runtimeModuleLoaded.promise;

Expand Down Expand Up @@ -262,7 +275,12 @@ export async function mono_download_assets (): Promise<void> {
}
}

let assetsPrepared = false;
export function prepareAssets () {
if (assetsPrepared) {
return;
}
assetsPrepared = true;
const config = loaderHelpers.config;
const modulesAssets: AssetEntryInternal[] = [];

Expand Down
10 changes: 9 additions & 1 deletion src/mono/browser/runtime/loader/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -233,12 +233,19 @@ export function normalizeConfig () {

let configLoaded = false;
export async function mono_wasm_load_config (module: DotnetModuleInternal): Promise<void> {
const configFilePath = module.configSrc;
if (configLoaded) {
await loaderHelpers.afterConfigLoaded.promise;
return;
}
let configFilePath;
try {
if (!module.configSrc && (!loaderHelpers.config || Object.keys(loaderHelpers.config).length === 0 || (!loaderHelpers.config.assets && !loaderHelpers.config.resources))) {
// if config file location nor assets are provided
module.configSrc = "./blazor.boot.json";
}

configFilePath = module.configSrc;

configLoaded = true;
if (configFilePath) {
mono_log_debug("mono_wasm_load_config");
Expand All @@ -262,6 +269,7 @@ export async function mono_wasm_load_config (module: DotnetModuleInternal): Prom
}

normalizeConfig();
loaderHelpers.afterConfigLoaded.promise_control.resolve(loaderHelpers.config);
} catch (err) {
const errMessage = `Failed to load config file ${configFilePath} ${err} ${(err as Error)?.stack}`;
loaderHelpers.config = module.config = Object.assign(loaderHelpers.config, { message: errMessage, error: err, isError: true });
Expand Down
1 change: 1 addition & 0 deletions src/mono/browser/runtime/loader/exit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ async function flush_node_streams () {

function abort_promises (reason: any) {
loaderHelpers.allDownloadsQueued.promise_control.reject(reason);
loaderHelpers.allDownloadsFinished.promise_control.reject(reason);
loaderHelpers.afterConfigLoaded.promise_control.reject(reason);
loaderHelpers.wasmCompilePromise.promise_control.reject(reason);
loaderHelpers.runtimeModuleLoaded.promise_control.reject(reason);
Expand Down
1 change: 1 addition & 0 deletions src/mono/browser/runtime/loader/globals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ export function setLoaderGlobals (

afterConfigLoaded: createPromiseController<MonoConfig>(),
allDownloadsQueued: createPromiseController<void>(),
allDownloadsFinished: createPromiseController<void>(),
wasmCompilePromise: createPromiseController<WebAssembly.Module>(),
runtimeModuleLoaded: createPromiseController<void>(),
loadingWorkers: createPromiseController<PThreadWorker[]>(),
Expand Down
59 changes: 47 additions & 12 deletions src/mono/browser/runtime/loader/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,15 @@ export class HostBuilder implements DotnetHostBuilder {
}
}

async download (): Promise<void> {
try {
await downloadOnly();
} catch (err) {
mono_exit(1, err);
throw err;
}
}

async create (): Promise<RuntimeAPI> {
try {
if (!this.instance) {
Expand Down Expand Up @@ -375,16 +384,22 @@ export class HostBuilder implements DotnetHostBuilder {
}

export async function createApi (): Promise<RuntimeAPI> {
await createEmscripten(emscriptenModule);
return globalObjectsRoot.api;
}

let emscriptenPrepared = false;
async function prepareEmscripten (moduleFactory: DotnetModuleConfig | ((api: RuntimeAPI) => DotnetModuleConfig)) {
if (emscriptenPrepared) {
return;
}
emscriptenPrepared = true;
if (ENVIRONMENT_IS_WEB && loaderHelpers.config.forwardConsoleLogsToWS && typeof globalThis.WebSocket != "undefined") {
setup_proxy_console("main", globalThis.console, globalThis.location.origin);
}
mono_assert(emscriptenModule, "Null moduleConfig");
mono_assert(loaderHelpers.config, "Null moduleConfig.config");
await createEmscripten(emscriptenModule);
return globalObjectsRoot.api;
}

export async function createEmscripten (moduleFactory: DotnetModuleConfig | ((api: RuntimeAPI) => DotnetModuleConfig)): Promise<RuntimeAPI | EmscriptenModuleInternal> {
// extract ModuleConfig
if (typeof moduleFactory === "function") {
const extension = moduleFactory(globalObjectsRoot.api) as any;
Expand All @@ -400,6 +415,11 @@ export async function createEmscripten (moduleFactory: DotnetModuleConfig | ((ap
}

await detect_features_and_polyfill(emscriptenModule);
}

export async function createEmscripten (moduleFactory: DotnetModuleConfig | ((api: RuntimeAPI) => DotnetModuleConfig)): Promise<RuntimeAPI | EmscriptenModuleInternal> {
await prepareEmscripten(moduleFactory);

if (BuildConfiguration === "Debug" && !ENVIRONMENT_IS_WORKER) {
mono_log_info(`starting script ${loaderHelpers.scriptUrl}`);
mono_log_info(`starting in ${loaderHelpers.scriptDirectory}`);
Expand All @@ -412,13 +432,16 @@ export async function createEmscripten (moduleFactory: DotnetModuleConfig | ((ap
: createEmscriptenMain();
}

let jsModuleRuntimePromise: Promise<RuntimeModuleExportsInternal>;
let jsModuleNativePromise: Promise<NativeModuleExportsInternal>;

// in the future we can use feature detection to load different flavors
function importModules () {
const jsModuleRuntimeAsset = resolve_single_asset_path("js-module-runtime");
const jsModuleNativeAsset = resolve_single_asset_path("js-module-native");

let jsModuleRuntimePromise: Promise<RuntimeModuleExportsInternal>;
let jsModuleNativePromise: Promise<NativeModuleExportsInternal>;
if (jsModuleRuntimePromise && jsModuleNativePromise) {
return [jsModuleRuntimePromise, jsModuleNativePromise];
}

if (typeof jsModuleRuntimeAsset.moduleExports === "object") {
jsModuleRuntimePromise = jsModuleRuntimeAsset.moduleExports;
Expand Down Expand Up @@ -475,12 +498,24 @@ async function initializeModules (es6Modules: [RuntimeModuleExportsInternal, Nat
});
}

async function createEmscriptenMain (): Promise<RuntimeAPI> {
if (!emscriptenModule.configSrc && (!loaderHelpers.config || Object.keys(loaderHelpers.config).length === 0 || (!loaderHelpers.config.assets && !loaderHelpers.config.resources))) {
// if config file location nor assets are provided
emscriptenModule.configSrc = "./blazor.boot.json";
}
async function downloadOnly ():Promise<void> {
prepareEmscripten(emscriptenModule);

// download config
await mono_wasm_load_config(emscriptenModule);

prepareAssets();

await initCacheToUseIfEnabled();

init_globalization();

mono_download_assets(); // intentionally not awaited

await loaderHelpers.allDownloadsFinished.promise;
}

async function createEmscriptenMain (): Promise<RuntimeAPI> {
// download config
await mono_wasm_load_config(emscriptenModule);

Expand Down
4 changes: 4 additions & 0 deletions src/mono/browser/runtime/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,10 @@ export interface DotnetHostBuilder {
* from a custom source, such as an external CDN.
*/
withResourceLoader(loadBootResource?: LoadBootResourceCallback): DotnetHostBuilder;
/**
* Downloads all the assets but doesn't create the runtime instance.
*/
download(): Promise<void>;
/**
* Starts the runtime and returns promise of the API object.
*/
Expand Down
1 change: 1 addition & 0 deletions src/mono/browser/runtime/types/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,7 @@ export type LoaderHelpers = {

afterConfigLoaded: PromiseAndController<MonoConfig>,
allDownloadsQueued: PromiseAndController<void>,
allDownloadsFinished: PromiseAndController<void>,
wasmCompilePromise: PromiseAndController<WebAssembly.Module>,
runtimeModuleLoaded: PromiseAndController<void>,
loadingWorkers: PromiseAndController<PThreadWorker[]>,
Expand Down
9 changes: 6 additions & 3 deletions src/mono/sample/wasm/browser-advanced/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ try {
}
return originalFetch(url, fetchArgs);
};
const { runtimeBuildInfo, setModuleImports, getAssemblyExports, runMain, getConfig, Module } = await dotnet
dotnet
.withElementOnExit()
// 'withModuleConfig' is internal lower level API
// here we show how emscripten could be further configured
Expand Down Expand Up @@ -69,8 +69,11 @@ try {
.withResourceLoader((type, name, defaultUri, integrity, behavior) => {
// loadBootResource could return string with unqualified name of resource. It assumes that we resolve it with document.baseURI
return name;
})
.create();
});

await dotnet.download();

const { runtimeBuildInfo, setModuleImports, getAssemblyExports, runMain, getConfig, Module } = await dotnet.create();

// at this point both emscripten and monoVM are fully initialized.
console.log('user code after dotnet.create');
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Xunit.Abstractions;
using Xunit;

#nullable enable

namespace Wasm.Build.Tests.TestAppScenarios;

public class DownloadThenInitTests : AppTestBase
{
public DownloadThenInitTests(ITestOutputHelper output, SharedBuildPerTestClassFixture buildContext)
: base(output, buildContext)
{
}

[Theory]
[InlineData("Debug")]
[InlineData("Release")]
public async Task NoResourcesReFetchedAfterDownloadFinished(string config)
{
CopyTestAsset("WasmBasicTestApp", "DownloadThenInitTests", "App");
BuildProject(config);

var result = await RunSdkStyleAppForBuild(new(Configuration: config, TestScenario: "DownloadThenInit"));
var resultTestOutput = result.TestOutput.ToList();
int index = resultTestOutput.FindIndex(s => s == "download finished");
Assert.True(index > 0); // number of fetched resources cannot be 0
var afterDownload = resultTestOutput.Skip(index + 1).Where(s => s.StartsWith("fetching")).ToList();
if (afterDownload.Count > 0)
{
var duringDownload = resultTestOutput.Take(index + 1).Where(s => s.StartsWith("fetching")).ToList();
var reFetchedResources = afterDownload.Intersect(duringDownload).ToList();
if (reFetchedResources.Any())
Assert.Fail($"Resources should not be fetched twice. Re-fetched on init: {string.Join(", ", reFetchedResources)}");
}
}
}
12 changes: 12 additions & 0 deletions src/mono/wasm/testassets/WasmBasicTestApp/App/wwwroot/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,15 @@ switch (testCase) {
.withRuntimeOptions(['--interp-pgo-logging'])
.withInterpreterPgo(true);
break;
case "DownloadThenInit":
const originalFetch = globalThis.fetch;
globalThis.fetch = (url, fetchArgs) => {
testOutput("fetching " + url);
return originalFetch(url, fetchArgs);
};
await dotnet.download();
testOutput("download finished");
break;
}

const { setModuleImports, getAssemblyExports, getConfig, INTERNAL } = await dotnet.create();
Expand Down Expand Up @@ -137,6 +146,9 @@ try {
await INTERNAL.interp_pgo_save_data();
exit(0);
break;
case "DownloadThenInit":
exit(0);
break;
default:
console.error(`Unknown test case: ${testCase}`);
exit(3);
Expand Down

0 comments on commit 8fac5af

Please sign in to comment.