diff --git a/docs/design/datacontracts/DebugInfo.md b/docs/design/datacontracts/DebugInfo.md index b99fc3d2b99a1c..1ed9349611b26c 100644 --- a/docs/design/datacontracts/DebugInfo.md +++ b/docs/design/datacontracts/DebugInfo.md @@ -173,6 +173,10 @@ public enum DebugVarLocKind DoubleStack, FloatingPointStack, FixedVarArg, + // The variable lives in a WebAssembly local (see "WebAssembly Variable Locations" below). + WasmLocal, + // The variable spans two WebAssembly locals. + WasmLocalPair, } public readonly struct DebugVarInfo @@ -192,6 +196,9 @@ public readonly struct DebugVarInfo public uint FloatingPointStackRegister { get; init; } public uint FixedVarArgOffset { get; init; } public uint CallReturnValueILOffset { get; init; } + // WASM only: Register and Register2 as unbiased local indices; null on every other architecture. + public uint? WasmLocal { get; init; } + public uint? WasmLocal2 { get; init; } } // Given a code pointer, return the variable location info for the method. @@ -225,6 +232,99 @@ Each variable entry in the Vars section is nibble-encoded as follows: Signed integers are encoded using the same unsigned scheme, with the sign bit stored in bit 0 (`value = unsigned >> 1`, negate if `unsigned & 1`). On x86, stack offsets are DWORD-aligned and stored divided by `sizeof(DWORD)`. +### WebAssembly Variable Register Encoding + +WASM has no physical registers. Every register field in the Vars stream holds a WebAssembly local +index biased past the reserved register numbers: + +```text +register = localIndex + WASM_LOCAL_REGNUM_BASE +``` + +`WASM_LOCAL_REGNUM_BASE` is `3`, one past `REGNUM_AMBIENT_SP`. Values `0` through `2` remain +`REGNUM_PC`, `REGNUM_COUNT`, and `REGNUM_AMBIENT_SP`. As on other targets, the value type is not +encoded: it is implied by the variable's type and by the local's declaration in the WebAssembly +function. + +`WASM_LOCAL_REGNUM_BASE` (`3`) and the stack base register (`2`, described below) are fixed +constants of DebugInfo contract version `c1`; readers hardcode them. Changing either requires a new +DebugInfo contract version. Because ReadyToRun images persist this encoding, it also requires a +ReadyToRun format version change so static readers such as R2RDump do not misdecode older images. + +### WebAssembly Stack Base Encoding + +WASM `VLT_STK` and `VLT_STK2` records currently encode base register `2`. +`REG_FPBASE`, `REG_SPBASE`, and `REGNUM_AMBIENT_SP` all have that value on this target, so the +debug record identifies a logical frame-relative stack home; it does not identify a particular +WebAssembly engine local. + +The absolute logical frame address is reconstructed by the runtime stack-walk and unwind +protocol from shadow-stack linear memory. The engine's current per-function SP/FP local allocation +is a separate code-generation detail: + +* Frame access allocates an FP value when a method has frame locals, uses `localloc`, or has + funclets. +* Without `localloc`, the root function's FP aliases its SP, including methods that make calls. +* `localloc` gives the root a distinct FP so later SP movement does not change frame-relative + addresses. +* Funclets receive a distinct parent establishing FP; with `localloc`, that remains the root's + pre-adjustment frame base. + +The numeric WebAssembly local indices holding those values can vary with function parameters and +compiler-created locals and are not part of this debug-info format. Readers must not infer the +logical frame address from a hardcoded `$varN`, and the producer does not advertise SP/FP engine +local indices. A future producer that changes stack records away from base `2` requires a +coordinated, fail-loud reader update. + +### WebAssembly Variable Locations + +On WASM the contract therefore reports: + +* `VLT_REG` / `VLT_REG_BYREF` as `DebugVarLocKind.WasmLocal`, and `VLT_REG_REG` as + `DebugVarLocKind.WasmLocalPair`. WASM locals are engine-private frame state: they are not in + linear memory and cannot be read through the data target, so the contract names them rather than + resolving them. A consumer attached to the WASM engine (for example over the Chrome DevTools + Protocol) can fetch the value from the local index. +* Stack-based kinds unchanged, because their storage is linear memory and is readable. Only the + base register differs, as described below. + +The frame pointer to add the offset to is the *logical* frame pointer, which for a method's root +function is its own frame base and for a funclet is the parent method's frame base. +`WasmContext.Unwind` and wasm explicit-frame seeding populate it by mirroring +`RtlVirtualUnwind` / `GetWasmFramePointerFromStackPointer` in +`src/coreclr/vm/wasm/helpers.cpp`; variable resolution consumes that context value rather than +independently unwinding again. + +The JIT never emits `VLT_REG_FP` or `VLT_FPSTK` on WASM; `f32` and `f64` locals are reported as +`VLT_REG`, so `IsFloatingPoint` is not set for them. + +`DebugVarInfo` exposes decoded register locations in `WasmLocal` and `WasmLocal2`, corresponding to +`Register` and `Register2` with `WASM_LOCAL_REGNUM_BASE` removed. A register-only location whose +register is below `WASM_LOCAL_REGNUM_BASE` names no local and is rejected. `Register` and +`Register2` retain the biased values from the Vars stream. Both are null on every other architecture. Stack base register `2` is +not a WASM local descriptor. + +The managed cDAC reader reports engine-private WASM register locals with no native locations. +Existing `ClrDataValue` behavior then reports those values unavailable (`GetNumLocations == 0`, +`GetBytes`/`GetAddress` fail) instead of fabricating a register value of zero. + +An unreadable or null WASM `VLT_STK_BYREF` indirection preserves one logical location, matching +the native DAC's location count, but its address/value/object accessors fail with +`CORDBG_E_READVIRTUAL_FAILURE`. This distinguishes a failed indirection from a direct +stack-homed null reference, which remains one readable location containing zero. This deliberately +differs from the native DAC (`DereferenceByRefVar` in `vm/util.cpp`), which reports a failed byref read as +address `0` and relies on address `0` being unmapped; WASM linear address `0` is readable, so the +same representation would read unrelated bytes as the variable's value. + +There is no native DAC build on WASM, so live `_legacyImpl` comparison assertions cannot validate +this representation. DacDbi coverage is structural managed unit coverage. + +WASM local index spaces are per function, and a method's funclets are separate WASM functions from +its root (`WasmRegAlloc` in `src/coreclr/jit/regallocwasm.cpp`). Variable ranges, by contrast, are +method-relative, and a funclet's virtual IP is rebased onto its root function. A consumer must +therefore establish which WASM function the current virtual IP belongs to before interpreting a +local index. + ### Async Suspension Point APIs We also support decoding async suspension points (and their captured continuation-object locals) from the `AsyncInfo` chunk of the debug info blob. The chunk is present only for methods that the JIT compiled with runtime-async suspension points; for all other methods, `AsyncInfoSize` is `0` in the FAT header and the API returns an empty list. diff --git a/src/coreclr/inc/cordebuginfo.h b/src/coreclr/inc/cordebuginfo.h index bc0d5cc5c9a69f..e4cfcd88be0550 100644 --- a/src/coreclr/inc/cordebuginfo.h +++ b/src/coreclr/inc/cordebuginfo.h @@ -299,6 +299,12 @@ class ICorDebugInfo }; +#ifdef TARGET_WASM + // WASM has no physical registers. A register field in a variable location holds a wasm + // local index biased past the reserved RegNum values above. + static constexpr uint32_t WASM_LOCAL_REGNUM_BASE = REGNUM_AMBIENT_SP + 1; +#endif // TARGET_WASM + // VarLoc describes the location of a native variable. Note that currently, VLT_REG_BYREF and VLT_STK_BYREF // are only used for value types on X64. // [cDAC]: Mirrored in managed code (IDacDbiInterface.cs). diff --git a/src/coreclr/jit/codegen.h b/src/coreclr/jit/codegen.h index 27ef29f71f05a9..1256b6fd86e7bb 100644 --- a/src/coreclr/jit/codegen.h +++ b/src/coreclr/jit/codegen.h @@ -741,6 +741,12 @@ class CodeGen final : public CodeGenInterface IL_OFFSET siLastEndOffs; // IL offset of the (exclusive) end of the last block processed +#if defined(TARGET_WASM) + // Wasm opens its method-wide scopes in the first emitted block rather than relying on the + // monotonic enter/exit cursors used by other targets. + bool siWasmScopesOpened; +#endif // defined(TARGET_WASM) + /* XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX diff --git a/src/coreclr/jit/compiler.cpp b/src/coreclr/jit/compiler.cpp index 87cf3549ff7b34..34367278325017 100644 --- a/src/coreclr/jit/compiler.cpp +++ b/src/coreclr/jit/compiler.cpp @@ -2901,13 +2901,6 @@ void Compiler::compInitOptions(JitFlags* jitFlags) opts.compScopeInfo = opts.compDbgInfo; -#ifdef TARGET_WASM - // Wasm uses virtual registers that cannot be encoded in the - // ICorDebugInfo register scheme, and there is no native debugger - // to consume scope info, so disable it entirely. - opts.compScopeInfo = false; -#endif - #ifdef LATE_DISASM codeGen->getDisAssembler().disOpenForLateDisAsm(info.compMethodName, info.compClassName, info.compMethodInfo->args.pSig); diff --git a/src/coreclr/jit/ee_il_dll.cpp b/src/coreclr/jit/ee_il_dll.cpp index 11dc072b49ae23..9e337116bf07d5 100644 --- a/src/coreclr/jit/ee_il_dll.cpp +++ b/src/coreclr/jit/ee_il_dll.cpp @@ -866,7 +866,11 @@ void Compiler::eeDispVar(ICorDebugInfo::NativeVarInfo* var) { case CodeGenInterface::VLT_REG: case CodeGenInterface::VLT_REG_BYREF: +#if defined(TARGET_WASM) + printf("$%u", (unsigned)var->loc.vlReg.vlrReg - ICorDebugInfo::WASM_LOCAL_REGNUM_BASE); +#else printf("%s", getRegName(var->loc.vlReg.vlrReg)); +#endif if (var->loc.vlType == (ICorDebugInfo::VarLocType)CodeGenInterface::VLT_REG_BYREF) { printf(" byref"); diff --git a/src/coreclr/jit/lclvars.cpp b/src/coreclr/jit/lclvars.cpp index 6cb32a2f78cec9..f67f211166c66d 100644 --- a/src/coreclr/jit/lclvars.cpp +++ b/src/coreclr/jit/lclvars.cpp @@ -1281,7 +1281,7 @@ unsigned Compiler::compMap2ILvarNum(unsigned varNum) const } #if defined(TARGET_WASM) - if (varNum == lvaWasmSpArg) + if ((varNum == lvaWasmSpArg) || (varNum == lvaWasmPortableEntryPointArg)) { return (unsigned)ICorDebugInfo::UNKNOWN_ILNUM; } @@ -1319,6 +1319,11 @@ unsigned Compiler::compMap2ILvarNum(unsigned varNum) const { varNum--; } + + if ((lvaWasmPortableEntryPointArg != BAD_VAR_NUM) && (originalVarNum > lvaWasmPortableEntryPointArg)) + { + varNum--; + } #endif if (varNum >= info.compLocalsCount) diff --git a/src/coreclr/jit/scopeinfo.cpp b/src/coreclr/jit/scopeinfo.cpp index f167f159f40b42..c12596db7acc86 100644 --- a/src/coreclr/jit/scopeinfo.cpp +++ b/src/coreclr/jit/scopeinfo.cpp @@ -57,6 +57,28 @@ XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX #include "emit.h" #include "codegen.h" +#if defined(TARGET_WASM) +// Stack VarLoc records use the reserved ambient SP register number, not a wasm local. +static_assert(REG_FPBASE == REG_SPBASE); +static_assert(REG_FPBASE == REG_NA); +static_assert(static_cast(REG_NA) == static_cast(ICorDebugInfo::REGNUM_AMBIENT_SP)); +static_assert(static_cast(ICorDebugInfo::REGNUM_AMBIENT_SP) == 2); + +//------------------------------------------------------------------------ +// siWasmDebugRegNum: Encode a wasm local as a debug-info register number. +// +// Arguments: +// reg - the JIT register representing a wasm local. +// +// Return Value: +// The wasm local index biased past the reserved debug-info register numbers. +// +static regNumber siWasmDebugRegNum(regNumber reg) +{ + return static_cast(ICorDebugInfo::WASM_LOCAL_REGNUM_BASE + WasmRegToIndex(reg)); +} +#endif // defined(TARGET_WASM) + //============================================================================ // siVarLoc functions //============================================================================ @@ -208,6 +230,30 @@ void CodeGenInterface::siVarLoc::storeVariableInRegisters(regNumber reg, regNumb // Note: mask registers (K0-K7) and XMM16+ are accepted but will produce // VLT_INVALID since they can't be encoded in debug info. +#if defined(TARGET_WASM) + if (reg == REG_NA) + { + vlType = VLT_INVALID; + return; + } + + assert(genIsValidReg(reg)); + + if (otherReg == REG_NA) + { + vlType = VLT_REG; + vlReg.vlrReg = siWasmDebugRegNum(reg); + } + else + { + assert(genIsValidReg(otherReg)); + vlType = VLT_REG_REG; + vlRegReg.vlrrReg1 = siWasmDebugRegNum(reg); + vlRegReg.vlrrReg2 = siWasmDebugRegNum(otherReg); + } + return; +#endif // defined(TARGET_WASM) + if (otherReg == REG_NA) { if (genIsValidFloatReg(reg)) @@ -462,6 +508,12 @@ void CodeGenInterface::siVarLoc::siFillStackVarLoc( void CodeGenInterface::siVarLoc::siFillRegisterVarLoc( const LclVarDsc* varDsc, var_types type, regNumber baseReg, int offset, bool isFramePointerUsed) { +#if defined(TARGET_WASM) + this->vlType = VLT_REG; + this->vlReg.vlrReg = siWasmDebugRegNum(varDsc->GetRegNum()); + return; +#endif // defined(TARGET_WASM) + switch (type) { case TYP_INT: @@ -623,7 +675,11 @@ void CodeGenInterface::dumpSiVarLoc(const siVarLoc* varLoc) const { case VLT_REG: case VLT_REG_BYREF: +#if defined(TARGET_WASM) + printf("$%u", (unsigned)varLoc->vlReg.vlrReg - ICorDebugInfo::WASM_LOCAL_REGNUM_BASE); +#else printf("%s", getRegName(varLoc->vlReg.vlrReg)); +#endif if (varLoc->vlType == VLT_REG_BYREF) { printf(" byref"); @@ -1589,6 +1645,10 @@ void CodeGen::siInit() siLastEndOffs = 0; m_compiler->compResetScopeLists(); + +#if defined(TARGET_WASM) + siWasmScopesOpened = false; +#endif // defined(TARGET_WASM) } /***************************************************************************** @@ -1674,11 +1734,35 @@ void CodeGen::siBeginBlock(BasicBlock* block) void CodeGen::siOpenScopesForNonTrackedVars(const BasicBlock* block, unsigned int lastBlockILEndOffset) { #if defined(TARGET_WASM) - // TODO-WASM: Wasm structured control flow - // requirements are incompatible with debug codegen's - // desire to keep blocks in increasing IL offset - // order. Figure out the proper scope manipulations. - // + // Wasm is compiled only ahead of time, where getVars reports no explicit ranges, so every + // scope covers the whole method. Our elimination of irreducible flow does not emit blocks in + // increasing IL offset order, so open all scopes in the first emitted block. + if (m_compiler->opts.OptimizationDisabled() && !siWasmScopesOpened) + { + siWasmScopesOpened = true; + + for (unsigned i = 0; i < m_compiler->info.compVarScopesCount; i++) + { + VarScopeDsc* varScope = &m_compiler->info.compVarScopes[i]; + assert(varScope->vsdLifeBeg == 0); + + LclVarDsc* lclVarDsc = m_compiler->lvaGetDesc(varScope->vsdVarNum); + + // Only report locals that were referenced, if we're not doing debug codegen + if (m_compiler->opts.compDbgCode || (lclVarDsc->lvRefCnt() > 0)) + { + JITDUMP("Scope info: opening scope, LVnum=%u [%03X..%03X)\n", varScope->vsdLVnum, varScope->vsdLifeBeg, + varScope->vsdLifeEnd); + + varLiveKeeper->siStartVariableLiveRange(lclVarDsc, varScope->vsdVarNum); + } + else + { + JITDUMP("Skipping open scope for V%02u, unreferenced\n", varScope->vsdVarNum); + } + } + } + return; #endif // defined(TARGET_WASM) 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 52e9e298f3684b..9b5832196811e0 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/R2RTestSuites.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/R2RTestSuites.cs @@ -6,6 +6,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Reflection.Metadata; using System.Reflection.PortableExecutable; using ILCompiler.ReadyToRun.Tests.TestCasesRunner; using ILCompiler.Reflection.ReadyToRun; @@ -458,6 +459,12 @@ public void WasmWebcilModule() static void ValidateDefault(ReadyToRunReader reader) { + const byte WasmI32 = 0x7F; + const byte WasmF64 = 0x7C; + const byte WasmV128 = 0x7B; + const int WasmEncodedFrameBase = 2; + const int WasmLocalRegisterBase = 3; + var webcilReader = Assert.IsType(reader.CompositeReader); WasmR2RAssert.AssertFrameIdentityPrologs(webcilReader); Assert.True(webcilReader.IsWasmWrapped); @@ -467,12 +474,157 @@ static void ValidateDefault(ReadyToRunReader reader) List methods = R2RAssert.GetAllMethods(reader); Assert.True(methods.Exists(method => method.SignatureString.Contains("AddIntegers", StringComparison.Ordinal))); + + // The variable debug-info checks validate the encoding contract rather than exact + // codegen: register records must name a declared wasm local of the expected type, and + // stack records must be frame-relative slots. Native offsets, frame layout, and + // instruction selection are deliberately not pinned. + foreach (ReadyToRunMethod method in methods) + { + foreach (RuntimeFunction runtimeFunction in method.RuntimeFunctions) + { + if (runtimeFunction.DebugInfo is DebugInfo debugInfo) + { + AssertValidRecords(debugInfo.VariablesList, ResolveWasmBody(reader, webcilReader, runtimeFunction)); + } + } + } + + // Optimized code keeps tracked variables, including vectors, in wasm locals. + ReadyToRunMethod optimizedTrackedVariables = GetMethod(methods, "OptimizedTrackedVariables"); + RuntimeFunction optimizedRoot = GetRoot(optimizedTrackedVariables); + WebcilImageReader.WasmFunctionInfo optimizedBody = ResolveWasmBody(reader, webcilReader, optimizedRoot); + AssertRegisterRecord(optimizedRoot, optimizedBody, VariableType.Parameter, 0, WasmI32); + AssertRegisterRecord(optimizedRoot, optimizedBody, VariableType.Parameter, 1, WasmI32); + AssertRegisterRecord(optimizedRoot, optimizedBody, VariableType.Local, 0, WasmI32); + ReadyToRunMethod optimizedVectorVariables = GetMethod(methods, "OptimizedVectorVariables"); + RuntimeFunction optimizedVectorRoot = GetRoot(optimizedVectorVariables); + WebcilImageReader.WasmFunctionInfo optimizedVectorBody = + ResolveWasmBody(reader, webcilReader, optimizedVectorRoot); + AssertRegisterRecord(optimizedVectorRoot, optimizedVectorBody, VariableType.Parameter, 0, WasmV128); + AssertRegisterRecord(optimizedVectorRoot, optimizedVectorBody, VariableType.Parameter, 1, WasmV128); + AssertRegisterRecord(optimizedVectorRoot, optimizedVectorBody, VariableType.Local, 0, WasmV128); + + // Unoptimized code receives parameters in wasm locals and then homes them to the frame. + ReadyToRunMethod leafFrameLocal = GetMethod(methods, "LeafFrameLocal"); + Assert.Equal(new[] { "int" }, leafFrameLocal.LocalSignature); + RuntimeFunction leafRoot = GetRoot(leafFrameLocal); + WebcilImageReader.WasmFunctionInfo leafBody = ResolveWasmBody(reader, webcilReader, leafRoot); + AssertHomedParameter(leafRoot, leafBody, 0, WasmI32, VarLocType.VLT_STK); + NativeVarInfo leafLocal = AssertStackRecord(leafRoot, VariableType.Local, 0, VarLocType.VLT_STK); + AssertReferencesFrameOffset(leafBody, leafLocal.VariableLocation.Data2); + + // localloc gives the root a frame pointer distinct from SP; stack records remain + // relative to the logical frame. + ReadyToRunMethod locallocFrameLocal = GetMethod(methods, "LocallocFrameLocal"); + Assert.Equal(new[] { "int" }, locallocFrameLocal.LocalSignature); + RuntimeFunction locallocRoot = GetRoot(locallocFrameLocal); + WebcilImageReader.WasmFunctionInfo locallocBody = ResolveWasmBody(reader, webcilReader, locallocRoot); + NativeVarInfo locallocParameter = AssertHomedParameter(locallocRoot, locallocBody, 0, WasmI32, VarLocType.VLT_STK); + AssertReferencesFrameOffset(locallocBody, locallocParameter.VariableLocation.Data2); + AssertStackRecord(locallocRoot, VariableType.Local, 0, VarLocType.VLT_STK); + + // Floating-point values use the same local encoding, and a double homed to the frame + // occupies two 32-bit slots. + ReadyToRunMethod addDoubles = GetMethod(methods, "AddDoubles"); + RuntimeFunction addDoublesRoot = GetRoot(addDoubles); + WebcilImageReader.WasmFunctionInfo addDoublesBody = ResolveWasmBody(reader, webcilReader, addDoublesRoot); + AssertHomedParameter(addDoublesRoot, addDoublesBody, 0, WasmF64, VarLocType.VLT_STK2); + AssertHomedParameter(addDoublesRoot, addDoublesBody, 1, WasmF64, VarLocType.VLT_STK2); + // Reads static data, so the JIT materializes the image base via a well-known-global global.get. - Assert.True(methods.Exists(method => - method.SignatureString.Contains("SumStaticData", StringComparison.Ordinal))); + ReadyToRunMethod sumStaticData = GetMethod(methods, "SumStaticData"); + RuntimeFunction sumStaticRoot = GetRoot(sumStaticData); + AssertRegisterRecord( + sumStaticRoot, + ResolveWasmBody(reader, webcilReader, sumStaticRoot), + VariableType.Parameter, + 0, + WasmI32); + // Has a try/finally, so the JIT materializes the table base via a well-known-global global.get. - Assert.True(methods.Exists(method => - method.SignatureString.Contains("SumWithFinally", StringComparison.Ordinal))); + // Variable records describe the root method's frame; funclets do not report scopes. + ReadyToRunMethod sumWithFinally = GetMethod(methods, "SumWithFinally"); + RuntimeFunction sumWithFinallyRoot = GetRoot(sumWithFinally); + AssertHomedParameter( + sumWithFinallyRoot, + ResolveWasmBody(reader, webcilReader, sumWithFinallyRoot), + 0, + WasmI32, + VarLocType.VLT_STK); + AssertStackRecord(sumWithFinallyRoot, VariableType.Local, 0, VarLocType.VLT_STK); + Assert.Single(sumWithFinally.RuntimeFunctions, runtimeFunction => + runtimeFunction.WasmIsFunclet && runtimeFunction.DebugInfo is null); + + // A GC reference kept live in the parent frame across a GC reached from a finally + // funclet. The funclet addresses the same frame slot through the parent frame. + ReadyToRunMethod gcLocalAcrossFinally = GetMethod(methods, "GcLocalAcrossFinally"); + Assert.Equal( + new[] { "Webcil.WasmWebcilModule+GcMarker", "int" }, + gcLocalAcrossFinally.LocalSignature); + RuntimeFunction gcLocalRoot = GetRoot(gcLocalAcrossFinally); + AssertHomedParameter( + gcLocalRoot, + ResolveWasmBody(reader, webcilReader, gcLocalRoot), + 0, + WasmI32, + VarLocType.VLT_STK); + NativeVarInfo gcMarker = AssertStackRecord(gcLocalRoot, VariableType.Local, 0, VarLocType.VLT_STK); + AssertStackRecord(gcLocalRoot, VariableType.Local, 1, VarLocType.VLT_STK); + AssertFrameGcSlot(gcLocalAcrossFinally, gcMarker); + AssertSafepointCovered(gcLocalAcrossFinally, gcLocalRoot, gcMarker); + RuntimeFunction gcLocalFunclet = Assert.Single(gcLocalAcrossFinally.RuntimeFunctions, runtimeFunction => + runtimeFunction.WasmIsFunclet && runtimeFunction.DebugInfo is null); + AssertReferencesFrameOffset( + ResolveWasmBody(reader, webcilReader, gcLocalFunclet), + gcMarker.VariableLocation.Data2); + + ReadyToRunMethod locallocAcrossFinally = GetMethod(methods, "LocallocAcrossFinally"); + Assert.Equal(new[] { "int", "int*", "int" }, locallocAcrossFinally.LocalSignature); + RuntimeFunction locallocFinallyRoot = GetRoot(locallocAcrossFinally); + WebcilImageReader.WasmFunctionInfo locallocFinallyBody = + ResolveWasmBody(reader, webcilReader, locallocFinallyRoot); + NativeVarInfo locallocFinallyParameter = + AssertHomedParameter(locallocFinallyRoot, locallocFinallyBody, 0, WasmI32, VarLocType.VLT_STK); + AssertReferencesFrameOffset(locallocFinallyBody, locallocFinallyParameter.VariableLocation.Data2); + for (int localIndex = 0; localIndex < 3; localIndex++) + { + AssertStackRecord(locallocFinallyRoot, VariableType.Local, localIndex, VarLocType.VLT_STK); + } + Assert.Single(locallocAcrossFinally.RuntimeFunctions, runtimeFunction => + runtimeFunction.WasmIsFunclet && runtimeFunction.DebugInfo is null); + + // Same-typed GC locals, including a null reference, keep distinct frame slots. Wasm opens + // every method-wide scope in the first emitted block, so the locals share one range. + ReadyToRunMethod gcSlotIdentity = GetMethod(methods, "GcSlotIdentity"); + Assert.Equal( + new[] + { + "Webcil.WasmWebcilModule+GcMarker", + "Webcil.WasmWebcilModule+GcMarker", + "Webcil.WasmWebcilModule+GcMarker", + "int", + }, + gcSlotIdentity.LocalSignature); + RuntimeFunction gcSlotRoot = GetRoot(gcSlotIdentity); + NativeVarInfo[] slotLocals = new NativeVarInfo[4]; + for (int localIndex = 0; localIndex < slotLocals.Length; localIndex++) + { + slotLocals[localIndex] = AssertStackRecord(gcSlotRoot, VariableType.Local, localIndex, VarLocType.VLT_STK); + } + Assert.All(slotLocals, local => + { + Assert.Equal(slotLocals[0].StartOffset, local.StartOffset); + Assert.Equal(slotLocals[0].EndOffset, local.EndOffset); + }); + Assert.Equal(slotLocals.Length, slotLocals.Select(local => local.VariableLocation.Data2).Distinct().Count()); + NativeVarInfo[] markers = slotLocals[..3]; + foreach (NativeVarInfo marker in markers) + { + AssertFrameGcSlot(gcSlotIdentity, marker); + } + AssertSafepointCovered(gcSlotIdentity, gcSlotRoot, markers); + // Has a catch clause, so the JIT emits a try_table catch_ref that references the // imported restore-context exception tag. Assert.True(methods.Exists(method => @@ -494,6 +646,230 @@ static void ValidateDefault(ReadyToRunReader reader) out string funcletDiagnostic), funcletDiagnostic); + static ReadyToRunMethod GetMethod(List methods, string name) + => Assert.Single(methods, method => method.SignatureString.Contains(name, StringComparison.Ordinal)); + + static RuntimeFunction GetRoot(ReadyToRunMethod method) + => Assert.Single(method.RuntimeFunctions, runtimeFunction => runtimeFunction.DebugInfo is not null); + + static void AssertValidRecords(List records, WebcilImageReader.WasmFunctionInfo body) + { + foreach (NativeVarInfo record in records) + { + Assert.True( + record.StartOffset < record.EndOffset, + $"Variable {record.VariableNumber} has an empty range [{record.StartOffset:X}, {record.EndOffset:X})."); + switch (record.VariableLocation.VarLocType) + { + case VarLocType.VLT_REG: + Assert.True(record.VariableLocation.Data1 >= WasmLocalRegisterBase); + GetWasmLocalType(body, record.VariableLocation.Data1 - WasmLocalRegisterBase); + break; + case VarLocType.VLT_STK: + case VarLocType.VLT_STK2: + Assert.Equal(WasmEncodedFrameBase, record.VariableLocation.Data1); + break; + default: + Assert.Fail($"Unexpected wasm variable location {record.VariableLocation.VarLocType}."); + break; + } + } + + foreach (IGrouping variable in records.GroupBy(record => record.VariableNumber)) + { + NativeVarInfo[] ranges = variable.OrderBy(record => record.StartOffset).ToArray(); + for (int i = 1; i < ranges.Length; i++) + { + Assert.True( + ranges[i - 1].EndOffset <= ranges[i].StartOffset, + $"Variable {variable.Key} has overlapping ranges."); + } + } + } + + static NativeVarInfo AssertRegisterRecord( + RuntimeFunction root, + WebcilImageReader.WasmFunctionInfo body, + VariableType variableType, + int variableIndex, + byte wasmType) + { + NativeVarInfo[] records = root.DebugInfo!.VariablesList + .Where(record => record.Variable.Type == variableType + && record.Variable.Index == variableIndex + && record.VariableLocation.VarLocType == VarLocType.VLT_REG) + .OrderBy(record => record.StartOffset) + .ToArray(); + Assert.NotEmpty(records); + Assert.All(records, record => Assert.Equal( + wasmType, + GetWasmLocalType(body, record.VariableLocation.Data1 - WasmLocalRegisterBase))); + return records[0]; + } + + static NativeVarInfo AssertStackRecord( + RuntimeFunction root, + VariableType variableType, + int variableIndex, + VarLocType locationType) + => Assert.Single(root.DebugInfo!.VariablesList, record => + record.Variable.Type == variableType + && record.Variable.Index == variableIndex + && record.VariableLocation.VarLocType == locationType); + + static NativeVarInfo AssertHomedParameter( + RuntimeFunction root, + WebcilImageReader.WasmFunctionInfo body, + int parameterIndex, + byte wasmType, + VarLocType stackLocationType) + { + NativeVarInfo inLocal = AssertRegisterRecord(root, body, VariableType.Parameter, parameterIndex, wasmType); + Assert.Equal(0u, inLocal.StartOffset); + NativeVarInfo onFrame = AssertStackRecord(root, VariableType.Parameter, parameterIndex, stackLocationType); + Assert.True(inLocal.EndOffset <= onFrame.StartOffset); + return onFrame; + } + + static byte GetWasmLocalType(WebcilImageReader.WasmFunctionInfo body, int localIndex) + { + if (localIndex < body.ParamTypes.Count) + { + return body.ParamTypes[localIndex]; + } + + int remaining = localIndex - body.ParamTypes.Count; + foreach ((uint count, byte valType) in body.Locals) + { + if (remaining < count) + { + return valType; + } + + remaining -= (int)count; + } + + Assert.Fail($"Wasm local {localIndex} is not declared by the function."); + return 0; + } + + static void AssertReferencesFrameOffset(WebcilImageReader.WasmFunctionInfo body, int frameOffset) + { + // Look for a load or store whose memarg offset addresses the frame slot. + const byte FirstMemoryAccess = 0x28; + const byte LastMemoryAccess = 0x3E; + ReadOnlySpan code = body.Image.AsSpan(body.InstructionOffset, body.InstructionLength); + for (int i = 0; i < code.Length; i++) + { + if (code[i] is >= FirstMemoryAccess and <= LastMemoryAccess + && TryReadUnsignedLeb128(code, i + 1, out _, out int offsetPosition) + && TryReadUnsignedLeb128(code, offsetPosition, out ulong memoryOffset, out _) + && memoryOffset == (ulong)frameOffset) + { + return; + } + } + + Assert.Fail($"Expected a wasm load or store at frame offset 0x{frameOffset:X}."); + } + + static bool TryReadUnsignedLeb128(ReadOnlySpan code, int position, out ulong value, out int next) + { + value = 0; + for (int shift = 0; position < code.Length && shift < 64; shift += 7) + { + byte b = code[position++]; + value |= (ulong)(b & 0x7F) << shift; + if ((b & 0x80) == 0) + { + next = position; + return true; + } + } + + next = position; + return false; + } + + static void AssertFrameGcSlot(ReadyToRunMethod method, NativeVarInfo record) + { + ILCompiler.Reflection.ReadyToRun.Amd64.GcInfo gcInfo = + Assert.IsType(method.GcInfo); + Assert.Equal(0u, gcInfo.SlotTable.NumRegisters); + ILCompiler.Reflection.ReadyToRun.Amd64.GcSlotTable.GcSlot slot = Assert.Single( + gcInfo.SlotTable.GcSlots, + candidate => candidate.StackSlot?.SpOffset == record.VariableLocation.Data2); + Assert.Equal(GcStackSlotBase.GC_FRAMEREG_REL, slot.StackSlot.Base); + } + + static void AssertSafepointCovered(ReadyToRunMethod method, RuntimeFunction root, params NativeVarInfo[] records) + { + uint callILOffset = FindCallILOffset(method, "CollectAtGcSafepoint"); + DebugInfoBoundsEntry call = Assert.Single( + root.DebugInfo!.BoundsList, + bound => bound.ILOffset == callILOffset); + Assert.All(records, record => Assert.True( + record.StartOffset <= call.NativeOffset && call.NativeOffset < record.EndOffset, + $"Variable {record.VariableNumber} range [{record.StartOffset:X}, {record.EndOffset:X}) does not cover the safepoint at {call.NativeOffset:X}.")); + } + + static uint FindCallILOffset(ReadyToRunMethod method, string calleeName) + { + const byte CallOpcode = 0x28; + const int MethodDefTable = 0x06; + byte[] il = GetMethodILBytes(method); + System.Reflection.Metadata.MetadataReader metadata = method.ComponentReader.MetadataReader; + int callOffset = -1; + for (int i = 0; i + sizeof(int) < il.Length; i++) + { + if (il[i] != CallOpcode) + { + continue; + } + + int token = BitConverter.ToInt32(il, i + 1); + int row = token & 0xFFFFFF; + if ((token >> 24) != MethodDefTable || row == 0 || row > metadata.MethodDefinitions.Count) + { + continue; + } + + MethodDefinitionHandle callee = System.Reflection.Metadata.Ecma335.MetadataTokens.MethodDefinitionHandle(row); + if (metadata.StringComparer.Equals(metadata.GetMethodDefinition(callee).Name, calleeName)) + { + Assert.Equal(-1, callOffset); + callOffset = i; + } + } + + Assert.True(callOffset >= 0, $"Expected a call to {calleeName}."); + return (uint)callOffset; + } + + static WebcilImageReader.WasmFunctionInfo ResolveWasmBody( + ReadyToRunReader reader, + WebcilImageReader webcilReader, + RuntimeFunction runtimeFunction) + { + uint tableIndex = checked(reader.WasmMinFunctionTableIndex + (uint)runtimeFunction.Id); + int functionIndex = webcilReader.GetFunctionIndexFromTableIndex(tableIndex); + Assert.True(functionIndex >= 0, $"Could not resolve wasm table index {tableIndex} to a function body."); + WebcilImageReader.WasmFunctionInfo? body = webcilReader.GetWasmFunctionBody(functionIndex); + Assert.True(body is not null, $"Wasm function body {functionIndex} was not found."); + return body.Value; + } + + static byte[] GetMethodILBytes(ReadyToRunMethod method) + { + MethodDefinition methodDefinition = + method.ComponentReader.MetadataReader.GetMethodDefinition((MethodDefinitionHandle)method.MethodHandle); + byte[]? ilBytes = null; + method.ComponentReader.GetSectionData( + methodDefinition.RelativeVirtualAddress, + sectionData => ilBytes = MethodBodyBlock.Create(sectionData).GetILBytes()); + return Assert.IsType(ilBytes); + } + // 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 // indices and shrink down to their minimal size. Verify the emitted code contains a correctly self-resolved 'global.get' for the @@ -940,6 +1316,18 @@ static void Validate(ReadyToRunReader reader) } } + [Theory] + [InlineData(0, "PC")] + [InlineData(2, "ambient SP")] + [InlineData(3, "$0")] + [InlineData(4, "$1")] + [InlineData(0x2000_0002, "$536870911")] + [InlineData(-1, "Unknown '-1'")] + public void WasmDebugRegisterIsDecoded(int register, string expected) + { + Assert.Equal(expected, DebugInfo.GetPlatformSpecificRegister(WasmMachine.Wasm32, register)); + } + [ConditionalFact(typeof(TestPaths), nameof(TestPaths.IsNotWasmTarget))] public void RuntimeFunctionsSectionSizeExcludesSentinel() { diff --git a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/Webcil/WasmWebcilModule.cs b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/Webcil/WasmWebcilModule.cs index 222bc392d603df..1ef6027d73662c 100644 --- a/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/Webcil/WasmWebcilModule.cs +++ b/src/coreclr/tools/aot/ILCompiler.ReadyToRun.Tests/TestCases/Webcil/WasmWebcilModule.cs @@ -4,6 +4,7 @@ using System; using System.Reflection; using System.Runtime.CompilerServices; +using System.Runtime.Intrinsics; namespace Webcil; @@ -100,6 +101,16 @@ public struct GenericTokenValue private static readonly int[] s_primes = new int[] { 3, 5, 7, 11, 13 }; private static int s_counter; + private sealed class GcMarker + { + public readonly int Value; + + public GcMarker(int value) + { + Value = value; + } + } + [MethodImpl(MethodImplOptions.NoInlining)] public static int AddIntegers(int left, int right) { @@ -116,6 +127,62 @@ public static void FoldableBodyTwo() { } + [MethodImpl(MethodImplOptions.NoInlining)] + public static int OptimizedTrackedVariables(int left, int right) + { + int sum = left + right; + if (sum < 0) + { + return AddIntegers(sum, right); + } + + int difference = left - right; + return AddIntegers(sum, difference); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public static Vector128 OptimizedVectorVariables(Vector128 left, Vector128 right) + { + Vector128 sum = left + right; + if (sum.GetElement(0) < 0) + { + return AddVectors(sum, right); + } + + Vector128 difference = left - right; + return AddVectors(sum, difference); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public static Vector128 AddVectors(Vector128 left, Vector128 right) + { + return left + right; + } + + [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] + public static unsafe int LeafFrameLocal(int value) + { + int local = value + 1; + int* address = &local; + return *address * 2; + } + + [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] + public static unsafe int LocallocFrameLocal(int value) + { + int length = (value & 3) + 1; + int* storage = stackalloc int[length]; + *storage = value; + return *storage; + } + + [MethodImpl(MethodImplOptions.NoOptimization)] + public static double AddDoubles(double left, double right) + { + double result = left + right; + return result; + } + // Explicit construction avoids distinct compiler-generated delegate cache fields. [MethodImpl(MethodImplOptions.NoInlining)] public static MethodInfo SelfMethodOne() => new Func(SelfMethodOne).Method; @@ -154,6 +221,76 @@ public static int SumWithFinally(int index) return total + s_counter; } + [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] + public static int GcLocalAcrossFinally(int value) + { + GcMarker marker = new(value); + try + { + return marker.Value; + } + finally + { + // Keep the newly allocated object live in the parent frame across an actual GC + // reached through the finally funclet. + CollectAtGcSafepoint(); + GC.KeepAlive(marker); + } + } + + [MethodImpl(MethodImplOptions.NoInlining)] + public static void CollectAtGcSafepoint() + { + GC.Collect(); + } + + [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] + public static unsafe int LocallocAcrossFinally(int value) + { + int length = (value & 3) + 1; + int* storage = stackalloc int[length]; + *storage = value; + try + { + return *storage; + } + finally + { + CollectAtGcSafepoint(); + } + } + + [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] + public static int GcSlotIdentity() + { + GcMarker first = new(17); + if (first.Value == 0) + { + return 0; + } + + GcMarker second = new(29); + if (second.Value == 0) + { + return 0; + } + + GcMarker? empty = null; + TouchMarkerSlot(ref empty); + + CollectAtGcSafepoint(); + int result = (first.Value * 100) + second.Value; + GC.KeepAlive(first); + GC.KeepAlive(second); + GC.KeepAlive(empty); + return result; + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private static void TouchMarkerSlot(ref GcMarker? marker) + { + } + [MethodImpl(MethodImplOptions.NoInlining | MethodImplOptions.NoOptimization)] public static int CatchExceptionOne(int value) { diff --git a/src/coreclr/tools/aot/ILCompiler.Reflection.ReadyToRun/DebugInfo.cs b/src/coreclr/tools/aot/ILCompiler.Reflection.ReadyToRun/DebugInfo.cs index 58cb620fe98558..280bf9f757f2fb 100644 --- a/src/coreclr/tools/aot/ILCompiler.Reflection.ReadyToRun/DebugInfo.cs +++ b/src/coreclr/tools/aot/ILCompiler.Reflection.ReadyToRun/DebugInfo.cs @@ -87,12 +87,27 @@ public static string GetPlatformSpecificRegister(Machine machine, int regnum) case Machine.RiscV64: return ((RiscV64.Registers)regnum).ToString(); case WasmMachine.Wasm32: - return $"NYI '{regnum}'"; // WASM-TODO Implement this correctly. + return GetWasmRegister(regnum); default: throw new NotImplementedException($"No implementation for machine type {machine}."); } } + private static string GetWasmRegister(int regnum) + { + // Keep in sync with ICorDebugInfo::WASM_LOCAL_REGNUM_BASE in cordebuginfo.h. + const int WasmLocalRegisterBase = 3; + + return regnum switch + { + 0 => "PC", + 1 => "REGNUM_COUNT", + 2 => "ambient SP", + >= WasmLocalRegisterBase => $"${regnum - WasmLocalRegisterBase}", + _ => $"Unknown '{regnum}'", + }; + } + private void EnsureInitialized() { if (_boundsList != null) diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Abstractions/Contracts/IDebugInfo.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Abstractions/Contracts/IDebugInfo.cs index f40f8479efb53d..a0845560298375 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Abstractions/Contracts/IDebugInfo.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Abstractions/Contracts/IDebugInfo.cs @@ -48,6 +48,18 @@ public enum DebugVarLocKind DoubleStack, FloatingPointStack, FixedVarArg, + /// + /// The variable lives in a WebAssembly local. WASM locals are engine-private frame state: + /// they are not in linear memory and cannot be read through the data target. Consumers with + /// access to the WASM engine (for example a debugger attached over the Chrome DevTools + /// Protocol) can fetch the value using . + /// + WasmLocal, + /// + /// The variable spans two WebAssembly locals, described by + /// and . + /// + WasmLocalPair, } /// @@ -84,6 +96,20 @@ public readonly struct DebugVarInfo /// the call site whose return value this entry describes. Zero for all other entries. /// public uint CallReturnValueILOffset { get; init; } + + /// + /// On WASM, decoded into the WASM local index that local.get / + /// local.set use, without the ICorDebugInfo::WASM_LOCAL_REGNUM_BASE bias. Local + /// index spaces are per WASM function and a method's funclets are separate functions from its + /// root, so a consumer must know which function the virtual IP belongs to before using it. + /// Null on every other architecture. + /// + public uint? WasmLocal { get; init; } + /// + /// On WASM, decoded as is. + /// Null on every other architecture. + /// + public uint? WasmLocal2 { get; init; } } /// diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/DebugInfo/DebugInfoHelpers.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/DebugInfo/DebugInfoHelpers.cs index 5b52d110947a98..675285269fdd4d 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/DebugInfo/DebugInfoHelpers.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/DebugInfo/DebugInfoHelpers.cs @@ -15,6 +15,10 @@ namespace Microsoft.Diagnostics.DataContractReader.Contracts; /// internal static class DebugInfoHelpers { + // Keep in sync with ICorDebugInfo::WASM_LOCAL_REGNUM_BASE in cordebuginfo.h: one past + // REGNUM_AMBIENT_SP, so PC, REGNUM_COUNT, and REGNUM_AMBIENT_SP never name a WASM local. + internal const uint WasmLocalRegNumBase = 3; + /// /// Mirrors ICorDebugInfo::VarLocType from cordebuginfo.h. /// Describes how a variable is stored at a particular point in native code. @@ -109,7 +113,10 @@ internal static IEnumerable DoBounds(NativeReader nativeReader, u /// public entries directly. /// Mirrors the native DoNativeVarInfo/TransferReader logic from debuginfostore.cpp. /// - internal static IEnumerable DoVars(NativeReader nativeReader, bool isX86) + internal static IEnumerable DoVars( + NativeReader nativeReader, + bool isX86, + bool isWasm = false) { NibbleReader reader = new(nativeReader, 0); @@ -137,7 +144,7 @@ internal static IEnumerable DoVars(NativeReader nativeReader, bool if (locType is VarLocType.VLT_INVALID or VarLocType.VLT_COUNT) continue; - yield return locType switch + DebugVarInfo info = locType switch { VarLocType.VLT_REG => new DebugVarInfo { @@ -199,9 +206,55 @@ internal static IEnumerable DoVars(NativeReader nativeReader, bool StartOffset = startOffset, EndOffset = endOffset, VarNumber = varNumber, CallReturnValueILOffset = callReturnValueILOffset, }, }; + + yield return isWasm ? ApplyWasmProjection(info) : info; } } + /// + /// Decodes a WASM regNumber into a local index. Returns null for the reserved + /// pseudo-register values (PC, REGNUM_COUNT, REGNUM_AMBIENT_SP), which do + /// not name a local. + /// + internal static uint? DecodeWasmRegister(uint register) + => register >= WasmLocalRegNumBase ? register - WasmLocalRegNumBase : null; + + /// + /// Reinterprets an architecture-neutral for WASM, where the JIT's + /// "registers" are biased WASM local indices rather than physical registers. + /// + private static DebugVarInfo ApplyWasmProjection(DebugVarInfo info) + { + uint? local = DecodeWasmRegister(info.Register); + uint? local2 = DecodeWasmRegister(info.Register2); + + // Promote the kinds whose storage is entirely WASM locals, so that consumers written + // against a real register file cannot mistake them for readable registers. Stack-based + // kinds keep their kind: their storage is linear memory, which is readable. + DebugVarLocKind kind = info.Kind switch + { + DebugVarLocKind.Register => local is not null + ? DebugVarLocKind.WasmLocal + : throw new InvalidOperationException( + $"Invalid WASM debug register encoding 0x{info.Register:X8}."), + DebugVarLocKind.RegisterRegister => local is not null && local2 is not null + ? DebugVarLocKind.WasmLocalPair + : throw new InvalidOperationException( + $"Invalid WASM debug register pair encoding 0x{info.Register:X8}, 0x{info.Register2:X8}."), + DebugVarLocKind.RegisterStack or DebugVarLocKind.StackRegister when local is null => + throw new InvalidOperationException( + $"Invalid WASM debug register encoding 0x{info.Register:X8}."), + _ => info.Kind, + }; + + return info with + { + Kind = kind, + WasmLocal = local, + WasmLocal2 = local2, + }; + } + private static int ReadEncodedStackOffset(NibbleReader reader, bool isX86) { int value = reader.ReadInt(); diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/DebugInfo/DebugInfo_1.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/DebugInfo/DebugInfo_1.cs index 67f451c711f7a0..4018642935851d 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/DebugInfo/DebugInfo_1.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Contracts/Contracts/DebugInfo/DebugInfo_1.cs @@ -140,9 +140,12 @@ IEnumerable IDebugInfo.GetMethodVarInfo(TargetCodePointer pCode, o if (chunks.VarsSize > 0) { - bool isX86 = _target.Contracts.RuntimeInfo.GetTargetArchitecture() == RuntimeInfoArchitecture.X86; + RuntimeInfoArchitecture arch = _target.Contracts.RuntimeInfo.GetTargetArchitecture(); + bool isX86 = arch == RuntimeInfoArchitecture.X86; + bool isWasm = arch == RuntimeInfoArchitecture.Wasm; + NativeReader varsNativeReader = new(new TargetStream(_target, chunks.VarsStart, chunks.VarsSize), _target.IsLittleEndian); - return DebugInfoHelpers.DoVars(varsNativeReader, isX86); + return DebugInfoHelpers.DoVars(varsNativeReader, isX86, isWasm); } return []; diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Legacy/ClrDataFrame.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Legacy/ClrDataFrame.cs index 3053371d06df1f..27d3113966797b 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Legacy/ClrDataFrame.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Legacy/ClrDataFrame.cs @@ -540,6 +540,7 @@ private ClrDataValue CreateValueFromDebugInfo( AddressOrValue = locations[0].AddressOrValue, Size = (ulong)typeSize, IsRegisterValue = locations[0].IsRegisterValue, + HasReadFailure = locations[0].HasReadFailure, }, ]; } @@ -943,13 +944,16 @@ private static NativeVarLocation[] FindAndResolveVarLocation( /// Resolves a DebugVarInfo entry to physical NativeVarLocation(s) /// using the given CPU context. Mirrors the native NativeVarLocations() from util.cpp. /// - private static NativeVarLocation[] ResolveVarLocation( + internal static NativeVarLocation[] ResolveVarLocation( DebugVarInfo varInfo, IPlatformAgnosticContext context, Target target) { int pointerSize = target.PointerSize; + if (target.Contracts.RuntimeInfo.GetTargetArchitecture() == RuntimeInfoArchitecture.Wasm) + return ResolveWasmVarLocation(varInfo, context, target, pointerSize); + return (varInfo.Kind, varInfo.IsByRef) switch { (DebugVarLocKind.Register, false) => @@ -989,6 +993,72 @@ private static NativeVarLocation[] ResolveVarLocation( }; } + /// + /// Resolves a variable location on WASM. + /// + /// + /// WASM has no register file — .TryReadRegister(int) always + /// fails — and a WASM "register" in debug info is not a register at all but a biased + /// local index naming a WASM local. WASM locals are engine-private frame + /// state: they are not in linear memory and cannot be read through the data target, so they + /// are reported as having no location rather than resolved. Routing them through + /// would instead silently report the value 0 for every variable. + /// Stack slots, by contrast, live in linear memory and are resolved here against the frame's + /// logical frame pointer. + /// + private static NativeVarLocation[] ResolveWasmVarLocation( + DebugVarInfo varInfo, + IPlatformAgnosticContext context, + Target target, + int pointerSize) + { + // The only base register of a WASM stack record: the logical frame pointer. See + // "WebAssembly Stack Base Encoding" in DebugInfo.md. + const uint WasmFrameBaseRegister = 2; + switch (varInfo.Kind) + { + case DebugVarLocKind.Stack: + case DebugVarLocKind.DoubleStack: + break; + default: + // WasmLocal, WasmLocalPair, and the mixed register/stack kinds all have at least + // part of their storage in a WASM local, which is unreadable from here. + return []; + } + + if (varInfo.BaseRegister != WasmFrameBaseRegister) + return []; + + TargetPointer framePointer = context.FramePointer; + if (framePointer == TargetPointer.Null) + return []; + + ulong address = (ulong)((long)framePointer.Value + varInfo.StackOffset); + ulong size = varInfo.Kind == DebugVarLocKind.DoubleStack ? 2 * (ulong)pointerSize : (ulong)pointerSize; + + if (varInfo.IsByRef) + { + // Native DereferenceByRefVar reports a failed indirection as address 0, which is unmapped + // natively. WASM linear address 0 is readable, so mark the location as unreadable instead. + if (!target.TryReadPointer(address, out TargetPointer byRefAddress) || + byRefAddress == TargetPointer.Null) + { + return + [ + new NativeVarLocation + { + AddressOrValue = 0, + Size = (ulong)pointerSize, + HasReadFailure = true, + }, + ]; + } + address = byRefAddress.Value; + } + + return [new NativeVarLocation { AddressOrValue = address, Size = size, IsRegisterValue = false }]; + } + private static NativeVarLocation[] ResolveRegByRef(IPlatformAgnosticContext context, Target target, uint register, int pointerSize) { // The register holds a pointer to the variable in target memory. diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Legacy/ClrDataValue.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Legacy/ClrDataValue.cs index ac8bd2f40fbb79..dc93e78aeb22e0 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Legacy/ClrDataValue.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Legacy/ClrDataValue.cs @@ -25,6 +25,7 @@ public readonly struct NativeVarLocation public ulong AddressOrValue { get; init; } public ulong Size { get; init; } public bool IsRegisterValue { get; init; } + public bool HasReadFailure { get; init; } } [GeneratedComClass] @@ -111,6 +112,7 @@ int IXCLRDataValue.GetAddress(ClrDataAddress* address) { throw new InvalidCastException(); // E_NOINTERFACE } + ThrowIfLocationReadFailed(); *address = _locations[0].AddressOrValue; } @@ -175,6 +177,8 @@ int IXCLRDataValue.GetBytes(uint bufLen, uint* dataSize, byte* buffer) if (_totalSize == 0) throw new InvalidCastException(); // E_NOINTERFACE + ThrowIfLocationReadFailed(); + if (dataSize is not null) *dataSize = (uint)_totalSize; @@ -463,6 +467,8 @@ int IXCLRDataValue.GetAssociatedValue(DacComNullableByRef assocV } NativeVarLocation currentLocation = _locations[0]; + ThrowIfLocationReadFailed(); + ulong address = currentLocation.IsRegisterValue ? currentLocation.AddressOrValue : _target.ReadPointer(new TargetPointer(currentLocation.AddressOrValue)).Value; @@ -512,6 +518,7 @@ int IXCLRDataValue.GetAssociatedType(DacComNullableByRef } else if ((_flags & (uint)ClrDataValueFlag.IS_ARRAY) != 0) { + ThrowIfLocationReadFailed(); IRuntimeTypeSystem rts = _target.Contracts.RuntimeTypeSystem; TargetPointer methodTable = _target.Contracts.Object.GetMethodTableAddress(_baseAddress); typeHandle = rts.GetTypeParam(rts.GetTypeHandle(methodTable)); @@ -544,6 +551,7 @@ int IXCLRDataValue.GetString(uint bufLen, uint* strLen, char* str) if ((_flags & (uint)ClrDataValueFlag.IS_STRING) == 0) throw new ArgumentException(); + ThrowIfLocationReadFailed(); string value = _target.Contracts.Object.GetStringValue(_baseAddress); OutputBufferHelpers.CopyStringToBuffer(str, bufLen, strLen, value); if (str is null || bufLen < value.Length + 1) @@ -585,6 +593,7 @@ int IXCLRDataValue.GetArrayProperties(uint* rank, uint* totalElements, uint numD if ((_flags & (uint)ClrDataValueFlag.IS_ARRAY) == 0) throw new ArgumentException(); + ThrowIfLocationReadFailed(); IRuntimeTypeSystem rts = _target.Contracts.RuntimeTypeSystem; ITypeHandle arrayType = rts.GetTypeHandle(_target.Contracts.Object.GetMethodTableAddress(_baseAddress)); rts.IsArray(arrayType, out uint arrayRank); @@ -645,6 +654,7 @@ int IXCLRDataValue.GetArrayElement(uint numInd, [In, MarshalUsing(CountElementNa if ((_flags & (uint)ClrDataValueFlag.IS_ARRAY) == 0) throw new ArgumentException(); + ThrowIfLocationReadFailed(); IRuntimeTypeSystem rts = _target.Contracts.RuntimeTypeSystem; ITypeHandle arrayType = rts.GetTypeHandle(_target.Contracts.Object.GetMethodTableAddress(_baseAddress)); if (!rts.IsArray(arrayType, out uint rank) || numInd != rank) @@ -984,6 +994,7 @@ private ClrDataValue CreateFieldValue( } else { + ThrowIfLocationReadFailed(); uint offset = rts.GetFieldDescOffset(fieldDesc, fieldDefinition); ulong objectOffset = rts.IsValueType(enclosingType) ? 0 : (ulong)_target.PointerSize; address = new TargetPointer(checked(_baseAddress + objectOffset + offset)); @@ -1153,6 +1164,18 @@ int IXCLRDataValue.GetNumLocations(uint* numLocs) return hr; } + private void ThrowIfLocationReadFailed() + { + foreach (NativeVarLocation location in _locations) + ThrowIfLocationReadFailed(location); + } + + private static void ThrowIfLocationReadFailed(NativeVarLocation location) + { + if (location.HasReadFailure) + throw new VirtualReadException("The variable's indirect location could not be read."); + } + int IXCLRDataValue.GetLocationByIndex(uint loc, uint* flags, ClrDataAddress* arg) { using Lock.Scope scope = _apiLock.EnterScope(); @@ -1166,6 +1189,8 @@ int IXCLRDataValue.GetLocationByIndex(uint loc, uint* flags, ClrDataAddress* arg throw new ArgumentException(); NativeVarLocation location = _locations[loc]; + ThrowIfLocationReadFailed(location); + *flags = location.IsRegisterValue ? ClrDataVLocFlag.CLRDATA_VLOC_REGISTER : ClrDataVLocFlag.CLRDATA_VLOC_MEMORY; *arg = location.IsRegisterValue ? 0 : location.AddressOrValue; } diff --git a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Legacy/Dbi/DacDbiImpl.NativeCodeInfo.cs b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Legacy/Dbi/DacDbiImpl.NativeCodeInfo.cs index 3db90d49d72b94..a6cc761e24bf14 100644 --- a/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Legacy/Dbi/DacDbiImpl.NativeCodeInfo.cs +++ b/src/native/managed/cdac/Microsoft.Diagnostics.DataContractReader.Legacy/Dbi/DacDbiImpl.NativeCodeInfo.cs @@ -54,6 +54,13 @@ internal static VarLoc ConvertToVarLoc(DebugVarInfo varInfo) (DebugVarLocKind.Register, false, false) => VarLocType.VLT_REG, (DebugVarLocKind.Register, false, true) => VarLocType.VLT_REG_FP, (DebugVarLocKind.Register, true, _) => VarLocType.VLT_REG_BYREF, + // WASM locals are reported to DacDbi in their original ICorDebugInfo form. VarLoc is a + // mirror of ICorDebugInfo::VarLoc, and the JIT encodes a WASM local as VLT_REG whose + // register number is the local index biased by WASM_LOCAL_REGNUM_BASE. Preserving that keeps + // parity with the native DAC, which passes the JIT's encoding through untouched. + (DebugVarLocKind.WasmLocal, false, _) => VarLocType.VLT_REG, + (DebugVarLocKind.WasmLocal, true, _) => VarLocType.VLT_REG_BYREF, + (DebugVarLocKind.WasmLocalPair, _, _) => VarLocType.VLT_REG_REG, (DebugVarLocKind.Stack, false, _) => VarLocType.VLT_STK, (DebugVarLocKind.Stack, true, _) => VarLocType.VLT_STK_BYREF, (DebugVarLocKind.RegisterRegister, _, _) => VarLocType.VLT_REG_REG, @@ -68,6 +75,7 @@ internal static VarLoc ConvertToVarLoc(DebugVarInfo varInfo) switch (varInfo.Kind) { case DebugVarLocKind.Register: + case DebugVarLocKind.WasmLocal: loc.vlrReg = varInfo.Register; break; case DebugVarLocKind.Stack: @@ -75,6 +83,7 @@ internal static VarLoc ConvertToVarLoc(DebugVarInfo varInfo) loc.vlsOffset = varInfo.StackOffset; break; case DebugVarLocKind.RegisterRegister: + case DebugVarLocKind.WasmLocalPair: loc.vlrrReg1 = varInfo.Register; loc.vlrrReg2 = varInfo.Register2; break; diff --git a/src/native/managed/cdac/tests/UnitTests/WasmDebugInfoTests.cs b/src/native/managed/cdac/tests/UnitTests/WasmDebugInfoTests.cs new file mode 100644 index 00000000000000..9c14d31ee131a3 --- /dev/null +++ b/src/native/managed/cdac/tests/UnitTests/WasmDebugInfoTests.cs @@ -0,0 +1,661 @@ +// 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 System.Linq; +using System.Threading; +using ILCompiler.Reflection.ReadyToRun; +using Microsoft.Diagnostics.DataContractReader.Contracts; +using Microsoft.Diagnostics.DataContractReader.Contracts.StackWalkHelpers; +using Microsoft.Diagnostics.DataContractReader.Legacy; +using Microsoft.Diagnostics.DataContractReader.TestInfrastructure; +using Moq; +using Xunit; + +namespace Microsoft.Diagnostics.DataContractReader.Tests; + +/// +/// Tests for decoding and resolving WASM variable debug information. +/// +/// +/// On WASM a debug-info "register" is not a register. RyuJIT records a wasm local index biased +/// past the reserved register numbers (ICorDebugInfo::WASM_LOCAL_REGNUM_BASE in +/// src/coreclr/inc/cordebuginfo.h), and the resulting wasm local is engine-private +/// frame state that is not in linear memory. exposes no indexed +/// register file, so a naive resolution reports the value 0 for every variable. +/// +public unsafe class WasmDebugInfoTests +{ + // ICorDebugInfo::WASM_LOCAL_REGNUM_BASE, spelled independently of the reader's constant so + // that a reader-side drift is caught. + private const uint WasmLocalRegNumBase = 3; + + // The base register RyuJIT emits for every WASM stack location. REG_FPBASE and REG_SPBASE are + // both REG_NA on WASM (targetwasm.h), REG_NA is REG_COUNT which is 2 because registerwasm.h + // defines only REG_STK, and ICorDebugInfo::REGNUM_AMBIENT_SP is also 2 (cordebuginfo.h). + private const uint JitEmittedWasmStackBaseRegister = 2; + + private static uint EncodeWasmRegister(uint index) + => index + WasmLocalRegNumBase; + + private static uint? DecodeWasmRegister(uint register) + => DebugInfoHelpers.DecodeWasmRegister(register); + + private static Target CreateTarget(RuntimeInfoArchitecture targetArch, bool is64Bit) + { + TestPlaceholderTarget.Builder builder = new( + new MockTarget.Architecture { IsLittleEndian = true, Is64Bit = is64Bit }); + builder + .AddGlobalStrings((Constants.Globals.Architecture, targetArch.ToString().ToLowerInvariant())) + .AddContract(version: "c1"); + + return builder.Build(); + } + + public static TheoryData WasmLocalIndices() => new() { 0u, 1u, 4u, 6u }; + + /// + /// A WASM local must not resolve to a fabricated location. Before this guard existed, + /// ReadRegister fell through to return 0 for every WASM local register, + /// silently reporting the value 0 as though it had been read successfully. + /// + [Theory] + [MemberData(nameof(WasmLocalIndices))] + public void ResolveVarLocation_WasmRegister_ReportsNoLocation(uint localIndex) + { + Target target = CreateTarget(RuntimeInfoArchitecture.Wasm, is64Bit: false); + IPlatformAgnosticContext context = IPlatformAgnosticContext.GetContextForPlatform(target); + + uint register = EncodeWasmRegister(localIndex); + + // Both the projected kind produced by the contract on WASM and the raw architecture-neutral + // kind must refuse to resolve. + foreach (DebugVarLocKind kind in new[] { DebugVarLocKind.WasmLocal, DebugVarLocKind.Register }) + { + DebugVarInfo varInfo = new() + { + StartOffset = 0, + EndOffset = 0x100, + VarNumber = 0, + Kind = kind, + Register = register, + WasmLocal = DecodeWasmRegister(register), + }; + + Assert.Empty(ClrDataFrame.ResolveVarLocation(varInfo, context, target)); + } + } + + /// + /// A WASM stack location whose base register is neither a decodable WASM local nor the WASM + /// frame-base sentinel has no frame pointer to resolve against and must not produce an address. + /// + [Theory] + [InlineData(0u)] // REGNUM_PC + [InlineData(1u)] // REGNUM_COUNT + public void ResolveVarLocation_WasmStackWithNonLocalBase_ReportsNoLocation(uint baseRegister) + { + Target target = CreateTarget(RuntimeInfoArchitecture.Wasm, is64Bit: false); + IPlatformAgnosticContext context = IPlatformAgnosticContext.GetContextForPlatform(target); + + DebugVarInfo varInfo = new() + { + StartOffset = 0, + EndOffset = 0x100, + VarNumber = 0, + Kind = DebugVarLocKind.Stack, + BaseRegister = baseRegister, + StackOffset = 0x10, + }; + + Assert.Empty(ClrDataFrame.ResolveVarLocation(varInfo, context, target)); + } + + /// + /// The WASM guard must be scoped to WASM. Architectures with a real register file keep + /// resolving register locations exactly as before. + /// + [Fact] + public void ResolveVarLocation_NonWasmRegister_StillResolves() + { + Target target = CreateTarget(RuntimeInfoArchitecture.X64, is64Bit: true); + IPlatformAgnosticContext context = IPlatformAgnosticContext.GetContextForPlatform(target); + + DebugVarInfo varInfo = new() + { + StartOffset = 0, + EndOffset = 0x100, + VarNumber = 0, + Kind = DebugVarLocKind.Register, + Register = 0, + }; + + NativeVarLocation[] locations = ClrDataFrame.ResolveVarLocation(varInfo, context, target); + + NativeVarLocation location = Assert.Single(locations); + Assert.True(location.IsRegisterValue); + } + + public static TheoryData DecodableRegisters() => new() + { + // The first register past REGNUM_AMBIENT_SP is local $0. + { 3u, 0u }, + { 4u, 1u }, + { 7u, 4u }, + // The JIT's previous packed (local, type) form for $1 (i32) is now an ordinary biased + // index; it must not be unregister. + { 0x20000001u, 0x1FFFFFFEu }, + { uint.MaxValue, uint.MaxValue - 3u }, + }; + + [Theory] + [MemberData(nameof(DecodableRegisters))] + public void DecodeWasmRegister_RemovesRegNumBias(uint register, uint expectedIndex) + { + uint? local = DecodeWasmRegister(register); + + Assert.NotNull(local); + Assert.Equal(expectedIndex, local); + } + + /// + /// The reserved register numbers below WASM_LOCAL_REGNUM_BASE are pseudo-registers and + /// must not decode as locals. + /// + [Theory] + [InlineData(0u)] // REGNUM_PC + [InlineData(1u)] // REGNUM_COUNT + [InlineData(2u)] // REGNUM_AMBIENT_SP + public void DecodeWasmRegister_ReservedValues_ReturnNull(uint register) + { + Assert.Null(DecodeWasmRegister(register)); + } + + /// + /// A register-only location whose register is a reserved pseudo-register names no local, so + /// the reader must reject it rather than report it as local $0 or a readable register. + /// + [Theory] + [InlineData(0u)] + [InlineData(1u)] + [InlineData(2u)] + public void DoVars_WasmReservedRegister_Throws(uint register) + { + byte[] encoded = EncodeNibbleUInts( + 1, + unchecked(0u - MaxILNum), + 0, + 1, + VLT_REG, + register); + NativeReader reader = new(new MemoryStream(encoded)); + + Assert.Throws( + () => DebugInfoHelpers.DoVars(reader, isX86: false, isWasm: true).ToList()); + } + + private const uint MaxILNum = unchecked((uint)-6); + + // VarLocType values from ICorDebugInfo (cordebuginfo.h), in declaration order. + private const uint VLT_REG = 0; + private const uint VLT_STK = 3; + private const uint VLT_REG_REG = 5; + + /// + /// A vars blob holding three entries: a variable in a single WASM local, a variable spanning + /// two WASM locals, and a stack slot based off the logical frame pointer. + /// + private static byte[] EncodeWasmVarsBlob() => EncodeNibbleUInts( + 3, + // var 0: [0x00, 0x10) in WASM local $4 + unchecked(0u - MaxILNum), 0x00, 0x10, VLT_REG, EncodeWasmRegister(4), + // var 1: [0x10, 0x30) spanning WASM locals $7 and $8 + unchecked(1u - MaxILNum), 0x10, 0x20, VLT_REG_REG, EncodeWasmRegister(7), EncodeWasmRegister(8), + // var 2: [0x30, 0x40) at [logical FP + 0x18]; WASM stack bases encode as register 2. + // The signed offset is encoded with the sign in bit 0. + unchecked(2u - MaxILNum), 0x30, 0x10, VLT_STK, JitEmittedWasmStackBaseRegister, 0x18 << 1); + + [Fact] + public void DoVars_Wasm_PromotesKindsAndDecodesLocals() + { + NativeReader reader = new(new MemoryStream(EncodeWasmVarsBlob())); + List result = new(DebugInfoHelpers.DoVars(reader, isX86: false, isWasm: true)); + + Assert.Collection( + result, + varInfo => + { + Assert.Equal(DebugVarLocKind.WasmLocal, varInfo.Kind); + Assert.NotNull(varInfo.WasmLocal); + Assert.Equal(4u, varInfo.WasmLocal); + Assert.Equal(EncodeWasmRegister(4), varInfo.Register); + }, + varInfo => + { + Assert.Equal(DebugVarLocKind.WasmLocalPair, varInfo.Kind); + Assert.NotNull(varInfo.WasmLocal); + Assert.NotNull(varInfo.WasmLocal2); + Assert.Equal(7u, varInfo.WasmLocal); + Assert.Equal(8u, varInfo.WasmLocal2); + }, + varInfo => + { + // Stack slots live in linear memory, so the kind and encoded base are unchanged. + Assert.Equal(DebugVarLocKind.Stack, varInfo.Kind); + Assert.Null(varInfo.WasmLocal); + Assert.Equal(JitEmittedWasmStackBaseRegister, varInfo.BaseRegister); + Assert.Equal(0x18, varInfo.StackOffset); + }); + } + + /// + /// The identical blob decoded as a non-WASM target must keep the architecture-neutral kinds + /// and decode no locals, so the projection cannot leak into other architectures. + /// + [Fact] + public void DoVars_NonWasm_LeavesKindsAndLocalsAlone() + { + NativeReader reader = new(new MemoryStream(EncodeWasmVarsBlob())); + List result = new(DebugInfoHelpers.DoVars(reader, isX86: false)); + + Assert.Collection( + result, + varInfo => + { + Assert.Equal(DebugVarLocKind.Register, varInfo.Kind); + Assert.Null(varInfo.WasmLocal); + }, + varInfo => + { + Assert.Equal(DebugVarLocKind.RegisterRegister, varInfo.Kind); + Assert.Null(varInfo.WasmLocal); + Assert.Null(varInfo.WasmLocal2); + }, + varInfo => + { + Assert.Equal(DebugVarLocKind.Stack, varInfo.Kind); + }); + } + + /// + /// DacDbi's VarLoc mirrors ICorDebugInfo::VarLoc, where a WASM local is encoded + /// as VLT_REG whose register number is the biased local index. + /// The projected WASM kinds must therefore round-trip to the original ICorDebugInfo form + /// rather than degrading to VLT_INVALID, which would lose the local index entirely. + /// + [Fact] + public void ConvertToVarLoc_WasmLocal_RoundTripsToRegisterForms() + { + uint register = EncodeWasmRegister(4); + uint register2 = EncodeWasmRegister(5); + + VarLoc single = DacDbiImpl.ConvertToVarLoc(new DebugVarInfo + { + Kind = DebugVarLocKind.WasmLocal, + Register = register, + WasmLocal = DecodeWasmRegister(register), + }); + Assert.Equal(VarLocType.VLT_REG, single.vlType); + Assert.Equal(register, single.vlrReg); + + VarLoc byref = DacDbiImpl.ConvertToVarLoc(new DebugVarInfo + { + Kind = DebugVarLocKind.WasmLocal, + IsByRef = true, + Register = register, + WasmLocal = DecodeWasmRegister(register), + }); + Assert.Equal(VarLocType.VLT_REG_BYREF, byref.vlType); + Assert.Equal(register, byref.vlrReg); + + VarLoc pair = DacDbiImpl.ConvertToVarLoc(new DebugVarInfo + { + Kind = DebugVarLocKind.WasmLocalPair, + Register = register, + Register2 = register2, + WasmLocal = DecodeWasmRegister(register), + WasmLocal2 = DecodeWasmRegister(register2), + }); + Assert.Equal(VarLocType.VLT_REG_REG, pair.vlType); + Assert.Equal(register, pair.vlrrReg1); + Assert.Equal(register2, pair.vlrrReg2); + } + + /// + /// The success path: a WASM stack slot resolves to logical frame pointer + StackOffset + /// in linear memory. + /// + [Theory] + [InlineData(0x18, false)] + [InlineData(0x00, false)] + [InlineData(0x18, true)] + public void ResolveVarLocation_WasmStack_ResolvesAgainstLogicalFramePointer(int stackOffset, bool isDoubleStack) + { + const ulong FrameAddress = 0x0020_0000; + + Target target = CreateTarget(RuntimeInfoArchitecture.Wasm, is64Bit: false); + IPlatformAgnosticContext context = IPlatformAgnosticContext.GetContextForPlatform(target); + context.StackPointer = new TargetPointer(FrameAddress); + context.FramePointer = new TargetPointer(FrameAddress); + + // The base register RyuJIT actually emits for a WASM stack slot: REG_FPBASE / REG_SPBASE + // are REG_NA (== REG_COUNT == 2), and REGNUM_AMBIENT_SP is also 2. + DebugVarInfo varInfo = new() + { + StartOffset = 0, + EndOffset = 0x100, + VarNumber = 0, + Kind = isDoubleStack ? DebugVarLocKind.DoubleStack : DebugVarLocKind.Stack, + BaseRegister = JitEmittedWasmStackBaseRegister, + StackOffset = stackOffset, + }; + + NativeVarLocation location = Assert.Single(ClrDataFrame.ResolveVarLocation(varInfo, context, target)); + + Assert.False(location.IsRegisterValue); + Assert.Equal(FrameAddress + (ulong)stackOffset, location.AddressOrValue); + Assert.Equal(isDoubleStack ? 8u : 4u, location.Size); + } + + /// + /// For a funclet frame the context's frame pointer is the establishing method's frame, not the + /// funclet's own frame base, so the resolved address must use the frame pointer rather than SP. + /// + [Fact] + public void ResolveVarLocation_WasmStackInFunclet_ResolvesAgainstEstablishingFrame() + { + const ulong FuncletFrame = 0x0020_0000; + const ulong EstablishingFramePointer = 0x0020_0100; + const int StackOffset = 0x18; + + Target target = CreateTarget(RuntimeInfoArchitecture.Wasm, is64Bit: false); + IPlatformAgnosticContext context = IPlatformAgnosticContext.GetContextForPlatform(target); + context.StackPointer = new TargetPointer(FuncletFrame); + context.FramePointer = new TargetPointer(EstablishingFramePointer); + + DebugVarInfo varInfo = new() + { + StartOffset = 0, + EndOffset = 0x100, + VarNumber = 0, + Kind = DebugVarLocKind.Stack, + BaseRegister = JitEmittedWasmStackBaseRegister, + StackOffset = StackOffset, + }; + + NativeVarLocation location = Assert.Single(ClrDataFrame.ResolveVarLocation(varInfo, context, target)); + + Assert.False(location.IsRegisterValue); + Assert.Equal(EstablishingFramePointer + StackOffset, location.AddressOrValue); + Assert.NotEqual(FuncletFrame + StackOffset, location.AddressOrValue); + } + + /// + /// Characterizes the pre-existing behavior that the WASM consumer + /// relies on: a symbolic engine local resolves to no native locations, and reads from that + /// empty value fail rather than returning a plausible zero. + /// + [Fact] + public unsafe void ClrDataValue_NoLocations_FailsInsteadOfReturningZero() + { + Target target = CreateTarget(RuntimeInfoArchitecture.Wasm, is64Bit: false); + + ClrDataValue value = new( + target, + TargetPointer.Null, + flags: (uint)ClrDataValueFlag.IS_PRIMITIVE, + typeHandle: null, + baseAddress: TargetPointer.Null, + locations: [], + legacyImpl: null, + apiLock: new Lock()); + + IXCLRDataValue dataValue = value; + + uint numLocs; + Assert.Equal(HResults.S_OK, dataValue.GetNumLocations(&numLocs)); + Assert.Equal(0u, numLocs); + + // GetBytes must not succeed with a zero-filled buffer. + byte[] buffer = new byte[8]; + uint dataSize; + int hr; + fixed (byte* pBuffer = buffer) + { + hr = dataValue.GetBytes((uint)buffer.Length, &dataSize, pBuffer); + } + Assert.True(hr < 0, $"GetBytes should fail for a value with no locations, got 0x{hr:X8}"); + + // GetAddress must not report address 0 as though it were a real address. + ClrDataAddress address; + Assert.True(dataValue.GetAddress(&address) < 0, "GetAddress should fail for a value with no locations"); + } + + [Fact] + public void ResolveVarLocation_WasmStackByRefUnreadable_PreservesFailedLocation() + { + const ulong FrameAddress = 0x0020_0000; + Target target = CreateWasmTargetWithZeroPage(); + IPlatformAgnosticContext context = IPlatformAgnosticContext.GetContextForPlatform(target); + context.FramePointer = new TargetPointer(FrameAddress); + + DebugVarInfo varInfo = new() + { + Kind = DebugVarLocKind.Stack, + IsByRef = true, + BaseRegister = JitEmittedWasmStackBaseRegister, + StackOffset = 0x24, + }; + + NativeVarLocation location = Assert.Single(ClrDataFrame.ResolveVarLocation(varInfo, context, target)); + + Assert.True(location.HasReadFailure); + Assert.False(location.IsRegisterValue); + Assert.Equal(0u, location.AddressOrValue); + Assert.Equal(4u, location.Size); + } + + [Fact] + public unsafe void ClrDataValue_WasmStackByRefUnreadable_FailsWithoutBecomingZero() + { + const ulong FrameAddress = 0x0020_0000; + Target target = CreateWasmTargetWithZeroPage(); + IPlatformAgnosticContext context = IPlatformAgnosticContext.GetContextForPlatform(target); + context.FramePointer = new TargetPointer(FrameAddress); + DebugVarInfo varInfo = new() + { + Kind = DebugVarLocKind.Stack, + IsByRef = true, + BaseRegister = JitEmittedWasmStackBaseRegister, + StackOffset = 0x24, + }; + NativeVarLocation[] locations = ClrDataFrame.ResolveVarLocation(varInfo, context, target); + + IXCLRDataValue dataValue = new ClrDataValue( + target, + TargetPointer.Null, + flags: (uint)ClrDataValueFlag.IS_REFERENCE, + typeHandle: new TargetTypeHandle(new TargetPointer(0x0030_0000)), + baseAddress: TargetPointer.Null, + locations, + legacyImpl: null, + apiLock: new Lock()); + + uint numLocs; + Assert.Equal(HResults.S_OK, dataValue.GetNumLocations(&numLocs)); + Assert.Equal(1u, numLocs); + + byte[] buffer = [0xCC, 0xCC, 0xCC, 0xCC]; + uint dataSize = 0; + int hr; + fixed (byte* pBuffer = buffer) + { + hr = dataValue.GetBytes((uint)buffer.Length, &dataSize, pBuffer); + } + Assert.Equal(CorDbgHResults.CORDBG_E_READVIRTUAL_FAILURE, hr); + Assert.Equal(new byte[] { 0xCC, 0xCC, 0xCC, 0xCC }, buffer); + + ClrDataAddress address; + Assert.Equal(CorDbgHResults.CORDBG_E_READVIRTUAL_FAILURE, dataValue.GetAddress(&address)); + + uint flags; + ClrDataAddress location; + Assert.Equal( + CorDbgHResults.CORDBG_E_READVIRTUAL_FAILURE, + dataValue.GetLocationByIndex(0, &flags, &location)); + + DacComNullableByRef associatedValue = new(isNullRef: false); + Assert.Equal( + CorDbgHResults.CORDBG_E_READVIRTUAL_FAILURE, + dataValue.GetAssociatedValue(associatedValue)); + } + + [Fact] + public unsafe void ClrDataValue_WasmStackNullReference_RemainsReadable() + { + const ulong FrameAddress = 0x0020_0000; + const int StackOffset = 0x24; + const ulong SlotAddress = 0x0020_0024; + Target target = CreateWasmTargetWithZeroPage( + new MockMemorySpace.HeapFragment + { + Address = SlotAddress, + Data = new byte[4], + Name = "null reference slot", + }); + IPlatformAgnosticContext context = IPlatformAgnosticContext.GetContextForPlatform(target); + context.FramePointer = new TargetPointer(FrameAddress); + DebugVarInfo varInfo = new() + { + Kind = DebugVarLocKind.Stack, + BaseRegister = JitEmittedWasmStackBaseRegister, + StackOffset = StackOffset, + }; + NativeVarLocation[] locations = ClrDataFrame.ResolveVarLocation(varInfo, context, target); + + IXCLRDataValue dataValue = new ClrDataValue( + target, + TargetPointer.Null, + flags: (uint)ClrDataValueFlag.IS_REFERENCE, + typeHandle: null, + baseAddress: new TargetPointer(SlotAddress), + locations, + legacyImpl: null, + apiLock: new Lock()); + + uint numLocs; + Assert.Equal(HResults.S_OK, dataValue.GetNumLocations(&numLocs)); + Assert.Equal(1u, numLocs); + + byte[] buffer = [0xCC, 0xCC, 0xCC, 0xCC]; + uint dataSize; + fixed (byte* pBuffer = buffer) + { + Assert.Equal(HResults.S_OK, dataValue.GetBytes((uint)buffer.Length, &dataSize, pBuffer)); + } + Assert.Equal(4u, dataSize); + Assert.Equal(new byte[4], buffer); + + ClrDataAddress address; + Assert.Equal(HResults.S_OK, dataValue.GetAddress(&address)); + Assert.Equal(SlotAddress, (ulong)address); + } + + [Fact] + public unsafe void ClrDataValue_WasmStackByRefNullPointer_FailsWithoutReadingZeroPage() + { + const ulong FrameAddress = 0x0020_0000; + const int StackOffset = 0x24; + Target target = CreateWasmTargetWithZeroPage( + new MockMemorySpace.HeapFragment + { + Address = FrameAddress + StackOffset, + Data = new byte[4], + Name = "null byref pointer slot", + }); + IPlatformAgnosticContext context = IPlatformAgnosticContext.GetContextForPlatform(target); + context.FramePointer = new TargetPointer(FrameAddress); + DebugVarInfo varInfo = new() + { + Kind = DebugVarLocKind.Stack, + IsByRef = true, + BaseRegister = JitEmittedWasmStackBaseRegister, + StackOffset = StackOffset, + }; + NativeVarLocation[] locations = ClrDataFrame.ResolveVarLocation(varInfo, context, target); + Assert.True(Assert.Single(locations).HasReadFailure); + + IXCLRDataValue dataValue = new ClrDataValue( + target, + TargetPointer.Null, + flags: (uint)ClrDataValueFlag.IS_REFERENCE, + typeHandle: null, + baseAddress: TargetPointer.Null, + locations, + legacyImpl: null, + apiLock: new Lock()); + + byte[] buffer = [0xCC, 0xCC, 0xCC, 0xCC]; + uint dataSize; + fixed (byte* pBuffer = buffer) + { + Assert.Equal( + CorDbgHResults.CORDBG_E_READVIRTUAL_FAILURE, + dataValue.GetBytes((uint)buffer.Length, &dataSize, pBuffer)); + } + Assert.Equal(new byte[] { 0xCC, 0xCC, 0xCC, 0xCC }, buffer); + } + + private static Target CreateWasmTargetWithZeroPage(params MockMemorySpace.HeapFragment[] fragments) + { + TestPlaceholderTarget.Builder builder = new( + new MockTarget.Architecture { IsLittleEndian = true, Is64Bit = false }); + builder + .AddGlobalStrings((Constants.Globals.Architecture, "wasm")) + .AddContract(version: "c1"); + builder.MemoryBuilder.AddHeapFragment( + new MockMemorySpace.HeapFragment + { + Address = 0, + Data = new byte[64], + Name = "readable wasm zero page", + }); + builder.MemoryBuilder.AddHeapFragments(fragments); + return builder.Build(); + } + + /// + /// Nibble encoder matching CoreCLR's NibbleWriter::WriteEncodedU32: three value bits per + /// nibble, high bit set on every nibble but the last, most significant group first, low nibble + /// of each byte used before the high nibble. + /// + private static byte[] EncodeNibbleUInts(params uint[] values) + { + List nibbles = new(); + Span groups = stackalloc byte[11]; + foreach (uint value in values) + { + int groupCount = 0; + uint remaining = value; + do + { + groups[groupCount++] = (byte)(remaining & 7); + remaining >>= 3; + } + while (remaining != 0); + + for (int i = groupCount - 1; i >= 0; i--) + { + byte continuation = i == 0 ? (byte)0 : (byte)8; + nibbles.Add((byte)(groups[i] | continuation)); + } + } + + byte[] bytes = new byte[(nibbles.Count + 1) / 2]; + for (int i = 0; i < nibbles.Count; i++) + bytes[i / 2] |= (byte)(nibbles[i] << (4 * (i & 1))); + return bytes; + } +}