From 2ebef9df6f76d99c526cb07272e237454304d1f3 Mon Sep 17 00:00:00 2001 From: Aviad Rozenhek Date: Sat, 15 Aug 2026 22:10:12 +0300 Subject: [PATCH] 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) --- .../test_ldclient_construction_failure.py | 69 +++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 ldclient/testing/test_ldclient_construction_failure.py diff --git a/ldclient/testing/test_ldclient_construction_failure.py b/ldclient/testing/test_ldclient_construction_failure.py new file mode 100644 index 00000000..43ae4c5f --- /dev/null +++ b/ldclient/testing/test_ldclient_construction_failure.py @@ -0,0 +1,69 @@ +""" +Tests for what happens to already-started background threads when LDClient construction fails +partway through. +""" + +import threading + +import pytest + +from ldclient.client import Config, LDClient +from ldclient.interfaces import UpdateProcessor + +unreachable_uri = "http://fake" + + +class FailingUpdateProcessor(UpdateProcessor): + """ + A data source that fails on start(). Reaching this is realistic: update_processor_class is a + documented configuration hook, and the built-in data sources do real work in start(). + """ + + def __init__(self, config, store, ready): + pass + + def start(self): + raise Exception("deliberate failure while starting the data source") + + def stop(self): + pass + + def initialized(self): + return False + + +def ldclient_threads() -> set: + return {t.name for t in threading.enumerate() if t.name.startswith('ldclient.')} + + +@pytest.mark.xfail(strict=True, reason="a failure partway through __start_up leaves already-started components running with no way to reach them") +def test_failed_construction_does_not_leak_background_threads(): + """ + INVARIANT: if the constructor raises, it leaves nothing running. A caller that never receives + a client object has no way to release anything. + + __start_up() creates the event processor - which starts 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 gets no + object, and there is no handle on which to call close(). Every thread the event processor + started stays running, along with its HTTP connection pool. + + They are daemon threads, so this does not prevent process exit, but it does leak steadily in + any application that retries client construction, and postfork() re-runs this same path. + """ + before = ldclient_threads() + + config = Config( + sdk_key='SDK_KEY', + base_uri=unreachable_uri, + events_uri=unreachable_uri, + stream_uri=unreachable_uri, + update_processor_class=FailingUpdateProcessor, + diagnostic_opt_out=True, + ) + + with pytest.raises(Exception): + LDClient(config=config, start_wait=0) + + leaked = ldclient_threads() - before + assert not leaked, "construction failed but left these threads running: %s" % sorted(leaked)