Skip to content

ENG-9661 fix: cancel stale on_load chains via handler-declared supersession#6713

Open
FarhanAliRaza wants to merge 1 commit into
reflex-dev:mainfrom
FarhanAliRaza:cancel-event
Open

ENG-9661 fix: cancel stale on_load chains via handler-declared supersession#6713
FarhanAliRaza wants to merge 1 commit into
reflex-dev:mainfrom
FarhanAliRaza:cancel-event

Conversation

@FarhanAliRaza

Copy link
Copy Markdown
Contributor

Navigating away while a page's on_load chain is still running left the stale chain executing, blocking the new page's events behind it and applying its late deltas (#6593).

Add a SUPERSEDES_MARKER for event handlers with latest-wins semantics: enqueuing a new chain-root invocation cancels the previous unfinished event chain rooted at the same handler for the same client token. Mark on_load_internal with it so a newer navigation supersedes the previous page's unfinished load.

The EventProcessor tracks the active chain root per (event name, token), drops events chained from an already-cancelled parent before they enter the queue, and defers future cleanup while the handler task is still unwinding so late-chained events can find their cancelled parent.

All Submissions:

  • Have you followed the guidelines stated in CONTRIBUTING.md file?
  • Have you checked to ensure there aren't any other open Pull Requests for the desired changed?

Type of change

Please delete options that are not relevant.

  • New feature (non-breaking change which adds functionality)

New Feature Submission:

  • Does your submission pass the tests?
  • Have you linted your code locally prior to submission?

Changes To Core Features:

  • Have you added an explanation of what your changes do and why you'd like us to include them?
  • Have you written new tests for your core changes, as applicable?
  • Have you successfully ran tests with your changes locally?

Navigating away while a page's on_load chain is still running left the
stale chain executing, blocking the new page's events behind it and
applying its late deltas (reflex-dev#6593).

Add a SUPERSEDES_MARKER for event handlers with latest-wins semantics:
enqueuing a new chain-root invocation cancels the previous unfinished
event chain rooted at the same handler for the same client token. Mark
on_load_internal with it so a newer navigation supersedes the previous
page's unfinished load.

The EventProcessor tracks the active chain root per (event name, token),
drops events chained from an already-cancelled parent before they enter
the queue, and defers future cleanup while the handler task is still
unwinding so late-chained events can find their cancelled parent.
@FarhanAliRaza FarhanAliRaza requested a review from a team as a code owner July 6, 2026 22:31

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2db865caa9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +509 to +512
if future.txid in self._tasks:
# The handler task is still running or unwinding; keep the future
# so late-chained events can find their (possibly cancelled) parent.
return

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Don’t retain failed futures during exception recovery

When a state handler raises and BaseStateEventProcessor starts backend_exception_handler, _finish_task stores that exception-handler task in _tasks under the same txid after setting the original future's exception. This new guard keeps the already-done failed future in _futures, so if the exception handler returns a backend EventSpec as the public API allows, ctx.enqueue() finds that done future as the parent and add_child() raises instead of queuing the recovery event. The retention here is only needed for cancelled tasks unwinding, not for failed futures being handled by the backend exception handler.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes stale on_load chains (issue #6593) by introducing a SUPERSEDES_MARKER / latest-wins supersession mechanism: when a new navigation arrives, EventProcessor._supersede_previous cancels the previous unfinished chain rooted at the same handler for the same client token. A companion deferred-cleanup guard (if future.txid in self._tasks: return in _try_clean_future) keeps the cancelled parent future alive in _futures while its task unwinds, preventing resurrection attempts from being misidentified as new chain roots.

  • EventProcessor gains _superseded tracking, updated enqueue (stillborn-child short-circuit + supersession call), revised _dispatch_next_for_token / _process_queue to skip missing-future entries, and a deferred cleanup call at the end of _finish_task.
  • reflex/state.py marks on_load_internal with the new SUPERSEDES_MARKER via post-class setattr and propagates the marker through _copy_fn; three unit tests plus one integration test cover the running-chain, queued-chain, and resurrection scenarios.

Confidence Score: 3/5

The supersession logic is carefully designed and the test suite validates the key paths, but an unchecked edge case in enqueue can raise RuntimeError if a backend exception handler tries to chain events.

The new guard in enqueue checks parent_future.cancelled() but EventFuture.add_child raises RuntimeError for any done() state. When backend_exception_handler is set and a handler raises, _finish_task calls set_exception on the parent future then spawns a replacement task reusing the same txid — if that task calls ctx.enqueue, the parent is done-with-exception, cancelled() returns False, the guard is skipped, and add_child raises at runtime.

packages/reflex-base/src/reflex_base/event/processor/event_processor.py — specifically the parent_future guard in enqueue and its interaction with the backend_exception_handler task path in _finish_task.

Important Files Changed

Filename Overview
packages/reflex-base/src/reflex_base/event/processor/event_processor.py Core change: adds supersession tracking, deferred-cleanup guard while tasks unwind, and cancellation skip for missing futures. The cancelled()-only guard in enqueue leaves a RuntimeError path when a parent future is done-with-exception.
packages/reflex-base/src/reflex_base/event/processor/future.py Adds supersede_key field to EventFuture for latest-wins bookkeeping; straightforward dataclass field addition.
reflex/state.py Applies SUPERSEDES_MARKER to on_load_internal via post-class setattr and propagates the marker through _copy_fn; setattr uses a literal string key that would raise at import time if the method is renamed.
packages/reflex-base/src/reflex_base/event/init.py Adds SUPERSEDES_MARKER constant and corresponding supersedes property on EventHandler; clean and consistent with existing BACKGROUND_TASK_MARKER pattern.
tests/units/reflex_base/event/processor/test_event_processor.py Adds three well-targeted tests for supersession: running-chain cancellation, queued-chain skipping, and resurrection prevention. _drain_superseded uses fragile polling that could flake under load.
tests/units/test_state.py Adds an integration test verifying that a second on_load_internal navigation cancels the stale slow_handler and completes cleanly; covers the real-world bug scenario end-to-end.
news/6593.bugfix.md Changelog entry for the bug fix.

Reviews (1): Last reviewed commit: "fix: cancel stale on_load chains via han..." | Re-trigger Greptile

Comment on lines 405 to 411
if parent_future is not None:
if parent_future.cancelled():
# The chain this event belongs to was cancelled; the event is
# stillborn and never enters the queue.
tracked.cancel()
return tracked
parent_future.add_child(tracked)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Guard only covers cancelled(), not all terminal states

The new early-return guards against parent_future.cancelled(), but add_child raises RuntimeError if the parent is done() in any state. A parent future can be done-with-exception (not cancelled) when the backend_exception_handler creates a replacement task reusing the same ev_ctx.txid: by that point set_exception has already been called on the parent future, so parent_future.done() is True and parent_future.cancelled() is False. If that exception-handler task then calls ctx.enqueue(...), add_child raises RuntimeError. Using parent_future.done() instead of parent_future.cancelled() would make the guard consistent with what add_child actually checks.

Comment thread reflex/state.py
Comment on lines +2471 to +2475
setattr(
OnLoadInternalState.event_handlers["on_load_internal"].fn,
SUPERSEDES_MARKER,
True,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Using setattr with a string key to mark on_load_internal post-class-definition will raise an unguarded KeyError at import time if the method is ever renamed. The existing BACKGROUND_TASK_MARKER is set before class creation via a decorator; it would be safer to give on_load_internal the same treatment, or at least guard with a .get() check.

Suggested change
setattr(
OnLoadInternalState.event_handlers["on_load_internal"].fn,
SUPERSEDES_MARKER,
True,
)
_on_load_internal_handler = OnLoadInternalState.event_handlers.get(
"on_load_internal"
)
if _on_load_internal_handler is not None:
setattr(_on_load_internal_handler.fn, SUPERSEDES_MARKER, True)
else:
raise RuntimeError(
"on_load_internal handler not found; "
"SUPERSEDES_MARKER could not be applied."
)

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Comment on lines +815 to +824
async def _drain_superseded(ep: EventProcessor) -> None:
"""Give done callbacks a few ticks to clean the supersession tracking.

Args:
ep: The event processor to wait on.
"""
for _ in range(20):
if not ep._superseded:
return
await asyncio.sleep(0)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 _drain_superseded polls rather than waiting on the chain

The helper spins up to 20 event-loop ticks and returns early only if _superseded is empty. It offers no signal if the dict is still populated after those ticks — the test just continues and then asserts ep._superseded == {}. If cleanup ever takes more than ~20 scheduling rounds (e.g., under CI load), the assertion can fail non-deterministically. A more reliable approach would be to yield one final tick after current.wait_all() (already awaited just before the call), or use asyncio.wait_for on a poll with an explicit timeout.

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