Skip to content

JSON.parse returns incorrectly rounded numbers and rejects valid JSON (1e400, lone-surrogate escapes, nesting > 128) #47

Closed

Description

@hassidan

Test I ran

A script that parses decimal strings with JSON.parse and compares each result with Number() of the same string, then checks a few inputs that ECMA-262 requires JSON.parse to accept.

node json_parse.ts
slice run json_parse.ts
json_parse.ts
// JSON.parse conformance: compare each result with the engine's own Number() / expected ECMA-262 behaviour.
const row = (k: string, v: unknown) => console.log(k.padEnd(44) + v);
for (const s of ["90.33333333333333", "14.185190207411157", "0.0075350713764946314", "95319.50955061219"]) {
  const p = JSON.parse(s); row(`JSON.parse("${s}")`, `${p}  ${p === Number(s) ? "ok" : "WRONG (Number() gives " + Number(s) + ")"}`);
}
let bad = 0;
for (let i = 1; i <= 20000; i++) { const x = i / 3; if (JSON.parse(String(x)) !== x) bad++; }
row("i/3 for i=1..20000, parse(String(x)) !== x", `${bad} / 20000`);
bad = 0; let seed = 12345; const rnd = () => { seed = (seed * 1103515245 + 12345) & 0x7fffffff; return seed / 0x7fffffff; };
for (let i = 0; i < 20000; i++) { const x = rnd() * Math.pow(10, Math.floor(rnd() * 12) - 4); if (JSON.parse(String(x)) !== x) bad++; }
row("20000 pseudo-random doubles, same check", `${bad} / 20000`);
const data = Array.from({ length: 3000 }, (_, i) => ({ id: i, x: i / 3 }));
const text = JSON.stringify(data);
row("JSON.stringify(JSON.parse(text)) === text", JSON.stringify(JSON.parse(text)) === text);
const attempt = (label: string, src: string, show: (v: any) => unknown) => { try { row(label, show(JSON.parse(src))); } catch (e: any) { row(label, "THROWS " + e.name + ": " + e.message); } };
attempt('JSON.parse("1e400")', "1e400", (v) => v);
attempt('JSON.parse("-1e400")', "-1e400", (v) => v);
attempt('JSON.parse(\'"\\\\ud800"\') (lone surrogate escape)', '"\\ud800"', (v) => "length " + v.length + ", charCode 0x" + v.charCodeAt(0).toString(16));
attempt("JSON.parse of 127 nested arrays", "[".repeat(127) + "]".repeat(127), () => "ok");
attempt("JSON.parse of 129 nested arrays", "[".repeat(129) + "]".repeat(129), () => "ok");
attempt("JSON.parse of 2000 nested arrays", "[".repeat(2000) + "]".repeat(2000), () => "ok");

Result

Node v24.22.2:

JSON.parse("90.33333333333333")             90.33333333333333  ok
JSON.parse("14.185190207411157")            14.185190207411157  ok
JSON.parse("0.0075350713764946314")         0.0075350713764946314  ok
JSON.parse("95319.50955061219")             95319.50955061219  ok
i/3 for i=1..20000, parse(String(x)) !== x  0 / 20000
20000 pseudo-random doubles, same check     0 / 20000
JSON.stringify(JSON.parse(text)) === text   true
JSON.parse("1e400")                         Infinity
JSON.parse("-1e400")                        -Infinity
JSON.parse('"\\ud800"') (lone surrogate escape)length 1, charCode 0xd800
JSON.parse of 127 nested arrays             ok
JSON.parse of 129 nested arrays             ok
JSON.parse of 2000 nested arrays            ok

Slice v0.10.7:

JSON.parse("90.33333333333333")             90.33333333333331  WRONG (Number() gives 90.33333333333333)
JSON.parse("14.185190207411157")            14.185190207411155  WRONG (Number() gives 14.185190207411157)
JSON.parse("0.0075350713764946314")         0.007535071376494632  WRONG (Number() gives 0.0075350713764946314)
JSON.parse("95319.50955061219")             95319.5095506122  WRONG (Number() gives 95319.50955061219)
i/3 for i=1..20000, parse(String(x)) !== x  228 / 20000
20000 pseudo-random doubles, same check     1913 / 20000
JSON.stringify(JSON.parse(text)) === text   false
JSON.parse("1e400")                         THROWS SyntaxError: number out of range at line 1 column 5
JSON.parse("-1e400")                        THROWS SyntaxError: number out of range at line 1 column 6
JSON.parse('"\\ud800"') (lone surrogate escape)THROWS SyntaxError: unexpected end of hex escape at line 1 column 8
JSON.parse of 127 nested arrays             ok
JSON.parse of 129 nested arrays             THROWS SyntaxError: recursion limit exceeded at line 1 column 128
JSON.parse of 2000 nested arrays            THROWS SyntaxError: recursion limit exceeded at line 1 column 128

What is wrong

  1. Numbers are not correctly rounded. 1,913 of 20,000 pseudo-random doubles (9.6%) and 228 of the first 20,000 thirds come back 1–2 ULP away from the value Number() produces for the same text, so JSON.parse(JSON.stringify(x)) no longer round-trips. Any number with ~16–17 significant digits is affected, in a bare value, an array or an object. Response#json() and Request#json() return the same wrong values (await new Response("90.33333333333333").json() gives 90.33333333333331).
  2. 1e400 / -1e400 throw SyntaxError: number out of range instead of returning ±Infinity.
  3. A lone surrogate escape ("\ud800") throws instead of producing a one-code-unit string.
  4. Nesting deeper than 128 throws SyntaxError: recursion limit exceeded. If this is kept as a deliberate guard it should be documented; Node, Bun and Deno parse 2,000 levels.

Likely cause

All four behaviours are serde_json defaults. vendor/boa_engine/src/builtins/json/mod.rs (SLICE PATCH P01, JsonSeed) materialises values straight from serde_json::Deserializer, and serde_json is built without the float_roundtrip feature (cargo tree -e features -i serde_json shows only default). Without that feature serde_json documents its float parsing as best-effort rather than correctly rounded. Upstream Boa only uses serde_json to validate and then evaluates the text with the JS parser, which is why upstream does not have the precision problem.

Environment

  • Slice v0.10.7, commit ea7c70c, built with cargo +1.96.0 build --release --locked --bin slice
  • Reference: Node.js v24.22.2 (same script, same machine)
  • Ubuntu 26.04, Linux 7.0, x86_64
  • The same output is produced by the v0.10.5 tag, so this is not a regression in 0.10.6/0.10.7.

Activity

  1. siktec-lab commented on Nov 11, 2026

    @siktec-lab
    Collaborator

    Fixed in PR #65 (v0.10.9). Replaced the serde_json and boa_parser paths in JSON.parse with a direct iterative ECMA-404 parser (parse_ecma404) over Latin1(&[u8]) / Utf16(&[u16]):

    • Uses fast-float2 for exact IEEE-754 round-to-nearest-even f64 conversion (9007199254740993.0, 1e23) and ±Infinity on exponent overflow (1e400, -1e400).
    • Preserves lone UTF-16 surrogates (\uD800–\uDFFF) in both JSON.parse and JSON.stringify.
    • Supports up to 2048 nesting levels via an explicit heap-allocated container stack (O(1) C/Rust call stack) and direct PropertyMap::insert_data_property slot insertion.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions