Skip to content

Commit 65a8274

Browse files
authored
Fixup destruction order in streams (#5906)
1 parent 24a834c commit 65a8274

File tree

3 files changed

+246
-61
lines changed

3 files changed

+246
-61
lines changed

src/workerd/api/streams/queue-test.c++

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1817,5 +1817,130 @@ KJ_TEST("ValueQueue draining read with default maxRead (unlimited)") {
18171817

18181818
#pragma endregion Draining Read maxRead Tests
18191819

1820+
#pragma region Queue/Consumer Destruction Order Tests
1821+
1822+
// These tests verify that destroying the queue before its consumers doesn't crash.
1823+
// This can happen during isolate teardown when the destruction order isn't guaranteed
1824+
// to follow the ownership hierarchy.
1825+
// These will typically only catch in builds with AddressSanitizer enabled.
1826+
1827+
KJ_TEST("ValueQueue destroyed before consumer doesn't crash") {
1828+
preamble([](jsg::Lock& js) {
1829+
// Heap-allocate the queue so we can control its destruction order
1830+
auto queue = kj::heap<ValueQueue>(2);
1831+
1832+
// Create a consumer attached to the queue
1833+
auto consumer = kj::heap<ValueQueue::Consumer>(*queue);
1834+
1835+
// Push some data to make sure the consumer has state
1836+
queue->push(js, getEntry(js, 4));
1837+
KJ_ASSERT(consumer->size() == 4);
1838+
1839+
// Now destroy the queue FIRST - this simulates the production scenario
1840+
// where wrapper cleanup destroys the controller (and its queue) before
1841+
// the consumer that holds a reference to it.
1842+
queue = nullptr;
1843+
1844+
// When the consumer is destroyed (here, or when going out of scope),
1845+
// its destructor calls queue.removeConsumer(this).
1846+
// Without the fix, this is a use-after-free.
1847+
// With the fix, the consumer knows the queue is gone and skips the call.
1848+
consumer = nullptr;
1849+
1850+
// If we get here without crashing, the test passes
1851+
});
1852+
}
1853+
1854+
KJ_TEST("ValueQueue destroyed before multiple consumers doesn't crash") {
1855+
preamble([](jsg::Lock& js) {
1856+
auto queue = kj::heap<ValueQueue>(2);
1857+
1858+
auto consumer1 = kj::heap<ValueQueue::Consumer>(*queue);
1859+
auto consumer2 = kj::heap<ValueQueue::Consumer>(*queue);
1860+
1861+
queue->push(js, getEntry(js, 4));
1862+
KJ_ASSERT(consumer1->size() == 4);
1863+
KJ_ASSERT(consumer2->size() == 4);
1864+
1865+
// Destroy queue before consumers
1866+
queue = nullptr;
1867+
1868+
// Both consumers should handle destruction gracefully
1869+
consumer1 = nullptr;
1870+
consumer2 = nullptr;
1871+
});
1872+
}
1873+
1874+
KJ_TEST("ByteQueue destroyed before consumer doesn't crash") {
1875+
preamble([](jsg::Lock& js) {
1876+
auto queue = kj::heap<ByteQueue>(2);
1877+
auto consumer = kj::heap<ByteQueue::Consumer>(*queue);
1878+
1879+
auto store = jsg::BackingStore::alloc(js, 4);
1880+
store.asArrayPtr().fill('a');
1881+
queue->push(js, kj::rc<ByteQueue::Entry>(jsg::BufferSource(js, kj::mv(store))));
1882+
KJ_ASSERT(consumer->size() == 4);
1883+
1884+
// Destroy queue before consumer
1885+
queue = nullptr;
1886+
consumer = nullptr;
1887+
});
1888+
}
1889+
1890+
KJ_TEST("ValueQueue destroyed with pending read requests doesn't crash") {
1891+
preamble([](jsg::Lock& js) {
1892+
auto queue = kj::heap<ValueQueue>(2);
1893+
auto consumer = kj::heap<ValueQueue::Consumer>(*queue);
1894+
1895+
// Queue a read request (no data pushed, so it will be pending)
1896+
auto prp = js.newPromiseAndResolver<ReadResult>();
1897+
consumer->read(js, ValueQueue::ReadRequest{.resolver = kj::mv(prp.resolver)});
1898+
1899+
KJ_ASSERT(consumer->hasReadRequests());
1900+
1901+
// Destroy queue while there are pending reads
1902+
queue = nullptr;
1903+
1904+
// Consumer destruction should handle this gracefully
1905+
consumer = nullptr;
1906+
1907+
js.runMicrotasks();
1908+
});
1909+
}
1910+
1911+
KJ_TEST("ValueQueue close then destroy before consumer doesn't crash") {
1912+
preamble([](jsg::Lock& js) {
1913+
auto queue = kj::heap<ValueQueue>(2);
1914+
auto consumer = kj::heap<ValueQueue::Consumer>(*queue);
1915+
1916+
// Close the queue first
1917+
queue->close(js);
1918+
1919+
// Then destroy it
1920+
queue = nullptr;
1921+
1922+
// Consumer should still handle destruction gracefully
1923+
consumer = nullptr;
1924+
});
1925+
}
1926+
1927+
KJ_TEST("ValueQueue error then destroy before consumer doesn't crash") {
1928+
preamble([](jsg::Lock& js) {
1929+
auto queue = kj::heap<ValueQueue>(2);
1930+
auto consumer = kj::heap<ValueQueue::Consumer>(*queue);
1931+
1932+
// Error the queue first
1933+
queue->error(js, js.v8Ref(js.v8Error("boom"_kj)));
1934+
1935+
// Then destroy it
1936+
queue = nullptr;
1937+
1938+
// Consumer should still handle destruction gracefully
1939+
consumer = nullptr;
1940+
});
1941+
}
1942+
1943+
#pragma endregion Queue / Consumer Destruction Order Tests
1944+
18201945
} // namespace
18211946
} // namespace workerd::api

