diff --git a/docs/design/mono/wasm-custom-sections.md b/docs/design/mono/wasm-custom-sections.md new file mode 100644 index 00000000000000..0727f014908298 --- /dev/null +++ b/docs/design/mono/wasm-custom-sections.md @@ -0,0 +1,121 @@ +# WebAssembly `producers` and `build_id` custom sections + +This document describes how .NET emits the two WebAssembly [tool-conventions](https://github.com/WebAssembly/tool-conventions) +custom sections into `dotnet.native.wasm` for browser-wasm applications, and the decisions behind the +current (prototype) implementation. + +Related issues: +- [dotnet/runtime#91049](https://github.com/dotnet/runtime/issues/91049) – emit `build_id` +- [dotnet/runtime#96334](https://github.com/dotnet/runtime/issues/96334) – emit `producers` + +Specs: +- [ProducersSection.md](https://github.com/WebAssembly/tool-conventions/blob/main/ProducersSection.md) +- [BuildId.md](https://github.com/WebAssembly/tool-conventions/blob/main/BuildId.md) + +## Summary of the two sections + +| Section | Purpose | How it is produced | +|-------------|-------------------------------------------------------------------------|--------------------| +| `producers` | Static telemetry about the toolchain that produced the module. | A small linkable object is passed to `wasm-ld`, which merges it into the module's `producers` section. | +| `build_id` | A value used to correlate a module with its symbols/sources. | Emitted by `wasm-ld` via the `--build-id` linker flag. | + +Both sections are **linker-driven**: they are produced by/through `wasm-ld` during the native link, +rather than by rewriting the module after the fact. + +## Scope + +- Applies to **application** builds that relink the native runtime (`WasmBuildNative=true`), for both + **Mono** and **CoreCLR** browser-wasm runtimes (in-tree targets only). +- The **default** `dotnet.native.wasm` shipped in the runtime pack (consumed by non-relinking app + builds) is intended to carry a `build_id` equal to the runtime git hash, stamped at runtime-build + time and surfaced the same way the git hash is (see [Default runtime pack](#default-runtime-pack)). + +## `build_id` + +`build_id` is emitted by the WebAssembly linker. `wasm-ld` accepts, among others: + +``` +--build-id=0x # a caller-specified value +--build-id=uuid # random UUID per link +--build-id=fast|sha1|none +``` + +Behavior: + +- `build_id` is emitted **only** when the app sets `$(WasmNativeBuildId)` to a hex string; the link + then uses `--build-id=0x` to pin that exact value. There is intentionally **no** implicit + random-per-build id. +- Setting `$(WasmNativeBuildId)` **forces a native build**. If `WasmBuildNative=false` is also set, the + build fails with an error, because a specific `build_id` can only be applied by relinking. + +## `producers` + +The `producers` section records the toolchain. Per the spec it must appear at most once. `wasm-ld` +already **merges** the `producers` sections found in its input objects (clang/LLVM contribute +`language`/`processed-by` automatically). `wasm-ld` has no command-line option to inject additional +`producers` entries, so .NET contributes its entries by handing `wasm-ld` a tiny **relocatable wasm +object** whose only meaningful content is a `producers` custom section. `wasm-ld` merges it with the +rest, keeping the section single. .NET contributes: + +- `language`: `C#` +- `processed-by`: the runtime name (`Mono` or `CoreCLR`) with the product version +- `sdk`: `.NET` with the product version + +Emitting `producers` can be disabled with `$(WasmEmitProducersSection)=false`. + +> Note: emscripten runs `wasm-opt` after the link. Binaryen preserves the `producers` section by +> default, so the merged entries survive optimization; this should be verified end-to-end by a real relink. + +## Implementation + +- `src/tasks/Microsoft.NET.WebAssembly.Webcil/WasmCustomSectionWriter.cs` – a reusable managed writer. + `WriteProducersObject` emits a minimal relocatable wasm object (module header + an empty `linking` + section that marks it as an object + the `producers` custom section). +- `src/tasks/WasmAppBuilder/WasmEmitProducersObject.cs` – the MSBuild task wrapping the writer. It runs + **before** the link and writes `dotnet-producers.o`, which is added to the link inputs. +- MSBuild wiring: + - Mono: `src/mono/browser/build/BrowserWasmApp.targets` (+ shared logic in + `src/mono/wasm/build/WasmApp.Common.targets`). + - CoreCLR: `src/mono/browser/build/BrowserWasmApp.CoreCLR.targets`. + +## JavaScript exposure + +`build_id` is **not** read back from the compiled module. It is exposed on `runtimeBuildInfo.buildId` +through the same two-channel mechanism used for options such as `wasmEnableSIMD` (both channels are fed +by the `WASM_BUILD_ID` environment variable, kept in sync with the `--build-id` flag): + +- **Default `dotnet.*.js` build** (no relink): a rollup constant (`consts:buildId`) baked into + `dotnet.js`/`dotnet.runtime.js` at runtime-pack build time provides the value. +- **Application re-link**: the (re)linked native module carries the value via the emscripten `.lib.js` + footer, which reads `process.env.WASM_BUILD_ID` at link time. When present, this overrides the rollup + constant (which cannot change on a relink, since `dotnet.runtime.js` is not rebuilt): + - Mono: the footer emits it onto `emscriptenBuildOptions.buildId`; `passEmscriptenInternals` copies it + onto `runtimeBuildInfo.buildId`. + - CoreCLR: the footer exposes it on the `$DOTNET` object; the native module init copies it onto + `runtimeBuildInfo.buildId`. + +```js +const { runtimeBuildInfo } = await dotnet.create(); +console.log(runtimeBuildInfo.buildId); // "" when no build_id was stamped +``` + +## Default runtime pack + +The default `dotnet.native.wasm` in the runtime pack is linked by the core native build. To give it a +stable `build_id` equal to the runtime git hash, that build sets `$(WasmNativeBuildId)` to the git hash +(so `--build-id` stamps the section) and passes the matching `WASM_BUILD_ID` into both the rollup +constant and the native footer, so `runtimeBuildInfo.buildId` reports the same value the way the git +hash is reported. When a value is not +stamped, `runtimeBuildInfo.buildId` is `""`, accurately reflecting the absence of a `build_id` section. + +## MSBuild properties + +| Property | Default | Meaning | +|------------------------------|---------|---------| +| `WasmEmitProducersSection` | `true` | Emit/merge the `producers` section (via a linkable object). | +| `WasmNativeBuildId` | (unset) | Hex string to stamp as the `build_id`. Forces a native build. Empty means no `build_id` section. | + +## Open questions / future work + +- **Upstream registration.** The `processed-by`/`sdk` values are not yet registered with the + tool-conventions repository. diff --git a/src/coreclr/hosts/corerun/wasm/libCorerun.extpost.js b/src/coreclr/hosts/corerun/wasm/libCorerun.extpost.js index 521ba459691b60..622c17d1cd1ecc 100644 --- a/src/coreclr/hosts/corerun/wasm/libCorerun.extpost.js +++ b/src/coreclr/hosts/corerun/wasm/libCorerun.extpost.js @@ -14,6 +14,7 @@ export function selfRun(moduleConfig) { runtimeBuildInfo: { productVersion: "corerun", gitHash: null, + buildId: "", buildConfiguration: "corerun", wasmEnableThreads: false, wasmEnableSIMD: true, diff --git a/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.cs b/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.cs index 9457a0368642aa..0f2b0d12e151af 100644 --- a/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.cs +++ b/src/coreclr/tools/Common/Compiler/ObjectWriter/WebCilObjectWriter.cs @@ -8,6 +8,7 @@ using System.IO; using System.Linq; using System.Numerics; +using System.Security.Cryptography; using ILCompiler.DependencyAnalysis; using ILCompiler.DependencyAnalysis.Wasm; using ILCompiler.DependencyAnalysisFramework; @@ -33,9 +34,30 @@ internal sealed class WebCilObjectWriter : WasmObjectWriter // 1 for the payload size, and the second for the payload itself. const int NumDataSegments = 2; - public WebCilObjectWriter(NodeFactory factory, ObjectWritingOptions options, OutputInfoBuilder outputInfoBuilder) + private readonly byte[] _wasmNativeBuildId; + + public WebCilObjectWriter(NodeFactory factory, ObjectWritingOptions options, OutputInfoBuilder outputInfoBuilder, string wasmNativeBuildId = null) : base(factory, options, outputInfoBuilder) { + _wasmNativeBuildId = ParseHexBuildId(wasmNativeBuildId); + } + + private static byte[] ParseHexBuildId(string value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + string hex = value.Trim(); + if (hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + hex = hex.Substring(2); + + if (hex.Length == 0 || (hex.Length % 2) != 0) + throw new ArgumentException($"WasmNativeBuildId '{value}' is not a valid hex string."); + + var bytes = new byte[hex.Length / 2]; + for (int i = 0; i < bytes.Length; i++) + bytes[i] = byte.Parse(hex.AsSpan(i * 2, 2), System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture); + return bytes; } private Dictionary _wellKnownSymbols = new(); @@ -551,6 +573,17 @@ private protected override void EmitObjectFile(Stream outputFileStream) WasmNameSection nameSection = new WasmNameSection(_wasmSymbolManager.GetDefinitions(WasmIndexSpace.Function)); nameSection.EmitToStream(outputFileStream); +#if READYTORUN + byte[] webcilBuildId = _wasmNativeBuildId ?? ComputeModuleHash(outputFileStream); + WasmCustomSectionWriter.ProducerValue[] producers = + [ + new(WasmCustomSectionWriter.ProducersFieldLanguage, "C#", string.Empty), + new(WasmCustomSectionWriter.ProducersFieldProcessedBy, "WebCIL", string.Empty), + new(WasmCustomSectionWriter.ProducersFieldSdk, ".NET", string.Empty), + ]; + WasmCustomSectionWriter.AppendMetadataSections(outputFileStream, producers, webcilBuildId); +#endif + if (_outputInfoBuilder is not null) { // Populate the output section layout so OutputInfoBuilder.EnumerateMethods can resolve each @@ -571,6 +604,18 @@ private protected override void EmitObjectFile(Stream outputFileStream) } } +#if READYTORUN + private static byte[] ComputeModuleHash(Stream outputFileStream) + { + long end = outputFileStream.Position; + outputFileStream.Position = 0; + using var sha = SHA256.Create(); + byte[] hash = sha.ComputeHash(outputFileStream); + outputFileStream.Position = end; + return hash; + } +#endif + // Maps each code-section entry boundary's pre-shrink content offset to its final (post-shrink) // offset, populated during ResolveCodeRelocations so method node offsets and lengths can be // corrected for the R2R perfmap. diff --git a/src/coreclr/tools/Common/Wasm/WasmCustomSectionWriter.cs b/src/coreclr/tools/Common/Wasm/WasmCustomSectionWriter.cs new file mode 100644 index 00000000000000..2abf878888d11a --- /dev/null +++ b/src/coreclr/tools/Common/Wasm/WasmCustomSectionWriter.cs @@ -0,0 +1,368 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Text; + +namespace Microsoft.NET.WebAssembly.Webcil; + +/// +/// Writes the tool-conventions producers and build_id custom sections into a +/// WebAssembly module. Both are non-semantic custom sections and can be stripped at any time. +/// +/// See: +/// - https://github.com/WebAssembly/tool-conventions/blob/main/ProducersSection.md +/// - https://github.com/WebAssembly/tool-conventions/blob/main/BuildId.md +/// +public static class WasmCustomSectionWriter +{ + public const string ProducersSectionName = "producers"; + public const string BuildIdSectionName = "build_id"; + public const string LinkingSectionName = "linking"; + + // The three well-known producers field names. + public const string ProducersFieldLanguage = "language"; + public const string ProducersFieldProcessedBy = "processed-by"; + public const string ProducersFieldSdk = "sdk"; + + private const uint WasmMagic = 0x6d736100u; // "\0asm" + private const uint WasmModuleVersion = 1; + // Metadata version of the tool-conventions "linking" section that marks a relocatable object. + private const uint WasmObjectMetadataVersion = 2; + private const byte CustomSectionId = 0; + + /// + /// A single (name, version) value belonging to a producers field. + /// + public readonly struct ProducerValue + { + public ProducerValue(string field, string name, string version) + { + Field = field ?? throw new ArgumentNullException(nameof(field)); + Name = name ?? throw new ArgumentNullException(nameof(name)); + Version = version ?? string.Empty; + } + + /// One of , or . + public string Field { get; } + public string Name { get; } + public string Version { get; } + } + + /// + /// Appends fresh producers and/or build_id custom sections to + /// at the current position. The caller is responsible for ensuring these are written after the + /// module's name section (as the tool-conventions spec requires for producers). This is + /// the low-level primitive used by producers that build their own module bytes (e.g. the webcil + /// wasm wrapper and the ReadyToRun wasm object writer). + /// + /// Stream positioned at the end of the module body. + /// Producers values to write. May be /empty to skip. + /// Raw build id bytes. May be /empty to skip. + public static void AppendMetadataSections(Stream output, IEnumerable? producers, byte[]? buildId) + { + if (output is null) + ThrowArgumentNull(nameof(output)); + + if (producers is not null) + { + var list = new List(producers); + if (list.Count > 0) + WriteProducersSection(output, list); + } + + if (buildId is not null && buildId.Length > 0) + WriteBuildIdSection(output, buildId); + } + + /// + /// Rewrites in place, replacing any existing producers and + /// build_id custom sections with fresh ones built from the supplied data. Existing + /// producers entries emitted by other tools (clang/LLVM/Emscripten) are preserved and merged. + /// + /// Path to the WebAssembly module to modify. + /// Producers values to add. May be to leave the section untouched. + /// Raw build id bytes. May be to leave the section untouched. + public static void WriteMetadata(string wasmPath, IEnumerable? producers, byte[]? buildId) + { + if (wasmPath is null) + ThrowArgumentNull(nameof(wasmPath)); + + byte[] input = File.ReadAllBytes(wasmPath); + byte[] output = WriteMetadata(input, producers, buildId); + File.WriteAllBytes(wasmPath, output); + } + + /// + /// Pure-in-memory variant of . + /// + public static byte[] WriteMetadata(byte[] moduleBytes, IEnumerable? producers, byte[]? buildId) + { + if (moduleBytes is null) + ThrowArgumentNull(nameof(moduleBytes)); + + // Nothing to do. + if (producers is null && buildId is null) + return moduleBytes; + + if (moduleBytes.Length < 8 || BitConverter.ToUInt32(moduleBytes, 0) != WasmMagic) + throw new InvalidDataException("Not a WebAssembly module (bad magic)."); + + uint version = BitConverter.ToUInt32(moduleBytes, 4); + if (version != 1) + throw new InvalidDataException($"Unsupported WebAssembly module version {version}."); + + const int bodyStart = 8; // magic (4) + version (4) + + var existingProducers = new List(); + var records = new List<(byte id, int recordStart, int recordLength, string? customName)>(); + + int pos = bodyStart; + while (pos < moduleBytes.Length) + { + int recordStart = pos; + byte id = moduleBytes[pos++]; + uint size = ReadULEB128(moduleBytes, ref pos); + int payloadStart = pos; + int payloadEnd = payloadStart + (int)size; + if (payloadEnd > moduleBytes.Length) + throw new InvalidDataException("Truncated WebAssembly section."); + + string? customName = null; + if (id == CustomSectionId) + { + int p = payloadStart; + customName = ReadName(moduleBytes, ref p); + if (customName == ProducersSectionName) + ReadProducers(moduleBytes, p, payloadEnd, existingProducers); + } + + records.Add((id, recordStart, payloadEnd - recordStart, customName)); + pos = payloadEnd; + } + + // Merge new producers on top of the ones we found (dedupe by field + name, new value wins for version). + List? mergedProducers = null; + if (producers is not null) + { + mergedProducers = new List(existingProducers); + foreach (ProducerValue value in producers) + { + int existingIndex = mergedProducers.FindIndex(p => + string.Equals(p.Field, value.Field, StringComparison.Ordinal) && + string.Equals(p.Name, value.Name, StringComparison.Ordinal)); + if (existingIndex >= 0) + mergedProducers[existingIndex] = value; + else + mergedProducers.Add(value); + } + } + + using var ms = new MemoryStream(moduleBytes.Length + 256); + ms.Write(moduleBytes, 0, bodyStart); + + foreach ((byte id, int recordStart, int recordLength, string? customName) in records) + { + // Drop the sections we are going to (re)write, so we never emit duplicates. + if (mergedProducers is not null && customName == ProducersSectionName) + continue; + if (buildId is not null && customName == BuildIdSectionName) + continue; + + // Re-emit the full section record (id byte + size + payload) verbatim. + ms.Write(moduleBytes, recordStart, recordLength); + } + + if (mergedProducers is not null) + WriteProducersSection(ms, mergedProducers); + + if (buildId is not null) + WriteBuildIdSection(ms, buildId); + + return ms.ToArray(); + } + + /// + /// Writes a minimal relocatable WebAssembly object file (.o) that carries only a + /// producers custom section. Passing this object as a link input lets wasm-ld merge + /// the .NET tool information into the final module's producers section, alongside the + /// entries clang/LLVM already contribute. This mirrors how build_id is produced by the + /// linker itself (via --build-id), keeping both custom sections linker-driven. + /// + public static void WriteProducersObject(string objectPath, IEnumerable producers) + { + if (objectPath is null) + ThrowArgumentNull(nameof(objectPath)); + if (producers is null) + ThrowArgumentNull(nameof(producers)); + + File.WriteAllBytes(objectPath, BuildProducersObject(producers)); + } + + /// + /// In-memory variant of . + /// + public static byte[] BuildProducersObject(IEnumerable producers) + { + if (producers is null) + ThrowArgumentNull(nameof(producers)); + + var values = new List(producers); + + using var ms = new MemoryStream(256); + WriteUInt32LE(ms, WasmMagic); + WriteUInt32LE(ms, WasmModuleVersion); + // The "linking" section (even with no subsections) marks the file as a relocatable object, + // which is what wasm-ld requires in order to consume and merge our producers section. + WriteLinkingSection(ms); + WriteProducersSection(ms, values); + return ms.ToArray(); + } + + private static void WriteLinkingSection(Stream output) + { + using var payload = new MemoryStream(); + WriteName(payload, LinkingSectionName); + WriteULEB128(payload, WasmObjectMetadataVersion); + WriteCustomSection(output, payload.ToArray()); + } + + private static void WriteUInt32LE(Stream output, uint value) + { + output.WriteByte((byte)(value & 0xff)); + output.WriteByte((byte)((value >> 8) & 0xff)); + output.WriteByte((byte)((value >> 16) & 0xff)); + output.WriteByte((byte)((value >> 24) & 0xff)); + } + + private static void ReadProducers(byte[] bytes, int pos, int end, List into) + { + if (pos >= end) + return; + uint fieldCount = ReadULEB128(bytes, ref pos); + for (uint f = 0; f < fieldCount && pos < end; f++) + { + string field = ReadName(bytes, ref pos); + uint valueCount = ReadULEB128(bytes, ref pos); + for (uint v = 0; v < valueCount && pos < end; v++) + { + string name = ReadName(bytes, ref pos); + string version = ReadName(bytes, ref pos); + into.Add(new ProducerValue(field, name, version)); + } + } + } + + // ------------------------------------------------------------------ + // Section writing helpers + // ------------------------------------------------------------------ + + private static void WriteProducersSection(Stream output, List producers) + { + // Group values by field, preserving first-seen order. + var order = new List(); + var byField = new Dictionary>(StringComparer.Ordinal); + foreach (ProducerValue value in producers) + { + if (!byField.TryGetValue(value.Field, out List? list)) + { + list = new List(); + byField[value.Field] = list; + order.Add(value.Field); + } + list.Add(value); + } + + using var payload = new MemoryStream(); + WriteName(payload, ProducersSectionName); + WriteULEB128(payload, (uint)order.Count); + foreach (string field in order) + { + List values = byField[field]; + WriteName(payload, field); + WriteULEB128(payload, (uint)values.Count); + foreach (ProducerValue value in values) + { + WriteName(payload, value.Name); + WriteName(payload, value.Version); + } + } + + WriteCustomSection(output, payload.ToArray()); + } + + private static void WriteBuildIdSection(Stream output, byte[] buildId) + { + using var payload = new MemoryStream(); + WriteName(payload, BuildIdSectionName); + // build_id payload: length-prefixed sequence of raw bytes. + WriteULEB128(payload, (uint)buildId.Length); + payload.Write(buildId, 0, buildId.Length); + + WriteCustomSection(output, payload.ToArray()); + } + + private static void WriteCustomSection(Stream output, byte[] payload) + { + output.WriteByte(CustomSectionId); + WriteULEB128(output, (uint)payload.Length); + output.Write(payload, 0, payload.Length); + } + + // ------------------------------------------------------------------ + // LEB128 / name primitives + // ------------------------------------------------------------------ + + [DoesNotReturn] + private static void ThrowArgumentNull(string paramName) => throw new ArgumentNullException(paramName); + + private static string ReadName(byte[] bytes, ref int pos) + { + uint len = ReadULEB128(bytes, ref pos); + string s = Encoding.UTF8.GetString(bytes, pos, (int)len); + pos += (int)len; + return s; + } + + private static void WriteName(Stream output, string value) + { + byte[] utf8 = Encoding.UTF8.GetBytes(value); + WriteULEB128(output, (uint)utf8.Length); + output.Write(utf8, 0, utf8.Length); + } + + private static uint ReadULEB128(byte[] bytes, ref int pos) + { + uint val = 0; + int shift = 0; + while (true) + { + byte b = bytes[pos++]; + val |= (b & 0x7fu) << shift; + if ((b & 0x80u) == 0) + break; + shift += 7; + if (shift >= 35) + throw new OverflowException("ULEB128 value too large."); + } + return val; + } + + private static void WriteULEB128(Stream output, uint value) + { + do + { + byte b = (byte)(value & 0x7fu); + value >>= 7; + if (value != 0) + b |= 0x80; + output.WriteByte(b); + } + while (value != 0); + } +} diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/CodeGen/ReadyToRunObjectWriter.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/CodeGen/ReadyToRunObjectWriter.cs index da31b47daab621..1757183b24ab26 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/CodeGen/ReadyToRunObjectWriter.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/CodeGen/ReadyToRunObjectWriter.cs @@ -117,6 +117,11 @@ internal class ReadyToRunObjectWriter /// private readonly int _customPESectionAlignment; + /// + /// Explicit WebAssembly build_id (hex) stamped into R2R webcil output; null to use a content hash. + /// + private readonly string _wasmNativeBuildId; + public ReadyToRunObjectWriter( string objectFilePath, EcmaModule componentModule, @@ -132,7 +137,8 @@ public ReadyToRunObjectWriter( int perfMapFormatVersion, bool generateProfileFile, CallChainProfile callChainProfile, - int customPESectionAlignment) + int customPESectionAlignment, + string wasmNativeBuildId = null) { _objectFilePath = objectFilePath; _componentModule = componentModule; @@ -140,6 +146,7 @@ public ReadyToRunObjectWriter( _nodes = nodes; _nodeFactory = factory; _customPESectionAlignment = customPESectionAlignment; + _wasmNativeBuildId = wasmNativeBuildId; _generateMapFile = generateMapFile; _generateMapCsvFile = generateMapCsvFile; _generatePdbFile = generatePdbFile; @@ -309,7 +316,7 @@ private MachObjectWriter CreateMachObjectWriter() private WasmObjectWriter CreateWasmObjectWriter() { - return new WebCilObjectWriter(_nodeFactory, ObjectWritingOptions.None, _outputInfoBuilder); + return new WebCilObjectWriter(_nodeFactory, ObjectWritingOptions.None, _outputInfoBuilder, _wasmNativeBuildId); } public static void EmitObject( @@ -329,7 +336,8 @@ public static void EmitObject( CallChainProfile callChainProfile, ReadyToRunContainerFormat format, int customPESectionAlignment, - Logger logger) + Logger logger, + string wasmNativeBuildId = null) { Console.WriteLine($@"Emitting R2R {format} file: {objectFilePath}"); ReadyToRunObjectWriter objectWriter = new ReadyToRunObjectWriter( @@ -347,7 +355,8 @@ public static void EmitObject( perfMapFormatVersion: perfMapFormatVersion, generateProfileFile: generateProfileFile, callChainProfile, - customPESectionAlignment); + customPESectionAlignment, + wasmNativeBuildId); objectWriter.EmitReadyToRunObjects(format, logger); } diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/ReadyToRunCodegenCompilation.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/ReadyToRunCodegenCompilation.cs index f68b30ded29608..ebff0563009749 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/ReadyToRunCodegenCompilation.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/ReadyToRunCodegenCompilation.cs @@ -319,6 +319,7 @@ public sealed class ReadyToRunCodegenCompilation : Compilation public ReadyToRunSymbolNodeFactory SymbolNodeFactory { get; } public ReadyToRunCompilationModuleGroupBase CompilationModuleGroup { get; } private readonly int _customPESectionAlignment; + private readonly string _wasmNativeBuildId; private readonly ReadyToRunContainerFormat _format; /// @@ -356,7 +357,8 @@ internal ReadyToRunCodegenCompilation( FileLayoutAlgorithm fileLayoutAlgorithm, int customPESectionAlignment, bool verifyTypeAndFieldLayout, - ReadyToRunContainerFormat format) + ReadyToRunContainerFormat format, + string wasmNativeBuildId = null) : base( dependencyGraph, nodeFactory, @@ -380,6 +382,7 @@ internal ReadyToRunCodegenCompilation( _generateProfileFile = generateProfileFile; _customPESectionAlignment = customPESectionAlignment; _format = format; + _wasmNativeBuildId = wasmNativeBuildId; SymbolNodeFactory = new ReadyToRunSymbolNodeFactory(nodeFactory, verifyTypeAndFieldLayout); _tokenManager = new ExternalReferenceTokenManager(_nodeFactory.ManifestMetadataTable._mutableModule, _nodeFactory.Resolver); if (nodeFactory.InstrumentationDataTable != null) @@ -440,7 +443,8 @@ public override void Compile(string outputFile) callChainProfile: _profileData.CallChainProfile, _format, _customPESectionAlignment, - _logger); + _logger, + _wasmNativeBuildId); CompilationModuleGroup moduleGroup = _nodeFactory.CompilationModuleGroup; if (moduleGroup.IsCompositeBuildMode) diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/ReadyToRunCodegenCompilationBuilder.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/ReadyToRunCodegenCompilationBuilder.cs index b400a308a51f9d..beff19260349cd 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/ReadyToRunCodegenCompilationBuilder.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/Compiler/ReadyToRunCodegenCompilationBuilder.cs @@ -39,6 +39,7 @@ public sealed class ReadyToRunCodegenCompilationBuilder : CompilationBuilder private MethodLayoutAlgorithm _r2rMethodLayoutAlgorithm; private FileLayoutAlgorithm _r2rFileLayoutAlgorithm; private int _customPESectionAlignment; + private string _wasmNativeBuildId; private bool _verifyTypeAndFieldLayout; private bool _hotColdSplitting; private bool _verifyGCModeTransitions; @@ -187,6 +188,12 @@ public ReadyToRunCodegenCompilationBuilder UseCustomPESectionAlignment(int custo return this; } + public ReadyToRunCodegenCompilationBuilder UseWasmNativeBuildId(string wasmNativeBuildId) + { + _wasmNativeBuildId = wasmNativeBuildId; + return this; + } + public ReadyToRunCodegenCompilationBuilder UseVerifyTypeAndFieldLayout(bool verifyTypeAndFieldLayout) { _verifyTypeAndFieldLayout = verifyTypeAndFieldLayout; @@ -377,7 +384,8 @@ public override ICompilation ToCompilation() _r2rFileLayoutAlgorithm, _customPESectionAlignment, _verifyTypeAndFieldLayout, - _format); + _format, + _wasmNativeBuildId); } } } diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj index b0dd89c86f575d..522809ec360d33 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun/ILCompiler.ReadyToRun.csproj @@ -215,6 +215,7 @@ + diff --git a/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs b/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs index 82111b81b11efc..23ae31aea9adc0 100644 --- a/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs +++ b/src/coreclr/tools/aot/crossgen2/Crossgen2RootCommand.cs @@ -32,6 +32,8 @@ internal class Crossgen2RootCommand : RootCommand new("--out", "-o") { Description = SR.OutputFilePath }; public Option OutputFormat { get; } = new("--obj-format", "-f") { CustomParser = MakeOutputFormat, DefaultValueFactory = MakeOutputFormat, Description = SR.OutputFormat, HelpName = "arg" }; + public Option WasmNativeBuildId { get; } = + new("--wasm-native-build-id") { Description = SR.WasmNativeBuildIdOption }; public Option CompositeRootPath { get; } = new("--compositerootpath", "--crp") { Description = SR.CompositeRootPath }; public Option Optimize { get; } = @@ -178,6 +180,7 @@ public Crossgen2RootCommand(string[] args) : base(SR.Crossgen2BannerText) Options.Add(MibcFilePaths); Options.Add(OutputFilePath); Options.Add(OutputFormat); + Options.Add(WasmNativeBuildId); Options.Add(CompositeRootPath); Options.Add(Optimize); Options.Add(OptimizeDisabled); diff --git a/src/coreclr/tools/aot/crossgen2/Program.cs b/src/coreclr/tools/aot/crossgen2/Program.cs index ca0c40ac43b632..3b4aaa20f53bd2 100644 --- a/src/coreclr/tools/aot/crossgen2/Program.cs +++ b/src/coreclr/tools/aot/crossgen2/Program.cs @@ -701,6 +701,7 @@ private void RunSingleCompilation(Dictionary inFilePaths, Instru .UseJitPath(Get(_command.JitPath)) .UseInstructionSetSupport(instructionSetSupport) .UseCustomPESectionAlignment(Get(_command.CustomPESectionAlignment)) + .UseWasmNativeBuildId(Get(_command.WasmNativeBuildId)) .UseVerifyTypeAndFieldLayout(Get(_command.VerifyTypeAndFieldLayout)) .UseHotColdSplitting(Get(_command.HotColdSplitting)) .UseVerifyGCModeTransitions(Get(_command.VerifyGCModeTransitions)) diff --git a/src/coreclr/tools/aot/crossgen2/Properties/Resources.resx b/src/coreclr/tools/aot/crossgen2/Properties/Resources.resx index 41cbb9df6a5ae7..f2ac0c5af731ee 100644 --- a/src/coreclr/tools/aot/crossgen2/Properties/Resources.resx +++ b/src/coreclr/tools/aot/crossgen2/Properties/Resources.resx @@ -330,6 +330,9 @@ Use custom alignment for PE sections in generated PE file + + Hex build_id to stamp into the WebAssembly build_id custom section of R2R webcil output. When omitted, a content hash is used. + Custom PE Section Alignment must be a power of two greater or equal to 4096. diff --git a/src/mono/browser/browser.proj b/src/mono/browser/browser.proj index 87f40c18f6ec01..3ca4fd436bd8f5 100644 --- a/src/mono/browser/browser.proj +++ b/src/mono/browser/browser.proj @@ -317,6 +317,7 @@ { "identity": "WasmEnableExceptionHandling", "defaultValueInRuntimePack": "$(WasmEnableExceptionHandling)" }, { "identity": "EnableDiagnostics", "defaultValueInRuntimePack": "$(EnableDiagnostics)" }, { "identity": "WasmProfilers", "defaultValueInRuntimePack": "$(WasmProfilers)" }, + { "identity": "WasmNativeBuildId", "defaultValueInRuntimePack": "$(WasmNativeBuildId)" }, { "identity": "EmccMaximumHeapSize", "defaultValueInRuntimePack": "$(EmccMaximumHeapSize)" }, { "identity": "EmccInitialHeapSize", "defaultValueInRuntimePack": "$(EmccInitialHeapSize)" }, { "identity": "RunAOTCompilation", "defaultValueInRuntimePack": "$(RunAOTCompilation)" } diff --git a/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets b/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets index d586baabf9d99b..0c0b0ca8266474 100644 --- a/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets +++ b/src/mono/browser/build/BrowserWasmApp.CoreCLR.targets @@ -70,6 +70,11 @@ <_ExeExt Condition="$([MSBuild]::IsOSPlatform('windows'))">.exe + + + true + <_WasmNormalizedBuildId Condition="'$(WasmNativeBuildId)' != ''">$(WasmNativeBuildId.Replace('0x', '').Replace('0X', '').ToLowerInvariant()) + @@ -910,6 +921,13 @@ <_EmccLinkStepArgs Include="-Wl,--error-limit=0" /> + + + <_EmccLinkStepArgs Condition="'$(_WasmNormalizedBuildId)' != ''" Include="-Wl,--build-id=0x$(_WasmNormalizedBuildId)" /> + + + <_EmccLinkStepArgs Condition="'$(WasmEmitProducersSection)' == 'true'" Include=""$(_WasmIntermediateOutputPath)dotnet-producers.o"" /> @@ -917,6 +935,7 @@ <_WasmLinkDependencies Include="@(_WasmNativeFileForLinking)" /> <_WasmLinkDependencies Include="@(_CoreCLRNativeLibs)" /> <_WasmLinkDependencies Include="@(_CoreCLRJsLibrary)" /> + <_WasmLinkDependencies Condition="'$(WasmEmitProducersSection)' == 'true'" Include="$(_WasmIntermediateOutputPath)dotnet-producers.o" /> <_WasmLinkDependencies Include="$(_WasmLinkRsp)" /> @@ -950,6 +969,18 @@ + + + + + diff --git a/src/mono/browser/build/BrowserWasmApp.targets b/src/mono/browser/build/BrowserWasmApp.targets index f4b70c287b7d7c..67ba826e097b03 100644 --- a/src/mono/browser/build/BrowserWasmApp.targets +++ b/src/mono/browser/build/BrowserWasmApp.targets @@ -43,6 +43,15 @@ <_WasmOutputFileName Condition="'$(WasmSingleFileBundle)' != 'true'">dotnet.native.wasm true + + + true + <_WasmNormalizedBuildId Condition="'$(WasmNativeBuildId)' != ''">$(WasmNativeBuildId.Replace('0x', '').Replace('0X', '').ToLowerInvariant()) @@ -250,6 +259,8 @@ + + @@ -329,6 +340,10 @@ <_EmccExportedRuntimeMethods>"[@(EmccExportedRuntimeMethod -> '%27%(Identity)%27', ',')]" <_EmccIncomingModuleJSAPIs>"[@(_EmccIncomingModuleJSAPI -> '%27%(Identity)%27', ',')]" <_EmccExportedFunctions>@(EmccExportedFunction -> '%(Identity)',',') + + + <_WasmBuildIdLinkArg Condition="'$(_WasmNormalizedBuildId)' != ''">-Wl,--build-id=0x$(_WasmNormalizedBuildId) @@ -350,6 +365,8 @@ <_WasmNativeFileForLinking Include="%(_BitcodeFile.ObjectFile)" /> <_WasmNativeFileForLinking Include="%(_WasmSourceFileToCompile.ObjectFile)" /> <_WasmNativeFileForLinking Include="@(_WasmObjectFilesForBundle)" /> + + <_WasmNativeFileForLinking Condition="'$(WasmEmitProducersSection)' == 'true'" Include="$(_WasmIntermediateOutputPath)dotnet-producers.o" /> <_WasmNativeFileForLinking Include="$(MicrosoftNetCoreAppRuntimePackRidNativeDir)*.a" @@ -364,6 +381,7 @@ <_EmccLinkStepArgs Include="@(_EmccLDFlags)" /> <_EmccLinkStepArgs Include="@(_EmccLDSFlags)" /> <_EmccLinkStepArgs Include="--emit-symbol-map" Condition="'$(WasmEmitSymbolMap)' == 'true'" /> + <_EmccLinkStepArgs Condition="'$(_WasmBuildIdLinkArg)' != ''" Include="$(_WasmBuildIdLinkArg)" /> <_EmccLinkStepArgs Include="--%(_WasmExtraJSFile.Kind) "%(_WasmExtraJSFile.Identity)"" Condition="'%(_WasmExtraJSFile.Kind)' != ''" /> <_WasmLinkDependencies Include="@(_WasmExtraJSFile)" /> @@ -415,6 +433,18 @@ + + + + + diff --git a/src/mono/browser/runtime/dotnet.d.ts b/src/mono/browser/runtime/dotnet.d.ts index 958a2ebf543c33..f9daa7e1fcde2a 100644 --- a/src/mono/browser/runtime/dotnet.d.ts +++ b/src/mono/browser/runtime/dotnet.d.ts @@ -687,6 +687,7 @@ type RuntimeAPI = { runtimeBuildInfo: { productVersion: string; gitHash: string; + buildId: string; buildConfiguration: string; wasmEnableThreads: boolean; wasmEnableSIMD: boolean; diff --git a/src/mono/browser/runtime/es6/dotnet.es6.lib.js b/src/mono/browser/runtime/es6/dotnet.es6.lib.js index 3e72ccc6fe26c1..910756a8ca2cc3 100644 --- a/src/mono/browser/runtime/es6/dotnet.es6.lib.js +++ b/src/mono/browser/runtime/es6/dotnet.es6.lib.js @@ -14,6 +14,7 @@ const ENABLE_DEVTOOLS_PROFILER = process.env.ENABLE_DEVTOOLS_PROFILER === "1"; const ENABLE_AOT_PROFILER = process.env.ENABLE_AOT_PROFILER === "1"; const ENABLE_LOG_PROFILER = process.env.ENABLE_LOG_PROFILER === "1"; const RUN_AOT_COMPILATION = process.env.RUN_AOT_COMPILATION === "1"; +const WASM_BUILD_ID = process.env.WASM_BUILD_ID || ""; var methodIndexByName = undefined; var gitHash = undefined; @@ -90,6 +91,7 @@ function injectDependencies() { `runAOTCompilation: ${RUN_AOT_COMPILATION}, ` + `wasmEnableThreads: ${!!PTHREADS}, ` + `gitHash: "${gitHash}", ` + + `buildId: "${WASM_BUILD_ID}", ` + `});`; autoAddDeps(DotnetSupportLib, "$DOTNET"); diff --git a/src/mono/browser/runtime/exports.ts b/src/mono/browser/runtime/exports.ts index 7af4b8647b5bdc..910d7f036bffc0 100644 --- a/src/mono/browser/runtime/exports.ts +++ b/src/mono/browser/runtime/exports.ts @@ -6,6 +6,7 @@ import BuildConfiguration from "consts:configuration"; import WasmEnableThreads from "consts:wasmEnableThreads"; import WasmEnableSIMD from "consts:wasmEnableSIMD"; import WasmEnableExceptionHandling from "consts:wasmEnableExceptionHandling"; +import BuildId from "consts:buildId"; import { type RuntimeAPI } from "./types"; @@ -29,7 +30,7 @@ import { utf8ToString } from "./strings"; export let runtimeList: RuntimeList; -function initializeExports (globalObjects: GlobalObjects): RuntimeAPI { +function initializeExports(globalObjects: GlobalObjects): RuntimeAPI { const module = Module; const globals = globalObjects; const globalThisAny = globalThis as any; @@ -60,6 +61,7 @@ function initializeExports (globalObjects: GlobalObjects): RuntimeAPI { runtimeBuildInfo: { productVersion: ProductVersion, gitHash: runtimeHelpers.gitHash, + buildId: BuildId, buildConfiguration: BuildConfiguration, wasmEnableThreads: WasmEnableThreads, wasmEnableSIMD: WasmEnableSIMD, @@ -82,7 +84,7 @@ function initializeExports (globalObjects: GlobalObjects): RuntimeAPI { class RuntimeList { private list: { [runtimeId: number]: WeakRef } = {}; - public registerRuntime (api: RuntimeAPI): number { + public registerRuntime(api: RuntimeAPI): number { if (api.runtimeId === undefined) { api.runtimeId = Object.keys(this.list).length; } @@ -91,7 +93,7 @@ class RuntimeList { return api.runtimeId; } - public getRuntime (runtimeId: number): RuntimeAPI | undefined { + public getRuntime(runtimeId: number): RuntimeAPI | undefined { const wr = this.list[runtimeId]; return wr ? wr.deref() : undefined; } diff --git a/src/mono/browser/runtime/globals.ts b/src/mono/browser/runtime/globals.ts index 2a5e6b5f70108f..29b775aef76dd5 100644 --- a/src/mono/browser/runtime/globals.ts +++ b/src/mono/browser/runtime/globals.ts @@ -35,9 +35,15 @@ export let globalObjectsRoot: GlobalObjects = null as any; export let _runtimeModuleLoaded = false; // please keep it in place also as rollup guard -export function passEmscriptenInternals (internals: EmscriptenInternals, emscriptenBuildOptions: EmscriptenBuildOptions): void { +export function passEmscriptenInternals(internals: EmscriptenInternals, emscriptenBuildOptions: EmscriptenBuildOptions): void { runtimeHelpers.emscriptenBuildOptions = emscriptenBuildOptions; + // For an application re-link, the build_id comes from the (re)linked native module via the + // WASM_BUILD_ID env var and overrides the default rollup constant baked into dotnet.runtime.js. + if (emscriptenBuildOptions.buildId) { + exportedRuntimeAPI.runtimeBuildInfo.buildId = emscriptenBuildOptions.buildId; + } + ENVIRONMENT_IS_PTHREAD = internals.isPThread; runtimeHelpers.quit = internals.quit_; runtimeHelpers.ExitStatus = internals.ExitStatus; @@ -47,7 +53,7 @@ export function passEmscriptenInternals (internals: EmscriptenInternals, emscrip } // NOTE: this is called AFTER the config is loaded -export function setRuntimeGlobals (globalObjects: GlobalObjects) { +export function setRuntimeGlobals(globalObjects: GlobalObjects) { if (_runtimeModuleLoaded) { throw new Error("Runtime module already loaded"); } @@ -91,14 +97,14 @@ export function setRuntimeGlobals (globalObjects: GlobalObjects) { }); } -export function createPromiseController (afterResolve?: () => void, afterReject?: () => void): PromiseAndController { +export function createPromiseController(afterResolve?: () => void, afterReject?: () => void): PromiseAndController { return loaderHelpers.createPromiseController(afterResolve, afterReject); } // this will abort the program if the condition is false // see src\mono\browser\runtime\rollup.config.js // we inline the condition, because the lambda could allocate closure on hot path otherwise -export function mono_assert (condition: unknown, messageFactory: string | (() => string)): asserts condition { +export function mono_assert(condition: unknown, messageFactory: string | (() => string)): asserts condition { if (condition) return; const message = "Assert failed: " + (typeof messageFactory === "function" ? messageFactory() diff --git a/src/mono/browser/runtime/loader/assets.ts b/src/mono/browser/runtime/loader/assets.ts index b2fc92a80891c4..bc32f654f87d8e 100644 --- a/src/mono/browser/runtime/loader/assets.ts +++ b/src/mono/browser/runtime/loader/assets.ts @@ -89,11 +89,11 @@ const loadIntoWorker: { "symbols": true, }; -export function shouldLoadIcuAsset (asset: AssetEntryInternal): boolean { +export function shouldLoadIcuAsset(asset: AssetEntryInternal): boolean { return !(asset.behavior == "icu" && asset.name != loaderHelpers.preferredIcuAsset); } -function convert_single_asset (assetsCollection: AssetEntryInternal[], resource: Asset[] | undefined, behavior: SingleAssetBehaviors): AssetEntryInternal { +function convert_single_asset(assetsCollection: AssetEntryInternal[], resource: Asset[] | undefined, behavior: SingleAssetBehaviors): AssetEntryInternal { resource ??= []; mono_assert(resource.length == 1, `Expect to have one ${behavior} asset in resources`); @@ -107,13 +107,13 @@ function convert_single_asset (assetsCollection: AssetEntryInternal[], resource: return assetEntry; } -function set_single_asset (asset: AssetEntryInternal) { +function set_single_asset(asset: AssetEntryInternal) { if (singleAssetTypes[asset.behavior]) { singleAssets.set(asset.behavior, asset); } } -export function try_resolve_single_asset_path (behavior: SingleAssetBehaviors): AssetEntryInternal|undefined { +export function try_resolve_single_asset_path(behavior: SingleAssetBehaviors): AssetEntryInternal | undefined { mono_assert(singleAssetTypes[behavior], `Unknown single asset behavior ${behavior}`); const asset = singleAssets.get(behavior); if (asset && !asset.resolvedUrl) { @@ -135,14 +135,14 @@ export function try_resolve_single_asset_path (behavior: SingleAssetBehaviors): return asset; } -export function resolve_single_asset_path (behavior: SingleAssetBehaviors): AssetEntryInternal { +export function resolve_single_asset_path(behavior: SingleAssetBehaviors): AssetEntryInternal { const asset = try_resolve_single_asset_path(behavior); mono_assert(asset, `Single asset for ${behavior} not found`); return asset; } let downloadAssetsStarted = false; -export async function mono_download_assets (): Promise { +export async function mono_download_assets(): Promise { if (downloadAssetsStarted) { return; } @@ -263,7 +263,7 @@ export async function mono_download_assets (): Promise { } let assetsPrepared = false; -export function prepareAssets () { +export function prepareAssets() { if (assetsPrepared) { return; } @@ -405,7 +405,7 @@ export function prepareAssets () { config.assets = [...coreAssetsToLoad, ...assetsToLoad, ...modulesAssets]; } -export function prepareAssetsWorker () { +export function prepareAssetsWorker() { const config = loaderHelpers.config; mono_assert(config.assets, "config.assets must be defined"); @@ -417,18 +417,18 @@ export function prepareAssetsWorker () { } } -export function delay (ms: number): Promise { +export function delay(ms: number): Promise { return new Promise(resolve => globalThis.setTimeout(resolve, ms)); } -export async function retrieve_asset_download (asset: AssetEntry): Promise { +export async function retrieve_asset_download(asset: AssetEntry): Promise { const pendingAsset = await start_asset_download(asset); await pendingAsset.pendingDownloadInternal!.response; return pendingAsset.buffer!; } // FIXME: Connection reset is probably the only good one for which we should retry -export async function start_asset_download (asset: AssetEntryInternal): Promise { +export async function start_asset_download(asset: AssetEntryInternal): Promise { try { return await start_asset_download_with_throttle(asset); } catch (err: any) { @@ -469,7 +469,7 @@ export async function start_asset_download (asset: AssetEntryInternal): Promise< } } -async function start_asset_download_with_throttle (asset: AssetEntryInternal): Promise { +async function start_asset_download_with_throttle(asset: AssetEntryInternal): Promise { // we don't addRunDependency to allow download in parallel with onRuntimeInitialized event! while (throttlingPromise) { await throttlingPromise.promise; @@ -503,7 +503,7 @@ async function start_asset_download_with_throttle (asset: AssetEntryInternal): P } } -async function start_asset_download_sources (asset: AssetEntryInternal): Promise { +async function start_asset_download_sources(asset: AssetEntryInternal): Promise { // we don't addRunDependency to allow download in parallel with onRuntimeInitialized event! if (asset.pendingDownload) { asset.pendingDownloadInternal = asset.pendingDownload; @@ -579,7 +579,7 @@ async function start_asset_download_sources (asset: AssetEntryInternal): Promise } } -function resolve_path (asset: AssetEntry, sourcePrefix: string): string { +function resolve_path(asset: AssetEntry, sourcePrefix: string): string { mono_assert(sourcePrefix !== null && sourcePrefix !== undefined, () => `sourcePrefix must be provided for ${asset.name}`); let attemptUrl; if (!asset.resolvedUrl) { @@ -603,7 +603,7 @@ function resolve_path (asset: AssetEntry, sourcePrefix: string): string { return attemptUrl; } -export function appendUniqueQuery (attemptUrl: string, behavior: AssetBehaviors): string { +export function appendUniqueQuery(attemptUrl: string, behavior: AssetBehaviors): string { // apply unique query to js modules to make the module state independent of the other runtime instances if (loaderHelpers.modulesUniqueQuery && appendQueryAssetTypes[behavior]) { attemptUrl = attemptUrl + loaderHelpers.modulesUniqueQuery; @@ -615,7 +615,7 @@ export function appendUniqueQuery (attemptUrl: string, behavior: AssetBehaviors) let resourcesLoaded = 0; const totalResources = new Set(); -function download_resource (asset: AssetEntryInternal): LoadingResource { +function download_resource(asset: AssetEntryInternal): LoadingResource { try { mono_assert(asset.resolvedUrl, "Request's resolvedUrl must be set"); const fetchResponse = fetchResource(asset); @@ -651,7 +651,7 @@ function download_resource (asset: AssetEntryInternal): LoadingResource { } } -function fetchResource (asset: AssetEntryInternal): Promise { +function fetchResource(asset: AssetEntryInternal): Promise { // Allow developers to override how the resource is loaded let url = asset.resolvedUrl!; if (loaderHelpers.loadBootResource) { @@ -702,7 +702,7 @@ const monoToBlazorAssetTypeMap: { [key: string]: WebAssemblyBootResourceType | u "js-module-runtime": "dotnetjs" }; -function invokeLoadBootResource (asset: AssetEntryInternal): string | Promise | Promise | null | undefined { +function invokeLoadBootResource(asset: AssetEntryInternal): string | Promise | Promise | null | undefined { if (loaderHelpers.loadBootResource) { const requestHash = asset.hash ?? ""; const url = asset.resolvedUrl!; @@ -720,7 +720,7 @@ function invokeLoadBootResource (asset: AssetEntryInternal): string | Promise= 0) { lastIndexOfSlash++; @@ -736,7 +736,7 @@ function fileName (name: string) { return name.substring(lastIndexOfSlash); } -export async function streamingCompileWasm () { +export async function streamingCompileWasm() { try { const wasmModuleAsset = resolve_single_asset_path("dotnetwasm"); await start_asset_download(wasmModuleAsset); @@ -769,7 +769,7 @@ export async function streamingCompileWasm () { } } -export function preloadWorkers () { +export function preloadWorkers() { if (!WasmEnableThreads) return; const loadingWorkers = []; for (let i = 0; i < loaderHelpers.config.pthreadPoolInitialSize!; i++) { diff --git a/src/mono/browser/runtime/loader/globals.ts b/src/mono/browser/runtime/loader/globals.ts index 8fd1ff269284f5..81d49a803152b8 100644 --- a/src/mono/browser/runtime/loader/globals.ts +++ b/src/mono/browser/runtime/loader/globals.ts @@ -57,7 +57,7 @@ export const globalObjectsRoot: GlobalObjects = { setLoaderGlobals(globalObjectsRoot); -export function setLoaderGlobals ( +export function setLoaderGlobals( globalObjects: GlobalObjects, ) { if (_loaderModuleLoaded) { @@ -142,7 +142,7 @@ export function setLoaderGlobals ( // this will abort the program if the condition is false // see src\mono\browser\runtime\rollup.config.js // we inline the condition, because the lambda could allocate closure on hot path otherwise -export function mono_assert (condition: unknown, messageFactory: string | (() => string)): asserts condition { +export function mono_assert(condition: unknown, messageFactory: string | (() => string)): asserts condition { if (condition) return; const message = "Assert failed: " + (typeof messageFactory === "function" ? messageFactory() diff --git a/src/mono/browser/runtime/rollup.config.js b/src/mono/browser/runtime/rollup.config.js index 60cd58d58302e7..d355053c356d85 100644 --- a/src/mono/browser/runtime/rollup.config.js +++ b/src/mono/browser/runtime/rollup.config.js @@ -21,6 +21,7 @@ const wasmObjDir = process.env.WasmObjDir ? process.env.WasmObjDir.replace(/"/g, const wasmEnableThreads = process.env.WasmEnableThreads === "true" ? true : false; const wasmEnableSIMD = process.env.WASM_ENABLE_SIMD === "1" ? true : false; const wasmEnableExceptionHandling = process.env.WASM_ENABLE_EH === "1" ? true : false; +const buildId = process.env.WASM_BUILD_ID || ""; const wasmEnableJsInteropByValue = process.env.ENABLE_JS_INTEROP_BY_VALUE == "1" ? true : false; // because of stack walk at src/mono/browser/debugger/BrowserDebugProxy/MonoProxy.cs // and unit test at with timers.mjs @@ -115,12 +116,13 @@ const envConstants = { wasmEnableSIMD, wasmEnableExceptionHandling, gitHash, + buildId, wasmEnableJsInteropByValue, isContinuousIntegrationBuild, }; const locationCache = {}; -function sourcemapPathTransform (relativeSourcePath, sourcemapPath) { +function sourcemapPathTransform(relativeSourcePath, sourcemapPath) { let res = locationCache[relativeSourcePath]; if (res === undefined) { if (!isContinuousIntegrationBuild) { @@ -138,7 +140,7 @@ function sourcemapPathTransform (relativeSourcePath, sourcemapPath) { return res; } -function consts (dict) { +function consts(dict) { // implement rollup-plugin-const in terms of @rollup/plugin-virtual // It's basically the same thing except "consts" names all its modules with a "consts:" prefix, // and the virtual module always exports a single default binding (the const value). @@ -269,14 +271,14 @@ const allConfigs = [ .concat(diagnosticMockTypesConfig ? [diagnosticMockTypesConfig] : []); export default defineConfig(allConfigs); -function evalCodePlugin () { +function evalCodePlugin() { return { name: "evalCode", generateBundle: evalCode }; } -async function evalCode (options, bundle) { +async function evalCode(options, bundle) { try { const name = Object.keys(bundle)[0]; const asset = bundle[name]; @@ -292,7 +294,7 @@ async function evalCode (options, bundle) { // this would create .sha256 file next to the output file, so that we do not touch datetime of the file if it's same -> faster incremental build. -function writeOnChangePlugin () { +function writeOnChangePlugin() { return { name: "writeOnChange", generateBundle: writeWhenChanged @@ -300,7 +302,7 @@ function writeOnChangePlugin () { } // force always unix line ending -function alwaysLF () { +function alwaysLF() { return { name: "writeOnChange", generateBundle: (options, bundle) => { @@ -312,7 +314,7 @@ function alwaysLF () { }; } -async function writeWhenChanged (options, bundle) { +async function writeWhenChanged(options, bundle) { try { const name = Object.keys(bundle)[0]; const asset = bundle[name]; @@ -343,31 +345,31 @@ async function writeWhenChanged (options, bundle) { } } -function checkFileExists (file) { +function checkFileExists(file) { return fs.promises.access(file, fs.constants.F_OK) .then(() => true) .catch(() => false); } -function regexCheck (checks = []) { +function regexCheck(checks = []) { const filter = createFilter("**/*.ts"); return { name: "regexCheck", - renderChunk (code, chunk) { + renderChunk(code, chunk) { const id = chunk.fileName; if (!filter(id)) return null; return executeCheck(this, code, id); }, - transform (code, id) { + transform(code, id) { if (!filter(id)) return null; return executeCheck(this, code, id); } }; - function executeCheck (self, code, id) { + function executeCheck(self, code, id) { // self.warn("executeCheck" + id); for (const rep of checks) { const { pattern, failure } = rep; @@ -383,25 +385,25 @@ function regexCheck (checks = []) { } -function regexReplace (replacements = []) { +function regexReplace(replacements = []) { const filter = createFilter("**/*.ts"); return { name: "regexReplace", - renderChunk (code, chunk) { + renderChunk(code, chunk) { const id = chunk.fileName; if (!filter(id)) return null; return executeReplacement(this, code, id); }, - transform (code, id) { + transform(code, id) { if (!filter(id)) return null; return executeReplacement(this, code, id); } }; - function executeReplacement (_, code, id) { + function executeReplacement(_, code, id) { const magicString = new MagicString(code); if (!codeHasReplacements(code, id, magicString)) { return null; @@ -412,7 +414,7 @@ function regexReplace (replacements = []) { return result; } - function codeHasReplacements (code, id, magicString) { + function codeHasReplacements(code, id, magicString) { let result = false; let match; for (const rep of replacements) { @@ -432,7 +434,7 @@ function regexReplace (replacements = []) { } } -function onwarn (warning) { +function onwarn(warning) { if (warning.code === "CIRCULAR_DEPENDENCY") { return; } diff --git a/src/mono/browser/runtime/types/index.ts b/src/mono/browser/runtime/types/index.ts index e268651744ccb2..2d52e06269468b 100644 --- a/src/mono/browser/runtime/types/index.ts +++ b/src/mono/browser/runtime/types/index.ts @@ -724,6 +724,7 @@ export type RuntimeAPI = { runtimeBuildInfo: { productVersion: string, gitHash: string, + buildId: string, buildConfiguration: string, wasmEnableThreads: boolean, wasmEnableSIMD: boolean, diff --git a/src/mono/browser/runtime/types/internal.ts b/src/mono/browser/runtime/types/internal.ts index 657203e787b9a1..74b27a9150d5d7 100644 --- a/src/mono/browser/runtime/types/internal.ts +++ b/src/mono/browser/runtime/types/internal.ts @@ -67,7 +67,7 @@ export const CharPtrNull: CharPtr = 0; export const NativePointerNull: NativePointer = 0; export const PThreadPtrNull: PThreadPtr = 0; -export function coerceNull (ptr: T | null | undefined): T { +export function coerceNull(ptr: T | null | undefined): T { if ((ptr === null) || (ptr === undefined)) return (0 as any) as T; else @@ -248,11 +248,11 @@ export type RuntimeHelpers = { } export type DiagnosticHelpers = { - ds_rt_websocket_create:(urlPtr :CharPtr) => number, - ds_rt_websocket_send:(client_socket :number, buffer:VoidPtr, bytes_to_write:number) => number, - ds_rt_websocket_poll:(client_socket :number) => number, - ds_rt_websocket_recv:(client_socket :number, buffer:VoidPtr, bytes_to_read:number) => number, - ds_rt_websocket_close:(client_socket :number) => number, + ds_rt_websocket_create: (urlPtr: CharPtr) => number, + ds_rt_websocket_send: (client_socket: number, buffer: VoidPtr, bytes_to_write: number) => number, + ds_rt_websocket_poll: (client_socket: number) => number, + ds_rt_websocket_recv: (client_socket: number, buffer: VoidPtr, bytes_to_read: number) => number, + ds_rt_websocket_close: (client_socket: number) => number, } export type AOTProfilerOptions = { @@ -271,7 +271,7 @@ export type DotnetModuleInternal = EmscriptenModule & DotnetModuleConfig & Emscr // Evaluates whether a value is nullish (same definition used as the ?? operator, // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Nullish_coalescing_operator) -export function is_nullish (value: T | null | undefined): value is null | undefined { +export function is_nullish(value: T | null | undefined): value is null | undefined { return (value === undefined) || (value === null); } @@ -286,6 +286,8 @@ export type EmscriptenBuildOptions = { runAOTCompilation: boolean, wasmEnableThreads: boolean, gitHash: string, + // build_id from the last native (re)link, via the WASM_BUILD_ID env var. Empty when not pinned. + buildId: string, }; export type EmscriptenInternals = { isPThread: boolean, @@ -318,14 +320,14 @@ export interface ExitStatusError { /// Always throws. Used to handle unreachable switch branches when TypeScript refines the type of a variable /// to 'never' after you handle all the cases it knows about. -export function assertNever (x: never): never { +export function assertNever(x: never): never { throw new Error("Unexpected value: " + x); } /// returns true if the given value is not Thenable /// /// Useful if some function returns a value or a promise of a value. -export function notThenable (x: T | PromiseLike): x is T { +export function notThenable(x: T | PromiseLike): x is T { return typeof x !== "object" || typeof ((>x).then) !== "function"; } diff --git a/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.targets b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.targets index 529106d2dc6a94..d0f6fe8e1a25bb 100644 --- a/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.targets +++ b/src/mono/nuget/Microsoft.NET.Sdk.WebAssembly.Pack/build/Microsoft.NET.Sdk.WebAssembly.Browser.targets @@ -416,6 +416,8 @@ Copyright (c) .NET Foundation. All rights reserved. <_WasmWebcilStampProperty Include="_WasmWebcilVersion" /> <_WasmWebcilStampProperty Include="PublishReadyToRun" /> <_WasmWebcilStampProperty Include="_WasmBuildPrebuiltR2RDirectory" /> + <_WasmWebcilStampProperty Include="WasmNativeBuildId" /> + <_WasmWebcilStampProperty Include="ProductVersion" /> browser:callspec=N:Sample + Advanced-1a2b3c4d5e6f7788-ID <_ExtraTrimmerArgs>$(_ExtraTrimmerArgs) --dump-dependencies diff --git a/src/mono/wasm/Wasm.Build.Tests/NativeRebuildTests/WasmMetadataSectionsTests.cs b/src/mono/wasm/Wasm.Build.Tests/NativeRebuildTests/WasmMetadataSectionsTests.cs new file mode 100644 index 00000000000000..f04ec93b9c60fd --- /dev/null +++ b/src/mono/wasm/Wasm.Build.Tests/NativeRebuildTests/WasmMetadataSectionsTests.cs @@ -0,0 +1,181 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.IO; +using System.Text; +using Wasm.Build.Tests; +using Xunit; +using Xunit.Abstractions; + +#nullable enable + +namespace Wasm.Build.NativeRebuild.Tests +{ + // Tests for the WebAssembly tool-conventions `producers` and `build_id` custom sections: + // - emitted into dotnet.native.wasm when the native runtime is re-linked, and + // - emitted into every webcil module (independent of native re-link). + // + // Categorized `no-workload` so it runs on both the Mono (no-workload leg) and CoreCLR runtimes. + // A successful re-link also validates that wasm-ld accepts the producers object we hand it. + [TestCategory("native")] + [TestCategory("no-workload")] + public class WasmMetadataSectionsTests : NativeRebuildTestsBase + { + // Arbitrary even-length lowercase hex; $(WasmNativeBuildId) is normalized to lowercase. + private const string PinnedBuildId = "1a2b3c4d5e6f7788"; + + public WasmMetadataSectionsTests(ITestOutputHelper output, SharedBuildPerTestClassFixture buildContext) + : base(output, buildContext) + { + } + + // The producers `processed-by` tool recorded in dotnet.native.wasm depends on the runtime engine. + private static string NativeRuntimeProducer => IsCoreClrRuntime ? "CoreCLR" : "Mono"; + + [Theory] + [InlineData(Configuration.Release)] + [InlineData(Configuration.Debug)] + public void NativeWasm_Relink_WithBuildId_EmitsProducersAndBuildId(Configuration config) + { + ProjectInfo info = CopyTestAsset(config, aot: false, TestAsset.WasmBasicTestApp, "meta_relink_id"); + // Setting $(WasmNativeBuildId) forces a native re-link and pins the build_id. + PublishProject(info, config, + new PublishOptions(ExtraMSBuildArgs: $"-p:_WasmDevel=true -p:WasmNativeBuildId={PinnedBuildId}"), + isNativeBuild: true); + + string wasm = Path.Combine(GetBinFrameworkDir(config, forPublish: true), "dotnet.native.wasm"); + AssertProducers(wasm, NativeRuntimeProducer); + Assert.Equal(PinnedBuildId, GetBuildIdHex(wasm)); + } + + [Theory] + [InlineData(Configuration.Release)] + public void NativeWasm_Relink_WithoutBuildId_EmitsProducersButNoBuildId(Configuration config) + { + ProjectInfo info = CopyTestAsset(config, aot: false, TestAsset.WasmBasicTestApp, "meta_relink_noid"); + PublishProject(info, config, + new PublishOptions(ExtraMSBuildArgs: "-p:_WasmDevel=true -p:WasmBuildNative=true"), + isNativeBuild: true); + + string wasm = Path.Combine(GetBinFrameworkDir(config, forPublish: true), "dotnet.native.wasm"); + AssertProducers(wasm, NativeRuntimeProducer); + // No build_id is emitted unless $(WasmNativeBuildId) is set. + Assert.Null(ReadCustomSection(wasm, "build_id")); + } + + [ConditionalTheory(typeof(BuildTestBase), nameof(UseWebcil))] + [InlineData(Configuration.Release, /*relink*/ true)] + [InlineData(Configuration.Release, /*relink*/ false)] + public void Webcil_EmitsProducersAndHashBuildId(Configuration config, bool relink) + { + ProjectInfo info = CopyTestAsset(config, aot: false, TestAsset.WasmBasicTestApp, "meta_webcil"); + string extraArgs = "-p:_WasmDevel=true"; + if (relink) + extraArgs += " -p:WasmBuildNative=true"; + PublishProject(info, config, new PublishOptions(ExtraMSBuildArgs: extraArgs), + isNativeBuild: relink ? true : (bool?)null); + + string webcil = Path.Combine(GetBinFrameworkDir(config, forPublish: true), $"{info.ProjectName}.wasm"); + Assert.True(File.Exists(webcil), $"Expected webcil module at {webcil}"); + AssertProducers(webcil, "WebCIL"); + + // Without an explicit id, each webcil gets a SHA-256 content hash (32 bytes). + byte[]? buildId = ReadBuildId(webcil); + Assert.NotNull(buildId); + Assert.Equal(32, buildId!.Length); + } + + [ConditionalTheory(typeof(BuildTestBase), nameof(UseWebcil))] + [InlineData(Configuration.Release)] + public void Webcil_WithBuildId_UsesPinnedBuildId(Configuration config) + { + ProjectInfo info = CopyTestAsset(config, aot: false, TestAsset.WasmBasicTestApp, "meta_webcil_id"); + PublishProject(info, config, + new PublishOptions(ExtraMSBuildArgs: $"-p:_WasmDevel=true -p:WasmNativeBuildId={PinnedBuildId}"), + isNativeBuild: true); + + string webcil = Path.Combine(GetBinFrameworkDir(config, forPublish: true), $"{info.ProjectName}.wasm"); + AssertProducers(webcil, "WebCIL"); + Assert.Equal(PinnedBuildId, GetBuildIdHex(webcil)); + } + + // -------------------- helpers -------------------- + + private static void AssertProducers(string wasmPath, string expectedProcessedBy) + { + byte[]? producers = ReadCustomSection(wasmPath, "producers"); + Assert.NotNull(producers); + + // The section holds length-prefixed ASCII field/value strings; a substring scan is enough. + string text = Encoding.Latin1.GetString(producers!); + Assert.Contains("C#", text); + Assert.Contains(".NET", text); + Assert.Contains(expectedProcessedBy, text); + } + + private static string? GetBuildIdHex(string wasmPath) + { + byte[]? bytes = ReadBuildId(wasmPath); + if (bytes is null) + return null; + + var sb = new StringBuilder(bytes.Length * 2); + foreach (byte b in bytes) + sb.Append(b.ToString("x2")); + return sb.ToString(); + } + + // The build_id payload is a WebAssembly vec(u8): a ULEB128 length followed by that many bytes. + private static byte[]? ReadBuildId(string wasmPath) + { + byte[]? payload = ReadCustomSection(wasmPath, "build_id"); + if (payload is null) + return null; + + int pos = 0; + uint len = ReadULEB128(payload, ref pos); + return payload[pos..(pos + (int)len)]; + } + + // Returns the payload of the named custom section (bytes following the section name), or null. + private static byte[]? ReadCustomSection(string wasmPath, string sectionName) + { + byte[] bytes = File.ReadAllBytes(wasmPath); + byte[]? found = null; + int pos = 8; // skip the 4-byte magic and 4-byte version + while (pos < bytes.Length) + { + byte id = bytes[pos++]; + uint size = ReadULEB128(bytes, ref pos); + int payloadStart = pos; + int payloadEnd = payloadStart + (int)size; + if (id == 0) // custom section + { + int p = payloadStart; + uint nameLen = ReadULEB128(bytes, ref p); + string name = Encoding.UTF8.GetString(bytes, p, (int)nameLen); + p += (int)nameLen; + if (name == sectionName) + found = bytes[p..payloadEnd]; // spec allows one; keep the last if duplicated + } + pos = payloadEnd; + } + return found; + } + + private static uint ReadULEB128(byte[] bytes, ref int pos) + { + uint value = 0; + int shift = 0; + while (true) + { + byte b = bytes[pos++]; + value |= (uint)(b & 0x7f) << shift; + if ((b & 0x80) == 0) + break; + shift += 7; + } + return value; + } + } +} diff --git a/src/mono/wasm/build/WasmApp.Common.targets b/src/mono/wasm/build/WasmApp.Common.targets index 8ba6b8e8302743..a0393be7b3fae3 100644 --- a/src/mono/wasm/build/WasmApp.Common.targets +++ b/src/mono/wasm/build/WasmApp.Common.targets @@ -67,6 +67,13 @@ - $(EmccStackSize) - Stack size. Default value: 5MB. Corresponds to `-s STACK_SIZE=...` emcc arg. + - $(WasmNativeBuildId) - Hex string (e.g. `a1b2c3...`, optional `0x` prefix) used to stamp a + WebAssembly tool-conventions `build_id` custom section into + `dotnet.native.wasm` via wasm-ld's build-id linker flag. + Setting it forces a native (re)link. When empty (the default), + no `build_id` section is emitted by the app build. + See https://github.com/WebAssembly/tool-conventions/blob/main/BuildId.md + - $(WasmBuildAppAfterThisTarget) - This target is used as `AfterTargets` for `WasmBuildApp. this is what triggers the wasm app building. Defaults to `Build`. - $(WasmTriggerPublishAppAfterThisTarget) - This target is used as `AfterTargets` for `WasmTriggerPublishApp. @@ -120,6 +127,7 @@ + @@ -494,8 +502,13 @@ <_WasmBuildNativeRequired Condition="@(_ChangedPropertiesThatTriggerRelinking->Count()) > 0">true + + <_WasmBuildNativeRequired Condition="'$(WasmNativeBuildId)' != ''">true + + diff --git a/src/native/libs/Common/JavaScript/loader/dotnet.d.ts b/src/native/libs/Common/JavaScript/loader/dotnet.d.ts index f9856d0eca1c3f..870193aee4fe20 100644 --- a/src/native/libs/Common/JavaScript/loader/dotnet.d.ts +++ b/src/native/libs/Common/JavaScript/loader/dotnet.d.ts @@ -670,6 +670,7 @@ type RuntimeAPI = { runtimeBuildInfo: { productVersion: string; gitHash: string; + buildId: string; buildConfiguration: string; wasmEnableThreads: boolean; wasmEnableSIMD: boolean; diff --git a/src/native/libs/Common/JavaScript/loader/index.ts b/src/native/libs/Common/JavaScript/loader/index.ts index 81d4ecddea5c19..4e008d0acc0948 100644 --- a/src/native/libs/Common/JavaScript/loader/index.ts +++ b/src/native/libs/Common/JavaScript/loader/index.ts @@ -12,6 +12,7 @@ import { InternalExchangeIndex } from "../types"; import ProductVersion from "consts:productVersion"; import BuildConfiguration from "consts:configuration"; import GitHash from "consts:gitHash"; +import BuildId from "consts:buildId"; import { loaderConfig, getLoaderConfig } from "./config"; import { exit, isExited, isRuntimeRunning, addOnExitListener, registerExit, quitNow } from "./exit"; @@ -32,6 +33,7 @@ export function dotnetInitializeModule(): RuntimeAPI { runtimeBuildInfo: { productVersion: ProductVersion, gitHash: GitHash, + buildId: BuildId, buildConfiguration: BuildConfiguration, wasmEnableThreads: false, wasmEnableSIMD: true, diff --git a/src/native/libs/Common/JavaScript/types/ems-ambient.ts b/src/native/libs/Common/JavaScript/types/ems-ambient.ts index e4e21ebde9e684..3d5427509dd11b 100644 --- a/src/native/libs/Common/JavaScript/types/ems-ambient.ts +++ b/src/native/libs/Common/JavaScript/types/ems-ambient.ts @@ -60,6 +60,7 @@ export type EmsAmbientSymbolsType = EmscriptenModuleInternal & { isAborting?: boolean; isAsyncMain?: boolean; gitHash?: string; + buildId?: string; } DOTNET_INTEROP: { gitHash?: string; diff --git a/src/native/libs/Common/JavaScript/types/public-api.ts b/src/native/libs/Common/JavaScript/types/public-api.ts index 0da1d6021fdd42..1ec893e91bfccf 100644 --- a/src/native/libs/Common/JavaScript/types/public-api.ts +++ b/src/native/libs/Common/JavaScript/types/public-api.ts @@ -700,6 +700,7 @@ export type RuntimeAPI = { runtimeBuildInfo: { productVersion: string; gitHash: string; + buildId: string; buildConfiguration: string; wasmEnableThreads: boolean; wasmEnableSIMD: boolean; diff --git a/src/native/libs/System.Native.Browser/libSystem.Native.Browser.footer.js b/src/native/libs/System.Native.Browser/libSystem.Native.Browser.footer.js index 2a19fa50ad77aa..34254d45fa0123 100644 --- a/src/native/libs/System.Native.Browser/libSystem.Native.Browser.footer.js +++ b/src/native/libs/System.Native.Browser/libSystem.Native.Browser.footer.js @@ -17,6 +17,10 @@ function libDotnetFactory() { const exports = {}; libNativeBrowser(exports); + // build_id from the application re-link, read from the WASM_BUILD_ID env var at (emcc) link time. + // Empty for the default runtime-pack build, where runtimeBuildInfo.buildId comes from the rollup constant. + const buildId = (typeof process !== "undefined" && process.env && process.env.WASM_BUILD_ID) ? process.env.WASM_BUILD_ID : ""; + let commonDeps = [ "$FS", "$BROWSER_UTILS", @@ -38,6 +42,7 @@ function libDotnetFactory() { }, dotnetInitializeModule: exports.dotnetInitializeModule, gitHash: exports.gitHash, + buildId, }, $DOTNET__deps: commonDeps, $DOTNET__postset: "DOTNET.selfInitialize()", diff --git a/src/native/libs/System.Native.Browser/native/index.ts b/src/native/libs/System.Native.Browser/native/index.ts index c85fa44fc286f8..d517c522ff20a7 100644 --- a/src/native/libs/System.Native.Browser/native/index.ts +++ b/src/native/libs/System.Native.Browser/native/index.ts @@ -25,6 +25,12 @@ export function dotnetInitializeModule(internals: InternalExchange): void { throw new Error(`Mismatched git hashes between loader and runtime. Loader: ${runtimeApi.runtimeBuildInfo.gitHash}, DOTNET: ${_ems_.DOTNET.gitHash}`); } + // For an application re-link, the build_id comes from the (re)linked native module via the + // WASM_BUILD_ID env var and overrides the default rollup constant baked into dotnet.js. + if (_ems_.DOTNET.buildId) { + runtimeApi.runtimeBuildInfo.buildId = _ems_.DOTNET.buildId; + } + internals[InternalExchangeIndex.NativeBrowserExportsTable] = nativeBrowserExportsToTable({ getWasmMemory, getWasmTable, diff --git a/src/native/rollup.config.defines.js b/src/native/rollup.config.defines.js index 34bd41fd13a604..052cdbd6aeaa9e 100644 --- a/src/native/rollup.config.defines.js +++ b/src/native/rollup.config.defines.js @@ -47,11 +47,14 @@ try { gitHash = "unknown"; } +export const buildId = process.env.WASM_BUILD_ID || ""; + export const envConstants = { productVersion, configuration, runtimeFlavor, gitHash, + buildId, isContinuousIntegrationBuild, }; diff --git a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/ConvertDllsToWebCil.cs b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/ConvertDllsToWebCil.cs index d348d2bc79f0e0..6e928cee15829a 100644 --- a/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/ConvertDllsToWebCil.cs +++ b/src/tasks/Microsoft.NET.Sdk.WebAssembly.Pack.Tasks/ConvertDllsToWebCil.cs @@ -37,6 +37,15 @@ public class ConvertDllsToWebcil : Task public int WebcilVersion { get; set; } + /// + /// Optional build_id (hex string, with or without a leading 0x) stamped into every produced + /// webcil module. When empty, each webcil gets a content hash so it is self-identifying. + /// + public string? WasmNativeBuildId { get; set; } + + /// Product version recorded in the producers section of each webcil module. + public string? ProductVersion { get; set; } + [Output] public ITaskItem[] WebcilCandidates { get; set; } @@ -209,6 +218,8 @@ private TaskItem ConvertDll(string tmpDir, ITaskItem candidate) var tmpWebcil = Path.Combine(tmpDir, webcilFileName); var logAdapter = new Microsoft.WebAssembly.Build.Tasks.LogAdapter(Log); var webcilWriter = Microsoft.WebAssembly.Build.Tasks.WebcilConverter.FromPortableExecutable(inputPath: dllFilePath, outputPath: tmpWebcil, logger: logAdapter, webcilVersion: WebcilVersion); + webcilWriter.ProductVersion = ProductVersion ?? string.Empty; + webcilWriter.BuildId = ParseBuildId(WasmNativeBuildId); webcilWriter.ConvertToWebcil(); if (!Directory.Exists(candidatePath)) @@ -242,6 +253,40 @@ private TaskItem ConvertDll(string tmpDir, ITaskItem candidate) return webcilItem; } + // Parses a hex build_id (optionally 0x-prefixed) into raw bytes. Returns null when empty so the + // wrapper falls back to a per-file content hash. + private byte[]? ParseBuildId(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + return null; + + string hex = value!.Trim(); + if (hex.StartsWith("0x", StringComparison.OrdinalIgnoreCase)) + hex = hex.Substring(2); + + if (hex.Length == 0 || (hex.Length % 2) != 0) + { + Log.LogError($"WasmNativeBuildId '{value}' is not a valid hex string."); + return null; + } + + var bytes = new byte[hex.Length / 2]; + for (int i = 0; i < bytes.Length; i++) + { +#if NETFRAMEWORK + if (!byte.TryParse(hex.Substring(i * 2, 2), System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out bytes[i])) +#else + if (!byte.TryParse(hex.AsSpan(i * 2, 2), System.Globalization.NumberStyles.HexNumber, System.Globalization.CultureInfo.InvariantCulture, out bytes[i])) +#endif + { + Log.LogError($"WasmNativeBuildId '{value}' is not a valid hex string."); + return null; + } + } + + return bytes; + } + private static bool OutputHasExpectedWebcilFlavor(string path, bool useR2R) { if (!File.Exists(path)) diff --git a/src/tasks/Microsoft.NET.WebAssembly.Webcil/Microsoft.NET.WebAssembly.Webcil.csproj b/src/tasks/Microsoft.NET.WebAssembly.Webcil/Microsoft.NET.WebAssembly.Webcil.csproj index e582aeabfe42a0..cce410ccfeab5a 100644 --- a/src/tasks/Microsoft.NET.WebAssembly.Webcil/Microsoft.NET.WebAssembly.Webcil.csproj +++ b/src/tasks/Microsoft.NET.WebAssembly.Webcil/Microsoft.NET.WebAssembly.Webcil.csproj @@ -12,6 +12,7 @@ + diff --git a/src/tasks/Microsoft.NET.WebAssembly.Webcil/WebcilConverter.cs b/src/tasks/Microsoft.NET.WebAssembly.Webcil/WebcilConverter.cs index 3d705299cd396a..0450991ffe97cb 100644 --- a/src/tasks/Microsoft.NET.WebAssembly.Webcil/WebcilConverter.cs +++ b/src/tasks/Microsoft.NET.WebAssembly.Webcil/WebcilConverter.cs @@ -50,6 +50,15 @@ FilePosition SectionStart public bool WrapInWebAssembly { get; set; } = true; + /// When wrapping in WebAssembly, also emit the tool-conventions producers + build_id sections. + public bool EmitMetadataSections { get; set; } = true; + + /// Version used for the producers section. + public string ProductVersion { get; set; } = string.Empty; + + /// Explicit build_id bytes; when null a content hash of the payload is used. + public byte[]? BuildId { get; set; } + private WebcilConverter(string inputPath, string outputPath, int webcilVersion) { if (webcilVersion != 0 && webcilVersion != 1) @@ -85,7 +94,12 @@ public void ConvertToWebcil() using var memoryStream = new MemoryStream(checked((int)inputStream.Length)); WriteConversionTo(memoryStream, inputStream, peInfo, wcInfo); memoryStream.Flush(); - var wrapper = new WebcilWasmWrapper(memoryStream, selfInstalling: _webcilVersion >= 1); + var wrapper = new WebcilWasmWrapper(memoryStream, selfInstalling: _webcilVersion >= 1) + { + EmitMetadataSections = EmitMetadataSections, + ProductVersion = ProductVersion, + BuildId = BuildId, + }; memoryStream.Seek(0, SeekOrigin.Begin); wrapper.WriteWasmWrappedWebcil(outputStream); } diff --git a/src/tasks/Microsoft.NET.WebAssembly.Webcil/WebcilWasmWrapper.cs b/src/tasks/Microsoft.NET.WebAssembly.Webcil/WebcilWasmWrapper.cs index 900e8278c9b0da..66379025e89bc8 100644 --- a/src/tasks/Microsoft.NET.WebAssembly.Webcil/WebcilWasmWrapper.cs +++ b/src/tasks/Microsoft.NET.WebAssembly.Webcil/WebcilWasmWrapper.cs @@ -6,6 +6,7 @@ using System.Collections.Immutable; using System.Reflection.PortableExecutable; using System.Runtime.InteropServices; +using System.Security.Cryptography; namespace Microsoft.NET.WebAssembly.Webcil; @@ -58,6 +59,21 @@ public class WebcilWasmWrapper private readonly uint _webcilPayloadSize; private readonly bool _selfInstalling; + /// + /// When true (the default), a WebAssembly tool-conventions producers section and a + /// build_id section are appended to the wrapped module. + /// + public bool EmitMetadataSections { get; set; } = true; + + /// Version used for the producers processed-by/sdk fields. + public string ProductVersion { get; set; } = string.Empty; + + /// + /// Explicit build_id bytes (e.g. from $(WasmNativeBuildId)). When , + /// a content hash of the webcil payload is used so each module is self-identifying. + /// + public byte[]? BuildId { get; set; } + public WebcilWasmWrapper(Stream webcilPayloadStream) : this(webcilPayloadStream, selfInstalling: false) { @@ -81,6 +97,43 @@ public void WriteWasmWrappedWebcil(Stream outputStream) WriteDataSection(writer); } WriteWasmSuffix(outputStream); + + if (EmitMetadataSections) + WriteMetadataSections(outputStream); + } + + // Appends the tool-conventions producers + build_id custom sections. These must come after the + // module's name section (which is part of the suffix), which is why this runs last. + private void WriteMetadataSections(Stream outputStream) + { + var producers = new[] + { + new WasmCustomSectionWriter.ProducerValue(WasmCustomSectionWriter.ProducersFieldLanguage, "C#", string.Empty), + new WasmCustomSectionWriter.ProducerValue(WasmCustomSectionWriter.ProducersFieldProcessedBy, "WebCIL", ProductVersion), + new WasmCustomSectionWriter.ProducerValue(WasmCustomSectionWriter.ProducersFieldSdk, ".NET", ProductVersion), + }; + + byte[]? buildId = BuildId ?? ComputePayloadHash(); + WasmCustomSectionWriter.AppendMetadataSections(outputStream, producers, buildId); + } + + // Content hash of the webcil payload, used as the build_id when none was supplied explicitly. + private byte[]? ComputePayloadHash() + { + if (!_webcilPayloadStream.CanSeek) + return null; + + long savedPosition = _webcilPayloadStream.Position; + try + { + _webcilPayloadStream.Seek(0, SeekOrigin.Begin); + using var sha = SHA256.Create(); + return sha.ComputeHash(_webcilPayloadStream); + } + finally + { + _webcilPayloadStream.Seek(savedPosition, SeekOrigin.Begin); + } } private diff --git a/src/tasks/WasmAppBuilder/WasmEmitProducersObject.cs b/src/tasks/WasmAppBuilder/WasmEmitProducersObject.cs new file mode 100644 index 00000000000000..5eb6f40f63c1ba --- /dev/null +++ b/src/tasks/WasmAppBuilder/WasmEmitProducersObject.cs @@ -0,0 +1,81 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Collections.Generic; +using System.IO; +using Microsoft.Build.Framework; +using Microsoft.Build.Utilities; +using Microsoft.NET.WebAssembly.Webcil; + +namespace Microsoft.WebAssembly.Build.Tasks; + +/// +/// Emits a small relocatable WebAssembly object carrying the tool-conventions producers +/// custom section with .NET toolchain information. The object is meant to be passed as a link +/// input so that wasm-ld merges it into dotnet.native.wasm, alongside the entries +/// clang/LLVM already contribute. This keeps producers linker-driven, matching how +/// build_id is emitted by the linker via --build-id. +/// +/// See: https://github.com/WebAssembly/tool-conventions/blob/main/ProducersSection.md +/// +public class WasmEmitProducersObject : Task +{ + /// Path of the object file to write. + [Required] + public string OutputPath { get; set; } = string.Empty; + + /// Runtime engine name used for the producers processed-by field (e.g. Mono, CoreCLR). + public string RuntimeName { get; set; } = string.Empty; + + /// Runtime/product version used for the producers processed-by and sdk fields. + public string ProductVersion { get; set; } = string.Empty; + + public override bool Execute() + { + var producers = new List + { + new(WasmCustomSectionWriter.ProducersFieldLanguage, "C#", string.Empty), + }; + + if (!string.IsNullOrEmpty(RuntimeName)) + producers.Add(new(WasmCustomSectionWriter.ProducersFieldProcessedBy, RuntimeName, ProductVersion)); + + producers.Add(new(WasmCustomSectionWriter.ProducersFieldSdk, ".NET", ProductVersion)); + + try + { + string? dir = Path.GetDirectoryName(OutputPath); + if (!string.IsNullOrEmpty(dir)) + Directory.CreateDirectory(dir); + + byte[] bytes = WasmCustomSectionWriter.BuildProducersObject(producers); + + // Avoid rewriting an identical object so the file timestamp stays stable and the native + // link (which consumes this object) is not forced to run on every incremental build. + if (File.Exists(OutputPath) && ByteArraysEqual(File.ReadAllBytes(OutputPath), bytes)) + return true; + + File.WriteAllBytes(OutputPath, bytes); + } + catch (Exception ex) + { + Log.LogError($"Failed to write producers object '{OutputPath}': {ex.Message}"); + return false; + } + + return !Log.HasLoggedErrors; + } + + private static bool ByteArraysEqual(byte[] a, byte[] b) + { + if (a.Length != b.Length) + return false; + for (int i = 0; i < a.Length; i++) + { + if (a[i] != b[i]) + return false; + } + return true; + } +} diff --git a/src/tasks/WasmAppBuilder/WebcilConverter.cs b/src/tasks/WasmAppBuilder/WebcilConverter.cs index 048e47991a9686..fba68873fc9bf5 100644 --- a/src/tasks/WasmAppBuilder/WebcilConverter.cs +++ b/src/tasks/WasmAppBuilder/WebcilConverter.cs @@ -36,6 +36,20 @@ public static WebcilConverter FromPortableExecutable(string inputPath, string ou return new WebcilConverter(converter, inputPath, outputPath, logger); } + /// Version used for the producers section of the wrapped webcil module. + public string ProductVersion + { + get => _converter.ProductVersion; + set => _converter.ProductVersion = value; + } + + /// Explicit build_id bytes; when null a content hash of the payload is used. + public byte[]? BuildId + { + get => _converter.BuildId; + set => _converter.BuildId = value; + } + public void ConvertToWebcil() { Log.LogMessage(MessageImportance.Low, $"Converting to Webcil: input {_inputPath} output: {_outputPath}");