Why `AsyncStream` breaks structured concurrency?

That's what I get for writing code directly in a post, instead of making a mini-program. Applied the suggested fixes, thanks.

But it gave me a chance to learn something new about automatic cancelling of async let (I thought it would just cause a compilation error if you failed to await), so worth it.

P.S. When it's possible for the continuation to finish, I might actually prefer defer { continuation.finish() } as a safety net, so the continuation finishes alongside the containing task (making it closer to a generator).

2 Likes

It’s not salient to the original question, but I would not recommend defer here: We use defer to invoke the same block of code for all paths of execution (notably, seamlessly handling all the potential “early exits”). But in this case, we explicitly only call finish if it has ended successfully.

I believe that calling finish on a cancelled AsyncStream is a NOOP, so no harm is done, but, I wouldn’t advise it.

The point of using defer is to erase the possibility that the task completes while the continuation is still "live". Such a situation would cause a deadlock when trying to iterate, so better safe than sorry.

As an aside, it's not even guaranteed the AsyncStream is cancelled from the containing task being cancelled - It would only be cancelled if body was right in the process of iterating through it. Sure, any attempt to read from it inside body after the task was cancelled would instantly cancel it, but there's no harm in finishing it first, so there's no chance of it blocking.

It also helps for any other way the containing task could end. For example, if instead of Task.sleep it runs a task that can throw something other than CancellationError, it would ensure that an error would result in an end of stream. Using AsyncThrowingStream might be more appropriate for that (so the error isn't discarded), but slightly more complex to set up.