diff --git a/docs/workflow/testing/libraries/testing-wasm.md b/docs/workflow/testing/libraries/testing-wasm.md
index bc6965cd5dc4e4..739dc943b5d35a 100644
--- a/docs/workflow/testing/libraries/testing-wasm.md
+++ b/docs/workflow/testing/libraries/testing-wasm.md
@@ -144,6 +144,59 @@ At the moment supported values are:
By default, `chrome` browser is used.
+## CoreCLR ReadyToRun library tests
+
+Build the browser CoreCLR runtime, libraries, packs, and host crossgen2 first:
+
+```bash
+./build.sh clr+libs+host+packs -os browser -c Release /p:AotHostArchitecture=arm64 /p:AotHostOS=osx
+```
+
+Use the architecture and OS of the build machine for `AotHostArchitecture` and `AotHostOS`
+(for example, `x64` and `linux` on an x64 Linux host).
+On macOS, ensure a supported Python installation is ahead of Xcode's Python on `PATH`.
+
+Run a library suite with trimmed ReadyToRun images:
+
+```bash
+XHARNESS_COMMAND=test-browser ./dotnet.sh build /t:Test \
+ src/libraries/System.Runtime/tests/System.IO.UnmanagedMemoryStream.Tests/System.IO.UnmanagedMemoryStream.Tests.csproj \
+ /p:TargetOS=browser /p:TargetArchitecture=wasm /p:RuntimeFlavor=CoreCLR /p:Configuration=Release \
+ /p:TestWasmReadyToRun=true /p:EnableAggressiveTrimming=true \
+ /p:Scenario=WasmTestOnChrome /p:InstallChromeForTests=true
+```
+
+`TestWasmReadyToRun` enables `PublishReadyToRun` only in browser CoreCLR library test projects.
+Do not pass `PublishReadyToRun=true` globally to `build.sh`: that also attempts to publish
+host-side build tools with ReadyToRun.
+`EnableAggressiveTrimming` selects the shared mobile test trimming configuration, including
+the xUnit and test-utility descriptors and trimming-aware test exclusions.
+Library-specific descriptors retain their existing platform conditions; Apple-only roots
+are not enabled for browser tests. The property must also be passed when building the test utilities.
+R2R browser test apps also set `TEST_READY_TO_RUN_MODE=1`, so existing
+`PlatformDetection.IsReadyToRunCompiled` conditions apply. Quarantines for shared WebAssembly
+R2R issues use `PlatformDetection.IsWasmReadyToRun`, covering browser and WASI R2R while
+leaving interpreter coverage enabled on both hosts.
+`TestUtilities.dll` stays interpreted while its guarded platform probes are affected by
+[the R2R platform-probe issue](https://github.com/dotnet/runtime/issues/133614).
+The library and test assemblies still use ReadyToRun.
+
+For the existing CoreCLR library smoke set, use:
+
+```bash
+XHARNESS_COMMAND=test-browser ./build.sh libs.tests -test -os browser -c Release \
+ /p:RuntimeFlavor=CoreCLR /p:TestWasmReadyToRun=true /p:EnableAggressiveTrimming=true \
+ /p:RunSmokeTestsOnly=true /p:Scenario=WasmTestOnChrome /p:InstallChromeForTests=true
+```
+
+Omit `RunSmokeTestsOnly` to build and run all supported library suites. To isolate trimming
+from R2R, keep `EnableAggressiveTrimming=true` and pass `PublishReadyToRun=false`.
+Use clean project-specific `bin` and `obj` browser-wasm outputs when switching modes;
+stale staged assets can otherwise cause assembly-loading failures before tests start.
+
+The `LibraryTestsCoreCLR_R2R` CI jobs use the same configuration and archive the published
+tests for execution on Helix. The existing interpreter jobs remain separate.
+
## AOT library tests
- Building library tests with AOT, and (even) with `EnableAggressiveTrimming` takes 3-9mins on CI, and that adds up for all the assemblies, causing
diff --git a/eng/liveILLink.targets b/eng/liveILLink.targets
index d51194e4c25619..e31b6df4417f52 100644
--- a/eng/liveILLink.targets
+++ b/eng/liveILLink.targets
@@ -30,7 +30,11 @@
+
false
false
- true
+ true
<_WasmInTreeDefaults>false
false
@@ -60,6 +60,39 @@
so copy framework files to the output directory. See https://github.com/dotnet/runtime/issues/127257. -->
<_WasmFrameworkCopyToOutputDirectory>PreserveNewest
+
+
+
+
+ <_WasmFrameworkCopyToOutputDirectory>Never
+
+
+
+
+
+
+
+
+
+
+
+
+
+
true
true
@@ -67,6 +100,12 @@
true
+
+
+
+
+
+
false
diff --git a/src/libraries/Common/tests/TestUtilities/System/PlatformDetection.cs b/src/libraries/Common/tests/TestUtilities/System/PlatformDetection.cs
index 7e352915ac39e3..47db2845b1777b 100644
--- a/src/libraries/Common/tests/TestUtilities/System/PlatformDetection.cs
+++ b/src/libraries/Common/tests/TestUtilities/System/PlatformDetection.cs
@@ -276,6 +276,7 @@ private static bool GetLinqExpressionsBuiltWithIsInterpretingOnly()
public static bool IsSingleFile => !HasAssemblyFiles;
public static bool IsReadyToRunCompiled => Environment.GetEnvironmentVariable("TEST_READY_TO_RUN_MODE") == "1";
+ public static bool IsWasmReadyToRun => IsWasm && IsReadyToRunCompiled;
private static volatile Tuple s_lazyNonZeroLowerBoundArraySupported;
public static bool IsNonZeroLowerBoundArraySupported
diff --git a/src/libraries/Directory.Build.props b/src/libraries/Directory.Build.props
index b85797ae211c21..976eccc487e5b0 100644
--- a/src/libraries/Directory.Build.props
+++ b/src/libraries/Directory.Build.props
@@ -94,6 +94,11 @@
true
+
+
+ true
+
+
--interpreter
diff --git a/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System.Runtime.InteropServices.JavaScript.Tests.csproj b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System.Runtime.InteropServices.JavaScript.Tests.csproj
index d5a40b9c28f982..f8338254bfdda4 100644
--- a/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System.Runtime.InteropServices.JavaScript.Tests.csproj
+++ b/src/libraries/System.Runtime.InteropServices.JavaScript/tests/System.Runtime.InteropServices.JavaScript.UnitTests/System.Runtime.InteropServices.JavaScript.Tests.csproj
@@ -20,6 +20,9 @@
Suppress the NU1511 warning in the whole project as putting it on a P2P doesn't work: https://github.com/NuGet/Home/issues/14121 -->
$(NoWarn);NU1511
false
+
+
+ true
diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System.Runtime.Tests.csproj b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System.Runtime.Tests.csproj
index e62c619d9c1bf9..4dcede7eb71324 100644
--- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System.Runtime.Tests.csproj
+++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System.Runtime.Tests.csproj
@@ -375,7 +375,7 @@
-
+
diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/ArrayTests.cs b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/ArrayTests.cs
index 621185f3a32c6c..ba03fd1e58ac9c 100644
--- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/ArrayTests.cs
+++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/ArrayTests.cs
@@ -479,6 +479,7 @@ public static IEnumerable
+
+
+
+
+
+
+
+
diff --git a/src/mono/browser/build/WasmApp.InTree.props b/src/mono/browser/build/WasmApp.InTree.props
index 27bc4d6b0670b7..2eb17ec0c0f94c 100644
--- a/src/mono/browser/build/WasmApp.InTree.props
+++ b/src/mono/browser/build/WasmApp.InTree.props
@@ -24,15 +24,6 @@
true
-
-
- $(AfterMicrosoftNETSdkTargets);$(Crossgen2SdkOverrideTargetsPath)
- $(AfterMicrosoftNETSdkTargets);$(MSBuildThisFileDirectory)WasmApp.ReadyToRun.targets
-
-
-
library
diff --git a/src/mono/browser/build/WasmApp.ReadyToRun.targets b/src/mono/browser/build/WasmApp.ReadyToRun.targets
deleted file mode 100644
index 1194acbb46f69d..00000000000000
--- a/src/mono/browser/build/WasmApp.ReadyToRun.targets
+++ /dev/null
@@ -1,18 +0,0 @@
-
-
-
-
- $([MSBuild]::NormalizePath('$(Crossgen2InBuildDir)', 'crossgen2$(ExeSuffix)'))
-
-
-
-
-
-
-
diff --git a/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.ReadyToRun.targets b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.ReadyToRun.targets
new file mode 100644
index 00000000000000..6d09e1c20b68f8
--- /dev/null
+++ b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.ReadyToRun.targets
@@ -0,0 +1,68 @@
+
+
+
+
+ <_WasmCrossgen2InBuildUsable Condition="'$(Crossgen2InBuildDir)' != '' and Exists('$(Crossgen2InBuildDir)')">true
+ <_WasmCrossgen2ExeSuffix Condition="'$(ExeSuffix)' != ''">$(ExeSuffix)
+ <_WasmCrossgen2ExeSuffix Condition="'$(_WasmCrossgen2ExeSuffix)' == '' and $([MSBuild]::IsOSPlatform('windows'))">.exe
+
+ <_WasmCrossgen2RootExe>$([MSBuild]::NormalizePath('$(Crossgen2InBuildDir)', 'crossgen2$(_WasmCrossgen2ExeSuffix)'))
+ <_WasmCrossgen2ToolsExe>$([MSBuild]::NormalizePath('$([MSBuild]::NormalizeDirectory('$(Crossgen2InBuildDir)', 'tools'))', 'crossgen2$(_WasmCrossgen2ExeSuffix)'))
+ $(_WasmCrossgen2RootExe)
+ $(_WasmCrossgen2ToolsExe)
+
+ <_WasmResolvedCrossgen2Dir Condition="'$(Crossgen2Path)' != ''">$([MSBuild]::EnsureTrailingSlash($([System.IO.Path]::GetDirectoryName('$(Crossgen2Path)'))))
+
+ <_WasmR2RTargetOS Condition="'$(TargetOS)' != ''">$(TargetOS)
+ <_WasmR2RTargetOS Condition="'$(_WasmR2RTargetOS)' == ''">browser
+ <_WasmR2RTargetArch Condition="'$(TargetArchitecture)' != ''">$(TargetArchitecture)
+ <_WasmR2RTargetArch Condition="'$(_WasmR2RTargetArch)' == ''">wasm
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.props b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.props
new file mode 100644
index 00000000000000..0dc363e8833f6f
--- /dev/null
+++ b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.props
@@ -0,0 +1,26 @@
+
+
+
+
+
+ $(AfterMicrosoftNETSdkTargets);$(Crossgen2SdkOverrideTargetsPath)
+ $(AfterMicrosoftNETSdkTargets);$(MSBuildThisFileDirectory)Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.ReadyToRun.targets
+
+
diff --git a/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.targets b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.targets
index 70d3fc52add4e7..cb7de8a0102550 100644
--- a/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.targets
+++ b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.targets
@@ -11,12 +11,11 @@ Copyright (c) .NET Foundation. All rights reserved.
-->
-
- true
+ false
- -->
+
-
-
<_WasmRuntimePackR2RDir Condition="'$(_WasmRuntimePackR2RDir)' == '' and '$(_RuntimePackNativeDir)' != ''">$([MSBuild]::NormalizeDirectory('$(_RuntimePackNativeDir)', 'r2r'))
+
+ <_WasmCoreClrUnderPublish Condition="'$(_IsPublishing)' == 'true' or '$(WasmBuildingForNestedPublish)' == 'true' or '$(WasmBuildOnlyAfterPublish)' == 'true'">true
+
<_WasmPublishR2RDir Condition="'$(_WasmPublishR2RDir)' == ''">$([MSBuild]::NormalizeDirectory('$(IntermediateOutputPath)', 'R2R'))
- <_WasmBuildPrebuiltR2RDirectory Condition="'$(PublishTrimmed)' == 'true'">$(_WasmPublishR2RDir)
- <_WasmBuildPrebuiltR2RDirectory Condition="'$(PublishTrimmed)' != 'true'">$(_WasmRuntimePackR2RDir)
- <_WasmPublishPrebuiltR2RDirectory Condition="'$(PublishTrimmed)' == 'true'">$(_WasmPublishR2RDir)
- <_WasmPublishPrebuiltR2RDirectory Condition="'$(PublishTrimmed)' != 'true'">$(_WasmRuntimePackR2RDir)
+ <_WasmPublishPrebuiltR2RDirectory>$(_WasmPublishR2RDir)
+ <_WasmBuildPrebuiltR2RDirectory Condition="'$(_WasmCoreClrUnderPublish)' == 'true'">$(_WasmPublishR2RDir)
+ <_WasmBuildPrebuiltR2RDirectory Condition="'$(_WasmCoreClrUnderPublish)' != 'true'">$(_WasmRuntimePackR2RDir)
+
+
+
+
+
+
+
+
+
+ <_WasmWebcilStampProperty Include="PublishTrimmed" />
+
+
@@ -69,6 +84,52 @@ Copyright (c) .NET Foundation. All rights reserved.
+
+
+
+ <_WasmTrimmedClosureDir Condition="'$(_WasmTrimmedClosureDir)' == '' and '$(IntermediateLinkDir)' != ''">$(IntermediateLinkDir)
+ <_WasmTrimmedClosureDir Condition="'$(_WasmTrimmedClosureDir)' == ''">$([MSBuild]::NormalizeDirectory('$(IntermediateOutputPath)', 'linked'))
+
+ <_WasmCoreClrHasFinalAssemblies Condition="'@(WasmAssembliesFinal)' != ''">true
+
+
+
+
+ <_WasmInTrimmedClosure>false
+
+
+ <_WasmInTrimmedClosure>true
+
+
+
+
+ <_WasmTrimmedClosureRedirect Include="@(ReferenceCopyLocalPaths)" Condition="'%(_WasmInTrimmedClosure)' == 'true'" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
@@ -93,11 +176,12 @@ Copyright (c) .NET Foundation. All rights reserved.
Condition="'%(ResolvedFileToPublish.PostprocessAssembly)' == 'true' and Exists('$(IntermediateLinkDir)%(ResolvedFileToPublish.FileName).dll')" />
-
- <_WasmReadyToRunCompileInput Include="@(ManagedAssemblyToLink->'$(IntermediateLinkDir)%(FileName)%(Extension)')"
- Condition="Exists('$(IntermediateLinkDir)%(FileName)%(Extension)')" />
+
+ <_WasmReadyToRunCompileInput Include="$(IntermediateLinkDir)*.dll" />
+
+
+
+ <_ReadyToRunCompilerInputs Include="@(_ReadyToRunCompileList);@(_ReadyToRunAssembliesToReference)" />
+
+ <_ReadyToRunCompilerInputs Include="$(_WasmResolvedCrossgen2Dir)crossgen2*;$(_WasmResolvedCrossgen2Dir)clrjit_universal_wasm_*"
+ Condition="'$(_WasmResolvedCrossgen2Dir)' != ''" />
+
+
+
+
+
+
+
+ <_WasmStalePerAppR2R Include="$(_WasmPublishR2RDir)*.wasm;$(_WasmPublishR2RDir)*.dll"
+ Exclude="@(_ReadyToRunCompileList->'%(OutputR2RImage)')" />
+
+
+
+
diff --git a/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.props b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.props
index df683130079f51..902c930716bcf8 100644
--- a/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.props
+++ b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.props
@@ -40,6 +40,14 @@ Copyright (c) .NET Foundation. All rights reserved.
true
+
+
+
diff --git a/src/mono/sample/wasm/Directory.Build.props b/src/mono/sample/wasm/Directory.Build.props
index c2daa07e81b05a..b63362260ba5e2 100644
--- a/src/mono/sample/wasm/Directory.Build.props
+++ b/src/mono/sample/wasm/Directory.Build.props
@@ -53,6 +53,7 @@
$(Nested_RuntimeFlavor)
$(Nested_PublishReadyToRun)
+ false
diff --git a/src/mono/wasm/Wasm.Build.Tests/Common/EnvironmentVariables.cs b/src/mono/wasm/Wasm.Build.Tests/Common/EnvironmentVariables.cs
index fb12b0830c7a0f..2bddb349b84042 100644
--- a/src/mono/wasm/Wasm.Build.Tests/Common/EnvironmentVariables.cs
+++ b/src/mono/wasm/Wasm.Build.Tests/Common/EnvironmentVariables.cs
@@ -28,5 +28,6 @@ internal static class EnvironmentVariables
internal static readonly string? WasiSdkPath = Environment.GetEnvironmentVariable("WASI_SDK_PATH");
internal static readonly bool WorkloadsTestPreviousVersions = Environment.GetEnvironmentVariable("WORKLOADS_TEST_PREVIOUS_VERSIONS") is "true";
internal static readonly string? RuntimeFlavor = Environment.GetEnvironmentVariable("RUNTIME_FLAVOR_FOR_TESTS");
+ internal static readonly string? BaseDir = Environment.GetEnvironmentVariable("BASE_DIR");
}
}
diff --git a/src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs b/src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs
new file mode 100644
index 00000000000000..4788ef6bfd32e3
--- /dev/null
+++ b/src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs
@@ -0,0 +1,264 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.RegularExpressions;
+using System.Threading.Tasks;
+using Microsoft.NET.WebAssembly.Webcil;
+using Microsoft.Playwright;
+using Xunit;
+using Xunit.Abstractions;
+
+#nullable enable
+
+namespace Wasm.Build.Tests
+{
+ // CoreCLR browser-wasm ships ReadyToRun images as webcil-in-wasm; a non-zero R2R table size in the
+ // System.Private.CoreLib webcil is the marker that R2R was produced and staged. These tests cover R2R in
+ // both build and publish, with and without IL trimming, driving all pages in a real browser. CoreCLR only.
+ public class ReadyToRunTests : BlazorWasmTestBase
+ {
+ private const int InteractionTimeoutMs = 60_000;
+
+ public ReadyToRunTests(ITestOutputHelper output, SharedBuildPerTestClassFixture buildContext)
+ : base(output, buildContext)
+ {
+ _enablePerTestCleanup = true;
+ }
+
+ [ConditionalTheory(typeof(BuildTestBase), nameof(IsCoreClrRuntime))]
+ [InlineData(Configuration.Release)]
+ [TestCategory("no-workload")]
+ public async Task BuildRunAllPages(Configuration config)
+ {
+ // A build stages the prebuilt framework R2R images from the runtime pack (no per-app crossgen2).
+ ProjectInfo info = CopyTestAsset(config, aot: false, TestAsset.BlazorBasicTestApp, "r2r_build",
+ extraProperties: "true");
+ BlazorBuild(info, config);
+
+ string webcilDir = GetBuildWebcilDir(config);
+ AssertCoreLibReadyToRun(webcilDir, expectReadyToRun: true);
+ AssertNoDuplicateAssemblies(webcilDir);
+ AssertPerAppCrossgenRan(config, expected: false);
+
+ await RunForBuildWithDotnetRun(new BlazorRunOptions(config,
+ CheckCounter: false,
+ ExecuteAfterLoaded: (_, page) => InteractAllPagesAsync(page)));
+ }
+
+ [ConditionalTheory(typeof(BuildTestBase), nameof(IsCoreClrRuntime))]
+ [InlineData(Configuration.Release, /*trimmed*/ true)]
+ [InlineData(Configuration.Release, /*trimmed*/ false)]
+ [TestCategory("no-workload")]
+ public Task PublishRunAllPages(Configuration config, bool trimmed)
+ => PublishRunAllPagesCore(config, trimmed, nativeRelink: false);
+
+ // CoreCLR relinks dotnet.native.wasm for Blazor when WasmBuildNative=true; the relink is driven by the
+ // IsBrowserWasmProject triggers in BrowserWasmApp.CoreCLR.targets. AssertBundle(isNativeBuild: true)
+ // proves the served dotnet.native.wasm was relinked rather than the runtime-pack prebuilt.
+ [ConditionalTheory(typeof(BuildTestBase), nameof(IsCoreClrRuntime))]
+ [InlineData(Configuration.Release, /*trimmed*/ true)]
+ [InlineData(Configuration.Release, /*trimmed*/ false)]
+ [TestCategory("no-workload")]
+ public Task PublishRunAllPagesNativeRelink(Configuration config, bool trimmed)
+ => PublishRunAllPagesCore(config, trimmed, nativeRelink: true);
+
+ private async Task PublishRunAllPagesCore(Configuration config, bool trimmed, bool nativeRelink)
+ {
+ // Publish runs per-app crossgen2 for the whole closure, trimmed or not: even the untrimmed CoreLib
+ // is a per-app image, not the runtime pack's. nativeRelink also relinks dotnet.native.wasm.
+ string label = $"r2r_pub_{(trimmed ? "trim" : "notrim")}{(nativeRelink ? "_native" : "")}";
+ ProjectInfo info = CopyTestAsset(config, aot: false, TestAsset.BlazorBasicTestApp, label,
+ extraProperties: $"true{(trimmed ? "true" : "false")}");
+ string extraArgs = GetR2RBuildArgs(config);
+ if (nativeRelink)
+ {
+ // CoreCLR relinks dotnet.native.wasm via the in-tree targets + EMSDK_PATH, not the browser
+ // workload; WasmBuildNative=true otherwise forces UsingBrowserRuntimeWorkload=true, which
+ // demands the (uninstalled) wasm-tools workload and disables the CoreCLR relink targets.
+ extraArgs += " -p:WasmBuildNative=true -p:UsingBrowserRuntimeWorkload=false";
+ }
+ BlazorPublish(info, config, new PublishOptions(UseCache: false, ExtraMSBuildArgs: extraArgs),
+ // Assert the native runtime was actually relinked (from obj), not the runtime-pack prebuilt,
+ // so the relink is proven rather than silently skipped. See dotnet/runtime#133185.
+ isNativeBuild: nativeRelink ? true : (bool?)null);
+
+ string frameworkDir = GetBlazorBinFrameworkDir(config, forPublish: true);
+ AssertCoreLibReadyToRun(frameworkDir, expectReadyToRun: true);
+ AssertNoDuplicateAssemblies(frameworkDir);
+ AssertNoManagedAssembliesOutsideFramework(frameworkDir);
+ AssertTrimmedClosureIsFullyStaged(config, frameworkDir);
+ AssertPerAppCrossgenRan(config, expected: true);
+
+ await RunForPublishWithWebServer(new BlazorRunOptions(config,
+ CheckCounter: false,
+ ExecuteAfterLoaded: (_, page) => InteractAllPagesAsync(page)));
+ }
+
+ [ConditionalTheory(typeof(BuildTestBase), nameof(IsCoreClrRuntime))]
+ [InlineData(Configuration.Release)]
+ [TestCategory("no-workload")]
+ public void FrameworkAssembliesAreNotReadyToRunWhenDisabled(Configuration config)
+ {
+ ProjectInfo info = CopyTestAsset(config, aot: false, TestAsset.BlazorBasicTestApp, "r2r_off",
+ extraProperties: "false");
+ BlazorBuild(info, config);
+
+ AssertCoreLibReadyToRun(GetBuildWebcilDir(config), expectReadyToRun: false);
+ AssertPerAppCrossgenRan(config, expected: false);
+ }
+
+ // Navigate Home -> Counter (increment 0 -> 1) -> Weather (forecast rows) -> Home, asserting content
+ // at each step. DetectRuntimeFailures (default) fails the run on any unhandled managed/JS exception.
+ private static async Task InteractAllPagesAsync(IPage page)
+ {
+ var counterLink = page.Locator("text=Counter");
+ await counterLink.WaitForAsync(new() { State = WaitForSelectorState.Visible, Timeout = InteractionTimeoutMs });
+ await counterLink.ClickAsync(new() { Timeout = InteractionTimeoutMs });
+
+ var status = page.Locator("p[role='status']");
+ await status.WaitForAsync(new() { State = WaitForSelectorState.Visible, Timeout = InteractionTimeoutMs });
+ Assert.Equal("Current count: 0", await status.InnerHTMLAsync());
+
+ var clickMe = page.Locator("text=\"Click me\"");
+ await clickMe.ClickAsync(new() { Timeout = InteractionTimeoutMs });
+ await page.WaitForFunctionAsync(
+ """selector => document.querySelector(selector)?.textContent?.trim() === 'Current count: 1'""",
+ "p[role='status']",
+ new() { Timeout = InteractionTimeoutMs });
+
+ var weatherLink = page.Locator("text=Weather");
+ await weatherLink.WaitForAsync(new() { State = WaitForSelectorState.Visible, Timeout = InteractionTimeoutMs });
+ await weatherLink.ClickAsync(new() { Timeout = InteractionTimeoutMs });
+ await page.WaitForFunctionAsync(
+ "() => document.querySelectorAll('table tbody tr').length > 0",
+ null,
+ new() { Timeout = InteractionTimeoutMs });
+
+ var homeLink = page.Locator("text=Home");
+ await homeLink.WaitForAsync(new() { State = WaitForSelectorState.Visible, Timeout = InteractionTimeoutMs });
+ await homeLink.ClickAsync(new() { Timeout = InteractionTimeoutMs });
+ await page.Locator("h1").WaitForAsync(new() { State = WaitForSelectorState.Visible, Timeout = InteractionTimeoutMs });
+ }
+
+ private string GetBuildWebcilDir(Configuration config) =>
+ Path.Combine(_projectDir, "obj", config.ToString(), DefaultTargetFrameworkForBlazor, "webcil");
+
+ private string GetObjSubDir(Configuration config, string name) =>
+ Path.Combine(_projectDir, "obj", config.ToString(), DefaultTargetFrameworkForBlazor, name);
+
+ // Static web assets are fingerprinted as .<10 chars>.wasm. The pattern is deliberately
+ // case-sensitive: a case-insensitive match also eats real trailing segments like ".Components".
+ private static string StripFingerprint(string filePath)
+ => Regex.Replace(Path.GetFileNameWithoutExtension(filePath), @"\.[a-z0-9]{10}$", string.Empty);
+
+ private static string[] GetStagedAssemblyNames(string frameworkDir)
+ => Directory.EnumerateFiles(frameworkDir, "*.wasm")
+ .Where(f => !Path.GetFileName(f).StartsWith("dotnet", System.StringComparison.Ordinal))
+ .Select(StripFingerprint)
+ .ToArray();
+
+ // Fingerprinted assets land beside their predecessors instead of replacing them, so a stale copy of an
+ // assembly survives as a second file and the runtime can bind the wrong version bubble.
+ private static void AssertNoDuplicateAssemblies(string frameworkDir)
+ {
+ string[] duplicates = GetStagedAssemblyNames(frameworkDir)
+ .GroupBy(n => n, System.StringComparer.Ordinal)
+ .Where(g => g.Count() > 1)
+ .Select(g => $"{g.Key} x{g.Count()}")
+ .OrderBy(n => n, System.StringComparer.Ordinal)
+ .ToArray();
+
+ Assert.True(duplicates.Length == 0,
+ $"Duplicate assemblies staged in '{frameworkDir}': {string.Join(", ", duplicates)}");
+ }
+
+ // A .wasm-named R2R image that is not routed back to a managed asset is treated as native and lands in
+ // the publish root, leaving the boot config without it. See dotnet/runtime#121257.
+ private static void AssertNoManagedAssembliesOutsideFramework(string frameworkDir)
+ {
+ string? wwwrootDir = Path.GetDirectoryName(frameworkDir);
+ if (wwwrootDir is null || !Directory.Exists(wwwrootDir))
+ return;
+
+ string[] stray = Directory.EnumerateFiles(wwwrootDir, "*.wasm").Select(Path.GetFileName).ToArray()!;
+ Assert.True(stray.Length == 0,
+ $"Managed assemblies leaked outside _framework into '{wwwrootDir}': {string.Join(", ", stray)}");
+ }
+
+ // Losing every crossgen'd assembly still exits 0 and can still leave a loadable-looking bundle, so
+ // compare the staged set against the linker's closure rather than trusting the exit code.
+ private void AssertTrimmedClosureIsFullyStaged(Configuration config, string frameworkDir)
+ {
+ string linkedDir = GetObjSubDir(config, "linked");
+ if (!Directory.Exists(linkedDir))
+ return;
+
+ HashSet staged = new(GetStagedAssemblyNames(frameworkDir), System.StringComparer.Ordinal);
+ string[] missing = Directory.EnumerateFiles(linkedDir, "*.dll")
+ .Select(Path.GetFileNameWithoutExtension)
+ .Where(name => !staged.Contains(name!))
+ .OrderBy(name => name, System.StringComparer.Ordinal)
+ .ToArray()!;
+
+ Assert.True(missing.Length == 0,
+ $"Assemblies in the trimmed closure but missing from '{frameworkDir}': {string.Join(", ", missing)}");
+ }
+
+ // The dev loop serves the runtime pack's prebuilt native/r2r images; only publish crossgens per app.
+ private void AssertPerAppCrossgenRan(Configuration config, bool expected)
+ {
+ string r2rDir = GetObjSubDir(config, "R2R");
+ int imageCount = Directory.Exists(r2rDir) ? Directory.EnumerateFiles(r2rDir).Count() : 0;
+
+ if (expected)
+ Assert.True(imageCount > 0, $"Expected per-app ReadyToRun images under '{r2rDir}'.");
+ else
+ Assert.True(imageCount == 0, $"Expected no per-app crossgen2 output, found {imageCount} file(s) under '{r2rDir}'.");
+ }
+
+ // Wire the wasm-aware Crossgen2Tasks shim (the wasm-container crossgen tasks) so R2R images use the
+ // right container, and the in-build crossgen2 when this leg shipped it. Each is passed only when present
+ // under BASE_DIR: the no-workload leg ships the shim but resolves crossgen2 itself from the SDK pack (the
+ // SDK restores it when PublishReadyToRun is set), so passing a non-existent Crossgen2InBuildDir there
+ // would break the call-helpers generator. All inert if BASE_DIR is unset.
+ private static string GetR2RBuildArgs(Configuration config)
+ {
+ string? baseDir = EnvironmentVariables.BaseDir;
+ if (string.IsNullOrEmpty(baseDir))
+ return string.Empty;
+
+ string hostArch = System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture.ToString().ToLowerInvariant();
+ string crossgenDir = Path.Combine(baseDir, "coreclr", $"browser.wasm.{config}", hostArch, "crossgen2");
+ string shimDir = Path.Combine(baseDir, "Crossgen2Tasks", config.ToString());
+ string shimProps = Path.Combine(shimDir, "Microsoft.NET.CrossGen.props");
+ string shimTargets = Path.Combine(shimDir, "Microsoft.NET.CrossGen.targets");
+
+ var args = new List();
+ if (Directory.Exists(crossgenDir))
+ args.Add($"-p:Crossgen2InBuildDir=\"{crossgenDir}\"");
+ if (File.Exists(shimProps))
+ args.Add($"-p:Crossgen2SdkOverridePropsPath=\"{shimProps}\"");
+ if (File.Exists(shimTargets))
+ args.Add($"-p:Crossgen2SdkOverrideTargetsPath=\"{shimTargets}\"");
+ return string.Join(" ", args);
+ }
+
+ private static void AssertCoreLibReadyToRun(string frameworkDir, bool expectReadyToRun)
+ {
+ string? coreLib = Directory.EnumerateFiles(frameworkDir, "System.Private.CoreLib*.wasm").FirstOrDefault();
+ Assert.True(coreLib is not null, $"Expected a System.Private.CoreLib webcil under '{frameworkDir}'.");
+
+ using FileStream stream = File.OpenRead(coreLib!);
+ bool ok = WebcilReader.TryReadWebcilInWasmSizes(stream, out _, out int tableSize, out string? failureReason);
+ Assert.True(ok, failureReason);
+
+ if (expectReadyToRun)
+ Assert.True(tableSize > 0, $"Expected a ReadyToRun table in '{coreLib}', but the R2R table size was 0.");
+ else
+ Assert.Equal(0, tableSize);
+ }
+ }
+}
diff --git a/src/mono/wasm/Wasm.Build.Tests/Templates/WasmTemplateTestsBase.cs b/src/mono/wasm/Wasm.Build.Tests/Templates/WasmTemplateTestsBase.cs
index f9842bed1047b4..b44664c5b0f532 100644
--- a/src/mono/wasm/Wasm.Build.Tests/Templates/WasmTemplateTestsBase.cs
+++ b/src/mono/wasm/Wasm.Build.Tests/Templates/WasmTemplateTestsBase.cs
@@ -181,6 +181,9 @@ private static void AddCoreClrProjectProperties(ref string extraProperties, ref
{{runtimePackVersion}}
+
+ {{runtimePackVersion}}
+
""";
diff --git a/src/mono/wasm/Wasm.Build.Tests/WebcilInWasmSizesTests.cs b/src/mono/wasm/Wasm.Build.Tests/WebcilInWasmSizesTests.cs
index b02b1d10bcbf81..fc0719e5f8610f 100644
--- a/src/mono/wasm/Wasm.Build.Tests/WebcilInWasmSizesTests.cs
+++ b/src/mono/wasm/Wasm.Build.Tests/WebcilInWasmSizesTests.cs
@@ -200,6 +200,43 @@ public void ConvertDllsToWebcil_StagesR2RWebcilWithDllExtension()
Assert.True(r2rWebcil.SequenceEqual(File.ReadAllBytes(Path.Combine(outputDirectory, "R2RAssembly.wasm"))));
}
+ [Fact]
+ public void ConvertDllsToWebcil_FallsBackToIL_WhenPrebuiltMvidMismatches()
+ {
+ // A prebuilt R2R image whose MVID differs from the candidate must never be staged: it would fail-fast
+ // at load against the current version bubble. Use two real assemblies with distinct MVIDs.
+ string candidatePath = typeof(System.Console).Assembly.Location;
+ string mismatchedAssembly = typeof(object).Assembly.Location;
+ Assert.True(File.Exists(candidatePath), $"Candidate assembly not found: '{candidatePath}'.");
+ Assert.True(File.Exists(mismatchedAssembly), $"Mismatched assembly not found: '{mismatchedAssembly}'.");
+
+ using var directory = new TempDirectory();
+ string prebuiltDirectory = Path.Combine(directory.Path, "prebuilt");
+ string outputDirectory = Path.Combine(directory.Path, "output");
+ Directory.CreateDirectory(prebuiltDirectory);
+ File.Copy(mismatchedAssembly, Path.Combine(prebuiltDirectory, "System.Console.wasm"));
+
+ var candidate = new TaskItem(candidatePath);
+ candidate.SetMetadata("RelativePath", "System.Console.dll");
+
+ var task = new ConvertDllsToWebcil
+ {
+ BuildEngine = new TestBuildEngine(),
+ Candidates = [candidate],
+ IntermediateOutputPath = Path.Combine(directory.Path, "intermediate"),
+ IsEnabled = true,
+ OutputPath = outputDirectory,
+ PrebuiltR2RDirectory = prebuiltDirectory,
+ };
+
+ Assert.True(task.Execute());
+
+ // The output must be a freshly converted IL webcil (no R2R table), not the mismatched prebuilt.
+ using FileStream output = File.OpenRead(Path.Combine(outputDirectory, "System.Console.wasm"));
+ Assert.True(WebcilReader.TryReadWebcilInWasmSizes(output, out _, out int tableSize, out string? failureReason), failureReason);
+ Assert.Equal(0, tableSize);
+ }
+
private const byte SectionCustom = 0x00;
private const byte SectionData = 0x0b;
diff --git a/src/mono/wasm/testassets/BlazorBasicTestApp/App/Pages/Weather.razor b/src/mono/wasm/testassets/BlazorBasicTestApp/App/Pages/Weather.razor
new file mode 100644
index 00000000000000..1664dd16d557de
--- /dev/null
+++ b/src/mono/wasm/testassets/BlazorBasicTestApp/App/Pages/Weather.razor
@@ -0,0 +1,60 @@
+@page "/weather"
+@using System.Linq
+
+Weather
+
+
Weather
+
+This component demonstrates showing data.
+
+@if (forecasts == null)
+{
+ Loading...
+}
+else
+{
+
+
+
+ | Date |
+ Temp. (C) |
+ Summary |
+
+
+
+ @foreach (var forecast in forecasts)
+ {
+
+ | @forecast.Date.ToShortDateString() |
+ @forecast.TemperatureC |
+ @forecast.Summary |
+
+ }
+
+
+}
+
+@code {
+ private WeatherForecast[]? forecasts;
+
+ protected override async Task OnInitializedAsync()
+ {
+ // Async load with local data (no HttpClient) so the page is self-contained on wasm.
+ await Task.Yield();
+ var startDate = DateOnly.FromDateTime(DateTime.Now);
+ var summaries = new[] { "Freezing", "Cool", "Mild", "Warm", "Hot" };
+ forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
+ {
+ Date = startDate.AddDays(index),
+ TemperatureC = Random.Shared.Next(-20, 55),
+ Summary = summaries[Random.Shared.Next(summaries.Length)]
+ }).ToArray();
+ }
+
+ private sealed class WeatherForecast
+ {
+ public DateOnly Date { get; set; }
+ public int TemperatureC { get; set; }
+ public string? Summary { get; set; }
+ }
+}
diff --git a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/ConvertDllsToWebCil.cs b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/ConvertDllsToWebCil.cs
index ffb61356b95730..d348d2bc79f0e0 100644
--- a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/ConvertDllsToWebCil.cs
+++ b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/ConvertDllsToWebCil.cs
@@ -260,7 +260,7 @@ private bool PrebuiltR2RMatchesCandidate(string candidateDllPath, string prebuil
return true;
}
- candidateHasILCode = AssemblyHasILCode(candidateDllPath, out Version candidateVersion);
+ candidateHasILCode = AssemblyHasILCode(candidateDllPath, out Guid candidateMvid);
if (!candidateHasILCode)
{
Log.LogMessage(MessageImportance.Low,
@@ -268,15 +268,17 @@ private bool PrebuiltR2RMatchesCandidate(string candidateDllPath, string prebuil
return false;
}
- Version prebuiltVersion = TryReadAssemblyVersion(prebuiltImagePath);
+ Guid? prebuiltMvid = TryReadMvid(prebuiltImagePath);
- // If the prebuilt identity is unreadable, keep it (prior behavior) so the common case where
- // every candidate is the pack's own assembly is never regressed.
- if (prebuiltVersion is null || candidateVersion.Equals(prebuiltVersion))
+ // Compare MVIDs, not assembly versions: with cross-module inlining every image in the bundle shares one
+ // version bubble the runtime checks by MVID at load, and the assembly version rarely changes between
+ // incremental builds, so a stale prebuilt R2R would pass a version check yet fail-fast at startup.
+ // If the prebuilt identity is unreadable, keep it (prior behavior).
+ if (prebuiltMvid is null || candidateMvid.Equals(prebuiltMvid.Value))
return true;
Log.LogMessage(MessageImportance.Normal,
- $"Not staging prebuilt R2R image '{prebuiltImagePath}' (v{prebuiltVersion}) for '{candidateDllPath}' (v{candidateVersion}): assembly version mismatch; converting IL instead.");
+ $"Not staging prebuilt R2R image '{prebuiltImagePath}' (MVID {prebuiltMvid}) for '{candidateDllPath}' (MVID {candidateMvid}): module version mismatch; converting IL instead.");
return false;
}
@@ -287,12 +289,12 @@ private static bool IsR2RWebcil(string path)
&& tableSize > 0;
}
- private static bool AssemblyHasILCode(string path, out Version version)
+ private static bool AssemblyHasILCode(string path, out Guid mvid)
{
using FileStream stream = File.OpenRead(path);
using var peReader = new PEReader(stream);
MetadataReader metadataReader = peReader.GetMetadataReader();
- version = metadataReader.GetAssemblyDefinition().Version;
+ mvid = metadataReader.GetGuid(metadataReader.GetModuleDefinition().Mvid);
foreach (MethodDefinitionHandle methodDefinitionHandle in metadataReader.MethodDefinitions)
{
@@ -305,23 +307,52 @@ private static bool AssemblyHasILCode(string path, out Version version)
return false;
}
- private static Version TryReadAssemblyVersion(string path)
+ private static Guid? TryReadMvid(string path)
{
try
{
using FileStream stream = File.OpenRead(path);
- if (path.EndsWith(Utils.WebcilInWasmExtension, StringComparison.OrdinalIgnoreCase))
+ // Detect webcil-in-wasm by content (the '\0asm' magic), not by extension: a prebuilt R2R image
+ // may still be named *.dll, and a PEReader would throw on it, returning null and silently
+ // bypassing the MVID guard.
+ if (IsWebcilInWasm(stream))
{
using var webcilReader = new WebcilReader(stream, path);
- return webcilReader.GetMetadataReader().GetAssemblyDefinition().Version;
+ MetadataReader webcilMetadata = webcilReader.GetMetadataReader();
+ return webcilMetadata.GetGuid(webcilMetadata.GetModuleDefinition().Mvid);
}
using var peReader = new PEReader(stream);
- return peReader.GetMetadataReader().GetAssemblyDefinition().Version;
+ MetadataReader peMetadata = peReader.GetMetadataReader();
+ return peMetadata.GetGuid(peMetadata.GetModuleDefinition().Mvid);
}
catch
{
return null;
}
}
+
+ // The WebAssembly module magic "\0asm" (0x00 0x61 0x73 0x6D). Leaves the stream position unchanged.
+ private static bool IsWebcilInWasm(Stream stream)
+ {
+ long position = stream.Position;
+ try
+ {
+ byte[] magic = new byte[4];
+ int read = 0;
+ while (read < magic.Length)
+ {
+ int n = stream.Read(magic, read, magic.Length - read);
+ if (n == 0)
+ return false;
+ read += n;
+ }
+
+ return magic[0] == 0x00 && magic[1] == 0x61 && magic[2] == 0x73 && magic[3] == 0x6D;
+ }
+ finally
+ {
+ stream.Position = position;
+ }
+ }
}
diff --git a/src/tasks/Microsoft.NET.WebAssembly.Webcil/WebcilReader.cs b/src/tasks/Microsoft.NET.WebAssembly.Webcil/WebcilReader.cs
index 1975b55ecd666e..6c56803b1e15af 100644
--- a/src/tasks/Microsoft.NET.WebAssembly.Webcil/WebcilReader.cs
+++ b/src/tasks/Microsoft.NET.WebAssembly.Webcil/WebcilReader.cs
@@ -421,6 +421,10 @@ private unsafe ImmutableArray ReadSections()
public void Dispose()
{
+ // The provider owns a memory-mapped section over _stream; leaving it to the finalizer keeps the file
+ // mapped inside long-lived MSBuild task hosts and later writers fail with "user-mapped section open".
+ _metadataReaderProvider?.Dispose();
+ _metadataReaderProvider = null;
_stream.Dispose();
}