Skip to content

Conversation

lrhn
Copy link
Member

@lrhn lrhn commented Sep 10, 2025

Was very under-specified.

This change changes the responsiveness of a yield* to be that of StreamController.addStream, meaning that it immediately reacts to pause and cancel and forwards it to the inner stream. Unlike await for, a yield* is at the yield for the entire duration of the inner stream, and can cancel at any time, not only when control reaches a yield inside the loop.

Experience has taught us that with async* functions, await for and yield*, back-pressure using cancel or pause must be acted on immediately, to avoid a stream computation continuing when it has nothing meaningful to do, and the caller knows that and tries to stop it.

For example, if you do stream.first, it should cancel after the first event, before starting on the computation of the next event, because then it's too late to cancel. Canonical example:

// Stream with one event and no done event.
Stream<int> oneValue => Stream<int>.multi((c) => c.add(1));
Stream<int> clone1(Stream<int> stream) async* {
  await for (var event in stream) yield event;
}
Stream<int> clone2(Stream<int> stream) async* {
  yield* stream;
}
void main() async {
  print(await oneValue.first); // 1
  print(await clone1(oneValue).first);
  print(await clone2(oneValue).first);
  print(await clone2(clone1(oneValue)).first);
}

If the first code's cancel doesn't reach the clone1 before it goes back to the await for loop, the cancel will never be processed because control never reaches a yield again.

  • Thanks for your contribution! Please replace this text with a description of what this PR is changing or adding and why, list any relevant issues, and review the contribution guidelines below.

  • I’ve reviewed the contributor guide and applied the relevant portions to this PR.
Contribution guidelines:

Note that many Dart repos have a weekly cadence for reviewing PRs - please allow for some latency before initial review feedback.

Was very under-specified.

This change changes the responsiveness of a `yield*` to be that
of `StreamController.addStream`, meaning that it *immediately*
reacts to pause and cancel and forwards it to the inner stream.
Unlike `await for`, a `yield*` is at the yield for the entire
duration of the inner stream, and can cancel at any time, not only
when control reaches a `yield` inside the loop.

Experience has taught us that with `async*` functions, `await for`
and `yield*`, back-pressure using `cancel` or `pause` *must*
be acted on immediately, to avoid a stream computation continuing
when it has nothing meaningful to do, and the caller knows that
and tries to stop it.

For example, if you do `stream.first`, it should cancel after
the first event, before starting on the computation of the next
event, because then it's too late to cancel. Canonical example:
```dart
// Stream with one event and no done event.
Stream<int> oneValue => Stream<int>.multi((c) => c.add(1));
Stream<int> clone1(Stream<int> stream) async* {
  await for (var event in stream) yield event;
}
Stream<int> clone2(Stream<int> stream) async* {
  yield* stream;
}
void main() async {
  print(await oneValue.first); // 1
  print(await clone1(oneValue).first);
  print(await clone2(oneValue).first);
  print(await clone2(clone1(oneValue)).first);
}
```
If the `first` code's cancel doesn't reach the `clone1` before
it goes back to the `await for` loop, the cancel will never
be processed because control never reaches a `yield` again.
@lrhn lrhn changed the title Update spec for yield\*. Update spec for yield*. Sep 10, 2025
@lrhn
Copy link
Member Author

lrhn commented Sep 10, 2025

@eernstg WDYT?

Copy link
Member

@eernstg eernstg left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM with a couple of comments.


\LMHash{}%
If $m$ is marked \code{\ASYNC*} (\ref{functions}), then:

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can't comment on line 19751, so I'll put it here: What happens if the evaluation of e in line 19751 does not complete normally? The interesting case would be async*.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The general rule is that if any step of the execution of a statement completes non-normally, unless something else is explicitly stated (like it is for try/catch), the execution of the statement completes in the same non-normal way.

So when await for (var x in (throw "Banana")) { ... } evaluates throw "Banana", the (throw "Banana") statement and then the enitire await for statement completes throwing "Banana" and a stack trace.

@lrhn lrhn merged commit ac2a70f into main Sep 15, 2025
4 checks passed
@lrhn lrhn deleted the yieldstar-semantics branch September 15, 2025 09:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants