Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eng/testing/scenarios/BuildWasmAppsJobsListCoreCLR.txt
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,4 @@ Wasm.Build.Tests.WasmRunOutOfAppBundleTests
Wasm.Build.Tests.WasmTemplateTests
Wasm.Build.Tests.MaxParallelDownloadsTests
Wasm.Build.Tests.LibraryInitializerTests
Wasm.Build.Tests.DownloadThenInitTests
43 changes: 43 additions & 0 deletions src/mono/wasm/Wasm.Build.Tests/DownloadThenInitTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,15 @@ public async Task NoResourcesReFetchedAfterDownloadFinished(Configuration config
var resultTestOutput = result.TestOutput.ToList();
int index = resultTestOutput.FindIndex(s => s.Contains("download finished"));
Assert.True(index > 0); // number of fetched resources cannot be 0

// Verify onConfigLoaded was called during download()
Assert.Contains(resultTestOutput, s => s.Contains("onConfigLoaded was called during download"));

// Verify resources were actually fetched during download
var fetchesDuringDownload = resultTestOutput.Take(index + 1).Where(s => s.StartsWith("fetching")).ToList();
Assert.True(fetchesDuringDownload.Count > 0, "Expected resources to be fetched during download()");

// Verify no resources were re-fetched during create()
var afterDownload = resultTestOutput.Skip(index + 1).Where(s => s.StartsWith("fetching")).ToList();
if (afterDownload.Count > 0)
{
Expand All @@ -39,5 +48,39 @@ public async Task NoResourcesReFetchedAfterDownloadFinished(Configuration config
if (reFetchedResources.Any())
Assert.Fail($"Resources should not be fetched twice. Re-fetched on init: {string.Join(", ", reFetchedResources)}");
}

// Verify create() completed successfully
Assert.Contains(resultTestOutput, s => s.Contains("create finished"));
}

[Theory]
[InlineData(Configuration.Debug)]
[InlineData(Configuration.Release)]
public async Task HttpCacheOnlyThenCreateWorks(Configuration config)
{
ProjectInfo info = CopyTestAsset(config, aot: false, TestAsset.WasmBasicTestApp, "DownloadThenInitHttpCacheOnly");
BuildProject(info, config);
BrowserRunOptions options = new(config, TestScenario: "DownloadThenInitHttpCacheOnly");
RunResult result = await RunForBuildWithDotnetRun(options);
var resultTestOutput = result.TestOutput.ToList();
int index = resultTestOutput.FindIndex(s => s.Contains("download finished"));
Assert.True(index > 0);

// Verify loadBootResource was called during download(true)
Assert.Contains(resultTestOutput, s => s.Contains("loadBootResource was called"));

// Verify onConfigLoaded was called during download(true) — config init runs before prefetch
Assert.Contains(resultTestOutput, s => s.Contains("onConfigLoaded was called during download"));

// Verify prefetch requests happened during download
var fetchesDuringDownload = resultTestOutput.Take(index + 1).Where(s => s.StartsWith("fetching")).ToList();
Assert.True(fetchesDuringDownload.Count > 0, "Expected prefetch requests during download(true)");

// Verify resource fetches happened during create() (httpCacheOnly doesn't load into memory)
var fetchesAfterDownload = resultTestOutput.Skip(index + 1).Where(s => s.StartsWith("fetching")).ToList();
Assert.True(fetchesAfterDownload.Count > 0, "Expected resource fetches during create() after httpCacheOnly download");

// Verify create() completed successfully
Assert.Contains(resultTestOutput, s => s.Contains("create finished"));
}
}
42 changes: 42 additions & 0 deletions src/mono/wasm/testassets/WasmBasicTestApp/App/wwwroot/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,50 @@ switch (testCase) {
.withInterpreterPgo(true);
break;
case "DownloadThenInit":
let dtConfigLoadedCalled = false;
dotnet.withModuleConfig({
onConfigLoaded: () => {
dtConfigLoadedCalled = true;
testOutput("onConfigLoaded called");
}
});
const originalFetch = globalThis.fetch;
globalThis.fetch = (url, fetchArgs) => {
testOutput("fetching " + url);
return originalFetch(url, fetchArgs);
};
await dotnet.download();
if (dtConfigLoadedCalled) {
testOutput("onConfigLoaded was called during download");
}
testOutput("download finished");
break;
case "DownloadThenInitHttpCacheOnly":
let loadBootResourceCalled = false;
let hcConfigLoadedCalled = false;
dotnet.withResourceLoader((type, name, defaultUri, integrity, behavior) => {
testOutput("loadBootResource " + type + " " + name);
loadBootResourceCalled = true;
return defaultUri;
});
dotnet.withModuleConfig({
onConfigLoaded: () => {
hcConfigLoadedCalled = true;
testOutput("onConfigLoaded called");
}
});
const originalFetch3 = globalThis.fetch;
globalThis.fetch = (url, fetchArgs) => {
testOutput("fetching " + url);
return originalFetch3(url, fetchArgs);
};
await dotnet.download(true);
if (loadBootResourceCalled) {
testOutput("loadBootResource was called");
}
if (hcConfigLoadedCalled) {
testOutput("onConfigLoaded was called during download");
}
testOutput("download finished");
break;
case "MaxParallelDownloads":
Expand Down Expand Up @@ -266,6 +304,10 @@ try {
exit(0);
break;
case "DownloadThenInit":
case "DownloadThenInitHttpCacheOnly":
testOutput("create finished");
exit(0);
break;
case "MaxParallelDownloads":
exit(0);
break;
Expand Down
126 changes: 124 additions & 2 deletions src/native/libs/Common/JavaScript/loader/assets.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

import type { JsModuleExports, JsAsset, AssemblyAsset, WasmAsset, IcuAsset, EmscriptenModuleInternal, WebAssemblyBootResourceType, AssetEntryInternal, PromiseCompletionSource, LoadBootResourceCallback, InstantiateWasmSuccessCallback, SymbolsAsset } from "./types";
import type { JsModuleExports, JsAsset, AssemblyAsset, WasmAsset, IcuAsset, EmscriptenModuleInternal, WebAssemblyBootResourceType, AssetEntryInternal, PromiseCompletionSource, LoadBootResourceCallback, InstantiateWasmSuccessCallback, SymbolsAsset, AssetBehaviors } from "./types";

import { dotnetAssert, dotnetLogger, dotnetInternals, dotnetBrowserHostExports, dotnetUpdateInternals, Module, dotnetDiagnosticsExports, dotnetNativeBrowserExports, dotnetApi } from "./cross-module";
import { ENVIRONMENT_IS_SHELL, ENVIRONMENT_IS_NODE, browserVirtualAppBase } from "./per-module";
import { ENVIRONMENT_IS_SHELL, ENVIRONMENT_IS_NODE, ENVIRONMENT_IS_WEB, browserVirtualAppBase } from "./per-module";
import { createPromiseCompletionSource, delay } from "./promise-completion-source";
import { locateFile, makeURLAbsoluteWithApplicationBase } from "./bootstrap";
import { fetchLike, responseLike } from "./polyfills";
Expand Down Expand Up @@ -585,3 +585,125 @@ const leaveAfterInstantiation: { [key: string]: number | undefined } = {
"dotnetwasm": 1,
"webcil": 1,
};

// Fetches all data resources into the browser HTTP cache without loading them into memory.
// JS modules get <link rel="prefetch"> hints instead of fetch() since they use import().
export async function prefetchAllResources(): Promise<void> {
const resources = loaderConfig.resources;
if (!resources) return;

const maxParallel = loaderConfig.maxParallelDownloads ?? 16;
const pending: Promise<void>[] = [];
const queue: { url: string; hash?: string | null | ""; name: string; behavior: AssetBehaviors }[] = [];

function enqueueAsset(asset: { name?: string; resolvedUrl?: string; hash?: string | null | "" }, behavior: AssetBehaviors): void {
if (!asset.resolvedUrl && asset.name) {
asset.resolvedUrl = locateFile(asset.name);
}
if (asset.resolvedUrl && asset.name) {
queue.push({ url: asset.resolvedUrl, hash: asset.hash, name: asset.name, behavior });
}
}

// Data assets: fetch and discard
if (resources.coreAssembly) resources.coreAssembly.forEach(a => enqueueAsset(a, "assembly"));
if (resources.assembly) resources.assembly.forEach(a => enqueueAsset(a, "assembly"));
if (resources.coreVfs) resources.coreVfs.forEach(a => enqueueAsset(a, "vfs"));
if (resources.vfs) resources.vfs.forEach(a => enqueueAsset(a, "vfs"));
if (resources.icu) resources.icu.forEach(a => enqueueAsset(a, "icu"));
if (resources.wasmNative) resources.wasmNative.forEach(a => enqueueAsset(a, "dotnetwasm"));
if (resources.corePdb) resources.corePdb.forEach(a => enqueueAsset(a, "pdb"));
if (resources.pdb) resources.pdb.forEach(a => enqueueAsset(a, "pdb"));
if (resources.wasmSymbols) resources.wasmSymbols.forEach(a => enqueueAsset(a, "symbols"));
// Satellite resources
if (loaderConfig.loadAllSatelliteResources && resources.satelliteResources) {
for (const culture of Object.keys(resources.satelliteResources)) {
for (const asset of resources.satelliteResources[culture]) {
if (!asset.resolvedUrl && asset.name) {
asset.resolvedUrl = locateFile(`${culture}/${asset.name}`);
}
enqueueAsset(asset, "assembly");
}
}
Comment thread
pavelsavara marked this conversation as resolved.
}

// JS modules: add <link rel="prefetch"> in web environments
prefetchJSModuleLinks([
...(resources.jsModuleNative || []),
...(resources.jsModuleRuntime || []),
...(resources.jsModuleDiagnostics || []),
...(resources.jsModuleWorker || []),
...(resources.modulesAfterConfigLoaded || []),
...(resources.modulesAfterRuntimeReady || []),
]);

// Fetch data assets with throttling
let index = 0;
async function worker(): Promise<void> {
while (index < queue.length) {
const item = queue[index++];
try {
await prefetchUrl(item.url, item.hash, item.name, item.behavior);
} catch {
// Best-effort cache warming — individual failures are non-fatal.
// The subsequent create() call will handle retries and error reporting.
}
}
}
const workerCount = Math.min(maxParallel, queue.length);
for (let i = 0; i < workerCount; i++) {
pending.push(worker());
}
await Promise.all(pending);
}

export function prefetchJSModuleLinks(modules: JsAsset[]): void {
if (!ENVIRONMENT_IS_WEB) return;
const document = globalThis.document;
const documentHead = document?.head;
if (!document || !documentHead) return;
for (const mod of modules) {
if (!mod.resolvedUrl && mod.name) {
mod.resolvedUrl = locateFile(mod.name, true);
}
if (mod.resolvedUrl) {
const link = document.createElement("link");
link.rel = "prefetch";
link.href = mod.resolvedUrl;
link.as = "script";
documentHead.appendChild(link);
}
}
}

async function prefetchUrl(url: string, hash?: string | null | "", name?: string, behavior?: AssetBehaviors): Promise<void> {
// Respect loadBootResourceCallback so prefetch goes through the same custom loader as create()
if (typeof loadBootResourceCallback === "function" && behavior && name) {
const blazorType = behaviorToBlazorAssetTypeMap[behavior];
if (blazorType) {
const customLoadResult = loadBootResourceCallback(blazorType, name, url, hash ?? "", behavior);
if (typeof customLoadResult === "string") {
url = makeURLAbsoluteWithApplicationBase(customLoadResult);
} else if (customLoadResult != null && typeof customLoadResult === "object") {
// Custom loader returned a Response promise — await and discard
const response = await (customLoadResult as Promise<Response>);
if (typeof response?.arrayBuffer === "function") {
await response.arrayBuffer();
}
return;
}
}
}
const fetchOptions: RequestInit = {};
if (!loaderConfig.disableNoCacheFetch) {
fetchOptions.cache = "no-cache";
}
if (!loaderConfig.disableIntegrityCheck && hash) {
fetchOptions.integrity = hash;
}
const response = await fetchLike(url, fetchOptions);
if (response.ok) {
// Read the body to ensure the response is fully received into cache
await response.arrayBuffer();
}
}
6 changes: 5 additions & 1 deletion src/native/libs/Common/JavaScript/loader/dotnet.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,12 @@ interface DotnetHostBuilder {
withResourceLoader(loadBootResource?: LoadBootResourceCallback): DotnetHostBuilder;
/**
* Downloads all the assets but doesn't create the runtime instance.
* @param httpCacheOnly If true, resources are only fetched into the browser HTTP cache
* and discarded. A subsequent create() call will re-fetch from cache and do full init.
* If false (default), resources are downloaded and loaded into WASM memory, so that
* a subsequent create() call only needs to initialize the managed runtime.
*/
download(): Promise<void>;
download(httpCacheOnly?: boolean): Promise<void>;
/**
* Starts the runtime and returns promise of the API object.
*/
Expand Down
4 changes: 2 additions & 2 deletions src/native/libs/Common/JavaScript/loader/host-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,10 +102,10 @@ export class HostBuilder implements DotnetHostBuilder {
return this;
}

async download(): Promise<void> {
async download(httpCacheOnly?: boolean): Promise<void> {
try {
validateLoaderConfig();
return createRuntime(true);
return createRuntime(true, httpCacheOnly ?? false);
} catch (err) {
exit(1, err);
throw err;
Expand Down
Loading