From 964412f27d55687fe79cdaae36e0549f5a6db042 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Fri, 28 Aug 2026 20:35:30 +0000 Subject: [PATCH 1/3] fix(events): run the no-context background flush when the handler raises A background handler that returns without entering async-with-self gets one compatibility delta flush under the state lock (refreshing uncached computed vars and preamble-dirty vars like router_data), but a handler that raises propagated the exception before the flush and the client got no delta at all. Wrap the handler await in try/finally so the locked flush runs however the task ended; the exception still propagates to the backend exception handler afterwards. A handler that raises after entering the context is unchanged: its context exit already flushed, and the entered flag skips the fallback. Fixes #6982 --- news/+bg-noctx-flush-on-raise.bugfix.md | 1 + .../news/+bg-noctx-flush-on-raise.bugfix.md | 1 + .../event/processor/base_state_processor.py | 50 ++++++++------- .../processor/test_base_state_processor.py | 61 +++++++++++++++++++ 4 files changed, 90 insertions(+), 23 deletions(-) create mode 100644 news/+bg-noctx-flush-on-raise.bugfix.md create mode 100644 packages/reflex-base/news/+bg-noctx-flush-on-raise.bugfix.md diff --git a/news/+bg-noctx-flush-on-raise.bugfix.md b/news/+bg-noctx-flush-on-raise.bugfix.md new file mode 100644 index 00000000000..62eddac7d3a --- /dev/null +++ b/news/+bg-noctx-flush-on-raise.bugfix.md @@ -0,0 +1 @@ +A background event handler that raises before ever entering `async with self` now still emits the compatibility delta flush (refreshing uncached computed vars and preamble-dirty vars like `router_data`) that a non-raising handler gets, instead of skipping it when the exception propagates. diff --git a/packages/reflex-base/news/+bg-noctx-flush-on-raise.bugfix.md b/packages/reflex-base/news/+bg-noctx-flush-on-raise.bugfix.md new file mode 100644 index 00000000000..448d47e89cd --- /dev/null +++ b/packages/reflex-base/news/+bg-noctx-flush-on-raise.bugfix.md @@ -0,0 +1 @@ +The compatibility delta flush for background handlers that never enter `async with self` now also runs when the handler raises, so uncached computed vars and preamble-dirty vars (like `router_data`) reach the client regardless of how the task ended. The exception still propagates to the backend exception handler afterwards. diff --git a/packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py b/packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py index d69baa8f77d..5d24c3dd083 100644 --- a/packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py +++ b/packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py @@ -459,29 +459,33 @@ async def _execute_event( # background task's own state changes are emitted (and cleaned) by its # `async with self` context exits, which re-acquire the lock. proxy = StateProxy(substate) - await process_event( - handler=registered_handler.handler, - state=proxy, - payload=event.payload, - root_state=None, - ) - if not proxy._self_entered_context: - # A handler that never entered `async with self` emitted nothing, - # but every background event used to flush a delta (refreshing - # uncached computed vars, and any dirty vars the preamble left, - # like router_data). Preserve that, under the lock this time. - async with ctx.state_manager.modify_state_with_links( - BaseStateToken( - ident=ctx.token, - cls=registered_handler.states[0], - ), - event=event, - ) as flush_state: - await chain_updates( - None, - root_state=flush_state._get_root_state(), - handler_name=registered_handler.handler.fn.__qualname__, - ) + try: + await process_event( + handler=registered_handler.handler, + state=proxy, + payload=event.payload, + root_state=None, + ) + finally: + if not proxy._self_entered_context: + # A handler that never entered `async with self` emitted nothing, + # but every background event used to flush a delta (refreshing + # uncached computed vars, and any dirty vars the preamble left, + # like router_data). Preserve that, under the lock this time -- + # also when the handler raises, so the client gets the same + # refresh regardless of how the task ended. + async with ctx.state_manager.modify_state_with_links( + BaseStateToken( + ident=ctx.token, + cls=registered_handler.states[0], + ), + event=event, + ) as flush_state: + await chain_updates( + None, + root_state=flush_state._get_root_state(), + handler_name=registered_handler.handler.fn.__qualname__, + ) async def _handle_backend_exception( self, ex: Exception, ev_ctx: EventContext | None = None diff --git a/tests/units/reflex_base/event/processor/test_base_state_processor.py b/tests/units/reflex_base/event/processor/test_base_state_processor.py index c8d0476edb3..2cd92cb3ff1 100644 --- a/tests/units/reflex_base/event/processor/test_base_state_processor.py +++ b/tests/units/reflex_base/event/processor/test_base_state_processor.py @@ -467,6 +467,67 @@ async def bg(self): ) +async def test_background_event_raising_without_context_still_flushes_a_delta( + wired_app: App, + real_base_state_processor: BaseStateEventProcessor, + emitted_deltas: list[tuple[str, Mapping[str, Mapping[str, Any]]]], + token: str, +): + """A background handler that raises before ``async with self`` still flushes. + + Regression: the compatibility flush for handlers that never enter their + proxy context ran only after a normal return, so a raising handler + propagated the exception before the flush and no delta was emitted -- + unlike every non-raising no-context background handler. The flush now + runs in a finally, and the exception still reaches the backend + exception handler afterwards. + + Args: + wired_app: The App wired to the processor's state manager. + real_base_state_processor: The unmocked BaseStateEventProcessor. + emitted_deltas: List to capture emitted deltas. + token: The client token. + """ + handled: list[Exception] = [] + + class RaisingBgState(State): + @rx.var(cache=False) + def beat(self) -> int: + return 11 + + @event(background=True) + async def bg_raises(self): + msg = "boom" + raise RuntimeError(msg) + + real_base_state_processor.backend_exception_handler = handled.append + + assert real_base_state_processor._root_context is not None + state_manager = real_base_state_processor._root_context.state_manager + async with state_manager.modify_state( + BaseStateToken(ident=token, cls=State) + ) as seed_root: + seed_root.router_data = {"pathname": "/", "query": {}} + + try: + async with real_base_state_processor as processor: + await processor.enqueue( + token, Event.from_event_type(RaisingBgState.bg_raises())[0] + ) + await processor.join(5) + finally: + State._always_dirty_substates.discard(RaisingBgState.get_name()) + + state_name = RaisingBgState.get_full_name() + beat_key = "beat" + FIELD_MARKER + assert any(d.get(state_name, {}).get(beat_key) == 11 for _, d in emitted_deltas), ( + f"no delta refreshed the uncached var after the handler raised: {emitted_deltas}" + ) + assert [type(ex) for ex in handled] == [RuntimeError], ( + f"the handler's exception did not reach the backend handler: {handled}" + ) + + async def test_chained_event_keeps_originating_router_data( wired_app: App, real_base_state_processor: BaseStateEventProcessor, From 1def528e73d36d8ac7c3c5181f93e0e8eb6454e2 Mon Sep 17 00:00:00 2001 From: Farhan Ali Raza Date: Fri, 28 Aug 2026 21:40:14 +0000 Subject: [PATCH 2/3] Preserve the handler's exception when the no-context background flush fails When a no-context background handler raises and the compatibility delta flush in the finally block then fails too, the flush error replaced the handler's exception on its way to the backend exception handler. Log the flush failure instead and let the original exception propagate; a flush failure after a successful handler still raises as before. --- .../news/+bg-noctx-flush-on-raise.bugfix.md | 2 +- .../event/processor/base_state_processor.py | 37 +++++++--- .../processor/test_base_state_processor.py | 69 +++++++++++++++++++ 3 files changed, 96 insertions(+), 12 deletions(-) diff --git a/packages/reflex-base/news/+bg-noctx-flush-on-raise.bugfix.md b/packages/reflex-base/news/+bg-noctx-flush-on-raise.bugfix.md index 448d47e89cd..d5c11f8cb65 100644 --- a/packages/reflex-base/news/+bg-noctx-flush-on-raise.bugfix.md +++ b/packages/reflex-base/news/+bg-noctx-flush-on-raise.bugfix.md @@ -1 +1 @@ -The compatibility delta flush for background handlers that never enter `async with self` now also runs when the handler raises, so uncached computed vars and preamble-dirty vars (like `router_data`) reach the client regardless of how the task ended. The exception still propagates to the backend exception handler afterwards. +The compatibility delta flush for background handlers that never enter `async with self` now also runs when the handler raises, so uncached computed vars and preamble-dirty vars (like `router_data`) reach the client regardless of how the task ended. The exception still propagates to the backend exception handler afterwards; if the flush itself fails in that case, the flush error is logged instead of masking the handler's exception. diff --git a/packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py b/packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py index 5d24c3dd083..de04bac1e93 100644 --- a/packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py +++ b/packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py @@ -459,6 +459,7 @@ async def _execute_event( # background task's own state changes are emitted (and cleaned) by its # `async with self` context exits, which re-acquire the lock. proxy = StateProxy(substate) + handler_error: BaseException | None = None try: await process_event( handler=registered_handler.handler, @@ -466,6 +467,9 @@ async def _execute_event( payload=event.payload, root_state=None, ) + except BaseException as ex: + handler_error = ex + raise finally: if not proxy._self_entered_context: # A handler that never entered `async with self` emitted nothing, @@ -474,17 +478,28 @@ async def _execute_event( # like router_data). Preserve that, under the lock this time -- # also when the handler raises, so the client gets the same # refresh regardless of how the task ended. - async with ctx.state_manager.modify_state_with_links( - BaseStateToken( - ident=ctx.token, - cls=registered_handler.states[0], - ), - event=event, - ) as flush_state: - await chain_updates( - None, - root_state=flush_state._get_root_state(), - handler_name=registered_handler.handler.fn.__qualname__, + try: + async with ctx.state_manager.modify_state_with_links( + BaseStateToken( + ident=ctx.token, + cls=registered_handler.states[0], + ), + event=event, + ) as flush_state: + await chain_updates( + None, + root_state=flush_state._get_root_state(), + handler_name=registered_handler.handler.fn.__qualname__, + ) + except Exception: + if handler_error is None: + raise + # The handler's exception is the actionable one; log the + # flush failure instead of letting it mask the exception + # already propagating to the backend exception handler. + logger.exception( + "Error flushing delta after background handler " + f"{registered_handler.handler.fn.__qualname__} raised:" ) async def _handle_backend_exception( diff --git a/tests/units/reflex_base/event/processor/test_base_state_processor.py b/tests/units/reflex_base/event/processor/test_base_state_processor.py index 2cd92cb3ff1..a70130f70c1 100644 --- a/tests/units/reflex_base/event/processor/test_base_state_processor.py +++ b/tests/units/reflex_base/event/processor/test_base_state_processor.py @@ -2,6 +2,7 @@ import asyncio import dataclasses +import logging import traceback from collections.abc import Mapping from typing import Any @@ -528,6 +529,74 @@ async def bg_raises(self): ) +async def test_background_flush_failure_does_not_mask_handler_exception( + wired_app: App, + real_base_state_processor: BaseStateEventProcessor, + token: str, + caplog: pytest.LogCaptureFixture, +): + """A failing compat flush must not replace a raised handler's exception. + + Regression: with the flush running after the handler raised, a flush + failure (e.g. an uncached computed var throwing during delta + resolution) replaced the handler's exception, so the backend exception + handler received the flush error instead of the actionable application + error. The flush failure is now logged and the handler's exception + still propagates. + + Args: + wired_app: The App wired to the processor's state manager. + real_base_state_processor: The unmocked BaseStateEventProcessor. + token: The client token. + caplog: Fixture capturing log records. + """ + handled: list[Exception] = [] + handler_ran: list[bool] = [] + + class MaskingBgState(State): + @rx.var(cache=False) + def beat(self) -> int: + if handler_ran: + msg = "flush boom" + raise ValueError(msg) + return 13 + + @event(background=True) + async def bg_raises(self): + handler_ran.append(True) + msg = "boom" + raise RuntimeError(msg) + + real_base_state_processor.backend_exception_handler = handled.append + + assert real_base_state_processor._root_context is not None + state_manager = real_base_state_processor._root_context.state_manager + async with state_manager.modify_state( + BaseStateToken(ident=token, cls=State) + ) as seed_root: + seed_root.router_data = {"pathname": "/", "query": {}} + + try: + async with real_base_state_processor as processor: + await processor.enqueue( + token, Event.from_event_type(MaskingBgState.bg_raises())[0] + ) + with caplog.at_level( + logging.ERROR, logger="reflex_base.event.processor.base_state_processor" + ): + await processor.join(5) + finally: + State._always_dirty_substates.discard(MaskingBgState.get_name()) + + assert [type(ex) for ex in handled] == [RuntimeError], ( + f"the flush failure masked the handler's exception: {handled}" + ) + assert any( + record.exc_info and record.exc_info[0] is ValueError + for record in caplog.records + ), f"the flush failure was not logged: {caplog.records}" + + async def test_chained_event_keeps_originating_router_data( wired_app: App, real_base_state_processor: BaseStateEventProcessor, From e2afbe25264d0c73d705b18a2006347e4cf1110c Mon Sep 17 00:00:00 2001 From: Masen Furer Date: Fri, 28 Aug 2026 16:45:08 -0700 Subject: [PATCH 3/3] Delete news/+bg-noctx-flush-on-raise.bugfix.md no need for duplicate news fragments --- news/+bg-noctx-flush-on-raise.bugfix.md | 1 - 1 file changed, 1 deletion(-) delete mode 100644 news/+bg-noctx-flush-on-raise.bugfix.md diff --git a/news/+bg-noctx-flush-on-raise.bugfix.md b/news/+bg-noctx-flush-on-raise.bugfix.md deleted file mode 100644 index 62eddac7d3a..00000000000 --- a/news/+bg-noctx-flush-on-raise.bugfix.md +++ /dev/null @@ -1 +0,0 @@ -A background event handler that raises before ever entering `async with self` now still emits the compatibility delta flush (refreshing uncached computed vars and preamble-dirty vars like `router_data`) that a non-raising handler gets, instead of skipping it when the exception propagates.