Skip to content

Honour discoverServices' timeout in Apple's native discovery - #351

Merged
fotiDim merged 6 commits into
Navideck:mainfrom
alvynfash:apple-discovery-timeout
Oct 18, 2026
Merged

fotiDim merged 6 commits into
Navideck:mainfrom
alvynfash:apple-discovery-timeout

Conversation

@alvynfash

@alvynfash alvynfash commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #349.

What

On iOS, macOS and Linux, discoverServices now honours its timeout. Until now discovery there always gave up after 10 seconds, so a longer timeout could not help a peripheral that is slow to answer.

Why it happened

UniversalBleAsyncServiceDiscovery has a fixed timeoutSeconds = 10.0 (added in #286 so that a discovery that never completes cannot block later calls, #285). The Dart timeout only reached the command queue and never the native side. Linux has the same limit in Dart: UniversalBleLinux.discoverServices waits for servicesResolved for a fixed 10 s.

The change

  • A new option. AppleDiscoverServicesOptions(timeoutMillis:), in DiscoverServicesPlatformConfig.apple, sets the native limit. It follows ConnectionPlatformConfig.apple and the guidance in CONTRIBUTING to put a single-platform option in the platform bag. Android and Windows ignore it. Custom UniversalBlePlatform implementations are unaffected, because no method signature changes.
  • timeout reaches Apple. UniversalBle.discoverServices fills the option in from its timeout, or without one from the global UniversalBle.timeout, when the caller has not set a positive value, and keeps any android options the caller gave. An apple.timeoutMillis the caller sets wins.
  • The option works on its own. Given without a timeout, apple.timeoutMillis is also the call's timeout, so the command queue's 10 s default does not cut it short.
  • Shared discoveries. Calls that differ only in their timeout still share one discovery, as before this change: the pending-discovery check compares Android's options and leaves Apple's out, as they hold only the timeout.
  • Linux. The discovery no longer has a limit of its own. The command queue bounds it with the caller's timeout, as on Android, Windows and Web, so Linux needs no option. A Linux discovery that runs out of time now fails with a TimeoutException rather than Failed to resolve services.
  • The default is unchanged. Without a timeout, discovery still fails after 10 s, so iOS/macOS: a discovery that never completes permanently poisons discoverServices for that device until disconnect #285 stays fixed. A non-positive value also falls back to 10 s.
  • Generated code. The Pigeon outputs are regenerated with v29.0.10, the version Migrate platform channels to Pigeon 29 #352 moved them to, so the generated diff holds only the new class.

Tests

  • Dart (test/discover_services_platform_config_test.dart):
    • the timeout is passed to Apple;
    • Android options are kept alongside it;
    • the global timeout is passed when there is no per-call one, and nothing when it is switched off;
    • an Apple timeout set by the caller wins, and a non-positive one gives way to the timeout;
    • an Apple timeout given alone bounds the call, and a non-positive one leaves the default;
    • calls that differ only in their timeout share one discovery, and other Android options still run their own.
  • Swift (example/ios/RunnerTests):
    • discovery waits as long as the caller asks;
    • it keeps its 10 s default without a timeout, or with zero.

All of them pass:

  • flutter analyze and flutter test (295 tests).
  • The Swift tests on an iPhone 17 simulator (iOS 27). With Xcode 27 the example needs IPHONEOS_DEPLOYMENT_TARGET=15.0 on the command line, as noted in Map ATT errors to UniversalBleErrorCode on Apple #350.
  • ./gradlew :universal_ble:testDebugUnitTest against 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 analyze covers 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.

@fotiDim

fotiDim commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

@alvynfash there is one merge conflict now. Please fix it and I will review.

@alvynfash
alvynfash force-pushed the apple-discovery-timeout branch from 25fd6e2 to 131e4b1 Compare October 16, 2026 12:17
@alvynfash

alvynfash commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor Author

@fotiDim Rebased onto #352 and resolved the conflict. The Pigeon outputs are regenerated with 29.0.10, so the generated diff is the new class only. flutter analyze, flutter test (288), the RunnerTests on an iOS simulator and the Android unit tests all pass.

@fotiDim

fotiDim commented Oct 16, 2026

Copy link
Copy Markdown
Contributor

@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.
@alvynfash
alvynfash force-pushed the apple-discovery-timeout branch from 131e4b1 to 08025ed Compare October 16, 2026 13:13
@alvynfash

Copy link
Copy Markdown
Contributor Author

Rebased onto main now that #350 is in. The branch is the #349 commit alone, and its tree is unchanged from the version tested above.

@fotiDim fotiDim left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment thread README.md Outdated
);
```

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread lib/src/universal_ble.dart Outdated
String? queueId,
DiscoverServicesPlatformConfig? platformConfig,
}) {
platformConfig = _withAppleDiscoveryTimeout(platformConfig, timeout);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread pigeon/universal_ble.dart

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Alvyn Fasuyi added 3 commits October 16, 2026 15:48
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.
@alvynfash

Copy link
Copy Markdown
Contributor Author

@fotiDim made changes concerning inline comments.

@fotiDim fotiDim left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 timeout

timeout 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Comment thread lib/src/universal_ble.dart Outdated
DiscoverServicesPlatformConfig? platformConfig,
Duration? timeout,
) {
if (timeout == null || platformConfig?.apple?.timeoutMillis != null) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

_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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Two consequences of dropping the internal limit, for the record:

  1. Callers that caught UniversalBleException(failed, "Failed to resolve services") on timeout now get a TimeoutException instead (already noted in the CHANGELOG).
  2. The queue's .timeout() completes the caller's future but does not cancel the platform call, so this propertiesChanged.firstWhere subscription now stays alive after a queue timeout until servicesResolved or a disconnect, whereas it used to self-terminate at 10 s.

Pre-existing pattern on the other platforms, but new for Linux.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

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.

Alvyn Fasuyi and others added 2 commits October 18, 2026 14:54
…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.
@fotiDim
fotiDim merged commit 7259771 into Navideck:main Oct 18, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Service discovery gives up after 10 s on Apple, whatever timeout is passed

2 participants