fix: Stop the store availability poller outside the lock - #447
Conversation
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, have a team admin enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 08c05e1. Configure here.
A store read can fail after stop, on another thread. Without a stopped flag the failure started a poller that nothing would ever stop.
|
Good catch — the race is real and I have fixed it in 40ded5d. Two notes on scope, though, because the finding is narrower than it reads. The leak is not introduced by this PR. There is no terminal state on the wrapper on I verified this by running the new regression spec against unmodified The fix is a stopped flag, not a re-ordering. Re-taking the mutex around poller = @mutex.synchronize do
@stopped = true
task = @poller
@poller = nil
task
end
poller&.stop@mutex.synchronize do
# A read can fail after stop, and a poller started then would never be stopped.
next if @stopped
@poller = task
@poller.start
endThat closes the described race and the pre-existing one, and makes Test:
|

BEGIN_COMMIT_OVERRIDE
fix: Prevent close from hanging after a persistent store read fails
fix: Prevent the store availability poller from outliving the client
END_COMMIT_OVERRIDE
Symptom
LDClient#closenever returns if a persistent store read has raised at any pointbeforehand. The process hangs.
The store does not have to be broken at the time of the call. A single transient error
earlier in the process — a Redis blip, a DynamoDB throttle — is enough.
Cause
A failed store read calls
update_availability(false), which starts aRepeatingTaskthat polls the store every 0.5 seconds (
store_client_wrapper.rb:101-106).FeatureStoreClientWrapper#stopthen took@mutexand called@poller.stopwhile stillholding it.
RepeatingTask#stopwaits for the worker thread (repeating_task.rb:66), andthat worker is in
check_availability→update_availability(true), which needs the same@mutex. The main thread waits for the worker; the worker waits for the mutex.Confirmed with thread backtraces — both threads report
status="sleep":The whole cycle is contained in one wrapper instance: the worker's stack bottoms out in the
earlier
update_availability(false)call that created the task.Impact beyond the hang
FDv1#stopnever gets past@store_wrapper.stop(data_system/fdv1.rb:95), so@shared_executor.shutdowndoes not run. Back inLDClient#close, neither@event_processor.stopnor@big_segment_store_manager.stopruns, so buffered events arelost as well.
Fix
Take the poller out of
@mutex, then stop it outside the lock. The mutex still guards@poller; it is released before the wait.Applied in both places that stop the poller. The second one, in
update_availability's"available again" branch, runs on the poller's own thread, where
RepeatingTask#stopreturns early on its
@worker != Thread.currentguard — so it does not deadlock today. Itis the same hazard from any other thread, and leaving one site in the old shape invites the
bug back.
One behavior note for review: this drops the
return if @poller.nil?early-out in favour ofpoller&.stop. The two are equivalent — in the original thatreturnexited the wholemethod, and here a nil poller no-ops and falls through to the same
return.Tests
spec/impl/store_client_wrapper_spec.rbgains "can stop while the availability poller isrunning". It fails a timeout assertion rather than blocking, because a hang in CI is worse
than a failure.
Verified load-bearing: with only the source change reverted, the spec fails. Restored, it
passes.
rspec spec1091 examples / 0 failures,rubocopclean.Related
Python has the same structure but escapes the deadlock, because its
RepeatingTask.stop()signals without waiting. It pays for that with a poller that is never stopped at all.
Tracked separately as SDK-3182.
Note
Overview
Fixes
LDClient#closehanging when a persistent store read had failed earlier:FeatureStoreClientWrapper#stopno longer callsRepeatingTask#stopwhile holding@mutex, avoiding deadlock with the availability poller thread inupdate_availability.stopnow sets@stopped, clears@pollerunder the lock, then stops the task outside the lock. The same extract-then-stop pattern applies when the store becomes available again.update_availability(false)skips starting a new poller if@stoppedis already true, so late failing reads after shutdown cannot leak a background task.Specs add coverage for stopping during an active poller (with a timeout so CI does not hang) and for not starting the poller after
stop.Reviewed by Cursor Bugbot for commit 40ded5d. Bugbot is set up for automated code reviews on this repo. Configure here.