You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
set_config() blocks every concurrent ldclient.get() while closing the previous client #496
ldclient.set_config() holds the global write lock across both the construction of the replacement client andold_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_waitold_client=__client__client=new_clientold_client.close() # blocks on network shutdownfinally:
__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.
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.
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.
Activity
aviadr1 commentedon Aug 16, 2026
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 callsset_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 anasync defblocks 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), defaultstart_wait=5, LaunchDarkly unreachable: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_uriathttp://localhost:1(connection refused is what an outage looks like to the SDK), callset_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 fullstart_waitelapses 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 pollis_initialized()ourselves.jsonbailey commentedon Aug 21, 2026
@aviadr1 Thank you for raising the issue and the PRs. We will look at this and provide an update as soon as we can.