You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
JSON.stringify produces invalid JSON when a record's property name contains a backslash or a double quote: the backslash loses its escape, and a quote closes the key and breaks the document. Worse, JSON.parse-ing that string aborts the process with SIGABRT instead of throwing a catchable error.
Environment
scriptc 0.1.1 (npm i -g scriptc)
macOS 15.6.1 arm64
Oracle: Node v24.15.0 (the release pinned by .node-version in this repo)
$ scriptc run repro.ts
{"a\nb":1,"q"q":2,"note":"ok"}
scriptc: TypeError: record has no key (typed slot — no undefined is representable)
scriptc: program killed by SIGABRT
exit=1
Three separate failures:
"a\\nb" comes out as "a\nb" — the backslash escape is gone, so the key silently changes from backslash + n to a newline character.
'q"q' comes out as "q"q" — the quote is not escaped, so the JSON document is structurally broken.
Because of that, JSON.parse(JSON.stringify(x)) aborts the process and the exit code flips from 0 to 1.
Debugger walkthrough
Shot 1 — the defective line: the key is interpolated raw into JSON
Paused at packages/compiler/src/backend/llvm/walkers.ts:272 (conditional breakpoint f.name.length !== 2, so it stops only on the field that triggers the bug):
f.name.split("").map(c=>c.charCodeAt(0)) = [97, 92, 110, 98], i.e. a, \ (92), n, b
So the runtime key string itself is correct — identical to Node. The problem is this line: it interpolates f.name straight into a "...": label and only applies the LLVM string-literal escaper, never JSON escaping. Byte 92 lands verbatim in the JSON text.
Shot 2 — contrast: index-signature keys go through the runtime escaper, and are correct
The same data typed as Record<string, number> (index-signature / overflow branch) serializes correctly, because that path calls the runtime JSON escaper:
// packages/compiler/src/backend/c/walkers.ts591: scr_jb_put_json_str(b,k);// correct: the key is really JSON-escaped here592: scr_jb_putc(b,':');
The breakpoint confirms why one branch is skipped and the other is taken: droppable = true, shape.indexValue.kind = 'f64', byName.size = 0.
This is not a missing feature — it is the same contract implemented asymmetrically: index-signature keys go through the runtime escaper (correct), while statically-known field names are string-concatenated (wrong), and both backends get it wrong independently.
Shot 3 — the LLVM backend's other branch has the same defect
A record with an optional property takes the droppable branch, and packages/compiler/src/backend/llvm/walkers.ts:308 interpolates raw as well:
this.puts(B,"%b",`"${f.name}":`);
Blast radius
Every record with statically-known literal property names is affected:
Property name
Node
scriptc
"a\\nb"
{"a\\nb":1}
{"a\nb":1}
'q"q'
{"q\"q":1}
{"q"q":1} (invalid JSON)
"a\tb"
{"a\tb":1}
raw TAB byte
"a\u0001b"
{"a\u0001b":1}
raw 0x01 byte
"aéb"
{"aéb":1}
matches (no escaping needed)
index signature Record<string, number>
correct
correct
Four sites, two per backend:
packages/compiler/src/backend/c/walkers.ts:532 (all-required branch) and :542 (droppable branch)
packages/compiler/src/backend/llvm/walkers.ts:272 (all-required branch) and :308 (droppable branch)
JSON.stringify(v, null, space), the %j format path, and island boundary serialization reuse the same record writer, so they inherit the bug. --backend c is not a workaround — both backends are wrong.
How it is fixed
At the four label-construction sites, JSON-encode the key before interpolation:
Compile-time JSON.stringify(f.name) returns an already-quoted, correctly escaped JSON token; the existing cStringLiteral / LLVM literal layer then re-escapes it for the C/LLVM string literal, so the runtime buffer holds valid JSON.
Verified with the same reproduction on both backends:
$ node repro.ts → {"a\\nb":1,"q\"q":2,"note":"ok"} / exit 0
$ scriptc run repro.ts → {"a\\nb":1,"q\"q":2,"note":"ok"} / exit 0
$ scriptc run repro.ts --backend c → {"a\\nb":1,"q\"q":2,"note":"ok"} / exit 0
A differential corpus program was added at tests/corpus/2853-json-stringify-record-key-escapes.ts (backslash, double quote, control bytes, optional-field record, index-signature record, nested record, space pretty printing, and a JSON.parse round-trip). The official differential harness treats Node as the expected output, so the case pins the behavior:
TL;DR
JSON.stringifyproduces invalid JSON when a record's property name contains a backslash or a double quote: the backslash loses its escape, and a quote closes the key and breaks the document. Worse,JSON.parse-ing that string aborts the process with SIGABRT instead of throwing a catchable error.Environment
npm i -g scriptc).node-versionin this repo)a8a52045(main, after Implement typed async generators #310)Minimal reproduction
Under Node (the expected output):
Compiled by scriptc:
Three separate failures:
"a\\nb"comes out as"a\nb"— the backslash escape is gone, so the key silently changes frombackslash + nto a newline character.'q"q'comes out as"q"q"— the quote is not escaped, so the JSON document is structurally broken.JSON.parse(JSON.stringify(x))aborts the process and the exit code flips from 0 to 1.Debugger walkthrough
Shot 1 — the defective line: the key is interpolated raw into JSON
Paused at
packages/compiler/src/backend/llvm/walkers.ts:272(conditional breakpointf.name.length !== 2, so it stops only on the field that triggers the bug):The Locals/Watch panels show the key facts:
f.name.length= 4f.name.split("").map(c=>c.charCodeAt(0))= [97, 92, 110, 98], i.e.a,\(92),n,bSo the runtime key string itself is correct — identical to Node. The problem is this line: it interpolates
f.namestraight into a"...":label and only applies the LLVM string-literal escaper, never JSON escaping. Byte 92 lands verbatim in the JSON text.Shot 2 — contrast: index-signature keys go through the runtime escaper, and are correct
The same data typed as
Record<string, number>(index-signature / overflow branch) serializes correctly, because that path calls the runtime JSON escaper:The breakpoint confirms why one branch is skipped and the other is taken:
droppable = true,shape.indexValue.kind = 'f64',byName.size = 0.This is not a missing feature — it is the same contract implemented asymmetrically: index-signature keys go through the runtime escaper (correct), while statically-known field names are string-concatenated (wrong), and both backends get it wrong independently.
Shot 3 — the LLVM backend's other branch has the same defect
A record with an optional property takes the droppable branch, and
packages/compiler/src/backend/llvm/walkers.ts:308interpolates raw as well:Blast radius
Every record with statically-known literal property names is affected:
"a\\nb"{"a\\nb":1}{"a\nb":1}'q"q'{"q\"q":1}{"q"q":1}(invalid JSON)"a\tb"{"a\tb":1}"a\u0001b"{"a\u0001b":1}"aéb"{"aéb":1}Record<string, number>Four sites, two per backend:
packages/compiler/src/backend/c/walkers.ts:532(all-required branch) and:542(droppable branch)packages/compiler/src/backend/llvm/walkers.ts:272(all-required branch) and:308(droppable branch)JSON.stringify(v, null, space), the%jformat path, and island boundary serialization reuse the same record writer, so they inherit the bug.--backend cis not a workaround — both backends are wrong.How it is fixed
At the four label-construction sites, JSON-encode the key before interpolation:
Compile-time
JSON.stringify(f.name)returns an already-quoted, correctly escaped JSON token; the existingcStringLiteral/ LLVM literal layer then re-escapes it for the C/LLVM string literal, so the runtime buffer holds valid JSON.Verified with the same reproduction on both backends:
A differential corpus program was added at
tests/corpus/2853-json-stringify-record-key-escapes.ts(backslash, double quote, control bytes, optional-field record, index-signature record, nested record,spacepretty printing, and aJSON.parseround-trip). The official differential harness treats Node as the expected output, so the case pins the behavior:The PR is up: #313.