Skip to content

Commit aefb36c

Browse files
kr1shna-exemasenfFarhanAliRaza
authored
fix(events): cancel disconnected page-load chains (#7415)
* fix(events): cancel disconnected page-load chains * fix(events): track rehydrated page-load chains * fix(events): guard page loads during hydration --------- Co-authored-by: Masen Furer <m_github@0x26.net> Co-authored-by: Farhan Ali Raza <62690310+FarhanAliRaza@users.noreply.github.com>
1 parent ece6341 commit aefb36c

11 files changed

Lines changed: 450 additions & 29 deletions

File tree

‎docs/events/page_load_events.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,17 @@ def index():
4141
return rx.text("A Beautiful App")
4242
```
4343

44+
## Cancellation
45+
46+
Unfinished `on_load` event chains are cancelled when the client disconnects,
47+
navigates to another page or reconnects. This includes events returned or yielded
48+
by the page-load handler. A reconnect starts the new page's load events again.
49+
Independent background events started outside the page-load chain continue
50+
running after a disconnect.
51+
52+
Use `try`/`finally` to release resources when an async handler is cancelled. Avoid
53+
suppressing `asyncio.CancelledError`, which prevents the handler from stopping.
54+
4455
## Handling Loading and Errors
4556

4657
Avoid heavy synchronous work directly in an `on_load` handler — the page renders before the handler finishes, so a slow handler leaves the user staring at stale or empty data with no feedback. Instead:
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Cancel unfinished page-load event chains when the client disconnects, including hydration that has not yet started. Independent background events continue running.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Cancel pending hydration and page-load event chains when their client disconnects.

‎packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py‎

Lines changed: 89 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
logger = logging.getLogger(__name__)
3232

3333
if TYPE_CHECKING:
34-
from reflex.state import BaseState
34+
from reflex.state import BaseState, State
3535

3636
# Resolved once at import: find_spec on a missing package scans sys.path (~90us),
3737
# far too slow for the per-event-argument path below.
@@ -394,44 +394,88 @@ class BaseStateEventProcessor(EventProcessor):
394394
"""
395395

396396
@staticmethod
397-
async def _rehydrate(root_state: BaseState):
398-
"""Rehydrate the state by calling the hydrate event handler.
397+
async def _rehydrate_snapshot(root_state: State) -> None:
398+
"""Send hydration data, finishing hydration when no page is available.
399399
400400
Args:
401401
root_state: The root state to rehydrate.
402402
"""
403-
from reflex.state import OnLoadInternalState, State
404-
405-
if type(root_state) is not State:
406-
return
403+
from reflex.state import State
407404

408-
# A backend-initiated event carries no route, so nothing sets
409-
# router_data and every one of them would rehydrate again.
410405
routeless = not root_state.router_data
411406
if routeless and root_state.is_hydrated:
412407
return
413-
414408
await process_event(
415409
handler=State.event_handlers["hydrate"],
416410
payload={},
417411
state=root_state,
418412
root_state=root_state,
419413
)
420414
if routeless:
421-
# No page to load, but hydration still has to finish.
422415
await process_event(
423416
handler=State.event_handlers["set_is_hydrated"],
424417
payload={"value": True},
425418
state=root_state,
426419
root_state=root_state,
427420
)
421+
422+
@functools.cached_property
423+
def _pending_hydrates(self) -> dict[str, dict[str, EventFuture]]:
424+
"""Hydrations cancellable on disconnect, but never by a navigation.
425+
426+
Returns:
427+
Pending hydration futures indexed by client token and transaction id.
428+
"""
429+
return {}
430+
431+
async def _rehydrate(self, root_state: BaseState):
432+
"""Rehydrate the state by calling the hydrate event handler.
433+
434+
Args:
435+
root_state: The root state to rehydrate.
436+
"""
437+
from reflex.event import Event
438+
from reflex.state import OnLoadInternalState, State
439+
440+
if type(root_state) is not State:
441+
return
442+
443+
# A backend-initiated event carries no route, so nothing sets
444+
# router_data and every one of them would rehydrate again.
445+
if not root_state.router_data:
446+
await self._rehydrate_snapshot(root_state)
428447
return
429-
await process_event(
430-
handler=OnLoadInternalState.event_handlers["on_load_internal"],
431-
payload={},
432-
state=await root_state.get_state(OnLoadInternalState),
433-
root_state=root_state,
434-
)
448+
449+
ctx = EventContext.get()
450+
# Disconnect cancels this marker without cancelling the ordinary event.
451+
pending = EventFuture(txid=ctx.txid)
452+
self._pending_hydrates.setdefault(ctx.token, {})[pending.txid] = pending
453+
try:
454+
await self._rehydrate_snapshot(root_state)
455+
if pending.cancelled():
456+
return
457+
parent = self._futures[ctx.txid]
458+
first_child = len(parent.children)
459+
handler = OnLoadInternalState.event_handlers["on_load_internal"]
460+
await process_event(
461+
handler=handler,
462+
payload={},
463+
state=await root_state.get_state(OnLoadInternalState),
464+
root_state=root_state,
465+
)
466+
# Inline rehydration bypasses enqueue's page-load registration. Track
467+
# only its children so the ordinary event remains independent.
468+
load_event = Event(name=format_event_handler(handler))
469+
for index in range(first_child, len(parent.children)):
470+
child = parent.children[index]
471+
if pending.cancelled():
472+
child.cancel()
473+
else:
474+
self._supersede_previous(
475+
token=ctx.token, event=load_event, tracked=child
476+
)
477+
finally:
478+
self._forget_hydrate(ctx.token, pending)
435479

436480
def _supersede_previous(
437481
self, *, token: str, event: Event, tracked: EventFuture
@@ -455,8 +499,36 @@ def _supersede_previous(
455499
boot_name, on_load_name = _connect_supersedes()
456500
if event.name == boot_name:
457501
self._cancel_older_chains((on_load_name, token), tracked.root_gen)
502+
self._pending_hydrates.setdefault(token, {})[tracked.txid] = tracked
503+
tracked.add_done_callback(functools.partial(self._forget_hydrate, token))
458504
return super()._supersede_previous(token=token, event=event, tracked=tracked)
459505

506+
def _forget_hydrate(self, token: str, future: EventFuture) -> None:
507+
"""Stop tracking a completed hydration invocation.
508+
509+
Its page-load descendants have their own supersession registration.
510+
511+
Args:
512+
token: The client token.
513+
future: The completed hydration future.
514+
"""
515+
if (pending := self._pending_hydrates.get(token)) is not None:
516+
pending.pop(future.txid, None)
517+
if not pending:
518+
del self._pending_hydrates[token]
519+
520+
def _on_disconnect(self, token: str) -> None:
521+
"""Cancel hydration and unfinished page-load chains for a disconnected client.
522+
523+
Args:
524+
token: The disconnected client token.
525+
"""
526+
_, on_load_name = _connect_supersedes()
527+
for future in self._pending_hydrates.pop(token, {}).values():
528+
future.cancel()
529+
for future in self._superseded.pop((on_load_name, token), {}).values():
530+
future.cancel()
531+
460532
async def _execute_event(
461533
self, *, entry: EventQueueEntry, registered_handler: RegisteredEventHandler
462534
) -> None:

‎packages/reflex-base/src/reflex_base/event/processor/event_processor.py‎

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,15 @@ async def enqueue(
460460
await queue.put(EventQueueEntry(event=event, ctx=ev_ctx))
461461
return tracked
462462

463+
def _on_disconnect(self, token: str) -> None:
464+
"""Handle a client disconnect without cancelling independent event work.
465+
466+
Subclasses may cancel work tied to the disconnected page's lifecycle.
467+
468+
Args:
469+
token: The disconnected client token.
470+
"""
471+
463472
async def enqueue_many(self, token: str, *events: Event) -> Sequence[EventFuture]:
464473
"""Enqueue multiple events to be processed.
465474
@@ -585,15 +594,15 @@ def _try_clean_future(self, future: EventFuture) -> None: # type: ignore[overri
585594
return
586595
parent = future.parent
587596
self._futures.pop(future.txid, None)
588-
if (
589-
(key := future.supersede_key) is not None
590-
and (slot := self._superseded.get(key)) is not None
591-
and slot.get(future.txid) is future
592-
and future.all_done()
593-
):
594-
del slot[future.txid]
595-
if not slot:
596-
del self._superseded[key]
597+
if future.supersede_key is not None and future.all_done():
598+
# Inline page loads can also have their own supersession key.
599+
for key in future.covered_supersede_keys:
600+
if (slot := self._superseded.get(key)) is not None and slot.get(
601+
future.txid
602+
) is future:
603+
del slot[future.txid]
604+
if not slot:
605+
del self._superseded[key]
597606
if parent is None or not parent.txid:
598607
return
599608
future = parent

‎reflex/app.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2319,6 +2319,14 @@ def on_disconnect(self, sid: str) -> asyncio.Task | None:
23192319
# Get token before cleaning up
23202320
disconnect_token = self.sid_to_token.get(sid)
23212321
if disconnect_token:
2322+
socket_record = self._token_manager.token_to_socket.get(disconnect_token)
2323+
if (
2324+
socket_record is not None
2325+
and socket_record.sid == sid
2326+
and socket_record.instance_id == self._token_manager.instance_id
2327+
and (processor := self.app._event_processor) is not None
2328+
):
2329+
processor._on_disconnect(disconnect_token)
23222330
# Use async cleanup through token manager
23232331
task = asyncio.create_task(
23242332
self._token_manager.disconnect_token(disconnect_token, sid),

‎reflex/istate/proxy.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,7 +281,7 @@ async def __aenter__(self) -> Self:
281281
# next event loads its current page.
282282
from reflex_base.event.processor import BaseStateEventProcessor
283283

284-
await BaseStateEventProcessor._rehydrate(mutable_state)
284+
await BaseStateEventProcessor._rehydrate_snapshot(mutable_state)
285285
except (Exception, asyncio.CancelledError):
286286
# Restore the proxy to a consistent state since __aexit__ will not be called when __aenter__ raises.
287287
await self.__aexit__(*sys.exc_info())

‎tests/units/conftest.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,9 @@ def mock_base_state_event_processor_obj(
350350
A fresh BaseState event processor.
351351
"""
352352
monkeypatch.setattr(BaseStateEventProcessor, "_rehydrate", mock.AsyncMock())
353+
monkeypatch.setattr(
354+
BaseStateEventProcessor, "_rehydrate_snapshot", mock.AsyncMock()
355+
)
353356

354357
def handle_backend_exception(ex: Exception) -> None:
355358
formatted_exc = "\n".join(traceback.format_exception(ex))

‎tests/units/istate/test_proxy.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -198,7 +198,9 @@ async def failing_rehydrate(root_state: BaseState): # noqa: RUF029
198198
msg = "Simulated rehydrate failure"
199199
raise RuntimeError(msg)
200200

201-
monkeypatch.setattr(BaseStateEventProcessor, "_rehydrate", failing_rehydrate)
201+
monkeypatch.setattr(
202+
BaseStateEventProcessor, "_rehydrate_snapshot", failing_rehydrate
203+
)
202204
with pytest.raises(RuntimeError, match="Simulated rehydrate failure"):
203205
async with state_proxy:
204206
pass

0 commit comments

Comments
 (0)