Skip to content

feat(reactive): Cancel an effect's in-progress run when it is destroyed - #2515

Draft
schloerke wants to merge 5 commits into
schloerke/async-pr-2182-reimplfrom
schloerke/async-session-end-cancel
Draft

schloerke wants to merge 5 commits into
schloerke/async-pr-2182-reimplfrom
schloerke/async-session-end-cancel

Conversation

@schloerke

@schloerke schloerke commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

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 end: the effect ran on to completion after its session was gone. Harmless, thanks to fix: Soft teardown of reactives when a session closes #2428's soft teardown, but wasted work (e.g. an expensive API call nobody will see).
  • session.destroy(id): a module's effect paused mid-await woke up after the scope's values had been destroyed. Its next read raised DestroyedReactiveError, which closed the whole session. On main this 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 an await, and a re-run waiting for it. CancelledError is raised at the current await, so finally blocks run. Ending a session and session.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:

    • an effect that destroys itself;
    • an effect that closes its own session, including through an error, which finishes the teardown (on_ended callbacks, 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.

  • ExtendedTask is not cancelled at session end, same as before and as in R. The skill says to call task.cancel() from session.on_ended if it should stop.

  • A destroyed effect's errors are logged, not sent to the session. A cancelled run's cleanup (finally, except CancelledError) runs after session.destroy(id) has destroyed the scope's values, so cleanup that reads one raises DestroyedReactiveError. 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's destroy() 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's asyncio can cancel it, and here that's what keeps a destroyed module's effect from reading its destroyed values.

Docs

  • Effect_.destroy() and Session.destroy() docstrings describe the cancellation.
  • CHANGELOG bullet under feat: Run reactive effects #2508's breaking-changes entry.
  • The bundled skill's session-lifecycle reference says to put cleanup in try/finally and not to swallow CancelledError.

Verification

  • 19 new test cases in tests/pytest/test_concurrency.py, with one existing test tightened. They cover:
    • destroying a running effect, and a queued re-run;
    • not cancelling finished effects or other sessions' effects;
    • an effect destroying itself, directly and from a child task;
    • session end cancelling effects and render outputs, with the busy count settling;
    • an effect error closing the session and cancelling its siblings;
    • an effect closing its own session, directly and via gather;
    • scope destroy cancelling before values go, including nested scopes and leaving prefix-sharing sibling scopes alone;
    • a cancelled calc run, with and without a waiting reader, and not reacting to sources only the cancelled run read;
    • cleanup that reads destroyed values (finally, except CancelledError, and an effect destroying its own scope) leaving the session open.
  • Each fix was reverted on its own, and at least one test failed every time. The scope-destroy test fails without the fix exactly as described above: the session closes.
  • pytest tests/pytest: 1309 passed. pyright, pyrefly, flake8 and black are clean.

@schloerke
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
@schloerke schloerke mentioned this pull request Oct 1, 2026
13 of 15 tasks
@schloerke
schloerke force-pushed the schloerke/async-session-end-cancel branch from e7b8659 to a5e2656 Compare October 1, 2026 20:00
@schloerke
schloerke force-pushed the schloerke/async-session-end-cancel branch 2 times, most recently from fe17050 to 6380cb7 Compare October 2, 2026 19:11
`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
jat255 force-pushed the schloerke/async-session-end-cancel branch from 70947ff to 8320cfd Compare October 3, 2026 14:31

This branch has not been deployed

No deployments
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.

1 participant