From c8e2c44727b167de1185af6945020049f9411e7c Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Thu, 27 Aug 2026 20:37:25 -0500 Subject: [PATCH 01/17] [wasm] Emit self-installing R2R images from crossgen2 A wasm R2R image previously emitted its webcil payload and R2R function table as passive segments and imported seven things under short names, so the WASI splice pipeline had to rewrite the segments and rename the imports after linking. Emit both correctly in the first place. An image that carries code - a composite or a single-assembly R2R image - now emits the payload as an active data segment at (global.get __memory_base) and the function table as an active element segment at (global.get __table_base), so the engine installs both at instantiation. Such an image exports patchWebcilHeader in place of getWebcilPayload/fillWebcilTable: memory.init and table.init against an active segment trap, because an active segment is implicitly dropped once applied, and the only remaining work is the header's tableBase field, which the runtime reads from the mapped image. Per-assembly component forwarding stubs keep the passive shape. A stub may be parsed from its file rather than instantiated, and the WASI host locates its payload by passive data segment index. Import names now match what wasm-ld exports, collapsing the R2R/NativeAOT split in WasmWellKnownGlobalSymbolNode rather than adding a second one, so a merged image resolves by name with no renaming step. The webcilCount segment stays passive in both shapes: the host loader reads it out of the module bytes before instantiation to size its allocation. Both JS loaders feature-detect the two shapes rather than assuming one. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/design/mono/webcil.md | 62 ++++++++++-- src/coreclr/hosts/corerun/wasm/libCorerun.js | 29 ++++-- .../WasmWellKnownGlobalSymbolNode.cs | 15 +-- .../Compiler/ObjectWriter/Wasm/WasmSection.cs | 43 +++++++- .../Compiler/ObjectWriter/WasmObjectWriter.cs | 4 +- .../ObjectWriter/WebCilObjectWriter.cs | 97 +++++++++++++++++-- .../TestCasesRunner/WasmR2RAssert.cs | 12 +-- .../libs/Common/JavaScript/host/assets.ts | 40 +++++--- 8 files changed, 241 insertions(+), 61 deletions(-) diff --git a/docs/design/mono/webcil.md b/docs/design/mono/webcil.md index afaf95689bd6e1..275325ecec37d5 100644 --- a/docs/design/mono/webcil.md +++ b/docs/design/mono/webcil.md @@ -80,10 +80,23 @@ If data segment 0 is at least 8 bytes in size, and the second 4 bytes has a non- little-endian unsigned 32-bit integer, then data segment 0 encodes two little-endian u32 values: `payloadSize` (first 4 bytes) and `tableSize` (second 4 bytes). In this case, `tableSize` shall be the number of table entries required for the WebAssembly -module to be loaded, and the module shall import a table, as well as `stackPointer`, `tableBase`, and -`imageBase` globals. There shall also be a `fillWebcilTable` function which will initialize the table -with appropriate values. The `getWebcilPayload` API shall be enhanced to fill in the `TableBase` field -of the `WebcilHeader`. +module to be loaded, and the module shall import a table, as well as `__stack_pointer`, `__table_base`, and +`__memory_base` globals. Two module shapes are permitted, distinguished by whether the payload and +table segments are passive or active. + +A **host-installed** module keeps both segments passive. It shall provide a `fillWebcilTable` +function which initializes the table, and its `getWebcilPayload` API shall copy the payload and fill +in the `TableBase` field of the `WebcilHeader`. Per-assembly component forwarding stubs use this +shape, because a stub may be parsed from its file rather than instantiated. + +A **self-installing** module emits the payload as an active data segment at `(global.get __memory_base)` +and the table as an active element segment at `(global.get __table_base)`, so the engine installs both +at instantiation. Such a module exports neither `getWebcilPayload` nor `fillWebcilTable` - calling +`memory.init` or `table.init` against an active segment traps, because an active segment is implicitly +dropped once applied. It shall instead export `patchWebcilHeader`, which fills in the `TableBase` field; +the host must call it after instantiation, since the runtime reads that field from the mapped image and +an unwritten field reads as 0, silently shifting every function index by `tableBase`. Composite and +single-assembly R2R images use this shape. The memory of the WebcilPayload must also be allocated with 16 byte alignment. @@ -94,14 +107,15 @@ reachable. Function names shall instead be carried in the `name` custom section, engines, counts towards no limit, and may be stripped when size matters. ``` wat +;; Host-installed shape (passive segments). (module (data "\0f\00\00\00\01\00\00\00") ;; data segment 0: two little-endian u32 values (payloadSize, tableSize). This specifies a Webcil payload of size 15 bytes with 1 required table entry (data "webcil Payload\cc") ;; data segment 1: Webcil payload (import "webcil" "memory" (memory (;0;) 1)) - (import "webcil" "stackPointer" (global (;0;) (mut i32))) - (import "webcil" "imageBase" (global (;1;) i32)) - (import "webcil" "tableBase" (global (;2;) i32)) - (import "webcil" "table" (table (;0;) 1 funcref)) + (import "webcil" "__stack_pointer" (global (;0;) (mut i32))) + (import "webcil" "__memory_base" (global (;1;) i32)) + (import "webcil" "__table_base" (global (;2;) i32)) + (import "webcil" "__indirect_function_table" (table (;0;) 1 funcref)) (global (export "webcilVersion") i32 (i32.const 1)) (func (export "getWebcilSize") (param $destPtr i32) (result) local.get $destPtr @@ -134,6 +148,38 @@ engines, counts towards no limit, and may be stripped when size matters. (elem (;0;) func 3)) ``` +``` wat +;; Self-installing shape (active segments). The engine applies both segments at +;; instantiation, so only the header's TableBase field is left for the host to trigger. +(module + (data "\0f\00\00\00\01\00\00\00") ;; data segment 0: payloadSize, tableSize - stays passive, read from the file before instantiation + (data (global.get 1) "webcil Payload\cc") ;; data segment 1: Webcil payload, active at __memory_base + (import "webcil" "memory" (memory (;0;) 1)) + (import "webcil" "__stack_pointer" (global (;0;) (mut i32))) + (import "webcil" "__memory_base" (global (;1;) i32)) + (import "webcil" "__table_base" (global (;2;) i32)) + (import "webcil" "__indirect_function_table" (table (;0;) 1 funcref)) + (global (export "webcilVersion") i32 (i32.const 1)) + (func (export "getWebcilSize") (param $destPtr i32) (result) + local.get $destPtr + i32.const 0 + i32.const 4 + memory.init 0) + (func (export "patchWebcilHeader") (param $d i32) (param $n i32) (result) + local.get 1 + i32.const 32 + i32.ge_s + if + local.get 0 + global.get 2 + i32.store offset=28 + end + ) + (func (param $d i32) (result i32) + local.get 0) + (elem (;0;) (global.get 2) func 2)) ;; active at __table_base +``` + (**Rationale**: With this approach it is possible to identify without loading the webcil module exactly the allocations/table growth/globals which are needed to load the webcil module via instantiateStreaming without actually loading the module.) diff --git a/src/coreclr/hosts/corerun/wasm/libCorerun.js b/src/coreclr/hosts/corerun/wasm/libCorerun.js index 4d0d4f80a275e8..4227cad47d2112 100644 --- a/src/coreclr/hosts/corerun/wasm/libCorerun.js +++ b/src/coreclr/hosts/corerun/wasm/libCorerun.js @@ -245,13 +245,13 @@ function libCoreRunFactory() { wasmInstance = new WebAssembly.Instance(wasmModule, { webcil: { memory: wasmMemory, - stackPointer: wasmExports.__stack_pointer, - rtlRestoreContextTag: wasmExports.__coreclr_wasm_rtlrestorecontext_tag, - table: wasmTable, - tableBase: new WebAssembly.Global({ value: "i32", mutable: false }, tableStartIndex), - imageBase: new WebAssembly.Global({ value: "i32", mutable: false }, payloadPtr), + __stack_pointer: wasmExports.__stack_pointer, + __coreclr_wasm_rtlrestorecontext_tag: wasmExports.__coreclr_wasm_rtlrestorecontext_tag, + __indirect_function_table: wasmTable, + __table_base: new WebAssembly.Global({ value: "i32", mutable: false }, tableStartIndex), + __memory_base: new WebAssembly.Global({ value: "i32", mutable: false }, payloadPtr), // Runtime-async continuation return value, shared with the runtime module. - asyncContinuation: wasmExports.__async_continuation + __async_continuation: wasmExports.__async_continuation } }); } catch (e) { @@ -267,10 +267,21 @@ function libCoreRunFactory() { throw new Error(`Unsupported Webcil version: ${webcilVersion}`); } - wasmInstance.exports.getWebcilPayload(payloadPtr, payloadSize); - if (tableSize > 0) { - wasmInstance.exports.fillWebcilTable(); + // Two image shapes reach this point. A component stub carries its payload and table in + // passive segments and hands them over via getWebcilPayload/fillWebcilTable. A composite + // uses active segments, so the engine has already installed both by the time the instance + // exists, and only the header's tableBase field is left to write. Feature-detect rather + // than assume: calling getWebcilPayload on a composite would trap, because memory.init + // against an active (hence dropped) segment is out of bounds. + if (typeof (wasmInstance.exports.patchWebcilHeader) === "function") { + wasmInstance.exports.patchWebcilHeader(payloadPtr, payloadSize); + } else { + wasmInstance.exports.getWebcilPayload(payloadPtr, payloadSize); + if (tableSize > 0) { + wasmInstance.exports.fillWebcilTable(); + } } + HEAPU32[outDataStartPtr >>> 2 >>> 0] = payloadPtr; HEAPU32[outSize >>> 2 >>> 0] = payloadSize; HEAPU32[(outSize + 4) >>> 2 >>> 0] = 0; diff --git a/src/coreclr/tools/Common/Compiler/DependencyAnalysis/WasmWellKnownGlobalSymbolNode.cs b/src/coreclr/tools/Common/Compiler/DependencyAnalysis/WasmWellKnownGlobalSymbolNode.cs index 7d0560a9c96302..087fafb15c8ca5 100644 --- a/src/coreclr/tools/Common/Compiler/DependencyAnalysis/WasmWellKnownGlobalSymbolNode.cs +++ b/src/coreclr/tools/Common/Compiler/DependencyAnalysis/WasmWellKnownGlobalSymbolNode.cs @@ -11,19 +11,20 @@ namespace ILCompiler.DependencyAnalysis /// crossgen2/R2R resolves it back to the fixed index defined in the WebCIL format, while a relocatable /// NativeAOT object emits it as an undefined imported global for wasm-ld to resolve. /// + /// + /// The names deliberately match what wasm-ld uses, so that a composite's imports resolve + /// against the host's exports by name and wasm-merge needs no renaming step. + /// __stack_pointer and __indirect_function_table come from the linker directly; + /// __memory_base and __table_base are wasm-ld's PIC names for exactly these two + /// quantities (where a module's data and table slice begin) and must be defined and exported by + /// the host, since a non-PIC main module does not produce them on its own. + /// public class WasmWellKnownGlobalSymbolNode(string symbolName) : ExternDataSymbolNode(new Utf8String(symbolName)) { -#if READYTORUN - public const string StackPointerName = "stackPointer"; - public const string ImageBaseName = "imageBase"; - public const string TableBaseName = "tableBase"; - public const string AsyncContinuationName = "asyncContinuation"; -#else public const string StackPointerName = "__stack_pointer"; public const string ImageBaseName = "__memory_base"; public const string TableBaseName = "__table_base"; public const string AsyncContinuationName = "__async_continuation"; -#endif public override int ClassCode => 0x79046cf9; diff --git a/src/coreclr/tools/Common/Compiler/ObjectWriter/Wasm/WasmSection.cs b/src/coreclr/tools/Common/Compiler/ObjectWriter/Wasm/WasmSection.cs index 404cb38b6bfc4c..0b6082a455a4b6 100644 --- a/src/coreclr/tools/Common/Compiler/ObjectWriter/Wasm/WasmSection.cs +++ b/src/coreclr/tools/Common/Compiler/ObjectWriter/Wasm/WasmSection.cs @@ -5,6 +5,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.IO; +using ILCompiler.ObjectWriter.WasmInstructions; using Internal.Text; namespace ILCompiler.ObjectWriter @@ -259,19 +260,51 @@ protected override void WriteEntryCore(SectionWriter writer, WasmExport entry) } } - internal sealed class WasmElementSection : WasmSection> + /// + /// A funcref element segment targeting table 0. + /// + /// + /// When is the segment is emitted as passive + /// (flag 1), which requires a table.init to install it. Otherwise it is emitted as + /// active (flag 0) with as its offset constant expression, and the + /// engine installs it at instantiation. + /// + internal readonly struct WasmElementSegment + { + public ReadOnlyMemory FunctionIndices { get; } + public WasmInstructionGroup OffsetExpr { get; } + + public WasmElementSegment(ReadOnlyMemory functionIndices, WasmInstructionGroup offsetExpr = null) + { + FunctionIndices = functionIndices; + OffsetExpr = offsetExpr; + } + } + + internal sealed class WasmElementSection : WasmSection { public WasmElementSection(Stream stream, Utf8String name, int sectionIndex) : base(WasmSectionType.Element, stream, name, sectionIndex) { } - protected override void WriteEntryCore(SectionWriter writer, ReadOnlyMemory entry) + protected override void WriteEntryCore(SectionWriter writer, WasmElementSegment entry) { - ReadOnlySpan functionIndices = entry.Span; + ReadOnlySpan functionIndices = entry.FunctionIndices.Span; + + if (entry.OffsetExpr is not null) + { + // Active element segment, table 0. Flag 0 implies both the table index and the + // funcref element type, so no element-type byte follows. + writer.WriteByte(0); + WriteEncodable(writer, entry.OffsetExpr); + } + else + { + writer.WriteByte(1); // Passive element segment + writer.WriteByte(0); // element type: ref func + } - writer.WriteByte(1); // Passive element segment - writer.WriteByte(0); // element type: ref func writer.WriteULEB128((ulong)functionIndices.Length); foreach (int functionIndex in functionIndices) { diff --git a/src/coreclr/tools/Common/Compiler/ObjectWriter/WasmObjectWriter.cs b/src/coreclr/tools/Common/Compiler/ObjectWriter/WasmObjectWriter.cs index 2d9bb6a8f946d2..4ef47fc9fb96bb 100644 --- a/src/coreclr/tools/Common/Compiler/ObjectWriter/WasmObjectWriter.cs +++ b/src/coreclr/tools/Common/Compiler/ObjectWriter/WasmObjectWriter.cs @@ -264,12 +264,12 @@ private protected void WriteMemoryExport(string name, int memoryIndex) => private protected void WriteGlobalExport(string name, int globalIndex) => WriteExport(name, WasmExportKind.Global, globalIndex); - private protected void WriteElementSegment(ReadOnlyMemory functionIndices) + private protected void WriteElementSegment(ReadOnlyMemory functionIndices, WasmInstructionGroup offsetExpr = null) { WasmElementSection section = GetOrCreateSection( WasmObjectNodeSection.ElementSection, out SectionWriter writer); - section.WriteEntry(writer, functionIndices); + section.WriteEntry(writer, new WasmElementSegment(functionIndices, offsetExpr)); } private protected SectionDataEmitter GetOrCreateSection( diff --git a/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.cs b/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.cs index 0618bf3c5aa80f..87a0f2bc1c3e89 100644 --- a/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.cs +++ b/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.cs @@ -170,6 +170,30 @@ public int GetFlatMappedSize() ] ); + /// + /// Counterpart of for a self-installing image. Its payload lives in an + /// active data segment, so the engine has already copied it to imageBase and a + /// memory.init would in fact trap: an active segment is implicitly dropped at + /// instantiation. All that remains is the header's tableBase field, which the runtime + /// reads from the mapped image rather than from a Wasm global + /// (WebcilDecoder::GetTableBaseOffset), and which returns 0 when unwritten - silently + /// shifting every R2R function index by tableBase. Both hosts must call this after + /// instantiation. + /// + static WasmFunctionBody PatchWebcilHeader = new WasmFunctionBody( + new WasmFuncType(new([WasmValueType.I32, WasmValueType.I32]), new([])), // (func ($d i32) ($n i32)) + [ + Local.Get(1), + I32.Const(32), + I32.Ge_s, + Block.If(WasmBlockType.Empty), + Local.Get(0), // (local.get $d) + Global.Get(WebCilObjectWriter.TableBaseGlobalIndex), // (global.get $tableBase) + I32.Store((ulong)WebcilEncoder.TableBaseOffset), // i32.store offset=TableBaseOffset + Block.End + ] + ); + private long ResolveSymbolRVA(WebcilSection[] sections, SymbolDefinition definition) { for (int i = 0; i < sections.Length; i++) @@ -285,13 +309,48 @@ private void WriteDataCountSection() writer.WriteULEB128(NumDataSegments); // number of data segments } + /// + /// Whether this image installs its own payload and function table via active segments. + /// + /// + /// True for any image that carries code - a composite or a single-assembly R2R image - since + /// the host instantiates those and the engine can apply the segments. False for a per-assembly + /// component forwarding stub, which must keep its payload passive: on WASI a stub is never + /// instantiated, it is parsed as a file by WasiExtractStubPayload, which locates the + /// payload by passive data segment index. + /// + private bool IsSelfInstallingImage => !_nodeFactory.OptimizationFlags.IsComponentModule; + + /// Offset constant expression placing the payload segment at the host-supplied image base. + private static WasmInstructionGroup ImageBaseOffsetExpr => + new WasmInstructionGroup([Global.Get(ImageBaseGlobalIndex)]); + + /// Offset constant expression placing the function table slice at the host-supplied table base. + private static WasmInstructionGroup TableBaseOffsetExpr => + new WasmInstructionGroup([Global.Get(TableBaseGlobalIndex)]); + private WebcilSegment _webcilSegment = null; private protected override void EmitSectionsAndLayout() { - int totalMethodCount = MethodCount + 3; + // The stub set is image-kind dependent. A self-installing image installs both its payload and its + // function table via active segments, so it needs neither getWebcilPayload (whose + // memory.init would trap against a dropped active segment) nor fillWebcilTable (whose + // work the engine has already done); it needs only patchWebcilHeader, because the + // header's tableBase field is read from linear memory and cannot come from a segment. + // A component stub keeps all three: it is passive throughout and is installed by the host. + int stubCount = IsSelfInstallingImage ? 2 : 3; + int totalMethodCount = MethodCount + stubCount; InsertWasmStub(new Utf8String("getWebcilSize"), GetWebcilSize); - InsertWasmStub(new Utf8String("getWebcilPayload"), GetWebcilPayload); - InsertWasmStub(new Utf8String("fillWebcilTable"), FillWebcilTable(totalMethodCount)); + if (IsSelfInstallingImage) + { + InsertWasmStub(new Utf8String("patchWebcilHeader"), PatchWebcilHeader); + } + else + { + InsertWasmStub(new Utf8String("getWebcilPayload"), GetWebcilPayload); + InsertWasmStub(new Utf8String("fillWebcilTable"), FillWebcilTable(totalMethodCount)); + } + Debug.Assert(MethodCount == totalMethodCount); WriteDataCountSection(); @@ -446,7 +505,9 @@ private protected override void EmitObjectFile(Stream outputFileStream) } Debug.Assert(webcilStream.Position == _webcilSegment.GetFlatMappedSize(), $"Total Size Mismatch: {webcilStream.Position} != {_webcilSegment.GetFlatMappedSize()}"); - // Create passive data segment for encoding the size of the webcil payload (size must fit in 32-bit uint) + // Passive data segment for the payload size; this is metadata for the host loader, which + // reads it straight out of the module bytes before instantiation in order to size its + // allocation, so it must stay passive in both image shapes. byte[] lengthBuffer = new byte[sizeof(uint) * 2]; BinaryPrimitives.WriteUInt32LittleEndian(lengthBuffer, (uint)_webcilSegment.GetFlatMappedSize()); BinaryPrimitives.WriteUInt32LittleEndian(lengthBuffer.AsSpan().Slice(4), (uint)MethodCount); @@ -454,9 +515,14 @@ private protected override void EmitObjectFile(Stream outputFileStream) WasmDataSegment webcilSizeSegment = new WasmDataSegment(webcilSizeSegmentStream, new Utf8String("webcilCount"), WasmDataSegmentType.Passive, null); - // Passive data segment for webcil payload contents - WasmDataSegment webcilContentsSegment = new WasmDataSegment(webcilStream, new Utf8String("webcilPayload"), - WasmDataSegmentType.Passive, null); + // Data segment for the webcil payload contents. A self-installing image emits it as active at the + // host-supplied image base so the engine installs it at instantiation; a component stub + // keeps it passive because it is extracted from the file rather than instantiated. + WasmDataSegment webcilContentsSegment = IsSelfInstallingImage + ? new WasmDataSegment(webcilStream, new Utf8String("webcilPayload"), + WasmDataSegmentType.Active, ImageBaseOffsetExpr) + : new WasmDataSegment(webcilStream, new Utf8String("webcilPayload"), + WasmDataSegmentType.Passive, null); // Create combined data section and emit WasmDataSection dataSection = new WasmDataSection([webcilSizeSegment, webcilContentsSegment], new Utf8String("data"), contentAlign: 4); @@ -952,7 +1018,14 @@ private void ResolveRelocations(int sectionIndex, Stream sectionStream, MemorySt #nullable disable public const int RtlRestoreContextTagIndex = 0; - private static readonly Utf8String RtlRestoreContextTagName = new("rtlRestoreContextTag"); + + /// + /// Import field names for the table and tag. These match the corresponding wasm-ld + /// exports (--export-table emits __indirect_function_table), so that a + /// composite merged into the host resolves by name with no renaming step. + /// + private const string IndirectFunctionTableName = "__indirect_function_table"; + private static readonly Utf8String RtlRestoreContextTagName = new("__coreclr_wasm_rtlrestorecontext_tag"); private static readonly WasmFuncType RtlRestoreContextTagSignature = new( new([]), @@ -973,7 +1046,7 @@ private WasmImport[] CreateDefaultGlobalImports() new WasmImport("webcil", WasmWellKnownGlobalSymbolNode.ImageBaseName, import: new WasmGlobalImportType(WasmValueType.I32, WasmMutabilityType.Const), index: ImageBaseGlobalIndex), new WasmImport("webcil", WasmWellKnownGlobalSymbolNode.TableBaseName, import: new WasmGlobalImportType(WasmValueType.I32, WasmMutabilityType.Const), index: TableBaseGlobalIndex), new WasmImport("webcil", WasmWellKnownGlobalSymbolNode.AsyncContinuationName, import: new WasmGlobalImportType(WasmValueType.I32, WasmMutabilityType.Mut), index: AsyncContinuationGlobalIndex), - new WasmImport("webcil", "table", import: new WasmTableImportType(), index: 0), + new WasmImport("webcil", IndirectFunctionTableName, import: new WasmTableImportType(), index: 0), new WasmImport("webcil", RtlRestoreContextTagName.ToString(), import: new WasmTagImportType(rtlRestoreContextTagTypeIndex), index: RtlRestoreContextTagIndex), ]; } @@ -1023,7 +1096,13 @@ private protected override void WriteElements() .Select(symbol => symbol.Index) .ToArray(); +#if READYTORUN + // A self-installing image installs its table slice via an active segment at the host-supplied table + // base. A component stub stays passive; it has no table slice of its own to install. + WriteElementSegment(functionIndices, IsSelfInstallingImage ? TableBaseOffsetExpr : null); +#else WriteElementSegment(functionIndices); +#endif } } } diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCasesRunner/WasmR2RAssert.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCasesRunner/WasmR2RAssert.cs index 5f913584862f72..cf92d76de963fe 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCasesRunner/WasmR2RAssert.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCasesRunner/WasmR2RAssert.cs @@ -65,13 +65,13 @@ public static bool WasmIndexSpacesHaveExpectedEntries(WebcilImageReader reader, Dictionary<(string Module, string Name), WasmImportIndex> imports = ReadWasmImports(reader); (string Name, WasmImportKind Kind, uint Index)[] expectedImports = [ - ("stackPointer", WasmImportKind.Global, 0), - ("imageBase", WasmImportKind.Global, 1), - ("tableBase", WasmImportKind.Global, 2), - ("asyncContinuation", WasmImportKind.Global, 3), - ("table", WasmImportKind.Table, 0), + ("__stack_pointer", WasmImportKind.Global, 0), + ("__memory_base", WasmImportKind.Global, 1), + ("__table_base", WasmImportKind.Global, 2), + ("__async_continuation", WasmImportKind.Global, 3), + ("__indirect_function_table", WasmImportKind.Table, 0), ("memory", WasmImportKind.Memory, 0), - ("rtlRestoreContextTag", WasmImportKind.Tag, 0), + ("__coreclr_wasm_rtlrestorecontext_tag", WasmImportKind.Tag, 0), ]; var failures = new List(); diff --git a/src/native/libs/Common/JavaScript/host/assets.ts b/src/native/libs/Common/JavaScript/host/assets.ts index e70315b5f6332c..ce7c1648b40a4f 100644 --- a/src/native/libs/Common/JavaScript/host/assets.ts +++ b/src/native/libs/Common/JavaScript/host/assets.ts @@ -105,10 +105,10 @@ function allocWebcilPayload(payloadSize: number): number { // Builds the `webcil` import object. For R2R images (tableSize > 0) the module imports the runtime's // stack pointer, exception tag, indirect-call table and base globals; this also grows the table. -// These import names and the webcilVersion/getWebcilPayload/fillWebcilTable handshake in -// finishWebcilInstance are the R2R Webcil-in-Wasm host ABI defined by crossgen's WasmObjectWriter -// (src/coreclr/tools/Common/Compiler/ObjectWriter/WasmObjectWriter.cs, CreateDefaultGlobalImports/ -// WriteExports). Keep in sync with the corerun host +// These import names and the webcilVersion/getWebcilPayload/fillWebcilTable/patchWebcilHeader +// handshake in finishWebcilInstance are the R2R Webcil-in-Wasm host ABI defined by crossgen's +// WasmObjectWriter (src/coreclr/tools/Common/Compiler/ObjectWriter/WasmObjectWriter.cs, +// CreateDefaultGlobalImports/WriteExports). Keep in sync with the corerun host // (src/coreclr/hosts/corerun/wasm/libCorerun.js, BrowserHost_ExternalAssemblyProbe). Unlike corerun, // which parses data segment 0 for payloadSize/tableSize, this loader receives them from boot config. function buildWebcilImports(memory: WebAssembly.Memory, payloadPtr: number, tableSize: number): Record { @@ -128,12 +128,12 @@ function buildWebcilImports(memory: WebAssembly.Memory, payloadPtr: number, tabl } const tableStartIndex = _ems_.wasmTable.length; _ems_.wasmTable.grow(tableSize); - webcilImports.stackPointer = stackPointer; - webcilImports.rtlRestoreContextTag = rtlRestoreContextTag as unknown as WebAssembly.ImportValue; - webcilImports.asyncContinuation = asyncContinuation as unknown as WebAssembly.ImportValue; - webcilImports.table = _ems_.wasmTable; - webcilImports.tableBase = new WebAssembly.Global({ value: "i32", mutable: false }, tableStartIndex); - webcilImports.imageBase = new WebAssembly.Global({ value: "i32", mutable: false }, payloadPtr); + webcilImports.__stack_pointer = stackPointer; + webcilImports.__coreclr_wasm_rtlrestorecontext_tag = rtlRestoreContextTag as unknown as WebAssembly.ImportValue; + webcilImports.__async_continuation = asyncContinuation as unknown as WebAssembly.ImportValue; + webcilImports.__indirect_function_table = _ems_.wasmTable; + webcilImports.__table_base = new WebAssembly.Global({ value: "i32", mutable: false }, tableStartIndex); + webcilImports.__memory_base = new WebAssembly.Global({ value: "i32", mutable: false }, payloadPtr); } return webcilImports; } @@ -146,11 +146,21 @@ function finishWebcilInstance(instance: WebAssembly.Instance, payloadPtr: number throw new Error(`Unsupported Webcil version: ${webcilVersion}`); } - const getWebcilPayload = instance.exports.getWebcilPayload as (ptr: number, size: number) => void; - getWebcilPayload(payloadPtr, payloadSize); - if (tableSize > 0) { - const fillWebcilTable = instance.exports.fillWebcilTable as () => void; - fillWebcilTable(); + // Two image shapes reach this point. A component stub carries its payload and table in passive + // segments and hands them over via getWebcilPayload/fillWebcilTable. A composite uses active + // segments, so the engine installed both at instantiation and only the header's tableBase field + // is left to write. Feature-detect rather than assume: getWebcilPayload on a composite would + // trap, because memory.init against an active (hence dropped) segment is out of bounds. + const patchWebcilHeader = instance.exports.patchWebcilHeader as ((ptr: number, size: number) => void) | undefined; + if (typeof patchWebcilHeader === "function") { + patchWebcilHeader(payloadPtr, payloadSize); + } else { + const getWebcilPayload = instance.exports.getWebcilPayload as (ptr: number, size: number) => void; + getWebcilPayload(payloadPtr, payloadSize); + if (tableSize > 0) { + const fillWebcilTable = instance.exports.fillWebcilTable as () => void; + fillWebcilTable(); + } } const name = virtualPath.startsWith(browserVirtualAppBase) From c783b7287a3f7ae6f87e4b2bf360bcbb71d653d4 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Thu, 27 Aug 2026 20:43:34 -0500 Subject: [PATCH 02/17] [wasm] Let WebcilImageReader find an active payload segment TryFindWebcilInWasm only inspected passive data segments, so it could not locate the payload of a self-installing image and reported "Unknown file format". That broke r2rdump and the WasmWebcilModule test, both of which read single-assembly R2R wasm images through this reader. Inspect every data segment for the Webcil magic regardless of kind, skipping an active segment's offset expression first. ParseElemSection already handled both forms, so only the data side needed it. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../ObjectWriter/WebCilObjectWriter.cs | 4 -- .../WebcilImageReader.cs | 50 +++++++++---------- 2 files changed, 23 insertions(+), 31 deletions(-) diff --git a/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.cs b/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.cs index 87a0f2bc1c3e89..a4fc6d9bebdd92 100644 --- a/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.cs +++ b/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.cs @@ -1096,13 +1096,9 @@ private protected override void WriteElements() .Select(symbol => symbol.Index) .ToArray(); -#if READYTORUN // A self-installing image installs its table slice via an active segment at the host-supplied table // base. A component stub stays passive; it has no table slice of its own to install. WriteElementSegment(functionIndices, IsSelfInstallingImage ? TableBaseOffsetExpr : null); -#else - WriteElementSegment(functionIndices); -#endif } } } diff --git a/src/coreclr/tools/aot/ILCompiler.Reflection.ReadyToRun/WebcilImageReader.cs b/src/coreclr/tools/aot/ILCompiler.Reflection.ReadyToRun/WebcilImageReader.cs index e1d68123809015..4bab4b0ed85649 100644 --- a/src/coreclr/tools/aot/ILCompiler.Reflection.ReadyToRun/WebcilImageReader.cs +++ b/src/coreclr/tools/aot/ILCompiler.Reflection.ReadyToRun/WebcilImageReader.cs @@ -735,7 +735,9 @@ private static bool TryFindWebcilInWasm(byte[] image, out long webcilOffset) webcilOffset = 0; // Parse WASM module structure to find the data section (id=11) - // which contains the Webcil payload as a passive data segment. + // which contains the Webcil payload. The payload segment is passive in a component + // forwarding stub and active in a self-installing image (one that carries code and + // installs itself at instantiation), so both kinds have to be inspected. int offset = 8; // Skip WASM magic + version while (offset < image.Length) { @@ -752,45 +754,39 @@ private static bool TryFindWebcilInWasm(byte[] image, out long webcilOffset) if (sectionId == 11) // Data section { // Data section contains: count(LEB128) then count segments. - // Each passive segment: kind=1(byte) + size(LEB128) + bytes - // The Webcil payload is in the second passive data segment. + // Passive segment: kind=1(byte) + size(LEB128) + bytes + // Active segment: kind=0(byte) [+ memidx if kind=2] + offset expr + size + bytes uint segmentCount = ReadLebU32(image, ref offset); for (uint i = 0; i < segmentCount && offset < sectionEnd; i++) { byte kind = image[offset++]; - if (kind == 1) // Passive segment + if (kind == 2) // Active segment with an explicit memory index { - uint dataSize = ReadLebU32(image, ref offset); - // Check if this segment starts with the Webcil magic - if (dataSize >= 4 && offset + dataSize <= image.Length) - { - uint magic = BinaryPrimitives.ReadUInt32LittleEndian(image.AsSpan(offset)); - if (magic == WebcilConstants.WEBCIL_MAGIC && TryReadHeader(image, offset, out _)) - { - webcilOffset = offset; - return true; - } - } - offset += (int)dataSize; + ReadLebU32(image, ref offset); // memory index } - else if (kind == 0) // Active segment (memory 0) + else if (kind is not (0 or 1)) { - // Skip the init expression + data - SkipConstExpr(image, ref offset); - uint dataSize = ReadLebU32(image, ref offset); - offset += (int)dataSize; + return false; // Unknown segment kind } - else if (kind == 2) // Active segment (explicit memory index) + + if (kind != 1) // Active segments carry an offset constant expression { - ReadLebU32(image, ref offset); // memory index SkipConstExpr(image, ref offset); - uint dataSize = ReadLebU32(image, ref offset); - offset += (int)dataSize; } - else + + uint dataSize = ReadLebU32(image, ref offset); + // Check if this segment starts with the Webcil magic + if (dataSize >= 4 && offset + dataSize <= image.Length) { - return false; // Unknown segment kind + uint magic = BinaryPrimitives.ReadUInt32LittleEndian(image.AsSpan(offset)); + if (magic == WebcilConstants.WEBCIL_MAGIC && TryReadHeader(image, offset, out _)) + { + webcilOffset = offset; + return true; + } } + + offset += (int)dataSize; } return false; } From 919c307fd02299f1238c167ea8f8bd34f22602a8 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Thu, 27 Aug 2026 21:07:25 -0500 Subject: [PATCH 03/17] [wasm] Note the dynamic-linking ABI caveat on the base global names __memory_base and __table_base belong to the emscripten/wasm-ld dynamic linking ABI, where they carry a per-side-module meaning. Reusing them for the R2R image and table bases is correct only while the host is a non-PIC main module; -sMAIN_MODULE would give the linker its own definitions and collide. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../DependencyAnalysis/WasmWellKnownGlobalSymbolNode.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/coreclr/tools/Common/Compiler/DependencyAnalysis/WasmWellKnownGlobalSymbolNode.cs b/src/coreclr/tools/Common/Compiler/DependencyAnalysis/WasmWellKnownGlobalSymbolNode.cs index 087fafb15c8ca5..1e4ccf07a5dc8a 100644 --- a/src/coreclr/tools/Common/Compiler/DependencyAnalysis/WasmWellKnownGlobalSymbolNode.cs +++ b/src/coreclr/tools/Common/Compiler/DependencyAnalysis/WasmWellKnownGlobalSymbolNode.cs @@ -18,6 +18,12 @@ namespace ILCompiler.DependencyAnalysis /// __memory_base and __table_base are wasm-ld's PIC names for exactly these two /// quantities (where a module's data and table slice begin) and must be defined and exported by /// the host, since a non-PIC main module does not produce them on its own. + /// + /// Those last two belong to the emscripten/wasm-ld dynamic linking ABI, where they carry a + /// per-side-module meaning. Reusing them is safe only while the host is a non-PIC main module. + /// Building the host with -sMAIN_MODULE would give the linker its own definitions of both + /// and collide with these; that would require picking runtime-specific names instead. + /// /// public class WasmWellKnownGlobalSymbolNode(string symbolName) : ExternDataSymbolNode(new Utf8String(symbolName)) { From 8ad195163507477c7f28567f5eb0c2279b57d258 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Thu, 27 Aug 2026 21:17:21 -0500 Subject: [PATCH 04/17] [wasm] Document how a host supplies the R2R image and table bases A self-installing image leaves __memory_base and __table_base as imports, and the two ways a host can satisfy them are not equivalent. Supplying them at instantiation keeps the segment offsets a global.get of an imported global, which is a valid constant expression and needs no further processing. Defining and exporting them, then merging the image into the host, internalizes the globals; global.get of a defined global is only a constant expression under the GC proposal, engines disagree, and the merged module has to have its offsets folded to i32.const to be portable. Record that the fold is not free, since the pass that performs it also propagates globals into function bodies, and that a host reserving the table slice at link time can treat __table_base as the constant 1 while __memory_base must be read out of the linked host. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/design/mono/webcil.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/design/mono/webcil.md b/docs/design/mono/webcil.md index 275325ecec37d5..d2d43bc63ab003 100644 --- a/docs/design/mono/webcil.md +++ b/docs/design/mono/webcil.md @@ -98,6 +98,24 @@ the host must call it after instantiation, since the runtime reads that field fr an unwritten field reads as 0, silently shifting every function index by `tableBase`. Composite and single-assembly R2R images use this shape. +A self-installing module leaves `__memory_base` and `__table_base` as imports, which a host may satisfy +in either of two ways, with different consequences: + +- **Supply them at instantiation**, as immutable `WebAssembly.Global` values. The segment offsets stay + `global.get` of an *imported* global, which is a valid constant expression, so the module needs no + further processing. The browser host does this. +- **Define and export them, then link the module into the host** with a merge tool. Merging internalizes + the globals, and `global.get` of a *defined* global is not a constant expression outside the GC + proposal - engines disagree here, so the merged module must have its offsets folded to `i32.const` + before it is portable. The offline WASI pipeline does this. + +Neither approach requires rewriting the segments themselves; only the second requires a fold, and that +fold is not free, because the pass that performs it also propagates globals into function bodies. + +A host that reserves the composite's table slice at link time can treat `__table_base` as a constant: +reserving the first N slots leaves the composite at base 1 regardless of its size. `__memory_base` is +the address of the host's payload region and has to be read out of the linked host. + The memory of the WebcilPayload must also be allocated with 16 byte alignment. The module shall not export its compiled functions. Exports count towards the engine's From 0ca754c415a1628f7b52b6523cf1501aaa144003 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Thu, 27 Aug 2026 21:53:08 -0500 Subject: [PATCH 05/17] [wasm] Describe the passive-stub requirement without citing absent code The remark named WasiExtractStubPayload, which lives in an offline host that is not in this tree, so a reader cannot follow the reference. State the constraint itself instead: a stub may be parsed as a file rather than instantiated, and an active segment would defeat locating its payload by passive data segment index. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Common/Compiler/ObjectWriter/WebCilObjectWriter.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.cs b/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.cs index a4fc6d9bebdd92..c362d8a89e34a6 100644 --- a/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.cs +++ b/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.cs @@ -315,9 +315,9 @@ private void WriteDataCountSection() /// /// True for any image that carries code - a composite or a single-assembly R2R image - since /// the host instantiates those and the engine can apply the segments. False for a per-assembly - /// component forwarding stub, which must keep its payload passive: on WASI a stub is never - /// instantiated, it is parsed as a file by WasiExtractStubPayload, which locates the - /// payload by passive data segment index. + /// component forwarding stub, which must keep its payload passive: a stub is not necessarily + /// instantiated at all, and an offline host may instead parse it as a file and locate the + /// payload by passive data segment index, which an active segment would defeat. /// private bool IsSelfInstallingImage => !_nodeFactory.OptimizationFlags.IsComponentModule; From 69b15190aff96eeb07fd98d11691e7e70710cfbb Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Thu, 27 Aug 2026 22:26:38 -0500 Subject: [PATCH 06/17] [wasm][R2R] Load a composite R2R image in corerun on WASI, spliced with stock tooling WASI has no productised R2R path. The runtime can load a composite, but nothing delivers one: there is no dynamic code loading, so the composite's native code has to be merged into the host binary before it runs, and the host has to hand the runtime the payload from memory rather than from disk. Adds that path to corerun: - wasi_r2r_probe.hpp: an external-assembly probe that serves the composite from a baked buffer whose address the merge step targets, and each assembly's per-assembly stub from comp/.wasm on disk. - Link flags supplying five of the composite's seven imports directly. --table-base reserves the low table slots for the composite's element segment, which crossgen2 emits ACTIVE, so the table must already be large enough at instantiation -- a growable table does not help. Reserve by the composite's FUNCTION count. The table stays fixed-size so it still validates statically; measured 6298/6298 -> 71834/71834, +51 KB (0.14%) at 500,001 slots. - eng/wasi-r2r/pipeline-shim.sh: the splice, using only wasm-tools, wabt, binaryen and python3. The two imports the linker cannot supply are __memory_base and __table_base -- wasm-ld creates those only in PIC mode, and a wasm global initialized to a data symbol's address is not expressible from C -- so the script generates a six-line shim module exporting them and merges it as a third input. The probe also patches WebcilHeader_1.TableBase (payload offset 28). With the payload installed by the engine from an active segment, nothing else writes it, and its absence is silent rather than fatal: GetTableBaseOffset returns 0 rather than failing, and that 0 becomes tableBaseDelta, shifting every R2R function index. Verified end to end on a 4-assembly composite (52,617 functions, 3,097,056-byte payload), and the negative control is what makes it evidence rather than a green run: with the host's table base set to 2 while the shim installs at 1, the run fails with `wasm trap: indirect call type mismatch`; with 1 it runs and reports four assemblies R2R-active. A run in which R2R never dispatched would be unaffected by the table base, so that flip establishes managed code genuinely executing out of the merged image -- which the activation log alone cannot show. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b49a31b-9632-4a48-bab4-bfcc98487a5f --- eng/wasi-r2r/README.md | 207 +++++++++++++++++ eng/wasi-r2r/comp.rsp.template | 17 ++ eng/wasi-r2r/pipeline-shim.sh | 112 ++++++++++ src/coreclr/hosts/corerun/CMakeLists.txt | 23 +- src/coreclr/hosts/corerun/corerun.cpp | 12 + src/coreclr/hosts/corerun/wasi_r2r_probe.hpp | 223 +++++++++++++++++++ src/mono/wasi/build/WasiApp.CoreCLR.targets | 3 + src/native/corehost/wasihost/wasihost.cpp | 35 +++ 8 files changed, 631 insertions(+), 1 deletion(-) create mode 100644 eng/wasi-r2r/README.md create mode 100644 eng/wasi-r2r/comp.rsp.template create mode 100755 eng/wasi-r2r/pipeline-shim.sh create mode 100644 src/coreclr/hosts/corerun/wasi_r2r_probe.hpp diff --git a/eng/wasi-r2r/README.md b/eng/wasi-r2r/README.md new file mode 100644 index 00000000000000..23b35601bde5da --- /dev/null +++ b/eng/wasi-r2r/README.md @@ -0,0 +1,207 @@ +# WASI composite-R2R splice tooling + +Tooling for building, splicing, and running a **composite ReadyToRun image on CoreCLR/WASI**. +It exists because WASI has no productised R2R path: the working flow is hand-driven — run +`crossgen2` directly, splice the result into `corerun`, and run it under `wasmtime`. + +Scope note, since this is easy to over-read: **the splice is a WASI requirement, not a composite +requirement.** `WasiStaticR2RProbe` serves `composite-r2r.wasm` only from a baked-in buffer that the +splice populates, so on WASI there is no way to hand the runtime a composite from disk. Browser has +no such constraint — `crossgen2 --composite --targetos:browser` plus a flat directory driven by +`corerun.js` works without any of this tooling. Browser also has a productised **non-composite** path +since [#132339](https://github.com/dotnet/runtime/pull/132339) (`-p:PublishReadyToRun=true`); that +path declines composite, but only as an SDK opt-out. + +**Read [`docs/workflow/building/coreclr/wasi-r2r.md`](../../docs/workflow/building/coreclr/wasi-r2r.md) first.** +That is the full playbook: build commands, crossgen2 invocations, run commands, and — most +importantly — the traps that repeatedly lead people to falsely conclude that CoreCLR R2R on WASI +does not work. This README only covers the tools in this directory. + +## Pieces + +| Path | Purpose | +| --- | --- | +| `pipeline-shim.sh` | The splice pipeline: unbundle → extract image base → generate shim → `wasm-merge` → `wasm-opt` fold → module-swap. | +| `comp.rsp.template` | `crossgen2` composite response file; replace `@ROOT@` with your worktree root. | + +## Prerequisites + +- `wasm-tools`, `wasm-merge` and `wasm-opt` (Binaryen), and `wasm-objdump` / `wat2wasm` (WABT) on + `PATH`, plus `python3`. `pipeline-shim.sh` fails fast if any are missing. +- `wasmtime` on `PATH` for running the result. + +There is no longer an out-of-repo dependency. The pipeline previously required `Nesm.dll` (a wasm +reader/writer from outside this repo) to drive two tools, `surgery` and `activate`, which rewrote the +merged module after the fact. Both are gone — see [How the splice works](#how-the-splice-works). + +## Is the splice still needed? + +**Yes, for WASI.** [#131016](https://github.com/dotnet/runtime/pull/131016) added VM-side loading of a +flat webcil composite, and that code is present — `NativeImage::Open` has a `TARGET_WASM` branch that +takes the R2R header from the decoder instead of the `RTR_HEADER` export. But it does not make direct +deployment work here, because the **WASI host probe never serves the composite from disk**: +`WasiStaticR2RProbe` ([`wasi_r2r_probe.hpp`](../../src/coreclr/hosts/corerun/wasi_r2r_probe.hpp)) +special-cases `composite-r2r.wasm` and returns the baked-in `g_wasi_r2r_image` buffer, which only the +splice populates. Per-assembly stubs *are* read from `comp/.wasm` on disk; the composite is not. + +Measured on a stock (unspliced) `corerun` with the composite deployed alongside — both in the run root +and colocated in `comp/` — this is what happens: + +1. `g_wasi_r2r_image` is empty, so `WasiWebcilPayloadSize` returns `<= 0` and the probe returns `false`. +2. `OpenR2RFromPE` falls through to `PEImageLayout::LoadNative`, which reads the raw file. +3. The file begins `\0asm` — it is webcil *wrapped in wasm* — so `WebcilDecoder::DetectWebcilFormat`, + which tests for the ASCII bytes `WbIL`, returns false. +4. `InitDecoders` therefore selects `FORMAT_PE` and runs `PEDecoder` over a wasm file. + +The result is **not** a graceful fallback. It is an out-of-bounds trap during EE startup: + +``` +0: corerun!PEDecoder::FindReadyToRunHeader() const +1: corerun!NativeImage::Open(...) +2: corerun!AssemblyBinder::LoadNativeImage(...) +3: corerun!AcquireCompositeImage(...) +4: corerun!ReadyToRunInfo::Initialize(...) +... +memory fault at wasm address 0x6541cc8b in linear memory of size 0x8000000 +wasm trap: out of bounds memory access +``` + +That backtrace is the signature of this deployment gap. It looks like a broken composite and reads +like "R2R does not work on wasm"; it is neither. Gate it with `DOTNET_ReadyToRun=0` — if the app then +runs clean, the composite was simply never delivered to the runtime, and you need the splice. + + +## Usage + +`pipeline-shim.sh` derives `ROOT` from the repo root above it, so from a worktree with a matching +build already in `artifacts/` it is just: + +```bash +eng/wasi-r2r/pipeline-shim.sh +``` + +Every input is overridable by environment variable — see the header comment in the script. +It prints the resolved bases, then `VALID` and the output path on success. + +Verify the result actually executes R2R code rather than falling back — see +[Proving R2R is actually active](../../docs/workflow/building/coreclr/wasi-r2r.md#proving-r2r-is-actually-active). +The activation log alone is not sufficient: it reports success as soon as the composite *loads*. + +## How the splice works + +The composite `crossgen2` emits is **self-installing**: the webcil payload is an ACTIVE data segment +at `(global.get __memory_base)` and the R2R function table is an ACTIVE element segment at +`(global.get __table_base)`, so the engine installs both at instantiation. Nothing has to rewrite the +module afterwards, which is what retired `activate`. + +`corerun` supplies five of the composite's seven imports directly, via link flags in +[`corerun/CMakeLists.txt`](../../src/coreclr/hosts/corerun/CMakeLists.txt): + +``` +-Wl,--table-base= # reserve table slots 1..N for the composite +-Wl,--export-table # -> __indirect_function_table +-Wl,--export=__stack_pointer +-Wl,--export=__coreclr_wasm_rtlrestorecontext_tag +-Wl,--export=__async_continuation +``` + +That covers `memory`, `__indirect_function_table`, `__stack_pointer`, +`__coreclr_wasm_rtlrestorecontext_tag` and `__async_continuation`. + +**The two it cannot supply are `__memory_base` and `__table_base`.** `wasm-ld` creates those globals +only in PIC mode, and a wasm global whose initializer is a data symbol's address is not expressible +from C — which is exactly what `surgery` used to inject post-link. `pipeline-shim.sh` generates a +six-line shim module exporting them as constants and merges it as a third input, which retired +`surgery`. + +Three things about this are easy to get wrong: + +- **`--table-base`, not a growable table.** An ACTIVE element segment is installed by the engine at + instantiation, so the table must *already* be large enough; growth at runtime does not help. + Reserve by the composite's **function** count, not its assembly count. The reservation keeps the + table fixed-size (`min == max`) so it still validates statically, and costs little — the extra bytes + come from wider LEB encodings for the shifted indices, not from the table. Measured: `6298/6298` → + `71834/71834` at `--table-base=65537`, +51 KB (0.14%) at 500,001 slots. +- **The fold is required, and is not free.** Merging internalizes the imported globals, and + `global.get` of a *defined* global is a constant expression only under the GC proposal — so the + merge needs `--enable-gc` and the result needs `wasm-opt --simplify-globals` to be portable + (wasmtime rejects the unfolded form under `exceptions` alone; V8 accepts it, so "it loaded in node" + proves nothing). The pass also propagates globals into function bodies, costing ~3.7% code size. + A host that supplies the bases at *instantiation* instead — as the browser does — keeps `global.get` + of an **imported** global, which is valid MVP, and pays neither cost. +- **Payload offset 28 is now a runtime responsibility.** `activate` used to bake + `WebcilHeader_1.TableBase` offline. With the segment installed by the engine, nothing writes it, and + its absence is silent: `GetTableBaseOffset` returns 0 rather than failing, and that 0 becomes + `tableBaseDelta`, shifting every R2R function index. The WASI host patches it in + [`wasi_r2r_probe.hpp`](../../src/coreclr/hosts/corerun/wasi_r2r_probe.hpp) (`WASI_R2R_TABLE_BASE`, + which must match the shim); browser calls the composite's exported `patchWebcilHeader`. + + This is measured, not argued. Setting the host's table base to 2 while the shim installs at 1 makes + the run fail with `wasm trap: indirect call type mismatch` — a symptom nowhere near its cause. Note + the corollary for the open `call_indirect` bugs: **table-index misalignment is a producer of that + symptom, so a signature mismatch is not by itself evidence of a signature-encoding fault.** + +## Historical note: the removed nesm dependency + +`surgery` and `activate` existed because nothing supplied the composite's imports at link time and +nothing emitted its segments in active form. Both were addressable, and the result is *more* +declarative than the pipeline they replaced rather than less. Kept here because the measurement that +sized the reservation is still the one to reuse, and because the import accounting is easy to get +wrong in the same way twice. + +**The host half is *mostly* done by the linker — five of the composite's seven imports, not all.** + +> **Correction, recorded because the wrong number was load-bearing.** An earlier revision claimed +> **six** of seven, implying only one gap. Enumerating the exports of the corerun actually built with +> these flags gives nine — `cabi_realloc`, `GetDotNetRuntimeContractDescriptor`, `memory`, +> `wasi:cli/run@0.2.0#run`, `wasi_r2r_image_base`, `__async_continuation`, +> `__coreclr_wasm_rtlrestorecontext_tag`, `__indirect_function_table`, `__stack_pointer` — of which +> **five** match composite imports. `--table-base` shifts the table layout but creates no exported +> `__table_base` global, and `wasi_r2r_image_base` is a *function*, so it cannot satisfy a global +> import. Independently corroborated: merging the real composite into the real browser `corerun.wasm` +> leaves exactly `__memory_base` and `__table_base` unresolved and nothing else. Two hosts, two +> toolchains, same two globals — which is what identified the shim as the remaining work. + +The extraction step the shim depends on is *not* new: reading the image base out of the linked host +was already how `surgery` got its argument. `wasi_r2r_image_base`'s body is a single +`i32.const ` (it returns `&g_wasi_r2r_image[0]`), so it decodes statically with no +instantiation. Two things to carry forward: + +- `wasm-tools component unbundle` is **mandatory** first — `corerun` is a WASI component and + `wasm-objdump` rejects components outright. +- Extract with `sed`, not `awk`. The `awk` form the old pipeline used silently yields an **empty + string** under BSD `awk` (the macOS default), which would feed an empty base downstream rather than + failing. `pipeline-shim.sh` validates that the result is numeric. + +Measured on the real 36 MB corerun: table `6298/6298` → `71834/71834` with `--table-base=65537`, +exports 6 → 9, and the run still passes with `DOTNET_ReadyToRun=0` (verified against a same-binary +control, since the `StackTrace` frame count differs between R2R on and off for unrelated reasons). + +Cost of the reservation is small and mostly independent of its size — the extra bytes come from wider +LEB encodings for the shifted function indices, not from the table itself: + +| `--table-base` | corerun bytes | table min/max | +| --- | --- | --- | +| default (1) | 36,284,003 | 6,298 | +| 65,537 | 36,284,095 | 71,834 | +| 500,001 | 36,336,407 | 506,298 | + +**Size it from the composite's function count, not its assembly count.** Every function in the +composite consumes a table slot: a 4-assembly composite needs 52,637; the `System.Text.Json` test +closure needs **283,573**. Reserve generously and fail loudly when a composite exceeds it — the same +contract the 16 MB `g_wasi_r2r_image` buffer already uses on the memory side. + +**The composite half is crossgen2 work.** It would need to emit import names matching the linker's +exports, emit the payload and element segments as **active** at the reserved bases rather than +passive, and drop the `tableBase`/`imageBase` global imports since both become compile-time constants. +`WasmDataSegmentType.Active` is already modelled; only `Passive` is currently ever emitted. + +That leaves the whole splice as `wasm-tools component unbundle` → `wasm-merge` → reassemble, all +standard tooling. + +> **Do not solve this with a `start` function.** A composite that grows its own table and populates it +> via `table.init`/`memory.init` at startup does work — verified end-to-end, including that +> `wasm-merge` correctly combines two start functions. But it replaces declarative, engine-applied +> installation with guest code mutating its own dispatch table at runtime, and it forfeits the +> statically-known table size. It would level WASI down to the browser's runtime-linking posture, +> which is the weaker of the two. The reservation approach above gets the same result declaratively. diff --git a/eng/wasi-r2r/comp.rsp.template b/eng/wasi-r2r/comp.rsp.template new file mode 100644 index 00000000000000..142417cb4feda3 --- /dev/null +++ b/eng/wasi-r2r/comp.rsp.template @@ -0,0 +1,17 @@ +# crossgen2 composite response file — TEMPLATE. +# Replace @ROOT@ with your worktree root. First line = the app assembly (Hello.dll). +# The framework assemblies listed here are R2R'd INTO the composite; their IL is still loaded at run time. +# +# NOTE: System.Private.CoreLib comes from CoreCLR's own IL output, NOT from the wasi-wasm runtime +# pack's native/ directory — the pack's copy is Mono's CoreLib and produces a silently broken image. +@ROOT@/r2rtest/in/Hello.dll +@ROOT@/artifacts/bin/coreclr/wasi.wasm.Release/IL/System.Private.CoreLib.dll +@ROOT@/artifacts/bin/microsoft.netcore.app.runtime.wasi-wasm/Release/runtimes/wasi-wasm/lib/net11.0/System.Runtime.dll +@ROOT@/artifacts/bin/microsoft.netcore.app.runtime.wasi-wasm/Release/runtimes/wasi-wasm/lib/net11.0/System.Console.dll +-o:@ROOT@/r2rtest/out/composite-r2r.wasm +--composite +-O +--targetarch:wasm +--targetos:wasi +--codegenopt:JitWasmNyiToR2RUnsupported=1 +--codegenopt:JitWasmSimdNyiToR2RUnsupported=1 diff --git a/eng/wasi-r2r/pipeline-shim.sh b/eng/wasi-r2r/pipeline-shim.sh new file mode 100755 index 00000000000000..affb1f96d9d5f4 --- /dev/null +++ b/eng/wasi-r2r/pipeline-shim.sh @@ -0,0 +1,112 @@ +#!/usr/bin/env bash +# Splice a wasm R2R composite into corerun using only stock tooling — no nesm. +# +# Replaces pipeline-sym.sh (surgery + activate) for composites emitted by a crossgen2 that +# produces SELF-INSTALLING images: the webcil payload as an ACTIVE data segment at +# (global.get __memory_base) and the R2R function table as an ACTIVE element segment at +# (global.get __table_base). The engine installs both at instantiation. +# +# corerun supplies five of the composite's seven imports directly (memory, __stack_pointer, +# __indirect_function_table, __coreclr_wasm_rtlrestorecontext_tag, __async_continuation). The +# remaining two are the base globals, which wasm-ld only creates in PIC mode — so a generated +# shim module supplies them instead. That is what surgery used to do by post-link injection. +# +# Requires: wasm-tools, wabt (wasm-objdump, wat2wasm), binaryen (wasm-merge, wasm-opt), python3. +# +# COMP= CORERUN= ./pipeline-shim.sh +set -euo pipefail + +ROOT=${ROOT:-$(cd "$(dirname "$0")/../.." && pwd)} +COMP=${COMP:-$ROOT/r2rtest/out2/composite-r2r.wasm} +CORERUN=${CORERUN:-$ROOT/artifacts/obj/coreclr/wasi.wasm.Release/hosts/corerun/corerun} +D=${OUTDIR:-$ROOT/r2rtest/shimout} + +# The table slot at which the composite installs. Must match WASI_R2R_TABLE_BASE in +# corerun/wasi_r2r_probe.hpp, which patches it into the webcil header at payload offset 28. +# Under -Wl,--table-base=N the linker leaves slots 1..N-1 free, so this is always 1. +TABLE_BASE=${TABLE_BASE:-1} + +[ -f "$COMP" ] || { echo "error: composite not found at '$COMP'" >&2; exit 1; } +[ -f "$CORERUN" ] || { echo "error: corerun not found at '$CORERUN'" >&2; exit 1; } + +rm -rf "$D"; mkdir -p "$D" + +# 1. Unbundle the corerun component -> core module. Mandatory: corerun is a WASI component and +# wasm-objdump rejects components outright ("wasm components are not yet supported"). +wasm-tools component unbundle "$CORERUN" --module-dir "$D" -o /dev/null >/dev/null 2>&1 +MAIN=$(ls "$D"/*module0*.wasm | head -1) + +# 2. Read the image base out of the LINKED host. wasi_r2r_image_base's body is a single +# i32.const holding &g_wasi_r2r_image[0], so it decodes statically with no instantiation. +# NOTE: use sed, not awk. The awk form in pipeline-sym.sh silently yields an EMPTY string +# under BSD awk (the macOS default), which would feed an empty base to the shim. +IDX=$(wasm-objdump -j Export -x "$MAIN" | grep -i wasi_r2r_image_base | grep -oE 'func\[[0-9]+\]' | grep -oE '[0-9]+') +ADDR=$(wasm-objdump -d "$MAIN" | grep -A1 "func\[$IDX\] " \ + | grep 'i32\.const' | sed -E 's/.*i32\.const +([0-9]+).*/\1/') +case "$ADDR" in ''|*[!0-9]*) echo "error: could not extract imageBase (got '$ADDR')" >&2; exit 1;; esac + +TBL=$(wasm-objdump -x "$MAIN" | grep -iE "^ - table\[0\]" | grep -oE "initial=[0-9]+" | grep -oE "[0-9]+") +NFUNC=$(wasm-objdump -h "$COMP" | grep -iE "^ Function " | grep -oE "count: [0-9]+" | grep -oE "[0-9]+") +echo "SHIM: imageBase=$ADDR tableBase=$TABLE_BASE hostTable=$TBL compositeFuncs=$NFUNC" + +# The engine applies an ACTIVE element segment at instantiation, so the host table must already +# be large enough. Too small is an instantiation failure, which is loud — but catching it here +# names the cause instead of leaving "active segments don't work". +if [ "$((TABLE_BASE + NFUNC))" -gt "$TBL" ]; then + echo "error: host table $TBL too small for $NFUNC functions at base $TABLE_BASE." >&2 + echo " Raise -Wl,--table-base in corerun/CMakeLists.txt to at least $((TABLE_BASE + NFUNC + 1))." >&2 + exit 1 +fi + +# 3. Generate the shim supplying the two globals wasm-ld cannot emit for a non-PIC main module. +cat > "$D/shim.wat" <&1 | tail -1 + +# 5. Fold global.get -> i32.const so the result is MVP-valid. Without this, wasmtime rejects the +# module unless the embedder enables GC. Costs ~3.7% code size: the pass also propagates +# globals into function bodies, and a multi-byte i32.const is larger than a 2-byte global.get. +wasm-opt "$D/merged.wasm" --all-features --simplify-globals -o "$D/final.wasm" + +# 6. Swap the merged core module back into the corerun component. +python3 - "$CORERUN" "$D/final.wasm" "$D/corerun-composite.wasm" <<'PY' +import sys +cp, mp, op = sys.argv[1:4] +merged = open(mp, 'rb').read(); data = open(cp, 'rb').read() +def wl(v): + o = bytearray() + while True: + b = v & 0x7f; v >>= 7 + if v: o.append(b | 0x80) + else: o.append(b); break + return bytes(o) +def rl(d, p): + r = s = 0 + while True: + b = d[p]; p += 1; r |= (b & 0x7f) << s; s += 7 + if not (b & 0x80): break + return r, p +out = bytearray(data[:8]); pos = 8; sw = False +while pos < len(data): + sid = data[pos]; ss = pos; pos += 1 + size, pos = rl(data, pos) + if sid == 1 and not sw: + out.append(1); out += wl(len(merged)); out += merged; sw = True + else: + out += data[ss:pos+size] + pos += size +open(op, 'wb').write(out) +PY + +wasm-tools validate --features all "$D/corerun-composite.wasm" >/dev/null 2>&1 && echo "VALID" || echo "INVALID" +echo "OUT: $D/corerun-composite.wasm" diff --git a/src/coreclr/hosts/corerun/CMakeLists.txt b/src/coreclr/hosts/corerun/CMakeLists.txt index 4965996f4b7eb3..b2ca3a565ac195 100644 --- a/src/coreclr/hosts/corerun/CMakeLists.txt +++ b/src/coreclr/hosts/corerun/CMakeLists.txt @@ -157,7 +157,28 @@ else() -Wl,-z,stack-size=8388608 -Wl,--initial-memory=134217728 -Wl,--max-memory=4294967296 - "-Wl,--component-type,${_wasi_http_world_wit}") + "-Wl,--component-type,${_wasi_http_world_wit}" + # Force-root the cDAC contract descriptor getter: its object has no live + # references (the getter is the only one, and DATA symbols aren't auto-exported), + # so without an explicit --export root wasm-ld's --gc-sections drops the whole + # object and the descriptor never reaches the final module. Mirrors the native + # `-Wl,-u,DotNetRuntimeContractDescriptor` used on other non-browser targets. + -Wl,--export=GetDotNetRuntimeContractDescriptor + # [wasi][prototype] Export the runtime-owned async continuation global (#131167, + # helpers.cpp) so the offline R2R merge can wire each composite's webcil.asyncContinuation + # import to this same global (the interp<->R2R accessors use it), and so --gc-sections + # doesn't drop it. + -Wl,--export=__async_continuation + # [wasi] Supply the R2R composite's imports at link time. --table-base reserves the + # low table slots 1..N-1 for a merged composite's element segment (which crossgen2 now + # emits ACTIVE, so the engine installs it at instantiation and the table must already + # be large enough), moving corerun's own address-taken functions above it. The table + # stays fixed-size so the engine can still validate it statically. + # Reserve by the composite's FUNCTION count, not its assembly count. + -Wl,--table-base=65537 + -Wl,--export-table + -Wl,--export=__stack_pointer + -Wl,--export=__coreclr_wasm_rtlrestorecontext_tag) endif() if (CORERUN_IN_BROWSER) diff --git a/src/coreclr/hosts/corerun/corerun.cpp b/src/coreclr/hosts/corerun/corerun.cpp index 49908c2675ac74..2f8e170722ca43 100644 --- a/src/coreclr/hosts/corerun/corerun.cpp +++ b/src/coreclr/hosts/corerun/corerun.cpp @@ -308,6 +308,10 @@ static char* s_core_root_path = nullptr; extern "C" bool BrowserHost_ExternalAssemblyProbe(const char* pathPtr, /*out*/ void **outDataStartPtr, /*out*/ int64_t* outSize); #endif // TARGET_BROWSER +// PROTOTYPE: WASI R2R external-assembly probe, shared with the wasihost corehost (libWasiHost.a) +// so both hosts serve R2R identically. See wasi_r2r_probe.hpp. +#include "wasi_r2r_probe.hpp" + static bool HOST_CONTRACT_CALLTYPE get_native_code_data( const host_runtime_contract_native_code_context* context, host_runtime_contract_native_code_data* data) @@ -376,6 +380,14 @@ static bool HOST_CONTRACT_CALLTYPE external_assembly_probe( if (pos != NULL) name = pos + 1; +#ifdef TARGET_WASI + { + const char* const r2r_dirs[] = { s_core_libs_path, s_core_root_path }; + if (wasi_r2r::WasiStaticR2RProbe(name, r2r_dirs, 2, data_start, size)) + return true; + } +#endif // TARGET_WASI + // Try to map the file from our known app assembly paths for (const char* dir : { s_core_libs_path, s_core_root_path }) { diff --git a/src/coreclr/hosts/corerun/wasi_r2r_probe.hpp b/src/coreclr/hosts/corerun/wasi_r2r_probe.hpp new file mode 100644 index 00000000000000..f870dafc841275 --- /dev/null +++ b/src/coreclr/hosts/corerun/wasi_r2r_probe.hpp @@ -0,0 +1,223 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +// PROTOTYPE: statically-composed WASI R2R external-assembly probe, shared by every CoreCLR-WASI host +// (the standalone corerun executable and the per-app-linked wasihost corehost / libWasiHost.a). The +// probe is a host_runtime_contract::external_assembly_probe callback: the runtime calls out to it to +// obtain the composite R2R webcil image and the per-assembly stubs. Keeping it here (rather than in a +// single host) means both hosts serve R2R identically instead of one silently falling back to interp. +// +// Requires corerun.hpp to be included first (for pal::try_map_file_readonly). Include exactly once per +// host translation unit; the internal-linkage buffer/functions then give one instance per host binary. + +#ifndef WASI_R2R_PROBE_HPP +#define WASI_R2R_PROBE_HPP + +#ifdef TARGET_WASI + +#include +#include +#include +#include +#include + +namespace wasi_r2r +{ +// A crossgen2-produced R2R webcil image is merged into the host module post-link (its native +// functions land in the shared indirect function table and its webcil payload/metadata is written +// into g_wasi_r2r_image by the offline merge's active data segment at this buffer's address == the +// composite image's imageBase). The runtime then finds the R2R webcil via this probe, exactly the way +// the browser host does via BrowserHost_ExternalAssemblyProbe. +// +// This buffer is a composite-AGNOSTIC cap: its address is exported (wasi_r2r_image_base) for the merge +// step to target, but its size is NOT tuned per composite. The actual payload size and the merge-time +// table base are discovered at runtime from the self-describing WbIL header (see WasiWebcilPayloadSize +// / WebcilHeader_1.TableBase), so this host never needs rebuilding when the composite changes. It only +// requires the composite's metadata payload to fit under the cap below. +#ifndef WASI_R2R_IMAGE_CAP +#define WASI_R2R_IMAGE_CAP (16u * 1024u * 1024u) +#endif + +// The table index at which the composite's functions are installed. Under the reservation model the +// host is linked with `-Wl,--table-base=`, which moves corerun's own address-taken functions up +// to start at N+1 and leaves slots 1..N free, so the composite always sits at base 1 regardless of +// its size. This MUST match the `__table_base` global supplied to the merge (see eng/wasi-r2r/README.md); +// the two are a coupled constant and a mismatch is silent -- see the patch in WasiStaticR2RProbe. +#ifndef WASI_R2R_TABLE_BASE +#define WASI_R2R_TABLE_BASE (1u) +#endif +alignas(16) static uint8_t g_wasi_r2r_image[WASI_R2R_IMAGE_CAP]; + +// The composite native image's bundle-relative file name (the ownerCompositeExecutable named by each +// per-assembly stub). The runtime asks for this via NativeImage::Open -> external_assembly_probe. +#ifndef WASI_R2R_COMPOSITE_NAME +#define WASI_R2R_COMPOSITE_NAME "composite-r2r.wasm" +#endif + +// Compute the exact WbIL payload size from its self-describing header - no baked constant needed. +// WebcilHeader_1 (32 bytes): Id[4] 'WbIL', VersionMajor u16, VersionMinor u16, CoffSections u16, +// Reserved0 u16, PeCliHeaderRva u32, PeCliHeaderSize u32, PeDebugRva u32, PeDebugSize u32, TableBase u32. +// Followed by CoffSections * WebcilSectionHeader{VirtualSize, VirtualAddress, SizeOfRawData, PointerToRawData}. +// The payload extent is the maximum (PointerToRawData + SizeOfRawData) across all sections. +static int64_t WasiWebcilPayloadSize(const uint8_t* p) +{ + if (p[0] != 'W' || p[1] != 'b' || p[2] != 'I' || p[3] != 'L') + return 0; + + uint16_t coffSections; + memcpy(&coffSections, p + 8, sizeof(coffSections)); + + const uint8_t* sec = p + 32; // section headers follow the 32-byte WebcilHeader_1 + uint32_t maxEnd = 0; + for (uint16_t i = 0; i < coffSections; i++) + { + uint32_t sizeOfRawData; + uint32_t pointerToRawData; + memcpy(&sizeOfRawData, sec + 8, sizeof(sizeOfRawData)); + memcpy(&pointerToRawData, sec + 12, sizeof(pointerToRawData)); + uint32_t end = pointerToRawData + sizeOfRawData; + if (end > maxEnd) + maxEnd = end; + sec += 16; + } + return (int64_t)maxEnd; +} + +// Minimal LEB128 reader for parsing a wasm binary's Data section. +static uint64_t wasi_read_uleb(const uint8_t* p, size_t len, size_t* pos) +{ + uint64_t result = 0; int shift = 0; + while (*pos < len) + { + uint8_t b = p[(*pos)++]; + result |= (uint64_t)(b & 0x7f) << shift; + if ((b & 0x80) == 0) break; + shift += 7; + } + return result; +} + +// Extract the raw WbIL webcil payload (passive data segment index 1) from a wasm-wrapped-webcil stub +// on disk and copy it into a malloc'd buffer. The stub's tableBase field (WebcilHeader_1 offset 28) is +// authoritative: the offline merge step patches it to the composite's merge-time table base, so this +// host trusts the on-disk value rather than injecting a baked constant. +// Mirrors what the browser JS loader's getWebcilPayload does, but purely in native code (no instantiation). +static bool WasiExtractStubPayload(const char* wasmPath, void** data_start, int64_t* size) +{ + void* filedata = nullptr; int64_t filesize = 0; + if (!pal::try_map_file_readonly(wasmPath, &filedata, &filesize)) + return false; + + const uint8_t* p = (const uint8_t*)filedata; + size_t len = (size_t)filesize; + bool ok = false; + if (len >= 8 && p[0] == 0x00 && p[1] == 0x61 && p[2] == 0x73 && p[3] == 0x6d) + { + size_t pos = 8; + while (pos < len) + { + uint8_t secId = p[pos++]; + uint64_t secSize = wasi_read_uleb(p, len, &pos); + size_t secEnd = pos + (size_t)secSize; + if (secEnd > len) break; + if (secId == 11) // Data section + { + size_t q = pos; + uint64_t segCount = wasi_read_uleb(p, len, &q); + for (uint64_t s = 0; s < segCount && q < secEnd; s++) + { + uint64_t mode = wasi_read_uleb(p, len, &q); + // Only passive segments (mode 1) are used by the webcil wrapper. + if (mode != 1) { break; } + uint64_t dlen = wasi_read_uleb(p, len, &q); + size_t dstart = q; + q += (size_t)dlen; + if (s == 1) // segment[1] == the WbIL payload + { + uint8_t* buf = (uint8_t*)malloc((size_t)dlen); + if (buf != nullptr) + { + memcpy(buf, p + dstart, (size_t)dlen); + *data_start = buf; + *size = (int64_t)dlen; + ok = true; + } + break; + } + } + break; + } + pos = secEnd; + } + } + munmap(filedata, (size_t)filesize); + return ok; +} + +// The external-assembly R2R probe: serves the composite webcil from the baked buffer and each managed +// assembly's per-assembly stub from "/comp/.wasm" on disk, searching the supplied dirs (each +// expected to carry a trailing path delimiter). Returns false for anything it does not provide, letting +// the caller fall back to its normal assembly load. +static bool WasiStaticR2RProbe(const char* name, const char* const* dirs, size_t ndirs, void** data_start, int64_t* size) +{ + // The composite native image itself: return the merged composite payload at imageBase. Its size is + // read from the self-describing WbIL header (no baked constant), and validated against the buffer cap. + if (strcmp(name, WASI_R2R_COMPOSITE_NAME) == 0) + { + int64_t payloadSize = WasiWebcilPayloadSize(&g_wasi_r2r_image[0]); + if (payloadSize <= 0 || (size_t)payloadSize > sizeof(g_wasi_r2r_image)) + return false; // buffer not populated, or composite payload exceeds the cap + + // Self-installing images: crossgen2 emits the payload as an ACTIVE data segment that the engine + // installs at instantiation, so the offline `activate` step that used to bake WebcilHeader_1.TableBase + // (payload offset 28) no longer runs and nothing has written it. That field is not optional -- + // WebcilDecoder::GetTableBaseOffset returns 0 rather than failing, and that 0 becomes tableBaseDelta + // in PEImageLayout, shifting every R2R function index by the table base. The symptom is call_indirect + // landing on the wrong function, nowhere near the cause. Patch it before the runtime parses the header. + uint8_t* hdr = &g_wasi_r2r_image[0]; + if (payloadSize >= 32 && hdr[28] == 0 && hdr[29] == 0 && hdr[30] == 0 && hdr[31] == 0) + { + uint32_t tableBase = WASI_R2R_TABLE_BASE; + memcpy(hdr + 28, &tableBase, sizeof(tableBase)); + } + + *data_start = &g_wasi_r2r_image[0]; + *size = payloadSize; + return true; + } + + // A managed assembly: return its per-assembly stub payload (extracted from .wasm on disk). + // The stub carries the assembly metadata + the R2R header naming the composite, which drives the + // runtime to then request WASI_R2R_COMPOSITE_NAME above. + size_t nlen = strlen(name); + if (nlen > 4 && strcmp(name + nlen - 4, ".dll") == 0) + { + char stub[512]; + for (size_t i = 0; i < ndirs; i++) + { + const char* dir = dirs[i]; + if (dir == nullptr) continue; + // Build "/comp/.wasm" + snprintf(stub, sizeof(stub), "%scomp/%.*s.wasm", dir, (int)(nlen - 4), name); + if (WasiExtractStubPayload(stub, data_start, size)) + { + return true; + } + } + } + return false; +} + +} // namespace wasi_r2r + +// Exported so the offline merge step can discover the buffer's address and wire it to the R2R image's +// imageBase global. Defined outside the namespace with C linkage so the export name is exactly +// "wasi_r2r_image_base" (the surgery step targets this symbol). +extern "C" __attribute__((export_name("wasi_r2r_image_base"))) uint32_t wasi_r2r_image_base(void) +{ + return (uint32_t)(uintptr_t)&wasi_r2r::g_wasi_r2r_image[0]; +} + +#endif // TARGET_WASI + +#endif // WASI_R2R_PROBE_HPP diff --git a/src/mono/wasi/build/WasiApp.CoreCLR.targets b/src/mono/wasi/build/WasiApp.CoreCLR.targets index 9415a5ab7f9ca4..87e62933a58abd 100644 --- a/src/mono/wasi/build/WasiApp.CoreCLR.targets +++ b/src/mono/wasi/build/WasiApp.CoreCLR.targets @@ -289,6 +289,9 @@ <_WasiRelinkLinkFlags Include="$(_WasiRelinkOptFlag)" /> <_WasiRelinkLinkFlags Include="-DNDEBUG" Condition="'$(Configuration)' != 'Debug'" /> <_WasiRelinkLinkFlags Include="-Wl,--gc-sections" /> + + <_WasiRelinkLinkFlags Include="-Wl,--export=__async_continuation" /> <_WasiRelinkLinkFlags Include="-fwasm-exceptions" /> <_WasiRelinkLinkFlags Include="-mllvm" /> <_WasiRelinkLinkFlags Include="-wasm-use-legacy-eh=false" /> diff --git a/src/native/corehost/wasihost/wasihost.cpp b/src/native/corehost/wasihost/wasihost.cpp index 9d34a920480a9d..3782fcea913971 100644 --- a/src/native/corehost/wasihost/wasihost.cpp +++ b/src/native/corehost/wasihost/wasihost.cpp @@ -17,6 +17,11 @@ // Shared pal (path handling, CORE_ROOT/TPA helpers); header-only, so no corerun object is linked. #include "corerun.hpp" +// Shared WASI R2R external-assembly probe (same code corerun uses), so the per-app test host serves +// statically-composed R2R images instead of silently interpreting everything. Requires corerun.hpp +// above (pal::try_map_file_readonly). +#include "wasi_r2r_probe.hpp" + #include using pal::char_t; @@ -70,11 +75,36 @@ extern "C" __attribute__((weak)) int32_t GlobalizationNative_LoadICUData(const c static std::vector s_property_keys; static std::vector s_property_values; +// R2R external-assembly probe search dirs, captured before coreclr_initialize so the probe callback +// (invoked later by the runtime) can reach them. +static string_t s_r2r_core_root; +static string_t s_r2r_core_libs; + static void log_error_info(const char* line) { std::fprintf(stderr, "%s\n", line); } +// Serves statically-composed R2R images (the composite plus per-assembly stubs) to the runtime, using +// the shared WASI probe. Returns false for everything else, so non-R2R assemblies load normally via the +// TPA list. +static bool HOST_CONTRACT_CALLTYPE external_assembly_probe( + const char* path, + void** data_start, + int64_t* size) +{ + const char* name = path; + const char* slash = ::strrchr(name, '/'); + if (slash != nullptr) + name = slash + 1; + + const char* const r2r_dirs[] = { + s_r2r_core_libs.empty() ? nullptr : s_r2r_core_libs.c_str(), + s_r2r_core_root.empty() ? nullptr : s_r2r_core_root.c_str() + }; + return wasi_r2r::WasiStaticR2RProbe(name, r2r_dirs, 2, data_start, size); +} + // Include only the first instance of each simple assembly name (CoreCLR may otherwise prefer a // later ni over an earlier il). static string_t build_tpa(const string_t& core_root, const string_t& core_libraries) @@ -172,6 +202,10 @@ int main(int argc, char* argv[]) core_root = app_path; pal::ensure_trailing_delimiter(core_root); + // Capture the R2R probe search dirs (trailing-delimited) for the external_assembly_probe callback. + s_r2r_core_root = core_root; + s_r2r_core_libs = core_libs; + string_t exe_path = pal::get_exe_path(); string_t tpa_list = build_tpa(core_root, core_libs); @@ -193,6 +227,7 @@ int main(int argc, char* argv[]) static host_runtime_contract host_contract = { sizeof(host_runtime_contract), nullptr }; host_contract.get_runtime_property = &get_runtime_property; host_contract.pinvoke_override = &callhelpers_pinvoke_override; + host_contract.external_assembly_probe = &external_assembly_probe; { std::stringstream ss; ss << "0x" << std::hex << (size_t)(&host_contract); From ea9b6fa3be92ec45f130f6f9d2508aa1b6eda866 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 28 Aug 2026 11:05:03 -0500 Subject: [PATCH 07/17] [wasm][R2R] Harden the WASI probe's parsers and move the cap check to build time Review of the host change (three models) found memory-safety problems in the two hand-rolled parsers, which take input this host did not produce -- a stub read off disk, and a composite installed into g_wasi_r2r_image by the wasm engine. Out-of-bounds read, the most serious: WasiExtractStubPayload copied `dlen` bytes from the mapping without checking `dstart + dlen` against the section extent, so a truncated or malformed comp/*.wasm read past the mmap region. The probe's contract is to return false for anything it cannot serve; instead it crashed the host. Also fixed in the same pass: - wasi_read_uleb never capped `shift`, so a run of continuation bytes drove `<< shift` past 64 -- UB. It now reports failure on an over-long or truncated encoding instead of returning a silently wrong value. - `pos + (size_t)secSize` could wrap on wasm32, where size_t is 32-bit, defeating the `secEnd > len` test on the following line. Compared against `len - pos`. - WasiWebcilPayloadSize took no length argument and summed two u32 section fields without an overflow check. A wrapped sum yields a SMALL extent that passes the cap test and hands the runtime a truncated image. It now bounds the section walk and rejects the overflow rather than checking the wrapped result. - The extracted stub payload is verified to start with 'WbIL'. If the wrapper's segment layout ever changes, that fails loudly instead of handing the runtime a non-webcil buffer. The buffer cap check moves to pipeline-shim.sh. In the host it could never protect anything: the engine installs the active data segment before any host code runs, so an over-cap payload has already overwritten what follows by the time the test executes -- it reads like a guard while being a post-mortem. The merge step knows both the payload size and the cap, so it is the only place the check is real. Stub payloads no longer allocate. The runtime takes ownership of nothing (ProbeExtensionResult::External never frees), so the malloc'd copy leaked on top of the mapping it was copied from. The mapping is now retained on success and *data_start points into it; every failure path still unmaps. Two things this pass got wrong, recorded because they cost a build each: Making wasi_r2r_image_base `static` to close the ODR hazard REMOVES the wasm export and silently breaks the merge's only anchor. Reverted, with the constraint noted at the definition; the real fix is a shared .cpp and is left as follow-up. pipeline-shim.sh exited 1 with no output when that export went missing, because `set -e` plus a failing grep killed it before its own validation could report. The extraction steps now tolerate the empty match and fail with a diagnosis. Verified end to end after the change, both arms: with the table base correct the run prints and reports four assemblies R2R-active; with it deliberately set to 2 it still fails with `wasm trap: indirect call type mismatch`. The negative arm is what shows R2R is dispatching at all, so it has to keep failing. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b49a31b-9632-4a48-bab4-bfcc98487a5f --- eng/wasi-r2r/pipeline-shim.sh | 28 ++++- src/coreclr/hosts/corerun/wasi_r2r_probe.hpp | 122 ++++++++++++++----- 2 files changed, 119 insertions(+), 31 deletions(-) diff --git a/eng/wasi-r2r/pipeline-shim.sh b/eng/wasi-r2r/pipeline-shim.sh index affb1f96d9d5f4..ce93f068b27508 100755 --- a/eng/wasi-r2r/pipeline-shim.sh +++ b/eng/wasi-r2r/pipeline-shim.sh @@ -40,10 +40,19 @@ MAIN=$(ls "$D"/*module0*.wasm | head -1) # i32.const holding &g_wasi_r2r_image[0], so it decodes statically with no instantiation. # NOTE: use sed, not awk. The awk form in pipeline-sym.sh silently yields an EMPTY string # under BSD awk (the macOS default), which would feed an empty base to the shim. -IDX=$(wasm-objdump -j Export -x "$MAIN" | grep -i wasi_r2r_image_base | grep -oE 'func\[[0-9]+\]' | grep -oE '[0-9]+') +IDX=$(wasm-objdump -j Export -x "$MAIN" | grep -i wasi_r2r_image_base | grep -oE 'func\[[0-9]+\]' | grep -oE '[0-9]+' || true) +case "$IDX" in ''|*[!0-9]*) + echo "error: the host does not export 'wasi_r2r_image_base'." >&2 + echo " Without it the merge has no anchor for __memory_base. Check that the probe header is" >&2 + echo " included and that the export survived --gc-sections." >&2 + exit 1;; +esac ADDR=$(wasm-objdump -d "$MAIN" | grep -A1 "func\[$IDX\] " \ - | grep 'i32\.const' | sed -E 's/.*i32\.const +([0-9]+).*/\1/') -case "$ADDR" in ''|*[!0-9]*) echo "error: could not extract imageBase (got '$ADDR')" >&2; exit 1;; esac + | grep 'i32\.const' | sed -E 's/.*i32\.const +([0-9]+).*/\1/' || true) +case "$ADDR" in ''|*[!0-9]*) + echo "error: could not extract imageBase from wasi_r2r_image_base (got '$ADDR')." >&2 + exit 1;; +esac TBL=$(wasm-objdump -x "$MAIN" | grep -iE "^ - table\[0\]" | grep -oE "initial=[0-9]+" | grep -oE "[0-9]+") NFUNC=$(wasm-objdump -h "$COMP" | grep -iE "^ Function " | grep -oE "count: [0-9]+" | grep -oE "[0-9]+") @@ -58,6 +67,19 @@ if [ "$((TABLE_BASE + NFUNC))" -gt "$TBL" ]; then exit 1 fi +# The payload is likewise installed by the engine, directly into the host's g_wasi_r2r_image buffer, +# BEFORE any host code runs. The host's own cap test therefore cannot protect that buffer -- by the +# time it executes, an over-cap payload has already overwritten whatever follows it. This is the only +# place the check is enforceable, so it lives here. +PAYLOAD=$(wasm-objdump -x "$COMP" | grep -iE "^ - segment\[1\]" | grep -oE "size=[0-9]+" | grep -oE "[0-9]+" | head -1 || true) +CAP=${WASI_R2R_IMAGE_CAP:-$((16 * 1024 * 1024))} +if [ -n "$PAYLOAD" ] && [ "$PAYLOAD" -gt "$CAP" ]; then + echo "error: composite payload $PAYLOAD bytes exceeds the host buffer cap $CAP." >&2 + echo " Raise WASI_R2R_IMAGE_CAP in corerun/wasi_r2r_probe.hpp and rebuild the host." >&2 + exit 1 +fi +echo "SHIM: payload=${PAYLOAD:-unknown} cap=$CAP" + # 3. Generate the shim supplying the two globals wasm-ld cannot emit for a non-PIC main module. cat > "$D/shim.wat" < a + b would overflow. + if (UINT32_MAX - pointerToRawData < sizeOfRawData) + return 0; + uint32_t end = pointerToRawData + sizeOfRawData; if (end > maxEnd) maxEnd = end; - sec += 16; + sec += WEBCIL_SECTION_HEADER_SIZE; } return (int64_t)maxEnd; } -// Minimal LEB128 reader for parsing a wasm binary's Data section. -static uint64_t wasi_read_uleb(const uint8_t* p, size_t len, size_t* pos) +// Minimal LEB128 reader for parsing a wasm binary's Data section. Returns false on a truncated or +// over-long encoding rather than shifting past the width of the result (which would be UB). +static bool wasi_read_uleb(const uint8_t* p, size_t len, size_t* pos, uint64_t* value) { - uint64_t result = 0; int shift = 0; + uint64_t result = 0; + int shift = 0; while (*pos < len) { uint8_t b = p[(*pos)++]; + if (shift >= 64) + return false; // over-long encoding result |= (uint64_t)(b & 0x7f) << shift; - if ((b & 0x80) == 0) break; + if ((b & 0x80) == 0) + { + *value = result; + return true; + } shift += 7; } - return result; + return false; // ran off the end without a terminating byte } // Extract the raw WbIL webcil payload (passive data segment index 1) from a wasm-wrapped-webcil stub -// on disk and copy it into a malloc'd buffer. The stub's tableBase field (WebcilHeader_1 offset 28) is -// authoritative: the offline merge step patches it to the composite's merge-time table base, so this -// host trusts the on-disk value rather than injecting a baked constant. +// on disk. The stub's tableBase field (WEBCIL_TABLE_BASE_OFFSET) is authoritative: the offline merge +// step patches it to the composite's merge-time table base, so this host trusts the on-disk value +// rather than injecting a baked constant. // Mirrors what the browser JS loader's getWebcilPayload does, but purely in native code (no instantiation). +// +// On success the file mapping is deliberately RETAINED and *data_start points into it: the runtime +// takes ownership of neither (ProbeExtensionResult::External never frees), so copying to a malloc'd +// buffer would leak the copy on top of the mapping. Every failure path unmaps. +// +// The stub is untrusted input, so each length read is validated against the remaining extent before +// it is used to advance or copy. static bool WasiExtractStubPayload(const char* wasmPath, void** data_start, int64_t* size) { void* filedata = nullptr; int64_t filesize = 0; @@ -117,28 +154,43 @@ static bool WasiExtractStubPayload(const char* wasmPath, void** data_start, int6 while (pos < len) { uint8_t secId = p[pos++]; - uint64_t secSize = wasi_read_uleb(p, len, &pos); + uint64_t secSize; + if (!wasi_read_uleb(p, len, &pos, &secSize)) + break; + // len - pos cannot underflow (pos <= len) and avoids overflowing pos + secSize, which + // wraps on wasm32 where size_t is 32-bit. + if (secSize > (uint64_t)(len - pos)) + break; size_t secEnd = pos + (size_t)secSize; - if (secEnd > len) break; if (secId == 11) // Data section { size_t q = pos; - uint64_t segCount = wasi_read_uleb(p, len, &q); + uint64_t segCount; + if (!wasi_read_uleb(p, len, &q, &segCount)) + break; for (uint64_t s = 0; s < segCount && q < secEnd; s++) { - uint64_t mode = wasi_read_uleb(p, len, &q); - // Only passive segments (mode 1) are used by the webcil wrapper. + uint64_t mode; + if (!wasi_read_uleb(p, len, &q, &mode)) + break; + // Only passive segments (mode 1) are used by the webcil wrapper. A composite's + // payload segment is ACTIVE, so this also declines a composite handed here by + // mistake rather than misreading its offset expression as segment data. if (mode != 1) { break; } - uint64_t dlen = wasi_read_uleb(p, len, &q); + uint64_t dlen; + if (!wasi_read_uleb(p, len, &q, &dlen)) + break; + if (dlen > (uint64_t)(secEnd - q)) + break; // segment claims more bytes than the section holds size_t dstart = q; q += (size_t)dlen; if (s == 1) // segment[1] == the WbIL payload { - uint8_t* buf = (uint8_t*)malloc((size_t)dlen); - if (buf != nullptr) + // Validate rather than assume: if the wrapper's segment layout ever changes, + // fail loudly here instead of handing the runtime a non-webcil buffer. + if (dlen >= 4 && memcmp(p + dstart, "WbIL", 4) == 0) { - memcpy(buf, p + dstart, (size_t)dlen); - *data_start = buf; + *data_start = (void*)(p + dstart); *size = (int64_t)dlen; ok = true; } @@ -150,7 +202,8 @@ static bool WasiExtractStubPayload(const char* wasmPath, void** data_start, int6 pos = secEnd; } } - munmap(filedata, (size_t)filesize); + if (!ok) + munmap(filedata, (size_t)filesize); return ok; } @@ -164,21 +217,27 @@ static bool WasiStaticR2RProbe(const char* name, const char* const* dirs, size_t // read from the self-describing WbIL header (no baked constant), and validated against the buffer cap. if (strcmp(name, WASI_R2R_COMPOSITE_NAME) == 0) { - int64_t payloadSize = WasiWebcilPayloadSize(&g_wasi_r2r_image[0]); + int64_t payloadSize = WasiWebcilPayloadSize(&g_wasi_r2r_image[0], sizeof(g_wasi_r2r_image)); if (payloadSize <= 0 || (size_t)payloadSize > sizeof(g_wasi_r2r_image)) return false; // buffer not populated, or composite payload exceeds the cap // Self-installing images: crossgen2 emits the payload as an ACTIVE data segment that the engine // installs at instantiation, so the offline `activate` step that used to bake WebcilHeader_1.TableBase - // (payload offset 28) no longer runs and nothing has written it. That field is not optional -- + // no longer runs and nothing has written it. That field is not optional -- // WebcilDecoder::GetTableBaseOffset returns 0 rather than failing, and that 0 becomes tableBaseDelta // in PEImageLayout, shifting every R2R function index by the table base. The symptom is call_indirect // landing on the wrong function, nowhere near the cause. Patch it before the runtime parses the header. + // + // NOTE: the cap test above cannot protect this buffer -- the engine installs the segment before any + // host code runs, so an over-cap payload has already overwritten whatever follows by the time we look. + // The enforceable check is at build time; pipeline-shim.sh compares the payload size against the cap. uint8_t* hdr = &g_wasi_r2r_image[0]; - if (payloadSize >= 32 && hdr[28] == 0 && hdr[29] == 0 && hdr[30] == 0 && hdr[31] == 0) + uint32_t existingTableBase; + memcpy(&existingTableBase, hdr + WEBCIL_TABLE_BASE_OFFSET, sizeof(existingTableBase)); + if (existingTableBase == 0) { uint32_t tableBase = WASI_R2R_TABLE_BASE; - memcpy(hdr + 28, &tableBase, sizeof(tableBase)); + memcpy(hdr + WEBCIL_TABLE_BASE_OFFSET, &tableBase, sizeof(tableBase)); } *data_start = &g_wasi_r2r_image[0]; @@ -211,8 +270,15 @@ static bool WasiStaticR2RProbe(const char* name, const char* const* dirs, size_t } // namespace wasi_r2r // Exported so the offline merge step can discover the buffer's address and wire it to the R2R image's -// imageBase global. Defined outside the namespace with C linkage so the export name is exactly -// "wasi_r2r_image_base" (the surgery step targets this symbol). +// __memory_base global. Defined outside the namespace with C linkage so the export name is exactly +// "wasi_r2r_image_base" (the merge step targets this symbol). +// +// NOTE: this is an external-linkage definition in a header, as is the WASI_R2R_IMAGE_CAP buffer above. +// That is safe only because the two includers -- corerun.cpp and wasihost.cpp -- link into separate +// binaries. A second includer in either binary is a duplicate-symbol error (loud) but would also add +// another cap-sized BSS buffer. Making this `static` does NOT work: the export then disappears and the +// merge step silently loses its anchor. The correct fix is to move the buffer and this definition into +// a shared .cpp compiled into both hosts; tracked as follow-up. extern "C" __attribute__((export_name("wasi_r2r_image_base"))) uint32_t wasi_r2r_image_base(void) { return (uint32_t)(uintptr_t)&wasi_r2r::g_wasi_r2r_image[0]; From 929d4ab56785186e695fe73604bbb309b0047d57 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 28 Aug 2026 11:15:30 -0500 Subject: [PATCH 08/17] [wasm][R2R] Give the per-app WASI host the link flags its probe needs, and make the cost opt-in Review found that wasihost -- the host real WASI apps link against -- carried the R2R probe but none of the link flags that let a composite be spliced into it. WasiApp.CoreCLR.targets had zero occurrences of --table-base, --export-table, --export=__stack_pointer or the rtlrestorecontext tag export, so the probe was present and could never be satisfied: pipeline-shim.sh would fail its table-size precondition, and an app that got past that would silently interpret everything. Only the corerun test host could actually run R2R. Rather than make that unconditional, both hosts now gate it: - corerun: CORERUN_WASI_COMPOSITE_R2R (default ON, since corerun is the host this is developed against) plus CORERUN_WASI_R2R_TABLE_BASE. When OFF, the table reservation and exports are dropped and the probe's staging buffer shrinks to a stub via WASI_R2R_IMAGE_CAP=64 -- the probe still compiles and simply declines. - apps: WasiEnableCompositeR2R (default OFF) plus WasiCompositeR2RTableBase. That addresses the second half of the finding. The 16 MB staging buffer and 65,536 reserved table slots were previously paid by every WASI host binary whether or not a composite was ever merged; the buffer alone is ~12.5% of --initial-memory. Both arms measured rather than assumed: ON -> table 71834/71834, __stack_pointer / __indirect_function_table / __coreclr_wasm_rtlrestorecontext_tag exported OFF -> table 6298/6298, none of those exports, builds clean with the stub buffer End to end still passes on the default build: pipeline reports payload=3097056 cap=16777216, VALID, and the run reports four assemblies R2R-active. Also drops the "PROTOTYPE" labels, replacing them with what a reader actually needs: that the splice is hand-driven with no SDK path yet, and that both hosts must be linked with the matching flags or the probe can never be satisfied. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b49a31b-9632-4a48-bab4-bfcc98487a5f --- src/coreclr/hosts/corerun/CMakeLists.txt | 48 ++++++++++++++------ src/coreclr/hosts/corerun/corerun.cpp | 2 +- src/coreclr/hosts/corerun/wasi_r2r_probe.hpp | 8 +++- src/mono/wasi/build/WasiApp.CoreCLR.targets | 17 +++++++ 4 files changed, 58 insertions(+), 17 deletions(-) diff --git a/src/coreclr/hosts/corerun/CMakeLists.txt b/src/coreclr/hosts/corerun/CMakeLists.txt index b2ca3a565ac195..f1e07ea9851917 100644 --- a/src/coreclr/hosts/corerun/CMakeLists.txt +++ b/src/coreclr/hosts/corerun/CMakeLists.txt @@ -4,6 +4,16 @@ set(CMAKE_INCLUDE_CURRENT_DIR ON) set(CORERUN_IN_BROWSER 0) +# Link corerun so a composite ReadyToRun image can be spliced into it afterwards (see +# eng/wasi-r2r/README.md). ON by default because corerun is the host this work is developed and +# tested against. Turning it OFF drops the table reservation and shrinks the probe's staging buffer +# to a stub, for a WASI corerun that will never be spliced. +option(CORERUN_WASI_COMPOSITE_R2R "Reserve table slots and export the globals a spliced R2R composite needs" ON) +# Table slots 1..N-1 are reserved for the composite's ACTIVE element segment, which the engine installs +# at instantiation, so the table must already be large enough. Reserve by the composite's FUNCTION +# count, not its assembly count; eng/wasi-r2r/pipeline-shim.sh checks this and names the value needed. +set(CORERUN_WASI_R2R_TABLE_BASE "65537" CACHE STRING "First table slot for corerun's own address-taken functions") + if(CLR_CMAKE_HOST_WIN32) add_definitions(-DFX_VER_INTERNALNAME_STR=corerun.exe) else() @@ -164,21 +174,29 @@ else() # object and the descriptor never reaches the final module. Mirrors the native # `-Wl,-u,DotNetRuntimeContractDescriptor` used on other non-browser targets. -Wl,--export=GetDotNetRuntimeContractDescriptor - # [wasi][prototype] Export the runtime-owned async continuation global (#131167, - # helpers.cpp) so the offline R2R merge can wire each composite's webcil.asyncContinuation - # import to this same global (the interp<->R2R accessors use it), and so --gc-sections - # doesn't drop it. - -Wl,--export=__async_continuation - # [wasi] Supply the R2R composite's imports at link time. --table-base reserves the - # low table slots 1..N-1 for a merged composite's element segment (which crossgen2 now - # emits ACTIVE, so the engine installs it at instantiation and the table must already - # be large enough), moving corerun's own address-taken functions above it. The table - # stays fixed-size so the engine can still validate it statically. - # Reserve by the composite's FUNCTION count, not its assembly count. - -Wl,--table-base=65537 - -Wl,--export-table - -Wl,--export=__stack_pointer - -Wl,--export=__coreclr_wasm_rtlrestorecontext_tag) + # [wasi] Export the runtime-owned async continuation global (#131167, helpers.cpp) so the + # offline R2R merge can wire each composite's webcil.asyncContinuation import to this same + # global (the interp<->R2R accessors use it), and so --gc-sections doesn't drop it. + -Wl,--export=__async_continuation) + + if (CORERUN_WASI_COMPOSITE_R2R) + # Supply the rest of a spliced composite's imports at link time. --table-base reserves + # the low table slots for the composite's ACTIVE element segment and moves corerun's own + # address-taken functions above it; the table stays fixed-size so the engine can still + # validate it statically. These must match the flags in + # src/mono/wasi/build/WasiApp.CoreCLR.targets, which links the per-app host with the + # same probe. + target_link_options(corerun PRIVATE + -Wl,--table-base=${CORERUN_WASI_R2R_TABLE_BASE} + -Wl,--export-table + -Wl,--export=__stack_pointer + -Wl,--export=__coreclr_wasm_rtlrestorecontext_tag) + else() + # No splice for this host: keep the probe compiled (it simply finds no image and + # declines) but drop its staging buffer, which is otherwise WASI_R2R_IMAGE_CAP of BSS + # taken out of --initial-memory whether or not R2R is ever used. + target_compile_definitions(corerun PRIVATE WASI_R2R_IMAGE_CAP=64u) + endif() endif() if (CORERUN_IN_BROWSER) diff --git a/src/coreclr/hosts/corerun/corerun.cpp b/src/coreclr/hosts/corerun/corerun.cpp index 2f8e170722ca43..93184a93628d74 100644 --- a/src/coreclr/hosts/corerun/corerun.cpp +++ b/src/coreclr/hosts/corerun/corerun.cpp @@ -308,7 +308,7 @@ static char* s_core_root_path = nullptr; extern "C" bool BrowserHost_ExternalAssemblyProbe(const char* pathPtr, /*out*/ void **outDataStartPtr, /*out*/ int64_t* outSize); #endif // TARGET_BROWSER -// PROTOTYPE: WASI R2R external-assembly probe, shared with the wasihost corehost (libWasiHost.a) +// WASI R2R external-assembly probe, shared with the wasihost corehost (libWasiHost.a) // so both hosts serve R2R identically. See wasi_r2r_probe.hpp. #include "wasi_r2r_probe.hpp" diff --git a/src/coreclr/hosts/corerun/wasi_r2r_probe.hpp b/src/coreclr/hosts/corerun/wasi_r2r_probe.hpp index 705eac3791e966..86b531525ec99f 100644 --- a/src/coreclr/hosts/corerun/wasi_r2r_probe.hpp +++ b/src/coreclr/hosts/corerun/wasi_r2r_probe.hpp @@ -1,12 +1,18 @@ // Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. -// PROTOTYPE: statically-composed WASI R2R external-assembly probe, shared by every CoreCLR-WASI host +// Statically-composed WASI R2R external-assembly probe, shared by every CoreCLR-WASI host // (the standalone corerun executable and the per-app-linked wasihost corehost / libWasiHost.a). The // probe is a host_runtime_contract::external_assembly_probe callback: the runtime calls out to it to // obtain the composite R2R webcil image and the per-assembly stubs. Keeping it here (rather than in a // single host) means both hosts serve R2R identically instead of one silently falling back to interp. // +// The splice that populates it is hand-driven (eng/wasi-r2r/pipeline-shim.sh); there is no SDK path +// for WASI R2R yet, so this serves the runtime tests and the development loop rather than shipping +// apps. Both hosts must be linked with the flags that supply a composite's imports -- see +// CORERUN_WASI_COMPOSITE_R2R in corerun/CMakeLists.txt and WasiEnableCompositeR2R in +// WasiApp.CoreCLR.targets. Without them this probe compiles but can never be satisfied. +// // Requires corerun.hpp to be included first (for pal::try_map_file_readonly). Include exactly once per // host translation unit; the internal-linkage buffer/functions then give one instance per host binary. diff --git a/src/mono/wasi/build/WasiApp.CoreCLR.targets b/src/mono/wasi/build/WasiApp.CoreCLR.targets index 87e62933a58abd..8bb6de3f34541f 100644 --- a/src/mono/wasi/build/WasiApp.CoreCLR.targets +++ b/src/mono/wasi/build/WasiApp.CoreCLR.targets @@ -282,6 +282,15 @@ <_WasiRelinkOutput>$(WasmAppDir)managed\corerun + + false + + 65537 @@ -292,6 +301,14 @@ <_WasiRelinkLinkFlags Include="-Wl,--export=__async_continuation" /> + + <_WasiRelinkLinkFlags Include="-Wl,--table-base=$(WasiCompositeR2RTableBase)" Condition="'$(WasiEnableCompositeR2R)' == 'true'" /> + <_WasiRelinkLinkFlags Include="-Wl,--export-table" Condition="'$(WasiEnableCompositeR2R)' == 'true'" /> + <_WasiRelinkLinkFlags Include="-Wl,--export=__stack_pointer" Condition="'$(WasiEnableCompositeR2R)' == 'true'" /> + <_WasiRelinkLinkFlags Include="-Wl,--export=__coreclr_wasm_rtlrestorecontext_tag" Condition="'$(WasiEnableCompositeR2R)' == 'true'" /> <_WasiRelinkLinkFlags Include="-fwasm-exceptions" /> <_WasiRelinkLinkFlags Include="-mllvm" /> <_WasiRelinkLinkFlags Include="-wasm-use-legacy-eh=false" /> From 75389e66409b334144d6c7c9d30f4d3acdd0742c Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 28 Aug 2026 11:22:52 -0500 Subject: [PATCH 09/17] [wasm][R2R] Make the host the single source of the splice parameters, and fix the table bound The splice carried its own copies of values the host owns: the table base the composite installs at, and the staging buffer's capacity. Nothing cross-checked them, and a mismatch is not an error but a wrong-function dispatch -- measured earlier as `wasm trap: indirect call type mismatch`, which points nowhere near its cause. The host now exports wasi_r2r_image_cap and wasi_r2r_table_base alongside the existing wasi_r2r_image_base, and pipeline-shim.sh reads all three out of the linked binary. It holds no default for any of them; a host that does not export them fails with a message naming the flag that was missing (CORERUN_WASI_COMPOSITE_R2R / WasiEnableCompositeR2R) rather than producing an image that looks fine. This also fixes a real bug in the precondition added with that script. It compared the composite against the table's TOTAL size, but the composite must end before the host's OWN element segment begins -- the host's functions sit above the reserved region, and both segments are ACTIVE in the merged module, so an overlap silently overwrites the host's function pointers instead of failing to link. With --table-base=65537 the real bound is 65537, not the 71834-entry table; a composite of 65537..71833 functions would have passed. The boundary is now derived from the host's element segment offset rather than from the link flag, so it cannot drift from what was actually linked. Verified both ways rather than by inspection. With the default build the composite needs slots 1..52617 against a boundary of 65537 and the run reports four assemblies R2R-active. Rebuilt with -DCORERUN_WASI_R2R_TABLE_BASE=1000, the check fires, names the overlap, prints the value needed for both hosts, and exits 1. One trap worth recording: extracting these values with `grep -oE '[0-9]+'` picks up the 32 in "i32" before the operand. That produced reservedSlots=32 and a confident, entirely wrong overlap error on the first run here -- the second time today the same substring bit this pipeline. All operand extraction uses sed with an anchored pattern now. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b49a31b-9632-4a48-bab4-bfcc98487a5f --- eng/wasi-r2r/pipeline-shim.sh | 90 +++++++++++--------- src/coreclr/hosts/corerun/wasi_r2r_probe.hpp | 14 +++ 2 files changed, 66 insertions(+), 38 deletions(-) diff --git a/eng/wasi-r2r/pipeline-shim.sh b/eng/wasi-r2r/pipeline-shim.sh index ce93f068b27508..97c230d56848a3 100755 --- a/eng/wasi-r2r/pipeline-shim.sh +++ b/eng/wasi-r2r/pipeline-shim.sh @@ -21,10 +21,9 @@ COMP=${COMP:-$ROOT/r2rtest/out2/composite-r2r.wasm} CORERUN=${CORERUN:-$ROOT/artifacts/obj/coreclr/wasi.wasm.Release/hosts/corerun/corerun} D=${OUTDIR:-$ROOT/r2rtest/shimout} -# The table slot at which the composite installs. Must match WASI_R2R_TABLE_BASE in -# corerun/wasi_r2r_probe.hpp, which patches it into the webcil header at payload offset 28. -# Under -Wl,--table-base=N the linker leaves slots 1..N-1 free, so this is always 1. -TABLE_BASE=${TABLE_BASE:-1} +# imageBase, tableBase and the buffer cap are all read from the linked host below -- this script +# deliberately holds no copy of any of them. The host is the single source of truth; anything it does +# not export is a build-time error rather than a silently mismatched image. [ -f "$COMP" ] || { echo "error: composite not found at '$COMP'" >&2; exit 1; } [ -f "$CORERUN" ] || { echo "error: corerun not found at '$CORERUN'" >&2; exit 1; } @@ -36,49 +35,64 @@ rm -rf "$D"; mkdir -p "$D" wasm-tools component unbundle "$CORERUN" --module-dir "$D" -o /dev/null >/dev/null 2>&1 MAIN=$(ls "$D"/*module0*.wasm | head -1) -# 2. Read the image base out of the LINKED host. wasi_r2r_image_base's body is a single -# i32.const holding &g_wasi_r2r_image[0], so it decodes statically with no instantiation. -# NOTE: use sed, not awk. The awk form in pipeline-sym.sh silently yields an EMPTY string -# under BSD awk (the macOS default), which would feed an empty base to the shim. -IDX=$(wasm-objdump -j Export -x "$MAIN" | grep -i wasi_r2r_image_base | grep -oE 'func\[[0-9]+\]' | grep -oE '[0-9]+' || true) -case "$IDX" in ''|*[!0-9]*) - echo "error: the host does not export 'wasi_r2r_image_base'." >&2 - echo " Without it the merge has no anchor for __memory_base. Check that the probe header is" >&2 - echo " included and that the export survived --gc-sections." >&2 - exit 1;; -esac -ADDR=$(wasm-objdump -d "$MAIN" | grep -A1 "func\[$IDX\] " \ - | grep 'i32\.const' | sed -E 's/.*i32\.const +([0-9]+).*/\1/' || true) -case "$ADDR" in ''|*[!0-9]*) - echo "error: could not extract imageBase from wasi_r2r_image_base (got '$ADDR')." >&2 - exit 1;; -esac - -TBL=$(wasm-objdump -x "$MAIN" | grep -iE "^ - table\[0\]" | grep -oE "initial=[0-9]+" | grep -oE "[0-9]+") +# 2. Read the R2R parameters out of the LINKED host. Each is exported as a function whose body is a +# single i32.const, so they decode statically with no instantiation. The host owns these values; +# this script must not carry its own copy of any of them, or a rebuild with different settings +# silently produces a mismatched image. +# NOTE: use sed, not awk. The awk form in the original pipeline silently yields an EMPTY string +# under BSD awk (the macOS default), which would feed an empty value downstream. +read_i32_export() { # $1=module $2=export name -> prints the i32.const in its body + local _idx + _idx=$(wasm-objdump -j Export -x "$1" | grep -i "$2" | grep -oE 'func\[[0-9]+\]' | grep -oE '[0-9]+' || true) + case "$_idx" in ''|*[!0-9]*) return 1;; esac + wasm-objdump -d "$1" | grep -A1 "func\[$_idx\] <$2>" \ + | grep 'i32\.const' | sed -E 's/.*i32\.const +([0-9]+).*/\1/' || true +} + +ADDR=$(read_i32_export "$MAIN" wasi_r2r_image_base || true) +CAP=$(read_i32_export "$MAIN" wasi_r2r_image_cap || true) +TABLE_BASE=$(read_i32_export "$MAIN" wasi_r2r_table_base || true) +for _v in ADDR:"$ADDR" CAP:"$CAP" TABLE_BASE:"$TABLE_BASE"; do + case "${_v#*:}" in ''|*[!0-9]*) + echo "error: the host does not export ${_v%%:*} as an R2R parameter." >&2 + echo " Link it with CORERUN_WASI_COMPOSITE_R2R=ON (corerun) or WasiEnableCompositeR2R=true" >&2 + echo " (apps); without those flags the probe is present but can never be satisfied." >&2 + exit 1;; + esac +done + +# The composite installs at TABLE_BASE and must end before the host's OWN element segment begins -- +# not merely inside the table. Both are ACTIVE segments in the merged module, so an overlap silently +# overwrites the host's function pointers rather than failing to link. Derive the boundary from the +# artifact rather than from --table-base, so it cannot drift from what was actually linked. +RESERVED=$(wasm-objdump -x "$MAIN" | grep -E "^ - segment\[0\] flags=0 table=0" | sed -E 's/.*init i32=([0-9]+).*/\1/' | head -1 || true) +case "$RESERVED" in ''|*[!0-9]*) RESERVED=0;; esac + NFUNC=$(wasm-objdump -h "$COMP" | grep -iE "^ Function " | grep -oE "count: [0-9]+" | grep -oE "[0-9]+") -echo "SHIM: imageBase=$ADDR tableBase=$TABLE_BASE hostTable=$TBL compositeFuncs=$NFUNC" - -# The engine applies an ACTIVE element segment at instantiation, so the host table must already -# be large enough. Too small is an instantiation failure, which is loud — but catching it here -# names the cause instead of leaving "active segments don't work". -if [ "$((TABLE_BASE + NFUNC))" -gt "$TBL" ]; then - echo "error: host table $TBL too small for $NFUNC functions at base $TABLE_BASE." >&2 - echo " Raise -Wl,--table-base in corerun/CMakeLists.txt to at least $((TABLE_BASE + NFUNC + 1))." >&2 +echo "SHIM: imageBase=$ADDR tableBase=$TABLE_BASE reservedSlots=$RESERVED compositeFuncs=$NFUNC cap=$CAP" + +if [ "$RESERVED" -eq 0 ]; then + echo "error: the host reserves no table slots (its element segment starts at 0 or was not found)." >&2 + exit 1 +fi +if [ "$((TABLE_BASE + NFUNC))" -gt "$RESERVED" ]; then + echo "error: composite needs slots $TABLE_BASE..$((TABLE_BASE + NFUNC - 1)) but the host's own" >&2 + echo " functions begin at $RESERVED. They would overlap and silently corrupt dispatch." >&2 + echo " Raise the table base to at least $((TABLE_BASE + NFUNC)):" >&2 + echo " corerun -DCORERUN_WASI_R2R_TABLE_BASE=$((TABLE_BASE + NFUNC))" >&2 + echo " apps -p:WasiCompositeR2RTableBase=$((TABLE_BASE + NFUNC))" >&2 exit 1 fi -# The payload is likewise installed by the engine, directly into the host's g_wasi_r2r_image buffer, -# BEFORE any host code runs. The host's own cap test therefore cannot protect that buffer -- by the -# time it executes, an over-cap payload has already overwritten whatever follows it. This is the only -# place the check is enforceable, so it lives here. +# The payload is installed by the engine directly into the host's staging buffer BEFORE any host code +# runs. The host's own cap test therefore cannot protect that buffer -- by the time it executes, an +# over-cap payload has already overwritten whatever follows. This is the only place it is enforceable. PAYLOAD=$(wasm-objdump -x "$COMP" | grep -iE "^ - segment\[1\]" | grep -oE "size=[0-9]+" | grep -oE "[0-9]+" | head -1 || true) -CAP=${WASI_R2R_IMAGE_CAP:-$((16 * 1024 * 1024))} if [ -n "$PAYLOAD" ] && [ "$PAYLOAD" -gt "$CAP" ]; then - echo "error: composite payload $PAYLOAD bytes exceeds the host buffer cap $CAP." >&2 + echo "error: composite payload $PAYLOAD bytes exceeds the host's staging buffer ($CAP)." >&2 echo " Raise WASI_R2R_IMAGE_CAP in corerun/wasi_r2r_probe.hpp and rebuild the host." >&2 exit 1 fi -echo "SHIM: payload=${PAYLOAD:-unknown} cap=$CAP" # 3. Generate the shim supplying the two globals wasm-ld cannot emit for a non-PIC main module. cat > "$D/shim.wat" < Date: Fri, 28 Aug 2026 13:55:24 -0500 Subject: [PATCH 10/17] [wasm][R2R] Pass -g to wasm-opt so the spliced host keeps its names wasm-merge was already given -g, but wasm-opt was not, and wasm-opt strips the name section by default. The spliced runtime therefore had no function names at all: a debugger reported every frame as anonymous, including the host's own corerun frames, which made the strongest available R2R check unusable. That check is a breakpoint on an R2R method body, and it is worth having because the alternative -- deliberately mis-setting the table base and looking for `indirect call type mismatch` -- is only sound in one direction. A trap proves dispatch happened; the absence of a trap proves nothing, because whether the off-by-one lands on a signature-incompatible function is incidental and changes when the merge renumbers. Two runs in a row were misread on that basis before it was isolated. With names restored the check works. On a 4-assembly composite, a breakpoint on Hello_Hello__Main (the R2R-compiled Main) is hit, with S_P_CoreLib_System_Environment__CallEntryPoint -- also R2R -- further up the same stack, above ExecuteInterpretedMethodWithArgs_PortableEntryPoint and below RunMain. That is direct evidence of managed code executing out of the merged image rather than of the image merely being acquired. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b49a31b-9632-4a48-bab4-bfcc98487a5f --- eng/wasi-r2r/pipeline-shim.sh | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/eng/wasi-r2r/pipeline-shim.sh b/eng/wasi-r2r/pipeline-shim.sh index 97c230d56848a3..95d253db949b3e 100755 --- a/eng/wasi-r2r/pipeline-shim.sh +++ b/eng/wasi-r2r/pipeline-shim.sh @@ -112,7 +112,9 @@ wasm-merge -g --all-features --enable-gc \ # 5. Fold global.get -> i32.const so the result is MVP-valid. Without this, wasmtime rejects the # module unless the embedder enables GC. Costs ~3.7% code size: the pass also propagates # globals into function bodies, and a multi-byte i32.const is larger than a 2-byte global.get. -wasm-opt "$D/merged.wasm" --all-features --simplify-globals -o "$D/final.wasm" +# -g preserves the name section; without it wasm-opt strips the names wasm-merge just kept, and +# every function in the spliced host becomes anonymous to a debugger. +wasm-opt "$D/merged.wasm" --all-features -g --simplify-globals -o "$D/final.wasm" # 6. Swap the merged core module back into the corerun component. python3 - "$CORERUN" "$D/final.wasm" "$D/corerun-composite.wasm" <<'PY' From e8c83ad9e27e13a20528f4301cd25b05c7d91577 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 28 Aug 2026 18:48:57 -0500 Subject: [PATCH 11/17] Record the splice's cost and name-renumbering at framework scale Measured on a 232,673-function post-#132906 composite: wasm-merge peaks at 4.25 GB RSS and wasm-opt at 2.70 GB, so a 4 GB CI container will not survive the merge. The peak is the whole working set rather than a delta, which is why it is measurable here when the earlier attempt to see a 28 MB allocation change inside crossgen2 was not. Also records that wasm-merge renumbers the name map alongside the functions -- the composite's function 0 lands at merged index 10,105 -- and that dropping -g from the fold removes the name section while wasm-tools validate still answers YES. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b49a31b-9632-4a48-bab4-bfcc98487a5f --- eng/wasi-r2r/README.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/eng/wasi-r2r/README.md b/eng/wasi-r2r/README.md index 23b35601bde5da..1e262f9a809e0f 100644 --- a/eng/wasi-r2r/README.md +++ b/eng/wasi-r2r/README.md @@ -87,6 +87,31 @@ Verify the result actually executes R2R code rather than falling back — see [Proving R2R is actually active](../../docs/workflow/building/coreclr/wasi-r2r.md#proving-r2r-is-actually-active). The activation log alone is not sufficient: it reports success as soon as the composite *loads*. +### Cost at framework scale + +Measured on a 232,673-function framework composite (post-#132906, so 4 exports and a 28.8 MB `name` +section) spliced into `corerun`: + +| step | wall | peak RSS | output | +| --- | --- | --- | --- | +| `wasm-merge -g` | — | **4.25 GB** | 134,753,587 bytes | +| `wasm-opt --simplify-globals -g` | 6.19 s | **2.70 GB** | names 34,152,317 bytes, 232,673 named | + +The peak is the whole working set, not a delta, so it is straightforward to measure and reproduce. +Fine on a dev box; **a CI container with a 4 GB limit will not survive the merge.** Size the runner +before putting this in a pipeline. + +`wasm-merge` renumbers the name map alongside the functions, verified at this scale: the composite's +function 0 lands at merged index 10,105, offset by corerun's own function count, and +`System_Console_System_Console__WriteLine` resolves at 17,514. A name section carried through +*unshifted* would have produced wrong names everywhere while still validating and still running, so +this is worth knowing rather than assuming. + +**Both `-g` flags are load-bearing.** Dropping it from the fold removes the `name` section entirely +and `wasm-tools validate` still answers `YES` — measured, not inferred. Since #132906 the name +section is the only record of function names, so a post-processing step without `-g` silently +anonymises every frame. + ## How the splice works The composite `crossgen2` emits is **self-installing**: the webcil payload is an ACTIVE data segment From a4d97631be15f8d82eaca62ffd045283dee85598 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 28 Aug 2026 18:54:18 -0500 Subject: [PATCH 12/17] Port the WASI splice to Python so it runs on Windows pipeline-shim.sh could not run on Windows: it drove the pipeline through wasm-objdump piped into grep and sed, with ls, case and a documented BSD-versus-GNU awk divergence. pipeline_shim.py parses the wasm sections directly instead, which removes the portability barrier and also drops WABT from the prerequisites -- the shim module is assembled here rather than via wat2wasm, so only wasm-tools and Binaryen remain, both of which ship Windows binaries. Parsing rather than scraping fixes a real bug. The payload cap check scraped segment[1] positionally and guarded itself with -n, so a layout change would not fail the build -- it would silently skip the check, on the one constraint the comment above it calls "the only place it is enforceable", because the engine installs the payload before any host code runs. The port selects the payload by meaning, as the composite's single active data segment, and errors when that does not hold. Its neighbour twelve lines up already failed closed, so the two checks in one file had opposite polarity. Equivalence is measured, not argued: from identical inputs the Python and shell pipelines produce a byte-identical component, sha256 6157cbfb..., 57,930,452 bytes, and both report the same parsed bases. Also exercised at framework scale, where the parsed payload of 15,894,768 bytes matches the value independently read out of the composite's webcilCount segment. Negative controls, since a guard that cannot fire proves nothing: on a module with no active data segment the port exits 1 naming the problem, where the old scrape returns an empty string and skips the check. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b49a31b-9632-4a48-bab4-bfcc98487a5f --- eng/wasi-r2r/README.md | 32 +- eng/wasi-r2r/pipeline-shim.sh | 150 ------ eng/wasi-r2r/pipeline_shim.py | 459 +++++++++++++++++++ src/coreclr/hosts/corerun/CMakeLists.txt | 2 +- src/coreclr/hosts/corerun/wasi_r2r_probe.hpp | 6 +- src/mono/wasi/build/WasiApp.CoreCLR.targets | 2 +- 6 files changed, 484 insertions(+), 167 deletions(-) delete mode 100755 eng/wasi-r2r/pipeline-shim.sh create mode 100644 eng/wasi-r2r/pipeline_shim.py diff --git a/eng/wasi-r2r/README.md b/eng/wasi-r2r/README.md index 1e262f9a809e0f..4378baa3d7848f 100644 --- a/eng/wasi-r2r/README.md +++ b/eng/wasi-r2r/README.md @@ -21,13 +21,15 @@ does not work. This README only covers the tools in this directory. | Path | Purpose | | --- | --- | -| `pipeline-shim.sh` | The splice pipeline: unbundle → extract image base → generate shim → `wasm-merge` → `wasm-opt` fold → module-swap. | +| `pipeline_shim.py` | The splice pipeline: unbundle → extract image base → generate shim → `wasm-merge` → `wasm-opt` fold → module-swap. | | `comp.rsp.template` | `crossgen2` composite response file; replace `@ROOT@` with your worktree root. | ## Prerequisites -- `wasm-tools`, `wasm-merge` and `wasm-opt` (Binaryen), and `wasm-objdump` / `wat2wasm` (WABT) on - `PATH`, plus `python3`. `pipeline-shim.sh` fails fast if any are missing. +- `wasm-tools` and Binaryen (`wasm-merge`, `wasm-opt`) on `PATH`, plus Python 3.8+. + `pipeline_shim.py` fails fast if any are missing. **WABT is not required** — the shim is + assembled directly and every value that used to come from `wasm-objdump` is parsed from the + module, which is also what lets the pipeline run on Windows. - `wasmtime` on `PATH` for running the result. There is no longer an out-of-repo dependency. The pipeline previously required `Nesm.dll` (a wasm @@ -73,15 +75,16 @@ runs clean, the composite was simply never delivered to the runtime, and you nee ## Usage -`pipeline-shim.sh` derives `ROOT` from the repo root above it, so from a worktree with a matching +`pipeline_shim.py` derives `ROOT` from the repo root above it, so from a worktree with a matching build already in `artifacts/` it is just: ```bash -eng/wasi-r2r/pipeline-shim.sh +python3 eng/wasi-r2r/pipeline_shim.py ``` -Every input is overridable by environment variable — see the header comment in the script. -It prints the resolved bases, then `VALID` and the output path on success. +Every input is overridable by environment variable (`COMP`, `CORERUN`, `OUTDIR`, `ROOT`) — see the +module docstring. It prints the resolved bases, then `VALID` and the output path on success, and +exits non-zero with a specific message on any failure. Verify the result actually executes R2R code rather than falling back — see [Proving R2R is actually active](../../docs/workflow/building/coreclr/wasi-r2r.md#proving-r2r-is-actually-active). @@ -135,7 +138,7 @@ That covers `memory`, `__indirect_function_table`, `__stack_pointer`, **The two it cannot supply are `__memory_base` and `__table_base`.** `wasm-ld` creates those globals only in PIC mode, and a wasm global whose initializer is a data symbol's address is not expressible -from C — which is exactly what `surgery` used to inject post-link. `pipeline-shim.sh` generates a +from C — which is exactly what `surgery` used to inject post-link. `pipeline_shim.py` generates a six-line shim module exporting them as constants and merges it as a third input, which retired `surgery`. @@ -193,10 +196,15 @@ was already how `surgery` got its argument. `wasi_r2r_image_base`'s body is a si instantiation. Two things to carry forward: - `wasm-tools component unbundle` is **mandatory** first — `corerun` is a WASI component and - `wasm-objdump` rejects components outright. -- Extract with `sed`, not `awk`. The `awk` form the old pipeline used silently yields an **empty - string** under BSD `awk` (the macOS default), which would feed an empty base downstream rather than - failing. `pipeline-shim.sh` validates that the result is numeric. + core-module readers reject components outright. +- The old shell pipeline extracted these values by scraping `wasm-objdump` text, and that is + where its sharpest edges were: the `awk` form silently yielded an **empty string** under BSD + `awk` (the macOS default), and the payload-size scrape selected `segment[1]` positionally and + skipped its own cap check when the scrape came back empty. `pipeline_shim.py` parses the + sections instead and selects by meaning — the payload is "the one active data segment", not + an index — so a layout change is an error rather than a silently skipped check. The general + lesson outlives the port: **scraping a disassembler's text makes a missing value + indistinguishable from a zero.** Measured on the real 36 MB corerun: table `6298/6298` → `71834/71834` with `--table-base=65537`, exports 6 → 9, and the run still passes with `DOTNET_ReadyToRun=0` (verified against a same-binary diff --git a/eng/wasi-r2r/pipeline-shim.sh b/eng/wasi-r2r/pipeline-shim.sh deleted file mode 100755 index 95d253db949b3e..00000000000000 --- a/eng/wasi-r2r/pipeline-shim.sh +++ /dev/null @@ -1,150 +0,0 @@ -#!/usr/bin/env bash -# Splice a wasm R2R composite into corerun using only stock tooling — no nesm. -# -# Replaces pipeline-sym.sh (surgery + activate) for composites emitted by a crossgen2 that -# produces SELF-INSTALLING images: the webcil payload as an ACTIVE data segment at -# (global.get __memory_base) and the R2R function table as an ACTIVE element segment at -# (global.get __table_base). The engine installs both at instantiation. -# -# corerun supplies five of the composite's seven imports directly (memory, __stack_pointer, -# __indirect_function_table, __coreclr_wasm_rtlrestorecontext_tag, __async_continuation). The -# remaining two are the base globals, which wasm-ld only creates in PIC mode — so a generated -# shim module supplies them instead. That is what surgery used to do by post-link injection. -# -# Requires: wasm-tools, wabt (wasm-objdump, wat2wasm), binaryen (wasm-merge, wasm-opt), python3. -# -# COMP= CORERUN= ./pipeline-shim.sh -set -euo pipefail - -ROOT=${ROOT:-$(cd "$(dirname "$0")/../.." && pwd)} -COMP=${COMP:-$ROOT/r2rtest/out2/composite-r2r.wasm} -CORERUN=${CORERUN:-$ROOT/artifacts/obj/coreclr/wasi.wasm.Release/hosts/corerun/corerun} -D=${OUTDIR:-$ROOT/r2rtest/shimout} - -# imageBase, tableBase and the buffer cap are all read from the linked host below -- this script -# deliberately holds no copy of any of them. The host is the single source of truth; anything it does -# not export is a build-time error rather than a silently mismatched image. - -[ -f "$COMP" ] || { echo "error: composite not found at '$COMP'" >&2; exit 1; } -[ -f "$CORERUN" ] || { echo "error: corerun not found at '$CORERUN'" >&2; exit 1; } - -rm -rf "$D"; mkdir -p "$D" - -# 1. Unbundle the corerun component -> core module. Mandatory: corerun is a WASI component and -# wasm-objdump rejects components outright ("wasm components are not yet supported"). -wasm-tools component unbundle "$CORERUN" --module-dir "$D" -o /dev/null >/dev/null 2>&1 -MAIN=$(ls "$D"/*module0*.wasm | head -1) - -# 2. Read the R2R parameters out of the LINKED host. Each is exported as a function whose body is a -# single i32.const, so they decode statically with no instantiation. The host owns these values; -# this script must not carry its own copy of any of them, or a rebuild with different settings -# silently produces a mismatched image. -# NOTE: use sed, not awk. The awk form in the original pipeline silently yields an EMPTY string -# under BSD awk (the macOS default), which would feed an empty value downstream. -read_i32_export() { # $1=module $2=export name -> prints the i32.const in its body - local _idx - _idx=$(wasm-objdump -j Export -x "$1" | grep -i "$2" | grep -oE 'func\[[0-9]+\]' | grep -oE '[0-9]+' || true) - case "$_idx" in ''|*[!0-9]*) return 1;; esac - wasm-objdump -d "$1" | grep -A1 "func\[$_idx\] <$2>" \ - | grep 'i32\.const' | sed -E 's/.*i32\.const +([0-9]+).*/\1/' || true -} - -ADDR=$(read_i32_export "$MAIN" wasi_r2r_image_base || true) -CAP=$(read_i32_export "$MAIN" wasi_r2r_image_cap || true) -TABLE_BASE=$(read_i32_export "$MAIN" wasi_r2r_table_base || true) -for _v in ADDR:"$ADDR" CAP:"$CAP" TABLE_BASE:"$TABLE_BASE"; do - case "${_v#*:}" in ''|*[!0-9]*) - echo "error: the host does not export ${_v%%:*} as an R2R parameter." >&2 - echo " Link it with CORERUN_WASI_COMPOSITE_R2R=ON (corerun) or WasiEnableCompositeR2R=true" >&2 - echo " (apps); without those flags the probe is present but can never be satisfied." >&2 - exit 1;; - esac -done - -# The composite installs at TABLE_BASE and must end before the host's OWN element segment begins -- -# not merely inside the table. Both are ACTIVE segments in the merged module, so an overlap silently -# overwrites the host's function pointers rather than failing to link. Derive the boundary from the -# artifact rather than from --table-base, so it cannot drift from what was actually linked. -RESERVED=$(wasm-objdump -x "$MAIN" | grep -E "^ - segment\[0\] flags=0 table=0" | sed -E 's/.*init i32=([0-9]+).*/\1/' | head -1 || true) -case "$RESERVED" in ''|*[!0-9]*) RESERVED=0;; esac - -NFUNC=$(wasm-objdump -h "$COMP" | grep -iE "^ Function " | grep -oE "count: [0-9]+" | grep -oE "[0-9]+") -echo "SHIM: imageBase=$ADDR tableBase=$TABLE_BASE reservedSlots=$RESERVED compositeFuncs=$NFUNC cap=$CAP" - -if [ "$RESERVED" -eq 0 ]; then - echo "error: the host reserves no table slots (its element segment starts at 0 or was not found)." >&2 - exit 1 -fi -if [ "$((TABLE_BASE + NFUNC))" -gt "$RESERVED" ]; then - echo "error: composite needs slots $TABLE_BASE..$((TABLE_BASE + NFUNC - 1)) but the host's own" >&2 - echo " functions begin at $RESERVED. They would overlap and silently corrupt dispatch." >&2 - echo " Raise the table base to at least $((TABLE_BASE + NFUNC)):" >&2 - echo " corerun -DCORERUN_WASI_R2R_TABLE_BASE=$((TABLE_BASE + NFUNC))" >&2 - echo " apps -p:WasiCompositeR2RTableBase=$((TABLE_BASE + NFUNC))" >&2 - exit 1 -fi - -# The payload is installed by the engine directly into the host's staging buffer BEFORE any host code -# runs. The host's own cap test therefore cannot protect that buffer -- by the time it executes, an -# over-cap payload has already overwritten whatever follows. This is the only place it is enforceable. -PAYLOAD=$(wasm-objdump -x "$COMP" | grep -iE "^ - segment\[1\]" | grep -oE "size=[0-9]+" | grep -oE "[0-9]+" | head -1 || true) -if [ -n "$PAYLOAD" ] && [ "$PAYLOAD" -gt "$CAP" ]; then - echo "error: composite payload $PAYLOAD bytes exceeds the host's staging buffer ($CAP)." >&2 - echo " Raise WASI_R2R_IMAGE_CAP in corerun/wasi_r2r_probe.hpp and rebuild the host." >&2 - exit 1 -fi - -# 3. Generate the shim supplying the two globals wasm-ld cannot emit for a non-PIC main module. -cat > "$D/shim.wat" <&1 | tail -1 - -# 5. Fold global.get -> i32.const so the result is MVP-valid. Without this, wasmtime rejects the -# module unless the embedder enables GC. Costs ~3.7% code size: the pass also propagates -# globals into function bodies, and a multi-byte i32.const is larger than a 2-byte global.get. -# -g preserves the name section; without it wasm-opt strips the names wasm-merge just kept, and -# every function in the spliced host becomes anonymous to a debugger. -wasm-opt "$D/merged.wasm" --all-features -g --simplify-globals -o "$D/final.wasm" - -# 6. Swap the merged core module back into the corerun component. -python3 - "$CORERUN" "$D/final.wasm" "$D/corerun-composite.wasm" <<'PY' -import sys -cp, mp, op = sys.argv[1:4] -merged = open(mp, 'rb').read(); data = open(cp, 'rb').read() -def wl(v): - o = bytearray() - while True: - b = v & 0x7f; v >>= 7 - if v: o.append(b | 0x80) - else: o.append(b); break - return bytes(o) -def rl(d, p): - r = s = 0 - while True: - b = d[p]; p += 1; r |= (b & 0x7f) << s; s += 7 - if not (b & 0x80): break - return r, p -out = bytearray(data[:8]); pos = 8; sw = False -while pos < len(data): - sid = data[pos]; ss = pos; pos += 1 - size, pos = rl(data, pos) - if sid == 1 and not sw: - out.append(1); out += wl(len(merged)); out += merged; sw = True - else: - out += data[ss:pos+size] - pos += size -open(op, 'wb').write(out) -PY - -wasm-tools validate --features all "$D/corerun-composite.wasm" >/dev/null 2>&1 && echo "VALID" || echo "INVALID" -echo "OUT: $D/corerun-composite.wasm" diff --git a/eng/wasi-r2r/pipeline_shim.py b/eng/wasi-r2r/pipeline_shim.py new file mode 100644 index 00000000000000..72445fd9e2289a --- /dev/null +++ b/eng/wasi-r2r/pipeline_shim.py @@ -0,0 +1,459 @@ +#!/usr/bin/env python3 +"""Splice a wasm R2R composite into corerun using only stock tooling. + +Replaces pipeline-shim.sh. Same pipeline, but it parses wasm sections directly instead of +scraping `wasm-objdump` output, which makes it run on Windows and lets every lookup select +by meaning rather than by position. + +The composite crossgen2 emits is SELF-INSTALLING: the webcil payload is an ACTIVE data +segment at (global.get __memory_base) and the R2R function table is an ACTIVE element +segment at (global.get __table_base). The engine installs both at instantiation. + +corerun supplies five of the composite's seven imports directly (memory, __stack_pointer, +__indirect_function_table, __coreclr_wasm_rtlrestorecontext_tag, __async_continuation). +The remaining two are the base globals, which wasm-ld only creates in PIC mode -- so a +generated shim module supplies them instead. + +Requires: wasm-tools, binaryen (wasm-merge, wasm-opt). wabt is NOT required; the shim is +assembled here and every value that used to come from wasm-objdump is parsed directly. + + COMP= CORERUN= python3 pipeline_shim.py +""" + +import os +import shutil +import subprocess +import sys +from pathlib import Path + +# ---------------------------------------------------------------- wasm reading + +SEC_CUSTOM, SEC_IMPORT, SEC_FUNCTION, SEC_GLOBAL = 0, 2, 3, 6 +SEC_EXPORT, SEC_ELEMENT, SEC_CODE, SEC_DATA = 7, 9, 10, 11 + +EXTERNKIND_FUNC = 0 + + +class WasmError(Exception): + pass + + +def _uleb(data, pos): + result = shift = 0 + while True: + byte = data[pos] + pos += 1 + result |= (byte & 0x7F) << shift + shift += 7 + if not byte & 0x80: + return result, pos + + +def _sleb(data, pos): + result = shift = 0 + while True: + byte = data[pos] + pos += 1 + result |= (byte & 0x7F) << shift + shift += 7 + if not byte & 0x80: + if shift < 64 and byte & 0x40: + result -= 1 << shift + return result, pos + + +def _emit_uleb(value): + out = bytearray() + while True: + byte = value & 0x7F + value >>= 7 + if value: + out.append(byte | 0x80) + else: + out.append(byte) + return bytes(out) + + +def _emit_sleb(value): + out = bytearray() + while True: + byte = value & 0x7F + value >>= 7 + done = (value == 0 and not byte & 0x40) or (value == -1 and byte & 0x40) + out.append(byte if done else byte | 0x80) + if done: + return bytes(out) + + +class WasmModule: + """Minimal core-module reader: just enough to answer the splice's questions.""" + + def __init__(self, path): + self.path = Path(path) + self.data = self.path.read_bytes() + if self.data[:4] != b"\0asm": + raise WasmError(f"{path} is not a wasm core module (bad magic). " + "If it is a component, unbundle it first.") + self.sections = [] # (id, payload_start, payload_end) + pos = 8 + while pos < len(self.data): + sec_id = self.data[pos] + size, pos = _uleb(self.data, pos + 1) + self.sections.append((sec_id, pos, pos + size)) + pos += size + + def _section(self, sec_id): + for sid, start, end in self.sections: + if sid == sec_id: + return start, end + return None + + def _vec(self, sec_id): + span = self._section(sec_id) + if span is None: + return 0, None + count, pos = _uleb(self.data, span[0]) + return count, pos + + def func_import_count(self): + count, pos = self._vec(SEC_IMPORT) + if pos is None: + return 0 + total = 0 + for _ in range(count): + for _ in range(2): # module, name + length, pos = _uleb(self.data, pos) + pos += length + kind = self.data[pos] + pos += 1 + if kind == EXTERNKIND_FUNC: + total += 1 + _, pos = _uleb(self.data, pos) + elif kind == 1: # table + pos += 1 + limits = self.data[pos] + pos += 1 + _, pos = _uleb(self.data, pos) + if limits: + _, pos = _uleb(self.data, pos) + elif kind == 2: # memory + limits = self.data[pos] + pos += 1 + _, pos = _uleb(self.data, pos) + if limits: + _, pos = _uleb(self.data, pos) + elif kind == 3: # global + pos += 2 + elif kind == 4: # tag + pos += 1 + _, pos = _uleb(self.data, pos) + else: + raise WasmError(f"unknown import kind {kind} in {self.path.name}") + return total + + def defined_func_count(self): + count, _ = self._vec(SEC_FUNCTION) + return count + + def exports(self): + """name -> (kind, index)""" + count, pos = self._vec(SEC_EXPORT) + found = {} + if pos is None: + return found + for _ in range(count): + length, pos = _uleb(self.data, pos) + name = self.data[pos:pos + length].decode("utf-8", "replace") + pos += length + kind = self.data[pos] + pos += 1 + index, pos = _uleb(self.data, pos) + found[name] = (kind, index) + return found + + def _code_body(self, defined_index): + count, pos = self._vec(SEC_CODE) + if pos is None or defined_index >= count: + return None + for i in range(count): + size, body = _uleb(self.data, pos) + if i == defined_index: + return self.data[body:body + size] + pos = body + size + return None + + def const_i32_export(self, name): + """Value of an exported function whose whole body is `i32.const N`. + + The host publishes each splice parameter this way so it decodes statically, with no + instantiation and no copy of the value living in this script. + """ + entry = self.exports().get(name) + if entry is None or entry[0] != EXTERNKIND_FUNC: + return None + defined = entry[1] - self.func_import_count() + if defined < 0: + return None # an imported function has no body to read + body = self._code_body(defined) + if not body: + return None + local_decls, pos = _uleb(body, 0) + for _ in range(local_decls): + _, pos = _uleb(body, pos) + pos += 1 + if body[pos] != 0x41: # i32.const + return None + value, pos = _sleb(body, pos + 1) + return value if body[pos] == 0x0B else None + + def _const_offset(self, pos): + """Decode a constant init_expr. Returns (value_or_None, kind, next_pos).""" + op = self.data[pos] + if op == 0x41: # i32.const + value, pos = _sleb(self.data, pos + 1) + return value, "i32.const", pos + 1 # skip 0x0B + if op == 0x23: # global.get + index, pos = _uleb(self.data, pos + 1) + return index, "global.get", pos + 1 + raise WasmError(f"unsupported init_expr opcode 0x{op:02x} in {self.path.name}") + + def element_segments(self): + count, pos = self._vec(SEC_ELEMENT) + out = [] + if pos is None: + return out + for _ in range(count): + flags, pos = _uleb(self.data, pos) + seg = {"flags": flags, "active": flags in (0, 2, 4, 6), "offset": None, + "offset_kind": None, "count": 0} + if flags in (2, 6): + _, pos = _uleb(self.data, pos) # table index + if seg["active"]: + value, kind, pos = self._const_offset(pos) + seg["offset"], seg["offset_kind"] = value, kind + if flags in (1, 2, 5, 6): + pos += 1 # elemkind / reftype + elif flags in (3, 7): + pos += 1 + n, pos = _uleb(self.data, pos) + seg["count"] = n + for _ in range(n): + _, pos = _uleb(self.data, pos) + out.append(seg) + return out + + def data_segments(self): + count, pos = self._vec(SEC_DATA) + out = [] + if pos is None: + return out + for _ in range(count): + flags, pos = _uleb(self.data, pos) + seg = {"flags": flags, "active": flags in (0, 2), "offset": None, + "offset_kind": None, "size": 0} + if flags == 2: + _, pos = _uleb(self.data, pos) # memory index + if seg["active"]: + value, kind, pos = self._const_offset(pos) + seg["offset"], seg["offset_kind"] = value, kind + size, pos = _uleb(self.data, pos) + seg["size"] = size + pos += size + out.append(seg) + return out + + +def make_shim(memory_base, table_base): + """Assemble the two-global module wasm-ld cannot emit for a non-PIC main module. + + Hand-assembled rather than written as WAT so wabt is not a prerequisite -- one fewer + toolchain to install on Windows. Validated by the caller before it is merged. + """ + def global_entry(value): + return b"\x7f\x00" + b"\x41" + _emit_sleb(value) + b"\x0b" # i32, const, init + + globals_payload = _emit_uleb(2) + global_entry(memory_base) + global_entry(table_base) + + def export_entry(name, index): + raw = name.encode("utf-8") + return _emit_uleb(len(raw)) + raw + b"\x03" + _emit_uleb(index) + + exports_payload = (_emit_uleb(2) + export_entry("__memory_base", 0) + + export_entry("__table_base", 1)) + + def section(sec_id, payload): + return bytes([sec_id]) + _emit_uleb(len(payload)) + payload + + return (b"\0asm\x01\x00\x00\x00" + + section(SEC_GLOBAL, globals_payload) + + section(SEC_EXPORT, exports_payload)) + + +def swap_core_module(component_path, module_path, out_path): + """Replace the first core-module section of a component with the merged module.""" + component = Path(component_path).read_bytes() + merged = Path(module_path).read_bytes() + out = bytearray(component[:8]) + pos, swapped = 8, False + while pos < len(component): + sec_id = component[pos] + start = pos + size, pos = _uleb(component, pos + 1) + if sec_id == 1 and not swapped: # core module + out += bytes([1]) + _emit_uleb(len(merged)) + merged + swapped = True + else: + out += component[start:pos + size] + pos += size + if not swapped: + raise WasmError(f"{component_path} has no core-module section to replace") + Path(out_path).write_bytes(bytes(out)) + + +# ---------------------------------------------------------------- external tools + +def tool(name): + found = shutil.which(name) + if found is None: + raise WasmError(f"required tool '{name}' is not on PATH. " + "Install wasm-tools and binaryen; see eng/wasi-r2r/README.md.") + return found + + +def run(args, capture=False): + result = subprocess.run(args, check=False, text=True, + stdout=subprocess.PIPE if capture else None, + stderr=subprocess.STDOUT if capture else None) + if result.returncode != 0: + detail = f"\n{result.stdout.strip()}" if capture and result.stdout else "" + raise WasmError(f"{Path(args[0]).name} failed (exit {result.returncode}){detail}") + return result.stdout if capture else "" + + +# ---------------------------------------------------------------- pipeline + +def main(): + root = Path(os.environ.get("ROOT") or Path(__file__).resolve().parents[2]) + comp = Path(os.environ.get("COMP") or root / "r2rtest/out2/composite-r2r.wasm") + corerun = Path(os.environ.get("CORERUN") + or root / "artifacts/obj/coreclr/wasi.wasm.Release/hosts/corerun/corerun") + outdir = Path(os.environ.get("OUTDIR") or root / "r2rtest/shimout") + + for label, path in (("composite", comp), ("corerun", corerun)): + if not path.is_file(): + raise WasmError(f"{label} not found at {path}") + outdir.mkdir(parents=True, exist_ok=True) + + wasm_tools = tool("wasm-tools") + + # 1. Unbundle the corerun component -> core module. Mandatory: corerun is a WASI + # component, and a component is not a core module. + run([wasm_tools, "component", "unbundle", str(corerun), + "--module-dir", str(outdir), "-o", os.devnull], capture=True) + modules = sorted(outdir.glob("*module0*.wasm")) + if not modules: + raise WasmError(f"unbundling {corerun.name} produced no *module0*.wasm in {outdir}") + host = WasmModule(modules[0]) + + # 2. Read the R2R parameters out of the LINKED host. The host owns these values; this + # script must not carry its own copy, or a rebuild with different settings silently + # produces a mismatched image. + params = {} + for key, export in (("image_base", "wasi_r2r_image_base"), + ("cap", "wasi_r2r_image_cap"), + ("table_base", "wasi_r2r_table_base")): + value = host.const_i32_export(export) + if value is None: + raise WasmError( + f"the host does not export {export} as an R2R parameter.\n" + " Link it with CORERUN_WASI_COMPOSITE_R2R=ON (corerun) or\n" + " WasiEnableCompositeR2R=true (apps); without those flags the probe\n" + " is present but can never be satisfied.") + params[key] = value + + composite = WasmModule(comp) + + # The composite installs at table_base and must end before the host's OWN element + # segment begins -- not merely inside the table. Both are ACTIVE in the merged module, + # so an overlap silently overwrites the host's function pointers rather than failing. + host_active = [s for s in host.element_segments() + if s["active"] and s["offset_kind"] == "i32.const"] + if not host_active: + raise WasmError("the host has no active element segment, so it reserves no table " + "slots for the composite.") + reserved = min(s["offset"] for s in host_active) + + n_funcs = composite.defined_func_count() + + # The payload is the composite's ONE active data segment. Select it by meaning: the + # 9-byte webcilCount segment is passive, so index-based selection would be a positional + # assumption that breaks silently if crossgen2 ever reorders segments. + payloads = [s for s in composite.data_segments() if s["active"]] + if len(payloads) != 1: + raise WasmError( + f"expected exactly one active data segment in {comp.name} (the webcil payload), " + f"found {len(payloads)}. The composite layout changed; this check would " + "otherwise pick the wrong segment.") + payload = payloads[0]["size"] + + print(f"SHIM: imageBase={params['image_base']} tableBase={params['table_base']} " + f"reservedSlots={reserved} compositeFuncs={n_funcs} payload={payload} " + f"cap={params['cap']}") + + if params["table_base"] + n_funcs > reserved: + need = params["table_base"] + n_funcs + raise WasmError( + f"composite needs slots {params['table_base']}..{need - 1} but the host's own\n" + f" functions begin at {reserved}. They would overlap and silently corrupt\n" + f" dispatch. Raise the table base to at least {need}:\n" + f" corerun -DCORERUN_WASI_R2R_TABLE_BASE={need}\n" + f" apps -p:WasiCompositeR2RTableBase={need}") + + # The engine installs the payload into the host's staging buffer BEFORE any host code + # runs, so the host's own cap test cannot protect that buffer. This is the only place + # it is enforceable. + if payload > params["cap"]: + raise WasmError( + f"composite payload {payload} bytes exceeds the host's staging buffer " + f"({params['cap']}).\n" + " Raise WASI_R2R_IMAGE_CAP in corerun/wasi_r2r_probe.hpp and rebuild.") + + # 3. Generate the shim supplying the two globals wasm-ld cannot emit for a non-PIC main + # module, and validate it before it reaches the merge. + shim = outdir / "shim.wasm" + shim.write_bytes(make_shim(params["image_base"], params["table_base"])) + run([wasm_tools, "validate", "--features", "all", str(shim)], capture=True) + + # 4. Merge host + shim + composite. --enable-gc is needed only for the INTERMEDIATE: + # merging internalizes the imported globals, and global.get of a *defined* global is + # a constant expression only under the GC proposal. Step 5 removes that requirement. + # -g carries the name section through; see step 5 for why that matters. + merged = outdir / "merged.wasm" + run([tool("wasm-merge"), "-g", "--all-features", "--enable-gc", + str(modules[0]), "webcil", str(shim), "webcil", str(comp), "composite", + "-o", str(merged)], capture=True) + + # 5. Fold global.get -> i32.const so the result is MVP-valid; without this wasmtime + # rejects the module unless the embedder enables GC. Costs ~3.7% code size. + # -g preserves the name section. Since #132906 that section is the ONLY record of + # function names, and dropping it still validates and still runs -- the sole symptom + # is that every frame goes anonymous. + final = outdir / "final.wasm" + run([tool("wasm-opt"), str(merged), "--all-features", "-g", "--simplify-globals", + "-o", str(final)], capture=True) + + # 6. Swap the merged core module back into the corerun component. + out = outdir / "corerun-composite.wasm" + swap_core_module(corerun, final, out) + + run([wasm_tools, "validate", "--features", "all", str(out)], capture=True) + print("VALID") + print(f"OUT: {out}") + + +if __name__ == "__main__": + try: + main() + except WasmError as error: + print(f"error: {error}", file=sys.stderr) + sys.exit(1) diff --git a/src/coreclr/hosts/corerun/CMakeLists.txt b/src/coreclr/hosts/corerun/CMakeLists.txt index f1e07ea9851917..7b2d13a129c9a9 100644 --- a/src/coreclr/hosts/corerun/CMakeLists.txt +++ b/src/coreclr/hosts/corerun/CMakeLists.txt @@ -11,7 +11,7 @@ set(CORERUN_IN_BROWSER 0) option(CORERUN_WASI_COMPOSITE_R2R "Reserve table slots and export the globals a spliced R2R composite needs" ON) # Table slots 1..N-1 are reserved for the composite's ACTIVE element segment, which the engine installs # at instantiation, so the table must already be large enough. Reserve by the composite's FUNCTION -# count, not its assembly count; eng/wasi-r2r/pipeline-shim.sh checks this and names the value needed. +# count, not its assembly count; eng/wasi-r2r/pipeline_shim.py checks this and names the value needed. set(CORERUN_WASI_R2R_TABLE_BASE "65537" CACHE STRING "First table slot for corerun's own address-taken functions") if(CLR_CMAKE_HOST_WIN32) diff --git a/src/coreclr/hosts/corerun/wasi_r2r_probe.hpp b/src/coreclr/hosts/corerun/wasi_r2r_probe.hpp index fe7ddcd7039a18..a7b0d06fc45100 100644 --- a/src/coreclr/hosts/corerun/wasi_r2r_probe.hpp +++ b/src/coreclr/hosts/corerun/wasi_r2r_probe.hpp @@ -7,7 +7,7 @@ // obtain the composite R2R webcil image and the per-assembly stubs. Keeping it here (rather than in a // single host) means both hosts serve R2R identically instead of one silently falling back to interp. // -// The splice that populates it is hand-driven (eng/wasi-r2r/pipeline-shim.sh); there is no SDK path +// The splice that populates it is hand-driven (eng/wasi-r2r/pipeline_shim.py); there is no SDK path // for WASI R2R yet, so this serves the runtime tests and the development loop rather than shipping // apps. Both hosts must be linked with the flags that supply a composite's imports -- see // CORERUN_WASI_COMPOSITE_R2R in corerun/CMakeLists.txt and WasiEnableCompositeR2R in @@ -236,7 +236,7 @@ static bool WasiStaticR2RProbe(const char* name, const char* const* dirs, size_t // // NOTE: the cap test above cannot protect this buffer -- the engine installs the segment before any // host code runs, so an over-cap payload has already overwritten whatever follows by the time we look. - // The enforceable check is at build time; pipeline-shim.sh compares the payload size against the cap. + // The enforceable check is at build time; pipeline_shim.py compares the payload size against the cap. uint8_t* hdr = &g_wasi_r2r_image[0]; uint32_t existingTableBase; memcpy(&existingTableBase, hdr + WEBCIL_TABLE_BASE_OFFSET, sizeof(existingTableBase)); @@ -292,7 +292,7 @@ extern "C" __attribute__((export_name("wasi_r2r_image_base"))) uint32_t wasi_r2r // The staging buffer's capacity and the table slot the composite installs at, exported for the same // reason as the base: the splice must not carry its own copy of either. The host owns these values; -// eng/wasi-r2r/pipeline-shim.sh reads them out of the linked binary and validates the composite +// eng/wasi-r2r/pipeline_shim.py reads them out of the linked binary and validates the composite // against them, so a mismatch is a build-time error instead of a wrong-function dispatch at runtime. extern "C" __attribute__((export_name("wasi_r2r_image_cap"))) uint32_t wasi_r2r_image_cap(void) { diff --git a/src/mono/wasi/build/WasiApp.CoreCLR.targets b/src/mono/wasi/build/WasiApp.CoreCLR.targets index 8bb6de3f34541f..90c3c0fa1fa293 100644 --- a/src/mono/wasi/build/WasiApp.CoreCLR.targets +++ b/src/mono/wasi/build/WasiApp.CoreCLR.targets @@ -289,7 +289,7 @@ + eng/wasi-r2r/pipeline_shim.py checks this and names the required value if it is too small. --> 65537 From afb34157c4eaff8b1c38fd79917482d2f1678a76 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 28 Aug 2026 20:15:54 -0500 Subject: [PATCH 13/17] Let the composite patch its own webcil header The probe wrote the header's TableBase field itself, duplicating both the field offset and the value. The webcil spec already defines the export for this: a self-installing module exports patchWebcilHeader(dest, len), which stores its own __table_base at offset 28. Calling it means the composite owns the format and the host cannot drift from it. The call lives in the generated shim rather than in corerun, and that placement is forced rather than chosen. corerun is a WASI component, and an arbitrary core import is not expressible in a WIT world -- declaring it there fails the build in wasm-component-ld with "failed to decode world from module ... failed to resolve import composite::patchWebcilHeader". The shim is merged and never componentized, so the merge resolves the import and the component wrapper never sees it. Verified that wasm-merge binds a host import to a later module's export in the pipeline's argument order, and that it preserves the shim's start section. Detects the export rather than assuming it: older composites predate it, and importing a function the composite does not export would leave the import unresolved and the host unable to instantiate. When absent the host's existing fallback still writes the field, so both composite generations work. Verified end to end on the spliced WASI host, with the host fallback compiled out so only the shim could have written the field: correct output and six assemblies reporting R2R. The negative control is what makes that meaningful -- with nothing patching TableBase the same image traps with "indirect call type mismatch", which is the symptom the field exists to prevent, so the passing case had a way to fail. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 7b49a31b-9632-4a48-bab4-bfcc98487a5f --- eng/wasi-r2r/pipeline_shim.py | 89 ++++++++++++++++++++++++++--------- 1 file changed, 68 insertions(+), 21 deletions(-) diff --git a/eng/wasi-r2r/pipeline_shim.py b/eng/wasi-r2r/pipeline_shim.py index 72445fd9e2289a..3068a5a2b2d9fa 100644 --- a/eng/wasi-r2r/pipeline_shim.py +++ b/eng/wasi-r2r/pipeline_shim.py @@ -28,10 +28,12 @@ # ---------------------------------------------------------------- wasm reading -SEC_CUSTOM, SEC_IMPORT, SEC_FUNCTION, SEC_GLOBAL = 0, 2, 3, 6 -SEC_EXPORT, SEC_ELEMENT, SEC_CODE, SEC_DATA = 7, 9, 10, 11 +SEC_CUSTOM, SEC_TYPE, SEC_IMPORT, SEC_FUNCTION = 0, 1, 2, 3 +SEC_GLOBAL, SEC_EXPORT, SEC_START = 6, 7, 8 +SEC_ELEMENT, SEC_CODE, SEC_DATA = 9, 10, 11 EXTERNKIND_FUNC = 0 +WEBCIL_HEADER_SIZE = 32 class WasmError(Exception): @@ -263,30 +265,65 @@ def data_segments(self): return out -def make_shim(memory_base, table_base): - """Assemble the two-global module wasm-ld cannot emit for a non-PIC main module. +def make_shim(memory_base, table_base, patch_header): + """Assemble the module supplying what wasm-ld cannot emit for a non-PIC main module. - Hand-assembled rather than written as WAT so wabt is not a prerequisite -- one fewer - toolchain to install on Windows. Validated by the caller before it is merged. - """ - def global_entry(value): - return b"\x7f\x00" + b"\x41" + _emit_sleb(value) + b"\x0b" # i32, const, init - - globals_payload = _emit_uleb(2) + global_entry(memory_base) + global_entry(table_base) + Always exports the two base globals. When `patch_header` is set it additionally imports + the composite's `patchWebcilHeader` and calls it from a start function, so the composite + fills in its own header's TableBase field using the `__table_base` this shim defines. + That is strictly better than the host writing that field: the composite owns both the + offset and the value, so the two cannot disagree about the format. - def export_entry(name, index): - raw = name.encode("utf-8") - return _emit_uleb(len(raw)) + raw + b"\x03" + _emit_uleb(index) - - exports_payload = (_emit_uleb(2) + export_entry("__memory_base", 0) - + export_entry("__table_base", 1)) + The import lives here rather than in corerun deliberately. corerun is a WASI *component*, + and an arbitrary core import is not expressible in a WIT world -- `wasm-component-ld` + rejects it with "failed to decode world from module". The shim is merged and never + componentized, so the import is resolved by the merge and the component wrapper never + sees it. + Hand-assembled rather than written as WAT so wabt is not a prerequisite; the caller + validates the result before it reaches the merge. + """ def section(sec_id, payload): return bytes([sec_id]) + _emit_uleb(len(payload)) + payload - return (b"\0asm\x01\x00\x00\x00" - + section(SEC_GLOBAL, globals_payload) - + section(SEC_EXPORT, exports_payload)) + def name(text): + raw = text.encode("utf-8") + return _emit_uleb(len(raw)) + raw + + out = bytearray(b"\0asm\x01\x00\x00\x00") + + if patch_header: + # (i32,i32)->() for patchWebcilHeader, and ()->() for the start function. + types = _emit_uleb(2) + b"\x60\x02\x7f\x7f\x00" + b"\x60\x00\x00" + out += section(SEC_TYPE, types) + out += section(SEC_IMPORT, + _emit_uleb(1) + name("composite") + name("patchWebcilHeader") + + b"\x00" + _emit_uleb(0)) + out += section(SEC_FUNCTION, _emit_uleb(1) + _emit_uleb(1)) + + def global_entry(value): + return b"\x7f\x00\x41" + _emit_sleb(value) + b"\x0b" # i32, immutable, i32.const + + out += section(SEC_GLOBAL, + _emit_uleb(2) + global_entry(memory_base) + global_entry(table_base)) + out += section(SEC_EXPORT, + _emit_uleb(2) + + name("__memory_base") + b"\x03" + _emit_uleb(0) + + name("__table_base") + b"\x03" + _emit_uleb(1)) + + if patch_header: + # Function 0 is the import, so the start function is index 1. + out += section(SEC_START, _emit_uleb(1)) + # patchWebcilHeader(dest = __memory_base, length): its own guard is `length >= 32`, + # and it writes 4 bytes at dest+28, so the header size is the only length it needs. + body = (_emit_uleb(0) # no locals + + b"\x41" + _emit_sleb(memory_base) # i32.const dest + + b"\x41" + _emit_sleb(WEBCIL_HEADER_SIZE) # i32.const 32 + + b"\x10" + _emit_uleb(0) # call 0 + + b"\x0b") # end + out += section(SEC_CODE, _emit_uleb(1) + _emit_uleb(len(body)) + body) + + return bytes(out) def swap_core_module(component_path, module_path, out_path): @@ -420,9 +457,19 @@ def main(): # 3. Generate the shim supplying the two globals wasm-ld cannot emit for a non-PIC main # module, and validate it before it reaches the merge. + # + # If the composite exports patchWebcilHeader (the self-installing shape in + # docs/design/mono/webcil.md), have the shim call it from a start function so the + # composite writes its own TableBase field. Older composites predate that export, so + # detect rather than assume -- importing a function the composite does not export makes + # the merge leave it unresolved and the host fails to instantiate. When it is absent the + # host's own fallback in wasi_r2r_probe.hpp writes the field instead. + patch_header = "patchWebcilHeader" in composite.exports() shim = outdir / "shim.wasm" - shim.write_bytes(make_shim(params["image_base"], params["table_base"])) + shim.write_bytes(make_shim(params["image_base"], params["table_base"], patch_header)) run([wasm_tools, "validate", "--features", "all", str(shim)], capture=True) + print(f"SHIM: tableBase written by " + f"{'the composite (patchWebcilHeader)' if patch_header else 'the host (fallback)'}") # 4. Merge host + shim + composite. --enable-gc is needed only for the INTERMEDIATE: # merging internalizes the imported globals, and global.get of a *defined* global is From 0d52de35757b3b824adf114f675c71d0078003c1 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Mon, 31 Aug 2026 16:28:01 -0500 Subject: [PATCH 14/17] [wasm][R2R] Integrate composite publishing for WASI Generate the WASI composite through the standard R2R targets, size the per-app host from the completed image, compose it into the final component, and deploy component stubs alongside the retained IL assemblies. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 34cdfceb-efbb-4d53-8194-59dce8dd935b --- eng/wasi-r2r/README.md | 59 +++--- eng/wasi-r2r/pipeline_shim.py | 72 +++++-- src/coreclr/hosts/corerun/wasi_r2r_probe.hpp | 93 +++++---- .../TestCases/R2RTestSuites.cs | 1 + .../TestCasesRunner/WasmR2RAssert.cs | 97 ++++++++++ src/mono/wasi/build/WasiApp.CoreCLR.targets | 179 ++++++++++++++++-- src/mono/wasi/build/WasiApp.InTree.props | 10 + .../wasi/build/WasiApp.ReadyToRun.targets | 20 ++ src/native/corehost/wasihost/wasihost.cpp | 12 ++ .../Microsoft.NET.CrossGen.targets | 11 +- .../PrepareForReadyToRunCompilation.cs | 17 +- 11 files changed, 461 insertions(+), 110 deletions(-) create mode 100644 src/mono/wasi/build/WasiApp.ReadyToRun.targets diff --git a/eng/wasi-r2r/README.md b/eng/wasi-r2r/README.md index 4378baa3d7848f..d2fb754c9f9773 100644 --- a/eng/wasi-r2r/README.md +++ b/eng/wasi-r2r/README.md @@ -1,8 +1,8 @@ # WASI composite-R2R splice tooling -Tooling for building, splicing, and running a **composite ReadyToRun image on CoreCLR/WASI**. -It exists because WASI has no productised R2R path: the working flow is hand-driven — run -`crossgen2` directly, splice the result into `corerun`, and run it under `wasmtime`. +Tooling for composing a **composite ReadyToRun image into a CoreCLR/WASI component**. +The in-tree CoreCLR-WASI app builder invokes it when `PublishReadyToRun=true`. The shipping WASI SDK +does not select the CoreCLR app builder yet, so this remains an experimental in-tree path. Scope note, since this is easy to over-read: **the splice is a WASI requirement, not a composite requirement.** `WasiStaticR2RProbe` serves `composite-r2r.wasm` only from a baked-in buffer that the @@ -12,11 +12,6 @@ no such constraint — `crossgen2 --composite --targetos:browser` plus a flat di since [#132339](https://github.com/dotnet/runtime/pull/132339) (`-p:PublishReadyToRun=true`); that path declines composite, but only as an SDK opt-out. -**Read [`docs/workflow/building/coreclr/wasi-r2r.md`](../../docs/workflow/building/coreclr/wasi-r2r.md) first.** -That is the full playbook: build commands, crossgen2 invocations, run commands, and — most -importantly — the traps that repeatedly lead people to falsely conclude that CoreCLR R2R on WASI -does not work. This README only covers the tools in this directory. - ## Pieces | Path | Purpose | @@ -75,20 +70,20 @@ runs clean, the composite was simply never delivered to the runtime, and you nee ## Usage -`pipeline_shim.py` derives `ROOT` from the repo root above it, so from a worktree with a matching -build already in `artifacts/` it is just: +The in-tree publish path drives crossgen2, sizes the host's image buffer and table from the generated +composite, invokes the splice, and deploys the component stubs: ```bash -python3 eng/wasi-r2r/pipeline_shim.py +./dotnet.sh publish -c Release -p:TargetOS=wasi \ + -p:RuntimeFlavor=CoreCLR -p:PublishReadyToRun=true ``` -Every input is overridable by environment variable (`COMP`, `CORERUN`, `OUTDIR`, `ROOT`) — see the -module docstring. It prints the resolved bases, then `VALID` and the output path on success, and -exits non-zero with a specific message on any failure. +For manual experiments, `pipeline_shim.py` still accepts `COMP`, `CORERUN`, `OUTDIR`, and `ROOT` +through the environment. It prints the resolved bases, then `VALID` and the output path on success. -Verify the result actually executes R2R code rather than falling back — see -[Proving R2R is actually active](../../docs/workflow/building/coreclr/wasi-r2r.md#proving-r2r-is-actually-active). -The activation log alone is not sufficient: it reports success as soon as the composite *loads*. +The activation log alone is not proof that a method executed from the composite. For a deterministic +check, break on the app method's wasm function from the final component; an interpreted fallback +cannot hit a breakpoint inside the R2R body. ### Cost at framework scale @@ -157,12 +152,10 @@ Three things about this are easy to get wrong: proves nothing). The pass also propagates globals into function bodies, costing ~3.7% code size. A host that supplies the bases at *instantiation* instead — as the browser does — keeps `global.get` of an **imported** global, which is valid MVP, and pays neither cost. -- **Payload offset 28 is now a runtime responsibility.** `activate` used to bake - `WebcilHeader_1.TableBase` offline. With the segment installed by the engine, nothing writes it, and - its absence is silent: `GetTableBaseOffset` returns 0 rather than failing, and that 0 becomes - `tableBaseDelta`, shifting every R2R function index. The WASI host patches it in - [`wasi_r2r_probe.hpp`](../../src/coreclr/hosts/corerun/wasi_r2r_probe.hpp) (`WASI_R2R_TABLE_BASE`, - which must match the shim); browser calls the composite's exported `patchWebcilHeader`. +- **Payload offset 28 must be patched before the runtime reads it.** The composition shim calls the + composite's exported `patchWebcilHeader` from its start function, so the image owns its format. + [`wasi_r2r_probe.hpp`](../../src/coreclr/hosts/corerun/wasi_r2r_probe.hpp) retains a native fallback + only for older composites that do not export that function. This is measured, not argued. Setting the host's table base to 2 while the shim installs at 1 makes the run fail with `wasm trap: indirect call type mismatch` — a symptom nowhere near its cause. Note @@ -219,22 +212,18 @@ LEB encodings for the shifted function indices, not from the table itself: | 65,537 | 36,284,095 | 71,834 | | 500,001 | 36,336,407 | 506,298 | -**Size it from the composite's function count, not its assembly count.** Every function in the -composite consumes a table slot: a 4-assembly composite needs 52,637; the `System.Text.Json` test -closure needs **283,573**. Reserve generously and fail loudly when a composite exceeds it — the same -contract the 16 MB `g_wasi_r2r_image` buffer already uses on the memory side. - -**The composite half is crossgen2 work.** It would need to emit import names matching the linker's -exports, emit the payload and element segments as **active** at the reserved bases rather than -passive, and drop the `tableBase`/`imageBase` global imports since both become compile-time constants. -`WasmDataSegmentType.Active` is already modelled; only `Passive` is currently ever emitted. +**Size from the composite's function count, not its assembly count.** Every function consumes a +table slot. The publish target inspects the completed composite before linking the host, reserves +exactly `function count + 1` table slots, and supplies a strong image-buffer symbol whose size exactly +matches the active payload. Non-R2R app links use the host archive's 64-byte weak fallback instead of +paying a fixed 16 MiB reservation. That leaves the whole splice as `wasm-tools component unbundle` → `wasm-merge` → reassemble, all standard tooling. -> **Do not solve this with a `start` function.** A composite that grows its own table and populates it +> **Do not populate the image or table from a `start` function.** A composite that grows its own table and populates it > via `table.init`/`memory.init` at startup does work — verified end-to-end, including that > `wasm-merge` correctly combines two start functions. But it replaces declarative, engine-applied > installation with guest code mutating its own dispatch table at runtime, and it forfeits the -> statically-known table size. It would level WASI down to the browser's runtime-linking posture, -> which is the weaker of the two. The reservation approach above gets the same result declaratively. +> statically-known table size. The small start function used here only calls `patchWebcilHeader`; +> active segments still install the payload and function table declaratively. diff --git a/eng/wasi-r2r/pipeline_shim.py b/eng/wasi-r2r/pipeline_shim.py index 3068a5a2b2d9fa..8a3a40880d42b6 100644 --- a/eng/wasi-r2r/pipeline_shim.py +++ b/eng/wasi-r2r/pipeline_shim.py @@ -33,7 +33,6 @@ SEC_ELEMENT, SEC_CODE, SEC_DATA = 9, 10, 11 EXTERNKIND_FUNC = 0 -WEBCIL_HEADER_SIZE = 32 class WasmError(Exception): @@ -42,18 +41,21 @@ class WasmError(Exception): def _uleb(data, pos): result = shift = 0 - while True: + while pos < len(data): byte = data[pos] pos += 1 result |= (byte & 0x7F) << shift shift += 7 if not byte & 0x80: return result, pos + if shift >= 64: + raise WasmError("invalid overlong ULEB128 value") + raise WasmError("truncated ULEB128 value") def _sleb(data, pos): result = shift = 0 - while True: + while pos < len(data): byte = data[pos] pos += 1 result |= (byte & 0x7F) << shift @@ -62,6 +64,9 @@ def _sleb(data, pos): if shift < 64 and byte & 0x40: result -= 1 << shift return result, pos + if shift >= 64: + raise WasmError("invalid overlong SLEB128 value") + raise WasmError("truncated SLEB128 value") def _emit_uleb(value): @@ -93,7 +98,7 @@ class WasmModule: def __init__(self, path): self.path = Path(path) self.data = self.path.read_bytes() - if self.data[:4] != b"\0asm": + if len(self.data) < 8 or self.data[:4] != b"\0asm": raise WasmError(f"{path} is not a wasm core module (bad magic). " "If it is a component, unbundle it first.") self.sections = [] # (id, payload_start, payload_end) @@ -101,6 +106,8 @@ def __init__(self, path): while pos < len(self.data): sec_id = self.data[pos] size, pos = _uleb(self.data, pos + 1) + if size > len(self.data) - pos: + raise WasmError(f"section {sec_id} in {self.path.name} extends beyond the file") self.sections.append((sec_id, pos, pos + size)) pos += size @@ -265,7 +272,7 @@ def data_segments(self): return out -def make_shim(memory_base, table_base, patch_header): +def make_shim(memory_base, table_base, patch_header, payload_size): """Assemble the module supplying what wasm-ld cannot emit for a non-PIC main module. Always exports the two base globals. When `patch_header` is set it additionally imports @@ -314,11 +321,11 @@ def global_entry(value): if patch_header: # Function 0 is the import, so the start function is index 1. out += section(SEC_START, _emit_uleb(1)) - # patchWebcilHeader(dest = __memory_base, length): its own guard is `length >= 32`, - # and it writes 4 bytes at dest+28, so the header size is the only length it needs. + # Let the composite validate the complete payload extent rather than duplicating its + # current header size in the host-side shim. body = (_emit_uleb(0) # no locals + b"\x41" + _emit_sleb(memory_base) # i32.const dest - + b"\x41" + _emit_sleb(WEBCIL_HEADER_SIZE) # i32.const 32 + + b"\x41" + _emit_sleb(payload_size) # i32.const length + b"\x10" + _emit_uleb(0) # call 0 + b"\x0b") # end out += section(SEC_CODE, _emit_uleb(1) + _emit_uleb(len(body)) + body) @@ -369,7 +376,37 @@ def run(args, capture=False): # ---------------------------------------------------------------- pipeline +def composite_requirements(composite): + n_funcs = composite.defined_func_count() + + # The payload is the composite's ONE active data segment. Select it by meaning: the + # 9-byte webcilCount segment is passive, so index-based selection would be a positional + # assumption that breaks silently if crossgen2 ever reorders segments. + payloads = [s for s in composite.data_segments() if s["active"]] + if len(payloads) != 1: + raise WasmError( + f"expected exactly one active data segment in {composite.path.name} " + f"(the webcil payload), found {len(payloads)}. The composite layout changed; " + "this check would otherwise pick the wrong segment.") + + return n_funcs, payloads[0]["size"] + + def main(): + if len(sys.argv) == 3 and sys.argv[1] in ("--describe", "--function-count", "--payload-size"): + composite = WasmModule(sys.argv[2]) + n_funcs, payload = composite_requirements(composite) + if sys.argv[1] == "--describe": + print(f"{n_funcs},{payload}") + else: + print(n_funcs if sys.argv[1] == "--function-count" else payload) + return + + if len(sys.argv) != 1: + raise WasmError( + "usage: pipeline_shim.py [--describe|--function-count|--payload-size] " + "") + root = Path(os.environ.get("ROOT") or Path(__file__).resolve().parents[2]) comp = Path(os.environ.get("COMP") or root / "r2rtest/out2/composite-r2r.wasm") corerun = Path(os.environ.get("CORERUN") @@ -420,18 +457,7 @@ def main(): "slots for the composite.") reserved = min(s["offset"] for s in host_active) - n_funcs = composite.defined_func_count() - - # The payload is the composite's ONE active data segment. Select it by meaning: the - # 9-byte webcilCount segment is passive, so index-based selection would be a positional - # assumption that breaks silently if crossgen2 ever reorders segments. - payloads = [s for s in composite.data_segments() if s["active"]] - if len(payloads) != 1: - raise WasmError( - f"expected exactly one active data segment in {comp.name} (the webcil payload), " - f"found {len(payloads)}. The composite layout changed; this check would " - "otherwise pick the wrong segment.") - payload = payloads[0]["size"] + n_funcs, payload = composite_requirements(composite) print(f"SHIM: imageBase={params['image_base']} tableBase={params['table_base']} " f"reservedSlots={reserved} compositeFuncs={n_funcs} payload={payload} " @@ -466,7 +492,8 @@ def main(): # host's own fallback in wasi_r2r_probe.hpp writes the field instead. patch_header = "patchWebcilHeader" in composite.exports() shim = outdir / "shim.wasm" - shim.write_bytes(make_shim(params["image_base"], params["table_base"], patch_header)) + shim.write_bytes(make_shim( + params["image_base"], params["table_base"], patch_header, payload)) run([wasm_tools, "validate", "--features", "all", str(shim)], capture=True) print(f"SHIM: tableBase written by " f"{'the composite (patchWebcilHeader)' if patch_header else 'the host (fallback)'}") @@ -504,3 +531,6 @@ def main(): except WasmError as error: print(f"error: {error}", file=sys.stderr) sys.exit(1) + except IndexError: + print("error: malformed wasm input ended unexpectedly", file=sys.stderr) + sys.exit(1) diff --git a/src/coreclr/hosts/corerun/wasi_r2r_probe.hpp b/src/coreclr/hosts/corerun/wasi_r2r_probe.hpp index a7b0d06fc45100..074c33da70846c 100644 --- a/src/coreclr/hosts/corerun/wasi_r2r_probe.hpp +++ b/src/coreclr/hosts/corerun/wasi_r2r_probe.hpp @@ -35,15 +35,21 @@ namespace wasi_r2r // composite image's imageBase). The runtime then finds the R2R webcil via this probe, exactly the way // the browser host does via BrowserHost_ExternalAssemblyProbe. // -// This buffer is a composite-AGNOSTIC cap: its address is exported (wasi_r2r_image_base) for the merge -// step to target, but its size is NOT tuned per composite. The actual payload size and the merge-time -// table base are discovered at runtime from the self-describing WbIL header (see WasiWebcilPayloadSize -// / WebcilHeader_1.TableBase), so this host never needs rebuilding when the composite changes. It only -// requires the composite's metadata payload to fit under the cap below. +// Standalone corerun uses the fixed development buffer below. The per-app host declares the symbols +// external instead: its publish builds a strong buffer definition sized exactly to the composite, +// while the host archive carries a 64-byte weak fallback for non-R2R apps. #ifndef WASI_R2R_IMAGE_CAP #define WASI_R2R_IMAGE_CAP (16u * 1024u * 1024u) #endif +#ifdef WASI_R2R_EXTERNAL_IMAGE_BUFFER +extern "C" uint8_t g_wasi_r2r_image[]; +extern "C" uint32_t g_wasi_r2r_image_cap; +#else +alignas(16) static uint8_t g_wasi_r2r_image[WASI_R2R_IMAGE_CAP]; +static constexpr uint32_t g_wasi_r2r_image_cap = WASI_R2R_IMAGE_CAP; +#endif + // The table index at which the composite's functions are installed. Under the reservation model the // host is linked with `-Wl,--table-base=`, which moves corerun's own address-taken functions up // to start at N+1 and leaves slots 1..N free, so the composite always sits at base 1 regardless of @@ -52,20 +58,29 @@ namespace wasi_r2r #ifndef WASI_R2R_TABLE_BASE #define WASI_R2R_TABLE_BASE (1u) #endif -// The webcil header's TableBase field lives at this offset; see the layout note on -// WasiWebcilPayloadSize. Named so the patch site below reads as a field access rather than a constant. -#define WEBCIL_HEADER_SIZE (32u) +// Header version 1 adds TableBase to the 28-byte version 0 header. +#define WEBCIL_HEADER_V0_SIZE (28u) +#define WEBCIL_HEADER_V1_SIZE (32u) #define WEBCIL_SECTION_HEADER_SIZE (16u) +#define WEBCIL_VERSION_MAJOR_OFFSET (4u) #define WEBCIL_TABLE_BASE_OFFSET (28u) -alignas(16) static uint8_t g_wasi_r2r_image[WASI_R2R_IMAGE_CAP]; - // The composite native image's bundle-relative file name (the ownerCompositeExecutable named by each // per-assembly stub). The runtime asks for this via NativeImage::Open -> external_assembly_probe. #ifndef WASI_R2R_COMPOSITE_NAME #define WASI_R2R_COMPOSITE_NAME "composite-r2r.wasm" #endif +static size_t WasiWebcilHeaderSize(const uint8_t* p, size_t len) +{ + if (len < WEBCIL_HEADER_V0_SIZE) + return 0; + + uint16_t versionMajor; + memcpy(&versionMajor, p + WEBCIL_VERSION_MAJOR_OFFSET, sizeof(versionMajor)); + return versionMajor >= 1 ? WEBCIL_HEADER_V1_SIZE : WEBCIL_HEADER_V0_SIZE; +} + // Compute the exact WbIL payload size from its self-describing header - no baked constant needed. // WebcilHeader_1 (32 bytes): Id[4] 'WbIL', VersionMajor u16, VersionMinor u16, CoffSections u16, // Reserved0 u16, PeCliHeaderRva u32, PeCliHeaderSize u32, PeDebugRva u32, PeDebugSize u32, TableBase u32. @@ -77,7 +92,8 @@ alignas(16) static uint8_t g_wasi_r2r_image[WASI_R2R_IMAGE_CAP]; // hands the runtime a truncated image. static int64_t WasiWebcilPayloadSize(const uint8_t* p, size_t len) { - if (len < WEBCIL_HEADER_SIZE) + size_t headerSize = WasiWebcilHeaderSize(p, len); + if (headerSize == 0 || headerSize > len) return 0; if (p[0] != 'W' || p[1] != 'b' || p[2] != 'I' || p[3] != 'L') @@ -87,10 +103,10 @@ static int64_t WasiWebcilPayloadSize(const uint8_t* p, size_t len) memcpy(&coffSections, p + 8, sizeof(coffSections)); // Section headers must fit entirely within the buffer. - if ((len - WEBCIL_HEADER_SIZE) / WEBCIL_SECTION_HEADER_SIZE < coffSections) + if ((len - headerSize) / WEBCIL_SECTION_HEADER_SIZE < coffSections) return 0; - const uint8_t* sec = p + WEBCIL_HEADER_SIZE; + const uint8_t* sec = p + headerSize; uint32_t maxEnd = 0; for (uint16_t i = 0; i < coffSections; i++) { @@ -172,19 +188,19 @@ static bool WasiExtractStubPayload(const char* wasmPath, void** data_start, int6 { size_t q = pos; uint64_t segCount; - if (!wasi_read_uleb(p, len, &q, &segCount)) + if (!wasi_read_uleb(p, secEnd, &q, &segCount)) break; for (uint64_t s = 0; s < segCount && q < secEnd; s++) { uint64_t mode; - if (!wasi_read_uleb(p, len, &q, &mode)) + if (!wasi_read_uleb(p, secEnd, &q, &mode)) break; // Only passive segments (mode 1) are used by the webcil wrapper. A composite's // payload segment is ACTIVE, so this also declines a composite handed here by // mistake rather than misreading its offset expression as segment data. if (mode != 1) { break; } uint64_t dlen; - if (!wasi_read_uleb(p, len, &q, &dlen)) + if (!wasi_read_uleb(p, secEnd, &q, &dlen)) break; if (dlen > (uint64_t)(secEnd - q)) break; // segment claims more bytes than the section holds @@ -223,27 +239,28 @@ static bool WasiStaticR2RProbe(const char* name, const char* const* dirs, size_t // read from the self-describing WbIL header (no baked constant), and validated against the buffer cap. if (strcmp(name, WASI_R2R_COMPOSITE_NAME) == 0) { - int64_t payloadSize = WasiWebcilPayloadSize(&g_wasi_r2r_image[0], sizeof(g_wasi_r2r_image)); - if (payloadSize <= 0 || (size_t)payloadSize > sizeof(g_wasi_r2r_image)) + int64_t payloadSize = WasiWebcilPayloadSize(&g_wasi_r2r_image[0], g_wasi_r2r_image_cap); + if (payloadSize <= 0 || (size_t)payloadSize > g_wasi_r2r_image_cap) return false; // buffer not populated, or composite payload exceeds the cap - // Self-installing images: crossgen2 emits the payload as an ACTIVE data segment that the engine - // installs at instantiation, so the offline `activate` step that used to bake WebcilHeader_1.TableBase - // no longer runs and nothing has written it. That field is not optional -- - // WebcilDecoder::GetTableBaseOffset returns 0 rather than failing, and that 0 becomes tableBaseDelta - // in PEImageLayout, shifting every R2R function index by the table base. The symptom is call_indirect - // landing on the wrong function, nowhere near the cause. Patch it before the runtime parses the header. + // A current self-installing image patches its own TableBase from the composition shim's start + // function. Older images predate patchWebcilHeader, so retain the native fallback when the + // field is still zero. WebcilDecoder treats an unwritten zero as a valid base and would + // otherwise shift every R2R function index to the wrong table slot. // // NOTE: the cap test above cannot protect this buffer -- the engine installs the segment before any // host code runs, so an over-cap payload has already overwritten whatever follows by the time we look. // The enforceable check is at build time; pipeline_shim.py compares the payload size against the cap. uint8_t* hdr = &g_wasi_r2r_image[0]; - uint32_t existingTableBase; - memcpy(&existingTableBase, hdr + WEBCIL_TABLE_BASE_OFFSET, sizeof(existingTableBase)); - if (existingTableBase == 0) + if (WasiWebcilHeaderSize(hdr, (size_t)payloadSize) >= WEBCIL_HEADER_V1_SIZE) { - uint32_t tableBase = WASI_R2R_TABLE_BASE; - memcpy(hdr + WEBCIL_TABLE_BASE_OFFSET, &tableBase, sizeof(tableBase)); + uint32_t existingTableBase; + memcpy(&existingTableBase, hdr + WEBCIL_TABLE_BASE_OFFSET, sizeof(existingTableBase)); + if (existingTableBase == 0) + { + uint32_t tableBase = WASI_R2R_TABLE_BASE; + memcpy(hdr + WEBCIL_TABLE_BASE_OFFSET, &tableBase, sizeof(tableBase)); + } } *data_start = &g_wasi_r2r_image[0]; @@ -279,12 +296,8 @@ static bool WasiStaticR2RProbe(const char* name, const char* const* dirs, size_t // __memory_base global. Defined outside the namespace with C linkage so the export name is exactly // "wasi_r2r_image_base" (the merge step targets this symbol). // -// NOTE: this is an external-linkage definition in a header, as is the WASI_R2R_IMAGE_CAP buffer above. -// That is safe only because the two includers -- corerun.cpp and wasihost.cpp -- link into separate -// binaries. A second includer in either binary is a duplicate-symbol error (loud) but would also add -// another cap-sized BSS buffer. Making this `static` does NOT work: the export then disappears and the -// merge step silently loses its anchor. The correct fix is to move the buffer and this definition into -// a shared .cpp compiled into both hosts; tracked as follow-up. +// This header is included once in each host binary. Keeping the exported accessor here ensures the +// linker roots the selected fixed or per-app buffer and gives the composition step a stable anchor. extern "C" __attribute__((export_name("wasi_r2r_image_base"))) uint32_t wasi_r2r_image_base(void) { return (uint32_t)(uintptr_t)&wasi_r2r::g_wasi_r2r_image[0]; @@ -294,10 +307,16 @@ extern "C" __attribute__((export_name("wasi_r2r_image_base"))) uint32_t wasi_r2r // reason as the base: the splice must not carry its own copy of either. The host owns these values; // eng/wasi-r2r/pipeline_shim.py reads them out of the linked binary and validates the composite // against them, so a mismatch is a build-time error instead of a wrong-function dispatch at runtime. -extern "C" __attribute__((export_name("wasi_r2r_image_cap"))) uint32_t wasi_r2r_image_cap(void) +#ifdef WASI_R2R_EXTERNAL_IMAGE_BUFFER +#define WASI_R2R_IMAGE_CAP_WEAK __attribute__((weak)) +#else +#define WASI_R2R_IMAGE_CAP_WEAK +#endif +extern "C" WASI_R2R_IMAGE_CAP_WEAK __attribute__((export_name("wasi_r2r_image_cap"))) uint32_t wasi_r2r_image_cap(void) { - return (uint32_t)sizeof(wasi_r2r::g_wasi_r2r_image); + return wasi_r2r::g_wasi_r2r_image_cap; } +#undef WASI_R2R_IMAGE_CAP_WEAK extern "C" __attribute__((export_name("wasi_r2r_table_base"))) uint32_t wasi_r2r_table_base(void) { diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/R2RTestSuites.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/R2RTestSuites.cs index fc25e2a448c02a..927c3400abdb86 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/R2RTestSuites.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/R2RTestSuites.cs @@ -102,6 +102,7 @@ static void Validate(ReadyToRunReader reader) method.SignatureString.Contains("CatchException", StringComparison.Ordinal))); Assert.True(WasmR2RAssert.WasmIndexSpacesHaveExpectedEntries(webcilReader, out string indexDiagnostic), indexDiagnostic); + Assert.True(WasmR2RAssert.WasmSelfInstallingSegmentsHaveExpectedModes(webcilReader, out string segmentDiagnostic), segmentDiagnostic); // The wasm JIT references the ABI well-known globals via maximally padded WASM_GLOBAL_INDEX_LEB // relocations that the R2R object writer must self-resolve to the fixed global diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCasesRunner/WasmR2RAssert.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCasesRunner/WasmR2RAssert.cs index cf92d76de963fe..d7665d248f32b3 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCasesRunner/WasmR2RAssert.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCasesRunner/WasmR2RAssert.cs @@ -125,6 +125,102 @@ public static bool WasmIndexSpacesHaveExpectedEntries(WebcilImageReader reader, return failures.Count == 0; } + /// + /// Verifies that a code-carrying Webcil module installs its payload and function table through + /// active segments at the imported image and table base globals. + /// + public static bool WasmSelfInstallingSegmentsHaveExpectedModes(WebcilImageReader reader, out string diagnostic) + { + ReadOnlySpan image = reader.GetEntireImage().AsSpan(); + var failures = new List(); + + if (!TryGetWasmSectionBounds(image, WasmSectionKind.Data, out int dataOffset, out int dataEnd)) + { + failures.Add("WASM image does not contain a data section."); + } + else + { + uint dataSegmentCount = ReadWasmUleb32(image, ref dataOffset, dataEnd); + if (dataSegmentCount != 2) + { + failures.Add($"WASM data section contains {dataSegmentCount} segments; expected 2."); + } + else + { + uint countSegmentMode = ReadWasmUleb32(image, ref dataOffset, dataEnd); + if (countSegmentMode != 1) + failures.Add($"The webcilCount data segment has mode {countSegmentMode}; expected passive mode 1."); + + uint countSegmentSize = ReadWasmUleb32(image, ref dataOffset, dataEnd); + if (countSegmentSize > dataEnd - dataOffset) + throw new BadImageFormatException("The webcilCount data segment extends beyond the data section."); + dataOffset += (int)countSegmentSize; + + uint payloadSegmentMode = ReadWasmUleb32(image, ref dataOffset, dataEnd); + if (payloadSegmentMode != 0) + { + failures.Add($"The webcilPayload data segment has mode {payloadSegmentMode}; expected active mode 0."); + } + else + { + CheckGlobalGetOffsetExpression(image, ref dataOffset, dataEnd, expectedGlobalIndex: 1, "webcilPayload", failures); + } + } + } + + if (!TryGetWasmSectionBounds(image, WasmSectionKind.Element, out int elementOffset, out int elementEnd)) + { + failures.Add("WASM image does not contain an element section."); + } + else + { + uint elementSegmentCount = ReadWasmUleb32(image, ref elementOffset, elementEnd); + if (elementSegmentCount != 1) + { + failures.Add($"WASM element section contains {elementSegmentCount} segments; expected 1."); + } + else + { + uint elementSegmentMode = ReadWasmUleb32(image, ref elementOffset, elementEnd); + if (elementSegmentMode != 0) + { + failures.Add($"The function-table element segment has mode {elementSegmentMode}; expected active mode 0."); + } + else + { + CheckGlobalGetOffsetExpression(image, ref elementOffset, elementEnd, expectedGlobalIndex: 2, "function-table", failures); + } + } + } + + diagnostic = failures.Count == 0 + ? "WASM payload and function-table segments use the expected self-installing modes." + : string.Join(Environment.NewLine, failures); + return failures.Count == 0; + } + + private static void CheckGlobalGetOffsetExpression( + ReadOnlySpan image, + ref int offset, + int end, + uint expectedGlobalIndex, + string segmentName, + List failures) + { + const byte GlobalGetOpcode = 0x23; + const byte EndOpcode = 0x0B; + + byte opcode = ReadWasmByte(image, ref offset, end); + uint globalIndex = ReadWasmUleb32(image, ref offset, end); + byte endOpcode = ReadWasmByte(image, ref offset, end); + if (opcode != GlobalGetOpcode || globalIndex != expectedGlobalIndex || endOpcode != EndOpcode) + { + failures.Add( + $"The {segmentName} segment offset was opcode 0x{opcode:X2}, global {globalIndex}, " + + $"end 0x{endOpcode:X2}; expected global.get {expectedGlobalIndex}, end."); + } + } + private static uint CountWasmImports( Dictionary<(string Module, string Name), WasmImportIndex> imports, WasmImportKind kind) @@ -578,6 +674,7 @@ private enum WasmSectionKind : byte Global = 6, Export = 7, Element = 9, + Data = 11, Tag = 13, } diff --git a/src/mono/wasi/build/WasiApp.CoreCLR.targets b/src/mono/wasi/build/WasiApp.CoreCLR.targets index 90c3c0fa1fa293..4b83260980fac0 100644 --- a/src/mono/wasi/build/WasiApp.CoreCLR.targets +++ b/src/mono/wasi/build/WasiApp.CoreCLR.targets @@ -35,6 +35,100 @@ false + + wasm + true + $(PublishReadyToRunCrossgen2ExtraArgs);--opt-cross-module:*;--codegenopt:JitWasmNyiToR2RUnsupported=1;--codegenopt:JitWasmSimdNyiToR2RUnsupported=1 + true + python + python3 + + + + + + + + + + + + $(MicrosoftNetCoreAppRuntimePackRidNativeDir) + $(MicrosoftNetCoreAppRuntimePackRidNativeDir) + $(MicrosoftNetCoreAppRuntimePackRidLibTfmDir)System.Private.CoreLib.dll + $(MicrosoftNetCoreAppRuntimePackRidNativeDir)System.Private.CoreLib.dll + + + + + + + + + + + <_WasiReadyToRunFrameworkInput Include="$(WasiCoreCLRSystemPrivateCoreLibPath)" /> + <_WasiReadyToRunFrameworkInput Include="$(MicrosoftNetCoreAppRuntimePackRidLibTfmDir)*.dll" + Exclude="$(WasiCoreCLRSystemPrivateCoreLibPath)" /> + + + + + + + <_WasiR2ROutputDir>$([MSBuild]::NormalizeDirectory('$(_ReadyToRunOutputPath)')) + <_WasiR2RCompositePath>$(_WasiR2ROutputDir)composite-r2r.wasm + <_WasiR2RImageBufferSource>$([MSBuild]::NormalizePath('$(_WasmIntermediateOutputPath)', 'corerun-relink', 'r2r-image-buffer.cpp')) + + + + + + + + + <_WasiR2RFunctionCount>$([System.String]::Copy('$(_WasiR2RDescription)').Split(',').GetValue(0)) + <_WasiR2RPayloadSize>$([System.String]::Copy('$(_WasiR2RDescription)').Split(',').GetValue(1)) + + $([MSBuild]::Add($(_WasiR2RFunctionCount), 1)) + + + + + + + + @@ -60,15 +154,17 @@ (linked below), which finds them via CORE_ROOT. See https://github.com/dotnet/runtime/issues/130129. --> - + - <_WasiCoreCLRCoreLib Include="$(MicrosoftNetCoreAppRuntimePackRidNativeDir)System.Private.CoreLib.dll" /> + <_WasiCoreCLRCoreLib Include="$(WasiCoreCLRSystemPrivateCoreLibPath)" /> - <_WasiCoreCLRFrameworkFiles Include="$(MicrosoftNetCoreAppRuntimePackRidLibTfmDir)\*.dll" /> + <_WasiCoreCLRFrameworkFiles Include="$(MicrosoftNetCoreAppRuntimePackRidLibTfmDir)\*.dll" + Exclude="$(WasiCoreCLRSystemPrivateCoreLibPath)" /> <_WasiCoreCLRFrameworkToCopy Include="@(_WasiCoreCLRFrameworkFiles)" Condition="!Exists('$(WasmAppDir)managed\%(_WasiCoreCLRFrameworkFiles.FileName)%(_WasiCoreCLRFrameworkFiles.Extension)')" /> @@ -91,7 +187,7 @@ <_WasiCallHelperSource Include="$(_WasiReversePInvokeTablePath)" ObjectFile="$(_WasiRelinkObjDir)callhelpers-reverse.o" /> <_WasiCallHelperSource Include="$(_WasiInterpToNativeTablePath)" ObjectFile="$(_WasiRelinkObjDir)callhelpers-interp-to-managed.o" /> + <_WasiCallHelperSource Include="$(_WasiR2RImageBufferSource)" + ObjectFile="$(_WasiRelinkObjDir)r2r-image-buffer.o" + Condition="'$(PublishReadyToRun)' == 'true'" /> - <_WasiHostLibsPre Include="$(MicrosoftNetCoreAppRuntimePackRidNativeDir)libminipal.a" /> - <_WasiHostLibsPre Include="$(MicrosoftNetCoreAppRuntimePackRidNativeDir)libcoreclr_static.a" /> + <_WasiHostLibsPre Include="$(WasiCoreCLRNativeRuntimeDir)libminipal.a" /> + <_WasiHostLibsPre Include="$(WasiCoreCLRNativeRuntimeDir)libcoreclr_static.a" /> <_WasiHostLibsPre Include="$(MicrosoftNetCoreAppRuntimePackRidNativeDir)libSystem.IO.Compression.Native.a" /> <_WasiHostLibsPre Include="$(MicrosoftNetCoreAppRuntimePackRidNativeDir)libSystem.Native.a" /> <_WasiHostLibsPre Condition="'$(InvariantTimezone)' == 'true'" Include="$(MicrosoftNetCoreAppRuntimePackRidNativeDir)libSystem.Native.TimeZoneData.Invariant.a" /> <_WasiHostLibsPre Condition="'$(InvariantTimezone)' != 'true'" Include="$(MicrosoftNetCoreAppRuntimePackRidNativeDir)libSystem.Native.TimeZoneData.a" /> <_WasiHostLibsPre Include="%(_WasiCallHelperSource.ObjectFile)" /> - <_WasiHostLibsPre Include="$(MicrosoftNetCoreAppRuntimePackRidNativeDir)libnativeresourcestring.a" /> + <_WasiHostLibsPre Include="$(WasiCoreCLRNativeRuntimeDir)libnativeresourcestring.a" /> - <_WasiHostLibsPost Include="$(MicrosoftNetCoreAppRuntimePackRidNativeDir)libcoreclrminipal.a" /> - <_WasiHostLibsPost Include="$(MicrosoftNetCoreAppRuntimePackRidNativeDir)libcoreclrpal.a" /> - <_WasiHostLibsPost Include="$(MicrosoftNetCoreAppRuntimePackRidNativeDir)libminipal.a" /> + <_WasiHostLibsPost Include="$(WasiCoreCLRNativeRuntimeDir)libcoreclrminipal.a" /> + <_WasiHostLibsPost Include="$(WasiCoreCLRNativeRuntimeDir)libcoreclrpal.a" /> + <_WasiHostLibsPost Include="$(WasiCoreCLRNativeRuntimeDir)libminipal.a" /> <_WasiHostLibsPost Include="$(MicrosoftNetCoreAppRuntimePackRidNativeDir)libz.a" /> <_WasiHostLibsPost Include="$(MicrosoftNetCoreAppRuntimePackRidNativeDir)libzstd.a" Condition="Exists('$(MicrosoftNetCoreAppRuntimePackRidNativeDir)libzstd.a')" /> - <_WasiRelinkOutput>$(WasmAppDir)managed\corerun + <_WasiRelinkOutput>$(_WasiRelinkObjDir)corerun + <_WasiPublishedHost>$(WasmAppDir)managed\corerun @@ -323,7 +423,7 @@ <_WasiRelinkLinkFlags Include="-Wl,--component-type,"$(_WasiHttpWorldWit.Replace('\','/'))"" /> <_WasiRelinkLinkFlags Include="-Wl,--whole-archive" /> - <_WasiRelinkLinkFlags Include=""$(MicrosoftNetCoreAppRuntimePackRidNativeDir)libWasiHost.a"" /> + <_WasiRelinkLinkFlags Include=""$(WasiCoreCLRHostNativeDir)libWasiHost.a"" /> <_WasiRelinkLinkFlags Include="-Wl,--no-whole-archive" /> <_WasiRelinkLinkFlags Include="@(_WasiHostLibsPre->'"%(Identity)"')" /> <_WasiRelinkLinkFlags Include="-lstdc++" /> @@ -335,10 +435,63 @@ + + + + + <_WasiR2RComposeOutputDir>$([MSBuild]::NormalizeDirectory('$(_WasmIntermediateOutputPath)', 'r2r-compose')) + <_WasiR2RComposedHost>$(_WasiR2RComposeOutputDir)corerun-composite.wasm + <_WasiR2RStubDir>$([MSBuild]::NormalizeDirectory('$(WasmAppDir)', 'managed', 'comp')) + + + + + + + <_WasiR2RComponentStub Include="@(_ReadyToRunCompositeBuildInput->'$(_WasiR2ROutputDir)%(FileName).wasm')" /> + <_WasiR2RComponentStub Include="@(_ReadyToRunCompositeUnrootedBuildInput->'$(_WasiR2ROutputDir)%(FileName).wasm')" /> + + + + + + + + + + + + $(WasmAppDir)run-wasmtime.sh + <_ScriptContent Condition="'$(WasmSingleFileBundle)' == 'true'">wasmtime run $([System.IO.Path]::GetFileNameWithoutExtension($(WasmMainAssemblyFileName))).wasm $* + <_ScriptContent Condition="'$(WasmSingleFileBundle)' != 'true'">cd managed && wasmtime run -W exceptions=y -S http --dir . corerun $(WasmMainAssemblyFileName) $* + + + + + + + + + diff --git a/src/mono/wasi/build/WasiApp.InTree.props b/src/mono/wasi/build/WasiApp.InTree.props index ae0f77312bfe23..9b61247b8e8e66 100644 --- a/src/mono/wasi/build/WasiApp.InTree.props +++ b/src/mono/wasi/build/WasiApp.InTree.props @@ -8,11 +8,21 @@ WasiApp.CoreCLR.targets). --> false + $([MSBuild]::NormalizeDirectory('$(ArtifactsBinDir)', 'wasi-wasm.$(Configuration)', 'sharedFramework')) + $([MSBuild]::NormalizeDirectory('$(ArtifactsBinDir)', 'coreclr', 'wasi.wasm.$(Configuration)', 'sharedFramework')) + $([MSBuild]::NormalizePath('$(ArtifactsBinDir)', 'coreclr', 'wasi.wasm.$(Configuration)', 'IL', 'System.Private.CoreLib.dll')) + $([MSBuild]::NormalizePath('$(RepoRoot)', 'eng', 'wasi-r2r', 'pipeline_shim.py')) false + + $(AfterMicrosoftNETSdkTargets);$(Crossgen2SdkOverrideTargetsPath) + $(AfterMicrosoftNETSdkTargets);$(MSBuildThisFileDirectory)WasiApp.ReadyToRun.targets + true diff --git a/src/mono/wasi/build/WasiApp.ReadyToRun.targets b/src/mono/wasi/build/WasiApp.ReadyToRun.targets new file mode 100644 index 00000000000000..55fd76a0ad77a0 --- /dev/null +++ b/src/mono/wasi/build/WasiApp.ReadyToRun.targets @@ -0,0 +1,20 @@ + + + + + $([MSBuild]::NormalizePath('$(Crossgen2InBuildDir)', 'crossgen2$(ExeSuffix)')) + + + + + + + + diff --git a/src/native/corehost/wasihost/wasihost.cpp b/src/native/corehost/wasihost/wasihost.cpp index 3782fcea913971..eb91e160b604f3 100644 --- a/src/native/corehost/wasihost/wasihost.cpp +++ b/src/native/corehost/wasihost/wasihost.cpp @@ -20,8 +20,20 @@ // Shared WASI R2R external-assembly probe (same code corerun uses), so the per-app test host serves // statically-composed R2R images instead of silently interpreting everything. Requires corerun.hpp // above (pal::try_map_file_readonly). +#define WASI_R2R_EXTERNAL_IMAGE_BUFFER #include "wasi_r2r_probe.hpp" +namespace wasi_r2r +{ +// A ReadyToRun publish supplies strong definitions sized from its composite. Keep non-R2R app links +// working without paying the 16 MiB development-host reservation. +extern "C" +{ + alignas(16) __attribute__((weak)) uint8_t g_wasi_r2r_image[64] = {}; + __attribute__((weak)) uint32_t g_wasi_r2r_image_cap = sizeof(g_wasi_r2r_image); +} +} + #include using pal::char_t; diff --git a/src/tasks/Crossgen2Tasks/Microsoft.NET.CrossGen.targets b/src/tasks/Crossgen2Tasks/Microsoft.NET.CrossGen.targets index 9f6e43811b9637..a475bdead6a25d 100644 --- a/src/tasks/Crossgen2Tasks/Microsoft.NET.CrossGen.targets +++ b/src/tasks/Crossgen2Tasks/Microsoft.NET.CrossGen.targets @@ -385,9 +385,14 @@ Copyright (c) .NET Foundation. All rights reserved. and do not replace them. IL PDBs are still required for debugging. Native PDBs emitted by the R2R compiler are only used for profiling purposes. --> - - - + + + + diff --git a/src/tasks/Crossgen2Tasks/PrepareForReadyToRunCompilation.cs b/src/tasks/Crossgen2Tasks/PrepareForReadyToRunCompilation.cs index 423e5e6e1aeac7..aa71bbf576e7cb 100644 --- a/src/tasks/Crossgen2Tasks/PrepareForReadyToRunCompilation.cs +++ b/src/tasks/Crossgen2Tasks/PrepareForReadyToRunCompilation.cs @@ -164,6 +164,11 @@ private void ProcessInputFileList( } var outputR2RImageRelativePath = file.GetMetadata(MetadataKeys.RelativePath); + if (Crossgen2Composite && Crossgen2ContainerFormat == "wasm") + { + outputR2RImageRelativePath = Path.ChangeExtension(outputR2RImageRelativePath, ".wasm"); + } + var outputR2RImage = Path.Combine(OutputPath, outputR2RImageRelativePath); string outputPDBImage = null; @@ -235,6 +240,11 @@ private void ProcessInputFileList( ItemSpec = outputR2RImage }; r2rFileToPublish.RemoveMetadata(MetadataKeys.OriginalItemSpec); + if (Crossgen2Composite && Crossgen2ContainerFormat == "wasm") + { + r2rFileToPublish.SetMetadata(MetadataKeys.RelativePath, outputR2RImageRelativePath); + } + r2rFilesPublishList.Add(r2rFileToPublish); // Note: ReadyToRun PDB/Map files are not needed for debugging. They are only used for profiling, therefore the default behavior is to not generate them @@ -286,7 +296,12 @@ private void ProcessInputFileList( // by any post-crossgen2 linking steps and used at runtime. var compositeR2RFinalImageRelativePath = compositeR2RImageRelativePath; - if (Crossgen2ContainerFormat == "macho") + if (Crossgen2ContainerFormat == "wasm") + { + compositeR2RImageRelativePath = "composite-r2r.wasm"; + compositeR2RFinalImageRelativePath = compositeR2RImageRelativePath; + } + else if (Crossgen2ContainerFormat == "macho") { compositeR2RImageRelativePath = Path.ChangeExtension(compositeR2RImageRelativePath, ".o"); compositeR2RFinalImageRelativePath = Path.ChangeExtension(compositeR2RImageRelativePath, ".dylib"); From 3258eb2c43e8a061c9d0e4243dd5028c4e3968dc Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 4 Sep 2026 16:13:03 -0500 Subject: [PATCH 15/17] Enable composite WASI ReadyToRun runtime-test CI Add a separate Checked R2R lane, provision splice tools for Helix, and launch per-runner composite hosts with external assembly probing. Preserve the interpreter lane and adapt WebCIL alignment coverage to active payloads. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4498e5c2-72ce-4aff-882e-77b102380716 --- .../common/evaluate-default-paths.yml | 2 + .../wasi-wasm-coreclr-runtime-tests.yml | 19 ++++++-- eng/pipelines/runtime.yml | 8 ++++ eng/testing/wasi-r2r-provisioning.targets | 47 +++++++++++++++++++ eng/wasi-r2r/README.md | 23 +++++++++ eng/wasm/PruneWasmToolCache.proj | 1 + eng/wasm/WasmToolCache.props | 16 ++++++- .../TestCases/R2RTestSuites.cs | 30 ++++++++++-- src/tests/Common/CLRTest.CrossGen.targets | 19 ++++++++ src/tests/Common/CLRTest.Execute.Bash.targets | 15 +++++- src/tests/Common/Directory.Build.targets | 6 +++ src/tests/Common/helixpublishwitharcade.proj | 27 +++++++++++ src/tests/Directory.Build.props | 3 ++ 13 files changed, 205 insertions(+), 11 deletions(-) create mode 100644 eng/testing/wasi-r2r-provisioning.targets diff --git a/eng/pipelines/common/evaluate-default-paths.yml b/eng/pipelines/common/evaluate-default-paths.yml index dba1aaad202b60..61166d87436975 100644 --- a/eng/pipelines/common/evaluate-default-paths.yml +++ b/eng/pipelines/common/evaluate-default-paths.yml @@ -295,6 +295,8 @@ jobs: - subset: wasm_coreclr_runtimetests combined: true include: + - eng/wasi-r2r/* + - eng/testing/wasi-r2r-provisioning.targets - src/tests/* - src/coreclr/* - ${{ parameters._const_paths._wasm_src_native }} diff --git a/eng/pipelines/common/templates/wasi-wasm-coreclr-runtime-tests.yml b/eng/pipelines/common/templates/wasi-wasm-coreclr-runtime-tests.yml index c9a025e8a7cb4d..2a18559d44fd4e 100644 --- a/eng/pipelines/common/templates/wasi-wasm-coreclr-runtime-tests.yml +++ b/eng/pipelines/common/templates/wasi-wasm-coreclr-runtime-tests.yml @@ -5,6 +5,7 @@ parameters: platforms: [] extraBuildArgs: '' useHelixMonitor: false + readyToRun: false jobs: @@ -22,7 +23,10 @@ jobs: parameters: jobTemplate: /eng/pipelines/common/global-build-job.yml helixQueuesTemplate: /eng/pipelines/libraries/helix-queues-setup.yml - buildConfig: Release + ${{ if eq(parameters.readyToRun, true) }}: + buildConfig: Checked + ${{ else }}: + buildConfig: Release runtimeFlavor: coreclr platforms: ${{ parameters.platforms }} variables: @@ -42,8 +46,11 @@ jobs: jobParameters: testGroup: innerloop isExtraPlatforms: ${{ parameters.isExtraPlatformsBuild }} - nameSuffix: CoreCLR_WASI_RuntimeTests - buildArgs: -s clr+libs+packs -c $(_BuildConfig) ${{ parameters.extraBuildArgs }} /p:TestAssemblies=false + ${{ if eq(parameters.readyToRun, true) }}: + nameSuffix: CoreCLR_WASI_RuntimeTests_R2R_CG2 + ${{ else }}: + nameSuffix: CoreCLR_WASI_RuntimeTests + buildArgs: -s clr+libs+packs -c $(_BuildConfig) -lc Release -hc Release ${{ parameters.extraBuildArgs }} /p:TestAssemblies=false timeoutInMinutes: 180 condition: >- or( @@ -55,8 +62,11 @@ jobs: creator: dotnet-bot testRunNamePrefixSuffix: CoreCLR_WASI_$(_BuildConfig) useHelixMonitor: ${{ parameters.useHelixMonitor }} + readyToRun: ${{ parameters.readyToRun }} + compositeBuildMode: ${{ parameters.readyToRun }} # Curated test-tree subset -- expand as gates stabilize. testBuildArgs: >- + /p:HostConfiguration=Release -priority1 -tree:JIT/CodeGenBringUpTests -tree:JIT/Generics @@ -68,3 +78,6 @@ jobs: -tree:reflection extraVariablesTemplates: - template: /eng/pipelines/common/templates/runtimes/test-variables.yml + parameters: + liveLibrariesBuildConfig: Release + readyToRun: ${{ parameters.readyToRun }} diff --git a/eng/pipelines/runtime.yml b/eng/pipelines/runtime.yml index e58e2d50c0b2af..f95dc3c4fb4a82 100644 --- a/eng/pipelines/runtime.yml +++ b/eng/pipelines/runtime.yml @@ -174,6 +174,14 @@ extends: alwaysRun: ${{ variables.isRollingBuild }} useHelixMonitor: ${{ variables.enableHelixJobMonitor }} + - template: /eng/pipelines/common/templates/wasi-wasm-coreclr-runtime-tests.yml + parameters: + platforms: + - wasi_wasm + readyToRun: true + alwaysRun: ${{ variables.isRollingBuild }} + useHelixMonitor: ${{ variables.enableHelixJobMonitor }} + # CoreCLR library-test smoke leg run on wasmtime via Helix # (wasi_wasm queue). Starts with a single-library smoke set; see # https://github.com/dotnet/runtime/issues/130129. diff --git a/eng/testing/wasi-r2r-provisioning.targets b/eng/testing/wasi-r2r-provisioning.targets new file mode 100644 index 00000000000000..3efe9381761423 --- /dev/null +++ b/eng/testing/wasi-r2r-provisioning.targets @@ -0,0 +1,47 @@ + + + <_WasiR2RToolOS Condition="'$(HostOS)' == 'linux'">linux + <_WasiR2RToolOS Condition="'$(HostOS)' == 'osx'">macos + <_WasiR2RToolArch Condition="'$(BuildArchitecture)' == 'x64'">x86_64 + <_WasiR2RToolArch Condition="'$(BuildArchitecture)' == 'arm64'">aarch64 + <_BinaryenArch>$(_WasiR2RToolArch) + <_BinaryenArch Condition="'$(HostOS)' == 'osx' and '$(BuildArchitecture)' == 'arm64'">arm64 + <_BinaryenArchiveName>binaryen-version_$(BinaryenVersion)-$(_BinaryenArch)-$(_WasiR2RToolOS) + <_WasmToolsArchiveName>wasm-tools-$(WasmToolsVersion)-$(_WasiR2RToolArch)-$(_WasiR2RToolOS) + + + + + + + + + + + + diff --git a/eng/wasi-r2r/README.md b/eng/wasi-r2r/README.md index d2fb754c9f9773..9837a1c4bc8628 100644 --- a/eng/wasi-r2r/README.md +++ b/eng/wasi-r2r/README.md @@ -85,6 +85,29 @@ The activation log alone is not proof that a method executed from the composite. check, break on the app method's wasm function from the final component; an interpreted fallback cannot hit a breakpoint inside the R2R body. +### Runtime tests + +The WASI runtime-test pipeline has separate interpreter and `R2R_CG2` legs. The R2R leg uses +Checked CoreCLR with Release libraries. Like the browser R2R leg, it compiles each merged runner +and its test assemblies at execution time. Unlike browser, it compiles them as one composite and +splices that composite into a private copy of `CORE_ROOT/corerun`. + +The generated Bash wrappers enable this path with `RunCrossGen2=1`. They stage the assembly stubs +under `IL-CG2/wasi-r2r/comp`, run the splice from `CORE_ROOT/wasi-r2r/pipeline_shim.py`, and launch +`IL-CG2/wasi-r2r/corerun-composite.wasm`. `APP_ASSEMBLIES=EXTERNAL` enables the host's assembly +probe, `CORE_LIBRARIES` points it at these stubs, and `TEST_READY_TO_RUN_MODE=1` reaches the guest +for R2R-specific test conditions. +Without `RunCrossGen2`, the wrapper uses the unmodified interpreter host and does not probe those +stubs. Composition failures fail the test rather than falling back to interpretation. + +Helix receives pinned Binaryen and `wasm-tools` binaries through a separate correlation payload, +provisioned by `eng/testing/wasi-r2r-provisioning.targets`; it does not need these tools preinstalled +in the queue image. Local runs need the same tools and Python 3 on `PATH`. + +The runtime-test path compiles only the test assemblies, not the framework. It uses the shared +corerun's reserved image buffer and table slots, whose bounds the splice checks before merging. +Publish still sizes and relinks its host from the complete app/framework composite. + ### Cost at framework scale Measured on a 232,673-function framework composite (post-#132906, so 4 exports and a 28.8 MB `name` diff --git a/eng/wasm/PruneWasmToolCache.proj b/eng/wasm/PruneWasmToolCache.proj index 0febac1c46ef72..8347bf5d1552f6 100644 --- a/eng/wasm/PruneWasmToolCache.proj +++ b/eng/wasm/PruneWasmToolCache.proj @@ -29,6 +29,7 @@ <_WasmToolInUseStamp Include="$(EmscriptenSdkStampFile);$(WasiSdkStampFile);$(WasmtimeStampFile)" /> + <_WasmToolInUseStamp Include="$(BinaryenStampFile);$(WasmToolsStampFile)" /> <_WasmToolInUseStamp Include="$(ChromeStampFile);$(ChromeDriverStampFile)" Condition="'$(ChromeVersion)' != ''" /> <_WasmToolInUseStamp Include="$(V8StampFile)" Condition="'$(V8Version)' != ''" /> <_WasmToolInUseStamp Include="$(FirefoxStampFile);$(GeckoDriverStampFile)" Condition="'$(FirefoxRevision)' != ''" /> diff --git a/eng/wasm/WasmToolCache.props b/eng/wasm/WasmToolCache.props index 92021e914890bf..49a39f2bbe9561 100644 --- a/eng/wasm/WasmToolCache.props +++ b/eng/wasm/WasmToolCache.props @@ -1,6 +1,6 @@ @@ -61,6 +61,12 @@ $([System.IO.File]::ReadAllText('$(_WasiSdkVersionFile)').Trim()) + + + 130 + 1.253.0 + + @@ -73,5 +79,11 @@ $([MSBuild]::NormalizeDirectory('$(WasmToolCacheDir)', 'wasmtime', '$(WasmtimeVersion)-$(WasmToolHostRid)')) $([MSBuild]::NormalizePath('$(WasmToolCacheDir)', 'wasmtime', '$(WasmtimeVersion)-$(WasmToolHostRid).complete')) + + $([MSBuild]::NormalizeDirectory('$(WasmToolCacheDir)', 'binaryen', '$(BinaryenVersion)-$(WasmToolHostRid)')) + $([MSBuild]::NormalizePath('$(WasmToolCacheDir)', 'binaryen', '$(BinaryenVersion)-$(WasmToolHostRid).complete')) + + $([MSBuild]::NormalizeDirectory('$(WasmToolCacheDir)', 'wasm-tools', '$(WasmToolsVersion)-$(WasmToolHostRid)')) + $([MSBuild]::NormalizePath('$(WasmToolCacheDir)', 'wasm-tools', '$(WasmToolsVersion)-$(WasmToolHostRid).complete')) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/R2RTestSuites.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/R2RTestSuites.cs index d0c48543c8d9c8..2663e7048cb30b 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/R2RTestSuites.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/R2RTestSuites.cs @@ -2043,9 +2043,12 @@ static void Validate(ReadyToRunReader reader) // Webcil segment 0 // | (byte) segment kind (1, passive) // | (ULEB) segment size - // | (byte*) content - 2 little endian u32 (payloadsize, tablesize) + // | (byte*) content - 2 little endian u32 (payloadsize, tablesize), then alignment padding // Webcil payload - // | (segment kind) (1, passive) + // | (segment kind) (0, active) + // | (byte) global.get + // | (ULEB) image base global index + // | (byte) end // | (ULEB) segment size // | (byte*) content - webcil data, aligned @@ -2056,12 +2059,29 @@ static void Validate(ReadyToRunReader reader) int firstSegmentKind = imageSpan[firstSegmentOffset]; Assert.True(firstSegmentKind == 1, "Expected first segment to be passive (kind 1)"); int firstSegmentSize = (int)DwarfHelper.ReadULEB128(imageSpan.Slice(firstSegmentOffset + 1), out int firstSegmentSizeBytes); + int firstSegmentContentOffset = firstSegmentOffset + 1 + firstSegmentSizeBytes; + const int SizeMetadataLength = sizeof(uint) * 2; + Assert.True(firstSegmentSize >= SizeMetadataLength, "Expected payload and table sizes in the first segment"); + foreach (byte padding in imageSpan.Slice(firstSegmentContentOffset + SizeMetadataLength, firstSegmentSize - SizeMetadataLength)) + { + Assert.Equal(0, padding); + } int payloadSegmentOffset = firstSegmentOffset + 1 + firstSegmentSizeBytes + firstSegmentSize; int payloadSegmentKind = imageSpan[payloadSegmentOffset]; - Assert.True(payloadSegmentKind == 1, "Expected second segment to be passive (kind 1)"); - int payloadSegmentSize = (int)DwarfHelper.ReadULEB128(imageSpan.Slice(payloadSegmentOffset + 1), out int payloadSegmentSizeBytes); - int payloadContentOffset = payloadSegmentOffset + 1 + payloadSegmentSizeBytes; + Assert.True(payloadSegmentKind == 0, "Expected second segment to be active (kind 0)"); + int payloadSizeOffset = payloadSegmentOffset + 1; + const byte GlobalGetOpcode = 0x23; + const byte EndOpcode = 0x0B; + Assert.Equal(GlobalGetOpcode, imageSpan[payloadSizeOffset++]); + int imageBaseGlobalIndex = (int)DwarfHelper.ReadULEB128(imageSpan.Slice(payloadSizeOffset), out int globalIndexBytes); + Assert.Equal(WebCilObjectWriter.ImageBaseGlobalIndex, imageBaseGlobalIndex); + payloadSizeOffset += globalIndexBytes; + Assert.Equal(EndOpcode, imageSpan[payloadSizeOffset++]); + int payloadSegmentSize = (int)DwarfHelper.ReadULEB128(imageSpan.Slice(payloadSizeOffset), out int payloadSegmentSizeBytes); + int payloadContentOffset = payloadSizeOffset + payloadSegmentSizeBytes; + Assert.Equal((uint)payloadSegmentSize, + System.Buffers.Binary.BinaryPrimitives.ReadUInt32LittleEndian(imageSpan.Slice(firstSegmentContentOffset, sizeof(uint)))); Assert.True(payloadContentOffset % WebCilObjectWriter.WebcilSectionAlignment == 0, $"Expected payload content to be aligned to {WebCilObjectWriter.WebcilSectionAlignment} bytes, but got offset {payloadContentOffset}"); Assert.True(payloadContentOffset + payloadSegmentSize == offset + sectionSize + 1 + sectionSizeBytes, diff --git a/src/tests/Common/CLRTest.CrossGen.targets b/src/tests/Common/CLRTest.CrossGen.targets index 4736289b4d401a..094569a23f5dc1 100644 --- a/src/tests/Common/CLRTest.CrossGen.targets +++ b/src/tests/Common/CLRTest.CrossGen.targets @@ -66,6 +66,10 @@ fi # CrossGen2 Script if [ ! -z ${RunCrossGen2+x} ]%3B then export TEST_READY_TO_RUN_MODE=1 + if [ "$(TargetOS)" == "wasi" ]; then + # WASI loads R2R code from a single composite spliced into the host. + export CompositeBuildMode=1 + fi compilationDoneFlagFile="IL-CG2/done" if [ -d IL-CG2 ]%3B then while [ ! -f $compilationDoneFlagFile ]%3B @@ -235,6 +239,21 @@ if [ ! -z ${RunCrossGen2+x} ]%3B then done fi + if [ "$(TargetOS)" == "wasi" ] && [ $__cg2ExitCode -eq 0 ] && [ $__r2rDumpExitCode -eq 0 ]; then + ( + mkdir -p IL-CG2/wasi-r2r/comp || exit 1 + for dllFile in "$PWD"/IL-CG2/*.dll; do + bareFileName="${dllFile##*/}" + cp "$PWD/${bareFileName%.dll}.wasm" IL-CG2/wasi-r2r/comp/ || exit 1 + done + COMP="$PWD/composite-r2r.wasm" CORERUN="$CORE_ROOT/corerun" OUTDIR="$PWD/IL-CG2/wasi-r2r" python3 "$CORE_ROOT/wasi-r2r/pipeline_shim.py" + ) + __linkExitCode=$? + if [ $__linkExitCode -ne 0 ]; then + rm -f "$PWD/IL-CG2/wasi-r2r/corerun-composite.wasm" + fi + fi + echo "Crossgen2 compilation finished, exit code $__cg2ExitCode" >> $compilationDoneFlagFile if [ $__cg2ExitCode -ne 0 ]; then echo Crossgen2 failed with exitcode: $__cg2ExitCode diff --git a/src/tests/Common/CLRTest.Execute.Bash.targets b/src/tests/Common/CLRTest.Execute.Bash.targets index b6e5b625928611..3f8473bb75fb68 100644 --- a/src/tests/Common/CLRTest.Execute.Bash.targets +++ b/src/tests/Common/CLRTest.Execute.Bash.targets @@ -272,7 +272,7 @@ fi block below and the matching block in corerun.cpp. wasmtime is expected to already be on PATH (provisioned via wasi-provisioning.targets or installed manually). --> - wasmtime run -W exceptions=y -S http --dir "$PWD::/" --dir "$CORE_ROOT::/core" --env CORE_ROOT=/core --env DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=true --env DOTNET_WASI_PRINT_EXIT_CODE=1 "$CORE_ROOT/corerun" $(CoreRunArgs) ${__DotEnvArg} + wasmtime run -W exceptions=y -S http --dir "$PWD::/" --dir "$CORE_ROOT::/core" --env CORE_ROOT=/core --env DOTNET_SYSTEM_GLOBALIZATION_INVARIANT=true --env DOTNET_WASI_PRINT_EXIT_CODE=1 ${__WasiR2RArgs} "$__WasiCorerun" $(CoreRunArgs) ${__DotEnvArg} "$CORE_ROOT/watchdog" $_WatcherTimeoutMins + $([MSBuild]::NormalizeDirectory($(ArtifactsObjDir), 'helix-staging', 'wasmtime')) + $([MSBuild]::NormalizeDirectory('$(ArtifactsObjDir)', 'helix-staging', 'wasi-r2r-tools', '$(BinaryenVersion)-$(WasmToolsVersion)-$(WasmToolHostRid)')) + + + <_BinaryenFilesToStage Include="$(BinaryenCacheDir)**\*" /> + <_WasmToolsFilesToStage Include="$(WasmToolsCacheDir)**\*" /> + + + + + + $(TestBinDir)Tests\Core_Root\ $([MSBuild]::NormalizeDirectory($(CoreRootDirectory))) @@ -705,6 +730,7 @@ + @@ -771,6 +797,7 @@ Uses the staged copy (see WasmtimeDirForHelixPayload) rather than $(WasmtimeDir) directly, matching sendtohelix-wasi.targets. --> + diff --git a/src/tests/Directory.Build.props b/src/tests/Directory.Build.props index 66f9d1fe232e6d..ed9f37c8dbecbd 100644 --- a/src/tests/Directory.Build.props +++ b/src/tests/Directory.Build.props @@ -206,6 +206,9 @@ /p:MSBuildEnableWorkloadResolver=false /p:Configuration=$(Configuration) + + + wasm From 33a136666443f9ed72b3ffd5b14f08d75c42304d Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 4 Sep 2026 17:02:15 -0500 Subject: [PATCH 16/17] Move WASI R2R composition into WASI build support Keep publishing implementation beside the WASI targets, consolidate workflow and design documentation, and remove prototype-only response files and implicit scratch paths. Update runtime-test staging and CI path selection. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4498e5c2-72ce-4aff-882e-77b102380716 --- docs/design/mono/webcil.md | 23 ++ docs/workflow/building/coreclr/README.md | 1 + docs/workflow/building/coreclr/wasi-r2r.md | 78 ++++++ .../common/evaluate-default-paths.yml | 3 +- eng/wasi-r2r/README.md | 252 ------------------ eng/wasi-r2r/comp.rsp.template | 17 -- src/coreclr/hosts/corerun/CMakeLists.txt | 4 +- src/coreclr/hosts/corerun/wasi_r2r_probe.hpp | 11 +- src/mono/wasi/build/WasiApp.CoreCLR.targets | 6 +- src/mono/wasi/build/WasiApp.InTree.props | 2 +- .../mono/wasi/build/compose-r2r.py | 28 +- src/tests/Common/CLRTest.CrossGen.targets | 2 +- src/tests/Common/Directory.Build.targets | 2 +- 13 files changed, 130 insertions(+), 299 deletions(-) create mode 100644 docs/workflow/building/coreclr/wasi-r2r.md delete mode 100644 eng/wasi-r2r/README.md delete mode 100644 eng/wasi-r2r/comp.rsp.template rename eng/wasi-r2r/pipeline_shim.py => src/mono/wasi/build/compose-r2r.py (95%) diff --git a/docs/design/mono/webcil.md b/docs/design/mono/webcil.md index d2d43bc63ab003..d5bcadda40c90d 100644 --- a/docs/design/mono/webcil.md +++ b/docs/design/mono/webcil.md @@ -214,6 +214,29 @@ of the runtime's wasm code, reducing the volume of code needed in each webcil fi images into linear memory, as well as for allowing for efficient storage of 128 bit vector constants within the binary.) +##### WASI host composition + +WASI hosts use offline composition rather than instantiating R2R modules at runtime. +The composer in `src/mono/wasi/build/compose-r2r.py` unbundles the host component, creates a +shim exporting the image and table bases, merges the host, shim, and composite with +`wasm-merge`, folds the globals with `wasm-opt --simplify-globals`, and replaces the component's +core module. Both Binaryen steps preserve the `name` section with `-g`. + +The host exports its reserved buffer address, buffer capacity, and composite table base through +`wasi_r2r_image_base`, `wasi_r2r_image_cap`, and `wasi_r2r_table_base`. Before merging, the composer +checks that the active payload fits the buffer and that the composite's table entries end before +the host's own active element segment. These checks must happen before instantiation: a runtime +check cannot prevent an active segment from overwriting an undersized reservation. + +The shim's start function calls `patchWebcilHeader`; the active segments themselves install the +payload and function table. The host's external assembly probe serves `composite-r2r.wasm` from +the embedded buffer and extracts per-assembly forwarding stubs from `comp/.wasm`. +Placing a composite on disk without composing it into the host does not satisfy this contract. + +Publishing sizes the host reservations from the generated app/framework composite. Runtime tests +instead use the shared corerun's fixed reservations and compose only their test assemblies. +See [the WASI R2R workflow](../../workflow/building/coreclr/wasi-r2r.md) for usage and diagnostics. + ### Webcil payload The webcil payload contains the ECMA-335 metadata, IL and resources comprising a .NET assembly. diff --git a/docs/workflow/building/coreclr/README.md b/docs/workflow/building/coreclr/README.md index a9ddf55587034b..4861a09ac9a05b 100644 --- a/docs/workflow/building/coreclr/README.md +++ b/docs/workflow/building/coreclr/README.md @@ -96,6 +96,7 @@ Detailed instructions on how to do cross-compilation can be found in the cross-b For specialized platforms, detailed instructions are available in the following guides: - **WebAssembly:** [Building CoreCLR for WebAssembly](/docs/workflow/building/coreclr/wasm.md) - Experimental support for building, running, and debugging CoreCLR on WebAssembly. +- **WASI ReadyToRun:** [Publishing and testing composite R2R images](wasi-r2r.md) - Experimental CoreCLR-WASI composition workflow. ## Other Features diff --git a/docs/workflow/building/coreclr/wasi-r2r.md b/docs/workflow/building/coreclr/wasi-r2r.md new file mode 100644 index 00000000000000..8937a0dd467f4f --- /dev/null +++ b/docs/workflow/building/coreclr/wasi-r2r.md @@ -0,0 +1,78 @@ +# CoreCLR-WASI composite ReadyToRun + +This is an experimental in-tree publishing and runtime-test workflow. The shipping WASI SDK +does not yet select the CoreCLR app builder. + +WASI requires the compiled composite to be composed into the host component before execution. +Copying `composite-r2r.wasm` beside an unmodified host is not sufficient. The composer lives +alongside the WASI app-builder targets in `src/mono/wasi/build/compose-r2r.py`. +The [WebCIL design document](../../../design/mono/webcil.md#wasi-host-composition) describes +the image layout and host contract. + +## Prerequisites + +Build prerequisites are described in the [CoreCLR build guide](README.md). +Composition additionally requires Python 3.8+, `wasm-tools`, and Binaryen's `wasm-merge` and +`wasm-opt` on `PATH`. Running the result requires wasmtime with WebAssembly exception support. +CI provisions pinned tool versions through `eng/testing/wasi-r2r-provisioning.targets`. + +Framework-sized composites can require several GiB of memory during composition. Allow for the +host and composite working sets when sizing build containers. + +## Publishing + +Build the runtime, libraries, and packs, then publish an in-tree WASI project: + +```bash +./build.sh -s clr+libs+packs -os wasi -arch wasm -c Release +./dotnet.sh publish -c Release -p:TargetOS=wasi \ + -p:RuntimeFlavor=CoreCLR -p:PublishReadyToRun=true +``` + +The app builder enables composite R2R, compiles the app/framework closure, sizes the host's image +buffer and table reservation, links the host, and invokes the composer. It deploys the composed +host and the per-assembly stubs under the app bundle's `managed/` directory. +Non-composite R2R and `WasmSingleFileBundle` are not supported by this path. + +## Runtime tests + +The WASI runtime-test pipeline keeps separate interpreter and `R2R_CG2` jobs. The R2R job uses +Checked CoreCLR with Release libraries. For a local merged-runner example: + +```bash +./build.sh -s clr+libs+packs -os wasi -arch wasm -c Release -rc Checked -lc Release -hc Release +src/tests/build.sh -os wasi -arch wasm Checked -dir:JIT/CodeGenBringUpTests \ + -priority1 -crossgen2 /p:LibrariesConfiguration=Release /p:HostConfiguration=Release +export CORE_ROOT="$PWD/artifacts/tests/coreclr/wasi.wasm.Checked/Tests/Core_Root" +export __TestDotNetCmd="$PWD/.dotnet/dotnet" +RunCrossGen2=1 bash artifacts/tests/coreclr/wasi.wasm.Checked/JIT/CodeGenBringUpTests/JIT.CodeGenBringUpTests_ro/JIT.CodeGenBringUpTests_ro.sh +``` + +The wrapper compiles the runner and referenced test assemblies into a composite, then invokes +`CORE_ROOT/wasi-r2r/compose-r2r.py`. It launches `IL-CG2/wasi-r2r/corerun-composite.wasm` with +`APP_ASSEMBLIES=EXTERNAL` and `CORE_LIBRARIES=/IL-CG2/wasi-r2r` so the guest probes the private +`comp/` stubs. `TEST_READY_TO_RUN_MODE=1` enables R2R-specific test conditions. + +Without `RunCrossGen2`, the wrapper uses the original host and does not probe those stubs. +Composition failures fail the test rather than silently falling back to interpretation. +Helix receives the composition tools through a separate correlation payload. + +## Composer interface and diagnostics + +The build targets generate Crossgen2 response files; a hand-maintained response-file template +is not required. To inspect or compose existing outputs directly: + +```bash +python3 src/mono/wasi/build/compose-r2r.py --describe +COMP= CORERUN= OUTDIR= \ + python3 src/mono/wasi/build/compose-r2r.py +``` + +`--describe` reports `functionCount,payloadBytes`. Composition requires all three environment +variables and writes `corerun-composite.wasm` into `OUTDIR`. The host supplies the image and table +bases; the script rejects an undersized buffer or overlapping table reservation. + +A valid composed module and passing tests alone do not prove R2R was used. Enable the guest's +`DOTNET_ReadyToRunLogFile` and look for `Ready to Run initialized successfully` for the test +assemblies. This confirms image loading; proving a particular method executes compiled code +requires a breakpoint or trace in that method's wasm body. diff --git a/eng/pipelines/common/evaluate-default-paths.yml b/eng/pipelines/common/evaluate-default-paths.yml index 61166d87436975..3914622f79a180 100644 --- a/eng/pipelines/common/evaluate-default-paths.yml +++ b/eng/pipelines/common/evaluate-default-paths.yml @@ -295,13 +295,12 @@ jobs: - subset: wasm_coreclr_runtimetests combined: true include: - - eng/wasi-r2r/* - eng/testing/wasi-r2r-provisioning.targets + - src/mono/wasi/build/compose-r2r.py - src/tests/* - src/coreclr/* - ${{ parameters._const_paths._wasm_src_native }} exclude: - - src/mono/* - ${{ parameters._const_paths._wasm_pipelines }} - ${{ parameters._const_paths._always_exclude }} - ${{ parameters._const_paths._perf_pipeline_specific_only }} diff --git a/eng/wasi-r2r/README.md b/eng/wasi-r2r/README.md deleted file mode 100644 index 9837a1c4bc8628..00000000000000 --- a/eng/wasi-r2r/README.md +++ /dev/null @@ -1,252 +0,0 @@ -# WASI composite-R2R splice tooling - -Tooling for composing a **composite ReadyToRun image into a CoreCLR/WASI component**. -The in-tree CoreCLR-WASI app builder invokes it when `PublishReadyToRun=true`. The shipping WASI SDK -does not select the CoreCLR app builder yet, so this remains an experimental in-tree path. - -Scope note, since this is easy to over-read: **the splice is a WASI requirement, not a composite -requirement.** `WasiStaticR2RProbe` serves `composite-r2r.wasm` only from a baked-in buffer that the -splice populates, so on WASI there is no way to hand the runtime a composite from disk. Browser has -no such constraint — `crossgen2 --composite --targetos:browser` plus a flat directory driven by -`corerun.js` works without any of this tooling. Browser also has a productised **non-composite** path -since [#132339](https://github.com/dotnet/runtime/pull/132339) (`-p:PublishReadyToRun=true`); that -path declines composite, but only as an SDK opt-out. - -## Pieces - -| Path | Purpose | -| --- | --- | -| `pipeline_shim.py` | The splice pipeline: unbundle → extract image base → generate shim → `wasm-merge` → `wasm-opt` fold → module-swap. | -| `comp.rsp.template` | `crossgen2` composite response file; replace `@ROOT@` with your worktree root. | - -## Prerequisites - -- `wasm-tools` and Binaryen (`wasm-merge`, `wasm-opt`) on `PATH`, plus Python 3.8+. - `pipeline_shim.py` fails fast if any are missing. **WABT is not required** — the shim is - assembled directly and every value that used to come from `wasm-objdump` is parsed from the - module, which is also what lets the pipeline run on Windows. -- `wasmtime` on `PATH` for running the result. - -There is no longer an out-of-repo dependency. The pipeline previously required `Nesm.dll` (a wasm -reader/writer from outside this repo) to drive two tools, `surgery` and `activate`, which rewrote the -merged module after the fact. Both are gone — see [How the splice works](#how-the-splice-works). - -## Is the splice still needed? - -**Yes, for WASI.** [#131016](https://github.com/dotnet/runtime/pull/131016) added VM-side loading of a -flat webcil composite, and that code is present — `NativeImage::Open` has a `TARGET_WASM` branch that -takes the R2R header from the decoder instead of the `RTR_HEADER` export. But it does not make direct -deployment work here, because the **WASI host probe never serves the composite from disk**: -`WasiStaticR2RProbe` ([`wasi_r2r_probe.hpp`](../../src/coreclr/hosts/corerun/wasi_r2r_probe.hpp)) -special-cases `composite-r2r.wasm` and returns the baked-in `g_wasi_r2r_image` buffer, which only the -splice populates. Per-assembly stubs *are* read from `comp/.wasm` on disk; the composite is not. - -Measured on a stock (unspliced) `corerun` with the composite deployed alongside — both in the run root -and colocated in `comp/` — this is what happens: - -1. `g_wasi_r2r_image` is empty, so `WasiWebcilPayloadSize` returns `<= 0` and the probe returns `false`. -2. `OpenR2RFromPE` falls through to `PEImageLayout::LoadNative`, which reads the raw file. -3. The file begins `\0asm` — it is webcil *wrapped in wasm* — so `WebcilDecoder::DetectWebcilFormat`, - which tests for the ASCII bytes `WbIL`, returns false. -4. `InitDecoders` therefore selects `FORMAT_PE` and runs `PEDecoder` over a wasm file. - -The result is **not** a graceful fallback. It is an out-of-bounds trap during EE startup: - -``` -0: corerun!PEDecoder::FindReadyToRunHeader() const -1: corerun!NativeImage::Open(...) -2: corerun!AssemblyBinder::LoadNativeImage(...) -3: corerun!AcquireCompositeImage(...) -4: corerun!ReadyToRunInfo::Initialize(...) -... -memory fault at wasm address 0x6541cc8b in linear memory of size 0x8000000 -wasm trap: out of bounds memory access -``` - -That backtrace is the signature of this deployment gap. It looks like a broken composite and reads -like "R2R does not work on wasm"; it is neither. Gate it with `DOTNET_ReadyToRun=0` — if the app then -runs clean, the composite was simply never delivered to the runtime, and you need the splice. - - -## Usage - -The in-tree publish path drives crossgen2, sizes the host's image buffer and table from the generated -composite, invokes the splice, and deploys the component stubs: - -```bash -./dotnet.sh publish -c Release -p:TargetOS=wasi \ - -p:RuntimeFlavor=CoreCLR -p:PublishReadyToRun=true -``` - -For manual experiments, `pipeline_shim.py` still accepts `COMP`, `CORERUN`, `OUTDIR`, and `ROOT` -through the environment. It prints the resolved bases, then `VALID` and the output path on success. - -The activation log alone is not proof that a method executed from the composite. For a deterministic -check, break on the app method's wasm function from the final component; an interpreted fallback -cannot hit a breakpoint inside the R2R body. - -### Runtime tests - -The WASI runtime-test pipeline has separate interpreter and `R2R_CG2` legs. The R2R leg uses -Checked CoreCLR with Release libraries. Like the browser R2R leg, it compiles each merged runner -and its test assemblies at execution time. Unlike browser, it compiles them as one composite and -splices that composite into a private copy of `CORE_ROOT/corerun`. - -The generated Bash wrappers enable this path with `RunCrossGen2=1`. They stage the assembly stubs -under `IL-CG2/wasi-r2r/comp`, run the splice from `CORE_ROOT/wasi-r2r/pipeline_shim.py`, and launch -`IL-CG2/wasi-r2r/corerun-composite.wasm`. `APP_ASSEMBLIES=EXTERNAL` enables the host's assembly -probe, `CORE_LIBRARIES` points it at these stubs, and `TEST_READY_TO_RUN_MODE=1` reaches the guest -for R2R-specific test conditions. -Without `RunCrossGen2`, the wrapper uses the unmodified interpreter host and does not probe those -stubs. Composition failures fail the test rather than falling back to interpretation. - -Helix receives pinned Binaryen and `wasm-tools` binaries through a separate correlation payload, -provisioned by `eng/testing/wasi-r2r-provisioning.targets`; it does not need these tools preinstalled -in the queue image. Local runs need the same tools and Python 3 on `PATH`. - -The runtime-test path compiles only the test assemblies, not the framework. It uses the shared -corerun's reserved image buffer and table slots, whose bounds the splice checks before merging. -Publish still sizes and relinks its host from the complete app/framework composite. - -### Cost at framework scale - -Measured on a 232,673-function framework composite (post-#132906, so 4 exports and a 28.8 MB `name` -section) spliced into `corerun`: - -| step | wall | peak RSS | output | -| --- | --- | --- | --- | -| `wasm-merge -g` | — | **4.25 GB** | 134,753,587 bytes | -| `wasm-opt --simplify-globals -g` | 6.19 s | **2.70 GB** | names 34,152,317 bytes, 232,673 named | - -The peak is the whole working set, not a delta, so it is straightforward to measure and reproduce. -Fine on a dev box; **a CI container with a 4 GB limit will not survive the merge.** Size the runner -before putting this in a pipeline. - -`wasm-merge` renumbers the name map alongside the functions, verified at this scale: the composite's -function 0 lands at merged index 10,105, offset by corerun's own function count, and -`System_Console_System_Console__WriteLine` resolves at 17,514. A name section carried through -*unshifted* would have produced wrong names everywhere while still validating and still running, so -this is worth knowing rather than assuming. - -**Both `-g` flags are load-bearing.** Dropping it from the fold removes the `name` section entirely -and `wasm-tools validate` still answers `YES` — measured, not inferred. Since #132906 the name -section is the only record of function names, so a post-processing step without `-g` silently -anonymises every frame. - -## How the splice works - -The composite `crossgen2` emits is **self-installing**: the webcil payload is an ACTIVE data segment -at `(global.get __memory_base)` and the R2R function table is an ACTIVE element segment at -`(global.get __table_base)`, so the engine installs both at instantiation. Nothing has to rewrite the -module afterwards, which is what retired `activate`. - -`corerun` supplies five of the composite's seven imports directly, via link flags in -[`corerun/CMakeLists.txt`](../../src/coreclr/hosts/corerun/CMakeLists.txt): - -``` --Wl,--table-base= # reserve table slots 1..N for the composite --Wl,--export-table # -> __indirect_function_table --Wl,--export=__stack_pointer --Wl,--export=__coreclr_wasm_rtlrestorecontext_tag --Wl,--export=__async_continuation -``` - -That covers `memory`, `__indirect_function_table`, `__stack_pointer`, -`__coreclr_wasm_rtlrestorecontext_tag` and `__async_continuation`. - -**The two it cannot supply are `__memory_base` and `__table_base`.** `wasm-ld` creates those globals -only in PIC mode, and a wasm global whose initializer is a data symbol's address is not expressible -from C — which is exactly what `surgery` used to inject post-link. `pipeline_shim.py` generates a -six-line shim module exporting them as constants and merges it as a third input, which retired -`surgery`. - -Three things about this are easy to get wrong: - -- **`--table-base`, not a growable table.** An ACTIVE element segment is installed by the engine at - instantiation, so the table must *already* be large enough; growth at runtime does not help. - Reserve by the composite's **function** count, not its assembly count. The reservation keeps the - table fixed-size (`min == max`) so it still validates statically, and costs little — the extra bytes - come from wider LEB encodings for the shifted indices, not from the table. Measured: `6298/6298` → - `71834/71834` at `--table-base=65537`, +51 KB (0.14%) at 500,001 slots. -- **The fold is required, and is not free.** Merging internalizes the imported globals, and - `global.get` of a *defined* global is a constant expression only under the GC proposal — so the - merge needs `--enable-gc` and the result needs `wasm-opt --simplify-globals` to be portable - (wasmtime rejects the unfolded form under `exceptions` alone; V8 accepts it, so "it loaded in node" - proves nothing). The pass also propagates globals into function bodies, costing ~3.7% code size. - A host that supplies the bases at *instantiation* instead — as the browser does — keeps `global.get` - of an **imported** global, which is valid MVP, and pays neither cost. -- **Payload offset 28 must be patched before the runtime reads it.** The composition shim calls the - composite's exported `patchWebcilHeader` from its start function, so the image owns its format. - [`wasi_r2r_probe.hpp`](../../src/coreclr/hosts/corerun/wasi_r2r_probe.hpp) retains a native fallback - only for older composites that do not export that function. - - This is measured, not argued. Setting the host's table base to 2 while the shim installs at 1 makes - the run fail with `wasm trap: indirect call type mismatch` — a symptom nowhere near its cause. Note - the corollary for the open `call_indirect` bugs: **table-index misalignment is a producer of that - symptom, so a signature mismatch is not by itself evidence of a signature-encoding fault.** - -## Historical note: the removed nesm dependency - -`surgery` and `activate` existed because nothing supplied the composite's imports at link time and -nothing emitted its segments in active form. Both were addressable, and the result is *more* -declarative than the pipeline they replaced rather than less. Kept here because the measurement that -sized the reservation is still the one to reuse, and because the import accounting is easy to get -wrong in the same way twice. - -**The host half is *mostly* done by the linker — five of the composite's seven imports, not all.** - -> **Correction, recorded because the wrong number was load-bearing.** An earlier revision claimed -> **six** of seven, implying only one gap. Enumerating the exports of the corerun actually built with -> these flags gives nine — `cabi_realloc`, `GetDotNetRuntimeContractDescriptor`, `memory`, -> `wasi:cli/run@0.2.0#run`, `wasi_r2r_image_base`, `__async_continuation`, -> `__coreclr_wasm_rtlrestorecontext_tag`, `__indirect_function_table`, `__stack_pointer` — of which -> **five** match composite imports. `--table-base` shifts the table layout but creates no exported -> `__table_base` global, and `wasi_r2r_image_base` is a *function*, so it cannot satisfy a global -> import. Independently corroborated: merging the real composite into the real browser `corerun.wasm` -> leaves exactly `__memory_base` and `__table_base` unresolved and nothing else. Two hosts, two -> toolchains, same two globals — which is what identified the shim as the remaining work. - -The extraction step the shim depends on is *not* new: reading the image base out of the linked host -was already how `surgery` got its argument. `wasi_r2r_image_base`'s body is a single -`i32.const ` (it returns `&g_wasi_r2r_image[0]`), so it decodes statically with no -instantiation. Two things to carry forward: - -- `wasm-tools component unbundle` is **mandatory** first — `corerun` is a WASI component and - core-module readers reject components outright. -- The old shell pipeline extracted these values by scraping `wasm-objdump` text, and that is - where its sharpest edges were: the `awk` form silently yielded an **empty string** under BSD - `awk` (the macOS default), and the payload-size scrape selected `segment[1]` positionally and - skipped its own cap check when the scrape came back empty. `pipeline_shim.py` parses the - sections instead and selects by meaning — the payload is "the one active data segment", not - an index — so a layout change is an error rather than a silently skipped check. The general - lesson outlives the port: **scraping a disassembler's text makes a missing value - indistinguishable from a zero.** - -Measured on the real 36 MB corerun: table `6298/6298` → `71834/71834` with `--table-base=65537`, -exports 6 → 9, and the run still passes with `DOTNET_ReadyToRun=0` (verified against a same-binary -control, since the `StackTrace` frame count differs between R2R on and off for unrelated reasons). - -Cost of the reservation is small and mostly independent of its size — the extra bytes come from wider -LEB encodings for the shifted function indices, not from the table itself: - -| `--table-base` | corerun bytes | table min/max | -| --- | --- | --- | -| default (1) | 36,284,003 | 6,298 | -| 65,537 | 36,284,095 | 71,834 | -| 500,001 | 36,336,407 | 506,298 | - -**Size from the composite's function count, not its assembly count.** Every function consumes a -table slot. The publish target inspects the completed composite before linking the host, reserves -exactly `function count + 1` table slots, and supplies a strong image-buffer symbol whose size exactly -matches the active payload. Non-R2R app links use the host archive's 64-byte weak fallback instead of -paying a fixed 16 MiB reservation. - -That leaves the whole splice as `wasm-tools component unbundle` → `wasm-merge` → reassemble, all -standard tooling. - -> **Do not populate the image or table from a `start` function.** A composite that grows its own table and populates it -> via `table.init`/`memory.init` at startup does work — verified end-to-end, including that -> `wasm-merge` correctly combines two start functions. But it replaces declarative, engine-applied -> installation with guest code mutating its own dispatch table at runtime, and it forfeits the -> statically-known table size. The small start function used here only calls `patchWebcilHeader`; -> active segments still install the payload and function table declaratively. diff --git a/eng/wasi-r2r/comp.rsp.template b/eng/wasi-r2r/comp.rsp.template deleted file mode 100644 index 142417cb4feda3..00000000000000 --- a/eng/wasi-r2r/comp.rsp.template +++ /dev/null @@ -1,17 +0,0 @@ -# crossgen2 composite response file — TEMPLATE. -# Replace @ROOT@ with your worktree root. First line = the app assembly (Hello.dll). -# The framework assemblies listed here are R2R'd INTO the composite; their IL is still loaded at run time. -# -# NOTE: System.Private.CoreLib comes from CoreCLR's own IL output, NOT from the wasi-wasm runtime -# pack's native/ directory — the pack's copy is Mono's CoreLib and produces a silently broken image. -@ROOT@/r2rtest/in/Hello.dll -@ROOT@/artifacts/bin/coreclr/wasi.wasm.Release/IL/System.Private.CoreLib.dll -@ROOT@/artifacts/bin/microsoft.netcore.app.runtime.wasi-wasm/Release/runtimes/wasi-wasm/lib/net11.0/System.Runtime.dll -@ROOT@/artifacts/bin/microsoft.netcore.app.runtime.wasi-wasm/Release/runtimes/wasi-wasm/lib/net11.0/System.Console.dll --o:@ROOT@/r2rtest/out/composite-r2r.wasm ---composite --O ---targetarch:wasm ---targetos:wasi ---codegenopt:JitWasmNyiToR2RUnsupported=1 ---codegenopt:JitWasmSimdNyiToR2RUnsupported=1 diff --git a/src/coreclr/hosts/corerun/CMakeLists.txt b/src/coreclr/hosts/corerun/CMakeLists.txt index 7b2d13a129c9a9..f58546186e8faf 100644 --- a/src/coreclr/hosts/corerun/CMakeLists.txt +++ b/src/coreclr/hosts/corerun/CMakeLists.txt @@ -5,13 +5,13 @@ set(CMAKE_INCLUDE_CURRENT_DIR ON) set(CORERUN_IN_BROWSER 0) # Link corerun so a composite ReadyToRun image can be spliced into it afterwards (see -# eng/wasi-r2r/README.md). ON by default because corerun is the host this work is developed and +# docs/workflow/building/coreclr/wasi-r2r.md). ON by default because corerun is the host this work is developed and # tested against. Turning it OFF drops the table reservation and shrinks the probe's staging buffer # to a stub, for a WASI corerun that will never be spliced. option(CORERUN_WASI_COMPOSITE_R2R "Reserve table slots and export the globals a spliced R2R composite needs" ON) # Table slots 1..N-1 are reserved for the composite's ACTIVE element segment, which the engine installs # at instantiation, so the table must already be large enough. Reserve by the composite's FUNCTION -# count, not its assembly count; eng/wasi-r2r/pipeline_shim.py checks this and names the value needed. +# count, not its assembly count; compose-r2r.py checks this and names the value needed. set(CORERUN_WASI_R2R_TABLE_BASE "65537" CACHE STRING "First table slot for corerun's own address-taken functions") if(CLR_CMAKE_HOST_WIN32) diff --git a/src/coreclr/hosts/corerun/wasi_r2r_probe.hpp b/src/coreclr/hosts/corerun/wasi_r2r_probe.hpp index 074c33da70846c..25ba71b610ebdd 100644 --- a/src/coreclr/hosts/corerun/wasi_r2r_probe.hpp +++ b/src/coreclr/hosts/corerun/wasi_r2r_probe.hpp @@ -7,9 +7,8 @@ // obtain the composite R2R webcil image and the per-assembly stubs. Keeping it here (rather than in a // single host) means both hosts serve R2R identically instead of one silently falling back to interp. // -// The splice that populates it is hand-driven (eng/wasi-r2r/pipeline_shim.py); there is no SDK path -// for WASI R2R yet, so this serves the runtime tests and the development loop rather than shipping -// apps. Both hosts must be linked with the flags that supply a composite's imports -- see +// src/mono/wasi/build/compose-r2r.py populates the image for in-tree publishing and runtime tests. +// Both hosts must be linked with the flags that supply a composite's imports -- see // CORERUN_WASI_COMPOSITE_R2R in corerun/CMakeLists.txt and WasiEnableCompositeR2R in // WasiApp.CoreCLR.targets. Without them this probe compiles but can never be satisfied. // @@ -53,7 +52,7 @@ static constexpr uint32_t g_wasi_r2r_image_cap = WASI_R2R_IMAGE_CAP; // The table index at which the composite's functions are installed. Under the reservation model the // host is linked with `-Wl,--table-base=`, which moves corerun's own address-taken functions up // to start at N+1 and leaves slots 1..N free, so the composite always sits at base 1 regardless of -// its size. This MUST match the `__table_base` global supplied to the merge (see eng/wasi-r2r/README.md); +// its size. This MUST match the `__table_base` global supplied to the merge (see docs/design/mono/webcil.md); // the two are a coupled constant and a mismatch is silent -- see the patch in WasiStaticR2RProbe. #ifndef WASI_R2R_TABLE_BASE #define WASI_R2R_TABLE_BASE (1u) @@ -250,7 +249,7 @@ static bool WasiStaticR2RProbe(const char* name, const char* const* dirs, size_t // // NOTE: the cap test above cannot protect this buffer -- the engine installs the segment before any // host code runs, so an over-cap payload has already overwritten whatever follows by the time we look. - // The enforceable check is at build time; pipeline_shim.py compares the payload size against the cap. + // The enforceable check is at build time; compose-r2r.py compares the payload size against the cap. uint8_t* hdr = &g_wasi_r2r_image[0]; if (WasiWebcilHeaderSize(hdr, (size_t)payloadSize) >= WEBCIL_HEADER_V1_SIZE) { @@ -305,7 +304,7 @@ extern "C" __attribute__((export_name("wasi_r2r_image_base"))) uint32_t wasi_r2r // The staging buffer's capacity and the table slot the composite installs at, exported for the same // reason as the base: the splice must not carry its own copy of either. The host owns these values; -// eng/wasi-r2r/pipeline_shim.py reads them out of the linked binary and validates the composite +// compose-r2r.py reads them out of the linked binary and validates the composite // against them, so a mismatch is a build-time error instead of a wrong-function dispatch at runtime. #ifdef WASI_R2R_EXTERNAL_IMAGE_BUFFER #define WASI_R2R_IMAGE_CAP_WEAK __attribute__((weak)) diff --git a/src/mono/wasi/build/WasiApp.CoreCLR.targets b/src/mono/wasi/build/WasiApp.CoreCLR.targets index 7b56dccc4cd03d..7937590246d00d 100644 --- a/src/mono/wasi/build/WasiApp.CoreCLR.targets +++ b/src/mono/wasi/build/WasiApp.CoreCLR.targets @@ -402,13 +402,13 @@ <_WasiRelinkOutput>$(_WasiRelinkObjDir)corerun <_WasiPublishedHost>$(WasmAppDir)managed\corerun false + compose-r2r.py checks this and names the required value if it is too small. --> 65537 @@ -478,7 +478,7 @@ + EnvironmentVariables="COMP=$(_WasiR2RCompositePath);CORERUN=$(_WasiRelinkOutput);OUTDIR=$(_WasiR2RComposeOutputDir)" /> diff --git a/src/mono/wasi/build/WasiApp.InTree.props b/src/mono/wasi/build/WasiApp.InTree.props index 9b61247b8e8e66..faf5cd0d1603d9 100644 --- a/src/mono/wasi/build/WasiApp.InTree.props +++ b/src/mono/wasi/build/WasiApp.InTree.props @@ -11,7 +11,7 @@ $([MSBuild]::NormalizeDirectory('$(ArtifactsBinDir)', 'wasi-wasm.$(Configuration)', 'sharedFramework')) $([MSBuild]::NormalizeDirectory('$(ArtifactsBinDir)', 'coreclr', 'wasi.wasm.$(Configuration)', 'sharedFramework')) $([MSBuild]::NormalizePath('$(ArtifactsBinDir)', 'coreclr', 'wasi.wasm.$(Configuration)', 'IL', 'System.Private.CoreLib.dll')) - $([MSBuild]::NormalizePath('$(RepoRoot)', 'eng', 'wasi-r2r', 'pipeline_shim.py')) + $(MSBuildThisFileDirectory)compose-r2r.py diff --git a/eng/wasi-r2r/pipeline_shim.py b/src/mono/wasi/build/compose-r2r.py similarity index 95% rename from eng/wasi-r2r/pipeline_shim.py rename to src/mono/wasi/build/compose-r2r.py index 8a3a40880d42b6..22726b8dbc4494 100644 --- a/eng/wasi-r2r/pipeline_shim.py +++ b/src/mono/wasi/build/compose-r2r.py @@ -1,9 +1,8 @@ #!/usr/bin/env python3 -"""Splice a wasm R2R composite into corerun using only stock tooling. +# Licensed to the .NET Foundation under one or more agreements. +# The .NET Foundation licenses this file to you under the MIT license. -Replaces pipeline-shim.sh. Same pipeline, but it parses wasm sections directly instead of -scraping `wasm-objdump` output, which makes it run on Windows and lets every lookup select -by meaning rather than by position. +"""Compose a wasm R2R image into a WASI host component. The composite crossgen2 emits is SELF-INSTALLING: the webcil payload is an ACTIVE data segment at (global.get __memory_base) and the R2R function table is an ACTIVE element @@ -14,10 +13,10 @@ The remaining two are the base globals, which wasm-ld only creates in PIC mode -- so a generated shim module supplies them instead. -Requires: wasm-tools, binaryen (wasm-merge, wasm-opt). wabt is NOT required; the shim is -assembled here and every value that used to come from wasm-objdump is parsed directly. +Requires: wasm-tools and binaryen (wasm-merge, wasm-opt). The shim is assembled here +and the host's reservation parameters are read directly from its module. - COMP= CORERUN= python3 pipeline_shim.py + COMP= CORERUN= OUTDIR= python3 compose-r2r.py """ import os @@ -360,7 +359,7 @@ def tool(name): found = shutil.which(name) if found is None: raise WasmError(f"required tool '{name}' is not on PATH. " - "Install wasm-tools and binaryen; see eng/wasi-r2r/README.md.") + "Install wasm-tools and binaryen; see docs/workflow/building/coreclr/wasi-r2r.md.") return found @@ -404,14 +403,15 @@ def main(): if len(sys.argv) != 1: raise WasmError( - "usage: pipeline_shim.py [--describe|--function-count|--payload-size] " + "usage: compose-r2r.py [--describe|--function-count|--payload-size] " "") - root = Path(os.environ.get("ROOT") or Path(__file__).resolve().parents[2]) - comp = Path(os.environ.get("COMP") or root / "r2rtest/out2/composite-r2r.wasm") - corerun = Path(os.environ.get("CORERUN") - or root / "artifacts/obj/coreclr/wasi.wasm.Release/hosts/corerun/corerun") - outdir = Path(os.environ.get("OUTDIR") or root / "r2rtest/shimout") + missing = [name for name in ("COMP", "CORERUN", "OUTDIR") if not os.environ.get(name)] + if missing: + raise WasmError(f"set the required environment variables: {', '.join(missing)}") + comp = Path(os.environ["COMP"]) + corerun = Path(os.environ["CORERUN"]) + outdir = Path(os.environ["OUTDIR"]) for label, path in (("composite", comp), ("corerun", corerun)): if not path.is_file(): diff --git a/src/tests/Common/CLRTest.CrossGen.targets b/src/tests/Common/CLRTest.CrossGen.targets index 094569a23f5dc1..127f485b0b62a9 100644 --- a/src/tests/Common/CLRTest.CrossGen.targets +++ b/src/tests/Common/CLRTest.CrossGen.targets @@ -246,7 +246,7 @@ if [ ! -z ${RunCrossGen2+x} ]%3B then bareFileName="${dllFile##*/}" cp "$PWD/${bareFileName%.dll}.wasm" IL-CG2/wasi-r2r/comp/ || exit 1 done - COMP="$PWD/composite-r2r.wasm" CORERUN="$CORE_ROOT/corerun" OUTDIR="$PWD/IL-CG2/wasi-r2r" python3 "$CORE_ROOT/wasi-r2r/pipeline_shim.py" + COMP="$PWD/composite-r2r.wasm" CORERUN="$CORE_ROOT/corerun" OUTDIR="$PWD/IL-CG2/wasi-r2r" python3 "$CORE_ROOT/wasi-r2r/compose-r2r.py" ) __linkExitCode=$? if [ $__linkExitCode -ne 0 ]; then diff --git a/src/tests/Common/Directory.Build.targets b/src/tests/Common/Directory.Build.targets index 7fe92cb20d4a9f..aa07c48b7f518c 100644 --- a/src/tests/Common/Directory.Build.targets +++ b/src/tests/Common/Directory.Build.targets @@ -101,7 +101,7 @@ From 2a51a273c2084dbefb5c6923d6e3f60ade587e61 Mon Sep 17 00:00:00 2001 From: Larry Ewing Date: Fri, 4 Sep 2026 18:23:59 -0500 Subject: [PATCH 17/17] Set Helix type for build-and-send R2R jobs ReadyToRun callers previously omitted the required SendHelixJob Type parameter. Use the same functional R2R Helix type as run-test-job. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 4498e5c2-72ce-4aff-882e-77b102380716 --- .../runtimes/build-runtime-tests-and-send-to-helix.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/eng/pipelines/common/templates/runtimes/build-runtime-tests-and-send-to-helix.yml b/eng/pipelines/common/templates/runtimes/build-runtime-tests-and-send-to-helix.yml index 6bdf98d58f9fa9..745b56ce898e41 100644 --- a/eng/pipelines/common/templates/runtimes/build-runtime-tests-and-send-to-helix.yml +++ b/eng/pipelines/common/templates/runtimes/build-runtime-tests-and-send-to-helix.yml @@ -112,7 +112,9 @@ steps: helixBuild: $(Build.BuildNumber) helixSource: $(_HelixSource) - ${{ if ne(parameters.readyToRun, true) }}: + ${{ if eq(parameters.readyToRun, true) }}: + helixType: 'test/functional/r2r/cli/' + ${{ else }}: helixType: 'test/functional/cli/' helixQueues: ${{ parameters.helixQueues }}