fix(events): release the dispatch-time state tree in background handlers

Merged
RA
1d ago
+66-0

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 finally compatibility flush loads its own tree (flush_state) under the lock, and only uses proxy, ctx, event, registered_handler and handler_error;
  • the proxy itself keeps the dispatch substate for reads until its first async with self replaces __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_state right after proxy = 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 exits async with self, then suspends. While it is suspended, the test runs gc.collect() and asserts the weakref is dead. It uses the existing processor_state_manager fixture with "redis" (the mock_redis client), 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.md says background tasks may read the state outside of an async with self block, 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 passed
  • uv run pytest tests/units/reflex_base/event: 126 passed, 1 skipped
  • uv run pytest tests/units/istate: 190 passed
  • uv run pytest tests/units/test_state.py: 249 passed
  • uv run pytest tests/units --cov --no-cov-on-fail --cov-report=: 12206 passed, 188 skipped; 4 failed and 10 errors, all in tests/units/reflex_bench/drivers/test_browser.py. Those fail the same way on clean main in 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 skipped biome-format because its hook environment would not install locally (npm EALLOWGIT), and it doesn't cover any file this PR touches.
  • uv run ruff check . and uv run ruff format --check .: clean.
  • uv run reflex-release changelog-check --base-ref origin/main: fragment present for reflex-base.

🤖 Generated with Claude Code