Skip to content

[browser][coreCLR] Fix download()create() lifecycle for WASM loader#127383

Draft
pavelsavara wants to merge 1 commit intodotnet:mainfrom
pavelsavara:browser_download_only
Draft

[browser][coreCLR] Fix download()create() lifecycle for WASM loader#127383
pavelsavara wants to merge 1 commit intodotnet:mainfrom
pavelsavara:browser_download_only

Conversation

@pavelsavara
Copy link
Copy Markdown
Member

Fixes #124896

The download() API was broken — calling download() followed by create() failed because the native module couldn't be re-initialized. This PR restructures the createRuntime() flow to properly support the two-phase download→create lifecycle.

Changes

Loader core (run.ts)

  • Track download/config state with module-level flags (downloadStarted, downloadedIntoMemory, configInitialized) so download() and create() can be called sequentially without re-running initialization.
  • Config initialization (onConfigLoaded, modulesAfterConfigLoaded, polyfills, console wiring) now runs exactly once regardless of how many times createRuntime is entered.
  • When download() already loaded assets into memory, create() takes a fast path: initialize CoreCLR, fire onDotnetReady, load modulesAfterRuntimeReady — skipping redundant downloads.
  • Duplicate download() calls are a no-op.

HTTP-cache-only prefetch mode (assets.ts)

  • New download(httpCacheOnly: true) parameter. When set, resources are fetched into the browser HTTP cache and discarded — no WASM memory is consumed. A subsequent create() will re-fetch from cache.
  • prefetchAllResources() enqueues all data assets (assemblies, ICU, VFS, WASM, PDBs, symbols, satellites) and fetches them with throttling (maxParallelDownloads).
  • prefetchJSModuleLinks() injects <link rel="prefetch"> hints for JS modules instead of fetching them, since they use import().
  • prefetchUrl() respects loadBootResourceCallback, integrity checks, and disableNoCacheFetch.

Public API (public-api.ts, dotnet.d.ts, host-builder.ts)

  • download() signature updated to download(httpCacheOnly?: boolean).

Tests

  • Extended NoResourcesReFetchedAfterDownloadFinished to verify onConfigLoaded fires during download(), resources are fetched during download, none re-fetched during create(), and create() completes.
  • New HttpCacheOnlyThenCreateWorks test covering download(true)create() with loadBootResource callback verification.
  • Added test scenarios DownloadThenInitHttpCacheOnly in main.js.
  • Enabled DownloadThenInitTests for CoreCLR in BuildWasmAppsJobsListCoreCLR.txt.

@pavelsavara pavelsavara added this to the 11.0.0 milestone Apr 24, 2026
@pavelsavara pavelsavara self-assigned this Apr 24, 2026
Copilot AI review requested due to automatic review settings April 24, 2026 12:21
@pavelsavara pavelsavara added arch-wasm WebAssembly architecture area-Host os-browser Browser variant of arch-wasm labels Apr 24, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Restructures the WASM loader lifecycle to support a two-phase download()create() flow (including an HTTP-cache-only prefetch mode), and extends coverage to prevent regressions.

Changes:

  • Adds loader state tracking so download() can be followed by create() without re-running one-time initialization.
  • Introduces download(httpCacheOnly?: boolean) and implements HTTP-cache-only prefetching for boot assets (plus <link rel="prefetch"> hints for JS modules).
  • Extends/introduces WASM build tests and enables the previously-disabled CoreCLR scenario.

Reviewed changes

