Skip to content

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

Open
FarhanAliRaza wants to merge 2 commits 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 2 commits 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 a stale on_load chain bug (#6593) where navigating away while a page's load chain was still running left stale events executing, blocking new-page events and applying late deltas. It introduces a SUPERSEDES_MARKER / latest-wins mechanism: on_load_internal is marked so that enqueuing a new navigation automatically cancels the previous unfinished load chain for the same client token.

  • EventProcessor._supersede_previous cancels the prior chain root, registers the new future as the active owner under (event_name, token), and propagates cancellation through existing child-future machinery (EventFuture.cancel() + _on_future_done task cancellation).
  • Deferred cleanup guard in _try_clean_future keeps the cancelled future in _futures while its task unwinds, ensuring that resurrection attempts are correctly rejected via the parent_future.cancelled() check in enqueue.
  • Three new unit tests cover the core cancellation, queue-skip, and resurrection-prevention scenarios; a state-level integration test confirms the end-to-end on_load_internal supersession path.

Confidence Score: 4/5

Safe to merge for the targeted bug; the supersession logic is sound and well-tested, but event_processor.py carries pre-existing edge cases around exception-handler task re-use that this PR partially exposes and that remain unresolved.

The core fix is correctly implemented and the tests exercise the key paths well. The _try_clean_future deferred-cleanup guard and the _superseded ownership invariant are subtle but correct on careful trace. The remaining concern is the enqueue parent-future guard, which only checks one terminal state and could raise RuntimeError via add_child when a parent future reaches a different terminal state through the exception-handler task path.

event_processor.py — the _try_clean_future deferred-cleanup guard and the enqueue parent-future terminal-state check warrant a second look before landing.

Important Files Changed

Filename Overview
packages/reflex-base/src/reflex_base/event/processor/event_processor.py Core supersession logic: adds _superseded dict, _supersede_previous(), deferred-cleanup guard in _try_clean_future, and queue-skip updates; complex but broadly correct, with subtle edge cases around future state-machine completeness
packages/reflex-base/src/reflex_base/event/processor/future.py Adds supersede_key field to EventFuture; minimal, clean change, correctly defaulting to None
packages/reflex-base/src/reflex_base/event/init.py Adds SUPERSEDES_MARKER constant and EventHandler.supersedes property, mirroring the existing BACKGROUND_TASK_MARKER/is_background pattern; clean
reflex/state.py Marks on_load_internal with SUPERSEDES_MARKER via post-class setattr; also correctly propagates the new marker through _copy_fn. The setattr approach is fragile if the method is renamed.
tests/units/reflex_base/event/processor/test_event_processor.py Three new integration tests covering supersession cancellation, queue-skipping, and resurrection prevention; good coverage but _drain_superseded uses a polling loop without a hard timeout guard
tests/units/test_state.py Adds an end-to-end test verifying that a second on_load_internal enqueue cancels the first stale chain; also verifies the supersedes property on the handler itself
news/6593.bugfix.md Changelog entry for bug #6593; correct and concise

Reviews (2): Last reviewed commit: "Merge remote-tracking branch 'upstream/m..." | 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

i think this is fine actually. if the parent_future reached a done state by some other means including exception, that doesn't affect the scheduling of chained events.

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.

You're right — I was conflating two different scenarios. The cancelled() check is intentionally specific to the supersession path: only an explicitly cancelled chain should suppress its children as stillborn. A parent that's done() with an exception represents a different error path, and any RuntimeError from add_child in that case would be a pre-existing invariant violation unrelated to this PR's logic. The narrow cancelled() guard is correct. Sorry for the noise.

Comment thread reflex/state.py
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.

@codspeed-hq

codspeed-hq Bot commented Jul 7, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 26 untouched benchmarks
⏩ 8 skipped benchmarks1


Comparing FarhanAliRaza:cancel-event (71a6edc) with main (2d446c3)2

Open in CodSpeed

Footnotes

  1. 8 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on main (d54ad96) during the generation of this report, so 2d446c3 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

Comment on lines +407 to +408
# The chain this event belongs to was cancelled; the event is
# stillborn and never enters the queue.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Suggested change
# The chain this event belongs to was cancelled; the event is
# stillborn and never enters the queue.
# The chain this event belongs to was cancelled, so cancel the
# tracker since this event will never enter the queue.

lets get rid of the potentially trama-linked terminology. "stillborn" could be triggering for some

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
Collaborator

Choose a reason for hiding this comment

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

i think this is fine actually. if the parent_future reached a done state by some other means including exception, that doesn't affect the scheduling of chained events.

self._try_clean_future(future)
if future is None or future.cancelled():
if future is not None:
self._try_clean_future(future)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

shouldn't this already hit since we add _try_clean_future as a done callback when we create the future?

Comment on lines -630 to +685
if future is not None and future.cancelled():
self._try_clean_future(future)
if future is None or future.cancelled():
if future is not None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

why did this conditional change structure like this? are these two nested conditionals not equivalent to what we had before?

Comment thread reflex/state.py
Comment on lines +2487 to +2493
# A newer navigation supersedes the previous unfinished on_load chain for the
# same client token, cancelling its stale work (#6593).
setattr(
OnLoadInternalState.event_handlers["on_load_internal"].fn,
SUPERSEDES_MARKER,
True,
)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

probably would be nice to expose this as a kwarg on rx.event like background

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.

2 participants