Skip to content

fix: tear down the component context tree on close, freeing it by refcount#52

Merged
maartenbreddels merged 2 commits into
masterfrom
fix/setter-context-cycle
Jul 5, 2026
Merged

fix: tear down the component context tree on close, freeing it by refcount#52
maartenbreddels merged 2 commits into
masterfrom
fix/setter-context-cycle

Conversation

@maartenbreddels

Copy link
Copy Markdown
Contributor

Summary

Closed render trees could not be freed by reference counting: use_state setters, reacton.ipyvue.use_event handlers, and _event_handler_exception_wrapper all capture the _RenderContext and ComponentContext strongly in closures that live in the hook state and on widget CallbackDispatchers (where event handlers deliberately stay after close, to avoid syncing _events right before the comm closes). That makes the whole closed tree one reference cycle — rc → hook state / widgets → closure → rc — which only a generation-2 gc can free.

On a solara server this shows up as a memory sawtooth whose peaks scale with per-kernel state: closed kernels (including e.g. use_memo payloads) pile up until the collector's own schedule kicks in. Measured with a task-heavy test app: idle footprint bounced up to 231 MB; with the tree refcount-clean it converges to a ~170 MB plateau (peak 174 MB). Details, the measurement harness, and the gc-disabled diagnostic that found these exact chains: widgetti/solara#1177 (docs/memory-usage-inspection.md) and widgetti/solara#1176.

The fix

Capture both contexts via weakref.ref in the three closure factories. Behavior during the tree's life is unchanged (the render context keeps the tree alive, so the weakrefs always resolve). After the tree is collected the closures become inert:

  • a setter no-ops — there is no tree left to update;
  • the exception wrappers re-raise instead of appending to a dead context — previously the error was swallowed into a tree nobody could see.

Testing

  • New test_no_reference_cycles_after_close (4 parametrizations, incl. a use_state + on_click component): asserts a closed tree is freed with gc disabled. Red on master (3/4 fail) → green with the fix. Passes with REACTON_FAST=1 too (make_setter and the wrappers are shared with the fast render context).
  • Full suite: 171 passed; the 5 failures + 1 error in generate_test.py / test_set_state_with_dataframe are pre-existing on master in the same environment (pandas/codegen version drift).
  • End-to-end (solara + patched reacton): a browser-driven kernel exercising reactive/computed/task/use_task + a 4.5 MB use_memo payload is fully freed by refcounting after the page closes, with gc disabled.

🤖 Generated with Claude Code

…count

Closures created during rendering (use_state setters, event handlers and
the event handler exception wrapper) capture their component context and
the render context strongly. They must: a setter can be the only
reference keeping its component context alive (cf61378 - weak references
lost live state). But the same closures are stored in the hook state and
on widget callback dispatchers (where event handlers deliberately stay
after close, to avoid syncing _events right before the comm closes), so
a closed render tree is one big reference cycle that refcounting cannot
free: it waits for a generation-2 gc. On a solara server this shows up
as a memory sawtooth whose peaks scale with per-kernel state (measured
up to 231MB vs a 174MB peak once the tree is refcount-clean, see
solara's docs/memory-usage-inspection.md).

Instead of weakening the captures, close() now empties every component
context (snapshotted before _remove_element detaches them) and drops the
render context's own element/container/root-widget references. Whoever
still holds a setter or handler keeps only a small hollow context alive;
set_ and force_update are no-ops once _closing is set (set_ checks it
first now, before touching the emptied state dict). The containers are
replaced rather than cleared, since state_get() hands out the live state
dicts (test_state_get re-renders with them after close).

The new test asserts a closed tree is freed with gc disabled; 3 of its 4
parametrizations fail without this fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@maartenbreddels maartenbreddels force-pushed the fix/setter-context-cycle branch from 954fefe to d0c6765 Compare July 5, 2026 16:46
@maartenbreddels

Copy link
Copy Markdown
Contributor Author

Design changed after checking history (thanks Maarten for the pointer): the first push used weakrefs in the closures, which is exactly what cf61378 reverted in Nov 2023 ("a setter might be the only reference" — weak captures sometimes lost live state in solara). The current version keeps strong captures and instead empties the component context tree at close(), which achieves the same refcount-collectability without that failure mode. Same red→green test, same end-to-end result.

@maartenbreddels maartenbreddels changed the title fix: capture render context weakly in setters and event handlers fix: tear down the component context tree on close, freeing it by refcount Jul 5, 2026
Every new build fails config validation since the deprecation; the last
green docs builds (#49/#51) predate it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@maartenbreddels

Copy link
Copy Markdown
Contributor Author

The readthedocs failure was unrelated config drift: readthedocs dropped the ubuntu-20.04 build image, so every new build fails config validation (the last green docs builds, #49/#51, predate the deprecation). Added a one-line commit bumping to ubuntu-22.04.

@maartenbreddels maartenbreddels merged commit fad473a into master Jul 5, 2026
22 checks passed
maartenbreddels added a commit to widgetti/solara that referenced this pull request Jul 5, 2026
…tate (#1178)

The handbook still described the reacton setter-closure cycle as
unfixed and claimed disabling the gc backstop brings the sawtooth
back; since widgetti/reacton#52 (context tree teardown on close) both
are outdated. Re-measured everything against the merged masters: the
kitchen-sink app plateaus at ~168 MB (peak 178) with gc fully
disabled, so refcounting alone now frees closed kernels. Also records
why weak references were not an option (reacton cf61378: a setter can
be the only reference keeping its component context alive).

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant