Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Cancelling Async Generator Hangs Indefinitely #59793

Open
tustvold opened this issue Dec 22, 2024 · 5 comments
Open

Cancelling Async Generator Hangs Indefinitely #59793

tustvold opened this issue Dec 22, 2024 · 5 comments
Labels
area-core-library SDK core library issues (core, async, ...); use area-vm or area-web for platform specific libraries. triage-automation See https://github.com/dart-lang/ecosystem/tree/main/pkgs/sdk_triage_bot. type-bug Incorrect behavior (everything from a crash to more subtle misbehavior)

Comments

@tustvold
Copy link

tustvold commented Dec 22, 2024

Problem

Given the following stream

Stream<String> foo() {
  final controller = StreamController<String>();
  controller.onListen = () => controller.add("FOO");
  controller.onCancel = () => print("Shutting down");
  controller.onPause = () => print("Pause");
  controller.onResume = () => print("Resume");
  return controller.stream;
}

If we then wrap this in

Stream<String> watch() async* {
  final response = foo();
  final mapped = response.map((response) {
    return response.toUpperCase();
  });
  yield* mapped;
}

Everything works correctly

Future main() async {
  final stream = watch().listen((x) => print(x));
  
  await Future.delayed(Duration(seconds: 1));
  
  await stream.cancel();
  print("finished");
}

Prints as expected

FOO
Shutting down
finished

If, however, we change watch to be

Stream<String> watch() async* {
  final response = foo();
  await for (var response in response) {
    yield response.toUpperCase();
  }
}

Cancellation hangs indefinitely.

The specification has the following to say on the topic.

The stream associated with an asynchronous generator could be canceled
by any code with a reference to that stream at any point
where the generator was passivated.
Such a cancellation constitutes an irretrievable error for the generator.
At this point, the only plausible action for the generator is
to clean up after itself via its \FINALLY{} clauses.%

I'm not entirely sure what passivated means, but I think hanging indefinitely is at least not an obvious behaviour if it is intentional.

Platform

I have tested this on Linux (flutter), Android (flutter) and DartPad and the behaviour seems to be consistent

@dart-github-bot
Copy link
Collaborator

Summary: Async generator using await for with a cancellable stream hangs indefinitely when the stream is cancelled, contrary to expected behavior and specification. The oncancel callback isn't triggered.

@dart-github-bot dart-github-bot added area-core-library SDK core library issues (core, async, ...); use area-vm or area-web for platform specific libraries. triage-automation See https://github.com/dart-lang/ecosystem/tree/main/pkgs/sdk_triage_bot. type-bug Incorrect behavior (everything from a crash to more subtle misbehavior) labels Dec 22, 2024
@tustvold
Copy link
Author

tustvold commented Dec 22, 2024

Interestingly if you modify the reproducer to be

Stream<String> foo() {
  final controller = StreamController<String>();
  controller.onListen = () {
    controller.add("FOO");
    Future.delayed(Duration(seconds: 2), () => controller.add("BAR"));
  };
  controller.onCancel = () => print("Shutting down");
  controller.onPause = () => print("Pause");
  controller.onResume = () => print("Resume");
  return controller.stream;
}

The generator shuts down once BAR is produced, in which case this may be related to, or a duplicate of, #45645.

In particular

Our async* functions are implemented incorrectly on some platforms (maybe all, not sure any more). They do not check after the yield has synchronously delivered an event whether they have been cancelled or paused, they just continue execution.
That means that the subscription.cancel call must wait until the next yield to exit the async* function.

@lrhn
Copy link
Member

lrhn commented Dec 23, 2024

Cancellation hangs indefinitely.

That seems to be correct and expected behavior.

You have an await for that is currently waiting for the next event, which never comes, when you cancel the surrounding async* function's subscription.
That changes nothing. Such a cancellation has no effect until control reaches a yield or yield* statement, which it never does.
It's no different from the sync*function being stuck at an await future where the future never completes, or an async function being stuck at the same places, it's a stuck-ness unrelated to being async*, so cancel has no effect on it.

The spec text is descriptive. Being "passivated" is presumably it being waiting at a yield or yield*.
It's a little misleading to use the word "error", because there is no error involved.

The effect of cancelling an async* function execution is simple: an active yield* cancels its subscription and waits for the returned future. Then every yield and yield* statement behaves just like return;. No more and no less.
If it's stuck at any point that is not a yield or yield*, it stays stuck.

@tustvold
Copy link
Author

tustvold commented Dec 23, 2024

Ok, it's a little odd to me that cancellation wouldn't be preemptive, but if that is the way it is that's the way it is.

My challenge is the stream corresponds to a network request, and with this behaviour there is no way to cancel it until the server sends more data. Is there some alternative cancellation mechanism, or is it simply a case of generators can't be used here?

Unless I'm missing something this makes generators a potential gold mine for resource leaks, which should probably be called out as a big warning, unless this behaviour can be changed?

@lrhn
Copy link
Member

lrhn commented Dec 24, 2024

It's non-trivial to integrate cancellation into the await for loop, probably requires it to not be an async generator to begin with.

You can only cancel at yields because cancelling at any normal await is a bigger risk of resource leak.

If you can cancel at an await future or await for(...), then the future result or next stream value will be dropped on the floor. If that value requires disposing, you have a leak.

It's unexpected to have a future that never completes or a steam that never emits another event (not even a done event). You may have to wait for a while, but you shouldn't wait forever. That means that leaks should be at most temporary.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
area-core-library SDK core library issues (core, async, ...); use area-vm or area-web for platform specific libraries. triage-automation See https://github.com/dart-lang/ecosystem/tree/main/pkgs/sdk_triage_bot. type-bug Incorrect behavior (everything from a crash to more subtle misbehavior)
Projects
None yet
Development

No branches or pull requests

3 participants