Skip to content

[wasm][R2R] 64-bit JS interop values (long/BigInt, double) corrupted - marshaler buffer not 8-byte aligned for HEAP64 #129802

Description

@pavelsavara

Summary

On the WebAssembly CoreCLR + ReadyToRun (crossgen2-wasm) configuration, all 64-bit JS interop values are corrupted when crossing the managed↔JS boundary. long/BigInt64, double, DateTime/DateTimeOffset and Int52 arguments and return values lose their high 32 bits in a very specific way:

corrupted = (original & 0xFFFFFFFF) << 32

i.e. the low 32 bits are moved into the high word of the 8-byte slot and the low word becomes zero. 32-bit types (int, float, char, bool, handles, nint) are not affected.

The same tests pass on main (CoreCLR + browser + interpreter, no R2R) — verified on CI: the full System.Runtime.InteropServices.JavaScript.Tests suite runs with 0 failures on the Chrome lane. So this is an R2R-only wasm codegen problem, not a pre-existing interpreter bug.

Motivation / expectations

.NET long is marshaled to/from JavaScript BigInt (via [JSMarshalAs<JSType.BigInt>]), and the managed side stores the value into a JSMarshalerArgument slot whose Int64Value field is documented as:

[FieldOffset(0)]
internal long Int64Value; // must be aligned to 8 because of HEAPI64 alignment

The JS side reads/writes that slot through the 8-byte heap views:

// getHeapI64Big / setHeapI64Big
HEAP64[<byteAddress> >>> 3]      // BigInt64Array view  -> requires 8-byte alignment

The contract is that a long/BigInt round-trips losslessly: EchoBigInt64(x) == x for every x in [long.MinValue, long.MaxValue]. Under R2R this contract is violated for every value whose high 32 bits are non-zero.

Observed behavior (real repro)

JSImport echo of a BigInt64 through an identity JS function:

// Program.cs
public partial class Test
{
    public static int Main()
    {
        int f = 0;
        f += Check(-1L,                 EchoBigInt64(-1L));
        f += Check(1L,                  EchoBigInt64(1L));
        f += Check(long.MaxValue,       EchoBigInt64(long.MaxValue));
        f += Check(long.MinValue,       EchoBigInt64(long.MinValue));
        f += Check(9007199254740991L,   EchoBigInt64(9007199254740991L));
        return f;
    }

    [JSImport("echoBigInt64", "main.mjs")]
    [return: JSMarshalAs<JSType.BigInt>]
    internal static partial long EchoBigInt64([JSMarshalAs<JSType.BigInt>] long value);

    static int Check(long expected, long actual)
    {
        bool ok = expected == actual;
        Console.WriteLine($"{(ok ? "PASS" : "FAIL")}: expected={expected} actual={actual}");
        return ok ? 0 : 1;
    }
}
// main.mjs
setModuleImports("main.mjs", { echoBigInt64: (value) => value });  // identity

Result under R2R:

input expected actual
-1 -1 4294967295
1 1 1 (fits in 32 bits)
long.MaxValue 9223372036854775807 4294967295
long.MinValue -9223372036854775808 0
9007199254740991 9007199254740991 4294967295

Instrumenting the JS callback shows the value arrives already corrupted, before the echo:

echoBigInt64 received: -4294967296   (0xFFFFFFFF00000000)   for input -1
echoBigInt64 received:  4294967296   (0x0000000100000000)   for input 1
echoBigInt64 received: -4294967296                          for input long.MaxValue
echoBigInt64 received:  0                                   for input long.MinValue

So the corruption is on the managed→JS argument path (and symmetrically on the JS→managed return path).

Root cause

The corruption pattern low32 << 32 is exactly what happens when an 8-byte value is read through HEAP64[byteAddr >>> 3] while byteAddr is 4 mod 8 (4-byte aligned but not 8-byte aligned): >>> 3 rounds the address down by 4 bytes, so JS reads [0,0,0,0, low32(value)].

The JSMarshalerArgument slot is Size = 32 (a multiple of 8), so each slot is 8-aligned relative to the buffer base. Therefore the buffer base itself is landing at a 4-mod-8 address under R2R. (In a trivial sample assembly, R2R aligns stackalloc and collection-expression spans to 8 correctly; the misalignment shows up for the real marshaler buffer in the generated JSImport stub frame / pinned local.)

