Title: test_runner: infinite loop in FileTest#drainRawBuffer when child stdout contains FF 0F followed by large size bytes · Issue #62693 · nodejs/node · GitHub
Open Graph Title: test_runner: infinite loop in FileTest#drainRawBuffer when child stdout contains FF 0F followed by large size bytes · Issue #62693 · nodejs/node
X Title: test_runner: infinite loop in FileTest#drainRawBuffer when child stdout contains FF 0F followed by large size bytes · Issue #62693 · nodejs/node
Description: Version v25.9.0 (also reproduced on v24.14.1 LTS, v22.21.0 LTS, v23.4.0) Platform Darwin 25.3.0 Darwin Kernel Version 25.3.0: Wed Jan 28 20:53:05 PST 2026; root:xnu-12377.81.4~5/RELEASE_ARM64_T6020 arm64 Bug is architectural (not OS-spec...
Open Graph Description: Version v25.9.0 (also reproduced on v24.14.1 LTS, v22.21.0 LTS, v23.4.0) Platform Darwin 25.3.0 Darwin Kernel Version 25.3.0: Wed Jan 28 20:53:05 PST 2026; root:xnu-12377.81.4~5/RELEASE_ARM64_T6020...
X Description: Version v25.9.0 (also reproduced on v24.14.1 LTS, v22.21.0 LTS, v23.4.0) Platform Darwin 25.3.0 Darwin Kernel Version 25.3.0: Wed Jan 28 20:53:05 PST 2026; root:xnu-12377.81.4~5/RELEASE_ARM64_T6020...
Opengraph URL: https://github.com/nodejs/node/issues/62693
X: @github
Domain: github.com
{"@context":"https://schema.org","@type":"DiscussionForumPosting","headline":"test_runner: infinite loop in FileTest#drainRawBuffer when child stdout contains FF 0F followed by large size bytes","articleBody":"### Version\nv25.9.0 (also reproduced on v24.14.1 LTS, v22.21.0 LTS, v23.4.0)\n\n### Platform\n```\nDarwin 25.3.0 Darwin Kernel Version 25.3.0: Wed Jan 28 20:53:05 PST 2026; root:xnu-12377.81.4~5/RELEASE_ARM64_T6020 arm64\n```\nBug is architectural (not OS-specific) — it's in the JS code of `lib/internal/test_runner/runner.js`.\n\n### Subsystem\ntest_runner\n\n### What steps will reproduce the bug?\n\nSave as `repro.mjs`:\n\n```js\nimport { test } from 'node:test';\n\ntest('hang', () =\u003e {\n // v8Header is [0xFF, 0x0F]. The next 4 bytes are parsed as a\n // big-endian \"full message size\". 0x7FFFFFFF is much larger than\n // any rawBufferSize this child will ever reach.\n process.stdout.write(Buffer.from([0xff, 0x0f, 0x7f, 0xff, 0xff, 0xff]));\n});\n```\n\nRun:\n\n```\nnode --test --test-force-exit repro.mjs\n```\n\nThe process hangs at 100 % CPU forever. Reproduces with and without `--test-force-exit`. `--test-timeout` cannot recover the process because the main thread is 100 % in JS microtasks — the event loop is starved. `SIGTERM` is ignored; only `SIGKILL` stops it.\n\n### How often does it reproduce? Is there a required condition?\n\n100 % deterministic with those exact 6 bytes on v22.21.0, v23.4.0, v24.14.1, and v25.9.0. Required condition: the child's stdout must contain the sequence `0xFF 0x0F` followed by 4 bytes that, read as a big-endian uint32, exceed the parser's accumulated `#rawBufferSize`. Any child stdout containing `FF 0F` near the start followed by arbitrary bytes is vulnerable — I first hit this in the wild via a test that wrote random binary output and intermittently hung roughly one run in five.\n\n### What is the expected behavior? Why is that the expected behavior?\n\nEither the parser treats unrecognized/oversized frames as normal stdout and keeps draining, or `#drainRawBuffer` detects no-progress iterations and breaks. Test runner output framing must be robust against arbitrary child stdout content; user tests cannot reasonably be required to avoid a particular byte sequence.\n\n### What do you see instead?\n\nInfinite loop in the parent test-runner process while handling the worker's `OnExit` callback. `sample`/`lldb` show:\n\n```\nuv_run → uv__wait_children → ProcessWrap::OnExit → MakeCallback\n → MicrotaskQueue::PerformCheckpointInternal → RunMicrotasks\n → PromiseFulfillReactionJob → AsyncFunctionAwaitResolveClosure\n → [interpreted frames] → Builtins_CreateTypedArray\n → Runtime_AllocateInYoungGeneration → Heap::Scavenge\n```\n\n~66 % of main-thread samples in `Heap::Scavenge`, ~670 `FastBuffer`/`TypedArray` allocations per second, `Buffer::IndexOfBuffer` also hot. Memory is flat (~1 GB) — pure alloc/GC churn, not a leak.\n\n### Additional information\n\n**Root cause** — `lib/internal/test_runner/runner.js` on `main`:\n\n```js\n#drainRawBuffer() {\n while (this.#rawBuffer.length \u003e 0) { // L366 — no no-progress guard\n this.#processRawBuffer();\n }\n}\n\n#processRawBuffer() {\n let bufferHead = this.#rawBuffer[0];\n let headerIndex = bufferHead.indexOf(v8Header);\n let nonSerialized = new FastBuffer();\n\n while (bufferHead \u0026\u0026 headerIndex !== 0) { // L376 — skipped when headerIndex === 0\n …\n }\n\n while (bufferHead?.length \u003e= kSerializedSizeHeader) { // L399\n const fullMessageSize = (\n bufferHead[kV8HeaderLength] \u003c\u003c 24 |\n bufferHead[kV8HeaderLength + 1] \u003c\u003c 16 |\n bufferHead[kV8HeaderLength + 2] \u003c\u003c 8 |\n bufferHead[kV8HeaderLength + 3]\n ) + kSerializedSizeHeader;\n\n if (this.#rawBufferSize \u003c fullMessageSize) break; // L409 — breaks without mutating #rawBuffer\n …\n }\n}\n```\n\nWhen the buffer starts with `FF 0F`, the first loop is skipped (`headerIndex === 0`). When the following 4 bytes form a size larger than `#rawBufferSize`, the second loop breaks on its first check. `#processRawBuffer` returns without shrinking `#rawBuffer` or `#rawBufferSize`, and `#drainRawBuffer`'s `while` condition is still true — so it re-enters `#processRawBuffer`, which again allocates a `FastBuffer`, again calls `bufferHead.indexOf(v8Header)`, again breaks on the same check, forever. `#drainRawBuffer` is reached via `drain() → report()` when the subtest file exits, so the hang manifests *after* the test body finishes, on the parent's `ProcessWrap::OnExit` path.\n\nThe framing uses only a 2-byte magic (`v8.Serializer` header `FF 0F`), which is short enough to collide with arbitrary user stdout (random/binary bytes, compressed data, protobuf frames, etc.).\n\n**Suggested fixes** (in order of increasing invasiveness):\n\n1. In `#drainRawBuffer`, track `#rawBufferSize` (and `#rawBuffer.length`) before/after each `#processRawBuffer` call and `break` if neither changed — guarantees termination regardless of content. Cheapest fix.\n2. At drain time (stream closed), if `fullMessageSize \u003e #rawBufferSize` in `#processRawBuffer`'s second loop, treat the leading bytes as corrupted framing and flush them as stdout instead of waiting for more data that will never arrive.\n3. (Long term, breaking protocol change) Use a longer framing magic or an explicit length prefix before `v8Header` so accidental collisions become astronomically unlikely.\n","author":{"url":"https://github.com/lslv1243","@type":"Person","name":"lslv1243"},"datePublished":"2026-04-11T16:28:44.000Z","interactionStatistic":{"@type":"InteractionCounter","interactionType":"https://schema.org/CommentAction","userInteractionCount":1},"url":"https://github.com/62693/node/issues/62693"}
| 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:e1d2eeea-910f-6438-703e-1fe838260657 |
| current-catalog-service-hash | 81bb79d38c15960b92d99bca9288a9108c7a47b18f2423d0f6438c5b7bcd2114 |
| request-id | 844E:3E4802:CDDF67:115ED7F:6A4D4A40 |
| html-safe-nonce | a5a39dabd9ac1180f517816b840630e57ad1d49289dac63316785b73f456d2db |
| visitor-payload | eyJyZWZlcnJlciI6IiIsInJlcXVlc3RfaWQiOiI4NDRFOjNFNDgwMjpDRERGNjc6MTE1RUQ3Rjo2QTRENEE0MCIsInZpc2l0b3JfaWQiOiI0ODE3NTUyOTA2OTA5OTY4MDAiLCJyZWdpb25fZWRnZSI6ImlhZCIsInJlZ2lvbl9yZW5kZXIiOiJpYWQifQ== |
| visitor-hmac | 7117314a69a0b726d3890e023cd87d2b17e5c66fa9dcafb8b5e8d125aa54d2f7 |
| hovercard-subject-tag | issue:4245159069 |
| 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/62693/issue_layout |
| twitter:image | https://opengraph.githubassets.com/3b64e7bceb9bbe9ba9103161b9e6865f2a5d4c352bd1b5f0555da0217bcf5b3b/nodejs/node/issues/62693 |
| twitter:card | summary_large_image |
| og:image | https://opengraph.githubassets.com/3b64e7bceb9bbe9ba9103161b9e6865f2a5d4c352bd1b5f0555da0217bcf5b3b/nodejs/node/issues/62693 |
| og:image:alt | Version v25.9.0 (also reproduced on v24.14.1 LTS, v22.21.0 LTS, v23.4.0) Platform Darwin 25.3.0 Darwin Kernel Version 25.3.0: Wed Jan 28 20:53:05 PST 2026; root:xnu-12377.81.4~5/RELEASE_ARM64_T6020... |
| og:image:width | 1200 |
| og:image:height | 600 |
| og:site_name | GitHub |
| og:type | object |
| og:author:username | lslv1243 |
| hostname | github.com |
| expected-hostname | github.com |
| None | 92571a8944142227b7e19cd10918b1ddd06e5066c1ad5bc7e4769cf6140a87e6 |
| 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 | 56fc8347865a14e2ec811533d68f929cf4e0ec19 |
| ui-target | full |
| theme-color | #1e2327 |
| color-scheme | light dark |
Links:
Viewport: width=device-width