fix(events): stop background tasks from cleaning the root state unlocked
The bug
After dropping the state lock, the background branch of _execute_event passes root_state into process_event, whose trailing chain_updates does three things on that root with no lock held:
get_delta()snapshots the dirty vars (sync)_get_resolved_delta()/emit_delta()suspend (async computed vars, socket write)finally: root_state._clean()wipes all dirty vars
On a shared state tree (opportunistic locking, or StateManagerMemory) that window races whatever event holds the lock now. A foreground handler's write landing between the snapshot and the clean is discarded before any delta harvests it. The value reaches no delta, ever: the handler completes normally, its delta arrives carrying only always-dirty computed vars, and the frontend keeps the stale value.
Writes made after the window survive, so a single handler can lose its early assignments and deliver its late ones, which makes the failure look impossible from the app side.
How it was found
A merge-queue flake in a downstream app: clicking a card 5-7 ms after a 1 Hz background poll silently did nothing, roughly 1 in 8 such clicks. The click's handler ran, its selected_integration assignment was wiped mid-handler by the poll's trailing clean, and the panel never opened. The same mechanism can wipe any state var, including auth state.
The fix
Pass root_state=None in the background branch. chain_updates already treats None as "route events only, no delta work", so the unlocked snapshot/clean simply stops existing. A background task's own state changes are emitted and cleaned by its async with self context exits, which re-acquire the lock, so nothing legitimate is lost. Yielded and returned events still route exactly as before.
Validation
- New regression test forces the exact interleaving with gates (no timing dependence): fails in 0.14 s on unpatched code, passes with the fix.
- A standalone repro (below) driving the real event processor against redis with
REFLEX_OPLOCK_ENABLEDgoes from 13/100 lost foreground deltas to 0/100 with this fix. The oplock-off control loses 0/100 either way. tests/units/test_state.py,tests/units/reflex_base/,tests/units/test_app.pyproduce a failure set identical to the pre-fix baseline on this checkout.
Standalone repro (redis on localhost:6399)
import asyncio
import os
import random
os.environ["REFLEX_REDIS_URL"] = "redis://localhost:6399/9"
os.environ["REFLEX_OPLOCK_ENABLED"] = "true"
os.environ["REFLEX_OPLOCK_HOLD_TIME_MS"] = "1000"
os.environ["REFLEX_REDIS_LOCK_EXPIRATION"] = "20000"
import redis.asyncio as aioredis
import reflex as rx
from reflex.config import get_config
get_config()
from reflex.app import BaseStateEventProcessor
from reflex.istate.manager.redis import StateManagerRedis
from reflex.istate.manager.token import BaseStateToken
from reflex_base.event import Event
class Victim(rx.State):
value: str = ""
@rx.event
async def set_value_with_await(self):
self.value = "clicked" # early write, the one that gets lost
await asyncio.sleep(0.005) # any await: db query, get_state, http
class Poller(rx.State):
ticks: int = 0
# Uncached async computed var: keeps every delta non-empty and suspending,
# like any real app with async computed vars.
@rx.var(cache=False)
async def load(self) -> int:
await asyncio.sleep(0.003)
return self.ticks
@rx.event(background=True)
async def poll(self):
await asyncio.sleep(random.uniform(0, 0.02)) # unlocked preamble work
async with self:
self.ticks += 1
VICTIM = Victim.get_full_name()
CLICK = f"{VICTIM}.set_value_with_await"
POLL = f"{Poller.get_full_name()}.poll"
RD = {"pathname": "/", "asPath": "/", "query": {}}
app = rx.App()
class CapturingNamespace:
def __init__(self):
self.updates = []
async def emit_update(self, update, token):
await asyncio.sleep(0.002) # a real socket write suspends
self.updates.append(update)
async def run_trial(sm):
ns = CapturingNamespace()
proc = BaseStateEventProcessor(middleware=app, backend_exception_handler=None)
app._event_processor = proc
losses = 0
async with proc.configure(state_manager=sm, event_namespace=ns):
with app.set_contexts():
tok = "tab-1"
async with sm.modify_state(BaseStateToken(ident=tok, cls=rx.State)) as root:
root.router_data = dict(RD)
for _ in range(100):
ns.updates.clear()
fut_poll = await proc.enqueue(
tok, Event(name=POLL, payload={}, router_data=dict(RD))
)
await asyncio.sleep(random.uniform(0, 0.03))
fut_click = await proc.enqueue(
tok, Event(name=CLICK, payload={}, router_data=dict(RD))
)
await asyncio.wait([fut_poll, fut_click], timeout=10)
await asyncio.sleep(0.08)
delivered = any(
VICTIM in (u.delta or {}) and "value_rx_state_" in u.delta[VICTIM]
for u in ns.updates
)
if not delivered:
losses += 1
return losses
async def main():
redis = aioredis.Redis.from_url("redis://localhost:6399/9")
await redis.flushdb()
lost_on = await run_trial(StateManagerRedis(redis=redis))
print(f"oplock ON : lost {lost_on}/100 foreground deltas")
await redis.flushdb()
sm_off = StateManagerRedis(redis=redis)
object.__setattr__(sm_off, "_oplock_enabled", False)
lost_off = await run_trial(sm_off)
print(f"oplock OFF: lost {lost_off}/100 foreground deltas")
await redis.aclose()
asyncio.run(main())
Notes for reviewers
- Background delta flow after this PR: yields from inside
async with selfstill flush ahead of the yielded event (the proxy holds the lock there); proxy context exits emit as documented; and a handler that never entersasync with selfgets one trailing flush under the state lock, preserving the old per-tick refresh of uncached computed vars. The only removed behavior is the unlocked flush itself. - An atomic snapshot-and-clean in
chain_updateswas attempted and reverted (it broketest_linked_stateon every in-memory leg): delta resolution itself re-marks linked vars dirty through the SharedState patch machinery, andSharedState._cleancaptures its dirty vars for the cross-client fan-out at clean time, so the clean must stay after resolution. That ordering dependency is now documented at the clean site; decoupling the fan-out capture from_cleanso the primitive can be hardened is left as follow-up. - Writing the regression test surfaced two pre-existing cross-test leaks in the suite, both worked around locally in the new test: an inline state with a
cache=Falsevar permanently mutates the sharedState._always_dirty_substates(the test discards its entry in afinally, mirroringreload_state_module), andtest_router_var_depleaks arouteredge into class-levelState._var_dependenciesthat breaks any later test whose events assign router state (the new test pre-seedsrouter_datainstead of carrying it on events). Happy to file these separately. reflex_enterprise.utils.call_event_from_computed_varhas the same defect class (a detachedstate._clean()outside any lock); that is a separate repo and a separate fix.