Minimal, deterministic, JS-free reproduction of the mechanism

This forces a 4-mod-8 slot address and reads it both ways — the managed exact-address read is correct, the HEAP64-style aligned-down read reproduces the corruption byte-for-byte. It also shows that a 4-byte read at the same address is fine:

using System;
using System.Runtime.InteropServices;

public class Test
{
    [StructLayout(LayoutKind.Explicit, Size = 32)]
    private struct Slot { [FieldOffset(0)] public long Int64Value; }

    public static int Main()
    {
        int f = 0;
        foreach (long v in new[] { -1L, 1L, long.MaxValue, long.MinValue, 9007199254740991L })
            f += Check(v);
        return f;
    }

    private static unsafe int Check(long value)
    {
        Slot s = default; s.Int64Value = value;

        byte* raw = stackalloc byte[3 * 32 + 16];
        nint aligned8 = ((nint)raw + 7) & ~7;
        byte* misaligned = (byte*)(aligned8 + 4);     // force 4 mod 8

        var buffer = new Span<Slot>(misaligned, 3);
        buffer.Clear();
        buffer[2] = s;

        nint addr = (nint)(misaligned + 2 * 32);      // slot[2].Int64Value, FieldOffset 0
        long jsI64 = *(long*)(addr & ~7);             // HEAP64[addr>>>3]  (needs 8-align)
        int  jsI32 = *(int*) (addr & ~3);             // HEAP32[addr>>>2]  (needs 4-align)

        Console.WriteLine($"value=0x{value:X16}  i64(JS)=0x{jsI64:X16}  i32(JS)=0x{jsI32:X8}  " +
                          $"i64:{(jsI64 == value ? "PASS" : "FAIL")}  i32:{(jsI32 == (int)value ? "PASS" : "FAIL")}");
        return jsI64 == value ? 0 : 1;
    }
}

Output (slot at 4-mod-8):

value=0xFFFFFFFFFFFFFFFF  i64(JS)=0xFFFFFFFF00000000  i32(JS)=0xFFFFFFFF  i64:FAIL  i32:PASS
value=0x0000000000000001  i64(JS)=0x0000000100000000  i32(JS)=0x00000001  i64:FAIL  i32:PASS
value=0x7FFFFFFFFFFFFFFF  i64(JS)=0xFFFFFFFF00000000  i32(JS)=0xFFFFFFFF  i64:FAIL  i32:PASS
value=0x8000000000000000  i64(JS)=0x0000000000000000  i32(JS)=0x00000000  i64:FAIL  i32:PASS
value=0x001FFFFFFFFFFFFF  i64(JS)=0xFFFFFFFF00000000  i32(JS)=0xFFFFFFFF  i64:FAIL  i32:PASS

i32: PASS for all — a 4-mod-8 address is still 0-mod-4, so HEAP32/HEAPU32 reads are unaffected. Only the 8-byte heap views break.

Affected types

8-byte JS-interop marshaling, which routes through HEAP64/HEAPF64:

  • long / BigInt64 (incl. nullable, arrays, Span<long>)
  • double (incl. arrays, Span<double>)
  • DateTime / DateTimeOffset (marshaled as double)
  • Int52

32-bit types are unaffected.

Expected fix

Ensure the JSMarshalerArgument marshaler buffer (the pinned span / stack local built by the generated JSImport/JSExport stub) is 8-byte aligned under crossgen2-wasm R2R, so that HEAP64[addr >>> 3] addresses the intended slot. Equivalently, R2R wasm codegen should honor 8-byte alignment for stack/pinned locals that contain 8-byte-aligned fields.

Environment

  • dotnet/runtime WebAssembly CoreCLR + R2R (crossgen2-wasm) configuration.
  • Reproduces in Node/V8/Chrome; does not reproduce with the interpreter (R2R disabled).
  • main (interpreter) CI: System.Runtime.InteropServices.JavaScript.Tests Chrome lane = 0 failures.

category

  • area-Codegen-AOT-mono / wasm crossgen2 (R2R), area-System.Runtime.InteropServices.JavaScript

Note

This issue text was generated with the assistance of GitHub Copilot and reviewed before posting.

Metadata

Metadata

Assignees

Labels

arch-wasmWebAssembly architecturearea-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMIos-browserBrowser variant of arch-wasm

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions