File
Blob: src/workerd/api/tests/streams-consumer-reentry-gc-test.js
| 1 | // Copyright (c) 2025 Cloudflare, Inc. |
| 2 | // Licensed under the Apache 2.0 license found in the LICENSE file or at: |
| 3 | // https://opensource.org/licenses/Apache-2.0 |
| 4 | |
| 5 | // Regression test for EDGEWORKER-RUNTIME-H40: SIGSEGV during ConsumerImpl |
| 6 | // close drain when re-entrant controller.error() destroys the consumer |
| 7 | // mid-iteration. |
| 8 | // |
| 9 | // The mechanism: during maybeDrainAndSetState's close-drain loop, each |
| 10 | // resolveAsDone call wraps ReadResult via wrapOpaque, which creates a V8 |
| 11 | // object. V8's promise resolution machinery checks for a "then" property |
| 12 | // on the resolved value. A malicious Object.prototype.then getter can |
| 13 | // re-enter C++ and call controller.error(), which: |
| 14 | // 1) Rejects all remaining readRequests (error path in maybeDrainAndSetState) |
| 15 | // 2) Transitions ConsumerImpl state to Errored (destroys Ready struct) |
| 16 | // 3) Notifies stateListener -> doError -> destroys ValueReadable -> Consumer |
| 17 | // The outer close-drain loop then dereferences freed memory -> SIGSEGV. |
| 18 | // |
| 19 | // The fix in queue.h extracts readRequests to local ownership before |
| 20 | // resolving/rejecting and uses selfRef WeakRef to guard member access. |
| 21 | |
| 22 | import { strictEqual } from 'node:assert'; |
| 23 | |
| 24 | export default { |
| 25 | async test() { |
| 26 | let controller; |
| 27 | const rs = new ReadableStream({ |
| 28 | start(c) { |
| 29 | controller = c; |
| 30 | }, |
| 31 | pull(c) { |
| 32 | return new Promise(() => {}); |
| 33 | }, // never resolves -> reads stay pending |
| 34 | }); |
| 35 | |
| 36 | // Let start() onSuccess microtask run. |
| 37 | await Promise.resolve(); |
| 38 | |
| 39 | const reader = rs.getReader(); |
| 40 | // Queue 3 pending readRequests in the consumer's RingBuffer. |
| 41 | reader.read().catch(() => {}); |
| 42 | reader.read().catch(() => {}); |
| 43 | reader.read().catch(() => {}); |
| 44 | |
| 45 | let armed = false; |
| 46 | const noopThen = function (resolve, reject) { |
| 47 | /* never settle */ |
| 48 | }; |
| 49 | |
| 50 | // Install a trap on Object.prototype.then that re-enters the stream |
| 51 | // controller when V8 tries to resolve the first pending read. |
| 52 | // V8's promise resolution checks for a "then" property on the resolved |
| 53 | // value (thenable detection). If it finds one, it queues a microtask |
| 54 | // instead of immediately fulfilling. Our getter uses this window to |
| 55 | // call controller.error(), which re-enters ConsumerImpl and destroys |
| 56 | // the readRequests being iterated. |
| 57 | Object.defineProperty(Object.prototype, 'then', { |
| 58 | configurable: true, |
| 59 | get() { |
| 60 | if (armed) { |
| 61 | armed = false; |
| 62 | try { |
| 63 | controller.error(new Error('boom')); |
| 64 | } catch { |
| 65 | // Intentionally empty |
| 66 | } |
| 67 | return noopThen; |
| 68 | } |
| 69 | return undefined; |
| 70 | }, |
| 71 | }); |
| 72 | |
| 73 | armed = true; |
| 74 | try { |
| 75 | controller.close(); |
| 76 | } catch { |
| 77 | // close may throw due to the re-entrant error — that's expected |
| 78 | } |
| 79 | armed = false; |
| 80 | delete Object.prototype.then; |
| 81 | |
| 82 | // If we get here without SIGSEGV, the fix works. |
| 83 | strictEqual(true, true, 'survived re-entrant error during close drain'); |
| 84 | }, |
| 85 | }; |