Skip to content

Commit b25e45c

Browse files
committed
fix ReadableStream.from() cancel behavior per WPT spec
1 parent 6045d2a commit b25e45c

File tree

3 files changed

+38
-17
lines changed

3 files changed

+38
-17
lines changed

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

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4195,7 +4195,18 @@ jsg::Ref<ReadableStream> ReadableStream::from(
41954195
}));
41964196
},
41974197
.cancel = [generator = rcGenerator.addRef()](jsg::Lock& js, auto reason) mutable {
4198-
return generator->generator.return_(js, js.v8Ref(reason))
4198+
bool pedanticWpt = FeatureFlags::get(js).getPedanticWpt();
4199+
// When pedanticWpt is enabled, follow the spec more strictly:
4200+
// 1. If return property exists but is not callable, throw TypeError
4201+
// 2. Propagate exceptions from return() directly (don't call throw_())
4202+
if (pedanticWpt) {
4203+
if (generator->generator.hasNonCallableReturn()) {
4204+
return js.rejectedPromise<void>(
4205+
js.typeError("property 'return' is not a function"_kj));
4206+
}
4207+
}
4208+
4209+
return generator->generator.return_(js, js.v8Ref(reason), pedanticWpt)
41994210
.then(js, [generator = kj::mv(generator)](auto& lock, auto) {
42004211
// The generator might produce a value on return and might even want to continue,
42014212
// but the stream has been canceled at this point, so we stop here.

src/workerd/jsg/iterator.h

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,15 @@ class AsyncGenerator final {
173173
}
174174
}
175175

176+
// Returns true if the 'return' property exists on the iterator but is not callable.
177+
// Per GetMethod spec, this should result in a TypeError when return_() is called.
178+
bool hasNonCallableReturn() const {
179+
KJ_IF_SOME(active, maybeActive) {
180+
return active.returnExistsButNotCallable;
181+
}
182+
return false;
183+
}
184+
176185
// If nothing is returned, the generator is complete.
177186
Promise<kj::Maybe<T>> next(Lock& js) {
178187
KJ_IF_SOME(active, maybeActive) {
@@ -205,7 +214,10 @@ class AsyncGenerator final {
205214
}
206215

207216
// If nothing is returned, the generator is complete.
208-
Promise<kj::Maybe<T>> return_(Lock& js, kj::Maybe<T> maybeValue = kj::none) {
217+
// When propagateException is true, rejections from return() are propagated directly
218+
// without calling throw_(). This matches spec behavior for ReadableStream.from.
219+
Promise<kj::Maybe<T>> return_(
220+
Lock& js, kj::Maybe<T> maybeValue = kj::none, bool propagateException = false) {
209221
KJ_IF_SOME(active, maybeActive) {
210222
KJ_IF_SOME(return_, active.maybeReturn) {
211223
auto& selfRef = KJ_ASSERT_NONNULL(maybeSelfRef);
@@ -216,17 +228,21 @@ class AsyncGenerator final {
216228
ref->runIfAlive([&](AsyncGenerator& self) { self.maybeActive = kj::none; });
217229
}
218230
return js.resolvedPromise(kj::mv(result.value));
219-
}, [ref = selfRef.addRef()](Lock& js, Value exception) {
231+
}, [ref = selfRef.addRef(), propagateException](Lock& js, Value exception) {
220232
Promise<kj::Maybe<T>> retPromise = nullptr;
221-
if (ref->runIfAlive([&](AsyncGenerator& self) {
233+
if (!propagateException && ref->runIfAlive([&](AsyncGenerator& self) {
222234
retPromise = self.throw_(js, kj::mv(exception));
223235
})) {
224236
return kj::mv(retPromise);
225237
}
238+
ref->runIfAlive([&](AsyncGenerator& self) { self.maybeActive = kj::none; });
226239
return js.rejectedPromise<kj::Maybe<T>>(kj::mv(exception));
227240
});
228241
}, [&](Value exception) {
229242
maybeActive = kj::none;
243+
if (propagateException) {
244+
return js.rejectedPromise<kj::Maybe<T>>(kj::mv(exception));
245+
}
230246
return throw_(js, kj::mv(exception));
231247
});
232248
}
@@ -276,13 +292,19 @@ class AsyncGenerator final {
276292
kj::Maybe<NextSignature> maybeNext;
277293
kj::Maybe<ReturnSignature> maybeReturn;
278294
kj::Maybe<ThrowSignature> maybeThrow;
295+
// Per GetMethod spec, if property exists but is not callable, we should throw TypeError.
296+
// We track this state to defer the error to when return_() is actually called.
297+
bool returnExistsButNotCallable = false;
279298

280299
template <typename TypeWrapper>
281300
Active(Lock& js, JsObject object, TypeWrapper*)
282301
: maybeNext(tryGetGeneratorFunction<NextSignature, TypeWrapper>(js, object, "next"_kj)),
283302
maybeReturn(
284303
tryGetGeneratorFunction<ReturnSignature, TypeWrapper>(js, object, "return"_kj)),
285304
maybeThrow(tryGetGeneratorFunction<ThrowSignature, TypeWrapper>(js, object, "throw"_kj)) {
305+
// Check if return property exists but isn't callable (per GetMethod spec)
306+
returnExistsButNotCallable =
307+
maybeReturn == kj::none && !object.get(js, "return"_kj).isNullOrUndefined();
286308
}
287309
Active(Active&&) = default;
288310
Active& operator=(Active&&) = default;

src/wpt/streams-test.ts

Lines changed: 1 addition & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -422,19 +422,7 @@ export default {
422422
'Floating point arithmetic must manifest near 0 (total ends up zero)',
423423
],
424424
},
425-
'readable-streams/from.any.js': {
426-
comment: 'See comments on tests',
427-
disabledTests: [
428-
// A hanging promise was cancelled
429-
'ReadableStream.from: cancel() rejects when return() rejects',
430-
'ReadableStream.from: cancel() rejects when return() fulfills with a non-object',
431-
],
432-
expectedFailures: [
433-
// TODO(soon): This one is a bit pedantic. We ignore the case where return() is not
434-
// a method whereas the spec expects us to return a rejected promise in this case.
435-
'ReadableStream.from: cancel() rejects when return() is not a method',
436-
],
437-
},
425+
'readable-streams/from.any.js': {},
438426
'readable-streams/garbage-collection.any.js': {
439427
comment: 'See comments on individual tests',
440428
disabledTests: [

0 commit comments

Comments
 (0)