Skip to content

Commit 2ebef9d

Browse files
aviadr1claude
andcommitted
test: Add failing test for thread leak when construction fails
__start_up() creates the event processor - starting a dispatcher thread, a pool of flush workers and two repeating timer threads - and only then starts the data system. If the data system fails to start, the exception propagates out of the constructor, the caller never receives a client object, and there is no handle on which to call close(). Everything already started keeps running. The test uses a configured update_processor_class that raises in start(), and currently reports eight leaked threads: ldclient.events.context-flush.repeating, ldclient.events.flush.repeating, ldclient.events.processor, ldclient.flush.1 .. ldclient.flush.5 plus the event processor's HTTP connection pool. These are daemon threads so they do not prevent process exit, but they leak steadily in any application that retries client construction, and postfork() re-runs this same path. Marked xfail strict so it fails loudly once the behaviour is fixed and the marker can be removed. No fix is proposed here. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 5da1515 commit 2ebef9d

1 file changed

Lines changed: 69 additions & 0 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"""
2+
Tests for what happens to already-started background threads when LDClient construction fails
3+
partway through.
4+
"""
5+
6+
import threading
7+
8+
import pytest
9+
10+
from ldclient.client import Config, LDClient
11+
from ldclient.interfaces import UpdateProcessor
12+
13+
unreachable_uri = "http://fake"
14+
15+
16+
class FailingUpdateProcessor(UpdateProcessor):
17+
"""
18+
A data source that fails on start(). Reaching this is realistic: update_processor_class is a
19+
documented configuration hook, and the built-in data sources do real work in start().
20+
"""
21+
22+
def __init__(self, config, store, ready):
23+
pass
24+
25+
def start(self):
26+
raise Exception("deliberate failure while starting the data source")
27+
28+
def stop(self):
29+
pass
30+
31+
def initialized(self):
32+
return False
33+
34+
35+
def ldclient_threads() -> set:
36+
return {t.name for t in threading.enumerate() if t.name.startswith('ldclient.')}
37+
38+
39+
@pytest.mark.xfail(strict=True, reason="a failure partway through __start_up leaves already-started components running with no way to reach them")
40+
def test_failed_construction_does_not_leak_background_threads():
41+
"""
42+
INVARIANT: if the constructor raises, it leaves nothing running. A caller that never receives
43+
a client object has no way to release anything.
44+
45+
__start_up() creates the event processor - which starts a dispatcher thread, a pool of flush
46+
workers and two repeating timer threads - and only then starts the data system. If the data
47+
system fails to start, the exception propagates out of the constructor, the caller gets no
48+
object, and there is no handle on which to call close(). Every thread the event processor
49+
started stays running, along with its HTTP connection pool.
50+
51+
They are daemon threads, so this does not prevent process exit, but it does leak steadily in
52+
any application that retries client construction, and postfork() re-runs this same path.
53+
"""
54+
before = ldclient_threads()
55+
56+
config = Config(
57+
sdk_key='SDK_KEY',
58+
base_uri=unreachable_uri,
59+
events_uri=unreachable_uri,
60+
stream_uri=unreachable_uri,
61+
update_processor_class=FailingUpdateProcessor,
62+
diagnostic_opt_out=True,
63+
)
64+
65+
with pytest.raises(Exception):
66+
LDClient(config=config, start_wait=0)
67+
68+
leaked = ldclient_threads() - before
69+
assert not leaked, "construction failed but left these threads running: %s" % sorted(leaked)

0 commit comments

Comments
 (0)