Skip to content

Commit ef99f18

Browse files
fix: memory leak in AutoSubscribeContextManager and Context
Context managers in toestand.py were retaining references to render contexts after cleanup, preventing garbage collection. This caused memory leaks in components that read @slab.computed values. Changes: - Clear previous_reactive_watch and reactive_used_before in __exit__ - Use weakrefs for Context.render_context and Context.kernel_context - Add comprehensive memory leak detection documentation and demo The leak occurred because Computed's AutoSubscribeContextManager stored a bound method (previous_reactive_watch) that transitively kept the _RenderContext alive through closure cells. Since Computed objects are module-level singletons, this reference persisted indefinitely. The Context weakref change is a defense-in-depth measure: Context objects stored in listeners dicts can no longer pin render contexts, even if other code paths create additional retention. Documentation includes: - Strategy guide for weakref-based leak detection - Full investigation report with objgraph analysis - Working demo script showing clean vs leaky components - Common leak patterns and diagnostic techniques Co-Authored-By: Claude Sonnet 4.5 <[email protected]>
1 parent a222221 commit ef99f18

File tree

6 files changed

+888
-10
lines changed

6 files changed

+888
-10
lines changed

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
CLAUDE.md

docs/memleak_demo.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"""Memory leak detection demo for Solara.
2+
3+
Demonstrates the weakref-based leak detection strategy described in
4+
docs/memory-leak-detection.md. We create two components:
5+
6+
1. LeakyComponent — stores a callback in a global list every render,
7+
preventing the render context (and all widgets) from being collected.
8+
2. CleanComponent — no global references, everything is collectable.
9+
10+
We render each one with solara.render_fixed(), take a weakref to the
11+
render context, close it, delete locals, run gc.collect(), and check
12+
whether the weakref resolves to None.
13+
14+
Note: We track the render context (_RenderContext), not the widget.
15+
Widgets (ipyvue Html nodes) have internal bound methods that keep them
16+
alive outside the kernel lifecycle — that's normal and handled by
17+
Widget.close_all() in production. The render context is the meaningful
18+
object: if it leaks, the entire component tree and its state leak.
19+
"""
20+
21+
import gc
22+
import weakref
23+
24+
import solara
25+
26+
# ---------------------------------------------------------------------------
27+
# The leak: a global list that accumulates strong references
28+
# ---------------------------------------------------------------------------
29+
_leaked_references: list = []
30+
31+
32+
@solara.component
33+
def LeakyComponent():
34+
text, set_text = solara.use_state("hello")
35+
# BUG: storing the setter globally prevents the entire render context
36+
# from being garbage collected, because set_text closes over the
37+
# component's internal state.
38+
_leaked_references.append(set_text)
39+
solara.Text(text)
40+
41+
42+
@solara.component
43+
def CleanComponent():
44+
text, set_text = solara.use_state("hello")
45+
# No global reference — everything stays local and can be collected.
46+
solara.Text(text)
47+
48+
49+
# ---------------------------------------------------------------------------
50+
# Detection helper — the core pattern from the doc
51+
# ---------------------------------------------------------------------------
52+
def _scoped_render(component_el):
53+
"""Render inside a function so all locals die when we return.
54+
55+
Returns only a weakref — the caller never holds a strong reference.
56+
"""
57+
widget, rc = solara.render_fixed(component_el, handle_error=False)
58+
rc_ref = weakref.ref(rc)
59+
60+
# Normal lifecycle cleanup
61+
rc.close()
62+
63+
# Drop our strong references
64+
del widget, rc
65+
66+
return rc_ref
67+
68+
69+
def check_leak(name: str, component_el) -> bool:
70+
"""Returns True if a leak is detected (render context still alive)."""
71+
rc_ref = _scoped_render(component_el)
72+
73+
# Aggressive GC — multiple passes across all generations
74+
for _ in range(20):
75+
for gen in [2, 1, 0]:
76+
gc.collect(gen)
77+
if rc_ref() is None:
78+
break
79+
80+
rc_alive = rc_ref() is not None
81+
82+
if rc_alive:
83+
print(f"[LEAK] {name}: render context is still alive!")
84+
obj = rc_ref()
85+
if obj is not None:
86+
referrers = gc.get_referrers(obj)
87+
print(f" render context has {len(referrers)} referrer(s):")
88+
for r in referrers[:5]:
89+
print(f" - {type(r).__name__}: {repr(r)[:120]}")
90+
return True
91+
else:
92+
print(f"[OK] {name}: render context collected — no leak")
93+
return False
94+
95+
96+
# ---------------------------------------------------------------------------
97+
# Main
98+
# ---------------------------------------------------------------------------
99+
def main():
100+
print("=" * 60)
101+
print("Memory Leak Detection Demo")
102+
print("=" * 60)
103+
print()
104+
105+
# 1. Baseline — clean component should NOT leak
106+
print("--- Test 1: CleanComponent (should pass) ---")
107+
leaked_clean = check_leak("CleanComponent", CleanComponent())
108+
print()
109+
110+
# 2. Leaky component — SHOULD leak
111+
print("--- Test 2: LeakyComponent (should detect leak) ---")
112+
leaked_leaky = check_leak("LeakyComponent", LeakyComponent())
113+
print()
114+
115+
# Summary
116+
print("=" * 60)
117+
if not leaked_clean and leaked_leaky:
118+
print("SUCCESS: Clean component passed, leaky component caught.")
119+
elif leaked_clean:
120+
print("UNEXPECTED: Clean component leaked — something else is wrong.")
121+
elif not leaked_leaky:
122+
print("UNEXPECTED: Leaky component was NOT caught — detection failed.")
123+
print("=" * 60)
124+
125+
# Cleanup the global list so it doesn't affect anything else
126+
_leaked_references.clear()
127+
128+
129+
if __name__ == "__main__":
130+
main()

0 commit comments

Comments
 (0)