From 9648ae8e8908a902a85540088a35d7728ef307ac Mon Sep 17 00:00:00 2001 From: jsonbailey Date: Thu, 15 Oct 2026 22:23:49 -0500 Subject: [PATCH] fix: Complete a pending identify or start with an error when the client closes --- .../lib/src/ld_common_client.dart | 27 ++++-- .../test/ld_dart_client_test.dart | 85 +++++++++++++++++++ .../flutter_client_sdk/lib/src/ld_client.dart | 8 +- 3 files changed, 114 insertions(+), 6 deletions(-) diff --git a/packages/common_client/lib/src/ld_common_client.dart b/packages/common_client/lib/src/ld_common_client.dart index bd8c496c..0f7fb8ff 100644 --- a/packages/common_client/lib/src/ld_common_client.dart +++ b/packages/common_client/lib/src/ld_common_client.dart @@ -70,7 +70,8 @@ final class IdentifyComplete implements IdentifyResult { /// and this one has been cancelled. final class IdentifySuperseded implements IdentifyResult {} -/// The identify operation encountered an error and will not complete. +/// The identify operation encountered an error and will not complete. This is +/// also the result when the client closes before the identify completes. final class IdentifyError implements IdentifyResult { /// The error which prevented the identify from completing. final Object error; @@ -240,6 +241,7 @@ final class LDCommonClient { bool _networkAvailable = true; Completer? _startCompleter; + final Completer _closed = Completer(); Future? _startFuture; Stream get dataSourceStatusChanges { @@ -358,13 +360,13 @@ final class LDCommonClient { // The setup of modifiers and other items must be done in the identify // queue to ensure that identifies cannot be done without those items // having been set resulting in a crash. - _identifyQueue.execute(() async { + _untilClosed(_identifyQueue.execute(() async { await _startInternal(); await _identifyInternal(_initialUndecoratedContext, (cb) { afterIdentify = cb; }, waitForNetworkResults: waitForNetworkResults); - }).then((res) { + })).then((res) { final identifyResult = _mapIdentifyResult(res); afterIdentify?.call(identifyResult); _startCompleter!.complete(identifyResult); @@ -537,16 +539,25 @@ final class LDCommonClient { return IdentifyError(Exception(message)); } Function(IdentifyResult)? afterRunner; - final res = await _identifyQueue.execute(() async { + final res = await _untilClosed(_identifyQueue.execute(() async { await _identifyInternal(context, (cb) { afterRunner = cb; }, waitForNetworkResults: waitForNetworkResults); - }); + })); final identifyResult = _mapIdentifyResult(res); afterRunner?.call(identifyResult); return identifyResult; } + /// Completes with a [TaskError] if the client closes before [task] + /// completes. + Future> _untilClosed(Future> task) => + Future.any([ + task, + _closed.future.then((_) => TaskError( + Exception('The client was closed before the identify completed.'))) + ]); + IdentifyResult _mapIdentifyResult(TaskResult res) { switch (res) { case TaskComplete(): @@ -957,7 +968,13 @@ final class LDCommonClient { /// Permanently shuts down the client. /// /// It's not normally necessary to explicitly shut down the client. + /// + /// A pending [start] completes with false, and a pending [identify] + /// completes with [IdentifyError]. Future close() async { + if (!_closed.isCompleted) { + _closed.complete(); + } await _eventProcessor?.flush(); _eventProcessor?.stop(); _dataSourceManager.stop(); diff --git a/packages/common_client/test/ld_dart_client_test.dart b/packages/common_client/test/ld_dart_client_test.dart index 6782735f..a0969daa 100644 --- a/packages/common_client/test/ld_dart_client_test.dart +++ b/packages/common_client/test/ld_dart_client_test.dart @@ -51,6 +51,25 @@ final class TestDataSource implements DataSource { } } +/// A data source that never sends events. +final class SilentDataSource implements DataSource { + final StreamController _eventController = StreamController(); + + @override + Stream get events => _eventController.stream; + + @override + void restart() {} + + @override + void start() {} + + @override + void stop() { + _eventController.close(); + } +} + void main() { group('given an offline client', () { late LDCommonClient client; @@ -294,6 +313,72 @@ void main() { }); }); + group('given a data source that sends data only for bob', () { + late LDCommonClient client; + + setUp(() { + client = LDCommonClient( + TestConfig('the-sdk-key', AutoEnvAttributes.disabled), + CommonPlatform(persistence: MockPersistence()), + LDContextBuilder().kind('user', 'bob').build(), + DiagnosticSdkData(name: '', version: ''), dataSourceFactories: + (LDCommonConfig config, LDLogger logger, + HttpProperties properties) { + DataSource factory(LDContext context) => context.canonicalKey == 'bob' + ? TestDataSource() + : SilentDataSource(); + return { + ConnectionMode.streaming: factory, + ConnectionMode.polling: factory, + }; + }); + }); + + test('close completes a pending identify with an error', () async { + await client.start(); + final identify = + client.identify(LDContextBuilder().kind('user', 'joe').build()); + + await client.close(); + + final result = await identify.timeout(const Duration(seconds: 1)); + expect(result, isA()); + expect((result as IdentifyError).error.toString(), + contains('The client was closed before the identify completed.')); + }); + + test('close completes a pending start with false', () async { + final otherClient = LDCommonClient( + TestConfig('the-sdk-key', AutoEnvAttributes.disabled), + CommonPlatform(persistence: MockPersistence()), + LDContextBuilder().kind('user', 'joe').build(), + DiagnosticSdkData(name: '', version: ''), dataSourceFactories: + (LDCommonConfig config, LDLogger logger, + HttpProperties properties) { + return { + ConnectionMode.streaming: (_) => SilentDataSource(), + ConnectionMode.polling: (_) => SilentDataSource(), + }; + }); + final start = otherClient.start(); + + await otherClient.close(); + + expect(await start.timeout(const Duration(seconds: 1)), isFalse); + }); + + test('close does not change a completed identify or start', () async { + expect(await client.start(), isTrue); + expect( + await client.identify(LDContextBuilder().kind('user', 'bob').build()), + isA()); + + await client.close(); + + expect(await client.start(), isTrue); + }); + }); + group('given an invalid context', () { test('start returns false with invalid context', () async { final invalidClient = LDCommonClient( diff --git a/packages/flutter_client_sdk/lib/src/ld_client.dart b/packages/flutter_client_sdk/lib/src/ld_client.dart index 058a70fa..5fa777fc 100644 --- a/packages/flutter_client_sdk/lib/src/ld_client.dart +++ b/packages/flutter_client_sdk/lib/src/ld_client.dart @@ -159,6 +159,8 @@ interface class LDClient { /// /// If [waitForNetworkResults] is true, and an error is encountered, then /// false may be returned even if cached values were loaded. + /// + /// If the client closes before start completes, then false is returned. Future start({bool waitForNetworkResults = false}) async { return _client.start(waitForNetworkResults: waitForNetworkResults); } @@ -210,7 +212,8 @@ interface class LDClient { /// last one. The intermediates would be cancelled for performance. /// /// [IdentifyError] this means that the identify has permanently failed. For - /// instance the SDK key is no longer valid. + /// instance the SDK key is no longer valid, or the client closed before the + /// identify completed. Future identify(LDContext context, {bool waitForNetworkResults = false}) async { return _client.identify(context, @@ -387,6 +390,9 @@ interface class LDClient { /// Permanently shuts down the client. /// /// It's not normally necessary to explicitly shut down the client. + /// + /// A pending [start] completes with false, and a pending [identify] + /// completes with [IdentifyError]. Future close() async { await _client.close(); _connectionManager.dispose();