From 08c05e17b8463bcd146ed169b9863744b9511424 Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Wed, 23 Sep 2026 10:01:38 -0500 Subject: [PATCH 1/2] fix: Stop the store availability poller outside the lock close() hung forever if a persistent store read had raised. The failed read starts an availability poller; stop then held @mutex while waiting for that poller's thread, which needed @mutex to finish its run. --- lib/ldclient-rb/impl/store_client_wrapper.rb | 15 ++++---- spec/impl/store_client_wrapper_spec.rb | 37 ++++++++++++++++++++ 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/lib/ldclient-rb/impl/store_client_wrapper.rb b/lib/ldclient-rb/impl/store_client_wrapper.rb index 92c64e72..b720d2c0 100644 --- a/lib/ldclient-rb/impl/store_client_wrapper.rb +++ b/lib/ldclient-rb/impl/store_client_wrapper.rb @@ -53,12 +53,13 @@ def initialized? def stop @store.stop - @mutex.synchronize do - return if @poller.nil? - @poller.stop + poller = @mutex.synchronize do + task = @poller @poller = nil + task end + poller&.stop end def monitoring_enabled? @@ -87,12 +88,12 @@ def monitoring_enabled? @store_update_sink.update_status(status) if available - @mutex.synchronize do - return if @poller.nil? - - @poller.stop + poller = @mutex.synchronize do + task = @poller @poller = nil + task end + poller&.stop return end diff --git a/spec/impl/store_client_wrapper_spec.rb b/spec/impl/store_client_wrapper_spec.rb index b0086992..4fe564d6 100644 --- a/spec/impl/store_client_wrapper_spec.rb +++ b/spec/impl/store_client_wrapper_spec.rb @@ -78,6 +78,43 @@ module Impl expect(statuses[1].available).to be true end end + + it "can stop while the availability poller is running" do + sink = double + store = double + checking = Concurrent::Event.new + + allow(store).to receive(:stop) + allow(store).to receive(:monitoring_enabled?).and_return(true) + allow(store).to receive(:all).and_raise(StandardError.new('read error')) + allow(sink).to receive(:update_status) + # Hold the poller's thread inside its availability check, so that stop has to wait + # for a thread that still needs the lock stop holds. + allow(store).to receive(:available?) do + checking.set + sleep 0.25 + true + end + + wrapper = FeatureStoreClientWrapper.new(store, sink, $null_log) + + begin + wrapper.all(:features) + raise "all should have raised exception" + rescue StandardError + # Ignored. The failed read starts the availability poller. + end + + expect(checking.wait(2)).to be true + + stopped = Concurrent::Event.new + Thread.new do + wrapper.stop + stopped.set + end + + expect(stopped.wait(5)).to be true + end end end end From 40ded5d27b1074b9316683a8f9ba1ee015c5a714 Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Wed, 23 Sep 2026 10:12:36 -0500 Subject: [PATCH 2/2] fix: Do not start the availability poller after stop A store read can fail after stop, on another thread. Without a stopped flag the failure started a poller that nothing would ever stop. --- lib/ldclient-rb/impl/store_client_wrapper.rb | 5 ++++ spec/impl/store_client_wrapper_spec.rb | 26 ++++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/lib/ldclient-rb/impl/store_client_wrapper.rb b/lib/ldclient-rb/impl/store_client_wrapper.rb index b720d2c0..7698b43f 100644 --- a/lib/ldclient-rb/impl/store_client_wrapper.rb +++ b/lib/ldclient-rb/impl/store_client_wrapper.rb @@ -23,6 +23,7 @@ def initialize(store, store_update_sink, logger) @mutex = Mutex.new # Covers the following variables @last_available = true + @stopped = false # @type [LaunchDarkly::Impl::RepeatingTask, nil] @poller = nil end @@ -55,6 +56,7 @@ def stop @store.stop poller = @mutex.synchronize do + @stopped = true task = @poller @poller = nil task @@ -103,6 +105,9 @@ def monitoring_enabled? task = Impl::RepeatingTask.new(0.5, 0, -> { self.check_availability }, @logger, 'LD/StoreWrapper#check_availability') @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 end diff --git a/spec/impl/store_client_wrapper_spec.rb b/spec/impl/store_client_wrapper_spec.rb index 4fe564d6..43ba7745 100644 --- a/spec/impl/store_client_wrapper_spec.rb +++ b/spec/impl/store_client_wrapper_spec.rb @@ -115,6 +115,32 @@ module Impl expect(stopped.wait(5)).to be true end + + it "does not start the availability poller after stop" do + sink = double + store = double + checks = Concurrent::AtomicFixnum.new(0) + + allow(store).to receive(:stop) + allow(store).to receive(:monitoring_enabled?).and_return(true) + allow(store).to receive(:all).and_raise(StandardError.new('read error')) + allow(sink).to receive(:update_status) + allow(store).to receive(:available?) { checks.increment; true } + + wrapper = FeatureStoreClientWrapper.new(store, sink, $null_log) + wrapper.stop + + begin + wrapper.all(:features) + raise "all should have raised exception" + rescue StandardError + # Ignored. On a running wrapper this would start the poller. + end + + # The poller is the only caller of available?, so it never ran. + sleep 1 + expect(checks.value).to eq 0 + end end end end