ReviewOS

also looking at this

craft-native/craft

iOS: the Bluetooth scan pair — ratchet 35 → 33

#97
Merged glennmichael123 wants to merge feat/ios-bluetooth into main
10 files +679 -3
10 days ago

startBluetoothScan and stopBluetoothScan. The first action in this migration whose reply does not come from its own handler.

startBluetoothScan creates a CBCentralManager and returns having done nothing observable. Core Bluetooth powers the radio up asynchronously and calls centralManagerDidUpdateState: later — and that is where Swift both starts the scan and answers the page. So the ticket has to survive the gap between the call and the callback, and that is precisely what the fixture asserts.

queue: nil is the main queue, so every callback lands on the thread the dispatch already ran on. The module has no mutex and says why: adding one would imply a second thread that does not exist.

Four decisions worth stating

A usage-description guard runs before Core Bluetooth is touched at all. iOS 13+ terminates a process that creates a CBCentralManager without NSBluetoothAlwaysUsageDescription — a kill, not a catchable exception. The plist is read first, the same shape bridge_mobile_location and bridge_mobile_imagepicker already use. A host test exercises the real refusal, since the test binary has no such key.

Five unusable radio states get three codes, not Swift's one string. "Bluetooth not available" covers unsupported (no radio, ever), unauthorized (a settings trip) and poweredOff (a toggle). A page that retries on all three is wrong twice.

stopBluetoothScan answers a promise Swift drops. If it runs while a start is still awaiting its first state callback, Swift's pendingCallbackId is overwritten and that start never settles. Here the pending ticket is answered CANCELLED, which is what actually happened.

That is not theoretical — the fixture caught it on the first run. I posted start and stop in the same burst, the stop answered the start's ticket before the radio ever reported, and the assertion failed with no error logged. The page had to be re-sequenced to send the stop only once the start is answered. The bug was in my test, but the path it exercised is the one Swift gets wrong.

Deduplication is bounded at 256 where Swift's array is unbounded, and the bounded failure — a device announced twice past the limit — is written down rather than left to be discovered. It is a belt in any case: the scan starts with allowDuplicates: false, so Core Bluetooth already suppresses repeats.

ios_delegate gains "v@:@@@@"

RSSI: is an NSNumber, not a primitive, so all four arguments of centralManager:didDiscoverPeripheral:advertisementData:RSSI: are objects. A narrower encoding registers fine, dispatches fine, and reads the signal strength from the wrong register — a plausible number, silently wrong.

Verification

zig build test green (153/153), zig fmt --check, build-ios and build-ios-simulator clean, pickier . 0 errors.

33 fixture assertions pass on a simulator, two new:

ok: scan refused by the radio's own state, via a deferred ticket (i=56 seen 1x)
ok: stopping with nothing running still answers true (i=58 seen 1x)

CoreBluetooth is linked and the usage description is present in the fixture's Info.plist, so the refusal is the radio's own state rather than a missing class or a missing key. The console confirms it:

warning: startBluetoothScan refused: CBManagerState is 2 (unsupported)

That distinction is the whole point of linking the framework in the fixture: without it the same assertion would have passed on PLATFORM_NOT_SUPPORTED from objc_getClass returning null, proving nothing about the state machine.

10 changed files on the files tab, with 0 review threads.

Sign in to comment on this pull request.