Skip to content

LDClient.close() leaks components when one fails to stop, and is not idempotent #495

Description

@aviadr1

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

  1. close() releases every component even if one fails — log the failure and continue.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions