Repository navigation
fix(bindings): keep bluetooth le links working with the app in the background or the screen off - #668
Merged
Merged
Conversation
mizanxali
marked this pull request as ready for review
October 30, 2026 20:24
…d by scanning for the mesh service and never replacing the central manager In the background iOS runs only a scan that names a service and delivers nothing to a filterless one, so the scan watchdog read the silence as a stall and, three restarts in, replaced the CBCentralManager. A second manager under the same restore identifier reports unsupported for good: the bridge reported Bluetooth unavailable, the core dropped every neighbour, and the drain stopped sending on every link until the app was killed, while inbound kept arriving. Measured on an iPhone about two minutes after backgrounding, beside a peer whose writes kept it awake. A backgrounded scan now names the mesh service, a scan follows the app across each transition, the watchdog does nothing in the background, and a restart never escalates to a new manager (BleScanPolicy).
…and stop treating the quiet as a stalled stack Since Android 8.1 an unfiltered scan is paused while the screen is off. The facade scanned unfiltered regardless, so a locked phone discovered nobody, and the watchdog restarted the scan every 30 seconds and reset the stack every 90, restarting the advertisement each time: a new random address that a linked peer then dials and never connects (ADR 0030). With the screen off the scan now filters on the mesh service, a scan follows the screen across each transition, a quiet screen-off scan is not a stall (BleScanPolicy), and the stack reset restarts the advertisement only as AdvertisingRefreshPolicy allows, as the forced refresh does.
…nd the failure that made it a rule C14 names when each mobile bridge scans for the mesh service (iOS in the background, Android with the screen off), the measured failure each half prevents, and what stays out of reach: an Android phone cannot discover a backgrounded iPhone, whose service is in Apple's overflow area.
…er cycle The app-state observers were removed in clearLinkState(), which also runs on every central or peripheral power-off and reset, while the transport carries on and only startUnsafe() adds them. After a Bluetooth toggle the scan kept whichever filter it had: a filterless scan in the background again, or a service-filtered one in the foreground with the watchdog off. They are now removed only on stop, and observeAppState() clears any pair a second start left registered. Device-checked: Bluetooth toggled off and on while backgrounded, then foreground and background again, each switch followed.
…al manager BleScanPolicy is unit-tested on both platforms, but a policy nobody calls passes its own tests. The guard pins: one CBCentralManager, made in startUnsafe; every iOS scan's services from BleScanPolicy.services (the targeted service scan aside); the scan monitor and the Android watchdog asking mayRestartQuietScan before any restart; the Android scan's filters from filtersOnService; the stack reset restarting the advertisement only as AdvertisingRefreshPolicy allows; and the app-state observation ended in stop, never in clearLinkState. Each was mutation-checked to fail.
…e screen-state restart The screen-off watchdog only re-stamped its clock, so a scan that was dead rather than quiet stayed dead for the whole pocket: Android refuses an app's sixth scan start within 30 seconds with no callback, each screen transition was a start, and the proactive refresh runs only on a result. A screen-off scan that delivers nothing for five minutes is now restarted, without counting toward the stack reset, and a screen transition moves the scan two seconds later and only if its filter is wrong, so a glance at the phone costs no start. Device-checked on a Galaxy M17: the lock moved the scan 2 s later, two glances cost no start, and a quiet locked scan was restarted after 300 s with the filter kept and no reset.
…omes on, and catch a screen change before the receiver registered Re-stamping on screen-off let every glance less than five minutes apart keep a silently refused pocket scan dead, since the debounce rightly skips a scan whose filter already matches. And the screen state was read at the top of start, but the receiver registered about 75 lines later; a non-sticky SCREEN_OFF in between left an unfiltered (paused) scan on the escalating watchdog, the original bug. The receiver's handler is shared, and registration re-reads the screen state through it.
…top a forced refresh firing on every foregrounding A manager made under a restore identifier whose released predecessor's session is still alive reports unsupported for good, and bluetoothd keeps that session for about 15 seconds. The bridges release their BleManager on stop and make a new one on the next start, so an ordinary stop then start, an engine rebuilt, or a second start while still starting made that pair. The managers are now made on the first start and reused, replaying their state through the delegate; stop detaches them. Device-checked: messenger off and on 5 s apart reused both managers, and scanning, advertising and the link came back within a second. The monitor skips every refresh in the background, so the forced refresh's clock was stale on return and fired on the next tick. Returning to the foreground now re-anchors it; device-checked after 3 minutes away.
…roid screen handling
…t on stop, now that stop keeps the managers Releasing the managers used to make bluetoothd drop the session, which unpublished the service and dropped every connect. With the managers kept for the process, a stopped iPhone kept serving its identity, took writes it never answered, and kept pending and restored connects that could complete after the stop. Stop now removes the services and cancels every known, pending and restored peripheral, while the managers are still its own. Device-checked: after the messenger was turned off, the Samsung's next dial found no mesh service; turned back on, the link returned in seconds.
…ry scan is filterless
…y, and stop a running predecessor before taking them Ownership was read from the managers' weak delegate, which reads nil in deinit, so a BleManager released while running skipped its stop teardown and left its service published and its connects pending on managers that now outlive it. And stop's scan and advertising commands hit the shared managers whoever owned them, so a predecessor stopped after a new instance started (a React Native reload) ended the new one's scan and advertising while it still believed both were running. Start now stops a running predecessor and records itself as owner by ObjectIdentifier; stop decides ownership once, and a non-owner lets go of the managers before touching anything. The PR's other paths are unchanged on device: messenger off and on relinked at once. The comment on the targeted service scan also says what a background scan misses.
The stack used to pause the screen-off scan at no cost; the filtered one this PR runs instead stayed at low latency, so a phone in a pocket listened continuously to every peer nearby. With the screen off the scan is now balanced (BleScanPolicy.scanMode); links already up do not depend on it, so the price is a slower discovery of a new peer. C14 says so, and no longer claims every advertiser puts the service in its primary packet: a backgrounded iPhone does not. Device-checked: the locked Samsung's scan ran balanced in the stack's own counters and still found the iPhone in 50 ms.
…creen-off scan mode
bahdotsh
force-pushed
the
fix/ble-background-links
branch
from
October 31, 2026 09:57
4fbb240 to
92a6c5c
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
A backgrounded iPhone stopped sending on every Bluetooth LE link about two minutes after the app left the foreground, and never recovered, even back in the foreground. An Android phone with its screen off discovered nobody and churned its advertising address every 90 s. Both came from a scan watchdog reading the quiet of an OS-restricted scan as a broken stack. This PR makes each mobile bridge scan for the mesh service whenever the OS restricts scanning, and stops treating that silence as a stall.
Context
Found during a two-hop device run (Python host → Samsung M17 → iPhone 16 → Chromium over Web Bluetooth). With both phones in "pocket" state, no message crossed in either direction after five minutes, and foregrounding did not recover it. Root-caused with temporary
os_logtracing on the iPhone (not part of this PR):bluetoothdreports "0 advertisements delivered" to our filterless one.CBCentralManager..unsupportedand never left it (it never registered a session withbluetoothd).bleStatusChanged(false)dropped every neighbour in the core in one instant, and the fragment drain'sstate == .runningguard stopped every send. Inbound kept arriving, so each peer saw a link that took its writes and never answered.Related issues
None filed.
Type of change
fix: bug fixtest: adding or correcting testsdocs: documentation onlyChanges
BleScanPolicy.swift/BleScanPolicy.kt(new): the scan names the mesh service in the restricted state (iOS background, Android screen off), and a quiet scan there is never a stall. Every SDK advertiser puts the service in its primary packet.BleManager.swift:nil;evaluateCentralHealthAfterRestart) is removed: a restart never escalates;BleManageron stop, andbluetoothdkeeps a released manager's session for about 15 s, so an ordinary stop then start (or an engine rebuild, or a second start while.starting) made the same.unsupportedpair. Stop does what releasing them used to: it unpublishes the GATT service and cancels every known, pending and restored peripheral (only while this instance still owns the managers), then detaches the delegates. Ownership is anObjectIdentifiertoken, which still compares indeinitwhere the weak delegate reads nil; a non-owner lets go of the managers before its stop touches anything, and start stops a still-running predecessor first (a React Native reload starts the new module before the old one is freed);BleTransportFacade.kt:startScanpasses a serviceScanFilterwith the screen off, at a balanced duty cycle instead of low latency (BleScanPolicy.scanMode): the stack used to pause that scan at no cost, and full duty in a pocket listens continuously to every peer nearby;SCREEN_OFFbefore registration was lost;AdvertisingRefreshPolicyallows, as the forced refresh does.crates/offline-protocol-uniffi/src/lib.rs,ble_scans_follow_the_platform_restriction_and_ios_never_replaces_its_central): pins oneCBCentralManagermade instartUnsafe, every iOS scan's services fromBleScanPolicy.services(the targeted service scan aside), the iOS monitor and Android watchdog consultingmayRestartQuietScanbefore any restart, the Android scan's filters fromfiltersOnService, the reset's advertising restart behindAdvertisingRefreshPolicy, and the app-state observation ended only on stop (not on radio loss). Each assertion was mutation-checked.MeshSdk.podspec,ios/Package.swift(source and test) and the CI typecheck list.docs/bridges/README.md(the rule, the measured failure, and what stays out of reach); twoCHANGELOG.mdentries under Unreleased → Fixed.Device runs (iPhone 16 + Galaxy M17 + Chromium on a Mac)
.unsupportedNot covered
connectshort-circuits whilegatt.connectedis true.Checklist
CHANGELOG.mdupdatedunsafecargo test -p offline-protocol-uniffi --lib: 316 passed (the guards that read the bridge sources)swift test --package-path bindings/react-native/ios: 501 passed:offline-protocol_mesh-sdk:testDebugUnitTest): passed, includingBleScanPolicyTestcargo fmt --all -- --check: clean (the only Rust is the source guard)clippy/cargo test --workspace: not run; the guard is a test inoffline-protocol-uniffi, whose lib tests passSwift Package/Android LibraryCI jobs: left to CI