diff --git a/docs/workflow/testing/libraries/testing-wasm.md b/docs/workflow/testing/libraries/testing-wasm.md index 4906192c6c1600..2dfb6d67e12cbb 100644 --- a/docs/workflow/testing/libraries/testing-wasm.md +++ b/docs/workflow/testing/libraries/testing-wasm.md @@ -194,6 +194,14 @@ stale staged assets can otherwise cause assembly-loading failures before tests s The `LibraryTestsCoreCLR_R2R` CI jobs use the same configuration and archive the published tests for execution on Helix. The existing interpreter jobs remain separate. +For CoreCLR browser applications, set `PublishReadyToRun=true` and +`PublishReadyToRunComposite=true` to publish a single composite ReadyToRun image and +component WebCIL stubs instead of per-assembly images. Publishing requires a wasm-capable +crossgen2 and WebCIL enabled; the composite is loaded before the runtime starts. +`ReadyToRunTests.PublishRunAllPagesComposite` exercises trimmed, untrimmed, and +native-relinked publishes in a browser, including asset fingerprinting when +`USE_FINGERPRINTING_FOR_TESTS=true`. + ## 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/src/libraries/sendtohelix-browser.targets b/src/libraries/sendtohelix-browser.targets index 4762716bdfc717..26d278bf6f6c3b 100644 --- a/src/libraries/sendtohelix-browser.targets +++ b/src/libraries/sendtohelix-browser.targets @@ -295,8 +295,10 @@ from the SDK crossgen2 pack (which the SDK restores when PublishReadyToRun is set), so it is only shipped as a correlation payload when the in-build crossgen2 is present in this leg (dev/in-tree). The shim ships whenever it is built - it is gated on its own path, not on the crossgen2 bin dir, which the WBT test leg - does not carry. --> - + does not carry. Wasm.Build.Tests (BuildWasmApps) need it for PublishReadyToRunComposite, whose output + naming the base SDK's ReadyToRun tasks don't implement yet. --> + + 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 50ca9b4e273791..1a353881064452 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 @@ -19,9 +19,9 @@ Copyright (c) .NET Foundation. All rights reserved. + An explicit unsupported value is left as-is and rejected by _WasmCoreClrValidateReadyToRun. --> wasm - + false $(PublishReadyToRunCrossgen2ExtraArgs);--opt-cross-module:* @@ -36,13 +36,6 @@ Copyright (c) .NET Foundation. All rights reserved. _ResolveGlobalizationConfiguration. --> - - - - <_WasmRuntimePackR2RDir Condition="'$(_WasmRuntimePackR2RDir)' == '' and '$(_RuntimePackNativeDir)' != ''">$([MSBuild]::NormalizeDirectory('$(_RuntimePackNativeDir)', 'r2r')) @@ -58,24 +51,42 @@ Copyright (c) .NET Foundation. All rights reserved. - - + BeforeTargets="_WasmCoreClrSelectR2RDirectories;_ResolveWasmOutputs"> + + + + + + + <_WasmCompositeOwnerEntry Include="@(_ReadyToRunCompileList->WithMetadataValue('CreateCompositeImage', 'true'))" /> + + + + <_WasmWebcilStampProperty Include="PublishTrimmed" /> + <_WasmWebcilStampProperty Include="PublishReadyToRunComposite" /> + out of the bundle - a publish that exits 0 with no System.Private.CoreLib. In composite mode, restore + all component IL candidates so ConvertDllsToWebcil can stage their forwarding stubs; the composite + itself is separately defined as a publish static web asset. --> - + + <_WasmCompositeR2RInput Include="@(_ReadyToRunCompositeBuildInput);@(_ReadyToRunCompositeUnrootedBuildInput);@(_ReadyToRunCompileList->WithMetadataValue('CreateCompositeImage', ''))" + Condition="'$(PublishReadyToRunComposite)' == 'true'" /> + <_WasmCompositeR2RImage Include="@(_ReadyToRunFilesToPublish)" + Condition="'$(PublishReadyToRunComposite)' == 'true' and '%(Extension)' == '.wasm'" /> + + @@ -174,7 +197,7 @@ Copyright (c) .NET Foundation. All rights reserved. On the Blazor/SWA route ILLink stamps PostprocessAssembly=true on its own collection, not on ResolvedFileToPublish, so the mainline compile list is empty and crossgen2 never runs. --> - + - <_ReadyToRunCompilerInputs Include="@(_ReadyToRunCompileList);@(_ReadyToRunAssembliesToReference)" /> + <_ReadyToRunCompilerInputs Include="@(_ReadyToRunCompileList);@(_ReadyToRunAssembliesToReference);@(_ReadyToRunCompositeBuildReferences);@(_ReadyToRunCompositeUnrootedBuildInput)" /> <_ReadyToRunCompilerInputs Include="$(_WasmResolvedCrossgen2Dir)crossgen2*;$(_WasmResolvedCrossgen2Dir)clrjit_universal_wasm_*" Condition="'$(_WasmResolvedCrossgen2Dir)' != ''" /> + + + <_WasmReadyToRunArgsStamp>$(IntermediateOutputPath)wasm-r2r-args.stamp + + + + <_ReadyToRunCompilerInputs Include="$(_WasmReadyToRunArgsStamp)" /> + + - + + + <_WasmEmittedPerAppR2R Include="@(_ReadyToRunCompileList->'%(OutputR2RImage)')" /> + <_WasmEmittedPerAppR2R Include="@(_ReadyToRunCompositeBuildInput->'$(_WasmPublishR2RDir)%(FileName).wasm');@(_ReadyToRunCompositeUnrootedBuildInput->'$(_WasmPublishR2RDir)%(FileName).wasm')" + Condition="'$(PublishReadyToRunComposite)' == 'true'" /> <_WasmStalePerAppR2R Include="$(_WasmPublishR2RDir)*.wasm;$(_WasmPublishR2RDir)*.dll" - Exclude="@(_ReadyToRunCompileList->'%(OutputR2RImage)')" /> + Exclude="@(_WasmEmittedPerAppR2R)" /> diff --git a/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.targets b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.targets index 529106d2dc6a94..456a4fcac12d53 100644 --- a/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.targets +++ b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.targets @@ -1024,6 +1024,18 @@ Copyright (c) .NET Foundation. All rights reserved. <_WasmResolvedFilesToPublish Include="@(ResolvedFileToPublish)" Condition="'%(ResolvedFileToPublish.CopyToPublishDirectory)' != 'Never'" /> + + + <_WasmResolvedFilesToPublish Include="@(_WasmCompositeR2RInput)" + Condition="'$(PublishReadyToRunComposite)' == 'true'" /> + <_WasmResolvedFilesToPublish Include="@(_WasmReadyToRunCompileInput)" + RelativePath="%(FileName)%(Extension)" + Condition="'$(PublishReadyToRunComposite)' != 'true' and '$(PublishTrimmed)' == 'true'" /> + <_WasmResolvedFilesToPublish Include="@(_ReadyToRunCompileList)" + Condition="'$(PublishReadyToRunComposite)' != 'true' and '$(PublishTrimmed)' != 'true'" /> + - + + + <_WasmCompositeCompilation Include="@(_ReadyToRunCompileList)" + Condition="'%(CreateCompositeImage)' == 'true'" /> + <_WasmCompositePublishCandidate Include="@(_WasmCompositeCompilation->'%(OutputR2RImage)')" KeepMetadata="OutputR2RImage" /> + <_WasmCompositePublishCandidate Update="@(_WasmCompositePublishCandidate)" + ContentRoot="%(RootDir)%(Directory)" + RelativePath="_framework/%(FileName)%(Extension)" /> + <_WasmCompositeFingerprintPattern Include="WasmFiles" Pattern="*.wasm" Expression="#[.{fingerprint}]!" /> + + + + + + + <_WasmDotnetJsForPublish Include="@(ResolvedFileToPublish)" Condition="'%(FileName)%(Extension)' == 'dotnet.js'" /> @@ -1065,6 +1107,7 @@ Copyright (c) .NET Foundation. All rights reserved. <_WasmPublishWebcilStampLine Include="_WasmEnableWebcil=$(_WasmEnableWebcil)" /> <_WasmPublishWebcilStampLine Include="_WasmWebcilVersion=$(_WasmWebcilVersion)" /> <_WasmPublishWebcilStampLine Include="PublishReadyToRun=$(PublishReadyToRun)" /> + <_WasmPublishWebcilStampLine Include="PublishReadyToRunComposite=$(PublishReadyToRunComposite)" /> <_WasmPublishWebcilStampLine Include="_WasmPublishPrebuiltR2RDirectory=$(_WasmPublishPrebuiltR2RDirectory)" /> <_WasmPublishWebcilStampLine Include="PrebuiltR2R=%(_WasmPublishPrebuiltR2RInput.Identity)|%(_WasmPublishPrebuiltR2RInput.ModifiedTime)" /> <_WasmPublishWebcilStampLine Include="Input=%(_WasmPublishWebcilStampInput.Identity)|%(_WasmPublishWebcilStampInput.ModifiedTime)" /> @@ -1111,7 +1154,7 @@ Copyright (c) .NET Foundation. All rights reserved. @@ -1120,7 +1163,7 @@ Copyright (c) .NET Foundation. All rights reserved. - + @@ -1273,7 +1316,7 @@ Copyright (c) .NET Foundation. All rights reserved. - <_WasmBootJsonPublishStampContent>$(WasmDebugLevel)|$(_WasmPublishApplicationEnvironmentName)|$(_BlazorCacheBootResources)|$(InvariantGlobalization)|$(_LoadCustomIcuData)|$(_BlazorWebAssemblyLoadAllGlobalizationData)|$(_BlazorWebAssemblyJiterpreter)|$(_BlazorWebAssemblyRuntimeOptions)|$(RunAOTCompilation)|$(WasmEnableThreads)|$(UseMonoRuntime)|$(_WasmFingerprintAssets)|$(_WasmBundlerFriendlyBootConfig)|$(WasmProfilers)|$(TargetFrameworkVersion)|$(DiagnosticPorts)|$(StaticWebAssetStandaloneHosting)|@(WasmEnvironmentVariable->'%(Identity)=%(Value)')|@(BlazorWebAssemblyLazyLoad)|@(WasmModuleAfterConfigLoaded)|@(WasmModuleAfterRuntimeReady)|$(WasmTestExitOnUnhandledError)|$(WasmTestAppendElementOnExit)|$(WasmTestLogExitCode)|$(WasmTestAsyncFlushOnExit) + <_WasmBootJsonPublishStampContent>$(WasmDebugLevel)|$(_WasmPublishApplicationEnvironmentName)|$(_BlazorCacheBootResources)|$(InvariantGlobalization)|$(_LoadCustomIcuData)|$(_BlazorWebAssemblyLoadAllGlobalizationData)|$(_BlazorWebAssemblyJiterpreter)|$(_BlazorWebAssemblyRuntimeOptions)|$(RunAOTCompilation)|$(WasmEnableThreads)|$(UseMonoRuntime)|$(PublishReadyToRunComposite)|$(_WasmFingerprintAssets)|$(_WasmBundlerFriendlyBootConfig)|$(WasmProfilers)|$(TargetFrameworkVersion)|$(DiagnosticPorts)|$(StaticWebAssetStandaloneHosting)|@(WasmEnvironmentVariable->'%(Identity)=%(Value)')|@(BlazorWebAssemblyLazyLoad)|@(WasmModuleAfterConfigLoaded)|@(WasmModuleAfterRuntimeReady)|$(WasmTestExitOnUnhandledError)|$(WasmTestAppendElementOnExit)|$(WasmTestLogExitCode)|$(WasmTestAsyncFlushOnExit) PublishRunAllPagesCore(config, trimmed, nativeRelink: false); + => PublishRunAllPagesCore(config, trimmed, nativeRelink: false, composite: false); + + [ConditionalTheory(typeof(BuildTestBase), nameof(IsCoreClrRuntime))] + [InlineData(Configuration.Release, /*trimmed*/ true, /*nativeRelink*/ false)] + [InlineData(Configuration.Release, /*trimmed*/ false, /*nativeRelink*/ false)] + [InlineData(Configuration.Release, /*trimmed*/ true, /*nativeRelink*/ true)] + [TestCategory("no-workload")] + public Task PublishRunAllPagesComposite(Configuration config, bool trimmed, bool nativeRelink) + => PublishRunAllPagesCore(config, trimmed, nativeRelink, composite: true); // 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) @@ -63,16 +72,22 @@ public Task PublishRunAllPages(Configuration config, bool trimmed) [InlineData(Configuration.Release, /*trimmed*/ false)] [TestCategory("no-workload")] public Task PublishRunAllPagesNativeRelink(Configuration config, bool trimmed) - => PublishRunAllPagesCore(config, trimmed, nativeRelink: true); + => PublishRunAllPagesCore(config, trimmed, nativeRelink: true, composite: false); - private async Task PublishRunAllPagesCore(Configuration config, bool trimmed, bool nativeRelink) + private async Task PublishRunAllPagesCore(Configuration config, bool trimmed, bool nativeRelink, bool composite) { // 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" : "")}"; + string label = $"r2r_pub_{(trimmed ? "trim" : "notrim")}{(nativeRelink ? "_native" : "")}{(composite ? "_composite" : "")}"; + string extraItems = composite + ? """""" + : string.Empty; ProjectInfo info = CopyTestAsset(config, aot: false, TestAsset.BlazorBasicTestApp, label, - extraProperties: $"true{(trimmed ? "true" : "false")}"); - string extraArgs = GetR2RBuildArgs(config); + extraProperties: $"true{(composite ? "true" : "false")}{(trimmed ? "true" : "false")}", + extraItems: extraItems); + if (composite) + LogR2RSuffixLibraryMarker(); + string extraArgs = GetR2RBuildArgs(config, composite); if (nativeRelink) { // CoreCLR relinks dotnet.native.wasm via the in-tree targets + EMSDK_PATH, not the browser @@ -86,15 +101,32 @@ private async Task PublishRunAllPagesCore(Configuration config, bool trimmed, bo isNativeBuild: nativeRelink ? true : (bool?)null); string frameworkDir = GetBlazorBinFrameworkDir(config, forPublish: true); - AssertCoreLibReadyToRun(frameworkDir, expectReadyToRun: true); + if (composite) + AssertCompositeReadyToRun(frameworkDir); + else + AssertCoreLibReadyToRun(frameworkDir, expectReadyToRun: true); AssertNoDuplicateAssemblies(frameworkDir); AssertNoManagedAssembliesOutsideFramework(frameworkDir); AssertTrimmedClosureIsFullyStaged(config, frameworkDir); AssertPerAppCrossgenRan(config, expected: true); + if (trimmed && !nativeRelink) + { + BlazorPublish(info, config, new PublishOptions(UseCache: false, ExtraMSBuildArgs: extraArgs)); + if (composite) + AssertCompositeReadyToRun(frameworkDir); + AssertNoDuplicateAssemblies(frameworkDir); + if (composite) + AssertSwitchingCompositeModeRecompiles(info, config, extraArgs, frameworkDir); + } + + bool suffixLibraryLoaded = false; await RunForPublishWithWebServer(new BlazorRunOptions(config, CheckCounter: false, + OnConsoleMessage: (_, msg) => suffixLibraryLoaded |= msg.Contains(SuffixLibraryLoadedMessage), ExecuteAfterLoaded: (_, page) => InteractAllPagesAsync(page))); + if (composite) + Assert.True(suffixLibraryLoaded, $"'{SuffixLibraryLoadedMessage}' was not logged; R2rSuffixLibrary.r2r did not load as a component assembly."); } [ConditionalTheory(typeof(BuildTestBase), nameof(IsCoreClrRuntime))] @@ -110,6 +142,19 @@ public void FrameworkAssembliesAreNotReadyToRunWhenDisabled(Configuration config AssertPerAppCrossgenRan(config, expected: false); } + [ConditionalTheory(typeof(BuildTestBase), nameof(IsCoreClrRuntime))] + [InlineData(Configuration.Release)] + [TestCategory("no-workload")] + public void CompositeRequiresWebcil(Configuration config) + { + ProjectInfo info = CopyTestAsset(config, aot: false, TestAsset.BlazorBasicTestApp, "r2r_composite_no_webcil", + extraProperties: "truetruefalse"); + (string _, string output) = BlazorPublish(info, config, + new PublishOptions(ExpectSuccess: false, ExtraMSBuildArgs: GetR2RBuildArgs(config, composite: true))); + + Assert.Contains("PublishReadyToRunComposite for CoreCLR browser-wasm requires WebCIL-in-Wasm assemblies", output); + } + // 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) @@ -219,12 +264,14 @@ private void AssertPerAppCrossgenRan(Configuration config, bool expected) 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) + // Wire the in-build crossgen2 when this leg shipped it, and for composite the wasm-aware Crossgen2Tasks shim: + // composite needs the shim's wasm output naming (.r2r.wasm owner, .wasm stubs), which the base + // SDK's ReadyToRun tasks don't implement yet (dotnet/sdk#56395). Per-assembly R2R keeps the base SDK tasks so + // that path stays covered. Each is passed only when present under BASE_DIR: the no-workload leg 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. + // TODO-WASM https://github.com/dotnet/runtime/issues/135023: drop the composite-only shim once the SDK names wasm R2R outputs. + private static string GetR2RBuildArgs(Configuration config, bool composite) { string? baseDir = EnvironmentVariables.BaseDir; if (string.IsNullOrEmpty(baseDir)) @@ -239,26 +286,79 @@ private static string GetR2RBuildArgs(Configuration config) var args = new List(); if (Directory.Exists(crossgenDir)) args.Add($"-p:Crossgen2InBuildDir=\"{crossgenDir}\""); - if (File.Exists(shimProps)) + if (composite && File.Exists(shimProps)) args.Add($"-p:Crossgen2SdkOverridePropsPath=\"{shimProps}\""); - if (File.Exists(shimTargets)) + if (composite && File.Exists(shimTargets)) args.Add($"-p:Crossgen2SdkOverrideTargetsPath=\"{shimTargets}\""); return string.Join(" ", args); } + private static int GetReadyToRunTableSize(string webcilPath) + { + using FileStream stream = File.OpenRead(webcilPath); + Assert.True(WebcilReader.TryReadWebcilInWasmSizes(stream, out _, out int tableSize, out string? failureReason), failureReason); + return tableSize; + } + 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); - + int tableSize = GetReadyToRunTableSize(coreLib!); if (expectReadyToRun) Assert.True(tableSize > 0, $"Expected a ReadyToRun table in '{coreLib}', but the R2R table size was 0."); else Assert.Equal(0, tableSize); } + + // Component stubs share the per-assembly .wasm output names, so a mode switch must recompile them + // instead of treating them as up-to-date per-assembly images that probe for a pruned composite owner. + // Switch back afterwards so the caller still runs the composite app. Only obj is incremental here: clear the + // published _framework so each mode's fingerprinted assets don't accumulate beside the other's. + private void AssertSwitchingCompositeModeRecompiles(ProjectInfo info, Configuration config, string extraArgs, string frameworkDir) + { + string coreLibImage = Path.Combine(GetObjSubDir(config, "R2R"), "System.Private.CoreLib.wasm"); + System.DateTime compositeStubTime = File.GetLastWriteTimeUtc(coreLibImage); + + Directory.Delete(frameworkDir, recursive: true); + BlazorPublish(info, config, new PublishOptions(UseCache: false, ExtraMSBuildArgs: $"{extraArgs} -p:PublishReadyToRunComposite=false")); + Assert.True(File.GetLastWriteTimeUtc(coreLibImage) > compositeStubTime, + $"'{coreLibImage}' was not recompiled after switching from composite to per-assembly ReadyToRun."); + + Directory.Delete(frameworkDir, recursive: true); + BlazorPublish(info, config, new PublishOptions(UseCache: false, ExtraMSBuildArgs: extraArgs)); + AssertCompositeReadyToRun(frameworkDir); + } + + // The boot config flags exactly the composite owner, delivered via coreAssembly under its crossgen2 name so + // component stubs can probe for it; an assembly merely named *.r2r stays an ordinary, unflagged assembly. + private void AssertCompositeReadyToRun(string frameworkDir) + { + AssetsData assets = (AssetsData)_provider.GetBootJson(_provider.GetBootConfigPath(frameworkDir)).resources; + WebcilAsset composite = Assert.Single(assets.coreAssembly, asset => asset.isCompositeImage == true); + Assert.Equal("BlazorBasicTestApp.r2r.wasm", composite.virtualPath); + if (EnvironmentVariables.UseFingerprinting) + Assert.Matches(@"^BlazorBasicTestApp\.r2r\.[a-z0-9]{10}\.wasm$", composite.name); + Assert.DoesNotContain(assets.assembly, asset => asset.isCompositeImage == true); + Assert.Contains(assets.coreAssembly, + asset => asset.virtualPath?.StartsWith("System.Private.CoreLib", System.StringComparison.Ordinal) == true); + Assert.Contains(assets.assembly, asset => asset.virtualPath == "R2rSuffixLibrary.r2r.wasm"); + + string compositePath = Path.Combine(frameworkDir, composite.name); + Assert.True(GetReadyToRunTableSize(compositePath) > 0, $"Expected compiled methods in '{compositePath}'."); + Assert.True(Directory.EnumerateFiles(frameworkDir, "System.Private.CoreLib*.wasm").Any(), + $"Expected a component stub for System.Private.CoreLib in '{frameworkDir}'."); + } + + private const string SuffixLibraryLoadedMessage = "Loaded R2rSuffixLibrary.r2r: 42"; + + // Make the app call into the R2rSuffixLibrary test asset (referenced for composite runs) so the run proves + // it loaded as a component assembly rather than being mistaken for the composite owner. + private void LogR2RSuffixLibraryMarker() + => UpdateFile("Program.cs", new Dictionary + { + { "var builder", "System.Console.WriteLine($\"Loaded {typeof(R2rSuffixLibraryMarker).Assembly.GetName().Name}: {R2rSuffixLibraryMarker.Value}\");\nvar builder" } + }); } } diff --git a/src/mono/wasm/testassets/BlazorBasicTestApp/R2rSuffixLibrary/R2rSuffixLibrary.csproj b/src/mono/wasm/testassets/BlazorBasicTestApp/R2rSuffixLibrary/R2rSuffixLibrary.csproj new file mode 100644 index 00000000000000..0d61c07c07f210 --- /dev/null +++ b/src/mono/wasm/testassets/BlazorBasicTestApp/R2rSuffixLibrary/R2rSuffixLibrary.csproj @@ -0,0 +1,9 @@ + + + + + net11.0 + R2rSuffixLibrary.r2r + + + diff --git a/src/mono/wasm/testassets/BlazorBasicTestApp/R2rSuffixLibrary/R2rSuffixLibraryMarker.cs b/src/mono/wasm/testassets/BlazorBasicTestApp/R2rSuffixLibrary/R2rSuffixLibraryMarker.cs new file mode 100644 index 00000000000000..58f414f41e9a88 --- /dev/null +++ b/src/mono/wasm/testassets/BlazorBasicTestApp/R2rSuffixLibrary/R2rSuffixLibraryMarker.cs @@ -0,0 +1,7 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +public static class R2rSuffixLibraryMarker +{ + public static int Value => 42; +} diff --git a/src/native/libs/Common/JavaScript/host/host.ts b/src/native/libs/Common/JavaScript/host/host.ts index 26f2b7ad9f0dcc..dfb9fb7d0977ff 100644 --- a/src/native/libs/Common/JavaScript/host/host.ts +++ b/src/native/libs/Common/JavaScript/host/host.ts @@ -1,7 +1,7 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -import type { CharPtrPtr, VoidPtr } from "../types"; +import type { AssetEntryInternal, CharPtrPtr, VoidPtr } from "../types"; import { _ems_ } from "../ems-ambient"; import { browserVirtualAppBase, sizeOfPtr } from "../per-module"; @@ -24,7 +24,9 @@ export function initializeCoreCLR(): number { } const assemblyPaths = loaderConfig.resources!.assembly.map(asset => asset.virtualPath.replace(/\.wasm$/, ".dll")); - const coreAssemblyPaths = loaderConfig.resources!.coreAssembly.map(asset => asset.virtualPath.replace(/\.wasm$/, ".dll")); + const coreAssemblyPaths = loaderConfig.resources!.coreAssembly + .filter(asset => !(asset as AssetEntryInternal).isCompositeImage) + .map(asset => asset.virtualPath.replace(/\.wasm$/, ".dll")); const tpa = [...coreAssemblyPaths, ...assemblyPaths].join(":"); runtimeConfigProperties.set(HOST_PROPERTY_TRUSTED_PLATFORM_ASSEMBLIES, tpa); runtimeConfigProperties.set(HOST_PROPERTY_NATIVE_DLL_SEARCH_DIRECTORIES, loaderConfig.virtualWorkingDirectory!); diff --git a/src/native/libs/Common/JavaScript/loader/assets.ts b/src/native/libs/Common/JavaScript/loader/assets.ts index eaf4508a140905..cb02a2074aec02 100644 --- a/src/native/libs/Common/JavaScript/loader/assets.ts +++ b/src/native/libs/Common/JavaScript/loader/assets.ts @@ -565,7 +565,11 @@ export function verifyAllAssetsDownloaded(): void { function normalizeVirtualPath(asset: AssetEntryInternal): void { dotnetAssert.check(asset.virtualPath, "Asset must have virtualPath"); - asset.virtualPath = asset.virtualPath!.replace(/\.wasm$/, ".dll"); + // Component stubs probe for the composite owner by its crossgen2 name (.r2r.wasm), so it keeps + // its .wasm virtual path; it is not a managed assembly (see initializeCoreCLR's TPA). + if (!asset.isCompositeImage) { + asset.virtualPath = asset.virtualPath!.replace(/\.wasm$/, ".dll"); + } asset.virtualPath = asset.virtualPath.startsWith("/") ? asset.virtualPath : asset.culture diff --git a/src/native/libs/Common/JavaScript/types/internal.ts b/src/native/libs/Common/JavaScript/types/internal.ts index cdf67e75aca812..05c58be9531547 100644 --- a/src/native/libs/Common/JavaScript/types/internal.ts +++ b/src/native/libs/Common/JavaScript/types/internal.ts @@ -65,6 +65,7 @@ export interface AssetEntryInternal extends AssetEntry { inprogress?: boolean tableSize?: number payloadSize?: number + isCompositeImage?: boolean } export type LoaderConfigInternal = LoaderConfig & { diff --git a/src/native/libs/Common/JavaScript/types/public-api.ts b/src/native/libs/Common/JavaScript/types/public-api.ts index 0da1d6021fdd42..ef1ba15b90dec5 100644 --- a/src/native/libs/Common/JavaScript/types/public-api.ts +++ b/src/native/libs/Common/JavaScript/types/public-api.ts @@ -269,6 +269,11 @@ export type WebcilAsset = AssemblyAsset & { * plain (non-R2R) webcil. */ tableSize?: number; + /** + * Set on the composite ReadyToRun owner image. It carries native code for its component assemblies + * but is not itself a managed assembly: it keeps its .wasm virtual path and is not a trusted platform assembly. + */ + isCompositeImage?: boolean; }; export type PdbAsset = Asset & { virtualPath: string; diff --git a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/BootJsonBuilderHelper.cs b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/BootJsonBuilderHelper.cs index de63f0f7f0383b..554ae0f476f01f 100644 --- a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/BootJsonBuilderHelper.cs +++ b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/BootJsonBuilderHelper.cs @@ -213,7 +213,7 @@ public int GetDebugLevel(bool hasPdb) return intValue; } - public string TransformResourcesToAssets(BootJsonData config, bool bundlerFriendly = false, Dictionary? webcilSizes = null) + public string TransformResourcesToAssets(BootJsonData config, bool bundlerFriendly = false, Dictionary? webcilSizes = null, HashSet? compositeImages = null) { List imports = []; @@ -333,6 +333,9 @@ public string TransformResourcesToAssets(BootJsonData config, bool bundlerFriend } } + if (subFolder is null && compositeImages?.Contains(a.Key) == true) + asset.isCompositeImage = true; + if (bundlerFriendly) { string escaped = EscapeName(string.Concat(subFolder, a.Key)); diff --git a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/BootJsonData.cs b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/BootJsonData.cs index dd26c45807d2da..a161042c338bfa 100644 --- a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/BootJsonData.cs +++ b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/BootJsonData.cs @@ -415,6 +415,14 @@ public class WebcilAsset : GeneralAsset /// [DataMember(EmitDefaultValue = false)] public int? payloadSize { get; set; } + + /// + /// Set on the composite ReadyToRun owner image. It holds native code for its component assemblies + /// but is not itself a managed assembly: the loader keeps its .wasm virtual path (component + /// stubs probe for it by that name) and excludes it from the trusted platform assemblies. + /// + [DataMember(EmitDefaultValue = false)] + public bool? isCompositeImage { get; set; } } [DataContract] diff --git a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/GenerateWasmBootJson.cs b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/GenerateWasmBootJson.cs index 90e5608d8d7bc7..56104bbc936f3c 100644 --- a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/GenerateWasmBootJson.cs +++ b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/GenerateWasmBootJson.cs @@ -130,6 +130,8 @@ private void WriteBootConfig(string entryAssemblyName) // boot config, letting the loader stream-instantiate instead of buffering and parsing. The // AttachWebcilSizes task attaches these as PayloadSize/TableSize metadata on the resources. var webcilSizes = new Dictionary(); + // Routes of composite ReadyToRun owner images, flagged isCompositeImage in the boot config. + var compositeImages = new HashSet(StringComparer.Ordinal); var result = new BootJsonData { @@ -292,6 +294,14 @@ private void WriteBootConfig(string entryAssemblyName) } } } + else if (string.Equals("WasmResource", assetTraitName, StringComparison.OrdinalIgnoreCase) + && string.Equals("readyToRunComposite", assetTraitValue, StringComparison.OrdinalIgnoreCase)) + { + MapFingerprintedAsset(resourceData, resourceRoute, resourceName); + Log.LogMessage(MessageImportance.Low, "Candidate '{0}' is defined as a composite ReadyToRun image.", resource.ItemSpec); + compositeImages.Add(resourceRoute); + resourceList = resourceData.coreAssembly; + } else if (string.Equals("runtime", assetTraitValue, StringComparison.OrdinalIgnoreCase)) { MapFingerprintedAsset(resourceData, resourceRoute, resourceName); @@ -515,7 +525,7 @@ private void WriteBootConfig(string entryAssemblyName) string? imports = null; if (IsTargeting100OrLater()) - imports = helper.TransformResourcesToAssets(result, BundlerFriendly, webcilSizes); + imports = helper.TransformResourcesToAssets(result, BundlerFriendly, webcilSizes, compositeImages); helper.WriteConfigToFile(result, OutputPath, mergeWith: MergeWith, imports: imports);