-
Notifications
You must be signed in to change notification settings - Fork 8
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
Run on.exit()
expressions when loops break early
#58
Conversation
And fix bug that caused cleaning up twice in generators
This looks great to me! I don't have the context to review the implementation but I tested some scenarios and everything worked as I thought it might. The one area I was slightly surprised by is when the for-loop body raises an exception, and an To set a precedent, let's take coro totally out of it and try a regular > (function() {
+ on.exit(stop("boom2"))
+ stop("boom")
+ })()
Error in (function() { : boom
Error in (function() { : boom2 I'm a little surprised both errors print; if I put a Synchronous coro: > g <- coro::generator(function() {
+ on.exit(stop("boom2"))
+ yield(1)
+ yield(2)
+ })
>
> coro::loop(
+ for (i in g()) {
+ stop("boom")
+ }
+ )
Error in eval_bare(loop, env) : boom
Error in evalq(envir = user_env, base::evalq(envir = rlang::wref_key(<weak reference>), :
boom2 Pretty similar. Async coro: > g <- coro::async_generator(function() {
+ on.exit(stop("boom2"))
+ yield(1)
+ yield(2)
+ })
>
> coro::async(function() {
+ for (i in await_each(g())) {
+ stop("boom")
+ }
+ })()
>
Unhandled promise error: boom2 No boom1, it's been lost along the way. I just thought this was interesting, I'm not saying anything needs to change about this behavior, necessarily. Like I said, cleanup code throwing exceptions is almost always a bad time. |
Good catch! I tried to model base R's behaviour in generators but for async functions that's trickier. The thing is that in non async code, errors are handled "on the spot" by R's default handler (the one that prints the message to stderr and jumps to top level). I'm not sure what would be the ideal behaviour in async functions. Since that's a rather specific edge case, I agree we can just merge this as is for now as it's a vast improvement for generator cleanups and revisit the async case in the future. |
oh probably worth noting that the async case is consistent with: f <- function() {
on.exit(stop("boom2"))
stop("boom")
}
tryCatch(
f(),
error = identity
)
#> <simpleError in f(): boom2> And in Positron we use global handlers to capture errors and see this same behaviour where the first error silently disappears: f()
#> Error in `f()`:
#> ! boom2 So I think this is just a consequence of handling errors at catch sites rather than at error sites. |
Makes total sense. If anything, seems like maybe I should have put more thought into how the promises package could do condition handling more like R instead of doing a pretty literal port of JavaScript's promises. |
Fixes #52.
A generator is allowed to register exit handlers via
on.exit()
or similar (e.g.withr::defer()
). Currently these exit expressions only run when the generator returns, or on unexpected exit such as an error happening while the generator is executing. They are not run when an iteration driver decides to cancel the iteration, or is interrupted by an unexpected exit. E.g.:To fix this:
We introduce the notion of closing an iterator. A closable iterator has a
close
argument in its formals. When it does, coro loop drivers (eithercoro::loop()
orfor
loops inside generators) call the iterator withclose = TRUE
when iteration has finished or is interrupted. The iterator can then clean up resources.A closable iterator should ignore any further call with
close = TRUE
as coro and other iteration drivers are allowed to do that multiple times to simplify their implementations. They are also allowed to call an exhausted iterator withclose = TRUE
, even though resource cleanup should have been accomplished already.Generators now produce iterators with a
close
argument. Closing a generator has two effects: First, all iterators in activefor
loops are closed, from the most nested to the least nested one. This triggers a cleanup chain across generators/iterators. Second, the generator's ownon.exit()
expressions are run.coro::loop()
now closes its iterator on exit.The same applies to async generators.
Note that Python also closes generators on garbage collection. I decided not to do that because resource cleanup should be hierarchical. Instead, it's up to the generator caller to ensure proper closing of their generator. The easiest way is to use
coro::loop()
. Javascript doesn't close on GC either, see https://esdiscuss.org/topic/garbage-collection-in-generators for some discussion.