Skip to content

Incorrect built-in Math constants in edge-runtime v1.74.3; Math.LN10 also wrong in v1.74.2 (linux/amd64) #723

Closed

Description

@raf-master

Bug report

Several native JavaScript Math.* constants return incorrect values inside supabase/edge-runtime:v1.74.3.

The issue reproduces with a single dependency-free TypeScript file. The equivalent probe returns the expected values in the official Deno 2.1.4 container.

Math.LN10 was also independently reproduced as incorrect in edge-runtime v1.74.2, including after deleting and cleanly pulling the same image digest.

Environment

  • Docker Desktop Server: 28.3.2
  • Docker operating system: Linux
  • Docker host architecture: x86_64
  • Kernel: 6.6.87.2-microsoft-standard-WSL2
  • Supabase CLI: 2.109.1
  • Control image: denoland/deno:2.1.4

Deno control:

  • Deno 2.1.4
  • V8 13.0.245.12-rusty
  • Target x86_64-unknown-linux-gnu

Edge Runtime v1.74.3:

  • Image: public.ecr.aws/supabase/edge-runtime:v1.74.3
  • Image ID: sha256:c52405002a890ca9fcf77978671c57f3a988e03174afb277f84ac65bc917013c
  • RepoDigest: public.ecr.aws/supabase/edge-runtime@sha256:c52405002a890ca9fcf77978671c57f3a988e03174afb277f84ac65bc917013c
  • Architecture: amd64
  • OS: linux
  • Created: 2026-07-31T04:09:03.225642475Z
  • Runtime reports:
    • edge-runtime 0.1.0
    • deno 2.1.4 (release, x86_64-unknown-linux-gnu)

Additional v1.74.2 evidence:

  • Image: public.ecr.aws/supabase/edge-runtime:v1.74.2
  • Digest: sha256:a82676277615aee03c4f288cbbbf68dedb5ba8693073e567ab8dbfdd11ba5d45
  • Architecture: amd64/linux

No application or Supabase environment variables were passed to the isolated v1.74.3 reproducer.

Minimal reproduction

Create <temp-dir>/index.ts:

function bits(value: number): string {
  const buffer = new ArrayBuffer(8);
  new DataView(buffer).setFloat64(0, value, false);

  return Array.from(new Uint8Array(buffer))
    .map((b) => b.toString(16).padStart(2, "0"))
    .join("");
}

const constants = {
  E: Math.E,
  LN2: Math.LN2,
  LN10: Math.LN10,
  LOG2E: Math.LOG2E,
  LOG10E: Math.LOG10E,
  PI: Math.PI,
  SQRT1_2: Math.SQRT1_2,
  SQRT2: Math.SQRT2,
};

Deno.serve(() =>
  Response.json(
    Object.fromEntries(
      Object.entries(constants).map(([name, value]) => [
        name,
        {
          value: value.toPrecision(17),
          bits: bits(value),
        },
      ]),
    ),
  ),
);

Replace <temp-dir> below with its absolute host path.

docker pull public.ecr.aws/supabase/edge-runtime:v1.74.3
docker run -d --rm --name edge-math-runtime-1743-repro --mount type=bind,source=<temp-dir>,target=/probe,readonly -p 127.0.0.1::9000 public.ecr.aws/supabase/edge-runtime:v1.74.3 start --main-service=/probe --port=9000 --policy=per_worker
docker port edge-math-runtime-1743-repro 9000/tcp
curl http://127.0.0.1:<edge-host-port>/
docker stop edge-math-runtime-1743-repro

No dependency installation is necessary.

Expected behavior

The built-in constants should match standard JavaScript/Deno values and IEEE-754 representations:

Constant Expected value Expected bits
Math.E 2.7182818284590451 4005bf0a8b145769
Math.LN2 0.69314718055994529 3fe62e42fefa39ef
Math.LN10 2.3025850929940459 40026bb1bbb55516
Math.LOG2E 1.4426950408889634 3ff71547652b82fe
Math.LOG10E 0.43429448190325182 3fdbcb7b1526e50e
Math.PI 3.1415926535897931 400921fb54442d18
Math.SQRT1_2 0.70710678118654757 3fe6a09e667f3bcd
Math.SQRT2 1.4142135623730951 3ff6a09e667f3bcd

