Fixed race condition in PollingDeviceDiscovery. (#145506)

There are two issues in the previous implementation:
1. `_populateDevices` will return the devices from `deviceNotifier` if it had been initialized, assuming that once it's initialized, it has been properly populated. That assumption is not true because calling getters like `onAdded` would initialize `deviceNotifier` without populating it.
2. `deviceNotifier` instance might be replaced in some cases, causing `onAdded` subscribers to lose any future updates.

To fix (1), this commit added the `isPopulated` field in `deviceNotifier` as a more accurate flag to determine if we need to populate it.

To fix (2), this commit made `deviceNotifier` a final member in `PolingDeviceDiscovery`.
This commit is contained in:
Lau Ching Jun 2024-03-21 15:37:08 -07:00 committed by GitHub
parent d69833ceb2
commit c759c22e71
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 122 additions and 45 deletions

View file

@ -97,9 +97,9 @@ String getSizeAsMB(int bytesLength) {
/// removed, and calculate a diff of changes when a new list of items is /// removed, and calculate a diff of changes when a new list of items is
/// available. /// available.
class ItemListNotifier<T> { class ItemListNotifier<T> {
ItemListNotifier(): _items = <T>{}; ItemListNotifier(): _items = <T>{}, _isPopulated = false;
ItemListNotifier.from(List<T> items) : _items = Set<T>.of(items); ItemListNotifier.from(List<T> items) : _items = Set<T>.of(items), _isPopulated = true;
Set<T> _items; Set<T> _items;
@ -111,6 +111,11 @@ class ItemListNotifier<T> {
List<T> get items => _items.toList(); List<T> get items => _items.toList();
bool _isPopulated;
/// Returns whether the list has been populated.
bool get isPopulated => _isPopulated;
void updateWithNewList(List<T> updatedList) { void updateWithNewList(List<T> updatedList) {
final Set<T> updatedSet = Set<T>.of(updatedList); final Set<T> updatedSet = Set<T>.of(updatedList);
@ -118,9 +123,10 @@ class ItemListNotifier<T> {
final Set<T> removedItems = _items.difference(updatedSet); final Set<T> removedItems = _items.difference(updatedSet);
_items = updatedSet; _items = updatedSet;
_isPopulated = true;
addedItems.forEach(_addedController.add);
removedItems.forEach(_removedController.add); removedItems.forEach(_removedController.add);
addedItems.forEach(_addedController.add);
} }
void removeItem(T item) { void removeItem(T item) {

View file

@ -480,18 +480,15 @@ abstract class PollingDeviceDiscovery extends DeviceDiscovery {
@protected @protected
@visibleForTesting @visibleForTesting
ItemListNotifier<Device>? deviceNotifier; final ItemListNotifier<Device> deviceNotifier = ItemListNotifier<Device>();
Timer? _timer; Timer? _timer;
Future<List<Device>> pollingGetDevices({Duration? timeout}); Future<List<Device>> pollingGetDevices({Duration? timeout});
void startPolling() { void startPolling() {
if (_timer == null) { // Make initial population the default, fast polling timeout.
deviceNotifier ??= ItemListNotifier<Device>(); _timer ??= _initTimer(null, initialCall: true);
// Make initial population the default, fast polling timeout.
_timer = _initTimer(null, initialCall: true);
}
} }
Timer _initTimer(Duration? pollingTimeout, {bool initialCall = false}) { Timer _initTimer(Duration? pollingTimeout, {bool initialCall = false}) {
@ -499,7 +496,7 @@ abstract class PollingDeviceDiscovery extends DeviceDiscovery {
return Timer(initialCall ? Duration.zero : _pollingInterval, () async { return Timer(initialCall ? Duration.zero : _pollingInterval, () async {
try { try {
final List<Device> devices = await pollingGetDevices(timeout: pollingTimeout); final List<Device> devices = await pollingGetDevices(timeout: pollingTimeout);
deviceNotifier!.updateWithNewList(devices); deviceNotifier.updateWithNewList(devices);
} on TimeoutException { } on TimeoutException {
// Do nothing on a timeout. // Do nothing on a timeout.
} }
@ -546,32 +543,28 @@ abstract class PollingDeviceDiscovery extends DeviceDiscovery {
DeviceDiscoveryFilter? filter, DeviceDiscoveryFilter? filter,
bool resetCache = false, bool resetCache = false,
}) async { }) async {
if (deviceNotifier == null || resetCache) { if (!deviceNotifier.isPopulated || resetCache) {
final List<Device> devices = await pollingGetDevices(timeout: timeout); final List<Device> devices = await pollingGetDevices(timeout: timeout);
// If the cache was populated while the polling was ongoing, do not // If the cache was populated while the polling was ongoing, do not
// overwrite the cache unless it's explicitly refreshing the cache. // overwrite the cache unless it's explicitly refreshing the cache.
if (resetCache) { if (!deviceNotifier.isPopulated || resetCache) {
deviceNotifier = ItemListNotifier<Device>.from(devices); deviceNotifier.updateWithNewList(devices);
} else {
deviceNotifier ??= ItemListNotifier<Device>.from(devices);
} }
} }
// If a filter is provided, filter cache to only return devices matching. // If a filter is provided, filter cache to only return devices matching.
if (filter != null) { if (filter != null) {
return filter.filterDevices(deviceNotifier!.items); return filter.filterDevices(deviceNotifier.items);
} }
return deviceNotifier!.items; return deviceNotifier.items;
} }
Stream<Device> get onAdded { Stream<Device> get onAdded {
deviceNotifier ??= ItemListNotifier<Device>(); return deviceNotifier.onAdded;
return deviceNotifier!.onAdded;
} }
Stream<Device> get onRemoved { Stream<Device> get onRemoved {
deviceNotifier ??= ItemListNotifier<Device>(); return deviceNotifier.onRemoved;
return deviceNotifier!.onRemoved;
} }
void dispose() => stopPolling(); void dispose() => stopPolling();

View file

@ -102,8 +102,6 @@ class IOSDevices extends PollingDeviceDiscovery {
return; return;
} }
deviceNotifier ??= ItemListNotifier<Device>();
// Start by populating all currently attached devices. // Start by populating all currently attached devices.
_updateCachedDevices(await pollingGetDevices()); _updateCachedDevices(await pollingGetDevices());
_updateNotifierFromCache(); _updateNotifierFromCache();
@ -127,10 +125,8 @@ class IOSDevices extends PollingDeviceDiscovery {
@visibleForTesting @visibleForTesting
Future<void> onDeviceEvent(XCDeviceEventNotification event) async { Future<void> onDeviceEvent(XCDeviceEventNotification event) async {
final ItemListNotifier<Device>? notifier = deviceNotifier; final ItemListNotifier<Device> notifier = deviceNotifier;
if (notifier == null) {
return;
}
Device? knownDevice; Device? knownDevice;
for (final Device device in notifier.items) { for (final Device device in notifier.items) {
if (device.id == event.deviceIdentifier) { if (device.id == event.deviceIdentifier) {
@ -186,10 +182,8 @@ class IOSDevices extends PollingDeviceDiscovery {
/// Updates notifier with devices found in the cache that are determined /// Updates notifier with devices found in the cache that are determined
/// to be connected. /// to be connected.
void _updateNotifierFromCache() { void _updateNotifierFromCache() {
final ItemListNotifier<Device>? notifier = deviceNotifier; final ItemListNotifier<Device> notifier = deviceNotifier;
if (notifier == null) {
return;
}
// Device is connected if it has either an observed usb or wifi connection // Device is connected if it has either an observed usb or wifi connection
// or it has not been observed but was found as connected in the cache. // or it has not been observed but was found as connected in the cache.
final List<Device> connectedDevices = _cachedPolledDevices.values.where((Device device) { final List<Device> connectedDevices = _cachedPolledDevices.values.where((Device device) {

View file

@ -36,5 +36,27 @@ void main() {
expect(removedItems.first, 'aaa'); expect(removedItems.first, 'aaa');
expect(removedItems[1], 'bbb'); expect(removedItems[1], 'bbb');
}); });
test('becomes populated when item is added', () async {
final ItemListNotifier<String> list = ItemListNotifier<String>();
expect(list.isPopulated, false);
expect(list.items, isEmpty);
// Becomes populated when a new list is added.
list.updateWithNewList(<String>['a']);
expect(list.isPopulated, true);
expect(list.items, <String>['a']);
// Remain populated even when the last item is removed.
list.removeItem('a');
expect(list.isPopulated, true);
expect(list.items, isEmpty);
});
test('is populated by default if initialized with list of items', () async {
final ItemListNotifier<String> list = ItemListNotifier<String>.from(<String>['a']);
expect(list.isPopulated, true);
expect(list.items, <String>['a']);
});
}); });
} }

View file

@ -1089,6 +1089,47 @@ void main() {
); );
}); });
}); });
group('PollingDeviceDiscovery', () {
final FakeDevice device1 = FakeDevice('Nexus 5', '0553790d0a4e726f');
testWithoutContext('initial call to devices returns the correct list', () async {
final List<Device> deviceList = <Device>[device1];
final TestPollingDeviceDiscovery testDeviceDiscovery = TestPollingDeviceDiscovery(deviceList);
// Call `onAdded` to make sure that calling `onAdded` does not affect the
// result of `devices()`.
final List<Device> addedDevice = <Device>[];
final List<Device> removedDevice = <Device>[];
testDeviceDiscovery.onAdded.listen(addedDevice.add);
testDeviceDiscovery.onRemoved.listen(removedDevice.add);
final List<Device> devices = await testDeviceDiscovery.devices();
expect(devices.length, 1);
expect(devices.first.id, device1.id);
});
testWithoutContext('call to devices triggers onAdded', () async {
final List<Device> deviceList = <Device>[device1];
final TestPollingDeviceDiscovery testDeviceDiscovery = TestPollingDeviceDiscovery(deviceList);
// Call `onAdded` to make sure that calling `onAdded` does not affect the
// result of `devices()`.
final List<Device> addedDevice = <Device>[];
final List<Device> removedDevice = <Device>[];
testDeviceDiscovery.onAdded.listen(addedDevice.add);
testDeviceDiscovery.onRemoved.listen(removedDevice.add);
final List<Device> devices = await testDeviceDiscovery.devices();
expect(devices.length, 1);
expect(devices.first.id, device1.id);
await pumpEventQueue();
expect(addedDevice.length, 1);
expect(addedDevice.first.id, device1.id);
});
});
} }
class TestDeviceManager extends DeviceManager { class TestDeviceManager extends DeviceManager {
@ -1203,3 +1244,24 @@ class ThrowingPollingDeviceDiscovery extends PollingDeviceDiscovery {
@override @override
List<String> get wellKnownIds => <String>[]; List<String> get wellKnownIds => <String>[];
} }
class TestPollingDeviceDiscovery extends PollingDeviceDiscovery {
TestPollingDeviceDiscovery(this._devices) : super('test');
final List<Device> _devices;
@override
Future<List<Device>> pollingGetDevices({ Duration? timeout }) async {
return _devices;
}
@override
bool get supportsPlatform => true;
@override
bool get canListAnything => true;
@override
List<String> get wellKnownIds => <String>[];
}

View file

@ -659,7 +659,7 @@ void main() {
await iosDevices.startPolling(); await iosDevices.startPolling();
expect(xcdevice.getAvailableIOSDevicesCount, 1); expect(xcdevice.getAvailableIOSDevicesCount, 1);
expect(iosDevices.deviceNotifier!.items, isEmpty); expect(iosDevices.deviceNotifier.items, isEmpty);
expect(xcdevice.deviceEventController.hasListener, isTrue); expect(xcdevice.deviceEventController.hasListener, isTrue);
xcdevice.deviceEventController.add( xcdevice.deviceEventController.add(
@ -670,9 +670,9 @@ void main() {
), ),
); );
await added.future; await added.future;
expect(iosDevices.deviceNotifier!.items.length, 2); expect(iosDevices.deviceNotifier.items.length, 2);
expect(iosDevices.deviceNotifier!.items, contains(device1)); expect(iosDevices.deviceNotifier.items, contains(device1));
expect(iosDevices.deviceNotifier!.items, contains(device2)); expect(iosDevices.deviceNotifier.items, contains(device2));
expect(iosDevices.eventsReceived, 1); expect(iosDevices.eventsReceived, 1);
iosDevices.resetEventCompleter(); iosDevices.resetEventCompleter();
@ -684,9 +684,9 @@ void main() {
), ),
); );
await iosDevices.receivedEvent.future; await iosDevices.receivedEvent.future;
expect(iosDevices.deviceNotifier!.items.length, 2); expect(iosDevices.deviceNotifier.items.length, 2);
expect(iosDevices.deviceNotifier!.items, contains(device1)); expect(iosDevices.deviceNotifier.items, contains(device1));
expect(iosDevices.deviceNotifier!.items, contains(device2)); expect(iosDevices.deviceNotifier.items, contains(device2));
expect(iosDevices.eventsReceived, 2); expect(iosDevices.eventsReceived, 2);
iosDevices.resetEventCompleter(); iosDevices.resetEventCompleter();
@ -698,9 +698,9 @@ void main() {
), ),
); );
await iosDevices.receivedEvent.future; await iosDevices.receivedEvent.future;
expect(iosDevices.deviceNotifier!.items.length, 2); expect(iosDevices.deviceNotifier.items.length, 2);
expect(iosDevices.deviceNotifier!.items, contains(device1)); expect(iosDevices.deviceNotifier.items, contains(device1));
expect(iosDevices.deviceNotifier!.items, contains(device2)); expect(iosDevices.deviceNotifier.items, contains(device2));
expect(iosDevices.eventsReceived, 3); expect(iosDevices.eventsReceived, 3);
xcdevice.deviceEventController.add( xcdevice.deviceEventController.add(
@ -711,7 +711,7 @@ void main() {
), ),
); );
await removed.future; await removed.future;
expect(iosDevices.deviceNotifier!.items, <Device>[device2]); expect(iosDevices.deviceNotifier.items, <Device>[device2]);
expect(iosDevices.eventsReceived, 4); expect(iosDevices.eventsReceived, 4);
iosDevices.resetEventCompleter(); iosDevices.resetEventCompleter();
@ -777,7 +777,7 @@ void main() {
xcdevice.devices.add(<IOSDevice>[]); xcdevice.devices.add(<IOSDevice>[]);
await iosDevices.startPolling(); await iosDevices.startPolling();
expect(iosDevices.deviceNotifier!.items, isEmpty); expect(iosDevices.deviceNotifier.items, isEmpty);
expect(xcdevice.deviceEventController.hasListener, isTrue); expect(xcdevice.deviceEventController.hasListener, isTrue);
iosDevices.dispose(); iosDevices.dispose();

View file

@ -539,11 +539,11 @@ void main() {
proxiedDevices.startPolling(); proxiedDevices.startPolling();
final ItemListNotifier<Device>? deviceNotifier = proxiedDevices.deviceNotifier; final ItemListNotifier<Device> deviceNotifier = proxiedDevices.deviceNotifier;
expect(deviceNotifier, isNotNull); expect(deviceNotifier, isNotNull);
final List<Device> devicesAdded = <Device>[]; final List<Device> devicesAdded = <Device>[];
deviceNotifier!.onAdded.listen((Device device) { deviceNotifier.onAdded.listen((Device device) {
devicesAdded.add(device); devicesAdded.add(device);
}); });