Steps to reproduce:
- Connect + pair to a peripheral, from an iOS 26 device
- Update firmware on the peripheral so that it now has a new characteristic in an existing service
- Reconnect to peripheral - this will trigger the
onServiceChanged flow because of the new characteristic
- It seems that on iOS 26,
discoverDescriptorsForCharacteristic will throw an error if it has already been called for the characteristic (behaviour change in this iOS version?). This crashes the app.
It seems like this essentially means the service table can't change on the peripheral without causing an iOS crash (we got some crash reports like this after testing a firmware update internally which added a characteristic, so we rolled that firmware change back)
Potential fix:
I don't have an iOS 26 device to test with right now so haven't been able to try this - Claude suggests that in Connection.kt/discoverServices, when serviceUuids is provided (service-change path), we should only process
those specific services, not all services.
diff --git a/kable-core/src/appleMain/kotlin/Connection.kt b/kable-core/src/appleMain/kotlin/Connection.kt
--- a/kable-core/src/appleMain/kotlin/Connection.kt
+++ b/kable-core/src/appleMain/kotlin/Connection.kt
@@ -96,7 +96,17 @@
// https://developer.apple.com/documentation/corebluetooth/cbperipheral/services
@Suppress("UNCHECKED_CAST")
- val discoveredServices = peripheral.services as List<CBService>?
+ val allServices = peripheral.services as List<CBService>?
+
+ // When serviceUuids is provided (service-change path), only process the re-discovered
+ // services. Re-discovering characteristics/descriptors for non-invalidated services is
+ // unnecessary and causes a crash on iOS 26 when calling discoverDescriptorsForCharacteristic
+ // on characteristics that already have their descriptors discovered.
+ val servicesToProcess = if (serviceUuids == null) {
+ allServices
+ } else {
+ allServices?.filter { service -> serviceUuids.any { it.isEqual(service.UUID) } }
+ }
- discoveredServices?.forEach { service ->
+ servicesToProcess?.forEach { service ->
execute<DidDiscoverCharacteristicsForService> {
peripheral.discoverCharacteristics(null, service)
Crash report:
Exception Type: EXC_CRASH (SIGABRT)
Exception Codes: 0x0000000000000000, 0x0000000000000000
Exception Reason: -[CBService handle]: unrecognized selector sent to instance 0x113643140
Termination Reason: SIGNAL 6 Abort trap: 6
Terminating Process: Pebble [27589]
Triggered by Thread: 1
Last Exception Backtrace:
0 CoreFoundation 0x195664964 __exceptionPreprocess + 164 (NSException.m:249)
1 libobjc.A.dylib 0x192579814 objc_exception_throw + 88 (objc-exception.mm:356)
2 CoreFoundation 0x1956ff2c0 -[NSObject(NSObject) doesNotRecognizeSelector:] + 384 (NSObject.m:168)
3 CoreFoundation 0x1955e435c ___forwarding___ + 1472 (NSForwarding.m:3616)
4 CoreFoundation 0x1955ec200 _CF_forwarding_prep_0 + 96
5 CoreBluetooth 0x1d3667e68 -[CBPeripheral discoverDescriptorsForCharacteristic:] + 212 (CBPeripheral.m:588)
6 ComposeApp 0x10482c65c kfun:com.juul.kable.Connection.Connection$discoverServices$$inlined$forEach$2.invoke#internal + 284 (_Collections.kt:151)
7 ComposeApp 0x10482c65c kfun:com.juul.kable.Connection.Connection$discoverServices$$inlined$forEach$2.$<bridge-DNN>invoke(platform.CoreBluetooth.CBPeripheral){}#internal + 328
8 ComposeApp 0x10482c808 kfun:kotlin.Function1#invoke(1:0){}1:1-trampoline + 76 (Mutex.kt:114)
9 ComposeApp 0x10482c808 kfun:com.juul.kable.Connection.Connection$execute$$inlined$withLock$1.invoke#internal + 184
10 ComposeApp 0x10462680c kfun:kotlin.Function2#invoke(1:0;1:1){}1:2-trampoline + 40 (IntrinsicsNative.kt:254)
11 ComposeApp 0x10462680c kfun:kotlin.coroutines.intrinsics#startCoroutineUninterceptedOrReturn__at__kotlin.coroutines.SuspendFunction1<0:0,0:1>(0:0;kotlin.coroutines.Continuation<0:1>){0§<kotlin.Any?>;1§<kotlin.Any?>}kotli... + 204
12 ComposeApp 0x10462680c <inlined-lambda> + 208
13 ComposeApp 0x10462680c kfun:kotlin.coroutines.intrinsics.createCoroutineUnintercepted$$inlined$createCoroutineFromSuspendFunction$2.invokeSuspend#internal + 380
14 ComposeApp 0x104624adc kfun:kotlin.coroutines.native.internal.BaseContinuationImpl#invokeSuspend(kotlin.Result<kotlin.Any?>){}kotlin.Any?-trampoline + 28 (ContinuationImpl.kt:26)
15 ComposeApp 0x104624adc <inlined-lambda> + 44
16 ComposeApp 0x104624adc kfun:kotlin#with(0:0;kotlin.Function1<0:0,0:1>){0§<kotlin.Any?>;1§<kotlin.Any?>}0:1 + 44
17 ComposeApp 0x104624adc kfun:kotlin.coroutines.native.internal.BaseContinuationImpl#resumeWith(kotlin.Result<kotlin.Any?>){} + 176
18 ComposeApp 0x104779780 kfun:kotlin.coroutines.Continuation#resumeWith(kotlin.Result<1:0>){}-trampoline + 72 (DispatchedTask.kt:82)
19 ComposeApp 0x104779780 kfun:kotlin.coroutines#resume__at__kotlin.coroutines.Continuation<0:0>(0:0){0§<kotlin.Any?>} + 72
20 ComposeApp 0x104779780 <inlined-lambda> + 708
21 ComposeApp 0x104779780 kfun:kotlinx.coroutines#withContinuationContext(kotlin.coroutines.Continuation<*>;kotlin.Any?;kotlin.Function0<0:0>){0§<kotlin.Any?>}0:0 + 708
22 ComposeApp 0x104779780 kfun:kotlinx.coroutines.DispatchedTask#run(){} + 908
23 ComposeApp 0x104834370 kfun:kotlinx.coroutines.Runnable#run(){}-trampoline + 68 (QueueDispatcher.kt:17)
24 ComposeApp 0x104834370 kfun:com.juul.kable.QueueDispatcher.QueueDispatcher$dispatch$1.invoke#internal + 72
25 ComposeApp 0x104834370 kfun:com.juul.kable.QueueDispatcher.QueueDispatcher$dispatch$1.$<bridge-DN>invoke(){}#internal + 100
26 ComposeApp 0x1065503d8 ___636f6d2e6a75756c2e6b61626c653a6b61626c652d636f72652f55736572732f72756e6e65722f776f726b2f6b61626c652f6b61626c652f6b61626c652d636f72652f7372632f6170706c654d61696e2f6b6f746c696e2f517565756544697370... + 296
27 libdispatch.dylib 0x1ce0c7adc _dispatch_call_block_and_release + 32 (init.c:1575)
28 libdispatch.dylib 0x1ce0e17fc _dispatch_client_callout + 16 (client_callout.mm:85)
29 libdispatch.dylib 0x1ce0d0468 _dispatch_lane_serial_drain + 740 (queue.c:3991)
30 libdispatch.dylib 0x1ce0d0f44 _dispatch_lane_invoke + 388 (queue.c:4082)
31 libdispatch.dylib 0x1ce0db3ec _dispatch_root_queue_drain_deferred_wlh + 292 (queue.c:7265)
32 libdispatch.dylib 0x1ce0dace4 _dispatch_workloop_worker_thread + 692 (queue.c:6859)
33 libsystem_pthread.dylib 0x1f1e883b8 _pthread_wqthread + 292 (pthread.c:2696)
34 libsystem_pthread.dylib 0x1f1e878c0 start_wqthread + 8
Thread 0 name:
Thread 0:
0 libsystem_kernel.dylib 0x000000024035ecd4 mach_msg2_trap + 8
1 libsystem_kernel.dylib 0x00000002403622f8 mach_msg2_internal + 76 (mach_msg.c:201)
2 libsystem_kernel.dylib 0x0000000240362214 mach_msg_overwrite + 428 (mach_msg.c:0)
3 libsystem_kernel.dylib 0x000000024036205c mach_msg + 24 (mach_msg.c:323)
4 CoreFoundation 0x00000001955e5868 __CFRunLoopServiceMachPort + 160 (CFRunLoop.c:2650)
5 CoreFoundation 0x00000001955bc848 __CFRunLoopRun + 1188 (CFRunLoop.c:3035)
6 CoreFoundation 0x00000001955bba6c _CFRunLoopRunSpecificWithOptions + 532 (CFRunLoop.c:3462)
7 GraphicsServices 0x00000002371f0498 GSEventRunModal + 120 (GSEvent.c:2049)
8 UIKitCore 0x000000019af96df8 -[UIApplication _run] + 792 (UIApplication.m:3904)
9 UIKitCore 0x000000019af3fe54 UIApplicationMain + 336 (UIApplication.m:5579)
10 SwiftUI 0x000000019e252d20 closure #1 in KitRendererCommon(_:) + 168 (UIKitApp.swift:65)
11 SwiftUI 0x000000019e24f8d4 runApp<A>(_:) + 112 (UIKitApp.swift:16)
12 SwiftUI 0x000000019e24f3c0 static App.main() + 172 (App.swift:137)
13 Pebble 0x0000000104202054 static iOSApp.$main() + 28 (iOSApp.swift:0)
14 Pebble 0x0000000104202054 main + 40 (:-1)
15 dyld 0x00000001925cee28 start + 7116 (dyldMain.cpp:1477)
Thread 1 name:
Thread 1 Crashed:
0 libsystem_kernel.dylib 0x00000002403690cc __pthread_kill + 8
1 libsystem_pthread.dylib 0x00000001f1e8e810 pthread_kill + 268 (pthread.c:1721)
2 libsystem_c.dylib 0x00000001a0e5fff4 __abort + 132 (abort.c:163)
3 libsystem_c.dylib 0x00000001a0e5ff70 abort + 136 (abort.c:130)
4 libc++abi.dylib 0x000000019267b808 __abort_message + 132 (abort_message.cpp:66)
5 libc++abi.dylib 0x000000019266a484 demangling_terminate_handler() + 304 (cxa_default_handlers.cpp:76)
6 libobjc.A.dylib 0x000000019257bf78 _objc_terminate() + 156 (objc-exception.mm:496)
7 FirebaseCrashlytics 0x0000000109de23e8 FIRCLSTerminateHandler() + 332 (FIRCLSException.mm:466)
8 ComposeApp 0x00000001065a9bb4 (anonymous namespace)::TerminateHandler::queuedHandler()::'lambda'()::operator()() const + 24
9 ComposeApp 0x00000001065a9ae4 void (anonymous namespace)::$_0::operator()<(anonymous namespace)::TerminateHandler::queuedHandler()::'lambda'()>((anonymous namespace)::TerminateHandler::queuedHandler()::'lambda'()) + 48
10 ComposeApp 0x00000001065a9ab4 (anonymous namespace)::TerminateHandler::queuedHandler() + 12
11 ComposeApp 0x00000001065a9800 (anonymous namespace)::TerminateHandler::kotlinHandler() + 432
12 libc++abi.dylib 0x000000019267abdc std::__terminate(void (*)()) + 16 (cxa_handlers.cpp:59)
13 libc++abi.dylib 0x000000019267ab80 std::terminate() + 108 (cxa_handlers.cpp:88)
14 ComposeApp 0x000000010482c808 kfun:kotlin.Function1#invoke(1:0){}1:1-trampoline + 76 (Mutex.kt:114)
15 ComposeApp 0x000000010482c808 kfun:com.juul.kable.Connection.Connection$execute$$inlined$withLock$1.invoke#internal + 184
16 ComposeApp 0x000000010462680c kfun:kotlin.Function2#invoke(1:0;1:1){}1:2-trampoline + 40 (IntrinsicsNative.kt:254)
17 ComposeApp 0x000000010462680c kfun:kotlin.coroutines.intrinsics#startCoroutineUninterceptedOrReturn__at__kotlin.coroutines.SuspendFunction1<0:0,0:1>(0:0;kotlin.coroutines.Continuation<0:1>){0§<kotlin.Any?>;1§<kotlin.Any?>}kotli... + 204
18 ComposeApp 0x000000010462680c <inlined-lambda> + 208
19 ComposeApp 0x000000010462680c kfun:kotlin.coroutines.intrinsics.createCoroutineUnintercepted$$inlined$createCoroutineFromSuspendFunction$2.invokeSuspend#internal + 380
20 ComposeApp 0x0000000104624adc kfun:kotlin.coroutines.native.internal.BaseContinuationImpl#invokeSuspend(kotlin.Result<kotlin.Any?>){}kotlin.Any?-trampoline + 28 (ContinuationImpl.kt:26)
21 ComposeApp 0x0000000104624adc <inlined-lambda> + 44
22 ComposeApp 0x0000000104624adc kfun:kotlin#with(0:0;kotlin.Function1<0:0,0:1>){0§<kotlin.Any?>;1§<kotlin.Any?>}0:1 + 44
23 ComposeApp 0x0000000104624adc kfun:kotlin.coroutines.native.internal.BaseContinuationImpl#resumeWith(kotlin.Result<kotlin.Any?>){} + 176
24 ComposeApp 0x0000000104779780 kfun:kotlin.coroutines.Continuation#resumeWith(kotlin.Result<1:0>){}-trampoline + 72 (DispatchedTask.kt:82)
25 ComposeApp 0x0000000104779780 kfun:kotlin.coroutines#resume__at__kotlin.coroutines.Continuation<0:0>(0:0){0§<kotlin.Any?>} + 72
26 ComposeApp 0x0000000104779780 <inlined-lambda> + 708
27 ComposeApp 0x0000000104779780 kfun:kotlinx.coroutines#withContinuationContext(kotlin.coroutines.Continuation<*>;kotlin.Any?;kotlin.Function0<0:0>){0§<kotlin.Any?>}0:0 + 708
28 ComposeApp 0x0000000104779780 kfun:kotlinx.coroutines.DispatchedTask#run(){} + 908
29 ComposeApp 0x0000000104834370 kfun:kotlinx.coroutines.Runnable#run(){}-trampoline + 68 (QueueDispatcher.kt:17)
30 ComposeApp 0x0000000104834370 kfun:com.juul.kable.QueueDispatcher.QueueDispatcher$dispatch$1.invoke#internal + 72
31 ComposeApp 0x0000000104834370 kfun:com.juul.kable.QueueDispatcher.QueueDispatcher$dispatch$1.$<bridge-DN>invoke(){}#internal + 100
32 ComposeApp 0x00000001065503d8 ___636f6d2e6a75756c2e6b61626c653a6b61626c652d636f72652f55736572732f72756e6e65722f776f726b2f6b61626c652f6b61626c652f6b61626c652d636f72652f7372632f6170706c654d61696e2f6b6f746c696e2f517565756544697370... + 296
33 libdispatch.dylib 0x00000001ce0c7adc _dispatch_call_block_and_release + 32 (init.c:1575)
34 libdispatch.dylib 0x00000001ce0e17fc _dispatch_client_callout + 16 (client_callout.mm:85)
35 libdispatch.dylib 0x00000001ce0d0468 _dispatch_lane_serial_drain + 740 (queue.c:3991)
36 libdispatch.dylib 0x00000001ce0d0f44 _dispatch_lane_invoke + 388 (queue.c:4082)
37 libdispatch.dylib 0x00000001ce0db3ec _dispatch_root_queue_drain_deferred_wlh + 292 (queue.c:7265)
38 libdispatch.dylib 0x00000001ce0dace4 _dispatch_workloop_worker_thread + 692 (queue.c:6859)
39 libsystem_pthread.dylib 0x00000001f1e883b8 _pthread_wqthread + 292 (pthread.c:2696)
40 libsystem_pthread.dylib 0x00000001f1e878c0 start_wqthread + 8
Steps to reproduce:
onServiceChangedflow because of the new characteristicdiscoverDescriptorsForCharacteristicwill throw an error if it has already been called for the characteristic (behaviour change in this iOS version?). This crashes the app.It seems like this essentially means the service table can't change on the peripheral without causing an iOS crash (we got some crash reports like this after testing a firmware update internally which added a characteristic, so we rolled that firmware change back)
Potential fix:
I don't have an iOS 26 device to test with right now so haven't been able to try this - Claude suggests that in
Connection.kt/discoverServices, whenserviceUuidsis provided (service-change path), we should only processthose specific services, not all services.
Crash report: