Repository navigation
Honour discoverServices' timeout in Apple's native discovery - #351
Conversation
|
@alvynfash there is one merge conflict now. Please fix it and I will review. |
25fd6e2 to
131e4b1
Compare
|
@alvynfash I merged your other PR and we have some new conflicts. |
…k#349) UniversalBleAsyncServiceDiscovery failed any discovery after a fixed 10 seconds, so the timeout passed to discoverServices could shorten discovery but never lengthen it. AppleDiscoverServicesOptions.timeoutMillis, in DiscoverServicesPlatformConfig.apple, now sets the native limit, and discoverServices fills it in from its timeout when the caller did not. Without either it stays 10 seconds, so a discovery that never completes still cannot hold back later calls (Navideck#285). Pigeon outputs regenerated with v26.3.4, the version the committed outputs were made with, so the diff holds only the new class.
131e4b1 to
08025ed
Compare
fotiDim
left a comment
There was a problem hiding this comment.
Reviewed the full diff against its merge-base plus the surrounding discovery/queue code. The core mechanism is sound: timeout now reaches UniversalBleAsyncServiceDiscovery.timeoutSeconds(for:), the > 0 guard falls back to 10 s, the override rules are correct, and the generated code is consistent across Dart/Kotlin/Swift/C++ (all type IDs agree). Two inline comments below, one medium and one low.
| ); | ||
| ``` | ||
|
|
||
| On iOS and macOS a discovery that has not completed after 10 seconds fails, so that it cannot hold back later calls for the same device. A `timeout` passed to `discoverServices` extends that too, for a peripheral that is slow to answer. `AppleDiscoverServicesOptions(timeoutMillis: ...)` sets the native limit on its own. |
There was a problem hiding this comment.
The native limit is set, but the call still fails at 10 s when only this option is used. _runDiscovery passes timeout: null to BleCommandQueue.queueCommand, which substitutes its default (const Duration(seconds: 10) in lib/src/utils/ble_command_queue.dart); Queue._QueuedFuture.execute then wraps the platform call in .timeout(10s). So a caller who does discoverServices(id, platformConfig: DiscoverServicesPlatformConfig(apple: AppleDiscoverServicesOptions(timeoutMillis: 30000))) without a timeout: argument gets a native discovery running for 30 s but a Dart future that throws TimeoutException at 10 s.
In other words, this option does not extend the wait "on its own" — only the timeout: argument does. Either derive the Dart-side timeout from apple.timeoutMillis when timeout is null, or reword this line (and the CHANGELOG entry and the option's doc comment) to say timeout must also be passed.
The tests don't cover this path: the Dart test only asserts the config value, and the Swift test only exercises timeoutSeconds(for:), so neither observes the effective 10 s cap.
There was a problem hiding this comment.
Good catch. discoverServices now takes apple.timeoutMillis as the call's timeout when no timeout is given (c284f7b), so the option works on its own as the README says. A new test lowers the queue default to 50 ms and checks that a 150 ms discovery with timeoutMillis: 1000 completes; it fails without the change. A non-positive value still leaves the default.
| String? queueId, | ||
| DiscoverServicesPlatformConfig? platformConfig, | ||
| }) { | ||
| platformConfig = _withAppleDiscoveryTimeout(platformConfig, timeout); |
There was a problem hiding this comment.
This runs on every platform, so passing a timeout now makes platformConfig non-null even where Apple options don't apply (Android/Windows/Linux/Web receive a config with android == null). That is harmless natively, but it changes the pending-discovery dedup just below: previously discoverServices(id) and discoverServices(id, timeout: 30s) both had platformConfig == null and shared a single native discovery; now their configs differ, so each enqueues its own. On Android that means two overlapping gatt.discoverServices() calls, which is exactly the serialization the command queue is meant to provide.
If sharing across differing timeouts is not desired, consider excluding apple.timeoutMillis from the dedup comparison, or only populating the option on Apple platforms.
There was a problem hiding this comment.
Agreed, a timeout shouldn't split a shared discovery. The pending check now compares Android's options only and leaves Apple's out, since they hold only the timeout (c284f7b). That restores the earlier behaviour on every platform: discoverServices(id) and discoverServices(id, timeout: 30s) share one discovery again, and the call that joins keeps the first call's timeout, as before. New tests cover the shared case, and that other Android options still run their own.
|
|
||
| class AppleDiscoverServicesOptions { | ||
| /// How long, in milliseconds, the native discovery may take before it fails. | ||
| /// iOS and macOS bound discovery themselves, so that a discovery that never |
There was a problem hiding this comment.
For parity: this says only iOS/macOS bound discovery themselves, but Linux has the same class of bug. UniversalBleLinux.discoverServices (lib/src/universal_ble_linux/universal_ble_linux.dart) waits on servicesResolved with a hardcoded const Duration(seconds: 10) and swallows it via onTimeout (returning []), then throws Failed to resolve services because nothing resolved. The caller's timeout never reaches it, since the platform interface's discoverServices doesn't take a timeout.
So discoverServices(timeout: Duration(seconds: 30)) on Linux still fails at ~10 s — the same failure mode as #349 on Apple. If the intended contract is "discoverServices honours its timeout", Linux needs the same treatment (plumb the timeout down, or carry it in the platform config like Apple's).
Android, Windows and Web do not have a native cap: they rely entirely on the Dart command queue, which already applies the caller's timeout (BleCommandQueue.queueCommand), so they don't need a platform option. Only Linux, which does its own internal waiting, matches Apple's situation.
There was a problem hiding this comment.
You're right, thanks. Rather than add a Linux option, I removed Linux's own 10 s limit (053f146). It now waits for servicesResolved and is bounded by the command queue with the caller's timeout, like Android, Windows and Web, so there is no new API, and this doc comment now says the other platforms are bounded by timeout alone. One visible change: a Linux discovery that runs out of time fails with a TimeoutException instead of Failed to resolve services, noted in the CHANGELOG. I have no Linux machine, so this part is checked by flutter analyze only, not on hardware.
UniversalBleLinux.discoverServices waited for servicesResolved with a fixed 10 second limit of its own, so a longer timeout passed to discoverServices could not help there either. It now waits without one and is bounded by the command queue, which applies the caller's timeout, as on Android, Windows and Web.
…across timeouts AppleDiscoverServicesOptions.timeoutMillis given without a timeout set the native limit, but the command queue still timed the call out at its default of 10 seconds. discoverServices now takes it as the call's timeout too. Filling the option in from timeout made calls that differ only in their timeout compare unequal, so each ran its own discovery where they used to share one. The pending-discovery check now leaves Apple's options out, as they hold only the timeout.
|
@fotiDim made changes concerning inline comments. |
fotiDim
left a comment
There was a problem hiding this comment.
Reviewed the full diff plus the surrounding discovery/queue code. The core mechanism is correct: the per-call timeout reaches UniversalBleAsyncServiceDiscovery.timeoutSeconds(for:), the > 0 guard falls back to 10 s, the caller's apple.timeoutMillis wins, the dedup only ignores Apple options, and the regenerated Dart/Kotlin/Swift/C++ agree on the shifted type IDs. A few gaps around the wider timeout contract and the Linux change below; none blocking.
| String? queueId, | ||
| DiscoverServicesPlatformConfig? platformConfig, | ||
| }) { | ||
| timeout ??= _appleDiscoveryTimeout(platformConfig); |
There was a problem hiding this comment.
The Apple native limit is derived only from the per-call timeout, so the global UniversalBle.timeout (documented at lib/src/utils/ble_command_queue.dart:9, setter at universal_ble.dart:36) still does not reach the native side.
If an app raises the global instead of passing a per-call timeout:
UniversalBle.timeout = const Duration(seconds: 30);
await UniversalBle.discoverServices(deviceId); // no per-call timeouttimeout stays null, no apple option is added, and the native discovery keeps its 10 s default, so the call still fails at 10 s even though the Dart queue would have waited 30 s. That is the #349 failure mode driven through the global knob. It only bites when the global is raised above 10 s (the default matches the native default), so low/medium. Consider falling back to _bleCommandQueue.timeout when timeout is null, or documenting that only the per-call value affects Apple.
There was a problem hiding this comment.
Fixed in db3c65a: without a per-call timeout, discoverServices now passes the global UniversalBle.timeout to Apple, so raising the global extends the native discovery too. With the global switched off (null) and no per-call timeout, nothing is passed and the native side keeps its 10 s, so #285 stays covered. The catch is that every discovery now carries an apple option, defaulting to 10000 ms, so two existing tests changed: forwards platformConfig now checks the Android options it was given rather than the config's identity, and without platformConfig passes null now expects only the global timeout. A new test covers the null case.
| DiscoverServicesPlatformConfig? platformConfig, | ||
| Duration? timeout, | ||
| ) { | ||
| if (timeout == null || platformConfig?.apple?.timeoutMillis != null) { |
There was a problem hiding this comment.
_withAppleDiscoveryTimeout treats any non-null apple.timeoutMillis as "the caller set it", but _appleDiscoveryTimeout (line 377) and UniversalBleAsyncServiceDiscovery.timeoutSeconds(for:) both treat a non-positive value as unset.
With timeout: 30s and apple.timeoutMillis: 0, this returns the config unchanged, so the native side falls back to 10 s while the Dart queue waits 30 s — the call fails at 10 s despite the explicit timeout. The two guards should agree, e.g. gate on (platformConfig?.apple?.timeoutMillis ?? 0) <= 0 here. Low; needs a deliberately non-positive value alongside a positive timeout.
There was a problem hiding this comment.
Agreed, fixed in db3c65a: _withAppleDiscoveryTimeout now uses the same check as _appleDiscoveryTimeout, so a non-positive apple.timeoutMillis gives way to the timeout. A test covers timeout: 30s with timeoutMillis: 0.
| peripheral: peripheral, | ||
| deviceId: normalizedDeviceId, | ||
| withDescriptors: withDescriptors, | ||
| timeoutSeconds: UniversalBleAsyncServiceDiscovery.timeoutSeconds(for: platformConfig), |
There was a problem hiding this comment.
This timeout only applies to a discovery that starts here. If one is already in flight, the early return above appends to discoverServicesFutures and ignores platformConfig, so the joining call inherits the in-flight discovery's timeout.
Dart's dedup keys on Android options only, so two concurrent calls with different Android options (e.g. one passing clearGattCache) both reach native, and the second's apple.timeoutMillis is silently dropped in favour of the first's. Narrow, but the new option is not guaranteed to be honoured in that case.
There was a problem hiding this comment.
True. I've documented it rather than changed it: it is the rule the Dart dedup already follows, where a call that differs only in its timeout shares the discovery that started first, and with it that discovery's timeout. On Apple the Android options don't change the result, so a call that differs only in those is the same discovery there, and joining it is the right outcome. Extending the in-flight timer to the longest waiter is possible, but it would add state to the native side for a narrow case, so I'd rather not unless you want it. The discoverServices doc comment now says that on Apple the native side joins such a call (db3c65a).
| final device = _findDeviceById(deviceId); | ||
| if (device.gattServices.isEmpty && !device.servicesResolved) { | ||
| // No timeout of its own: the command queue bounds the call with the | ||
| // caller's `timeout`, as on Android, Windows and Web. |
There was a problem hiding this comment.
Two consequences of dropping the internal limit, for the record:
- Callers that caught
UniversalBleException(failed, "Failed to resolve services")on timeout now get aTimeoutExceptioninstead (already noted in the CHANGELOG). - The queue's
.timeout()completes the caller's future but does not cancel the platform call, so thispropertiesChanged.firstWheresubscription now stays alive after a queue timeout untilservicesResolvedor a disconnect, whereas it used to self-terminate at 10 s.
Pre-existing pattern on the other platforms, but new for Linux.
There was a problem hiding this comment.
Both right, thanks. The first is in the CHANGELOG. On the second: the subscription ends at servicesResolved or a disconnect, as you say, which is also how a timed-out call is left on the other platforms. Related, for the record: with the global timeout switched off (UniversalBle.timeout = null), a Linux discovery that never resolves on a device that stays connected now waits without limit and holds that queue, as on Android, where it used to give up at 10 s.
…e Apple timeout as unset discoverServices derived the native limit from its own timeout only, so an app that raised UniversalBle.timeout instead still had discovery fail at 10 seconds on Apple. Without a per-call timeout, the global one is now passed on. With a positive timeout and apple.timeoutMillis of zero, the config was left as given, although both the Dart side and the native side read zero as unset; the native discovery then fell back to 10 seconds. A non-positive value now gives way to the timeout.
Fixes #349.
What
On iOS, macOS and Linux,
discoverServicesnow honours itstimeout. Until now discovery there always gave up after 10 seconds, so a longertimeoutcould not help a peripheral that is slow to answer.Why it happened
UniversalBleAsyncServiceDiscoveryhas a fixedtimeoutSeconds = 10.0(added in #286 so that a discovery that never completes cannot block later calls, #285). The Darttimeoutonly reached the command queue and never the native side. Linux has the same limit in Dart:UniversalBleLinux.discoverServiceswaits forservicesResolvedfor a fixed 10 s.The change
AppleDiscoverServicesOptions(timeoutMillis:), inDiscoverServicesPlatformConfig.apple, sets the native limit. It followsConnectionPlatformConfig.appleand the guidance in CONTRIBUTING to put a single-platform option in the platform bag. Android and Windows ignore it. CustomUniversalBlePlatformimplementations are unaffected, because no method signature changes.timeoutreaches Apple.UniversalBle.discoverServicesfills the option in from itstimeout, or without one from the globalUniversalBle.timeout, when the caller has not set a positive value, and keeps anyandroidoptions the caller gave. Anapple.timeoutMillisthe caller sets wins.timeout,apple.timeoutMillisis also the call's timeout, so the command queue's 10 s default does not cut it short.timeout, as on Android, Windows and Web, so Linux needs no option. A Linux discovery that runs out of time now fails with aTimeoutExceptionrather thanFailed to resolve services.Tests
test/discover_services_platform_config_test.dart):example/ios/RunnerTests):All of them pass:
flutter analyzeandflutter test(295 tests).IPHONEOS_DEPLOYMENT_TARGET=15.0on the command line, as noted in Map ATT errors to UniversalBleErrorCode on Apple #350../gradlew :universal_ble:testDebugUnitTestagainst the regenerated Kotlin.The Swift and Kotlin tests ran before the review changes, which touch the native code only in generated doc comments. The Linux change was not run on Linux (no Linux machine);
flutter analyzecovers it.The Windows C++ changes are generated code only and were not built locally (no Windows machine); CI's Windows job builds them.
Seen on hardware
A Leica DISTO D5's first discovery after connecting can take longer than 10 s on iOS, while iOS is also putting up a pairing prompt for it. A caller that passed
timeout: Duration(seconds: 20)still saw it fail at 10 s.