Skip to content

[wasm][coreclr] ReadyToRun for CoreCLR browser-wasm - #133378

Merged
pavelsavara merged 18 commits into
dotnet:mainfrom
pavelsavara:browsehost_load_r2r_9
Sep 17, 2026
Merged

pavelsavara merged 18 commits into
dotnet:mainfrom
pavelsavara:browsehost_load_r2r_9

Conversation

@pavelsavara

@pavelsavara pavelsavara commented Sep 7, 2026 •

Copy link
Copy Markdown
Member

Makes PublishReadyToRun work for CoreCLR on browser-wasm, in-tree and out-of-tree, in both the dev loop
and publish, integrated with the Static Web Assets pipeline and the WebAssembly SDK.

PublishReadyToRun defaults to false. Nothing changes for anyone who does not opt in, and nothing
changes for Mono at all — every new branch lives in a *.CoreCLR.* file or is gated on RuntimeFlavor.

Modes

Mode Framework assemblies App assemblies crossgen2
dotnet build prebuilt native/r2r/*.wasm from the runtime pack; facades converted from pack IL IL webcil no
dotnet publish, untrimmed per-app crossgen of the whole closure per-app crossgen yes
dotnet publish, trimmed per-app crossgen of the trimmed closure, CoreLib included per-app crossgen yes

Publish always compiles the whole closure per app, trimmed or untrimmed — it never serves the runtime
pack's prebuilt CoreLib. The dev-loop build is the only consumer of the pack images. That uniformity is
what keeps the pipeline to a single publish flow.

One version bubble

Every staged image is compiled with --opt-cross-module:*, so any image may inline from any other. The
runtime validates this at load through ReadyToRunSectionType::ManifestAssemblyMvids, and a mismatch is a
fail-fast at startup, not a graceful fallback to IL.

That single constraint explains most of the design:

  • every image in a bundle must come from one crossgen run over one IL set;
  • incrementality has to be conservative — one changed assembly recompiles all of them;
  • stale images must be deleted rather than left to lose a timestamp race;
  • a prebuilt image may only be substituted for IL when the MVID matches.

Changes

Staging correctness in the webcil converter

ConvertDllsToWebcil can stage a prebuilt R2R image instead of converting IL. The guard compared
assembly versions, which almost never change between incremental builds, so an image compiled against
a previous IL set passed and was staged — a startup fail-fast. It now compares MVIDs, which turns that
into a build-time fallback to IL conversion.

Webcil-in-wasm is now detected by content (the wasm magic) rather than by file extension: a prebuilt image
may still be named *.dll, and PEReader would throw on it, return null, and silently bypass the guard.

WebcilReader.Dispose leaked its MetadataReaderProvider, which owns a memory-mapped section over the
stream. Inside a long-lived MSBuild task host the file stayed mapped and a later writer failed with
"user-mapped section open" — observed as crossgen2 being unable to write an R2R image that an earlier
probe had opened.

Crossgen2 resolution moves into the shipped pack

The resolution override lived in WasmApp.InTree.props, so only in-tree builds could produce per-app R2R.
An out-of-tree app fell through to the base SDK, whose ReadyToRun pipeline predates wasm support and emits
composite images; composite strips the assembly manifest, so the runtime fails coreclr_initialize
with 0x80131018.

The wiring now ships from Microsoft.NET.Sdk.WebAssembly.Pack in two new CoreCLR-only files, imported only
when an in-build crossgen2 is available, and inert for stock consumers. It probes both the raw in-build
layout (crossgen2 at the root) and the shipped Microsoft.NETCore.App.Crossgen2 pack layout (under
tools/), and sets Crossgen2Tool directly from the in-build crossgen2, falling back to the SDK-resolved
ResolvedCrossgen2Pack when there is no in-build layout (a standalone app with PublishReadyToRun=true
populates that pack through the base SDK).

Publish and build pipeline

The bulk of the work, in Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.targets.

Routing per-app images into _framework. The SDK CrossGen pipeline replaces each IL .dll in
ResolvedFileToPublish with an R2R image named <name>.wasm, but ComputeWasmPublishAssets classifies
managed assemblies by the .dll extension — so the images were treated as native and leaked to the publish
root, leaving the boot config with no coreAssembly. Restoring the IL .dll makes the asset managed again
and ConvertDllsToWebcil stages the image from PrebuiltR2RDirectory, which is the dev-loop mechanism.

This must happen in both the outer and the nested pass. A native relink crossgens inside
WasmNestedPublishApp, where ProcessPublishFilesForWasm is never scheduled, and _GatherWasmFilesToPublish
filters the bundle to .dll — so without the second hook every compiled assembly is still named .wasm at
that point and drops out silently. The symptom is a publish that exits 0 having staged only the
IL-less facades crossgen never compiled, with no System.Private.CoreLib at all.

Feeding the compile list. On the Blazor / static-web-assets route ILLink stamps PostprocessAssembly
on its own collection rather than on ResolvedFileToPublish, so the mainline compile list was empty and
crossgen2 never ran.

Restricting a trimmed publish to the linker closure. The runtime pack's copy-local set carries every
shared-framework assembly; the trimmed closure is a small subset. Serving the two mixed is wrong with or
without ReadyToRun — it mixes version bubbles, and an untrimmed assembly can call a member ILLink removed
from the trimmed framework (MissingMethodException). The served set is now restricted to the linker
output and repointed there, including the project's own assembly, which otherwise reaches the bundle
untrimmed via @(IntermediateAssembly).

Webcil re-conversion on a trim flip. PublishTrimmed is added to the webcil staging stamp, so a trim
change forces re-conversion (the stamp otherwise misses it). A broader mode-change cleanup that deleted stale
fingerprinted copies from a previous version bubble is intentionally left out of this PR — it will be
reimplemented later without deleting files — so an incremental mode flip over a dirty tree can leave a
duplicate; a clean publish is unaffected.

Conservative crossgen inputs and stale-image pruning, per the version-bubble constraint above.

Validation. Composite and non-wasm container formats are rejected with a comprehensible error rather
than producing images that fail at startup, and an unresolvable crossgen2 is reported at the point of use.

Native relink

BrowserWasmApp.CoreCLR.targets (new, CoreCLR-only) relinks dotnet.native.wasm with emcc when
WasmBuildNative=true, mirroring the Mono path. The entry points (WasmBuildApp, WasmTriggerPublishApp)
are gated on IsBrowserWasmProject, and _CoreCLRSetWasmBuildNativeDefaults mirrors Mono's
_SetWasmBuildNativeDefaults: it auto-enables a relink when a relink-affecting property differs from the
value baked into the runtime pack, when the app references native files, or — in a trimmed Release nested
publish — to bake the app's own [UnmanagedCallersOnly] reverse thunks. Because UsingBrowserRuntimeWorkload
is false in the no-workload CoreCLR mode, _CoreCLRWasmNativeForBuild / _CoreCLRWasmNative re-schedule the
relink early enough for the static-web-assets manifest to pick up the relinked binary.

ILLink.Tasks and the nested publish

The nested publish evaluates ILLink.Tasks.csproj with different global properties, so MSBuild builds it a
second time and copies obj → bin over the assembly the outer pass has already loaded, failing with
MSB3027. The outer pass has built the task by then, so the reference is redundant there as well as harmful.

Tests

Wasm.Build.Tests.ReadyToRunTests covers the dev-loop build, publish trimmed and untrimmed, both with and
without a native relink, and the disabled case. Each publish case drives Home / Counter / Weather in a real
browser — a file count cannot distinguish a staged bundle from one that boots.

The assertions target failures seen during bring-up that still exit 0:

Assertion Failure it catches
AssertTrimmedClosureIsFullyStaged compiled assemblies dropping out of the bundle
AssertNoDuplicateAssemblies fingerprinted re-stage landing beside the old copy
AssertNoManagedAssembliesOutsideFramework .wasm-named images classified as native
AssertPerAppCrossgenRan crossgen silently not running, or running in the dev loop
AssertCoreLibReadyToRun IL served where R2R was requested, and vice versa

The test app gains the Weather page its nav menu already linked to. The in-build crossgen2 and the
wasm-aware Crossgen2Tasks shim ship as Helix correlation payload so the tests can resolve them there.

System.Runtime.InteropServices.JavaScript.Tests opts into ReadyToRun for CoreCLR, giving the pipeline a
library-test vehicle for the trimmed publish flow. Mono is unaffected.

Validation

Four independent vehicles, two different SDKs, three different apps.

Vehicle Cases Result
Wasm.Build.Tests.ReadyToRunTests 6 6/6 passed
Out-of-tree Blazor app 16 16/16, all 8 publish rows browser-verified
In-tree src/mono/sample/wasm/browser 12 12/12
System.Runtime.InteropServices.JavaScript.Tests 2 476 run / 474 passed / 0 failed, R2R on and off

_CreateR2RImages batches per assembly, so its execution count is the image count: 122 untrimmed / 37
trimmed
— matching exactly across two different apps and two different SDKs.

Browser evidence for the hardest combination (trimmed + R2R + native relink, out-of-tree):

{ "coreAssembly": { "name": "System.Private.CoreLib.gix5ckm7tz.wasm",
                    "bytes": 11566018, "wasmModule": true },
  "counter": "Current count: 2", "weatherRows": 5, "failures": [] }

Appendix — how assemblies flow

Reference for how managed assemblies move from their source to the served bundle, for every reachable
combination of WasmBuildNative, PublishReadyToRun and PublishTrimmed. CoreCLR only; Mono takes a
different path through BrowserWasmApp.targets and none of this describes it.

Every number below comes from a build, not from reading targets.

Assembly categories

Category What it is Where it starts
app project output plus its ProjectReference / PackageReference closure obj/.../<app>.dll, NuGet cache
BCL shared-framework assemblies runtime pack lib/net11.0/*.dll (IL) and native/r2r/*.wasm (prebuilt)
runner xharness / test-runner set, library tests only $(WasmTestRunnerDir)
satellite *.resources.dll per culture obj/.../<culture>/

Grid A — dev loop (dotnet build)

PublishTrimmed has no effect: ILLink is a publish-only step. Verified in-tree and out-of-tree — the
staged set is identical either way.

flags app BCL source → target
R2R=false, native=* IL → webcil .wasm IL → webcil .wasm pack lib/net11.0/X.dll → obj/webcil/X.wasm
R2R=true, native=* IL → webcil .wasm pack R2R where one exists, IL → webcil otherwise pack native/r2r/X.wasm → obj/webcil/X.wasm

The dev loop never runs crossgen2. obj/R2R is empty in all four build rows, yet CoreLib grows from
5,543 KB to 28,990 KB with R2R=true — those bytes are the pack's prebuilt image being staged rather than
IL being converted.

Pack R2R covers only part of the framework. The same app converts 202 assemblies to webcil with
R2R=false but only 101 with R2R=true. The pack ships native/r2r images for 101 assemblies; the
rest are converted from IL in both modes. The remainder are pure type-forwarding facades with no IL bodies,
which crossgen2 correctly skips. A dev-loop build with R2R on is therefore a mixture, not a wholesale swap.

Transformation points

# Target Item / metadata Effect
1 ResolveReferences ReferenceCopyLocalPaths app + BCL IL .dll enter the graph
2 _WasmCoreClrPrunePackR2RFromBuild ReferenceCopyLocalPaths Remove drops .wasm items that also exist in $(_WasmRuntimePackR2RDir)
3 _ComputeWasmBuildCandidates WasmAssembliesToBundle candidate set for conversion
4 _ConvertBuildDllsToWebcil → ConvertDllsToWebcil WebcilOutputPath .dll → obj/webcil/X.wasm
4a — _WasmBuildPrebuiltR2RDirectory with R2R=true, points at pack native/r2r/; a matching MVID stages that image instead of converting IL
5 DefineStaticWebAssets WasmStaticWebAsset, CopyToOutputDirectory AssetKind=Build, ContentRoot = obj/webcil/
6 UpdatePackageStaticWebAssets _WasmMaterializedFrameworkAssets non-managed framework assets only (ICU .dat, dotnet.js, dotnet.native.wasm) → obj/fx/

Managed assemblies are served from obj/webcil/, not obj/fx/. With webcil on, _ResolveWasmOutputs
routes everything .dll-derived into _WebcilAssetsCandidates; _WasmFrameworkCandidates receives only
@(_WasmNonDllNonNativeCandidates). BCL assemblies never pass through UpdatePackageStaticWebAssets.

Build output is not staged to bin. bin/wwwroot/_framework is empty after a build; assets are served
out of obj/ by the static-web-assets middleware. Library tests differ, because they set
_WasmFrameworkCopyToOutputDirectory=PreserveNewest.

The MVID check at 4a is what makes staging safe: ConvertDllsToWebcil falls back to converting IL whenever
both MVIDs are readable and differ. Two paths accept without comparing — a candidate that is already an R2R
webcil, and a prebuilt image whose MVID cannot be read (the deliberate "unreadable means accept" fallback).

Grid B — publish

trim R2R app BCL CoreLib assemblies
false false IL → webcil IL → webcil 5,543 KB 182 / 203
false true per-app R2R per-app R2R 26,760 KB 182 / 203
true false trimmed IL → webcil trimmed IL → webcil 1,752 / 2,347 KB 9 / 40
true true trimmed per-app R2R trimmed per-app R2R 7,375 / 11,295 KB 9 / 40

Counts and sizes are in-tree / out-of-tree; the two apps differ in size, the flow does not.

WasmBuildNative changes none of these outcomes — each native=true row is byte-identical to its
native=false twin. The path differs: steps 7–8 below only engage when _CoreCLRWasmBuildAppCore runs,
which is native-gated, so the relink decides how the bundle is assembled even though it does not change
what ends up in it.

Transformation points

# Target Item / metadata Effect Pass
1 _RunILLink IntermediateLinkDir trimmed closure → obj/linked/X.dll outer + nested
2 _WasmFeedReadyToRunCompileList _ReadyToRunCompileList feeds the trimmed IL closure as crossgen's compile + reference set outer
3 _PrepareForReadyToRunCompilation OutputR2RImage plans obj/R2R/X.wasm per assembly outer
4 CreateReadyToRunImages ResolvedFileToPublish crossgen2 writes obj/R2R/X.wasm; replaces IL .dll with .wasm outer + nested
5 _WasmCoreClrPruneR2RFromPublish ResolvedFileToPublish Remove drops pack R2R images outer
6 _WasmCoreClrRoutePerAppR2RToFramework ResolvedFileToPublish Remove + Include removes %(OutputR2RImage), restores the IL .dll so the asset is classified managed outer + nested
7 _GatherWasmFilesToPublish WasmAssembliesToBundle derives the bundle from ResolvedFileToPublish, keeping only .dll nested
8 _CoreCLREmitAssembliesFinal WasmAssembliesFinal splits satellites; returned to the outer pass nested
9 ProcessPublishFilesForWasm _WasmResolvedFilesToPublish uses WasmAssembliesFinal if non-empty, else ResolvedFileToPublish outer
10 ConvertDllsToWebcil PrebuiltR2RDirectory = obj/R2R stages the per-app R2R image as X.wasm outer
11 ComputeWasmPublishAssets StaticWebAsset → $(PublishDir)wwwroot/_framework/X.<fingerprint>.wasm outer

Why step 6 must run in both passes

Steps 4, 7 and 9 interact in a way that is easy to get wrong. Crossgen renames compiled assemblies to
.wasm (4); _GatherWasmFilesToPublish then keeps only .dll (7); and ProcessPublishFilesForWasm prefers
WasmAssembliesFinal over ResolvedFileToPublish whenever it is non-empty (9).

A native relink populates WasmAssembliesFinal from the nested pass. Hooking only
ProcessPublishFilesForWasm means step 6 never runs there, so every crossgen'd assembly is still named
.wasm at step 7 and drops out of the bundle. Hence the two hooks:

BeforeTargets="ProcessPublishFilesForWasm;_GatherWasmFilesToPublish"

The reason is scheduling, not the skip condition:

BeforeTargets hooks DependsOnTargets body
target scheduled, Condition false run no no
target never scheduled no no no

ProcessPublishFilesForWasm being condition-skipped would not by itself stop a hook. What stops it is that
the target is never scheduled in the nested pass at all. Target executions per build, before and after:

target before after
ProcessPublishFilesForWasm ran 1, skipped 2 ran 1, skipped 2
_GatherWasmFilesToPublish ran 2 ran 2
_WasmCoreClrRoutePerAppR2RToFramework ran 1 ran 2
_WasmCoreClrPruneR2RFromPublish ran 1 ran 1

The second hook buys the nested execution and nothing else moves. The same numbers show
_WasmCoreClrPruneR2RFromPublish runs outer-only.

Context deltas

in-tree out-of-tree library tests Wasm.Build.Tests
SDK / packs live from artifacts/bin private SDK + overlaid packs live from artifacts/bin dotnet-none + local feed
_WasmFrameworkCopyToOutputDirectory Never Never PreserveNewest Never
served from obj/fx/ (build), PublishDir (publish) same bin/.../wwwroot/_framework PublishDir
trimming publish only publish only always — the outer build triggers a nested publish per test case
extra assemblies — — runner set —
nested publish yes (relink) yes (relink) yes never

Wasm.Build.Tests differs structurally: no nested publish

WBT runs against the no-workload SDK (artifacts/bin/dotnet-none, which contains no WebAssembly packs and no
*.CoreCLR.targets) and restores the WebAssembly packages from a local feed into a per-test NuGet cache
recreated for every test.

Its native-relink cases pass -p:UsingBrowserRuntimeWorkload=false, and the consequence is visible in the
binlogs: WasmNestedPublishApp never runs in any of the six cases, and _GatherWasmFilesToPublish runs
once rather than twice. The relink still happens — AssertBundle(isNativeBuild: true) proves it — but
through the build-phase targets rather than a nested publish.

out-of-tree, trimmed + R2R + native WBT, trimmed + R2R + native
WasmNestedPublishApp ran never
_GatherWasmFilesToPublish ran 2 (one per pass) ran 1
_WasmCoreClrRoutePerAppR2RToFramework ran 2 ran 1
_CreateR2RImages 37 images 37 images

The out-of-tree matrix is consequently the vehicle that exercises the two-hook nested path.

Library tests only

Target Effect
AddTestRunnersToReferenceCopyLocalPaths injects the runner set with CopyToOutputDirectory=PreserveNewest — the only assemblies carrying that metadata explicitly
_WasmCoreClrSuppressNestedPublishAssetCopy nested pass sets _WasmFrameworkCopyToOutputDirectory=Never and stamps CopyToOutputDirectory=Never on copy-local items
_WasmCoreClrRestoreCopyToOutputDirectory outer pass undoes that stamp — the metadata is sticky and rides back on items the nested publish returns

That last pair matters: DefineStaticWebAssets prefers per-item metadata over its task parameter, so a
Never returned from the nested pass leaves the asset defined in the manifest but never copied — the
boot config then requests a fingerprint that is not on disk and startup fails on a 500.

All three targets live in eng/testing/tests.browser.targets, not the shipped WebAssembly SDK: they only
do anything when the test infra sets _WasmFrameworkCopyToOutputDirectory=PreserveNewest (a real app leaves
the default Never, making the suppress/restore pair no-ops), so they are CoreCLR-gated test-only targets.

Not library tests only

_WasmCoreClrRestrictBuildToTrimmedClosure fires in any trimmed publish flow, because _IsPublishing
alone sets _WasmCoreClrUnderPublish. It drops copy-local assemblies outside the trimmed closure, repoints
survivors at obj/linked/, and adds WasmAssembliesFinal for the project's own assembly, which otherwise
ships untrimmed from @(IntermediateAssembly).

On a clean publish it no-ops, because obj/linked does not exist yet when build candidates are computed.

SIMD in trimmed CoreCLR library tests

The interpreter maps every System.Runtime.Intrinsics.<arch> member except get_IsSupported to
PlatformNotSupportedException; only crossgen'd code can execute them. eng/testing/tests.wasm.targets
therefore selects the NoWasmIntrinsics ILLink substitutions when the runtime is CoreCLR and ReadyToRun is
off, and the SIMD-enabled ones when R2R is on.

Satellite assemblies

_CoreCLREmitAssembliesFinal separates *.resources.dll into _WasmSatelliteAssemblies, stamps CultureName
from the parent directory, and re-adds them to WasmAssembliesFinal after the main set; they stage under
_framework/<culture>/. None of the test apps carry satellite assemblies, so this row is from reading
BrowserWasmApp.CoreCLR.targets rather than from a build.

Excluded combinations

Excluded Why
stock SDK, no wasm-tools manifest a standalone app hard-errors with NETSDK1147 — distinct from the Wasm.Build.Tests no-workload lane, whose dotnet-none carries the manifest and restores the packs from a local feed
build × trimmed ILLink is publish-only; verified to change nothing at build time
WasmEnableSIMD=false rejected — the CoreCLR runtime requires SIMD
Mono out of scope

Evidence index

In-tree — src/mono/sample/wasm/browser

case exit obj/webcil obj/R2R staged CoreLib
build, R2R=false, native=f/t 0 182 0 0 5,543 KB
build, R2R=true, native=f/t 0 182 0 0 28,990 KB
publish, trim=f, R2R=f, native=f/t 0 182 0 182 5,543 KB
publish, trim=f, R2R=t, native=f/t 0 182 102 182 26,760 KB
publish, trim=t, R2R=f, native=f/t 0 182 0 9 1,752 KB
publish, trim=t, R2R=t, native=f/t 0 182 9 9 7,375 KB

Out-of-tree Blazor app — 16/16, browser-verified

case assemblies dupes CoreLib obj/R2R root leak browser
publish, trim=f, R2R=f, native=f/t 203 0 5,543 KB 0 0 PASS
publish, trim=f, R2R=t, native=f/t 203 0 26,760 KB 122 0 PASS
publish, trim=t, R2R=f, native=f/t 40 0 2,347 KB 0 0 PASS
publish, trim=t, R2R=t, native=f/t 40 0 11,295 KB 37 0 PASS

Library tests — System.Runtime.InteropServices.JavaScript.Tests

case served obj/R2R CoreLib tests
trimmed, R2R=false, native=true 43 0 3,029 KB (IL) 476 run / 474 passed / 0 failed
trimmed, R2R=true, native=true 43 39 13,463 KB (R2R) 476 run / 474 passed / 0 failed

Wasm.Build.Tests — ReadyToRunTests, 6/6 passed

case ILLink R2R images webcil conversions nested
build, R2R=true 1 0 101 (of 202 — rest staged from pack) no
build, R2R=false 1 0 202 no
publish, untrimmed, R2R, native=f/t 1 122 283 no
publish, trimmed, R2R, native=f/t 2 37 205 no

The native=true and native=false rows are identical in every counter.

Note

This pull request description was generated with the assistance of GitHub Copilot.

@dotnet-policy-service dotnet-policy-service Bot added the linkable-framework Issues associated with delivering a linker friendly framework label Sep 7, 2026
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

@pavelsavara pavelsavara added arch-wasm WebAssembly architecture os-browser Browser variant of arch-wasm labels Sep 7, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

Tagging subscribers to 'arch-wasm': @lewing, @pavelsavara
See info in area-owners.md if you want to be subscribed.

@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
Successfully started running 3 pipeline(s).
13 pipeline(s) were filtered out due to trigger conditions.
There may be pipelines that require an authorized user to comment /azp run to run.

The MetadataReaderProvider owns a memory-mapped section over the underlying stream. Leaving it to the
finalizer keeps the file mapped inside long-lived MSBuild task hosts, so a later writer targeting the
same path fails with "user-mapped section open". Observed as crossgen2 failing to rewrite an R2R image
that an earlier ConvertDllsToWebcil probe had opened.
ConvertDllsToWebcil may stage a prebuilt ReadyToRun image in place of converting IL. The guard compared
assembly versions, which almost never change between incremental builds, so a stale image compiled
against a previous IL set passed the check and was staged. With cross-module inlining every image in a
bundle belongs to one version bubble that the runtime validates by MVID at load, so that stale image is
a startup fail-fast rather than a graceful fallback.

Compare MVIDs instead, which turns the failure into a build-time fallback to IL conversion. Detect
webcil-in-wasm by content (the wasm magic) rather than by extension, because a prebuilt image may still
be named *.dll and PEReader would throw on it, returning null and silently bypassing the guard. The
"unreadable identity means accept" fallback is preserved.
…mbly SDK pack

The crossgen2 resolution override lived in WasmApp.InTree.props, so only in-tree builds could produce
per-app R2R; an out-of-tree app fell through to the base SDK, whose ReadyToRun pipeline predates wasm
support and emits composite images. Composite strips the assembly manifest, so the runtime fails
coreclr_initialize with 0x80131018 at startup.

Ship the wiring from the pack instead, in CoreCLR-only files so no Mono path gains a branch. The import
is gated on a props-time signal for an in-build crossgen2 (Crossgen2InBuildDir, or
Crossgen2SdkOverridePropsPath in-tree, since liveBuilds.targets sets the former at targets-time), and is
inert for stock consumers, which keep resolving crossgen2 through the base SDK.

The override probes both the raw in-build layout (crossgen2 at the root) and the shipped
Microsoft.NETCore.App.Crossgen2 pack layout (under tools/), and sets Crossgen2Tool directly, because the
base SDK resolver keys on ResolvedCrossgen2Pack which a standalone app does not populate for a local
build. It can be retired once a restorable wasm crossgen2 pack exists (dotnet/sdk#55785).
…d pipeline

Implements the two modes: a dev-loop build stages the prebuilt framework R2R images from the runtime
pack and ships the app as IL, while publish crossgens the whole closure per app, trimmed or untrimmed.

The main correctness problems addressed:

- Per-app R2R images are named <name>.wasm, but ComputeWasmPublishAssets classifies managed assemblies by
  the .dll extension, so the images were treated as native and leaked to the publish root, leaving the
  boot config with no coreAssembly. Restore the IL .dll in the publish list so ConvertDllsToWebcil stages
  the image from PrebuiltR2RDirectory. This has to happen in both the outer and nested passes: a native
  relink crossgens inside WasmNestedPublishApp, where ProcessPublishFilesForWasm is never scheduled, and
  _GatherWasmFilesToPublish filters to .dll, dropping every compiled assembly while exiting 0.

- ILLink stamps PostprocessAssembly on its own collection rather than ResolvedFileToPublish on the
  Blazor/static-web-assets route, so the mainline compile list was empty and crossgen2 never ran.

- A trimmed publish flow served the full copy-local set from the runtime pack mixed with the trimmed
  closure, which mixes version bubbles and lets an untrimmed assembly call a member ILLink removed from
  the trimmed framework. Restrict the served set to the linker output and repoint it there.

- Flag flips left derived outputs behind. Static web assets are content-fingerprinted, so a re-stage adds
  a new name beside the old file instead of replacing it, leaving two copies of an assembly from two
  different version bubbles. Record the mode and drop the derived outputs when it changes.

- Per-app crossgen inputs are deliberately conservative: cross-module inlining means any change must
  recompile every image, and stale images in obj/R2R are pruned.

Composite and non-wasm container formats are rejected with a comprehensible error instead of producing
images that fail at startup, and a missing crossgen2 is reported at the point of use.

PublishReadyToRun defaults to false; flipping it belongs to the codegen-quality work stream.
The four relink triggers keyed solely on IsBrowserWasmProject, which a Blazor app leaves unset because it
resolves the wasm RID late, so WasmBuildNative=true was a silent no-op there and the app shipped the
prebuilt dotnet.native.wasm from the runtime pack. OR in WasmBuildNative, which is unambiguous: this file
is imported only for CoreCLR browser-wasm apps. Kept as an OR so IsBrowserWasmProject, which also steers
ICU and tzdata skipping, is never forced on.

Fixes dotnet#133185
The nested publish evaluates ILLink.Tasks.csproj with different global properties, so MSBuild builds it a
second time and copies obj to bin over the assembly the outer pass has already loaded, failing with
MSB3027. The outer pass has built the task by the time the nested publish runs, so the reference is
redundant there as well as harmful.
Covers the dev-loop build (framework R2R staged from the runtime pack, no per-app crossgen), publish
trimmed and untrimmed (whole closure compiled per app), both with and without a native relink, and the
disabled case. Each publish case drives Home, Counter and Weather in a real browser, which is what
distinguishes a bundle that boots from one that merely looks staged.

The assertions target failures seen during bring-up that still exit 0: assemblies missing from the
staged set relative to the linker closure, duplicate fingerprinted copies of one assembly, managed
assemblies leaking outside _framework, and per-app crossgen running (or not) for the mode.

Adds the Weather page that the nav menu of the test app already linked to, and ships the in-build
crossgen2 plus the wasm-aware Crossgen2Tasks shim as Helix correlation payload so the tests can resolve
them there.
Gives the R2R pipeline a library-test vehicle: this suite exercises the trimmed publish flow, where the
served bundle must be exactly the linker closure staged as per-app R2R images. CoreCLR only; Mono is
unaffected. tests.browser.targets already implies PublishTrimmed from PublishReadyToRun.
@pavelsavara
pavelsavara force-pushed the browsehost_load_r2r_9 branch from c42f302 to e00a2dd Compare September 8, 2026 11:18

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

The new CoreCLR MSBuild targets attempt to modify existing item metadata without using Update=..., which risks creating empty items or not applying the intended metadata changes.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Enables PublishReadyToRun for CoreCLR browser-wasm by wiring a wasm-capable crossgen2 into the WebAssembly SDK/pack pipeline, ensuring per-app R2R images are correctly staged as managed framework assets, and hardening incremental correctness (MVID-based validation, stale-output pruning). It also adds end-to-end browser tests that exercise build/publish (trimmed/untrimmed) scenarios and fixes a resource leak in WebcilReader.

Changes:

  • Fix webcil staging correctness by comparing MVIDs (instead of assembly versions) and by detecting webcil-in-wasm by wasm magic rather than extension.
  • Add CoreCLR-only MSBuild props/targets to resolve crossgen2 correctly and route per-app R2R images into _framework, with mode-stamp invalidation and stale R2R pruning.
  • Add browser-driven test coverage for CoreCLR R2R build/publish flows and ship needed crossgen2/shim bits to Helix correlation payload.
File summaries
File Description
src/tasks/Microsoft.NET.WebAssembly.Webcil/WebcilReader.cs Dispose MetadataReaderProvider to avoid file-mapping leaks in long-lived task hosts.
src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/ConvertDllsToWebCil.cs Switch prebuilt R2R matching to MVID checks and add wasm-magic detection for webcil-in-wasm.
src/mono/wasm/Wasm.Build.Tests/WebcilInWasmSizesTests.cs Add regression test ensuring fallback to IL conversion on prebuilt MVID mismatch.
src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs New Playwright-based tests validating CoreCLR R2R behavior across build/publish modes.
src/mono/wasm/Wasm.Build.Tests/Common/EnvironmentVariables.cs Add BASE_DIR plumbing for resolving shipped crossgen2/shim under Helix payload.
src/mono/wasm/testassets/BlazorBasicTestApp/App/Pages/Weather.razor Add a Weather page to exercise multi-page navigation in browser tests.
src/mono/sample/wasm/Directory.Build.props Default PublishReadyToRun to false for samples unless explicitly set/nested-propagated.
src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.props Import CoreCLR R2R wiring when an in-build crossgen2 signal is present.
src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.targets CoreCLR browser-wasm R2R/publish staging, trimming closure restriction, and invalidation/pruning logic.
src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.ReadyToRun.targets New ResolveReadyToRunCompilers override to point at in-build crossgen2 for wasm.
src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.props New props-time hook to append Crossgen2Tasks shim + CoreCLR ReadyToRun override targets.
src/mono/browser/build/WasmApp.ReadyToRun.targets Remove in-tree-only ResolveReadyToRunCompilers override (replaced by shipped pack wiring).
src/mono/browser/build/WasmApp.InTree.props Remove prior in-tree-only CoreCLR R2R wiring hooks (moved to shipped pack files).
src/mono/browser/build/BrowserWasmApp.CoreCLR.targets Ensure native relink triggers also respect WasmBuildNative when RID resolves late.
src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System.Runtime.InteropServices.JavaScript.Tests.csproj Opt CoreCLR wasm test lane into PublishReadyToRun=true pending default flip.
src/libraries/sendtohelix-browser.targets Add correlation payload entries for in-build crossgen2 + Crossgen2Tasks shim (CoreCLR).
eng/liveILLink.targets Avoid redundant ILLink.Tasks ProjectReference during wasm nested publish to prevent MSB3027.
Review details

Suppressed comments (1)

src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.targets:94

  • These ItemGroup entries are intended to restore metadata on existing items, but without Update they may add new items (or be rejected by MSBuild) instead of modifying the current item list. Use Update="@(ItemName)" so the CopyToOutputDirectory metadata is actually restored.
      <ReferenceCopyLocalPaths Condition="'%(ReferenceCopyLocalPaths.CopyToOutputDirectory)' == 'Never'"
                               CopyToOutputDirectory="$(_WasmFrameworkCopyToOutputDirectory)" />
      <WasmAssembliesFinal Condition="'%(WasmAssembliesFinal.CopyToOutputDirectory)' == 'Never'"
                           CopyToOutputDirectory="$(_WasmFrameworkCopyToOutputDirectory)" />
  • Files reviewed: 17/17 changed files
  • Comments generated: 2
  • Review effort level: Lite

Copilot AI review requested due to automatic review settings September 14, 2026 09:17

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Unresolved critical and moderate issues remain in R2R validation, crossgen2 resolution, and trimmed asset handling.

Get a fresh assessment by requesting another Copilot review.

Review details

Suppressed comments (6)

src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.targets:64

  • A valid explicit Crossgen2Path is supported by the resolver below (it is converted into @(Crossgen2Tool) at lines 46-50), but this preflight does not consider it. Therefore a publish with Crossgen2InBuildDir and ResolvedCrossgen2Pack both empty is rejected here even when the caller supplied a usable crossgen2 executable via Crossgen2Path. Include that override in the availability check; otherwise this new validation breaks the standard escape hatch used by the adjacent CoreCLR native path.
    <Error Condition="('$(_IsPublishing)' == 'true' or '$(WasmBuildingForNestedPublish)' == 'true' or '$(WasmBuildOnlyAfterPublish)' == 'true') and ('$(Crossgen2InBuildDir)' == '' or !Exists('$(Crossgen2InBuildDir)')) and '@(ResolvedCrossgen2Pack)' == ''"

src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.targets:65

  • An existing but incomplete Crossgen2InBuildDir satisfies Exists('$(Crossgen2InBuildDir)') here even when neither supported executable (crossgen2 at the root nor under tools/) exists. The resolver then leaves @(Crossgen2Tool) empty, and with no resolved pack this guard has already suppressed the intended actionable error; the failure is reported later during compiler setup. Validate the same executable candidates as the resolver, or run this validation after compiler resolution.
    <Error Condition="('$(_IsPublishing)' == 'true' or '$(WasmBuildingForNestedPublish)' == 'true' or '$(WasmBuildOnlyAfterPublish)' == 'true') and ('$(Crossgen2InBuildDir)' == '' or !Exists('$(Crossgen2InBuildDir)')) and '@(ResolvedCrossgen2Pack)' == ''"
           Text="PublishReadyToRun=true for CoreCLR browser-wasm requires a wasm-capable crossgen2, but none was resolved. Set Crossgen2InBuildDir to an in-build crossgen2 directory (or provide a ResolvedCrossgen2Pack) until wasm crossgen2 support flows through the base SDK (dotnet/sdk#55785)." />

src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.targets:123

  • In a trimmed publish, ReferenceCopyLocalPaths can contain satellite items such as fr/Foo.resources.dll. This transform repoints them to the flat linker path linked/Foo.resources.dll and overwrites RelativePath with only the filename, while ComputeWasmBuildAssets recognizes satellites by matching the candidate identity to ReferenceSatellitePaths or by culture metadata. The redirected item no longer matches the satellite list and has no culture path here, so it can be staged as a root assembly instead of under _framework/fr/; the trimmed R2R compile-list target repeats the same flattening. Preserve the satellite culture/related-asset metadata and relative directory when redirecting/linking.
      <ReferenceCopyLocalPaths Include="@(_WasmTrimmedClosureRedirect->'$(_WasmTrimmedClosureDir)%(FileName).dll')"
                               RelativePath="%(FileName)%(Extension)" />

src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.props:49

  • This import is not CoreCLR-gated. In an in-tree Mono build, Directory.Build.props sets Crossgen2SdkOverridePropsPath for every Core MSBuild, so this condition imports the shim and appends the CrossGen targets in Mono projects as well; the shim sets Crossgen2TasksOverriden and the CrossGen targets also initialize ReadyToRun properties. That contradicts the stated Mono isolation and can affect a Mono project that enables ReadyToRun. Gate this import on an explicit CoreCLR signal, while retaining the separate out-of-tree CoreCLR signal used by the tests.
  <Import Project="$(MSBuildThisFileDirectory)Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.props"
          Condition="'$(Crossgen2InBuildDir)' != '' or '$(Crossgen2SdkOverridePropsPath)' != ''" />

src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.props:49

  • For a normal out-of-tree/stock SDK build, neither Crossgen2InBuildDir nor Crossgen2SdkOverridePropsPath is set, so this import is skipped. That makes Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.ReadyToRun.targets—the only place that invokes the @(ResolvedCrossgen2Pack) fallback—unreachable, leaving the base SDK ReadyToRun targets in control even though the CoreCLR targets below select the wasm format. Import the CoreCLR resolver for all CoreCLR browser projects and keep only the in-build shim import conditional, or otherwise make the SDK-pack fallback reachable.
  <Import Project="$(MSBuildThisFileDirectory)Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.props"
          Condition="'$(Crossgen2InBuildDir)' != '' or '$(Crossgen2SdkOverridePropsPath)' != ''" />

src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/ConvertDllsToWebCil.cs:318

  • The new content-based Webcil branch is not exercised by ConvertDllsToWebcil_StagesR2RWebcilWithDllExtension: its candidate and prebuilt paths are the same Webcil file, so IsR2RWebcil(candidateDllPath) returns before TryReadMvid reaches this branch. Add a case with a real IL candidate and a distinct .dll-named Webcil prebuilt (with a matching MVID) to verify that the prebuilt image is read without PEReader and staged.
            if (IsWebcilInWasm(stream))
  • Files reviewed: 17/17 changed files
  • Comments generated: 2
  • Review effort level: Lite

Comment thread eng/testing/tests.browser.targets
@maraf

maraf commented Sep 16, 2026 •

Copy link
Copy Markdown
Member

There are 3 major areas we need to improve. All the posted comments are conceptually still valid. I'm not confident current implementation won't fall a part out of tree in time. The issues are not related to just this PR, but spread as cross recent changes, as the shape of the R2R was evolving.

1. How we hook crossgen in to build

The R2R compilation should run as a prerequisite for ProcessPublishFilesForWasm (respectively _GatherWasmFilesToPublish) to correctly define order of targets and gate stage when it runs (either outer publish or nested publish).

2. ConvertDllsToWebcil

This task and whole orchestration around should be skipped / replaced by R2R stage. My thinking is that we should switch which variant runs and both should produce "identical shape of output". ConvertDllsToWebcil still expects R2R binaries with .dll extension to be post processed to .wasm

3. How we work ReferenceCopyLocalPaths and ResolvedFileToPublish

There are many places where we modify these collections (and other R2R collections as well) resulting in duplicate entries that are not properly removed, causing need for other places to clear the state.

Related

Copilot AI review requested due to automatic review settings September 16, 2026 15:29
@pavelsavara pavelsavara added the NO-MERGE The PR is not ready for merge yet (see discussion for detailed reasons) label Sep 16, 2026

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Unresolved issues remain in crossgen2/JIT invalidation, publish stamp invalidation, and trimmed-closure freshness.

Review details

Suppressed comments (5)

src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.ReadyToRun.targets:32

  • When the fallback at lines 55-65 resolves the SDK crossgen2 pack, it populates @(Crossgen2Tool) but never sets _WasmResolvedCrossgen2Dir, so this property remains empty. The conservative-input target therefore omits the fallback pack's clrjit_universal_wasm_* sidecars; the base _CreateR2RImages input list tracks the crossgen2 item but not that JIT file. Updating the JIT without changing the crossgen2 path can consequently leave stale per-app images in obj/R2R. Derive the resolved tool directory from the fallback item (or add its JIT path explicitly) before constructing the invalidation inputs.
      <_WasmResolvedCrossgen2Dir Condition="'$(Crossgen2Path)' != ''">$([MSBuild]::EnsureTrailingSlash($([System.IO.Path]::GetDirectoryName('$(Crossgen2Path)'))))</_WasmResolvedCrossgen2Dir>

src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.targets:72

  • This item is consumed only by _WriteWasmBuildWebcilStamp, but publish uses a separate wasm-webcil-publish.stamp in Microsoft.NET.Sdk.WebAssembly.Browser.targets and does not record PublishTrimmed. Therefore changing only PublishTrimmed between incremental publishes does not invalidate the publish conversion stamp, so an existing webcil can remain staged with the wrong trim mode when the other inputs retain their timestamps. Add PublishTrimmed to the publish stamp as well (or make the shared property list drive both stamps).
  </ItemGroup>

src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.targets:211

  • The SDK-pack fallback leaves $(_WasmResolvedCrossgen2Dir) empty because it only sets that property when Crossgen2Path comes from the in-build layout. As a result, _ReadyToRunCompilerInputs contains the resolved crossgen2 item but not the clrjit_universal_wasm_* file that the SDK-pack crossgen2 auto-loads, so a JIT-only update can leave all per-app images up to date and serve code compiled with the old JIT. Please add the fallback pack's JIT path(s) to the conservative inputs (or derive the resolved compiler directory from @(Crossgen2Tool)) so both resolution paths invalidate the whole image set.
      <_ReadyToRunCompilerInputs Include="$(_WasmResolvedCrossgen2Dir)crossgen2*;$(_WasmResolvedCrossgen2Dir)clrjit_universal_wasm_*"
                                 Condition="'$(_WasmResolvedCrossgen2Dir)' != ''" />

src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.targets:105

  • This restriction can consume a stale linker closure on an incremental trimmed publish. IntermediateLinkDir is defined even when ILLink is not running, and _ComputeWasmBuildCandidates is reached through the build/static-assets path before the current ILLink target; if linked/ is left by a previous publish, the ReferenceCopyLocalPaths rewrite can drop assemblies newly added to the current closure (or select old linked inputs). Gate this rewrite on outputs from the current ILLink run, or otherwise invalidate/clean the linked directory before using it instead of relying only on Exists(...).
    <ItemGroup Condition="'$(_WasmCoreClrUnderPublish)' == 'true' and '$(WasmBuildingForNestedPublish)' != 'true' and Exists('$(_WasmTrimmedClosureDir)')">

src/tasks/Microsoft.NET.WebAssembly.Webcil/WebcilReader.cs:427

  • This change fixes a Windows-specific file-locking failure, but the tests do not exercise the lifetime being changed. The existing Webcil tests use WebcilSizesModuleReader and conversion probes; they never instantiate WebcilReader, force metadata initialization, dispose it, and then rewrite the backing file. Please add a regression test for that sequence so a future omission of _metadataReaderProvider.Dispose() cannot reintroduce the mapped-section failure.
        _metadataReaderProvider?.Dispose();
        _metadataReaderProvider = null;
  • Files reviewed: 17/17 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@davidwrighton

Copy link
Copy Markdown
Member

I hit these issues trying to use this logic yesterday. I don't know if they are still relevant. https://gist.github.com/davidwrighton/b43461cf4d808c7c4f7164ac987b3cbf

@pavelsavara

Copy link
Copy Markdown
Member Author

I hit these issues trying to use this logic yesterday. I don't know if they are still relevant. https://gist.github.com/davidwrighton/b43461cf4d808c7c4f7164ac987b3cbf

@davidwrighton Some of it is probably still relevant, but we are still discussing how exactly the solutions should look like.
In short, the in-tree publish is special and different from out-of tree build and it get's ugly around the edges.

I think we don't want IL-only DLLs in the wwwroot publish folder of the product. The out-of-tree debugging would always happen in dev-loop/build, not in publish mode.

I also agree with most @maraf 's comments above.

Making it simpler depends on wasm-tools workflow design, some of it depends on recent Net12 SDK flowing into runtime repo. And that will take much more time to untangle.

So we agreed with @kotlarmilos and @maraf to merge this set of ugly hacks to unblock dependent work and fix the MSbuild flow later.

I will resolve all comments on this PR now, not because they are actually resolved, but because we need to move on incrementally.

@pavelsavara

Copy link
Copy Markdown
Member Author

/ba-g unrelated PR failures

@pavelsavara
pavelsavara merged commit 8817f6b into dotnet:main Sep 17, 2026
161 of 166 checks passed
@pavelsavara
pavelsavara deleted the browsehost_load_r2r_9 branch September 17, 2026 11:40
jtschuster pushed a commit to jtschuster/runtime that referenced this pull request Sep 18, 2026
@maraf maraf removed the NO-MERGE The PR is not ready for merge yet (see discussion for detailed reasons) label Sep 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch-wasm WebAssembly architecture area-Build-mono linkable-framework Issues associated with delivering a linker friendly framework os-browser Browser variant of arch-wasm

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants