|
| 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