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 @@ + <_WasmFrameworkCopyToOutputDirectory>PreserveNewest + + + + + <_WasmFrameworkCopyToOutputDirectory>Never + + + + + + + + + + + + + + true true 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/sendtohelix-browser.targets b/src/libraries/sendtohelix-browser.targets index 429078ca20d274..4762716bdfc717 100644 --- a/src/libraries/sendtohelix-browser.targets +++ b/src/libraries/sendtohelix-browser.targets @@ -290,6 +290,20 @@ + + + + + + + + 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/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 +{ + + + + + + + + + + @foreach (var forecast in forecasts) + { + + + + + + } + +
DateTemp. (C)Summary
@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(); }