Skip to content

Commit a10751c

Browse files
aviadr1claude
andcommitted
test: Add failing tests for LDClient.close() releasing all components
close() is documented as releasing all threads and network connections, but it calls the event processor, the data system and the big segment store manager in sequence with no error handling and no closed-flag. Two failing tests (marked xfail strict, so they will start failing loudly once the behaviour is fixed and the markers can be removed): - test_close_releases_every_component_even_if_one_raises: if the first component's stop() raises, the data system and big segment store manager are never stopped. Two of the three reach code the SDK does not control - the eventsource client, and the application's own BigSegmentStore - so this is a realistic failure, not a contrived one. The caller is left with leaked threads and connections from a client it believes is closed. - test_close_is_idempotent: with no closed-flag, a second close() re-runs the whole sequence, calling stop() twice on the application's BigSegmentStore and store.close() twice under FDv2. The recorded call log shows ['update_processor', 'big_segment_store', 'update_processor', 'big_segment_store']. No fix is proposed here; these only pin the contract. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5da1515 commit a10751c

1 file changed

Lines changed: 142 additions & 0 deletions

File tree

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"""
2+
Tests for the shutdown contract of LDClient.close().
3+
4+
close() is documented as "Releases all threads and network connections used by the LaunchDarkly
5+
client". These tests pin that contract. They are currently marked xfail because the SDK does not
6+
yet honour it; each one describes a specific way the contract is broken.
7+
"""
8+
9+
import time
10+
11+
import pytest
12+
13+
from ldclient.client import Config, LDClient
14+
from ldclient.config import BigSegmentsConfig
15+
from ldclient.interfaces import (
16+
BigSegmentStore,
17+
BigSegmentStoreMetadata,
18+
EventProcessor,
19+
UpdateProcessor
20+
)
21+
22+
unreachable_uri = "http://fake"
23+
24+
25+
class RecordingBigSegmentStore(BigSegmentStore):
26+
"""A user-supplied big segment store, of the kind an application would provide."""
27+
28+
def __init__(self, log: list):
29+
self._log = log
30+
31+
def get_metadata(self) -> BigSegmentStoreMetadata:
32+
return BigSegmentStoreMetadata(int(time.time() * 1000))
33+
34+
def get_membership(self, user_hash: str):
35+
return None
36+
37+
def stop(self):
38+
self._log.append('big_segment_store')
39+
40+
41+
class RecordingUpdateProcessor(UpdateProcessor):
42+
def __init__(self, log: list):
43+
self._log = log
44+
45+
def start(self):
46+
pass
47+
48+
def stop(self):
49+
self._log.append('update_processor')
50+
51+
def initialized(self):
52+
return True
53+
54+
55+
class FailingEventProcessor(EventProcessor):
56+
"""
57+
Stands in for any component whose stop() raises. This is not far-fetched: close() reaches
58+
third-party code in two places - the eventsource client, and the application's own
59+
BigSegmentStore implementation.
60+
"""
61+
62+
def start(self):
63+
pass
64+
65+
def stop(self):
66+
raise Exception("deliberate error from a component's stop()")
67+
68+
def send_event(self, event):
69+
pass
70+
71+
def flush(self):
72+
pass
73+
74+
75+
def make_client(stop_log: list, event_processor_class=None) -> LDClient:
76+
config = Config(
77+
sdk_key='SDK_KEY',
78+
base_uri=unreachable_uri,
79+
events_uri=unreachable_uri,
80+
stream_uri=unreachable_uri,
81+
event_processor_class=event_processor_class or (lambda config: FailingEventProcessor()),
82+
update_processor_class=lambda config, store, ready: RecordingUpdateProcessor(stop_log),
83+
big_segments=BigSegmentsConfig(store=RecordingBigSegmentStore(stop_log)),
84+
)
85+
return LDClient(config=config, start_wait=0)
86+
87+
88+
@pytest.mark.xfail(strict=True, reason="close() has no error handling, so a failure in one component orphans the rest")
89+
def test_close_releases_every_component_even_if_one_raises():
90+
"""
91+
INVARIANT: close() releases all of the client's resources. A component that fails to shut
92+
down cleanly must not prevent the remaining components from being released.
93+
94+
close() calls the event processor, the data system and the big segment store manager in
95+
sequence with no error handling, so an exception from the first abandons the other two.
96+
Two of the three reach code the SDK does not control - the eventsource client and the
97+
application's own BigSegmentStore - so a raise here is a realistic scenario, not a contrived
98+
one. The result is leaked threads and connections from a client the caller believes is closed.
99+
"""
100+
stop_log: list = []
101+
client = make_client(stop_log)
102+
103+
try:
104+
client.close()
105+
except Exception:
106+
pass # whether close() propagates is a separate question; the leak is the bug
107+
108+
assert 'update_processor' in stop_log, "the data system was never stopped"
109+
assert 'big_segment_store' in stop_log, "the big segment store was never stopped"
110+
111+
112+
@pytest.mark.xfail(strict=True, reason="close() has no closed-flag, so it re-runs shutdown on every call")
113+
def test_close_is_idempotent():
114+
"""
115+
INVARIANT: closing an already-closed client has no further effect.
116+
117+
LDClient has no closed-flag, so a second close() runs the whole sequence again. That calls
118+
stop() a second time on the application's own BigSegmentStore, and on FDv2 calls
119+
store.close() twice. Double-close is easy to reach by accident - an explicit close() inside
120+
a `with` block does it, as does any cleanup path that runs more than once.
121+
"""
122+
stop_log: list = []
123+
124+
class NoopEventProcessor(EventProcessor):
125+
def start(self):
126+
pass
127+
128+
def stop(self):
129+
pass
130+
131+
def send_event(self, event):
132+
pass
133+
134+
def flush(self):
135+
pass
136+
137+
client = make_client(stop_log, event_processor_class=lambda config: NoopEventProcessor())
138+
139+
client.close()
140+
client.close()
141+
142+
assert stop_log.count('big_segment_store') == 1, "the big segment store was stopped more than once"

0 commit comments

Comments
 (0)