Copilot reviewed 7 out of 8 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
src/native/libs/Common/JavaScript/types/public-api.ts Updates the public download(httpCacheOnly?: boolean) API signature and docs.
src/native/libs/Common/JavaScript/loader/run.ts Implements the new lifecycle/state machine and cache-only download path.
src/native/libs/Common/JavaScript/loader/host-builder.ts Wires download(httpCacheOnly?: boolean) through to createRuntime.
src/native/libs/Common/JavaScript/loader/dotnet.d.ts Updates TypeScript declarations for the new download signature.
src/native/libs/Common/JavaScript/loader/assets.ts Adds HTTP-cache-only prefetch implementation and JS module prefetch link hints.
src/mono/wasm/testassets/WasmBasicTestApp/App/wwwroot/main.js Adds browser test scenarios for download()create() and download(true)create().
src/mono/wasm/Wasm.Build.Tests/DownloadThenInitTests.cs Extends existing test and adds a new test validating the cache-only mode.
eng/testing/scenarios/BuildWasmAppsJobsListCoreCLR.txt Enables DownloadThenInitTests for CoreCLR scenario runs.

Comment on lines +659 to +668
for (const mod of modules) {
if (!mod.resolvedUrl && mod.name) {
mod.resolvedUrl = locateFile(mod.name, true);
}
if (mod.resolvedUrl) {
const link = globalThis.document.createElement("link");
link.rel = "prefetch";
link.href = mod.resolvedUrl;
link.as = "script";
globalThis.document.head.appendChild(link);
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prefetchJSModuleLinks assumes document/document.head exist whenever ENVIRONMENT_IS_WEB is true, but ENVIRONMENT_IS_WEB also includes Web Workers where document is undefined. This will throw during download() in worker/sidecar scenarios. Add a stricter guard (e.g., check globalThis.document?.head before creating/appending links).

Suggested change
for (const mod of modules) {
if (!mod.resolvedUrl && mod.name) {
mod.resolvedUrl = locateFile(mod.name, true);
}
if (mod.resolvedUrl) {
const link = globalThis.document.createElement("link");
link.rel = "prefetch";
link.href = mod.resolvedUrl;
link.as = "script";
globalThis.document.head.appendChild(link);
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);

Copilot uses AI. Check for mistakes.
Comment on lines +673 to +687
async function prefetchUrl(url: string, hash?: string | null | "", name?: string, behavior?: string): 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 as AssetBehaviors);
if (typeof customLoadResult === "string") {
url = makeURLAbsoluteWithApplicationBase(customLoadResult);
} else if (typeof customLoadResult === "object") {
// Custom loader returned a Response promise — await and discard
const response = await (customLoadResult as Promise<Response>);
await response.arrayBuffer();
return;
}
}
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prefetchUrl treats any non-string object result from loadBootResourceCallback as a Promise<Response>, but the callback contract allows null/undefined (default handling) and can also return non-Response objects (e.g., BootModule). With null, this path will skip the default fetch and then fail when calling arrayBuffer(), silently disabling prefetch. Tighten the check (exclude null/undefined) and validate the awaited value is Response-like (has arrayBuffer) before consuming; otherwise fall back to the default fetch.

Copilot uses AI. Check for mistakes.
Comment on lines +595 to +605
const maxParallel = loaderConfig.maxParallelDownloads || 1000000;
const pending: Promise<void>[] = [];
const queue: { url: string; hash?: string | null | ""; name: string; behavior: string }[] = [];

function enqueueAsset(asset: { name?: string; resolvedUrl?: string; hash?: string | null | "" }, behavior: string): 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 });
}
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

prefetchAllResources stores behavior as a plain string and then casts to AssetBehaviors later. Since you already import AssetBehaviors, type the queue/parameters as AssetBehaviors to prevent accidental unsupported behavior strings from compiling and to avoid the cast.

Copilot uses AI. Check for mistakes.
Comment on lines +28 to 31
// Calling download() again is a noop
if (downloadOnly && downloadStarted) {
return;
}
Copy link

Copilot AI Apr 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

downloadStarted is used as a simple boolean guard, so a second download() call returns immediately (without awaiting the first call’s work) and it also prevents switching from download(true) (HTTP-cache-only) to download(false) (download into WASM memory). Consider tracking the in-flight download promise and the completed mode (e.g., none/cacheOnly/intoMemory) so subsequent calls can await completion and/or upgrade modes when requested.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch-wasm WebAssembly architecture area-Host os-browser Browser variant of arch-wasm

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[coreclr] Download only - Can't re-init native module

2 participants