src/workerd/api/streams/queue.c++

Lines changed: 47 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@ ValueQueue::Consumer::Consumer(
6868
QueueImpl& impl, kj::Maybe<ConsumerImpl::StateListener&> stateListener)
6969
: impl(impl, stateListener) {}
7070

71+
ValueQueue::Consumer::Consumer(kj::Maybe<ConsumerImpl::StateListener&> stateListener)
72+
: impl(stateListener) {}
73+
7174
void ValueQueue::Consumer::cancel(jsg::Lock& js, jsg::Optional<v8::Local<v8::Value>> maybeReason) {
7275
impl.cancel(js, maybeReason);
7376
}
@@ -102,7 +105,14 @@ size_t ValueQueue::Consumer::size() {
102105

103106
kj::Own<ValueQueue::Consumer> ValueQueue::Consumer::clone(
104107
jsg::Lock& js, kj::Maybe<ConsumerImpl::StateListener&> stateListener) {
105-
auto consumer = kj::heap<Consumer>(impl.queue, stateListener);
108+
// If the queue was destroyed (e.g., stream was closed), we can still clone
109+
// the consumer - the cloneTo() will copy the closed/errored state.
110+
kj::Own<Consumer> consumer;
111+
KJ_IF_SOME(q, impl.queue) {
112+
consumer = kj::heap<Consumer>(q, stateListener);
113+
} else {
114+
consumer = kj::heap<Consumer>(stateListener);
115+
}
106116
impl.cloneTo(js, consumer->impl);
107117
return kj::mv(consumer);
108118
}
@@ -160,7 +170,7 @@ jsg::Promise<DrainingReadResult> ValueQueue::Consumer::drainingRead(jsg::Lock& j
160170
}
161171

162172
auto& ready = impl.state.requireActiveUnsafe();
163-
ConsumerImpl::UpdateBackpressureScope scope(impl.queue);
173+
ConsumerImpl::UpdateBackpressureScope scope(impl);
164174

165175
// Mark that we're doing a draining read. This allows onConsumerWantsData()
166176
// to use forcePull() which bypasses backpressure checks. The flag is cleared
@@ -320,7 +330,7 @@ size_t ValueQueue::size() const {
320330
}
321331

322332
void ValueQueue::handlePush(
323-
jsg::Lock& js, ConsumerImpl::Ready& state, QueueImpl& queue, kj::Rc<Entry> entry) {
333+
jsg::Lock& js, ConsumerImpl::Ready& state, kj::Maybe<QueueImpl&> queue, kj::Rc<Entry> entry) {
324334
// If there are no pending reads, just add the entry to the buffer and return, adjusting
325335
// the size of the queue in the process.
326336
if (state.readRequests.empty()) {
@@ -339,7 +349,7 @@ void ValueQueue::handlePush(
339349
void ValueQueue::handleRead(jsg::Lock& js,
340350
ConsumerImpl::Ready& state,
341351
ConsumerImpl& consumer,
342-
QueueImpl& queue,
352+
kj::Maybe<QueueImpl&> queue,
343353
ReadRequest request) {
344354
// If there are no pending read requests and there is data in the buffer,
345355
// we will try to fulfill the read request immediately.
@@ -392,8 +402,10 @@ void ValueQueue::handleRead(jsg::Lock& js,
392402
}
393403
}
394404

395-
bool ValueQueue::handleMaybeClose(
396-
jsg::Lock& js, ConsumerImpl::Ready& state, ConsumerImpl& consumer, QueueImpl& queue) {
405+
bool ValueQueue::handleMaybeClose(jsg::Lock& js,
406+
ConsumerImpl::Ready& state,
407+
ConsumerImpl& consumer,
408+
kj::Maybe<QueueImpl&> queue) {
397409
// If the value queue is not yet empty we have to keep waiting for more reads to consume it.
398410
// Return false to indicate that we cannot close yet.
399411
return false;
@@ -518,6 +530,9 @@ ByteQueue::Consumer::Consumer(
518530
QueueImpl& impl, kj::Maybe<ConsumerImpl::StateListener&> stateListener)
519531
: impl(impl, stateListener) {}
520532

533+
ByteQueue::Consumer::Consumer(kj::Maybe<ConsumerImpl::StateListener&> stateListener)
534+
: impl(stateListener) {}
535+
521536
void ByteQueue::Consumer::cancel(jsg::Lock& js, jsg::Optional<v8::Local<v8::Value>> maybeReason) {
522537
impl.cancel(js, maybeReason);
523538
}
@@ -552,7 +567,14 @@ size_t ByteQueue::Consumer::size() const {
552567

553568
kj::Own<ByteQueue::Consumer> ByteQueue::Consumer::clone(
554569
jsg::Lock& js, kj::Maybe<ConsumerImpl::StateListener&> stateListener) {
555-
auto consumer = kj::heap<Consumer>(impl.queue, stateListener);
570+
// If the queue was destroyed (e.g., stream was closed), we can still clone
571+
// the consumer - the cloneTo() will copy the closed/errored state.
572+
kj::Own<Consumer> consumer;
573+
KJ_IF_SOME(q, impl.queue) {
574+
consumer = kj::heap<Consumer>(q, stateListener);
575+
} else {
576+
consumer = kj::heap<Consumer>(stateListener);
577+
}
556578
impl.cloneTo(js, consumer->impl);
557579
return kj::mv(consumer);
558580
}
@@ -582,7 +604,7 @@ jsg::Promise<DrainingReadResult> ByteQueue::Consumer::drainingRead(jsg::Lock& js
582604
}
583605

584606
auto& ready = impl.state.requireActiveUnsafe();
585-
ConsumerImpl::UpdateBackpressureScope scope(impl.queue);
607+
ConsumerImpl::UpdateBackpressureScope scope(impl);
586608

587609
// Mark that we're doing a draining read. This allows onConsumerWantsData()
588610
// to use forcePull() which bypasses backpressure checks. The flag is cleared
@@ -898,8 +920,10 @@ size_t ByteQueue::size() const {
898920
return impl.size();
899921
}
900922

901-
void ByteQueue::handlePush(
902-
jsg::Lock& js, ConsumerImpl::Ready& state, QueueImpl& queue, kj::Rc<Entry> newEntry) {
923+
void ByteQueue::handlePush(jsg::Lock& js,
924+
ConsumerImpl::Ready& state,
925+
kj::Maybe<QueueImpl&> queue,
926+
kj::Rc<Entry> newEntry) {
903927
const auto bufferData = [&](size_t offset) {
904928
state.queueTotalSize += newEntry->getSize() - offset;
905929
state.buffer.emplace_back(QueueEntry{
@@ -1046,7 +1070,7 @@ void ByteQueue::handlePush(
10461070
void ByteQueue::handleRead(jsg::Lock& js,
10471071
ConsumerImpl::Ready& state,
10481072
ConsumerImpl& consumer,
1049-
QueueImpl& queue,
1073+
kj::Maybe<QueueImpl&> queue,
10501074
ReadRequest request) {
10511075
const auto pendingRead = [&]() {
10521076
bool isByob = request.pullInto.type == ReadRequest::Type::BYOB;
@@ -1055,11 +1079,14 @@ void ByteQueue::handleRead(jsg::Lock& js,
10551079
// Because ReadRequest is movable, and because the ByobRequest captures
10561080
// a reference to the ReadRequest, we wait until after it is added to
10571081
// state.readRequests to create the associated ByobRequest.
1058-
// If the queue state is nullptr here, it means the queue has already
1059-
// been closed.
1060-
KJ_IF_SOME(queueState, queue.getState()) {
1061-
queueState.pendingByobReadRequests.push_back(
1062-
state.readRequests.back()->makeByobReadRequest(consumer, queue));
1082+
// If the queue is none, the consumer was cloned from a closed stream
1083+
// and we can't create a ByobRequest. If the queue state is none,
1084+
// the queue has already been closed.
1085+
KJ_IF_SOME(q, queue) {
1086+
KJ_IF_SOME(queueState, q.getState()) {
1087+
queueState.pendingByobReadRequests.push_back(
1088+
state.readRequests.back()->makeByobReadRequest(consumer, q));
1089+
}
10631090
}
10641091
}
10651092
KJ_IF_SOME(listener, consumer.stateListener) {
@@ -1178,8 +1205,10 @@ void ByteQueue::handleRead(jsg::Lock& js,
11781205
}
11791206
}
11801207

1181-
bool ByteQueue::handleMaybeClose(
1182-
jsg::Lock& js, ConsumerImpl::Ready& state, ConsumerImpl& consumer, QueueImpl& queue) {
1208+
bool ByteQueue::handleMaybeClose(jsg::Lock& js,
1209+
ConsumerImpl::Ready& state,
1210+
ConsumerImpl& consumer,
1211+
kj::Maybe<QueueImpl&> queue) {
11831212
// This is called when we know that we are closing and we still have data in
11841213
// the queue. We want to see if we can drain as much of it into pending reads
11851214
// as possible. If we're able to drain all of it, then yay! We can go ahead and

0 commit comments

Comments
 (0)