Skip to content

set_config() blocks every concurrent ldclient.get() while closing the previous client #496

Open

Description

@aviadr1

Describe the bug

ldclient.set_config() holds the global write lock across both the construction of the replacement client and old_client.close(), so every concurrent ldclient.get() blocks for the entire duration.

ldclient/__init__.py:38-47:

with __lock.write():
    try:
        if __client:
            new_client = LDClient(config=config, start_wait=start_wait)   # blocks up to start_wait
            old_client = __client
            __client = new_client
            old_client.close()                                           # blocks on network shutdown
    finally:
        __config = config

ReadWriteLock.lock() (ldclient/impl/rwlock.py) acquires the underlying mutex and holds it for the whole write section, and rlock() must acquire that same mutex. So while set_config() runs, every ldclient.get() — which is on the hot path of every flag evaluation in an application using the singleton API — is blocked.

This couples a network-dependent shutdown to a lock that every evaluating thread needs. A slow close() stalls the whole application, not just the thread that called set_config().

Found while investigating #493, a production incident where close() hung and left worker pods alive for hours to days. This is what makes that class of hang so damaging under the singleton API: before a shutdown timeout existed, a stalled close() inside set_config() would block every get() in the process indefinitely. I have not observed this specific path in our own production — we do not call set_config() after startup — but the coupling is real and reproduces reliably.

Even with a bounded close(), the window is start_wait (default 5s) plus the shutdown timeout, so a reconfigure can stall all evaluating threads for ~10 seconds.

To reproduce

PR #499 adds a failing test (marked xfail(strict=True); run with --runxfail to see it fail). It drives a 3 second close() and measures a concurrent get():

AssertionError: ldclient.get() blocked for 2.8s while set_config() was closing the previous client

Expected behavior

Reconfiguring should not stall threads that are only reading the shared client. The write lock should cover the pointer swap, not the construction and teardown around it.

SDK version

9.16.1 / main at 5da1515

Language version, developer tools

Python 3.14

OS/platform

Linux

Additional context

I have not proposed a fix. Moving construction and close() outside the lock is the obvious direction, but doing it without introducing a lost-update race between concurrent set_config() callers is a design decision I would rather leave to you.

Activity

  1. aviadr1 commented on Aug 16, 2026

    @aviadr1
    Author

    Some extra data from using this in production, in case it helps.

    1. This path is reachable at runtime, not just at startup.

    The report notes you hadn't seen it because you don't call set_config() after startup. Our wrapper does. It retries client construction in a loop whenever LaunchDarkly is unreachable, and every attempt calls set_config(). During an outage that was one call roughly every 45 seconds, for the whole outage, across ~15 services. Any "resilient reconnect" wrapper reaches this path.

    2. From async code it stalls the entire event loop, not just evaluating threads.

    set_config() is synchronous, so calling it from an async def blocks the event loop for the whole lock hold. Measured on 9.16.0 with a client already present (so it takes the construct + close path), default start_wait=5, LaunchDarkly unreachable:

    set_config() from a coroutine took : 5.00s
    worst event-loop stall             : 5.00s   (healthy loop ~0.02s)
    

    For those 5 seconds the process served nothing at all — no HTTP handlers, no Kafka polls, no timers. So the blast radius is wider than threads doing flag evaluation: it's everything on the loop.

    Repro: point base_uri/stream_uri/events_uri at http://localhost:1 (connection refused is what an outage looks like to the SDK), call set_config() from a coroutine, and sample the loop from a heartbeat coroutine.

    3. The 5s is the normal case during an outage, not a worst case.

    update_processor_ready.wait(start_wait) runs to completion whenever LaunchDarkly is unreachable, so the full start_wait elapses on every reconfigure for the duration of the outage.

    We fixed it on our side by not using the singleton API: construct LDClient(config, start_wait=0) directly and poll is_initialized() ourselves.

  2. jsonbailey commented on Aug 21, 2026

    @jsonbailey
    Contributor

    @aviadr1 Thank you for raising the issue and the PRs. We will look at this and provide an update as soon as we can.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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