From e51f73194496e9e99d89f01a696daf5b931f2a81 Mon Sep 17 00:00:00 2001 From: Devin AI Date: Wed, 2 Sep 2026 14:50:13 +0000 Subject: [PATCH 1/4] fix: Wait for the data source when no start wait time is configured --- README.md | 2 +- .../impl/event_listeners.rb | 25 +++++++++++ lib/ldclient-openfeature/provider.rb | 31 +++++++++++++- .../impl/data_source_outcome_listener_spec.rb | 29 +++++++++++++ spec/provider_spec.rb | 42 ++++++++++++++++++- 5 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 spec/impl/data_source_outcome_listener_spec.rb diff --git a/README.md b/README.md index 466bc84..074fc54 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ This matrix mirrors the [feature matrix of the OpenFeature SDK for Ruby](https:/ | ✅ | Logging | The provider logs through the logger of the `LaunchDarkly::Config` it is given. | | ✅ | Domains | Domains bind clients to providers in the OpenFeature SDK; a separate provider instance may be registered per domain. | | ✅ | Eventing | LaunchDarkly data source status changes are emitted as `PROVIDER_READY`, `PROVIDER_STALE`, and `PROVIDER_ERROR`. Flag changes are emitted as `PROVIDER_CONFIGURATION_CHANGED` with the changed flag key. | -| ✅ | Initialization | `init` reports whether the LaunchDarkly client became ready within the configured wait time; a failure results in the `ERROR` state so that cached or fallback flag data is still evaluated. | +| ✅ | Initialization | `init` reports whether the LaunchDarkly client became ready within the configured wait time, and waits without a deadline when that wait time is zero; a failure results in the `ERROR` state so that cached or fallback flag data is still evaluated. | | ✅ | Shutdown | `shutdown` closes the LaunchDarkly client. A closed client cannot be restarted, so a new provider instance is required afterward. | | ✅ | Tracking | `track` sends a LaunchDarkly custom event for the evaluation context, with the tracking event value and remaining details attached. | | ✅ | Transaction Context Propagation | Provided by the OpenFeature SDK, which merges the transaction context into the evaluation context before the provider is called; no provider support is required. | diff --git a/lib/ldclient-openfeature/impl/event_listeners.rb b/lib/ldclient-openfeature/impl/event_listeners.rb index a78fc6d..792d4a3 100644 --- a/lib/ldclient-openfeature/impl/event_listeners.rb +++ b/lib/ldclient-openfeature/impl/event_listeners.rb @@ -54,6 +54,31 @@ def update(status) end end + # + # Reports the first data source state which decides the outcome of the client's initial connection attempt. + # + class DataSourceOutcomeListener + # + # @param outcome [Queue] + # + def initialize(outcome) + @outcome = outcome + end + + # + # @param status [LaunchDarkly::Interfaces::DataSource::Status] + # + # @return [void] + # + def update(status) + case status.state + when ::LaunchDarkly::Interfaces::DataSource::Status::VALID, + ::LaunchDarkly::Interfaces::DataSource::Status::OFF + @outcome.push(status.state) + end + end + end + # # Translates LaunchDarkly flag change events into OpenFeature configuration changed events. # diff --git a/lib/ldclient-openfeature/provider.rb b/lib/ldclient-openfeature/provider.rb index eb091cd..3b9d9fd 100644 --- a/lib/ldclient-openfeature/provider.rb +++ b/lib/ldclient-openfeature/provider.rb @@ -38,6 +38,7 @@ class Provider def initialize(sdk_key, config = LaunchDarkly::Config.default, wait_for_seconds = 5) @client = LaunchDarkly::LDClient.new(sdk_key, config.with_wrapper_information(WRAPPER_NAME, VERSION), wait_for_seconds) + @wait_for_seconds = wait_for_seconds @logger = config.logger @context_converter = Impl::EvaluationContextConverter.new(config.logger) @details_converter = Impl::ResolutionDetailsConverter.new @@ -49,14 +50,19 @@ def initialize(sdk_key, config = LaunchDarkly::Config.default, wait_for_seconds end # - # Called by the OpenFeature SDK when this provider is set. The LaunchDarkly client has already been given the - # opportunity to initialize, so this only reports whether that succeeded. + # Called by the OpenFeature SDK when this provider is set. + # + # A positive wait time has already been applied by the LaunchDarkly client constructor, so this reports whether + # that succeeded. A wait time of zero asks for no deadline, so this waits for the data source to become valid or + # to fail permanently. # # @param _evaluation_context [::OpenFeature::SDK::EvaluationContext, nil] # # @return [void] # def init(_evaluation_context = nil) + wait_for_data_source_outcome if @wait_for_seconds.to_f <= 0 + return if @client.initialized? state = @client.data_source_status_provider.status.state @@ -170,6 +176,27 @@ def track(tracking_event_name, evaluation_context: nil, tracking_event_details: @details_converter.to_resolution_details(evaluation_detail) end + # + # Block until the data source reports an outcome for the client's first connection attempt. + # + # @return [void] + # + private def wait_for_data_source_outcome + outcome = Queue.new + listener = Impl::DataSourceOutcomeListener.new(outcome) + status_provider = @client.data_source_status_provider + status_provider.add_listener(listener) + + begin + return if @client.initialized? + return if status_provider.status.state == ::LaunchDarkly::Interfaces::DataSource::Status::OFF + + outcome.pop + ensure + status_provider.remove_listener(listener) + end + end + # # @param default_value [any] # diff --git a/spec/impl/data_source_outcome_listener_spec.rb b/spec/impl/data_source_outcome_listener_spec.rb new file mode 100644 index 0000000..3be6db2 --- /dev/null +++ b/spec/impl/data_source_outcome_listener_spec.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +RSpec.describe LaunchDarkly::OpenFeature::Impl::DataSourceOutcomeListener do + let(:outcome) { Queue.new } + let(:listener) { described_class.new(outcome) } + + def status(state) + LaunchDarkly::Interfaces::DataSource::Status.new(state, Time.now, nil) + end + + it "reports a valid data source" do + listener.update(status(LaunchDarkly::Interfaces::DataSource::Status::VALID)) + + expect(outcome.pop).to eq(LaunchDarkly::Interfaces::DataSource::Status::VALID) + end + + it "reports a shut down data source" do + listener.update(status(LaunchDarkly::Interfaces::DataSource::Status::OFF)) + + expect(outcome.pop).to eq(LaunchDarkly::Interfaces::DataSource::Status::OFF) + end + + it "does not report a state which leaves the outcome undecided" do + listener.update(status(LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING)) + listener.update(status(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED)) + + expect(outcome).to be_empty + end +end diff --git a/spec/provider_spec.rb b/spec/provider_spec.rb index 112f4f4..ecd45c7 100644 --- a/spec/provider_spec.rb +++ b/spec/provider_spec.rb @@ -10,6 +10,10 @@ let(:config) { LaunchDarkly::Config.new(data_source: td, send_events: false) } let(:provider) { described_class.new("example-key", config) } + def data_source_status(state, error = nil) + LaunchDarkly::Interfaces::DataSource::Status.new(state, Time.now, error) + end + it "metadata is set correctly" do expect(provider.metadata.name).to eq("launchdarkly-openfeature-server") end @@ -48,13 +52,47 @@ end it "init raises when the client failed to initialize" do - status = LaunchDarkly::Interfaces::DataSource::Status.new(LaunchDarkly::Interfaces::DataSource::Status::OFF, Time.now, nil) - status_provider = double(status: status) + status_provider = double(status: data_source_status(LaunchDarkly::Interfaces::DataSource::Status::OFF)) allow(provider.client).to receive_messages(initialized?: false, data_source_status_provider: status_provider) expect { provider.init(evaluation_context) }.to raise_error(/unable to initialize/) end + it "init with no wait time waits for the data source to become valid" do + zero_wait_provider = described_class.new("example-key", config, 0) + + expect { zero_wait_provider.init(evaluation_context) }.not_to raise_error + expect(zero_wait_provider.client.initialized?).to be(true) + end + + it "init with no wait time waits for a data source outcome which arrives later" do + zero_wait_provider = described_class.new("example-key", config, 0) + listeners = [] + status_provider = double(status: data_source_status(LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING)) + allow(status_provider).to receive(:add_listener) { |listener| listeners << listener } + allow(status_provider).to receive(:remove_listener) + allow(zero_wait_provider.client).to receive_messages(initialized?: false, data_source_status_provider: status_provider) + + reporter = Thread.new do + sleep(0.01) while listeners.empty? + listeners.each { |listener| listener.update(data_source_status(LaunchDarkly::Interfaces::DataSource::Status::OFF)) } + end + + expect { zero_wait_provider.init(evaluation_context) }.to raise_error(/unable to initialize/) + + reporter.join + end + + it "init with a wait time does not wait again" do + wait_provider = described_class.new("example-key", config, 5) + status_provider = double(status: data_source_status(LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING)) + allow(status_provider).to receive(:add_listener) + allow(wait_provider.client).to receive_messages(initialized?: false, data_source_status_provider: status_provider) + + expect { wait_provider.init(evaluation_context) }.to raise_error(/unable to initialize/) + expect(status_provider).not_to have_received(:add_listener) + end + it "shutdown closes the client" do allow(provider.client).to receive(:close) From 7b1616a7dd4d4d30db9ed7cc390f05d6d37f64eb Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:48:17 +0000 Subject: [PATCH 2/4] fix: Wait for the data source when no start wait time is configured Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com> --- README.md | 2 +- lib/ldclient-openfeature/provider.rb | 13 +++++++------ spec/provider_spec.rb | 26 ++++++++++++++++++-------- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 074fc54..dc78a48 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ This matrix mirrors the [feature matrix of the OpenFeature SDK for Ruby](https:/ | ✅ | Logging | The provider logs through the logger of the `LaunchDarkly::Config` it is given. | | ✅ | Domains | Domains bind clients to providers in the OpenFeature SDK; a separate provider instance may be registered per domain. | | ✅ | Eventing | LaunchDarkly data source status changes are emitted as `PROVIDER_READY`, `PROVIDER_STALE`, and `PROVIDER_ERROR`. Flag changes are emitted as `PROVIDER_CONFIGURATION_CHANGED` with the changed flag key. | -| ✅ | Initialization | `init` reports whether the LaunchDarkly client became ready within the configured wait time, and waits without a deadline when that wait time is zero; a failure results in the `ERROR` state so that cached or fallback flag data is still evaluated. | +| ✅ | Initialization | `init` reports whether the LaunchDarkly client became ready within the configured wait time, and waits without a deadline when that wait time is `nil`; a failure results in the `ERROR` state so that cached or fallback flag data is still evaluated. | | ✅ | Shutdown | `shutdown` closes the LaunchDarkly client. A closed client cannot be restarted, so a new provider instance is required afterward. | | ✅ | Tracking | `track` sends a LaunchDarkly custom event for the evaluation context, with the tracking event value and remaining details attached. | | ✅ | Transaction Context Propagation | Provided by the OpenFeature SDK, which merges the transaction context into the evaluation context before the provider is called; no provider support is required. | diff --git a/lib/ldclient-openfeature/provider.rb b/lib/ldclient-openfeature/provider.rb index 3b9d9fd..642fd10 100644 --- a/lib/ldclient-openfeature/provider.rb +++ b/lib/ldclient-openfeature/provider.rb @@ -33,10 +33,11 @@ class Provider # # @param sdk_key [String] # @param config [LaunchDarkly::Config] - # @param wait_for_seconds [Float] + # @param wait_for_seconds [Float, nil] the number of seconds to wait for the client to become ready, or nil to + # wait for it without a deadline # def initialize(sdk_key, config = LaunchDarkly::Config.default, wait_for_seconds = 5) - @client = LaunchDarkly::LDClient.new(sdk_key, config.with_wrapper_information(WRAPPER_NAME, VERSION), wait_for_seconds) + @client = LaunchDarkly::LDClient.new(sdk_key, config.with_wrapper_information(WRAPPER_NAME, VERSION), wait_for_seconds.nil? ? 0 : wait_for_seconds) @wait_for_seconds = wait_for_seconds @logger = config.logger @@ -52,16 +53,16 @@ def initialize(sdk_key, config = LaunchDarkly::Config.default, wait_for_seconds # # Called by the OpenFeature SDK when this provider is set. # - # A positive wait time has already been applied by the LaunchDarkly client constructor, so this reports whether - # that succeeded. A wait time of zero asks for no deadline, so this waits for the data source to become valid or - # to fail permanently. + # A wait time has already been applied by the LaunchDarkly client constructor, so this reports whether that + # succeeded. A nil wait time asks for no deadline, so this waits for the data source to become valid or to fail + # permanently. # # @param _evaluation_context [::OpenFeature::SDK::EvaluationContext, nil] # # @return [void] # def init(_evaluation_context = nil) - wait_for_data_source_outcome if @wait_for_seconds.to_f <= 0 + wait_for_data_source_outcome if @wait_for_seconds.nil? return if @client.initialized? diff --git a/spec/provider_spec.rb b/spec/provider_spec.rb index ecd45c7..80a48ca 100644 --- a/spec/provider_spec.rb +++ b/spec/provider_spec.rb @@ -58,31 +58,41 @@ def data_source_status(state, error = nil) expect { provider.init(evaluation_context) }.to raise_error(/unable to initialize/) end - it "init with no wait time waits for the data source to become valid" do - zero_wait_provider = described_class.new("example-key", config, 0) + it "init without a deadline waits for the data source to become valid" do + indefinite_provider = described_class.new("example-key", config, nil) - expect { zero_wait_provider.init(evaluation_context) }.not_to raise_error - expect(zero_wait_provider.client.initialized?).to be(true) + expect { indefinite_provider.init(evaluation_context) }.not_to raise_error + expect(indefinite_provider.client.initialized?).to be(true) end - it "init with no wait time waits for a data source outcome which arrives later" do - zero_wait_provider = described_class.new("example-key", config, 0) + it "init without a deadline waits for a data source outcome which arrives later" do + indefinite_provider = described_class.new("example-key", config, nil) listeners = [] status_provider = double(status: data_source_status(LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING)) allow(status_provider).to receive(:add_listener) { |listener| listeners << listener } allow(status_provider).to receive(:remove_listener) - allow(zero_wait_provider.client).to receive_messages(initialized?: false, data_source_status_provider: status_provider) + allow(indefinite_provider.client).to receive_messages(initialized?: false, data_source_status_provider: status_provider) reporter = Thread.new do sleep(0.01) while listeners.empty? listeners.each { |listener| listener.update(data_source_status(LaunchDarkly::Interfaces::DataSource::Status::OFF)) } end - expect { zero_wait_provider.init(evaluation_context) }.to raise_error(/unable to initialize/) + expect { indefinite_provider.init(evaluation_context) }.to raise_error(/unable to initialize/) reporter.join end + it "init with a wait time of zero does not wait" do + zero_wait_provider = described_class.new("example-key", config, 0) + status_provider = double(status: data_source_status(LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING)) + allow(status_provider).to receive(:add_listener) + allow(zero_wait_provider.client).to receive_messages(initialized?: false, data_source_status_provider: status_provider) + + expect { zero_wait_provider.init(evaluation_context) }.to raise_error(/unable to initialize/) + expect(status_provider).not_to have_received(:add_listener) + end + it "init with a wait time does not wait again" do wait_provider = described_class.new("example-key", config, 5) status_provider = double(status: data_source_status(LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING)) From 9a48135fad88e7eb64ca052bf7ad54b9142e24cb Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:56:55 +0000 Subject: [PATCH 3/4] fix: Do not emit duplicate or unchanged provider status events Co-Authored-By: rlamb@launchdarkly.com <4955475+kinyoklion@users.noreply.github.com> --- .../impl/event_listeners.rb | 6 +-- lib/ldclient-openfeature/provider.rb | 41 ++++++++++++++++++- spec/impl/data_source_status_listener_spec.rb | 10 ++--- spec/provider_spec.rb | 32 +++++++++++++++ 4 files changed, 80 insertions(+), 9 deletions(-) diff --git a/lib/ldclient-openfeature/impl/event_listeners.rb b/lib/ldclient-openfeature/impl/event_listeners.rb index 792d4a3..2cabc2e 100644 --- a/lib/ldclient-openfeature/impl/event_listeners.rb +++ b/lib/ldclient-openfeature/impl/event_listeners.rb @@ -25,14 +25,14 @@ def initialize(provider) def update(status) case status.state when ::LaunchDarkly::Interfaces::DataSource::Status::VALID - @provider.emit_event(::OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + @provider.emit_status_event(::OpenFeature::SDK::ProviderEvent::PROVIDER_READY) when ::LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED - @provider.emit_event( + @provider.emit_status_event( ::OpenFeature::SDK::ProviderEvent::PROVIDER_STALE, message: message(status, "the data source has been interrupted") ) when ::LaunchDarkly::Interfaces::DataSource::Status::OFF - @provider.emit_event( + @provider.emit_status_event( ::OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, error_code: ::OpenFeature::SDK::Provider::ErrorCode::GENERAL, message: message(status, "the data source has been permanently shut down") diff --git a/lib/ldclient-openfeature/provider.rb b/lib/ldclient-openfeature/provider.rb index 642fd10..4ec1583 100644 --- a/lib/ldclient-openfeature/provider.rb +++ b/lib/ldclient-openfeature/provider.rb @@ -40,6 +40,9 @@ def initialize(sdk_key, config = LaunchDarkly::Config.default, wait_for_seconds @client = LaunchDarkly::LDClient.new(sdk_key, config.with_wrapper_information(WRAPPER_NAME, VERSION), wait_for_seconds.nil? ? 0 : wait_for_seconds) @wait_for_seconds = wait_for_seconds + @status_lock = Mutex.new + @status_event = nil + @initialization_complete = false @logger = config.logger @context_converter = Impl::EvaluationContextConverter.new(config.logger) @details_converter = Impl::ResolutionDetailsConverter.new @@ -64,12 +67,48 @@ def initialize(sdk_key, config = LaunchDarkly::Config.default, wait_for_seconds def init(_evaluation_context = nil) wait_for_data_source_outcome if @wait_for_seconds.nil? - return if @client.initialized? + initialized = @client.initialized? + + @status_lock.synchronize do + # The OpenFeature SDK emits its own event for the outcome of initialization, so the status is recorded + # here without emitting an event. + @status_event = if initialized + ::OpenFeature::SDK::ProviderEvent::PROVIDER_READY + else + ::OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR + end + @initialization_complete = true + end + + return if initialized state = @client.data_source_status_provider.status.state raise "the LaunchDarkly client was unable to initialize; the data source state is #{state}" end + # + # Emit a provider event for a data source state change. + # + # A state which does not change the provider status is not emitted, and neither is the change which completes + # initialization, because the OpenFeature SDK emits its own event for that one. Changes after initialization has + # completed, including after it has failed, are emitted. + # + # @param event [String] + # @param details [Hash] + # + # @return [void] + # + def emit_status_event(event, **details) + @status_lock.synchronize do + return if event == @status_event + + @status_event = event + return unless @initialization_complete + end + + emit_event(event, **details) + end + # # Called by the OpenFeature SDK when this provider is replaced or the SDK is shut down. The LaunchDarkly client # cannot be restarted, so a new provider instance is required afterward. diff --git a/spec/impl/data_source_status_listener_spec.rb b/spec/impl/data_source_status_listener_spec.rb index a879a30..9ce1303 100644 --- a/spec/impl/data_source_status_listener_spec.rb +++ b/spec/impl/data_source_status_listener_spec.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true RSpec.describe LaunchDarkly::OpenFeature::Impl::DataSourceStatusListener do - let(:provider) { double(emit_event: nil) } + let(:provider) { double(emit_status_event: nil) } let(:listener) { described_class.new(provider) } def status(state, error = nil) @@ -11,13 +11,13 @@ def status(state, error = nil) it "a valid data source is ready" do listener.update(status(LaunchDarkly::Interfaces::DataSource::Status::VALID)) - expect(provider).to have_received(:emit_event).with(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + expect(provider).to have_received(:emit_status_event).with(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) end it "an interrupted data source is stale" do listener.update(status(LaunchDarkly::Interfaces::DataSource::Status::INTERRUPTED)) - expect(provider).to have_received(:emit_event) + expect(provider).to have_received(:emit_status_event) .with(OpenFeature::SDK::ProviderEvent::PROVIDER_STALE, hash_including(:message)) end @@ -28,7 +28,7 @@ def status(state, error = nil) listener.update(status(LaunchDarkly::Interfaces::DataSource::Status::OFF, error)) - expect(provider).to have_received(:emit_event).with( + expect(provider).to have_received(:emit_status_event).with( OpenFeature::SDK::ProviderEvent::PROVIDER_ERROR, hash_including(error_code: OpenFeature::SDK::Provider::ErrorCode::GENERAL, message: /401/) ) @@ -37,6 +37,6 @@ def status(state, error = nil) it "an initializing data source does not emit an event" do listener.update(status(LaunchDarkly::Interfaces::DataSource::Status::INITIALIZING)) - expect(provider).not_to have_received(:emit_event) + expect(provider).not_to have_received(:emit_status_event) end end diff --git a/spec/provider_spec.rb b/spec/provider_spec.rb index 80a48ca..0a9b6f0 100644 --- a/spec/provider_spec.rb +++ b/spec/provider_spec.rb @@ -103,6 +103,38 @@ def data_source_status(state, error = nil) expect(status_provider).not_to have_received(:add_listener) end + it "a status change before initialization completes is not emitted" do + allow(provider).to receive(:emit_event) + + provider.emit_status_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + + expect(provider).not_to have_received(:emit_event) + end + + it "a status change after initialization failed is emitted" do + status_provider = double(status: data_source_status(LaunchDarkly::Interfaces::DataSource::Status::OFF)) + allow(provider.client).to receive_messages(initialized?: false, data_source_status_provider: status_provider) + expect { provider.init(evaluation_context) }.to raise_error(/unable to initialize/) + allow(provider).to receive(:emit_event) + + provider.emit_status_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + + expect(provider).to have_received(:emit_event).with(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + end + + it "a status which does not change is not emitted" do + provider.init(evaluation_context) + allow(provider).to receive(:emit_event) + + provider.emit_status_event(OpenFeature::SDK::ProviderEvent::PROVIDER_READY) + provider.emit_status_event(OpenFeature::SDK::ProviderEvent::PROVIDER_STALE, message: "interrupted") + provider.emit_status_event(OpenFeature::SDK::ProviderEvent::PROVIDER_STALE, message: "interrupted again") + + expect(provider).to have_received(:emit_event).once + expect(provider).to have_received(:emit_event) + .with(OpenFeature::SDK::ProviderEvent::PROVIDER_STALE, hash_including(:message)) + end + it "shutdown closes the client" do allow(provider.client).to receive(:close) From c6e8ce6e5f8a2f285a6b497250fe965a5ca2cc8b Mon Sep 17 00:00:00 2001 From: Ryan Lamb <4955475+kinyoklion@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:47:20 -0700 Subject: [PATCH 4/4] fix: Unsubscribe the provider's listeners on shutdown (#37) The provider's data source status and flag change listeners stayed registered on the LaunchDarkly client after `shutdown`, so a provider which the OpenFeature SDK had already replaced could still emit events. They are now removed before the client is closed. Event deduplication and the events emitted after a failed initialization, which this PR previously carried, now live in [#36](https://github.com/launchdarkly/openfeature-ruby-server/pull/36) instead; this branch has been merged with that one and keeps only the `shutdown` change. Review [#36](https://github.com/launchdarkly/openfeature-ruby-server/pull/36) first. Found during the weekly OpenFeature provider audit.
Implementation details **Requirements** - [x] I have added test coverage for new or changed functionality - [x] I have followed the repository's [pull request submission guidelines](../blob/main/CONTRIBUTING.md#submitting-pull-requests) - [x] I have validated my changes against all supported platform versions **Related issues** None. **Describe the solution you've provided** The listeners the constructor registers are kept on the provider so that `shutdown` can pass them to `remove_listener` on the data source status provider and the flag tracker before closing the client. **Describe alternatives you've considered** Relying on `close` alone: the LaunchDarkly client stops notifying listeners once it is closed, but the provider is the owner of those subscriptions and a closed client is not a guarantee the OpenFeature SDK makes about a replaced provider. **Additional context** Testing: `bundle exec rspec` (83 examples) and `bundle exec rubocop` on Ruby 3.4.5. The `shutdown` spec now asserts both listeners are removed as well as the client being closed.
Link to Devin session: https://app.devin.ai/sessions/fe1eb757fe694ef79f3d09f6307d4b47 Open in Devin Desktop: https://app.devin.ai/desktop/session/fe1eb757fe694ef79f3d09f6307d4b47?variant=devin Requested by: @kinyoklion --------- Signed-off-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Devin AI Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- lib/ldclient-openfeature/provider.rb | 9 +++++++-- spec/provider_spec.rb | 8 ++++++-- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/lib/ldclient-openfeature/provider.rb b/lib/ldclient-openfeature/provider.rb index 4ec1583..b4ea7fc 100644 --- a/lib/ldclient-openfeature/provider.rb +++ b/lib/ldclient-openfeature/provider.rb @@ -49,8 +49,11 @@ def initialize(sdk_key, config = LaunchDarkly::Config.default, wait_for_seconds @metadata = ::OpenFeature::SDK::Provider::ProviderMetadata.new(name: "launchdarkly-openfeature-server").freeze - @client.data_source_status_provider.add_listener(Impl::DataSourceStatusListener.new(self)) - @client.flag_tracker.add_listener(Impl::FlagChangeListener.new(self)) + @status_listener = Impl::DataSourceStatusListener.new(self) + @flag_change_listener = Impl::FlagChangeListener.new(self) + + @client.data_source_status_provider.add_listener(@status_listener) + @client.flag_tracker.add_listener(@flag_change_listener) end # @@ -116,6 +119,8 @@ def emit_status_event(event, **details) # @return [void] # def shutdown + @client.data_source_status_provider.remove_listener(@status_listener) + @client.flag_tracker.remove_listener(@flag_change_listener) @client.close end diff --git a/spec/provider_spec.rb b/spec/provider_spec.rb index 0a9b6f0..773e0a8 100644 --- a/spec/provider_spec.rb +++ b/spec/provider_spec.rb @@ -135,11 +135,15 @@ def data_source_status(state, error = nil) .with(OpenFeature::SDK::ProviderEvent::PROVIDER_STALE, hash_including(:message)) end - it "shutdown closes the client" do - allow(provider.client).to receive(:close) + it "shutdown unsubscribes the listeners and closes the client" do + status_provider = double(remove_listener: nil) + flag_tracker = double(remove_listener: nil) + allow(provider.client).to receive_messages(close: nil, data_source_status_provider: status_provider, flag_tracker: flag_tracker) provider.shutdown + expect(status_provider).to have_received(:remove_listener).with(instance_of(LaunchDarkly::OpenFeature::Impl::DataSourceStatusListener)) + expect(flag_tracker).to have_received(:remove_listener).with(instance_of(LaunchDarkly::OpenFeature::Impl::FlagChangeListener)) expect(provider.client).to have_received(:close) end