Actual behavior

Edge Runtime v1.74.3 returns:

Constant Actual value Actual bits
Math.E 2.0000000000000000 4000000000000000
Math.LN2 0.0000000000000000 0000000000000000
Math.LN10 2.0000000000000000 4000000000000000
Math.LOG2E 1.0000000000000000 3ff0000000000000
Math.LOG10E 0.0000000000000000 0000000000000000
Math.PI 3.1415926535897931 400921fb54442d18
Math.SQRT1_2 0.70710678118654757 3fe6a09e667f3bcd
Math.SQRT2 1.4142135623730951 3ff6a09e667f3bcd

Control

Run the same index.ts with Deno 2.1.4:

docker run -d --rm --name edge-math-deno-control --mount type=bind,source=<temp-dir>,target=/probe,readonly -p 127.0.0.1::8000 denoland/deno:2.1.4 deno run --allow-net /probe/index.ts
docker port edge-math-deno-control 8000/tcp
curl http://127.0.0.1:<deno-host-port>/
docker stop edge-math-deno-control

The Deno control returns every expected value and bit pattern.

Additional evidence

With Edge Runtime v1.74.2:

  • Math.LN10 returned 2.0000000000000000
  • Actual bits: 4000000000000000
  • Expected bits: 40026bb1bbb55516

The v1.74.2 image was deleted and cleanly pulled again. The pulled image had the same expected digest and reproduced the same incorrect result.

This refutes a simple local image/cache corruption explanation.

Impact

Code performing mathematical operations may silently receive incorrect built-in constants.

Libraries that validate or use these constants can fail during module initialization or produce incorrect numerical results.

As one observed downstream example, decimal.js-light validates Math.LN10 during initialization and throws when the value does not match. The library is not required for this reproduction.

Notes

  • The reproducer contains only native JavaScript/TypeScript.
  • No external dependency is imported.
  • No database, secrets, application environment, or application code is involved.
  • The exact internal cause has not been established.
  • V8 snapshot corruption, serialization, Docker Desktop/WSL2 behavior, Windows-host specificity, or a deno_core/V8 issue remain hypotheses only.
  • Reproduction on a native Linux host has not yet been tested.

