Title: test_runner: parent runner still crashes on child stdout bytes that mimic an event frame (survives the #64706 fix) · Issue #66164 · nodejs/node · GitHub
Open Graph Title: test_runner: parent runner still crashes on child stdout bytes that mimic an event frame (survives the #64706 fix) · Issue #66164 · nodejs/node
X Title: test_runner: parent runner still crashes on child stdout bytes that mimic an event frame (survives the #64706 fix) · Issue #66164 · nodejs/node
Description: Version: v24.21.0 (this release includes the >>> 0 fix from #64706); also verified on v26.4.0 and v22.23.2 Platform: macOS 26.6.2 (25G83), arm64 Subsystem: test_runner Current behavior node --test crashes the parent runner (exit code 1; ...
Open Graph Description: Version: v24.21.0 (this release includes the >>> 0 fix from #64706); also verified on v26.4.0 and v22.23.2 Platform: macOS 26.6.2 (25G83), arm64 Subsystem: test_runner Current behavior node --test ...
X Description: Version: v24.21.0 (this release includes the >>> 0 fix from #64706); also verified on v26.4.0 and v22.23.2 Platform: macOS 26.6.2 (25G83), arm64 Subsystem: test_runner Current behavior nod...
Opengraph URL: https://github.com/nodejs/node/issues/66164
X: @github
Domain: github.com
{"@context":"https://schema.org","@type":"DiscussionForumPosting","headline":"test_runner: parent runner still crashes on child stdout bytes that mimic an event frame (survives the #64706 fix)","articleBody":"- **Version**: v24.21.0 (this release *includes* the `>>> 0` fix from #64706); also verified on v26.4.0 and v22.23.2\n- **Platform**: macOS 26.6.2 (25G83), arm64\n- **Subsystem**: test_runner\n\n## Current behavior\n\n`node --test` crashes the **parent runner** (exit code 1; the failing component is the parent's stdout frame parser, not any test assertion) with:\n\n```console\nError: Unable to deserialize cloned data due to invalid or unsupported version.\n at #processRawBuffer (node:internal/test_runner/runner:497:20)\n at FileTest.parseMessage (node:internal/test_runner/runner:404:29)\n at Socket.\u003canonymous> (node:internal/test_runner/runner:552:15)\n```\n\nHow often: deterministic — 10/10 runs on every version below. Control (same file minus the stdout write): 10/10 green on every version.\n\n| node | has #64706 `>>> 0` fix | repro | control |\n|---|---|---|---|\n| v24.21.0 | yes (verified in shipped `runner.js` source) | 10/10 crash | 10/10 pass |\n| v26.4.0 | no (compiled-in source still has the signed read) | 10/10 crash | 10/10 pass |\n| v22.23.2 | no (v22 backport tracked in #65934) | 10/10 crash | 10/10 pass |\n\n## Expected behavior\n\nAnything a test (or code it imports) writes to stdout — text, banners, progress bars, QR codes, binary — should not be able to take down the parent runner. At worst it should degrade to `test:stdout` output or a per-file diagnostic, not abort the whole run with an error pointing at runner internals.\n\n## Steps to reproduce\n\nSave as `poison.test.mjs`, then run `node --test poison.test.mjs`:\n\n```js\nimport { test } from 'node:test'\n\ntest('writes bytes that mimic a frame header', () => {\n const poison = Buffer.from([\n 0xFF, 0x0F, // frame magic (v8 serdes header tag)\n 0x00, 0x00, 0x00, 0x08, // fake payload size = 8\n 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, // \"ABCDEFGH\" payload\n ])\n process.stdout.write(poison)\n})\n\ntest('subsequent test whose real frames must also be parsed', () => {\n // trivial\n})\n```\n\nRemoving the `process.stdout.write(poison)` line makes it 10/10 green on all three versions.\n\n## Analysis\n\nIn `lib/internal/test_runner/runner.js`:\n\n- `runTestFile` spawns each test file with `stdio: ['pipe', 'pipe', 'pipe']` and pipes the child's **raw stdout** into `FileTest.parseMessage → #processRawBuffer`.\n- The child's stdout therefore carries two unrelated producers on one pipe: the runner's own length-prefixed frames (`FF 0F | uint32 BE size | v8 payload`) and arbitrary application output. There is no isolation or escape mechanism for user bytes.\n- The first loop in `#processRawBuffer` skips bytes up to the next `FF 0F` magic and reports them as `test:stdout`. But after consuming a frame, the second loop continues **without re-checking the magic** — it trusts the next 4 bytes as a size and hands the slice straight to `readHeader()` / `readValue()`, with **no try/catch anywhere on this path** (the escaping stack above is the proof).\n- #64706 hardened the size read itself (`>>> 0`), which fixes the negative/garbage-size case. What remains — and what the repro above hits on v24.21.0 — is the rest of the path: any stdout bytes that begin with `FF 0F` plus a plausible small size pass every guard, `readHeader()` throws on the payload, and the exception takes down the entire run.\n- Binary output containing `FF 0F` at a buffer head reaches exactly this state; plain text mostly takes the negative-size branch instead, which pre-#64706 silently drops buffered frames (see #65934) rather than crashing.\n\nReal-world impact: one test's stdout can abort the whole `node --test` run with an internals-only error, which is hard for users to act on. We hit this as CI flakiness: a test printed a large terminal-QR block graphic and CI failed intermittently with the identical error; removing that output made the failures disappear.\n\nPossible directions (not exhaustive): re-validate the `FF 0F` magic in the second loop before trusting the size; treat `readHeader()` / `readValue()` failure as lost/stray-output diagnostics for that file instead of a fatal exception; or an escaping mechanism so user stdout is never frame-parsed.\n\n## Related\n\n- #64061 — signed size-read crash (same error/stack); fixed by #64706. This issue is about what that fix does *not* cover.\n- #65934 — pending v22.x backport of #64706.\n\n## Secondary repro (plain text, intermittent)\n\nThe deterministic file above is a minimized stand-in. The organic trigger is chunk-boundary dependent — text landing in the same parse buffer as the end of a frame gets its bytes read as a size. This file matches our real-world case (QR-style block text) and crashes only intermittently:\n\n```js\nimport { test } from 'node:test'\n\nconst tick = () => new Promise((r) => setTimeout(r, 0))\n\ntest('passes some ordinary assertions', async () => { await tick() })\n\ntest('prints a large block of text like a QR code / ASCII banner', async () => {\n for (let i = 0; i \u003c 54; i++) {\n console.log('█▀▄ '.repeat(25))\n await tick() // let runner event frames flush between text writes\n }\n})\n\ntest('keeps running tests after the noisy one', async () => { await tick() })\n```\n","author":{"url":"https://github.com/fan56","@type":"Person","name":"fan56"},"datePublished":"2026-09-21T01:50:42Z","url":"https://github.com/nodejs/node/issues/66164"}
| 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:1e4dcc3c-1e44-1204-fcc2-3a1f43622f20 |
| current-catalog-service-hash | 81bb79d38c15960b92d99bca9288a9108c7a47b18f2423d0f6438c5b7bcd2114 |
| request-id | A8B8:36D84F:975320:C95F72:6AED30AF |
| html-safe-nonce | a8a2b06a52f0dab419e1d0b03fbe801e50109825be6480167352fe7a82f48ef6 |
| visitor-payload | eyJyZWZlcnJlciI6IiIsInJlcXVlc3RfaWQiOiJBOEI4OjM2RDg0Rjo5NzUzMjA6Qzk1RjcyOjZBRUQzMEFGIiwidmlzaXRvcl9pZCI6IjIwMTA2NTAwODA1NTIyMzUxODQiLCJyZWdpb25fZWRnZSI6ImlhZCIsInJlZ2lvbl9yZW5kZXIiOiJpYWQifQ== |
| visitor-hmac | 2c90d8e9f65f30de259811f166a8c661a7dc7d01be897d8f616d37c6db55b00a |
| hovercard-subject-tag | issue:5521862162 |
| 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/66164/issue_layout |
| twitter:image | https://opengraph.githubassets.com/537303612757410611793e3b7204d3f82ca4e366d1dc64c95b891d2532f70cb5/nodejs/node/issues/66164 |
| twitter:card | summary_large_image |
| og:image | https://opengraph.githubassets.com/537303612757410611793e3b7204d3f82ca4e366d1dc64c95b891d2532f70cb5/nodejs/node/issues/66164 |
| og:image:alt | Version: v24.21.0 (this release includes the >>> 0 fix from #64706); also verified on v26.4.0 and v22.23.2 Platform: macOS 26.6.2 (25G83), arm64 Subsystem: test_runner Current behavior node --test ... |
| og:image:width | 1200 |
| og:image:height | 600 |
| og:site_name | GitHub |
| og:type | object |
| og:author:username | fan56 |
| hostname | github.com |
| expected-hostname | github.com |
| None | e51d0b7b5ab34b1688c05b54c3ed17b8658b2fa9bca973a9c740683ccb12cf4a |
| 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 | 59ebb7b901cc154ecc179a44f266fcf69f343b30 |
| ui-target | full |
| theme-color | #1e2327 |
| color-scheme | light dark |
| react-app-name | issues |
Links:
Viewport: width=device-width