From b581af1968d4b75ad74696019aa865f2de78f3a8 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Thu, 24 Sep 2026 16:06:11 -0500 Subject: [PATCH 01/11] [wasm][coreclr] Enable composite ReadyToRun publishing Enable composite R2R output for browser CoreCLR, load the owner image before runtime initialization, and preserve component stubs and linked inputs across incremental publishes. Fix WebCIL function relocations and add browser publish coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../testing/libraries/testing-wasm.md | 8 ++ .../ObjectWriter/WebCilObjectWriter.cs | 7 ++ ...ET.Sdk.WebAssembly.Browser.CoreCLR.targets | 52 +++++---- ...rosoft.NET.Sdk.WebAssembly.Browser.targets | 42 +++++++- .../wasm/Wasm.Build.Tests/ReadyToRunTests.cs | 100 ++++++++++++++++-- .../libs/Common/JavaScript/host/host.ts | 4 +- .../libs/Common/JavaScript/loader/assets.ts | 17 ++- .../libs/Common/JavaScript/loader/run.ts | 4 +- .../GenerateWasmBootJson.cs | 7 ++ 9 files changed, 205 insertions(+), 36 deletions(-) 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/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.cs b/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.cs index d61c5757135988..d9f74730988802 100644 --- a/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.cs +++ b/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.cs @@ -853,6 +853,9 @@ private unsafe int ResolveReloc(int sectionIndex, MemoryStream sourceStream, lon case RelocType.WASM_TABLE_INDEX_SLEB: case RelocType.WASM_TABLE_INDEX_REL_I32: case RelocType.WASM_FUNCTION_INDEX_LEB: + // A code-section address names a function index, not an offset into the WebCIL payload. + case RelocType.WASM_MEMORY_ADDR_REL_SLEB when + _sections[definedSymbol.SectionIndex] is WasmSection { Type: WasmSectionType.Code }: { // These relocations reference a wasm structural index (function, type, // table entry, or well-known global). For R2R we self-resolve them here to @@ -861,6 +864,10 @@ private unsafe int ResolveReloc(int sectionIndex, MemoryStream sourceStream, lon { throw new InvalidOperationException($"Symbol '{reloc.SymbolName}' was not registered. Relocation type {reloc.Type}."); } + if (reloc.Type == RelocType.WASM_MEMORY_ADDR_REL_SLEB && symbol.IndexSpace != WasmIndexSpace.Function) + { + throw new InvalidDataException($"Code-section relocation for '{reloc.SymbolName}' does not reference a function."); + } if (shrink && Relocation.IsVariableLength(reloc.Type)) { 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 cb7de8a0102550..159fa921ddd56b 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 @@ -21,7 +21,7 @@ Copyright (c) .NET Foundation. All rights reserved. wasm - + false $(PublishReadyToRunCrossgen2ExtraArgs);--opt-cross-module:* @@ -51,14 +51,14 @@ Copyright (c) .NET Foundation. All rights reserved. - - + BeforeTargets="_WasmCoreClrSelectR2RDirectories;_ResolveWasmOutputs"> + <_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'" /> + + @@ -167,7 +180,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)' != ''" /> - + <_WasmStalePerAppR2R Include="$(_WasmPublishR2RDir)*.wasm;$(_WasmPublishR2RDir)*.dll" - Exclude="@(_ReadyToRunCompileList->'%(OutputR2RImage)')" /> + Exclude="@(_ReadyToRunFilesToPublish)" /> 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..2710416f5efa3d 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)')"> + $(PackageId) + Computed + $([MSBuild]::NormalizeDirectory($(PublishDir), 'wwwroot')) + $(StaticWebAssetBasePath) + _framework/$([System.IO.Path]::GetFileName('%(OutputR2RImage)')) + _framework/$([System.IO.Path]::GetFileNameWithoutExtension('%(OutputR2RImage)'))#[.{fingerprint}]!.wasm + Publish + All + Primary + WasmResource + core + Never + PreserveNewest + %(OutputR2RImage) + + + + <_WasmDotnetJsForPublish Include="@(ResolvedFileToPublish)" Condition="'%(FileName)%(Extension)' == 'dotnet.js'" /> @@ -1065,6 +1100,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)" /> @@ -1090,7 +1126,7 @@ Copyright (c) .NET Foundation. All rights reserved. --> <_NewWebcilPublishStaticWebAssetsCandidatesNoMetadata - Include="@(_NewWebcilPublishStaticWebAssetsCandidates)" + Include="@(_NewWebcilPublishStaticWebAssetsCandidates);@(_WasmCompositePublishCandidate)" RemoveMetadata="Integrity;Fingerprint" /> @@ -1273,7 +1309,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,15 +72,21 @@ 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")}"); + extraProperties: $"true{(composite ? "true" : "false")}{(trimmed ? "true" : "false")}", + extraItems: extraItems); + if (composite) + AddR2RSuffixLibrary(info); string extraArgs = GetR2RBuildArgs(config); if (nativeRelink) { @@ -86,12 +101,23 @@ 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); + } + await RunForPublishWithWebServer(new BlazorRunOptions(config, CheckCounter: false, ExecuteAfterLoaded: (_, page) => InteractAllPagesAsync(page))); @@ -110,6 +136,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))); + + 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) @@ -260,5 +299,54 @@ private static void AssertCoreLibReadyToRun(string frameworkDir, bool expectRead else Assert.Equal(0, tableSize); } + + private void AssertCompositeReadyToRun(string frameworkDir) + { + string bootConfigPath = _provider.GetBootConfigPath(frameworkDir); + if (EnvironmentVariables.UseFingerprinting) + { + string indexPath = Path.Combine(Path.GetDirectoryName(frameworkDir)!, "index.html"); + Match activeBoot = Regex.Match(File.ReadAllText(indexPath), + @"""./_framework/dotnet\.js"": ""./_framework/(?dotnet\.[a-z0-9]{10}\.js)"""); + Assert.True(activeBoot.Success, $"Missing active dotnet.js import in '{indexPath}'."); + bootConfigPath = Path.Combine(frameworkDir, activeBoot.Groups["name"].Value); + } + AssetsData assets = (AssetsData)_provider.GetBootJson(bootConfigPath).resources; + GeneralAsset composite = Assert.Single(assets.coreAssembly, + asset => asset.virtualPath?.EndsWith(".r2r.wasm", System.StringComparison.Ordinal) == true); + Assert.Equal("BlazorBasicTestApp.r2r.wasm", composite.virtualPath); + if (EnvironmentVariables.UseFingerprinting) + Assert.Matches(@"^BlazorBasicTestApp\.r2r\.[a-z0-9]{10}\.wasm$", composite.name); + 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"); + Assert.DoesNotContain(assets.assembly, + asset => asset.virtualPath == composite.virtualPath); + + string compositePath = Path.Combine(frameworkDir, composite.name); + using FileStream stream = File.OpenRead(compositePath); + Assert.True(WebcilReader.TryReadWebcilInWasmSizes(stream, out _, out int tableSize, out string? failureReason), failureReason); + Assert.True(tableSize > 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 static void AddR2RSuffixLibrary(ProjectInfo info) + { + string appDirectory = Path.GetDirectoryName(info.ProjectFilePath)!; + string libraryDirectory = Path.GetFullPath(Path.Combine(appDirectory, "..", "R2rSuffixLibrary")); + Directory.CreateDirectory(libraryDirectory); + File.WriteAllText(Path.Combine(libraryDirectory, "R2rSuffixLibrary.csproj"), $$""" + + + {{DefaultTargetFrameworkForBlazor}} + R2rSuffixLibrary.r2r + + + """); + File.WriteAllText(Path.Combine(libraryDirectory, "Marker.cs"), + "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..25fa932f605d54 100644 --- a/src/native/libs/Common/JavaScript/host/host.ts +++ b/src/native/libs/Common/JavaScript/host/host.ts @@ -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.virtualPath.endsWith(".r2r.wasm")) + .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..90358722faf61d 100644 --- a/src/native/libs/Common/JavaScript/loader/assets.ts +++ b/src/native/libs/Common/JavaScript/loader/assets.ts @@ -149,6 +149,14 @@ export async function fetchIcu(asset: IcuAsset): Promise { } export async function fetchAssembly(asset: AssemblyAsset): Promise { + return fetchAssemblyInternal(asset, false); +} + +export async function fetchCoreAssembly(asset: AssemblyAsset): Promise { + return fetchAssemblyInternal(asset, true); +} + +async function fetchAssemblyInternal(asset: AssemblyAsset, isCoreAssembly: boolean): Promise { const assetInternal = asset as AssetEntryInternal; totalAssetsToDownload++; dotnetAssert.check(assetInternal.virtualPath, "Assembly asset must have virtualPath"); @@ -160,7 +168,7 @@ export async function fetchAssembly(asset: AssemblyAsset): Promise { } const isWebcilInWasm = assetInternal.virtualPath?.endsWith(".wasm") ?? false; - normalizeVirtualPath(assetInternal); + normalizeVirtualPath(assetInternal, isCoreAssembly && assetInternal.virtualPath.endsWith(".r2r.wasm")); if (isWebcilInWasm) { await fetchWebcil(assetInternal); @@ -563,9 +571,12 @@ export function verifyAllAssetsDownloaded(): void { dotnetAssert.check(downloadedAssetsCount === totalAssetsToDownload, `Not all assets were downloaded. Downloaded ${downloadedAssetsCount} out of ${totalAssetsToDownload}`); } -function normalizeVirtualPath(asset: AssetEntryInternal): void { +function normalizeVirtualPath(asset: AssetEntryInternal, preserveWasmExtension: boolean = false): void { dotnetAssert.check(asset.virtualPath, "Asset must have virtualPath"); - asset.virtualPath = asset.virtualPath!.replace(/\.wasm$/, ".dll"); + // Composite R2R images are probed by their .r2r.wasm name, not as managed .dll assemblies. + if (!preserveWasmExtension) { + asset.virtualPath = asset.virtualPath!.replace(/\.wasm$/, ".dll"); + } asset.virtualPath = asset.virtualPath.startsWith("/") ? asset.virtualPath : asset.culture diff --git a/src/native/libs/Common/JavaScript/loader/run.ts b/src/native/libs/Common/JavaScript/loader/run.ts index 6604334d3ce51e..c2c70435840c44 100644 --- a/src/native/libs/Common/JavaScript/loader/run.ts +++ b/src/native/libs/Common/JavaScript/loader/run.ts @@ -8,7 +8,7 @@ import { exit, runtimeState } from "./exit"; import { createPromiseCompletionSource } from "./promise-completion-source"; import { getIcuResourceName } from "./icu"; import { loaderConfig, validateLoaderConfig } from "./config"; -import { fetchAssembly, fetchIcu, fetchNativeSymbols, fetchPdb, fetchSatelliteAssemblies, fetchVfs, fetchMainWasm, loadDotnetModule, loadJSModule, nativeModulePromiseController, verifyAllAssetsDownloaded, callLibraryInitializerOnRuntimeReady, callLibraryInitializerOnRuntimeConfigLoaded, prefetchAllResources, prefetchJSModuleLinks, resolveAllDownloadsQueued } from "./assets"; +import { fetchAssembly, fetchCoreAssembly, fetchIcu, fetchNativeSymbols, fetchPdb, fetchSatelliteAssemblies, fetchVfs, fetchMainWasm, loadDotnetModule, loadJSModule, nativeModulePromiseController, verifyAllAssetsDownloaded, callLibraryInitializerOnRuntimeReady, callLibraryInitializerOnRuntimeConfigLoaded, prefetchAllResources, prefetchJSModuleLinks, resolveAllDownloadsQueued } from "./assets"; import { initPolyfillsLoader } from "./polyfills"; import { validateEngineFeatures } from "./bootstrap"; @@ -117,7 +117,7 @@ export async function createRuntime(downloadOnly: boolean, httpCacheOnly: boolea const runtimeModulePromise: Promise = loadDotnetModule(resources.jsModuleRuntime[0]); const wasmNativePromise: Promise = fetchMainWasm(resources.wasmNative[0]); - const coreAssembliesPromise = forEachResource(resources.coreAssembly, fetchAssembly); + const coreAssembliesPromise = forEachResource(resources.coreAssembly, fetchCoreAssembly); const icuResourceName = getIcuResourceName(); const icuDataPromise = forEachResource(resources.icu, fetchIcu, asset => asset.name === icuResourceName); 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..41e864feb49d5c 100644 --- a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/GenerateWasmBootJson.cs +++ b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/GenerateWasmBootJson.cs @@ -292,6 +292,13 @@ private void WriteBootConfig(string entryAssemblyName) } } } + else if (string.Equals("WasmResource", assetTraitName, StringComparison.OrdinalIgnoreCase) + && string.Equals("core", assetTraitValue, StringComparison.OrdinalIgnoreCase)) + { + MapFingerprintedAsset(resourceData, resourceRoute, resourceName); + Log.LogMessage(MessageImportance.Low, "Candidate '{0}' is defined as a core assembly.", resource.ItemSpec); + resourceList = resourceData.coreAssembly; + } else if (string.Equals("runtime", assetTraitValue, StringComparison.OrdinalIgnoreCase)) { MapFingerprintedAsset(resourceData, resourceRoute, resourceName); From 3df1ea384d60c5ca3b49bbcfdf8bc8d21388c127 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Thu, 24 Sep 2026 16:55:56 -0500 Subject: [PATCH 02/11] [wasm][coreclr] Single-source composite owner identity in the loader The loader now decides once, while fetching resources.coreAssembly, whether an asset is the composite ReadyToRun owner and records it on the asset; the host builds the TPA from that flag instead of re-deriving it from the .r2r.wasm suffix. The composite test app now calls into the referenced R2rSuffixLibrary.r2r at startup and asserts it loaded, so an ordinary library whose name ends in .r2r is verified to load as a managed component rather than only checked in the boot manifest. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../wasm/Wasm.Build.Tests/ReadyToRunTests.cs | 16 ++++++++++++++-- src/native/libs/Common/JavaScript/host/host.ts | 4 ++-- .../libs/Common/JavaScript/loader/assets.ts | 11 +++++++---- .../libs/Common/JavaScript/types/internal.ts | 1 + 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs b/src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs index 7ab5a726df92d1..11a125ba92ef78 100644 --- a/src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs +++ b/src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs @@ -80,7 +80,7 @@ private async Task PublishRunAllPagesCore(Configuration config, bool trimmed, bo // 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" : "")}{(composite ? "_composite" : "")}"; string extraItems = composite - ? """""" + ? """""" : string.Empty; ProjectInfo info = CopyTestAsset(config, aot: false, TestAsset.BlazorBasicTestApp, label, extraProperties: $"true{(composite ? "true" : "false")}{(trimmed ? "true" : "false")}", @@ -118,9 +118,13 @@ private async Task PublishRunAllPagesCore(Configuration config, bool trimmed, bo AssertNoDuplicateAssemblies(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))] @@ -332,8 +336,16 @@ private void AssertCompositeReadyToRun(string frameworkDir) $"Expected a component stub for System.Private.CoreLib in '{frameworkDir}'."); } - private static void AddR2RSuffixLibrary(ProjectInfo info) + private const string SuffixLibraryLoadedMessage = "Loaded R2rSuffixLibrary.r2r: 42"; + + // An ordinary library whose name ends in .r2r must still load as a managed component assembly, + // not be mistaken for the composite owner image (.r2r.wasm). + private void AddR2RSuffixLibrary(ProjectInfo info) { + UpdateFile("Program.cs", new Dictionary + { + { "var builder", "System.Console.WriteLine($\"Loaded {typeof(R2rSuffixLibraryMarker).Assembly.GetName().Name}: {R2rSuffixLibraryMarker.Value}\");\nvar builder" } + }); string appDirectory = Path.GetDirectoryName(info.ProjectFilePath)!; string libraryDirectory = Path.GetFullPath(Path.Combine(appDirectory, "..", "R2rSuffixLibrary")); Directory.CreateDirectory(libraryDirectory); diff --git a/src/native/libs/Common/JavaScript/host/host.ts b/src/native/libs/Common/JavaScript/host/host.ts index 25fa932f605d54..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"; @@ -25,7 +25,7 @@ export function initializeCoreCLR(): number { const assemblyPaths = loaderConfig.resources!.assembly.map(asset => asset.virtualPath.replace(/\.wasm$/, ".dll")); const coreAssemblyPaths = loaderConfig.resources!.coreAssembly - .filter(asset => !asset.virtualPath.endsWith(".r2r.wasm")) + .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); diff --git a/src/native/libs/Common/JavaScript/loader/assets.ts b/src/native/libs/Common/JavaScript/loader/assets.ts index 90358722faf61d..839cc6e9768f68 100644 --- a/src/native/libs/Common/JavaScript/loader/assets.ts +++ b/src/native/libs/Common/JavaScript/loader/assets.ts @@ -168,7 +168,11 @@ async function fetchAssemblyInternal(asset: AssemblyAsset, isCoreAssembly: boole } const isWebcilInWasm = assetInternal.virtualPath?.endsWith(".wasm") ?? false; - normalizeVirtualPath(assetInternal, isCoreAssembly && assetInternal.virtualPath.endsWith(".r2r.wasm")); + // The SDK places only System.Private.CoreLib and the composite R2R owner in coreAssembly. Crossgen2 names + // the owner .r2r.wasm and every component stub probes for it by that exact name, so it keeps its + // .wasm virtual path and is not a managed assembly (see initializeCoreCLR's TPA). + assetInternal.isCompositeImage = isCoreAssembly && assetInternal.virtualPath.endsWith(".r2r.wasm"); + normalizeVirtualPath(assetInternal); if (isWebcilInWasm) { await fetchWebcil(assetInternal); @@ -571,10 +575,9 @@ export function verifyAllAssetsDownloaded(): void { dotnetAssert.check(downloadedAssetsCount === totalAssetsToDownload, `Not all assets were downloaded. Downloaded ${downloadedAssetsCount} out of ${totalAssetsToDownload}`); } -function normalizeVirtualPath(asset: AssetEntryInternal, preserveWasmExtension: boolean = false): void { +function normalizeVirtualPath(asset: AssetEntryInternal): void { dotnetAssert.check(asset.virtualPath, "Asset must have virtualPath"); - // Composite R2R images are probed by their .r2r.wasm name, not as managed .dll assemblies. - if (!preserveWasmExtension) { + if (!asset.isCompositeImage) { asset.virtualPath = asset.virtualPath!.replace(/\.wasm$/, ".dll"); } asset.virtualPath = asset.virtualPath.startsWith("/") diff --git a/src/native/libs/Common/JavaScript/types/internal.ts b/src/native/libs/Common/JavaScript/types/internal.ts index 7307d336ee6772..9569ab37c07d54 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 & { From 8e5939f6d072a1b833ddddd979c18ae2b6608454 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 25 Sep 2026 09:42:18 -0500 Subject: [PATCH 03/11] Require wasm-aware ReadyToRun tasks for browser composite Stock SDK ReadyToRun tasks name the browser composite owner '.r2r.dll' and plan component outputs as '.dll', while crossgen2 emits '.r2r.wasm' plus '.wasm' stubs. Fail fast with an actionable error instead of a downstream ConvertDllsToWebcil file-not-found. Ship the Crossgen2Tasks shim to BuildWasmApps Helix payloads and use it only for composite Wasm.Build.Tests so per-assembly R2R keeps stock SDK coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/libraries/sendtohelix-browser.targets | 5 +++-- ...ET.Sdk.WebAssembly.Browser.CoreCLR.targets | 13 ++++++++++++ .../wasm/Wasm.Build.Tests/ReadyToRunTests.cs | 21 ++++++++++--------- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/libraries/sendtohelix-browser.targets b/src/libraries/sendtohelix-browser.targets index 4762716bdfc717..224027c11aee34 100644 --- a/src/libraries/sendtohelix-browser.targets +++ b/src/libraries/sendtohelix-browser.targets @@ -295,8 +295,9 @@ 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 159fa921ddd56b..99e69165211f10 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 @@ -65,6 +65,19 @@ Copyright (c) .NET Foundation. All rights reserved. Text="PublishReadyToRun=true for CoreCLR browser-wasm requires a wasm-capable crossgen2, but none was resolved. Set Crossgen2InBuildDir to an in-build crossgen2 directory (or provide a ResolvedCrossgen2Pack) until wasm crossgen2 support flows through the base SDK (dotnet/sdk#55785)." /> + + + + <_WasmCompositeOwnerEntry Include="@(_ReadyToRunCompileList->WithMetadataValue('CreateCompositeImage', 'true'))" /> + + + + diff --git a/src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs b/src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs index 11a125ba92ef78..b801ae4d708c6d 100644 --- a/src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs +++ b/src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs @@ -87,7 +87,7 @@ private async Task PublishRunAllPagesCore(Configuration config, bool trimmed, bo extraItems: extraItems); if (composite) AddR2RSuffixLibrary(info); - string extraArgs = GetR2RBuildArgs(config); + string extraArgs = GetR2RBuildArgs(config, composite); if (nativeRelink) { // CoreCLR relinks dotnet.native.wasm via the in-tree targets + EMSDK_PATH, not the browser @@ -148,7 +148,7 @@ 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))); + new PublishOptions(ExpectSuccess: false, ExtraMSBuildArgs: GetR2RBuildArgs(config, composite: true))); Assert.Contains("PublishReadyToRunComposite for CoreCLR browser-wasm requires WebCIL-in-Wasm assemblies", output); } @@ -262,12 +262,13 @@ 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. + private static string GetR2RBuildArgs(Configuration config, bool composite) { string? baseDir = EnvironmentVariables.BaseDir; if (string.IsNullOrEmpty(baseDir)) @@ -282,9 +283,9 @@ 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); } From 942f63586a8d15d8d02bd2afeba9a9b20683fe29 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 25 Sep 2026 11:56:06 -0500 Subject: [PATCH 04/11] Address wasm composite R2R review feedback - Boot config: flag the composite owner with isCompositeImage (new WasmResource/readyToRunComposite trait) instead of the loader inferring it from the .r2r.wasm file name. - Define the composite owner static web asset with DefineStaticWebAssets rather than a hand-built candidate. - Stale-prune keeps what crossgen2 actually emitted (compile outputs plus composite component stubs), not the task's publish plan. - Move R2rSuffixLibrary into testassets; GetBootConfigPath picks the newest fingerprinted dotnet.js so composite assertions use the shared helper. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...ET.Sdk.WebAssembly.Browser.CoreCLR.targets | 7 ++- ...rosoft.NET.Sdk.WebAssembly.Browser.targets | 45 +++++++------ .../Wasm.Build.Tests/ProjectProviderBase.cs | 10 ++- .../wasm/Wasm.Build.Tests/ReadyToRunTests.cs | 63 ++++++------------- .../R2rSuffixLibrary/R2rSuffixLibrary.csproj | 9 +++ .../R2rSuffixLibraryMarker.cs | 7 +++ .../libs/Common/JavaScript/loader/assets.ts | 14 +---- .../libs/Common/JavaScript/loader/run.ts | 4 +- .../Common/JavaScript/types/public-api.ts | 5 ++ .../BootJsonBuilderHelper.cs | 5 +- .../BootJsonData.cs | 8 +++ .../GenerateWasmBootJson.cs | 9 ++- 12 files changed, 102 insertions(+), 84 deletions(-) create mode 100644 src/mono/wasm/testassets/BlazorBasicTestApp/R2rSuffixLibrary/R2rSuffixLibrary.csproj create mode 100644 src/mono/wasm/testassets/BlazorBasicTestApp/R2rSuffixLibrary/R2rSuffixLibraryMarker.cs 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 99e69165211f10..041760b179402b 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 @@ -243,10 +243,15 @@ Copyright (c) .NET Foundation. All rights reserved. DependsOnTargets="_WasmCoreClrSelectR2RDirectories" AfterTargets="_CreateR2RImages"> + + <_WasmEmittedPerAppR2R Include="@(_ReadyToRunCompileList->'%(OutputR2RImage)')" /> + <_WasmEmittedPerAppR2R Include="@(_ReadyToRunCompositeBuildInput->'$(_WasmPublishR2RDir)%(FileName).wasm');@(_ReadyToRunCompositeUnrootedBuildInput->'$(_WasmPublishR2RDir)%(FileName).wasm')" + Condition="'$(PublishReadyToRunComposite)' == 'true'" /> <_WasmStalePerAppR2R Include="$(_WasmPublishR2RDir)*.wasm;$(_WasmPublishR2RDir)*.dll" - Exclude="@(_ReadyToRunFilesToPublish)" /> + 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 2710416f5efa3d..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 @@ -1062,25 +1062,32 @@ Copyright (c) .NET Foundation. All rights reserved. <_WasmCompositeCompilation Include="@(_ReadyToRunCompileList)" Condition="'%(CreateCompositeImage)' == 'true'" /> - <_WasmCompositePublishCandidate Include="@(_WasmCompositeCompilation->'%(OutputR2RImage)')"> - $(PackageId) - Computed - $([MSBuild]::NormalizeDirectory($(PublishDir), 'wwwroot')) - $(StaticWebAssetBasePath) - _framework/$([System.IO.Path]::GetFileName('%(OutputR2RImage)')) - _framework/$([System.IO.Path]::GetFileNameWithoutExtension('%(OutputR2RImage)'))#[.{fingerprint}]!.wasm - Publish - All - Primary - WasmResource - core - Never - PreserveNewest - %(OutputR2RImage) - + <_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'" /> @@ -1126,7 +1133,7 @@ Copyright (c) .NET Foundation. All rights reserved. --> <_NewWebcilPublishStaticWebAssetsCandidatesNoMetadata - Include="@(_NewWebcilPublishStaticWebAssetsCandidates);@(_WasmCompositePublishCandidate)" + Include="@(_NewWebcilPublishStaticWebAssetsCandidates)" RemoveMetadata="Integrity;Fingerprint" /> @@ -1147,7 +1154,7 @@ Copyright (c) .NET Foundation. All rights reserved. @@ -1156,7 +1163,7 @@ Copyright (c) .NET Foundation. All rights reserved. - + diff --git a/src/mono/wasm/Wasm.Build.Tests/ProjectProviderBase.cs b/src/mono/wasm/Wasm.Build.Tests/ProjectProviderBase.cs index ccd4b435dc5253..ba9e8f3e8bd39b 100644 --- a/src/mono/wasm/Wasm.Build.Tests/ProjectProviderBase.cs +++ b/src/mono/wasm/Wasm.Build.Tests/ProjectProviderBase.cs @@ -560,9 +560,11 @@ public string GetBootConfigPath(string binFrameworkDir, string? bootConfigFileNa { string bootFileNameWithoutExtension = Path.GetFileNameWithoutExtension(bootConfigFileName); string bootFileExtension = Path.GetExtension(bootConfigFileName); + // A republish into an existing output leaves the previous fingerprinted copy beside the current one; + // pick the newest, as FindAndAssertDotnetFiles does, so assertions read the active boot config. string? fingerprintedBootJsonPath = Directory .EnumerateFiles(binFrameworkDir) - .FirstOrDefault(f => + .Where(f => { if (Path.GetExtension(f) != bootFileExtension) return false; @@ -575,8 +577,10 @@ public string GetBootConfigPath(string binFrameworkDir, string? bootConfigFileNa return false; return true; - }); - + }) + .OrderByDescending(File.GetLastWriteTimeUtc) + .FirstOrDefault(); + if (fingerprintedBootJsonPath == null) throw new XunitException($"Could not find boot config '{bootConfigFileName}' with fingerprint in '{binFrameworkDir}'"); diff --git a/src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs b/src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs index b801ae4d708c6d..2980d817eb409f 100644 --- a/src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs +++ b/src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs @@ -86,7 +86,7 @@ private async Task PublishRunAllPagesCore(Configuration config, bool trimmed, bo extraProperties: $"true{(composite ? "true" : "false")}{(trimmed ? "true" : "false")}", extraItems: extraItems); if (composite) - AddR2RSuffixLibrary(info); + LogR2RSuffixLibraryMarker(); string extraArgs = GetR2RBuildArgs(config, composite); if (nativeRelink) { @@ -290,76 +290,53 @@ private static string GetR2RBuildArgs(Configuration config, bool composite) 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); } + // 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) { - string bootConfigPath = _provider.GetBootConfigPath(frameworkDir); - if (EnvironmentVariables.UseFingerprinting) - { - string indexPath = Path.Combine(Path.GetDirectoryName(frameworkDir)!, "index.html"); - Match activeBoot = Regex.Match(File.ReadAllText(indexPath), - @"""./_framework/dotnet\.js"": ""./_framework/(?dotnet\.[a-z0-9]{10}\.js)"""); - Assert.True(activeBoot.Success, $"Missing active dotnet.js import in '{indexPath}'."); - bootConfigPath = Path.Combine(frameworkDir, activeBoot.Groups["name"].Value); - } - AssetsData assets = (AssetsData)_provider.GetBootJson(bootConfigPath).resources; - GeneralAsset composite = Assert.Single(assets.coreAssembly, - asset => asset.virtualPath?.EndsWith(".r2r.wasm", System.StringComparison.Ordinal) == true); + 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"); - Assert.DoesNotContain(assets.assembly, - asset => asset.virtualPath == composite.virtualPath); + Assert.Contains(assets.assembly, asset => asset.virtualPath == "R2rSuffixLibrary.r2r.wasm"); string compositePath = Path.Combine(frameworkDir, composite.name); - using FileStream stream = File.OpenRead(compositePath); - Assert.True(WebcilReader.TryReadWebcilInWasmSizes(stream, out _, out int tableSize, out string? failureReason), failureReason); - Assert.True(tableSize > 0, $"Expected compiled methods in '{compositePath}'."); + 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"; - // An ordinary library whose name ends in .r2r must still load as a managed component assembly, - // not be mistaken for the composite owner image (.r2r.wasm). - private void AddR2RSuffixLibrary(ProjectInfo info) - { - UpdateFile("Program.cs", new Dictionary + // 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" } }); - string appDirectory = Path.GetDirectoryName(info.ProjectFilePath)!; - string libraryDirectory = Path.GetFullPath(Path.Combine(appDirectory, "..", "R2rSuffixLibrary")); - Directory.CreateDirectory(libraryDirectory); - File.WriteAllText(Path.Combine(libraryDirectory, "R2rSuffixLibrary.csproj"), $$""" - - - {{DefaultTargetFrameworkForBlazor}} - R2rSuffixLibrary.r2r - - - """); - File.WriteAllText(Path.Combine(libraryDirectory, "Marker.cs"), - "public static class R2rSuffixLibraryMarker { public static int Value => 42; }"); - } } } 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/loader/assets.ts b/src/native/libs/Common/JavaScript/loader/assets.ts index 839cc6e9768f68..cb02a2074aec02 100644 --- a/src/native/libs/Common/JavaScript/loader/assets.ts +++ b/src/native/libs/Common/JavaScript/loader/assets.ts @@ -149,14 +149,6 @@ export async function fetchIcu(asset: IcuAsset): Promise { } export async function fetchAssembly(asset: AssemblyAsset): Promise { - return fetchAssemblyInternal(asset, false); -} - -export async function fetchCoreAssembly(asset: AssemblyAsset): Promise { - return fetchAssemblyInternal(asset, true); -} - -async function fetchAssemblyInternal(asset: AssemblyAsset, isCoreAssembly: boolean): Promise { const assetInternal = asset as AssetEntryInternal; totalAssetsToDownload++; dotnetAssert.check(assetInternal.virtualPath, "Assembly asset must have virtualPath"); @@ -168,10 +160,6 @@ async function fetchAssemblyInternal(asset: AssemblyAsset, isCoreAssembly: boole } const isWebcilInWasm = assetInternal.virtualPath?.endsWith(".wasm") ?? false; - // The SDK places only System.Private.CoreLib and the composite R2R owner in coreAssembly. Crossgen2 names - // the owner .r2r.wasm and every component stub probes for it by that exact name, so it keeps its - // .wasm virtual path and is not a managed assembly (see initializeCoreCLR's TPA). - assetInternal.isCompositeImage = isCoreAssembly && assetInternal.virtualPath.endsWith(".r2r.wasm"); normalizeVirtualPath(assetInternal); if (isWebcilInWasm) { @@ -577,6 +565,8 @@ export function verifyAllAssetsDownloaded(): void { function normalizeVirtualPath(asset: AssetEntryInternal): void { dotnetAssert.check(asset.virtualPath, "Asset must have virtualPath"); + // 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"); } diff --git a/src/native/libs/Common/JavaScript/loader/run.ts b/src/native/libs/Common/JavaScript/loader/run.ts index c2c70435840c44..6604334d3ce51e 100644 --- a/src/native/libs/Common/JavaScript/loader/run.ts +++ b/src/native/libs/Common/JavaScript/loader/run.ts @@ -8,7 +8,7 @@ import { exit, runtimeState } from "./exit"; import { createPromiseCompletionSource } from "./promise-completion-source"; import { getIcuResourceName } from "./icu"; import { loaderConfig, validateLoaderConfig } from "./config"; -import { fetchAssembly, fetchCoreAssembly, fetchIcu, fetchNativeSymbols, fetchPdb, fetchSatelliteAssemblies, fetchVfs, fetchMainWasm, loadDotnetModule, loadJSModule, nativeModulePromiseController, verifyAllAssetsDownloaded, callLibraryInitializerOnRuntimeReady, callLibraryInitializerOnRuntimeConfigLoaded, prefetchAllResources, prefetchJSModuleLinks, resolveAllDownloadsQueued } from "./assets"; +import { fetchAssembly, fetchIcu, fetchNativeSymbols, fetchPdb, fetchSatelliteAssemblies, fetchVfs, fetchMainWasm, loadDotnetModule, loadJSModule, nativeModulePromiseController, verifyAllAssetsDownloaded, callLibraryInitializerOnRuntimeReady, callLibraryInitializerOnRuntimeConfigLoaded, prefetchAllResources, prefetchJSModuleLinks, resolveAllDownloadsQueued } from "./assets"; import { initPolyfillsLoader } from "./polyfills"; import { validateEngineFeatures } from "./bootstrap"; @@ -117,7 +117,7 @@ export async function createRuntime(downloadOnly: boolean, httpCacheOnly: boolea const runtimeModulePromise: Promise = loadDotnetModule(resources.jsModuleRuntime[0]); const wasmNativePromise: Promise = fetchMainWasm(resources.wasmNative[0]); - const coreAssembliesPromise = forEachResource(resources.coreAssembly, fetchCoreAssembly); + const coreAssembliesPromise = forEachResource(resources.coreAssembly, fetchAssembly); const icuResourceName = getIcuResourceName(); const icuDataPromise = forEachResource(resources.icu, fetchIcu, asset => asset.name === icuResourceName); diff --git a/src/native/libs/Common/JavaScript/types/public-api.ts b/src/native/libs/Common/JavaScript/types/public-api.ts index bf15adfa46ee7e..21820addf6496c 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 41e864feb49d5c..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 { @@ -293,10 +295,11 @@ private void WriteBootConfig(string entryAssemblyName) } } else if (string.Equals("WasmResource", assetTraitName, StringComparison.OrdinalIgnoreCase) - && string.Equals("core", assetTraitValue, StringComparison.OrdinalIgnoreCase)) + && string.Equals("readyToRunComposite", assetTraitValue, StringComparison.OrdinalIgnoreCase)) { MapFingerprintedAsset(resourceData, resourceRoute, resourceName); - Log.LogMessage(MessageImportance.Low, "Candidate '{0}' is defined as a core assembly.", resource.ItemSpec); + 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)) @@ -522,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); From 15a61fdf4210c2c98c48790fb20a3f88af9b06db Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 25 Sep 2026 12:57:10 -0500 Subject: [PATCH 05/11] Drop WebCIL code-section WASM_MEMORY_ADDR_REL_SLEB handling The relocation only arises with dotnet/runtime#134555, where a same-image managed helper is returned as IAT_VALUE but genEmitHelperCall still loads the PEP through an indirection cell. Encoding it as a function index would load from imageBase + funcIndex, so the fix belongs in the JIT. On main no code path emits it for composite images. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Common/Compiler/ObjectWriter/WebCilObjectWriter.cs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.cs b/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.cs index d9f74730988802..d61c5757135988 100644 --- a/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.cs +++ b/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.cs @@ -853,9 +853,6 @@ private unsafe int ResolveReloc(int sectionIndex, MemoryStream sourceStream, lon case RelocType.WASM_TABLE_INDEX_SLEB: case RelocType.WASM_TABLE_INDEX_REL_I32: case RelocType.WASM_FUNCTION_INDEX_LEB: - // A code-section address names a function index, not an offset into the WebCIL payload. - case RelocType.WASM_MEMORY_ADDR_REL_SLEB when - _sections[definedSymbol.SectionIndex] is WasmSection { Type: WasmSectionType.Code }: { // These relocations reference a wasm structural index (function, type, // table entry, or well-known global). For R2R we self-resolve them here to @@ -864,10 +861,6 @@ private unsafe int ResolveReloc(int sectionIndex, MemoryStream sourceStream, lon { throw new InvalidOperationException($"Symbol '{reloc.SymbolName}' was not registered. Relocation type {reloc.Type}."); } - if (reloc.Type == RelocType.WASM_MEMORY_ADDR_REL_SLEB && symbol.IndexSpace != WasmIndexSpace.Function) - { - throw new InvalidDataException($"Code-section relocation for '{reloc.SymbolName}' does not reference a function."); - } if (shrink && Relocation.IsVariableLength(reloc.Type)) { From 51909e99b5b039bbcd7e661e4951b9da4cfd8453 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 25 Sep 2026 15:16:05 -0500 Subject: [PATCH 06/11] Drop composite ban re-added by the main merge #132721 duplicated the composite and container-format errors into _WasmCoreClrSelectR2RDirectories. That brought back the composite rejection this PR removes and failed PublishRunAllPagesComposite on CI. Keep the checks in _WasmCoreClrValidateReadyToRun, which runs first, and move the new WasmPerformanceInstrumentation check there. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...crosoft.NET.Sdk.WebAssembly.Browser.CoreCLR.targets | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) 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 534d3aac2b7909..ef14fb88830085 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 @@ -36,13 +36,6 @@ Copyright (c) .NET Foundation. All rights reserved. _ResolveGlobalizationConfiguration. --> - - - - <_WasmRuntimePackR2RDir Condition="'$(_WasmRuntimePackR2RDir)' == '' and '$(_RuntimePackNativeDir)' != ''">$([MSBuild]::NormalizeDirectory('$(_RuntimePackNativeDir)', 'r2r')) @@ -68,6 +61,9 @@ Copyright (c) .NET Foundation. All rights reserved. Text="PublishReadyToRunComposite for CoreCLR browser-wasm requires WebCIL-in-Wasm assemblies; enable WasmEnableWebcil." /> + + From 3ffb97466460795b140948543e2dc976a414c05e Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 25 Sep 2026 15:36:22 -0500 Subject: [PATCH 07/11] Point container-format comment at the validating target Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../build/Microsoft.NET.Sdk.WebAssembly.Browser.CoreCLR.targets | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ef14fb88830085..6e5fa73e6cf852 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,7 +19,7 @@ Copyright (c) .NET Foundation. All rights reserved. + An explicit unsupported value is left as-is and rejected by _WasmCoreClrValidateReadyToRun. --> wasm false From 43c27ac13109050cfa1d4c6b3f6e99bc44bcd0ba Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 25 Sep 2026 20:19:42 -0500 Subject: [PATCH 08/11] [wasm][coreclr] Strip R2R debug info by default for browser-wasm publish (#134689) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Pass `--strip-debug-info` to crossgen2 for CoreCLR browser-wasm ReadyToRun publish. This drops the R2R `DebugInfo` section (native-to-IL offset maps and variable locations) from shipped images. Opt out with `PublishReadyToRunStripDebugInfo=false` to keep the data for debugging R2R code (e.g. the cDAC work in #133086 / #133890). Also makes crossgen2 argument changes invalidate per-app R2R images. `_CreateR2RImages` only tracks file inputs, so toggling `PublishReadyToRunStripDebugInfo` (or any `PublishReadyToRunCrossgen2ExtraArgs` change) previously left stale images in `obj/R2R`. The arguments are now written to `obj/wasm-r2r-args.stamp` (only when different) and added to `_ReadyToRunCompilerInputs`, mirroring the existing P/Invoke manifest input. > [!IMPORTANT] > Stacked on #134618, which rewrites the same targets file. Retarget to `main` after it merges. ## Size impact Per-assembly R2R images compiled directly with crossgen2 using the SDK's browser-wasm arguments (`--obj-format:wasm --opt-cross-module:* --codegenopt:JitWasm*NyiToR2RUnsupported=1`), with and without `--strip-debug-info`. Total for System.Private.CoreLib, System.Text.Json, System.Linq, and System.Collections: | Compiler | Raw saved | gzip -9 saved | brotli -q 11 saved | | --- | --- | --- | --- | | Current (#134618 base) | 859,712 B (2.4%) | 624,690 B (7.1%) | 565,366 B (9.4%) | | With #133086 variable info | 2,105,520 B (5.6%) | 1,332,733 B (13.9%) | 1,121,574 B (17.0%) |
Per-assembly brotli sizes (bytes, keep → strip) | Assembly | Current | With #133086 | | --- | --- | --- | | System.Private.CoreLib | 4,822,231 → 4,347,541 | 5,331,799 → 4,403,849 | | System.Text.Json | 823,314 → 761,533 | 887,982 → 754,444 | | System.Linq | 219,177 → 200,471 | 238,853 → 200,441 | | System.Collections | 119,635 → 109,446 | 130,742 → 109,068 | The #133086 compiler is based on an older commit, so compare keep vs. strip within a column rather than across columns.
## Validation - MSBuild evaluation: `--strip-debug-info` is present by default, absent with `PublishReadyToRunStripDebugInfo=false`, and absent when `PublishReadyToRun` is off. - Incremental harness: `_CreateR2RImages` runs on first build, skips when unchanged, reruns on opt-out, skips when repeated, and reruns when switching back. - Not yet run: an end-to-end browser-wasm publish loading stripped images in a browser (Wasm.Build.Tests in CI will cover this). Runtime-pack framework R2R images (used only by the dev-loop build) are unchanged; publish recompiles the whole closure through these targets. `--strip-inlining-info` is intentionally not included: it removes `CrossModuleInlineInfo`, and cross-module inlining is load-bearing on wasm, so it needs separate validation. > [!NOTE] > This pull request was created with assistance from GitHub Copilot. --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- ...t.NET.Sdk.WebAssembly.Browser.CoreCLR.targets | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) 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 6e5fa73e6cf852..97fef920278c75 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 @@ -27,6 +27,9 @@ Copyright (c) .NET Foundation. All rights reserved. $(PublishReadyToRunCrossgen2ExtraArgs);--opt-cross-module:* $(PublishReadyToRunCrossgen2ExtraArgs);--codegenopt:JitWasmNyiToR2RUnsupported=1;--codegenopt:JitWasmSimdNyiToR2RUnsupported=1 + + $(PublishReadyToRunCrossgen2ExtraArgs);--strip-debug-info
+ + <_WasmReadyToRunArgsStamp>$(IntermediateOutputPath)wasm-r2r-args.stamp + + + + <_ReadyToRunCompilerInputs Include="$(_WasmReadyToRunArgsStamp)" /> + +
$(PublishReadyToRunCrossgen2ExtraArgs);--codegenopt:JitWasmNyiToR2RUnsupported=1;--codegenopt:JitWasmSimdNyiToR2RUnsupported=1 - - $(PublishReadyToRunCrossgen2ExtraArgs);--strip-debug-info + <_WasmReadyToRunArgsStamp>$(IntermediateOutputPath)wasm-r2r-args.stamp diff --git a/src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs b/src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs index 2980d817eb409f..c7185f6dd7175a 100644 --- a/src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs +++ b/src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs @@ -116,6 +116,8 @@ private async Task PublishRunAllPagesCore(Configuration config, bool trimmed, bo if (composite) AssertCompositeReadyToRun(frameworkDir); AssertNoDuplicateAssemblies(frameworkDir); + if (composite) + AssertSwitchingCompositeModeRecompiles(info, config, extraArgs, frameworkDir); } bool suffixLibraryLoaded = false; @@ -309,6 +311,22 @@ private static void AssertCoreLibReadyToRun(string frameworkDir, bool expectRead 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. + 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); + + 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."); + + 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) From a36ea807434fa5d52148e119fdc7ba446453535c Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Thu, 1 Oct 2026 09:33:28 -0500 Subject: [PATCH 11/11] Address composite R2R review: track the shim, keep GetBootConfigPath unchanged Mark the Crossgen2Tasks shim wiring (Helix payload, composite task validation, test args) with TODO-WASM pointing at #135023. Revert the newest-file selection in ProjectProviderBase.GetBootConfigPath. Only the composite mode-switch republish leaves another mode's fingerprinted dotnet.js behind, so clear the published _framework there instead. The mode switch only needs obj to be incremental. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/libraries/sendtohelix-browser.targets | 1 + ...crosoft.NET.Sdk.WebAssembly.Browser.CoreCLR.targets | 3 ++- src/mono/wasm/Wasm.Build.Tests/ProjectProviderBase.cs | 10 +++------- src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs | 6 +++++- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/libraries/sendtohelix-browser.targets b/src/libraries/sendtohelix-browser.targets index 224027c11aee34..26d278bf6f6c3b 100644 --- a/src/libraries/sendtohelix-browser.targets +++ b/src/libraries/sendtohelix-browser.targets @@ -297,6 +297,7 @@ 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. 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 898b199401cea3..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 @@ -70,7 +70,8 @@ Copyright (c) .NET Foundation. All rights reserved. + and .dll outputs that are never produced, so fail before the (long) composite compile. + TODO-WASM https://github.com/dotnet/runtime/issues/135023: revisit once the SDK names wasm R2R outputs. --> diff --git a/src/mono/wasm/Wasm.Build.Tests/ProjectProviderBase.cs b/src/mono/wasm/Wasm.Build.Tests/ProjectProviderBase.cs index ba9e8f3e8bd39b..ccd4b435dc5253 100644 --- a/src/mono/wasm/Wasm.Build.Tests/ProjectProviderBase.cs +++ b/src/mono/wasm/Wasm.Build.Tests/ProjectProviderBase.cs @@ -560,11 +560,9 @@ public string GetBootConfigPath(string binFrameworkDir, string? bootConfigFileNa { string bootFileNameWithoutExtension = Path.GetFileNameWithoutExtension(bootConfigFileName); string bootFileExtension = Path.GetExtension(bootConfigFileName); - // A republish into an existing output leaves the previous fingerprinted copy beside the current one; - // pick the newest, as FindAndAssertDotnetFiles does, so assertions read the active boot config. string? fingerprintedBootJsonPath = Directory .EnumerateFiles(binFrameworkDir) - .Where(f => + .FirstOrDefault(f => { if (Path.GetExtension(f) != bootFileExtension) return false; @@ -577,10 +575,8 @@ public string GetBootConfigPath(string binFrameworkDir, string? bootConfigFileNa return false; return true; - }) - .OrderByDescending(File.GetLastWriteTimeUtc) - .FirstOrDefault(); - + }); + if (fingerprintedBootJsonPath == null) throw new XunitException($"Could not find boot config '{bootConfigFileName}' with fingerprint in '{binFrameworkDir}'"); diff --git a/src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs b/src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs index c7185f6dd7175a..84f705655670cd 100644 --- a/src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs +++ b/src/mono/wasm/Wasm.Build.Tests/ReadyToRunTests.cs @@ -270,6 +270,7 @@ private void AssertPerAppCrossgenRan(Configuration config, bool expected) // 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; @@ -313,16 +314,19 @@ private static void AssertCoreLibReadyToRun(string frameworkDir, bool expectRead // 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. + // 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); }