Title: JSON.parse decodes an escaped quote (`\"` → `\`) in a property key under a specific heap state (deterministic under --predictable) · Issue #64546 · nodejs/node · GitHub
Open Graph Title: JSON.parse decodes an escaped quote (`\"` → `\`) in a property key under a specific heap state (deterministic under --predictable) · Issue #64546 · nodejs/node
X Title: JSON.parse decodes an escaped quote (`\"` → `\`) in a property key under a specific heap state (deterministic under --predictable) · Issue #64546 · nodejs/node
Description: Version Reproduced on: Node.js v24.15.0 (V8 13.6.233.17) Node.js v26.5.0 (V8 14.6.202.34) — latest released at time of filing Platform macOS arm64 (Darwin 25.x). Not yet tested on other OS/arch. Subsystem JSON / V8 What steps will reprod...
Open Graph Description: Version Reproduced on: Node.js v24.15.0 (V8 13.6.233.17) Node.js v26.5.0 (V8 14.6.202.34) — latest released at time of filing Platform macOS arm64 (Darwin 25.x). Not yet tested on other OS/arch. Su...
X Description: Version Reproduced on: Node.js v24.15.0 (V8 13.6.233.17) Node.js v26.5.0 (V8 14.6.202.34) — latest released at time of filing Platform macOS arm64 (Darwin 25.x). Not yet tested on other OS/arch. Su...
Opengraph URL: https://github.com/nodejs/node/issues/64546
X: @github
Domain: github.com
{"@context":"https://schema.org","@type":"DiscussionForumPosting","headline":"JSON.parse decodes an escaped quote (`\\\"` → `\\`) in a property key under a specific heap state (deterministic under --predictable)","articleBody":"### Version\nReproduced on:\n- Node.js **v24.15.0** (V8 13.6.233.17)\n- Node.js **v26.5.0** (V8 14.6.202.34) — latest released at time of filing\n\n### Platform\nmacOS arm64 (Darwin 25.x). Not yet tested on other OS/arch.\n\n### Subsystem\nJSON / V8\n\n### What steps will reproduce the bug?\n\n`JSON.parse(JSON.stringify(obj))` fails to round-trip one specific object: a property **key** that is a single double-quote `\"` (U+0022) comes back as a single backslash `\\` (U+005C) — i.e. the source token `\\\"` is unescaped to `\\` instead of `\"`.\n\nThe triggering object is produced deterministically by `fast-check`. A self-contained repro (one dependency, `fast-check@4.9.0`):\n\n```json\n// package.json\n{ \"type\": \"module\", \"dependencies\": { \"fast-check\": \"4.9.0\" } }\n```\n\n```js\n// repro.mjs\nimport fc from 'fast-check'\n\nconst hasNegativeZero = v => {\n if (typeof v === 'number') return Object.is(v, -0)\n if (Array.isArray(v)) return v.some(hasNegativeZero)\n if (v && typeof v === 'object') return Object.values(v).some(hasNegativeZero)\n return false\n}\nconst jsonPropsArb = fc\n .dictionary(fc.string({ minLength: 1, maxLength: 10 }), fc.jsonValue({ maxDepth: 2 }))\n .filter(props => !hasNegativeZero(props))\nconst refArb = fc.record(\n { id: fc.string({ maxLength: 10 }), alias: fc.string({ maxLength: 10 }), sourceField: fc.string({ maxLength: 10 }) },\n { requiredKeys: ['id', 'alias'] },\n)\nconst objArb = fc.record({\n id: fc.string({ minLength: 1, maxLength: 20 }), workspaceId: fc.string({ minLength: 1, maxLength: 20 }),\n parentId: fc.option(fc.string({ minLength: 1, maxLength: 20 }), { nil: null }), orderKey: fc.string({ minLength: 1, maxLength: 10 }),\n content: fc.string({ maxLength: 40 }), properties: jsonPropsArb, references: fc.array(refArb, { maxLength: 5 }),\n createdAt: fc.integer({ min: 0, max: 2 ** 33 }), updatedAt: fc.integer({ min: 0, max: 2 ** 33 }), userUpdatedAt: fc.integer({ min: 0, max: 2 ** 33 }),\n createdBy: fc.string({ maxLength: 20 }), updatedBy: fc.string({ maxLength: 20 }), deleted: fc.boolean(),\n})\n\nlet firstBad = null\ntry {\n fc.assert(fc.property(objArb, obj => {\n const props = obj.properties ?? {}\n const back = JSON.parse(JSON.stringify(props))\n const ok = Object.keys(props).join(' ') === Object.keys(back).join(' ')\n if (!ok && !firstBad) firstBad = { props, stored: JSON.stringify(props) }\n return ok\n }), { numRuns: 25000, seed: -915705129, endOnFailure: true })\n} catch { /* fc throws on the failing property */ }\n\nconst hex = s => [...s].map(c => c.codePointAt(0).toString(16)).join(',')\nconsole.log(`node ${process.version} v8 ${process.versions.v8} flags: ${process.execArgv.join(' ') || '(none)'}`)\nconsole.log('reproduced:', !!firstBad)\nif (firstBad) {\n console.log('input keys:', Object.keys(firstBad.props).map(hex).join(' | '))\n console.log('parsed keys:', Object.keys(JSON.parse(firstBad.stored)).map(hex).join(' | '))\n process.exitCode = 1\n}\n```\n\n```\nnpm install\nnode --predictable repro.mjs # deterministic — reproduces every run (~2s)\nnode repro.mjs # reproduces ~10–15% of runs without the flag\n```\n\n### What is the expected behavior?\n`JSON.parse(JSON.stringify(x))` round-trips; the `\"` key stays `\"` (`0x22`).\n\n### What do you see instead?\n```\ninput keys: 68 | 22 | 6b,69 | ...\nparsed keys: 68 | 5c | 6b,69 | ...\n```\nThe `22` (`\"`) key is decoded as `5c` (`\\`).\n\n### Additional information\n\nThis is almost certainly a **V8** bug (I'm filing here since it reproduces in stock Node with no native addons; happy to move it to the V8 tracker if you prefer). It was found via property-based fuzzing; the investigation below was AI-assisted. I searched the tracker and didn't find an existing report — apologies if I missed one.\n\nNarrowing (all tested under `--predictable`, so deterministic):\n\n- **`--predictable` makes it 100% reproducible**; unflagged it's ~10–15% per process. So the flakiness is GC-scheduling nondeterminism, not a fuzzer artifact. Also reproduces under `--jitless`, so it is not limited to optimized code.\n- **Not a rope/representation bug.** In the failing process the ConsString from `JSON.stringify`, a `String.fromCharCode(...codeUnits)` flat copy, and an `Array.map().join('')` flat copy of the *identical* ~486-char bytes **all** misparse to `\\`.\n- **Content-specific, not global.** In the same failing process, `JSON.parse(JSON.stringify({'\"':1}))` and the literal `JSON.parse('{\"\\\\\"\":1}')` both decode correctly to `\"`.\n- **Heap-state-dependent.** The exact same bytes parse **correctly** in a cold process, and correctly after 25 000 unrelated `JSON.parse/stringify` warmup round-trips. Only the heap state accumulated by the ~20 420 `fast-check` generations in `repro.mjs` primes it — which is why the repro can't currently be reduced below \"fast-check + this seed\". The stringified source bytes are intact (`… 22 5c 22 22 3a …`, the key `\"` encoded as `\"\\\"\"`); `JSON.stringify` is correct and `JSON.parse` misreads.\n\nThe failing case is `seed: -915705129`, generation index 20419. `--predictable` is the recommended entry point for a debugger — break in the JSON scanner's string/escape handling and inspect the ~486-char source and the key at offset 11.\n","author":{"url":"https://github.com/Stvad","@type":"Person","name":"Stvad"},"datePublished":"2026-07-16T22:32:14Z","url":"https://github.com/nodejs/node/issues/64546"}
| react-profiling | 0 |
| react-import-map | react,react-dom,react-dom/client,react-dom/profiling,react-is,react-reconciler,react/compiler-runtime,react/jsx-dev-runtime,react/jsx-runtime,scheduler@d5750093facf |
| route-pattern | /_view_fragments/issues/show/:user_id/:repository/:id/issue_layout(.:format) |
| route-controller | voltron_issues_fragments |
| route-action | issue_layout |
| fetch-nonce | v2:3e114676-2f0c-58f1-f752-91a6fb10cf48 |
| current-catalog-service-hash | 81bb79d38c15960b92d99bca9288a9108c7a47b18f2423d0f6438c5b7bcd2114 |
| request-id | D132:AF99F:FCFD7:15B50C:6AED35A5 |
| html-safe-nonce | 188144f4da4042bcd8dedf472618767bdc59a27ffe3f440130c8d4696aa7c9af |
| visitor-payload | eyJyZWZlcnJlciI6IiIsInJlcXVlc3RfaWQiOiJEMTMyOkFGOTlGOkZDRkQ3OjE1QjUwQzo2QUVEMzVBNSIsInZpc2l0b3JfaWQiOiIyNjQzODA1MDExMDQ4MjE1OTczIiwicmVnaW9uX2VkZ2UiOiJpYWQiLCJyZWdpb25fcmVuZGVyIjoiaWFkIn0= |
| visitor-hmac | f8f7b19551e511685fba20fc80a7328ad7002735eab03ca0cc150afb297b2333 |
| hovercard-subject-tag | issue:4906511467 |
| github-keyboard-shortcuts | repository,issues,copilot |
| google-site-verification | Apib7-x98H0j5cPqHWwSMm6dNU4GmODRoqxLiDzdx9I |
| octolytics-url | https://collector.github.com/github/collect |
| analytics-location | / |
| fb:app_id | 1401488693436528 |
| apple-itunes-app | app-id=1477376905, app-argument=https://github.com/_view_fragments/issues/show/nodejs/node/64546/issue_layout |
| twitter:image | https://opengraph.githubassets.com/80427aefe426962f9c1c0d284ce59fcb13217d82c3ae55090e7858a0af5b1877/nodejs/node/issues/64546 |
| twitter:card | summary_large_image |
| og:image | https://opengraph.githubassets.com/80427aefe426962f9c1c0d284ce59fcb13217d82c3ae55090e7858a0af5b1877/nodejs/node/issues/64546 |
| og:image:alt | Version Reproduced on: Node.js v24.15.0 (V8 13.6.233.17) Node.js v26.5.0 (V8 14.6.202.34) — latest released at time of filing Platform macOS arm64 (Darwin 25.x). Not yet tested on other OS/arch. Su... |
| og:image:width | 1200 |
| og:image:height | 600 |
| og:site_name | GitHub |
| og:type | object |
| og:author:username | Stvad |
| hostname | github.com |
| expected-hostname | github.com |
| None | 26c92a9388ddfad3fa88d132b5d5f0b4747030233dcc4e5efb05f1cbae152184 |
| turbo-cache-control | no-preview |
| go-import | github.com/nodejs/node git https://github.com/nodejs/node.git |
| octolytics-dimension-user_id | 9950313 |
| octolytics-dimension-user_login | nodejs |
| octolytics-dimension-repository_id | 27193779 |
| octolytics-dimension-repository_nwo | nodejs/node |
| octolytics-dimension-repository_public | true |
| octolytics-dimension-repository_is_fork | false |
| octolytics-dimension-repository_network_root_id | 27193779 |
| octolytics-dimension-repository_network_root_nwo | nodejs/node |
| turbo-body-classes | logged-out env-production page-responsive |
| disable-turbo | false |
| browser-stats-url | https://api.github.com/_private/browser/stats |
| browser-errors-url | https://api.github.com/_private/browser/errors |
| release | c629ad99c254d306375fc8354d04321c2c362236 |
| ui-target | full |
| theme-color | #1e2327 |
| color-scheme | light dark |
| react-app-name | issues |
Links:
Viewport: width=device-width