Activity

  1. raf-master commented on Aug 27, 2026

    @raf-master
    Author

    Additional reproduction: GitHub Actions Ubuntu runner

    I reproduced the same issue on an independent GitHub-hosted Ubuntu runner, outside my original Windows / WSL2 / Docker Desktop environment.

    Public reproducer repository:

    https://github.com/raf-master/edge-runtime-math-linux-control

    GitHub Actions run:

    https://github.com/raf-master/edge-runtime-math-linux-control/actions/runs/33109361743

    Environment:

    • GitHub-hosted runner
    • Ubuntu 24.04.4 LTS
    • Kernel: 6.17.0-1022-azure
    • Architecture: x86_64
    • Docker Engine: 28.0.4
    • Docker OS/Arch: linux/amd64

    Edge Runtime image:

    public.ecr.aws/supabase/edge-runtime:v1.74.3

    RepoDigest:

    sha256:c52405002a890ca9fcf77978671c57f3a988e03174afb277f84ac65bc917013c

    Control:

    denoland/deno:2.1.4

    The Deno 2.1.4 control returned all 8 expected values and IEEE-754 bit patterns.

    The Edge Runtime returned exactly the same incorrect values previously observed:

    Constant Expected Edge Runtime
    Math.E 2.7182818284590451 2.0000000000000000
    Math.LN2 0.69314718055994529 0.0000000000000000
    Math.LN10 2.3025850929940459 2.0000000000000000
    Math.LOG2E 1.4426950408889634 1.0000000000000000
    Math.LOG10E 0.43429448190325182 0.0000000000000000
    Math.PI 3.1415926535897931 correct
    Math.SQRT1_2 0.70710678118654757 correct
    Math.SQRT2 1.4142135623730951 correct

    The reproducer classified the result as:

    CASE_LINUX_FAIL_SAME

    This means the failure is not specific to my original Windows/WSL2/Docker Desktop host. It reproduces on an independent Ubuntu Linux VM using Docker Engine.

    This still does not establish the internal root cause, and I have not tested bare-metal Linux. V8 snapshot/serialization/deno_core remain hypotheses only.

  2. da1suk8 commented on Aug 29, 2026

    @da1suk8
    Contributor

    I can add three data points that narrow this down considerably.

    TL;DR: the corruption is specific to the linux/amd64 image, the five broken constants are exactly the five that V8 computes at heap-genesis time (vs. the three installed from literals / hardware sqrt), and in edge-runtime those genesis values are frozen into the binary at build time by the startup snapshot. So this looks like a corrupted build-time snapshot in the amd64 release lane, not a runtime or user-environment issue.

    1. The linux/arm64 variant of the same tag is clean

    I ran the unmodified reproducer from the OP against both platform variants of public.ecr.aws/supabase/edge-runtime:v1.74.3 (digest sha256:c52405002a890ca9fcf77978671c57f3a988e03174afb277f84ac65bc917013c, matching the digest cited above) on the same machine (Docker Desktop, Apple Silicon):

    variant execution result
    linux/arm64 native all 8 constants correct, bit-exact
    linux/amd64 emulated broken exactly as reported — same bit patterns (4000000000000000, 0000000000000000, 4000000000000000, 3ff0000000000000, 0000000000000000); PI/SQRT1_2/SQRT2 intact

    The emulated amd64 run reproduces bit-for-bit what was already measured on native x86_64 (including the GitHub-hosted runner repro above), so emulation isn't a factor; the arm64-native result is the new information.

    Additionally, building current main from source natively (macOS arm64, debug) yields all-correct values — the source itself looks fine.

    2. The broken/intact split matches V8's genesis code exactly

    Bit-level, each broken value is the true value truncated toward zero to an integer, as a float64: e→2.0, ln2→0.0, ln10→2.0, log2(e)→1.0, log10(e)→0.0. Exponents ≥ 1 survive as the truncated integer, values < 1 collapse to +0 — i.e. a clean float→integer→float roundtrip of the computed result, not a bit-masking pattern.

    V8 13.0.245.12 installs the Math constants like this (bootstrapper.cc#3679):

    double const kE = base::ieee754::exp(1.0);
    double const kPI = 3.1415926535897932;
    InstallConstant(isolate_, math, "E", factory->NewNumber(kE));
    InstallConstant(isolate_, math, "LN10", factory->NewNumber(base::ieee754::log(10.0)));
    InstallConstant(isolate_, math, "LN2", factory->NewNumber(base::ieee754::log(2.0)));
    InstallConstant(isolate_, math, "LOG10E", factory->NewNumber(base::ieee754::log10(kE)));
    InstallConstant(isolate_, math, "LOG2E", factory->NewNumber(base::ieee754::log2(kE)));
    InstallConstant(isolate_, math, "PI", factory->NewNumber(kPI));
    InstallConstant(isolate_, math, "SQRT1_2", factory->NewNumber(std::sqrt(0.5)));
    InstallConstant(isolate_, math, "SQRT2", factory->NewNumber(std::sqrt(2.0)));

    Broken set == the base::ieee754::* computations; intact set == the literal + hardware sqrt. That split is hard to explain by anything at request time, and easy to explain if the genesis-time computation (or its serialized result) was corrupted once.

    3. Genesis runs on the release builder, and its output ships in the binary

    edge-runtime bakes its own V8 startup snapshot at build time (crates/base/build.rs → deno_core::snapshot::create_snapshot), and workers boot from that snapshot. So Math.* in production reflects whatever the machine that built the release computed. This fits all the symptoms: stable across clean pulls of the same digest (it's inside the binary), differing between v1.74.2 (only LN10) and v1.74.3 (five constants), and independent of the user's environment.

    As for why only amd64: in .github/workflows/release.yml, the amd64 lane (publish_x86) builds with BuildKit GHA cache (cache-from/cache-to: type=gha) plus the Dockerfile's persistent cargo cache mounts (--mount=type=cache,target=…/target), while the arm64 lane (publish_arm) sets no-cache: true. Since cargo won't re-run build.rs while its outputs look fresh, a once-corrupted snapshot artifact in the cached target dir can keep being linked into subsequent releases — which would also explain the difference in degree between v1.74.2 and v1.74.3. What corrupted the ieee754 results in the first place I can't tell from outside; the caching explains the persistence and the arch asymmetry.

    Suggested next steps

    1. Rebuild the amd64 image with caches busted (as the arm lane already does) and compare — if a fresh build is clean, that both fixes the shipped images and confirms the poisoned-cache theory.
    2. Add a tripwire to crates/base/build.rs: after create_snapshot, boot a runtime from the fresh snapshot and assert the eight Math constants, so a corrupted snapshot can never ship again regardless of root cause.
      fix(build): verify Math constants in runtime snapshot #726
    3. To check whether the current CI cache is poisoned: run the x86 lane once with no-cache: true and compare the probe output against the current image.
  3. da1suk8 commented on Sep 29, 2026

    @da1suk8
    Contributor

    Root cause: the corruption lives in the default V8 startup snapshot embedded in the prebuilt x86_64 Linux librusty_v8 published by supabase/rusty_v8. edge-runtime never computes these constants; it inherits them.

    Why

    build.rs creates its snapshot with startup_snapshot: None. V8 13.0.245.12 then starts the SnapshotCreator from the library's embedded default blob (https://github.com/v8/v8/blob/13.0.245.12/src/snapshot/snapshot.cc#L898) and the context is deserialized, not built by genesis. Genesis, where Math.E is computed, only runs inside mksnapshot; forcing it elsewhere fails with Check failed: Heap setup supported only in mksnapshot. So every edge-runtime snapshot, isolate and image carries whatever mksnapshot produced when the V8 library itself was built.

    This retires my cache theory from August: the approved CI run for #726 shows the values are already wrong in a snapshot created fresh in that run.

    Evidence

    I extracted the generated snapshot.o from each published archive and searched it for the IEEE-754 bytes of the eight constants:

    archive E LN2 LN10 LOG2E LOG10E PI SQRT1_2 SQRT2
    denoland/rusty_v8 v130.0.7, x86_64 and aarch64 present present
    supabase/rusty_v8 v130.0.7 = v130.0.7-patch.1 (same sha1), x86_64 release and debug absent present
    supabase/rusty_v8, aarch64 present present

    The five absent values are exactly the ones V8 computes at genesis and boxes via Factory::NewNumber; absent double bytes plus the observed integers means they were stored as Smis (2, 0, 2, 1, 0).

    The code is not the difference: ieee754.o, bootstrapper.o, factory.o, the serializer objects and the mksnapshot-generated embedded.o are identical instruction for instruction between the fork's and upstream's x86_64 archives. Only the heap snapshot's numbers differ, so it is the environment that particular mksnapshot ran in. The fork has no CI build runs on either release date and its devcontainer is FROM --platform=linux/amd64; built on an Apple Silicon host, that means mksnapshot ran under x86 emulation. I could not reproduce that step, so this last part is inference.

    Affected range

    Every linux/amd64 build since #549 switched to the fork binaries (2025-06-18). The first stable tag is v1.69.0; v1.68.x is unaffected. Note the v8 crate downloads {RUSTY_V8_MIRROR}/v{CARGO_PKG_VERSION}/…, i.e. the assets of the fork's v130.0.7 release, whatever the git tag.

    Proposed fix

    1. Rebuild the x86_64 release and debug librusty_v8 from the fork on native x86_64 Linux (the fork's ci.yml already builds with V8_FROM_SOURCE=true on ubuntu-22.04) and publish. Upstream's archive is not a drop-in: the fork's V8 adds the thin-locker API used by binding.cc.
    2. Publish under a bumped crate version rather than replacing assets in place: the v8 crate skips the download when its cached checksum matches the URL, so an in-place replacement also needs every rust-cache busted.
    3. Verify the blob before publishing (script below). I'll open a small PR on supabase/rusty_v8 adding this as a CI step before "Binary publish", and can follow up with one that lets the fork's CI publish on tags if you want that.
    4. Keep fix(build): verify Math constants in runtime snapshot #726 as is; it goes green once the binary is fixed and guards against a repeat.
    How to reproduce the check (no build needed)
    curl -sLo up.a.gz   https://github.com/denoland/rusty_v8/releases/download/v130.0.7/librusty_v8_release_x86_64-unknown-linux-gnu.a.gz
    curl -sLo fork.a.gz https://github.com/supabase/rusty_v8/releases/download/v130.0.7/librusty_v8_release_x86_64-unknown-linux-gnu.a.gz
    gunzip up.a.gz fork.a.gz
    for f in up fork; do python3 scan.py $f.a; done
    

    scan.py (parses the GNU ar archive, takes the largest member named snapshot.o, i.e. the generated one defining DefaultSnapshotBlob):

    import struct, sys
    d = open(sys.argv[1], 'rb').read(); off = 8; longnames = b''; snaps = []
    while off + 60 <= len(d):
        h = d[off:off+60]; n = h[:16].decode().rstrip(); sz = int(h[48:58]); body = d[off+60:off+60+sz]
        if n == '//': longnames = body
        elif n not in ('/', '/SYM64/'):
            if n[0] == '/': i = int(n[1:]); n = longnames[i:longnames.index(b'/\n', i)].decode()
            if n.rstrip('/') == 'snapshot.o': snaps.append(body)
        off += 60 + sz + (sz & 1)
    blob = max(snaps, key=len)
    C = {'E': 2.718281828459045, 'LN2': 0.6931471805599453, 'LN10': 2.302585092994046,
         'LOG2E': 1.4426950408889634, 'LOG10E': 0.4342944819032518, 'PI': 3.141592653589793,
         'SQRT1_2': 0.7071067811865476, 'SQRT2': 1.4142135623730951}
    print(sys.argv[1], {k: struct.pack('<d', v) in blob for k, v in C.items()})
    

    Expected: up.a all True; fork.a False for E, LN2, LN10, LOG2E, LOG10E. For the code comparison, write any member (e.g. ieee754.o) out with the same parser and diff llvm-objdump -d --no-show-raw-insn --demangle output after stripping addresses; it is empty for every object named above.

    cc @satyarohith

  4. da1suk8 commented on Sep 29, 2026

    @da1suk8
    Contributor

    Opened a PR on supabase/rusty_v8 that adds this check to CI so a snapshot like this cannot be published again: supabase/rusty_v8#2

  5. da1suk8 commented on Oct 1, 2026

    @da1suk8
    Contributor

    Verified on the published linux/amd64 images with the probe from this issue: v1.77.1 still returns the truncated values, v1.77.2 returns all eight constants bit-exact.

    v1.77.1  E=2                  4000000000000000   LN2=0                    0000000000000000   LN10=2                   4000000000000000
    v1.77.2  E=2.718281828459045  4005bf0a8b145769   LN2=0.6931471805599453   3fe62e42fefa39ef   LN10=2.302585092994046   40026bb1bbb55516
    

    Correction to my earlier note: the original build was a cross-compile on an Intel Mac Pro, not emulation on Apple Silicon.

  6. da1suk8 commented on Oct 1, 2026

    @da1suk8
    Contributor

    Status from my side: the fixed librusty_v8 shipped in v1.77.2 (verified above), and the build-time guard is merged in #726. The remaining piece is the upstream check in supabase/rusty_v8#2, which is independent of this issue.

    I think this can be closed; leaving that to the maintainers.

  7. nyannyacha commented on Oct 1, 2026

    @nyannyacha
    Contributor

    Closing this as the issue was resolved in latest release.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions