Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 22 additions & 5 deletions packages/common_client/lib/src/ld_common_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -240,6 +241,7 @@ final class LDCommonClient {
bool _networkAvailable = true;

Completer<IdentifyResult>? _startCompleter;
final Completer<void> _closed = Completer();
Future<IdentifyResult>? _startFuture;

Stream<DataSourceStatus> get dataSourceStatusChanges {
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<TaskResult<void>> _untilClosed(Future<TaskResult<void>> task) =>
Future.any([
task,
_closed.future.then((_) => TaskError<void>(
Exception('The client was closed before the identify completed.')))
]);

IdentifyResult _mapIdentifyResult(TaskResult<void> res) {
switch (res) {
case TaskComplete<void>():
Expand Down Expand Up @@ -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<void> close() async {

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cross-SDK note, for team discussion: iOS and Android both leave a pending identify waiting after close. On iOS, internalClose goes offline without calling the completion. On Android, ConnectivityManager.shutDown stops the data source with a no-op callback. Flutter completes it with IdentifyError("The client was closed before the identify completed."), so that code awaiting identify without a timeout does not wait forever.

if (!_closed.isCompleted) {
_closed.complete();
}
await _eventProcessor?.flush();
_eventProcessor?.stop();
_dataSourceManager.stop();
Expand Down
85 changes: 85 additions & 0 deletions packages/common_client/test/ld_dart_client_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,25 @@ final class TestDataSource implements DataSource {
}
}

/// A data source that never sends events.
final class SilentDataSource implements DataSource {
final StreamController<DataSourceEvent> _eventController = StreamController();

@override
Stream<DataSourceEvent> 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;
Expand Down Expand Up @@ -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<IdentifyError>());
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<IdentifyComplete>());

await client.close();

expect(await client.start(), isTrue);
});
});

group('given an invalid context', () {
test('start returns false with invalid context', () async {
final invalidClient = LDCommonClient(
Expand Down
8 changes: 7 additions & 1 deletion packages/flutter_client_sdk/lib/src/ld_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<bool> start({bool waitForNetworkResults = false}) async {
return _client.start(waitForNetworkResults: waitForNetworkResults);
}
Expand Down Expand Up @@ -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<IdentifyResult> identify(LDContext context,
{bool waitForNetworkResults = false}) async {
return _client.identify(context,
Expand Down Expand Up @@ -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<void> close() async {
await _client.close();
_connectionManager.dispose();
Expand Down
Loading