Skip to content

Commit 5a43bd9

Browse files
Korijnclaude
andauthored
Add tests that verify observ creates no reference cycles (#189)
Observ promises (see proxy_db.py and docs/guide/gotchas.md) that all of its internal state is reclaimed through plain reference counting, without relying on the cyclic garbage collector. Until now nothing in the test suite actually verified that promise. The new test module runs with the garbage collector disabled and checks, via weakrefs and proxy_db registry entries, that proxies, deps, keydeps, watchers and computed watchers are all freed as soon as the last strong reference to them is dropped. It also covers the cycle-prone paths specifically: the callback argument probing in Watcher._run_callback (which inspects exception tracebacks, a classic source of frame<->traceback cycles) and exceptions propagating out of callbacks and watched functions. A final sweep exercises most of the machinery end to end and asserts that gc.collect() finds nothing to collect afterwards. Claude-Session: https://claude.ai/code/session_01HPVDgVA15vRGPpACepNtqZ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 32c7cc9 commit 5a43bd9

1 file changed

Lines changed: 320 additions & 0 deletions

File tree

tests/test_reference_cycles.py

Lines changed: 320 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,320 @@
1+
"""
2+
Tests that verify that observ itself never creates reference cycles:
3+
everything observ allocates must be reclaimable through plain reference
4+
counting, without the help of the cyclic garbage collector. See the
5+
notes on lifetimes in observ/proxy_db.py and docs/guide/gotchas.md.
6+
7+
The garbage collector is disabled during each of these tests, so an
8+
object that would only be reclaimed by the cycle collector shows up as
9+
a weakref that is still alive (or a registry entry that is left
10+
behind) after the last strong reference to it is dropped.
11+
"""
12+
13+
import gc
14+
import weakref
15+
16+
import pytest
17+
18+
from observ import computed, reactive, watch, watch_effect
19+
from observ.proxy_db import proxy_db
20+
21+
22+
@pytest.fixture(autouse=True)
23+
def refcounting_only():
24+
"""
25+
Disable the garbage collector so that objects can only be
26+
reclaimed through reference counting.
27+
"""
28+
gc.collect()
29+
gc.disable()
30+
try:
31+
yield
32+
finally:
33+
gc.enable()
34+
35+
36+
def test_proxy_is_reclaimed_by_refcount():
37+
state = reactive({"count": 0})
38+
target_id = id(state.__target__)
39+
weak_proxy = weakref.ref(state)
40+
weak_dep = weakref.ref(state.__dep__)
41+
42+
del state
43+
44+
assert weak_proxy() is None
45+
assert weak_dep() is None
46+
assert target_id not in proxy_db.db
47+
48+
49+
def test_nested_proxies_are_reclaimed_by_refcount():
50+
state = reactive({"items": [{"value": 1}], "tags": {"a", "b"}})
51+
items = state["items"]
52+
item = items[0]
53+
tags = state["tags"]
54+
55+
proxies = (state, items, item, tags)
56+
target_ids = [id(p.__target__) for p in proxies]
57+
weak_proxies = [weakref.ref(p) for p in proxies]
58+
weak_deps = [weakref.ref(p.__dep__) for p in proxies]
59+
60+
del state, items, item, tags, proxies
61+
62+
assert all(r() is None for r in weak_proxies)
63+
assert all(r() is None for r in weak_deps)
64+
assert all(target_id not in proxy_db.db for target_id in target_ids)
65+
66+
67+
def test_watcher_is_reclaimed_by_refcount():
68+
state = reactive({"count": 0})
69+
calls = []
70+
71+
watcher = watch(lambda: state["count"], lambda new: calls.append(new), sync=True)
72+
state["count"] += 1
73+
assert calls == [1]
74+
75+
weak_watcher = weakref.ref(watcher)
76+
del watcher
77+
assert weak_watcher() is None
78+
79+
# the dep no longer has any subscribers to notify
80+
state["count"] += 1
81+
assert calls == [1]
82+
83+
84+
def test_watch_effect_is_reclaimed_by_refcount():
85+
state = reactive({"count": 0})
86+
seen = []
87+
88+
watcher = watch_effect(lambda: seen.append(state["count"]), sync=True)
89+
state["count"] += 1
90+
assert seen == [0, 1]
91+
92+
weak_watcher = weakref.ref(watcher)
93+
del watcher
94+
assert weak_watcher() is None
95+
96+
97+
def test_deep_watcher_keeps_and_releases_registry_entries():
98+
state = reactive({"nested": {"count": 0}})
99+
outer_id = id(state.__target__)
100+
inner_id = id(state.__target__["nested"])
101+
102+
watcher = watch(state, lambda: None, sync=True)
103+
104+
# the deep traversal registered a dep for the nested raw dict
105+
assert outer_id in proxy_db.db
106+
assert inner_id in proxy_db.db
107+
108+
# the watcher's strong references to the deps (and the watched
109+
# proxy, closed over by Watcher.fn) keep the registry entries
110+
# alive even when the proxy variable goes out of scope
111+
del state
112+
assert outer_id in proxy_db.db
113+
assert inner_id in proxy_db.db
114+
115+
del watcher
116+
assert outer_id not in proxy_db.db
117+
assert inner_id not in proxy_db.db
118+
119+
120+
def test_stopped_watcher_releases_state():
121+
state = reactive({"count": 0})
122+
target_id = id(state.__target__)
123+
weak_dep = weakref.ref(state.__dep__)
124+
125+
watcher = watch(lambda: state["count"], lambda: None, sync=True)
126+
127+
watcher.stop()
128+
# Rebind instead of del: the name is closed over by the watched
129+
# lambda, and pyflakes would flag that closure as undefined (F821)
130+
state = None
131+
132+
# the stopped watcher no longer keeps the state alive: stop()
133+
# released the watched function (which closed over the proxy)
134+
# and the collected deps
135+
assert not watcher.active
136+
assert weak_dep() is None
137+
assert target_id not in proxy_db.db
138+
139+
140+
def test_keydep_is_reclaimed_by_refcount():
141+
state = reactive({"count": 0})
142+
watcher = watch(lambda: state["count"], lambda: None, sync=True)
143+
144+
keydeps = state.__dep__.keydeps
145+
assert keydeps is not None
146+
assert "count" in keydeps
147+
148+
# the watcher was the only thing keeping the key's dep alive
149+
del watcher
150+
assert "count" not in keydeps
151+
152+
153+
def test_computed_is_reclaimed_by_refcount():
154+
state = reactive({"count": 1})
155+
target_id = id(state.__target__)
156+
157+
@computed
158+
def double():
159+
return state["count"] * 2
160+
161+
assert double() == 2
162+
163+
weak_watcher = weakref.ref(double.__watcher__)
164+
# Rebind instead of del: the names are closed over, and pyflakes
165+
# would flag those closures as undefined (F821)
166+
double = None
167+
assert weak_watcher() is None
168+
169+
state = None
170+
assert target_id not in proxy_db.db
171+
172+
173+
def test_chained_computed_is_reclaimed_by_refcount():
174+
state = reactive({"count": 1})
175+
target_id = id(state.__target__)
176+
177+
@computed
178+
def double():
179+
return state["count"] * 2
180+
181+
@computed
182+
def quadruple():
183+
return double() * 2
184+
185+
assert quadruple() == 4
186+
187+
weak_watchers = [
188+
weakref.ref(double.__watcher__),
189+
weakref.ref(quadruple.__watcher__),
190+
]
191+
192+
# Rebind instead of del: the names are closed over, and pyflakes
193+
# would flag those closures as undefined (F821)
194+
double = quadruple = None
195+
assert all(r() is None for r in weak_watchers)
196+
197+
state = None
198+
assert target_id not in proxy_db.db
199+
200+
201+
@pytest.mark.parametrize("number_of_args", [0, 1, 2])
202+
def test_callback_argument_probing_creates_no_cycle(number_of_args):
203+
"""
204+
Figuring out the number of callback arguments involves raising and
205+
catching exceptions and inspecting their tracebacks (see
206+
Watcher._run_callback), which is a classic way to accidentally
207+
create frame<->traceback reference cycles that keep the watcher
208+
alive until the next gc collection.
209+
"""
210+
state = reactive({"count": 0})
211+
calls = []
212+
213+
if number_of_args == 0:
214+
215+
def cb():
216+
calls.append(())
217+
218+
elif number_of_args == 1:
219+
220+
def cb(new):
221+
calls.append((new,))
222+
223+
else:
224+
225+
def cb(new, old):
226+
calls.append((new, old))
227+
228+
watcher = watch(lambda: state["count"], cb, sync=True)
229+
state["count"] += 1
230+
assert len(calls) == 1
231+
232+
weak_watcher = weakref.ref(watcher)
233+
del watcher
234+
assert weak_watcher() is None
235+
236+
237+
@pytest.mark.parametrize("exception_class", [RuntimeError, TypeError])
238+
def test_exception_from_callback_creates_no_cycle(exception_class):
239+
"""
240+
An exception raised from within a callback travels through frames
241+
that reference the watcher (Watcher._run_callback in particular
242+
inspects the traceback to tell a wrong signature apart from a
243+
TypeError raised inside the callback). Once the exception has been
244+
handled, the watcher must be reclaimable by refcounting alone.
245+
"""
246+
state = reactive({"count": 0})
247+
248+
def cb(new):
249+
raise exception_class("raised from inside the callback")
250+
251+
watcher = watch(lambda: state["count"], cb, sync=True)
252+
253+
with pytest.raises(exception_class):
254+
state["count"] += 1
255+
256+
weak_watcher = weakref.ref(watcher)
257+
del watcher
258+
assert weak_watcher() is None
259+
260+
261+
def test_exception_from_watched_fn_creates_no_cycle():
262+
state = reactive({"count": 0})
263+
264+
def fn():
265+
if state["count"] > 0:
266+
raise RuntimeError("raised from inside the watched function")
267+
return state["count"]
268+
269+
watcher = watch(fn, lambda: None, sync=True)
270+
271+
with pytest.raises(RuntimeError):
272+
state["count"] += 1
273+
274+
weak_watcher = weakref.ref(watcher)
275+
del watcher
276+
assert weak_watcher() is None
277+
278+
279+
def test_observ_machinery_creates_no_collectable_cycles():
280+
"""
281+
Run a scenario that touches most of the observ machinery and
282+
assert that the cycle collector finds nothing to collect
283+
afterwards: everything must have been reclaimed by reference
284+
counting already.
285+
"""
286+
287+
class Counter:
288+
count = 0
289+
290+
def cb(self, new, old):
291+
type(self).count += 1
292+
293+
def scenario():
294+
counter = Counter()
295+
state = reactive({"items": [1, 2], "nested": {"count": 0}, "tags": {"a"}})
296+
watchers = [
297+
watch(lambda: state["nested"]["count"], counter.cb, sync=True),
298+
watch(state, lambda: None, sync=True),
299+
watch_effect(lambda: state["items"][0], sync=True),
300+
]
301+
302+
@computed
303+
def total():
304+
return state["nested"]["count"] + len(state["items"])
305+
306+
assert total() == 2
307+
state["nested"]["count"] += 1
308+
state["items"].append(3)
309+
assert total() == 4
310+
state["tags"].add("b")
311+
312+
watchers[0].stop()
313+
314+
# Warm up any caches in the machinery (inspect.signature and
315+
# friends) so they don't show up in the measured run
316+
scenario()
317+
gc.collect()
318+
319+
scenario()
320+
assert gc.collect() == 0

0 commit comments

Comments
 (0)