Describe the bug
LDClient.close() is documented as "Releases all threads and network connections used by the LaunchDarkly client", but it cannot honour that in two situations.
Found while investigating #493 — a production incident where close() hung and left worker pods alive for hours to days, each holding a concurrency slot. These two are separate defects in the same method, found by auditing the shutdown path afterwards. I have not observed either in our own production, but both reproduce reliably.
1. A failure in one component orphans the rest
ldclient/client.py:355-358:
self._event_processor.stop()
self._data_system.stop()
self.__big_segment_store_manager.stop()
There is no error handling. If the first raises, the data system and the big segment store manager are never stopped.
This is not contrived: two of these three reach code the SDK does not control — _data_system.stop() reaches the eventsource client, and __big_segment_store_manager.stop() calls stop() on the application's own BigSegmentStore implementation. A third-party store raising on shutdown is entirely ordinary. The caller is then left with leaked threads and connections belonging to a client it believes is closed.
2. close() is not idempotent
LDClient has no closed-flag, so a second close() runs the whole sequence again. That calls stop() a second time on the application's BigSegmentStore, and under FDv2 calls store.close() twice. DefaultEventProcessor.stop() is the only layer that self-guards.
Double-close is easy to reach by accident — an explicit close() inside a with block does it, as does any cleanup path that can run twice.
To reproduce
PR #498 adds two failing tests (marked xfail(strict=True) so CI stays green). Run them with --runxfail to see the real failures.
For the idempotency case the recorded shutdown log is:
['update_processor', 'big_segment_store', 'update_processor', 'big_segment_store']
Expected behavior
close() releases every component even if one fails — log the failure and continue.
close() on an already-closed client does nothing further.
SDK version
9.16.1 / main at 5da1515
Language version, developer tools
Python 3.14
OS/platform
Linux
Additional context
I have deliberately not proposed a fix, since the error-propagation semantics (whether close() should still raise after releasing everything) is a design call for the SDK team. The tests pin the leak, not the exception behaviour.
Describe the bug
LDClient.close()is documented as "Releases all threads and network connections used by the LaunchDarkly client", but it cannot honour that in two situations.Found while investigating #493 — a production incident where
close()hung and left worker pods alive for hours to days, each holding a concurrency slot. These two are separate defects in the same method, found by auditing the shutdown path afterwards. I have not observed either in our own production, but both reproduce reliably.1. A failure in one component orphans the rest
ldclient/client.py:355-358:There is no error handling. If the first raises, the data system and the big segment store manager are never stopped.
This is not contrived: two of these three reach code the SDK does not control —
_data_system.stop()reaches the eventsource client, and__big_segment_store_manager.stop()callsstop()on the application's ownBigSegmentStoreimplementation. A third-party store raising on shutdown is entirely ordinary. The caller is then left with leaked threads and connections belonging to a client it believes is closed.2.
close()is not idempotentLDClienthas no closed-flag, so a secondclose()runs the whole sequence again. That callsstop()a second time on the application'sBigSegmentStore, and under FDv2 callsstore.close()twice.DefaultEventProcessor.stop()is the only layer that self-guards.Double-close is easy to reach by accident — an explicit
close()inside awithblock does it, as does any cleanup path that can run twice.To reproduce
PR #498 adds two failing tests (marked
xfail(strict=True)so CI stays green). Run them with--runxfailto see the real failures.For the idempotency case the recorded shutdown log is:
Expected behavior
close()releases every component even if one fails — log the failure and continue.close()on an already-closed client does nothing further.SDK version
9.16.1 /
mainat 5da1515Language version, developer tools
Python 3.14
OS/platform
Linux
Additional context
I have deliberately not proposed a fix, since the error-propagation semantics (whether
close()should still raise after releasing everything) is a design call for the SDK team. The tests pin the leak, not the exception behaviour.