Repository navigation
Conversation
schloerke
added this pull request to stack #2516
October 1, 2026 19:36
schloerke
added a commit
that referenced
this pull request
Oct 1, 2026
13 of 15 tasks
schloerke
force-pushed
the
schloerke/async-session-end-cancel
branch
from
October 1, 2026 20:00
e7b8659 to
a5e2656
Compare
schloerke
force-pushed
the
schloerke/async-session-end-cancel
branch
2 times, most recently
from
October 2, 2026 19:11
fe17050 to
6380cb7
Compare
`Effect_.destroy()` now cancels runs of the effect that are still in progress (paused at an `await`, or a re-run waiting on the previous run). Session end and `session.destroy(id)` destroy their effects, so both now cancel running async effects and render functions instead of letting them run on. This fixes a race introduced by running effects concurrently: a module's effect paused mid-`await` could wake after `session.destroy(id)` removed the module's values, and the resulting DestroyedReactiveError closed the whole session. Effects are destroyed before the scope's values, so the cancellation lands first. The run that calls `destroy()` (directly, or from a task it started) is not cancelled, so an effect that closes or destroys its own session still finishes the teardown. Also fix an async calc whose run is cancelled: it cached no value and no error, so the next reader, or one waiting on that run, raised IndexError. It now recomputes.
… session A cancelled effect's cleanup (`finally`, or `except CancelledError`) runs after `session.destroy(id)` has destroyed the scope's values, so reading one raises DestroyedReactiveError, which closed the whole session. An effect that has been destroyed has no session to protect, so its errors are now logged only. Also ignore invalidations from a calc's superseded runs. A cancelled run stays subscribed to the sources it read, so a later change to one of them invalidated the calc and re-ran its dependents, even if the current run never read that source. Reword the changelog, which described a session-closing failure no release had.
jat255
force-pushed
the
schloerke/async-session-end-cancel
branch
from
October 3, 2026 14:31
70947ff to
8320cfd
Compare
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #2508. Fixes two of its open TODOs: cancelling in-flight tasks at session end, and
destroy()racing effects that are mid-run.Problem
With #2508, a flush no longer waits for an effect's async part, so a session can end, or a module scope can be destroyed, while an effect is paused at an
await:session.destroy(id): a module's effect paused mid-awaitwoke up after the scope's values had been destroyed. Its next read raisedDestroyedReactiveError, which closed the whole session. Onmainthis couldn't happen, because the global lock serialized effects and the destroy.Change
Effect_.destroy()cancels the effect's in-progress runs: the run paused at anawait, and a re-run waiting for it.CancelledErroris raised at the currentawait, sofinallyblocks run. Ending a session andsession.destroy(id)both destroy their effects (including render functions), so both now cancel them. Effects are destroyed before the scope's values, so the cancellation lands before any read.The run that calls
destroy()is not cancelled, whether it calls it directly or from a task it started (asyncio.gather(),wait_for()). That keeps working:on_endedcallbacks, removing the session).An async calc whose run is cancelled now recomputes. Before, it cached neither a value nor an error, so the next reader, or one waiting to share that run, raised
IndexError. Cancelling one of a calc's two readers made this easy to hit.ExtendedTaskis not cancelled at session end, same as before and as in R. The skill says to calltask.cancel()fromsession.on_endedif it should stop.A destroyed effect's errors are logged, not sent to the session. A cancelled run's cleanup (
finally,except CancelledError) runs aftersession.destroy(id)has destroyed the scope's values, so cleanup that reads one raisesDestroyedReactiveError. A destroyed effect has no session to protect, so that no longer closes the session.A calc ignores invalidations from its superseded runs. A cancelled run stays subscribed to what it read, so a later change to one of those sources re-ran the calc and its dependents, even if the current run never read that source.
Compared with R:
observer$destroy()in R also stops all future runs, which is what py-shiny'sdestroy()did before this PR too. But R can't stop an async run already in progress, because a promise can't be cancelled, so that run continues to the end. Python'sasynciocan cancel it, and here that's what keeps a destroyed module's effect from reading its destroyed values.Docs
Effect_.destroy()andSession.destroy()docstrings describe the cancellation.try/finallyand not to swallowCancelledError.Verification
tests/pytest/test_concurrency.py, with one existing test tightened. They cover:gather;finally,except CancelledError, and an effect destroying its own scope) leaving the session open.pytest tests/pytest: 1309 passed. pyright, pyrefly, flake8 and black are clean.