[wasm][coreclr] ReadyToRun for CoreCLR browser-wasm - #133378
Conversation
|
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. |
|
Tagging subscribers to 'arch-wasm': @lewing, @pavelsavara |
|
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.
c42f302 to
e00a2dd
Compare
There was a problem hiding this comment.
🟡 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
There was a problem hiding this comment.
🟡 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
Crossgen2Pathis supported by the resolver below (it is converted into@(Crossgen2Tool)at lines 46-50), but this preflight does not consider it. Therefore a publish withCrossgen2InBuildDirandResolvedCrossgen2Packboth empty is rejected here even when the caller supplied a usable crossgen2 executable viaCrossgen2Path. 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
Crossgen2InBuildDirsatisfiesExists('$(Crossgen2InBuildDir)')here even when neither supported executable (crossgen2at the root nor undertools/) 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,
ReferenceCopyLocalPathscan contain satellite items such asfr/Foo.resources.dll. This transform repoints them to the flat linker pathlinked/Foo.resources.dlland overwritesRelativePathwith only the filename, whileComputeWasmBuildAssetsrecognizes satellites by matching the candidate identity toReferenceSatellitePathsor 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.propssetsCrossgen2SdkOverridePropsPathfor every Core MSBuild, so this condition imports the shim and appends the CrossGen targets in Mono projects as well; the shim setsCrossgen2TasksOverridenand 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
Crossgen2InBuildDirnorCrossgen2SdkOverridePropsPathis set, so this import is skipped. That makesMicrosoft.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, soIsR2RWebcil(candidateDllPath)returns beforeTryReadMvidreaches 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 withoutPEReaderand staged.
if (IsWebcilInWasm(stream))
- Files reviewed: 17/17 changed files
- Comments generated: 2
- Review effort level: Lite
|
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 buildThe R2R compilation should run as a prerequisite for 2. ConvertDllsToWebcilThis 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 3. How we work
|
There was a problem hiding this comment.
🔵 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'sclrjit_universal_wasm_*sidecars; the base_CreateR2RImagesinput 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 inobj/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 separatewasm-webcil-publish.stampinMicrosoft.NET.Sdk.WebAssembly.Browser.targetsand does not recordPublishTrimmed. Therefore changing onlyPublishTrimmedbetween 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. AddPublishTrimmedto 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 whenCrossgen2Pathcomes from the in-build layout. As a result,_ReadyToRunCompilerInputscontains the resolved crossgen2 item but not theclrjit_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.
IntermediateLinkDiris defined even when ILLink is not running, and_ComputeWasmBuildCandidatesis reached through the build/static-assets path before the currentILLinktarget; iflinked/is left by a previous publish, theReferenceCopyLocalPathsrewrite 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 onExists(...).
<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
WebcilSizesModuleReaderand conversion probes; they never instantiateWebcilReader, 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
|
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. 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. |
|
/ba-g unrelated PR failures |
Makes
PublishReadyToRunwork for CoreCLR on browser-wasm, in-tree and out-of-tree, in both the dev loopand publish, integrated with the Static Web Assets pipeline and the WebAssembly SDK.
PublishReadyToRundefaults tofalse. Nothing changes for anyone who does not opt in, and nothingchanges for Mono at all — every new branch lives in a
*.CoreCLR.*file or is gated onRuntimeFlavor.Modes
dotnet buildnative/r2r/*.wasmfrom the runtime pack; facades converted from pack ILdotnet publish, untrimmeddotnet publish, trimmedPublish 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. Theruntime validates this at load through
ReadyToRunSectionType::ManifestAssemblyMvids, and a mismatch is afail-fast at startup, not a graceful fallback to IL.
That single constraint explains most of the design:
Changes
Staging correctness in the webcil converter
ConvertDllsToWebcilcan stage a prebuilt R2R image instead of converting IL. The guard comparedassembly 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, andPEReaderwould throw on it, return null, and silently bypass the guard.WebcilReader.Disposeleaked itsMetadataReaderProvider, which owns a memory-mapped section over thestream. 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_initializewith
0x80131018.The wiring now ships from
Microsoft.NET.Sdk.WebAssembly.Packin two new CoreCLR-only files, imported onlywhen an in-build crossgen2 is available, and inert for stock consumers. It probes both the raw in-build
layout (
crossgen2at the root) and the shippedMicrosoft.NETCore.App.Crossgen2pack layout (undertools/), and setsCrossgen2Tooldirectly from the in-build crossgen2, falling back to the SDK-resolvedResolvedCrossgen2Packwhen there is no in-build layout (a standalone app withPublishReadyToRun=truepopulates 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.dllinResolvedFileToPublishwith an R2R image named<name>.wasm, butComputeWasmPublishAssetsclassifiesmanaged assemblies by the
.dllextension — so the images were treated as native and leaked to the publishroot, leaving the boot config with no
coreAssembly. Restoring the IL.dllmakes the asset managed againand
ConvertDllsToWebcilstages the image fromPrebuiltR2RDirectory, which is the dev-loop mechanism.This must happen in both the outer and the nested pass. A native relink crossgens inside
WasmNestedPublishApp, whereProcessPublishFilesForWasmis never scheduled, and_GatherWasmFilesToPublishfilters the bundle to
.dll— so without the second hook every compiled assembly is still named.wasmatthat 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.CoreLibat all.Feeding the compile list. On the Blazor / static-web-assets route ILLink stamps
PostprocessAssemblyon its own collection rather than on
ResolvedFileToPublish, so the mainline compile list was empty andcrossgen2 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 linkeroutput and repointed there, including the project's own assembly, which otherwise reaches the bundle
untrimmed via
@(IntermediateAssembly).Webcil re-conversion on a trim flip.
PublishTrimmedis added to the webcil staging stamp, so a trimchange 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-
wasmcontainer formats are rejected with a comprehensible error ratherthan 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) relinksdotnet.native.wasmwith emcc whenWasmBuildNative=true, mirroring the Mono path. The entry points (WasmBuildApp,WasmTriggerPublishApp)are gated on
IsBrowserWasmProject, and_CoreCLRSetWasmBuildNativeDefaultsmirrors Mono's_SetWasmBuildNativeDefaults: it auto-enables a relink when a relink-affecting property differs from thevalue 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. BecauseUsingBrowserRuntimeWorkloadis false in the no-workload CoreCLR mode,
_CoreCLRWasmNativeForBuild/_CoreCLRWasmNativere-schedule therelink 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.csprojwith different global properties, so MSBuild builds it asecond time and copies
obj→binover the assembly the outer pass has already loaded, failing withMSB3027. The outer pass has built the task by then, so the reference is redundant there as well as harmful.Tests
Wasm.Build.Tests.ReadyToRunTestscovers the dev-loop build, publish trimmed and untrimmed, both with andwithout 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:
AssertTrimmedClosureIsFullyStagedAssertNoDuplicateAssembliesAssertNoManagedAssembliesOutsideFramework.wasm-named images classified as nativeAssertPerAppCrossgenRanAssertCoreLibReadyToRunThe test app gains the
Weatherpage its nav menu already linked to. The in-build crossgen2 and thewasm-aware
Crossgen2Tasksshim ship as Helix correlation payload so the tests can resolve them there.System.Runtime.InteropServices.JavaScript.Testsopts into ReadyToRun for CoreCLR, giving the pipeline alibrary-test vehicle for the trimmed publish flow. Mono is unaffected.
Validation
Four independent vehicles, two different SDKs, three different apps.
Wasm.Build.Tests.ReadyToRunTestssrc/mono/sample/wasm/browserSystem.Runtime.InteropServices.JavaScript.Tests_CreateR2RImagesbatches per assembly, so its execution count is the image count: 122 untrimmed / 37trimmed — 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,PublishReadyToRunandPublishTrimmed. CoreCLR only; Mono takes adifferent path through
BrowserWasmApp.targetsand none of this describes it.Every number below comes from a build, not from reading targets.
Assembly categories
ProjectReference/PackageReferenceclosureobj/.../<app>.dll, NuGet cachelib/net11.0/*.dll(IL) andnative/r2r/*.wasm(prebuilt)$(WasmTestRunnerDir)*.resources.dllper cultureobj/.../<culture>/Grid A — dev loop (
dotnet build)PublishTrimmedhas no effect: ILLink is a publish-only step. Verified in-tree and out-of-tree — thestaged set is identical either way.
R2R=false,native=*.wasm.wasmlib/net11.0/X.dll→obj/webcil/X.wasmR2R=true,native=*.wasmnative/r2r/X.wasm→obj/webcil/X.wasmThe dev loop never runs crossgen2.
obj/R2Ris empty in all four build rows, yet CoreLib grows from5,543 KB to 28,990 KB with
R2R=true— those bytes are the pack's prebuilt image being staged rather thanIL being converted.
Pack R2R covers only part of the framework. The same app converts 202 assemblies to webcil with
R2R=falsebut only 101 withR2R=true. The pack shipsnative/r2rimages for 101 assemblies; therest 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
ResolveReferencesReferenceCopyLocalPaths.dllenter the graph_WasmCoreClrPrunePackR2RFromBuildReferenceCopyLocalPathsRemove.wasmitems that also exist in$(_WasmRuntimePackR2RDir)_ComputeWasmBuildCandidatesWasmAssembliesToBundle_ConvertBuildDllsToWebcil→ConvertDllsToWebcilWebcilOutputPath.dll→obj/webcil/X.wasm_WasmBuildPrebuiltR2RDirectoryR2R=true, points at packnative/r2r/; a matching MVID stages that image instead of converting ILDefineStaticWebAssetsWasmStaticWebAsset,CopyToOutputDirectoryAssetKind=Build,ContentRoot=obj/webcil/UpdatePackageStaticWebAssets_WasmMaterializedFrameworkAssets.dat,dotnet.js,dotnet.native.wasm) →obj/fx/Managed assemblies are served from
obj/webcil/, notobj/fx/. With webcil on,_ResolveWasmOutputsroutes everything
.dll-derived into_WebcilAssetsCandidates;_WasmFrameworkCandidatesreceives only@(_WasmNonDllNonNativeCandidates). BCL assemblies never pass throughUpdatePackageStaticWebAssets.Build output is not staged to
bin.bin/wwwroot/_frameworkis empty after a build; assets are servedout 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:
ConvertDllsToWebcilfalls back to converting IL wheneverboth 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
Counts and sizes are in-tree / out-of-tree; the two apps differ in size, the flow does not.
WasmBuildNativechanges none of these outcomes — eachnative=truerow is byte-identical to itsnative=falsetwin. The path differs: steps 7–8 below only engage when_CoreCLRWasmBuildAppCoreruns,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
_RunILLinkIntermediateLinkDirobj/linked/X.dll_WasmFeedReadyToRunCompileList_ReadyToRunCompileList_PrepareForReadyToRunCompilationOutputR2RImageobj/R2R/X.wasmper assemblyCreateReadyToRunImagesResolvedFileToPublishobj/R2R/X.wasm; replaces IL.dllwith.wasm_WasmCoreClrPruneR2RFromPublishResolvedFileToPublishRemove_WasmCoreClrRoutePerAppR2RToFrameworkResolvedFileToPublishRemove + Include%(OutputR2RImage), restores the IL.dllso the asset is classified managed_GatherWasmFilesToPublishWasmAssembliesToBundleResolvedFileToPublish, keeping only.dll_CoreCLREmitAssembliesFinalWasmAssembliesFinalProcessPublishFilesForWasm_WasmResolvedFilesToPublishWasmAssembliesFinalif non-empty, elseResolvedFileToPublishConvertDllsToWebcilPrebuiltR2RDirectory=obj/R2RX.wasmComputeWasmPublishAssetsStaticWebAsset$(PublishDir)wwwroot/_framework/X.<fingerprint>.wasmWhy 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);_GatherWasmFilesToPublishthen keeps only.dll(7); andProcessPublishFilesForWasmprefersWasmAssembliesFinaloverResolvedFileToPublishwhenever it is non-empty (9).A native relink populates
WasmAssembliesFinalfrom the nested pass. Hooking onlyProcessPublishFilesForWasmmeans step 6 never runs there, so every crossgen'd assembly is still named.wasmat step 7 and drops out of the bundle. Hence the two hooks:The reason is scheduling, not the skip condition:
BeforeTargetshooksDependsOnTargetsConditionfalseProcessPublishFilesForWasmbeing condition-skipped would not by itself stop a hook. What stops it is thatthe target is never scheduled in the nested pass at all. Target executions per build, before and after:
ProcessPublishFilesForWasm_GatherWasmFilesToPublish_WasmCoreClrRoutePerAppR2RToFramework_WasmCoreClrPruneR2RFromPublishThe second hook buys the nested execution and nothing else moves. The same numbers show
_WasmCoreClrPruneR2RFromPublishruns outer-only.Context deltas
artifacts/binartifacts/bindotnet-none+ local feed_WasmFrameworkCopyToOutputDirectoryNeverNeverPreserveNewestNeverobj/fx/(build),PublishDir(publish)bin/.../wwwroot/_frameworkPublishDirWasm.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 cacherecreated for every test.
Its native-relink cases pass
-p:UsingBrowserRuntimeWorkload=false, and the consequence is visible in thebinlogs:
WasmNestedPublishAppnever runs in any of the six cases, and_GatherWasmFilesToPublishrunsonce rather than twice. The relink still happens —
AssertBundle(isNativeBuild: true)proves it — butthrough the build-phase targets rather than a nested publish.
WasmNestedPublishApp_GatherWasmFilesToPublish_WasmCoreClrRoutePerAppR2RToFramework_CreateR2RImagesThe out-of-tree matrix is consequently the vehicle that exercises the two-hook nested path.
Library tests only
AddTestRunnersToReferenceCopyLocalPathsCopyToOutputDirectory=PreserveNewest— the only assemblies carrying that metadata explicitly_WasmCoreClrSuppressNestedPublishAssetCopy_WasmFrameworkCopyToOutputDirectory=Neverand stampsCopyToOutputDirectory=Neveron copy-local items_WasmCoreClrRestoreCopyToOutputDirectoryThat last pair matters:
DefineStaticWebAssetsprefers per-item metadata over its task parameter, so aNeverreturned from the nested pass leaves the asset defined in the manifest but never copied — theboot 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 onlydo anything when the test infra sets
_WasmFrameworkCopyToOutputDirectory=PreserveNewest(a real app leavesthe default
Never, making the suppress/restore pair no-ops), so they are CoreCLR-gated test-only targets.Not library tests only
_WasmCoreClrRestrictBuildToTrimmedClosurefires in any trimmed publish flow, because_IsPublishingalone sets
_WasmCoreClrUnderPublish. It drops copy-local assemblies outside the trimmed closure, repointssurvivors at
obj/linked/, and addsWasmAssembliesFinalfor the project's own assembly, which otherwiseships untrimmed from
@(IntermediateAssembly).On a clean publish it no-ops, because
obj/linkeddoes not exist yet when build candidates are computed.SIMD in trimmed CoreCLR library tests
The interpreter maps every
System.Runtime.Intrinsics.<arch>member exceptget_IsSupportedtoPlatformNotSupportedException; only crossgen'd code can execute them.eng/testing/tests.wasm.targetstherefore selects the
NoWasmIntrinsicsILLink substitutions when the runtime is CoreCLR and ReadyToRun isoff, and the SIMD-enabled ones when R2R is on.
Satellite assemblies
_CoreCLREmitAssembliesFinalseparates*.resources.dllinto_WasmSatelliteAssemblies, stampsCultureNamefrom the parent directory, and re-adds them to
WasmAssembliesFinalafter the main set; they stage under_framework/<culture>/. None of the test apps carry satellite assemblies, so this row is from readingBrowserWasmApp.CoreCLR.targetsrather than from a build.Excluded combinations
wasm-toolsmanifestNETSDK1147— distinct from the Wasm.Build.Tests no-workload lane, whosedotnet-nonecarries the manifest and restores the packs from a local feedWasmEnableSIMD=falseEvidence index
In-tree —
src/mono/sample/wasm/browserOut-of-tree Blazor app — 16/16, browser-verified
Library tests —
System.Runtime.InteropServices.JavaScript.TestsWasm.Build.Tests —
ReadyToRunTests, 6/6 passedThe
native=trueandnative=falserows are identical in every counter.Note
This pull request description was generated with the assistance of GitHub Copilot.