diff --git a/eng/testing/scenarios/BuildWasmAppsJobsList.txt b/eng/testing/scenarios/BuildWasmAppsJobsList.txt index 58c5621f45240..6b41da61285c2 100644 --- a/eng/testing/scenarios/BuildWasmAppsJobsList.txt +++ b/eng/testing/scenarios/BuildWasmAppsJobsList.txt @@ -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 diff --git a/src/mono/browser/runtime/dotnet.d.ts b/src/mono/browser/runtime/dotnet.d.ts index 484d5dad3b762..94e31ff6a3e88 100644 --- a/src/mono/browser/runtime/dotnet.d.ts +++ b/src/mono/browser/runtime/dotnet.d.ts @@ -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; /** * Starts the runtime and returns promise of the API object. */ diff --git a/src/mono/browser/runtime/loader/assets.ts b/src/mono/browser/runtime/loader/assets.ts index 5a4e3e708c1a3..6d4232675d11d 100644 --- a/src/mono/browser/runtime/loader/assets.ts +++ b/src/mono/browser/runtime/loader/assets.ts @@ -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 { + if (downloadAssetsStarted) { + return; + } + downloadAssetsStarted = true; mono_log_debug("mono_download_assets"); try { const promises_of_assets_core: Promise[] = []; @@ -177,6 +182,14 @@ export async function mono_download_assets (): Promise { 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; @@ -262,7 +275,12 @@ export async function mono_download_assets (): Promise { } } +let assetsPrepared = false; export function prepareAssets () { + if (assetsPrepared) { + return; + } + assetsPrepared = true; const config = loaderHelpers.config; const modulesAssets: AssetEntryInternal[] = []; diff --git a/src/mono/browser/runtime/loader/config.ts b/src/mono/browser/runtime/loader/config.ts index 6b80f7326fb82..24afa653772fa 100644 --- a/src/mono/browser/runtime/loader/config.ts +++ b/src/mono/browser/runtime/loader/config.ts @@ -233,12 +233,19 @@ export function normalizeConfig () { let configLoaded = false; export async function mono_wasm_load_config (module: DotnetModuleInternal): Promise { - 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"); @@ -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 }); diff --git a/src/mono/browser/runtime/loader/exit.ts b/src/mono/browser/runtime/loader/exit.ts index 3632969487868..6c690dde8ec9b 100644 --- a/src/mono/browser/runtime/loader/exit.ts +++ b/src/mono/browser/runtime/loader/exit.ts @@ -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); diff --git a/src/mono/browser/runtime/loader/globals.ts b/src/mono/browser/runtime/loader/globals.ts index d9d3ec0183827..91e0a89f2c0f9 100644 --- a/src/mono/browser/runtime/loader/globals.ts +++ b/src/mono/browser/runtime/loader/globals.ts @@ -106,6 +106,7 @@ export function setLoaderGlobals ( afterConfigLoaded: createPromiseController(), allDownloadsQueued: createPromiseController(), + allDownloadsFinished: createPromiseController(), wasmCompilePromise: createPromiseController(), runtimeModuleLoaded: createPromiseController(), loadingWorkers: createPromiseController(), diff --git a/src/mono/browser/runtime/loader/run.ts b/src/mono/browser/runtime/loader/run.ts index 936f81e78b19d..2a9173bc3dcb4 100644 --- a/src/mono/browser/runtime/loader/run.ts +++ b/src/mono/browser/runtime/loader/run.ts @@ -348,6 +348,15 @@ export class HostBuilder implements DotnetHostBuilder { } } + async download (): Promise { + try { + await downloadOnly(); + } catch (err) { + mono_exit(1, err); + throw err; + } + } + async create (): Promise { try { if (!this.instance) { @@ -375,16 +384,22 @@ export class HostBuilder implements DotnetHostBuilder { } export async function createApi (): Promise { + 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 { // extract ModuleConfig if (typeof moduleFactory === "function") { const extension = moduleFactory(globalObjectsRoot.api) as any; @@ -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 { + await prepareEmscripten(moduleFactory); + if (BuildConfiguration === "Debug" && !ENVIRONMENT_IS_WORKER) { mono_log_info(`starting script ${loaderHelpers.scriptUrl}`); mono_log_info(`starting in ${loaderHelpers.scriptDirectory}`); @@ -412,13 +432,16 @@ export async function createEmscripten (moduleFactory: DotnetModuleConfig | ((ap : createEmscriptenMain(); } +let jsModuleRuntimePromise: Promise; +let jsModuleNativePromise: Promise; + // 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; - let jsModuleNativePromise: Promise; + if (jsModuleRuntimePromise && jsModuleNativePromise) { + return [jsModuleRuntimePromise, jsModuleNativePromise]; + } if (typeof jsModuleRuntimeAsset.moduleExports === "object") { jsModuleRuntimePromise = jsModuleRuntimeAsset.moduleExports; @@ -475,12 +498,24 @@ async function initializeModules (es6Modules: [RuntimeModuleExportsInternal, Nat }); } -async function createEmscriptenMain (): Promise { - 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 { + 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 { // download config await mono_wasm_load_config(emscriptenModule); diff --git a/src/mono/browser/runtime/types/index.ts b/src/mono/browser/runtime/types/index.ts index d4417065b8330..5e8e8c9d18bb7 100644 --- a/src/mono/browser/runtime/types/index.ts +++ b/src/mono/browser/runtime/types/index.ts @@ -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; /** * Starts the runtime and returns promise of the API object. */ diff --git a/src/mono/browser/runtime/types/internal.ts b/src/mono/browser/runtime/types/internal.ts index 827c9787af27c..26f66bcf2d1f8 100644 --- a/src/mono/browser/runtime/types/internal.ts +++ b/src/mono/browser/runtime/types/internal.ts @@ -141,6 +141,7 @@ export type LoaderHelpers = { afterConfigLoaded: PromiseAndController, allDownloadsQueued: PromiseAndController, + allDownloadsFinished: PromiseAndController, wasmCompilePromise: PromiseAndController, runtimeModuleLoaded: PromiseAndController, loadingWorkers: PromiseAndController, diff --git a/src/mono/sample/wasm/browser-advanced/main.js b/src/mono/sample/wasm/browser-advanced/main.js index 115514db72991..fe0331693d6de 100644 --- a/src/mono/sample/wasm/browser-advanced/main.js +++ b/src/mono/sample/wasm/browser-advanced/main.js @@ -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 @@ -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'); diff --git a/src/mono/wasm/Wasm.Build.Tests/TestAppScenarios/DownloadThenInitTests.cs b/src/mono/wasm/Wasm.Build.Tests/TestAppScenarios/DownloadThenInitTests.cs new file mode 100644 index 0000000000000..aaa2df9b59655 --- /dev/null +++ b/src/mono/wasm/Wasm.Build.Tests/TestAppScenarios/DownloadThenInitTests.cs @@ -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)}"); + } + } +} diff --git a/src/mono/wasm/testassets/WasmBasicTestApp/App/wwwroot/main.js b/src/mono/wasm/testassets/WasmBasicTestApp/App/wwwroot/main.js index e426886485d9d..3c8e36199d25e 100644 --- a/src/mono/wasm/testassets/WasmBasicTestApp/App/wwwroot/main.js +++ b/src/mono/wasm/testassets/WasmBasicTestApp/App/wwwroot/main.js @@ -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(); @@ -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);