Conversation
Adds a refcounted reference to the Reader object to stop it
going away too early.
This bug was detected with a gc-stress mode on a test
in streams-test.ts:
"ReadableStream start should be able to return a promise and reject it"
The test is as follows:
```js
promise_test(() => {
const theError = new Error('rejected!');
const rs = new ReadableStream({
start() {
return delay(1).then(() => { throw theError; });
}
});
return rs.getReader().closed.then(() => {
assert_unreached('closed promise should be rejected');
}, e => {
assert_equals(e, theError, 'promise should be rejected with the same error');
});
}, 'ReadableStream start should be able to return a promise and reject it');
```
The root cause is that the Reader has a reference to the Stream, but
the Stream has no reference to the Reader. If the user does not keep
a reference to the Reader, it can be collected early, which causes
a destructor to remove promises from the queue and never resolve them.
When the Reader is GC'd, the following cascade occurs:
1. V8 GC collects the Reader's JS wrapper (unreachable from JS roots)
2. CppGC collects the shim → `detachWrapper()` on the Reader's Wrappable
3. Reader C++ object is destroyed:
- `ReaderImpl::~ReaderImpl()` destroys `closedPromise` (the C++ handle; the JS
promise object survives, held by the `.then()` chain)
- `ReaderImpl::~ReaderImpl()` destroys `state`, which is `Attached`
- `Attached::~Attached()` destroys `jsg::Ref<ReadableStream>` — the **last** ref
to the stream (the `rs` local variable went out of scope when the test function
returned)
4. `ReadableStream` is destroyed:
- `ReadableStreamJsController` (member) is destroyed
- `kj::Own<ValueReadable>` state is destroyed
- `ValueReadable::~ValueReadable()` destroys its `Consumer`
- `~ConsumerImpl()` calls `queue.removeConsumer(*this)` (queue.h:390) — **removes
itself from the Controller's queue**
- `ReadableLockImpl` (the `lock` member) is destroyed
- `ReaderLocked::~ReaderLocked()` calls `reader.detach()` — but the Reader is
already destroyed, so this is a **use-after-free** on the raw `Reader&` (common.h:815).
In practice this likely doesn't crash because the memory hasn't been reused yet.
- `ReaderLocked::~ReaderLocked()` then implicitly destroys `closedFulfiller`
(the Resolver) **without ever resolving or rejecting it**
5. Later: `delay(1)` fires → `start()` promise rejects → `onFailure` lambda fires →
`ReadableImpl::doError()` on the Controller (which is alive, held by the lambda's
`Ref<ReadableStreamDefaultController>`) → `queue.error()` → iterates consumers →
**no consumers left** (removed in step 4) → `onConsumerError` is never called →
`ReadableStreamJsController::doError` is never called → `lock.onError()` is never
called → `closedFulfiller` was already destroyed
There was a problem hiding this comment.
I'm Bonk, and I've done a quick review of your PR.
This adds a kj::Own<void> ref-counted reference from ReaderLocked back to the Reader to prevent premature GC collection, and removes the unsafe detach() call from the ReaderLocked destructor.
- [MEDIUM]
WriterLockedhas the same vulnerability.WriterLocked::~WriterLocked()(common.h:906-910) callsw.detach()on a bareWriter&reference, which is the exact same use-after-free pattern that this PR fixes inReaderLocked.WriterImpl::Attachedholds ajsg::Ref<WritableStream>, sows.getWriter().closed.then(...)creates the same unrooted-temporary hazard. Consider applying the same fix toWriterLockedin this PR, or at minimum filing a follow-up.
This review was generated by an AI code review assistant and may contain inaccuracies.
|
I've posted the review on PR #6661. Here's a summary of my analysis: The fix itself is correct and well-designed. The approach of using One medium-severity finding: |
|
Looks like this causes a leak, caught by the internal build. Trying to see how to fix it. |
Adds a refcounted reference to the Reader object to stop it going away too early.
This bug was detected with a gc-stress mode on a test in streams-test.ts:
"ReadableStream start should be able to return a promise and reject it"
The test is as follows:
The root cause is that the Reader has a reference to the Stream, but the Stream has no reference to the Reader. If the user does not keep a reference to the Reader, it can be collected early, which causes a destructor to remove promises from the queue and never resolve them.
When the Reader is GC'd, the following cascade occurs:
V8 GC collects the Reader's JS wrapper (unreachable from JS roots)
CppGC collects the shim →
detachWrapper()on the Reader's WrappableReader C++ object is destroyed:
ReaderImpl::~ReaderImpl()destroysclosedPromise(the C++ handle; the JS promise object survives, held by the.then()chain)ReaderImpl::~ReaderImpl()destroysstate, which isAttachedAttached::~Attached()destroysjsg::Ref<ReadableStream>— the last ref to the stream (therslocal variable went out of scope when the test function returned)ReadableStreamis destroyed:ReadableStreamJsController(member) is destroyedkj::Own<ValueReadable>state is destroyedValueReadable::~ValueReadable()destroys itsConsumer~ConsumerImpl()callsqueue.removeConsumer(*this)(queue.h:390) — removes itself from the Controller's queueReadableLockImpl(thelockmember) is destroyedReaderLocked::~ReaderLocked()callsreader.detach()— but the Reader is already destroyed, so this is a use-after-free on the rawReader&(common.h:815). In practice this likely doesn't crash because the memory hasn't been reused yet.ReaderLocked::~ReaderLocked()then implicitly destroysclosedFulfiller(the Resolver) without ever resolving or rejecting itLater:
delay(1)fires →start()promise rejects →onFailurelambda fires →ReadableImpl::doError()on the Controller (which is alive, held by the lambda'sRef<ReadableStreamDefaultController>) →queue.error()→ iterates consumers → no consumers left (removed in step 4) →onConsumerErroris never called →ReadableStreamJsController::doErroris never called →lock.onError()is never called →closedFulfillerwas already destroyed