fix(events): release the dispatch-time state tree in background handlers
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
- Bug fix (non-breaking change which fixes an issue)
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?
Summary
Addresses part 1 of #7444: the dispatch-time state tree.
After _execute_event drops the state lock for a background handler, it kept state, substate and root_state in its frame for as long as the handler ran. None of them is read after the StateProxy is created:
- since #6920 the background branch calls
process_event(..., root_state=None); - the
finallycompatibility flush loads its own tree (flush_state) under the lock, and only usesproxy,ctx,event,registered_handlerandhandler_error; - the proxy itself keeps the dispatch substate for reads until its first
async with selfreplaces__wrapped__.
With a manager that loads a fresh tree for every lock, like StateManagerRedis with oplock off (the default), those locals pinned an extra copy of the session's state for the life of every long-running background task. This PR deletes them once the proxy holds the substate. The memory and disk managers cache the tree anyway, so this changes nothing for them.
Changes
packages/reflex-base/src/reflex_base/event/processor/base_state_processor.py:del state, substate, root_stateright afterproxy = StateProxy(substate)in the background branch.tests/units/reflex_base/event/processor/test_base_state_processor.py:test_background_event_releases_the_dispatch_state_tree[redis]. A background handler takes a weakref to its dispatch-time root, enters and exitsasync with self, then suspends. While it is suspended, the test runsgc.collect()and asserts the weakref is dead. It uses the existingprocessor_state_managerfixture with"redis"(themock_redisclient), so it runs in the default unit test setup without a redis server. Only the redis variant is parametrized: the in-process manager keeps the tree cached, so the assertion would not mean anything there.packages/reflex-base/news/+background-dispatch-tree.performance.md.
The regression test fails on main (AssertionError: the dispatch-time state tree stayed referenced ...) and passes with the fix. I checked this by restoring only the source file from main and rerunning the module.
Before / after
This is the reproduction script from #7444, run against a real redis-server:
# main (8b97272a2)
dispatch_tree alive while the task waits: True
tick_tree alive while the task waits: True
# this branch
dispatch_tree alive while the task waits: False
tick_tree alive while the task waits: True
On main, gc.get_referrers shows the dispatch tree is held only by the _execute_event coroutine's frame (state, root_state, substate). With the fix, the dispatch tree is collected.
Not in this PR: the async with self tree (part 2 of #7444)
tick_tree above is still alive. StateProxy.__aexit__ releases the lock but leaves __wrapped__ pointing at the tree loaded by the last async with self, until the next async with self replaces it. I looked for a fix that keeps current behaviour and didn't find a clean one:
docs/events/background_events.mdsays background tasks may read the state outside of anasync with selfblock, but the value may be stale. Today those reads see the last snapshot.- A detached, parentless copy of the handler substate's own fields would break reads that go through the tree:
self.router(resolved on the root), inherited vars (stored on ancestor states), and computed vars that depend on other states. - Reloading lazily on read would need async I/O inside the synchronous
__getattr__. - Clearing
__wrapped__on exit would change what those reads return. That is a behaviour change, and maintainers should decide on it.
#7313 / #7338 rework StateProxy and how checked-out states are held, so that is likely the right place to settle whether the tree is released when the context exits. I'm happy to follow up if you prefer a specific approach.
Tests run
uv run pytest tests/units/reflex_base/event/processor/test_base_state_processor.py: 31 passeduv run pytest tests/units/reflex_base/event: 126 passed, 1 skippeduv run pytest tests/units/istate: 190 passeduv run pytest tests/units/test_state.py: 249 passeduv run pytest tests/units --cov --no-cov-on-fail --cov-report=: 12206 passed, 188 skipped; 4 failed and 10 errors, all intests/units/reflex_bench/drivers/test_browser.py. Those fail the same way on cleanmainin my environment, because the Playwright Chromium binary isn't installed.uv run pre-commit run --files <changed files>(ruff-format, ruff-check, codespell, pyright, ty): passed. I skippedbiome-formatbecause its hook environment would not install locally (npmEALLOWGIT), and it doesn't cover any file this PR touches.uv run ruff check .anduv run ruff format --check .: clean.uv run reflex-release changelog-check --base-ref origin/main: fragment present forreflex-base.
🤖 Generated with Claude Code