8000 fix(ble): close the FlutterBluePlus scan stream before cleanup can block it by shroominic · Pull Request #144 · shroominic/OpenGlucose · GitHub
[go: up one dir, main page]

Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ class FlutterBluePlusTransport
StreamSubscription<bool>? scanningSubscription;
Future<void>? startupFuture;
Future<void>? closeFuture;
Timer? scanDeadline;
var startedScan = false;
var closed = false;

Expand All @@ -92,10 +93,21 @@ class FlutterBluePlusTransport
return existing;
}
closed = true;
scanDeadline?.cancel();
scanDeadline = null;
final completer = Completer<void>();
closeFuture = completer.future;
final results = resultsSubscription;
final scanning = scanningSubscription;
// Close the caller's stream before cleanup. Cleanup awaits plugin
// futures that can stay pending on some Android stacks, and a scan the
// platform already stopped must not hold its caller open behind them.
unawaited(
controller.close().then<void>(
(_) {},
onError: (Object _, StackTrace _) {},
),
);
unawaited(
closeFlutterBluePlusScanResources(
cancelResults: results?.cancel,
Expand All @@ -116,6 +128,11 @@ class FlutterBluePlusTransport
}
: null,
closeController: controller.close,
).timeout(
// The radio is already stopped by this point; a plugin future that
// never comp 8000 letes must not leave cancellation pending forever.
const Duration(seconds: 5),
onTimeout: () {},
).then<void>(
(_) => completer.complete(),
onError: (Object error, StackTrace stackTrace) =>
Expand All @@ -134,6 +151,35 @@ class FlutterBluePlusTransport
);
}

/// Closes this scan when the requested window elapses.
///
/// `flutter_blue_plus` stops the radio when its own `timeout` fires, but
/// that stop is not reliably visible here: the plugin can finish the scan
/// without publishing `isScanning = false`, and its pending `startScan`
/// future can stay pending past the window. Callers pass `timeout`
/// expecting a bounded scan, so the stream must bound itself instead of
/// waiting for a signal that may never arrive.
void armScanDeadline(Duration window) {
scanDeadline?.cancel();
scanDeadline = Timer(
window + const Duration(milliseconds: 750),
() {
if (closed) {
return;
}
// Close the caller's stream first: cleanup below awaits plugin
// futures that may themselves be the pending signal.
unawaited(
controller.close().then<void>(
(_) {},
onError: (Object _, StackTrace _) {},
),
);
closeStreamSafely(waitForStartup: false);
},
);
}

String signatureOf(BleScanResult result) {
final manufacturer = result.manufacturerData
.map((entry) => '${entry.companyId}:${entry.bytes.join(",")}')
Expand Down Expand Up @@ -170,6 +216,10 @@ class FlutterBluePlusTransport

startupFuture = () async {
try {
final window = timeout;
if (window != null) {
armScanDeadline(window);
}
await _ensureAdapterReady();
if (closed) {
return;
Expand Down Expand Up @@ -414,6 +464,7 @@ Future<void> closeFlutterBluePlusScanResources({
Future<void> Function()? awaitPendingStart,
required Future<void> Function()? stopScan,
required Future<void> Function() closeController,
Duration stepTimeout = const Duration(seconds: 5),
}) async {
Object? firstError;
StackTrace? firstStackTrace;
Expand All @@ -423,18 +474,22 @@ Future<void> closeFlutterBluePlusScanResources({
return;
}
try {
await action();
// A radio that already stopped can leave these plugin futures pending
// forever. Bound each one so cleanup always finishes.
await action().timeout(stepTimeout, onTimeout: () {});
} catch (error, stackTrace) {
firstError ??= error;
firstStackTrace ??= stackTrace;
}
}

// Close the caller's stream first. This is the step every caller depends on;
// it must not sit behind plugin futures that may never complete.
await run(closeController);
await run(cancelResults);
await run(cancelScanning);
await run(awaitPendingStart);
await run(stopScan);
await run(closeController);
if (firstError != null) {
Error.throwWithStackTrace(firstError!, firstStackTrace!);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,10 @@ void main() {
throwsA(same(firstFailure)),
);

expect(steps, <String>['results', 'scanning', 'stop', 'close']);
// The caller's stream closes first: plugin cancellations can stay
// pending, and a stream the platform already stopped must not wait on
// them.
expect(steps, <String>['close', 'results', 'scanning', 'stop']);
},
);

Expand Down Expand Up @@ -71,20 +74,20 @@ void main() {
});

await Future<void>.delayed(Duration.zero);
expect(steps, <String>['results', 'scanning', 'await-start']);
expect(steps, <String>['close', 'results', 'scanning', 'await-start']);
expect(cleanupFinished.isCompleted, isFalse);
expect(steps, isNot(contains('replacement-start')));

startFinished.complete();
await replacement;

expect(steps, <String>[
'close',
'results',
'scanning',
'await-start',
'start-finished',
'stop',
'close',
'replacement-start',
]);
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import 'dart:async';

import 'package:cgm_ble_flutter/src/flutter_blue_plus_transport.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
test(
'scan cleanup closes the caller stream when plugin futures never finish',
() async {
// A stopped radio can leave plugin futures pending forever. The caller's
// stream must still end, and cancellation must still return, or the
// nearby-sensor list hangs on "Looking for supported sensors".
final controller = StreamController<int>();
final never = Completer<void>().future;
var closed = false;
final subscription = controller.stream.listen(
(_) {},
onDone: () => closed = true,
);

await closeFlutterBluePlusScanResources(
cancelResults: () => never,
cancelScanning: () => never,
awaitPendingStart: () => never,
stopScan: () => never,
closeController: controller.close,
stepTimeout: const Duration(milliseconds: 50),
).timeout(
const Duration(seconds: 1),
onTimeout: () => fail('cleanup never returned'),
);
await subscription.cancel();

expect(closed, isTrue);
},
);
}
Loading
0