kable
kable copied to clipboard
ClassCastException when instantiating Peripheral
Hardware Model: iPhone17,1
Role: Foreground
OS Version: iOS 18.3.2
kotlin.ClassCastException: class com.juul.kable.PeripheralDelegate.Response.DidDiscoverServices cannot be cast to class com.juul.kable.PeripheralDelegate.Response.DidDiscoverCharacteristicsForService
0 BaseBetaiOS +0x4c4ec ThrowClassCastException (RuntimeUtils.kt:39:11)
1 BaseBetaiOS +0x11dd598 <inlined-out:execute> (Connection.kt:125:12)
2 BaseBetaiOS +0x2e5fc kfun:kotlin.coroutines.native.internal.BaseContinuationImpl#invokeSuspend(kotlin.Result<kotlin.Any?>){}kotlin.Any?-trampoline (ContinuationImpl.kt:50:5)
3 BaseBetaiOS +0x18ea90 kfun:kotlin.coroutines.Continuation#resumeWith(kotlin.Result<1:0>){}-trampoline (Continuation.kt:26:5)
4 BaseBetaiOS +0x1b13ac kfun:kotlinx.coroutines.Runnable#run(){}-trampoline (Runnable.kt:12:5)
5 BaseBetaiOS +0x24e4040 ___6f72672e6a6574627261696e732e6b6f746c696e783a6b6f746c696e782d636f726f7574696e65732d636f72652f6f70742f6275696c644167656e742f776f726b2f343465633665383530643563363366302f6b6f746c696e782d636f726f7574696e65732d636f72652f6e617469766544617277696e2f7372632f44697370617463686572732e6b74_knbridge2_block_invoke
6 libdispatch.dylib +0x2244 __dispatch_call_block_and_release
7 libdispatch.dylib +0x3fa4 __dispatch_client_callout
8 libdispatch.dylib +0x70f0 __dispatch_queue_override_invoke
9 libdispatch.dylib +0x15ebc __dispatch_root_queue_drain
10 libdispatch.dylib +0x166c0 __dispatch_worker_thread2
11 libsystem_pthread.dylib +0x3640 __pthread_wqthread
12 libsystem_pthread.dylib +0x1470 _start_wqthread
Notes from Deepseek about the crash
If the connection is interrupted/reset during service discovery:
1. CoreBluetooth might automatically rediscover services
2. Delegate methods can be called out of sequence
3. Pending responses might be flushed to the queue in unexpected order
Reproduction Scenario
1. Sequence starts normally:
discoverServices() → DidDiscoverServices
discoverCharacteristics() → Expect DidDiscoverCharacteristicsForService
2. During transient connection failure:
CoreBluetooth automatically retries service discovery
didDiscoverServices delegate method is called again
This response gets queued before characteristic discovery completes
3. Code flow:
execute<DidDiscoverCharacteristicsForService> expects characteristic response
Actually receives service discovery response from retry
as? T cast fails
Can you provide a small repro? A quick refresher of looking at the code, I'm not sure how the classes you mentioned could be involved at time of instantiation.
@twyatt I haven't been able to reproduce the crash. I've just seen this show up once via bugsnag. Sharing in case others hit, but will revisit investigation only if I see this pop up again.
From reading through kable, my assumption is that the ClassCastException is happening on line 101 of appleMain/kotlin/connection.kt. And you are correct, I don't think this would be hit during peripheral instantiation. It should be hit when peripheral.connect() is called.
Here are the breadcrumbs
0ms before | | manualError: Bluetooth connection lost
491ms before | | manualInfo: (laser) advertisement found: $Advertisement(identifier=99272772-e8f5-e4cc-6b34-a376736ae806, name=LiaoWang_BT , rssi=-53, txPower=0)
700ms before | | manualInfo: (laser) advertisement found: $Advertisement(identifier=99272772-e8f5-e4cc-6b34-a376736ae806, name=LiaoWang_BT , rssi=-56, txPower=0)
701ms before | | manualInfo: (laser) advertisement found: $Advertisement(identifier=99272772-e8f5-e4cc-6b34-a376736ae806, name=LiaoWang_BT , rssi=-56, txPower=0)
754ms before | | stateScene ActivatedroleUIWindowSceneSessionRoleApplication
794ms before | | manualInfo: (laser) advertisement found: $Advertisement(identifier=99272772-e8f5-e4cc-6b34-a376736ae806, name=LiaoWang_BT , rssi=-56, txPower=0)
796ms before | | manualInfo: (laser) advertisement found: $Advertisement(identifier=99272772-e8f5-e4cc-6b34-a376736ae806, name=null, rssi=-56, txPower=null)
1.17s before | | manualInfo: (laser) starting scanning for ble devices
1.17s before | | manualInfo: (laser) bluetooth is available
6.46s before | | stateScene Will DeactivateroleUIWindowSceneSessionRoleApplication
6.76s before | | manualInfo: (laser) bluetooth is unavailable
6.77s before | | manualInfo: (laser) new bluetooth coroutine launched
6.77s before | | manualInfo: (laser) startBluetoothSearch
Here is the calling code
actual class LaserManager : ILaserManager {
override val profileDataFlow: MutableSharedFlow<String?> = MutableSharedFlow()
private var bleScope: CoroutineScope? = null
override val bleStatus = MutableStateFlow<BleStatus>(BleStatus.NotSearching)
private var peripheral: Peripheral? = null
private lateinit var scanner: Scanner<PlatformAdvertisement>
private val sentenceIterator = RfSentenceIterator()
override suspend fun startBluetoothSearch() {
Kermit.i(tag = "laser", messageString = "startBluetoothSearch")
if (bleStatus.value != BleStatus.NotSearching) {
return
}
bleStatus.emit(BleStatus.Searching)
bleScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
bleScope?.launch(Dispatchers.Default) {
Kermit.i(tag = "laser", messageString = "new bluetooth coroutine launched")
Bluetooth.availability.collect { bluetoothAvailability ->
when (bluetoothAvailability) {
Bluetooth.Availability.Available -> {
Kermit.i(tag = "laser", messageString = "bluetooth is available")
scanner = Scanner {
filters {
match {
services = listOf(uineye.serviceIdentifier)
}
}
}
scanForBleDevices()
}
is Bluetooth.Availability.Unavailable ->
Kermit.i(tag = "laser", messageString = "bluetooth is unavailable")
}
}
}
}
private fun scanForBleDevices() {
bleScope?.launch(Dispatchers.Default) {
scanner.advertisements
.onStart {
Kermit.i(tag = "laser", messageString = "starting scanning for ble devices")
}
.catch { cause ->
Kermit.e("scanning failed", cause)
}
.filter { it.uuids.contains(uineye.serviceIdentifier) }
.collect { advertisement ->
Kermit.i(tag = "laser", messageString = "advertisement found: $advertisement")
listenToUineye(advertisement)
}
}
}
private suspend fun listenToUineye(advertisement: Advertisement) {
bleScope?.launch(Dispatchers.Default) {
if (peripheral == null) {
try {
peripheral = Peripheral(advertisement = advertisement) {
// Verbose logging for debugging
/*logging {
data = Logging.DataProcessor { data, _, _, _, _ ->
data.joinToString { byte -> byte.toString() }
}
level = Logging.Level.Data
}*/
onServicesDiscovered {
Kermit.i(tag = "laser", messageString = "onServicesDiscovered")
}
}
} catch (e: Exception) {
Kermit.e("Error instantiating peripheral")
resetConnectionAndRestartSearch()
return@launch
}
try {
peripheral?.connect()
} catch (e: Exception) {
Kermit.e(messageString = "Bluetooth connection lost", e)
resetConnectionAndRestartSearch()
e.printStackTrace()
return@launch
}
peripheral?.state?.collect { state ->
when (state) {
State.Connected -> {
Kermit.i(tag = "laser", messageString = "uineye connected")
bleStatus.emit(BleStatus.Found("uineye"))
onUineyeConnected()
peripheral?.services?.collect { services ->
services?.forEach { service ->
service.characteristics.forEach { characteristic ->
if (characteristic.characteristicUuid.equals(uuidFrom(uineye.characteristic))) {
// useful logging for debugging. 2024 uineye requires sendHello and sendHeartbeat to be
// of writetype withoutresponse NOT withResponse. I learned this from looking at
// props+descriptors of the discovered characteristic
Kermit.i(tag = "laser", messageString = "rangefinder characteristic found")
Kermit.i(
tag = "laser",
messageString = "characteristic properties: ${characteristic.properties}"
)
Kermit.i(
tag = "laser",
messageString = "char descriptors: ${characteristic.descriptors}"
)
}
Kermit.i(
tag = "laser",
messageString = "Characteristic UUID: ${characteristic.characteristicUuid}"
)
}
}
}
}
is State.Disconnected -> {
Kermit.i(tag = "laser", messageString = "uineye disconnected involuntarily")
Kermit.e(messageString = "uineye disconnected involuntarily")
bleStatus.emit(BleStatus.NotSearching)
resetConnectionAndRestartSearch()
}
else -> {
// TODO: handle
Kermit.i(tag = "laser", messageString = "peripheral state: $state")
}
}
}
}
}
}
private suspend fun onUineyeConnected() {
Kermit.i(tag = "laser", messageString = "onUineyeConnected")
bleScope?.launch {
// if Uineye hasn't said hello within 8s, it's better to restart the search
Kermit.i(tag = "laser", messageString = "starting 8s wait")
delay(8_000)
// oddly enough, we've seen more than 8s pass without connection where below logs weren't recorded
// suggesting something goes wrong with a coroutine internally
Kermit.i(tag = "laser", messageString = "finished waiting 8s")
if (bleStatus.value !is BleStatus.Connected) {
Kermit.i(tag = "laser", messageString = "8s passed without hello from uineye. we will reset connection")
resetConnectionAndRestartSearch()
}
}
try {
val characteristic = characteristicOf(uineye.serviceIdentifier.toString(), uineye.characteristic)
peripheral?.read(characteristic)
sendHello()
readUineye()
peripheral?.observe(characteristic) {
Kermit.i(tag = "laser", messageString = "hello-heartbeat sequence")
sendHello()
readUineye()
}?.retry(retries = 3) {
delay(500)
true
}
?.collect { data ->
try {
sentenceIterator.addBytes(data)
} catch (e: Exception) {
sentenceIterator.reset()
}
Kermit.i(tag = "laser", messageString = "uineye: ${data.contentToString()}")
while (sentenceIterator.hasNext()) {
handleSentence(sentenceIterator.next())
}
}
} catch (e: Exception) {
// this block gets triggered anytime bleScope is canceled!
// this block occasionally gets triggered by NoSuchElementFoundException that happens sporadically
// when writing initial hello message
e.printStackTrace()
Kermit.i(tag = "laser", messageString = "error in hello/sentenceiterator sequence: ${e.message}")
withContext(Dispatchers.Main) {
playDisconnectedSound()
}
if (e !is CancellationException && e !is CancellationException) {
resetConnectionAndRestartSearch()
}
}
}
private suspend fun handleSentence(parsedData: ByteArray) {
when {
parsedData.contentEquals(laserHello) -> {
Kermit.i(tag = "laser", messageString = "uineye: hello")
bleStatus.emit(BleStatus.Connected("Uineye"))
withContext(Dispatchers.Main) {
playConnectedSound()
}
}
parsedData.contentEquals(heartbeat) -> {
Kermit.i(tag = "laser", messageString = "uineye: heartbeat")
sendHeartbeatAck()
}
parsedData.contentEquals(norange) -> {
Kermit.i(tag = "laser", messageString = "uineye: norange")
}
parsedData[0] == 23.toByte() && parsedData[1] == 0.toByte() -> {
Kermit.i(tag = "laser", messageString = "uineye: here is a measurement")
val point = processUineyeMeasurement(parsedData)
val newRow = "${point.x},${point.y}"
profileDataFlow.emit("$newRow\n")
withContext(Dispatchers.Main) {
playMeasurementRecordedSound()
}
}
}
}
private suspend fun sendHello() {
Kermit.i(tag = "laser", messageString = "app says: hello")
val characteristic = characteristicOf(uineye.serviceIdentifier.toString(), uineye.characteristic)
try {
// 2023 uineye expects WithResponse
peripheral?.write(characteristic, appHello, WriteType.WithResponse)
} catch (e: Exception) {
// 2024 uineye expects WithoutResponse
peripheral?.write(characteristic, appHello, WriteType.WithoutResponse)
}
}
private suspend fun sendHeartbeatAck() {
Kermit.i(tag = "laser", messageString = "app says: heartbeat acknowledged")
val characteristic = characteristicOf(uineye.serviceIdentifier.toString(), uineye.characteristic)
try {
// 2023 uineye expects WithResponse
peripheral?.write(characteristic, appHeartbeatAck, WriteType.WithResponse)
} catch (e: Exception) {
// 2024 uineye expects WithoutResponse
peripheral?.write(characteristic, appHeartbeatAck, WriteType.WithoutResponse)
}
}
private suspend fun readUineye(): ByteArray {
val characteristic = characteristicOf(uineye.serviceIdentifier.toString(), uineye.characteristic)
return peripheral?.read(characteristic) ?: ByteArray(0)
}
private suspend fun resetConnectionAndRestartSearch() {
try {
Kermit.i(tag = "laser", messageString = "resetConnectionAndRestartSearch")
bleScope?.cancel()
withContext(Dispatchers.Main) {
bleScope = null
}
withTimeoutOrNull(5_000L) {
peripheral?.disconnect()
}
peripheral = null
bleStatus.emit(BleStatus.NotSearching)
startBluetoothSearch()
} catch (e: Exception) {
e.printStackTrace()
Kermit.e(messageString = "error restarting laser connection after involuntary disconnect", throwable = e)
Kermit.i(tag = "laser", messageString = "restart error: ${e.message}")
}
}
override suspend fun stopBluetooth() {
bleStatus.emit(BleStatus.NotSearching)
bleScope?.cancel()
withContext(Dispatchers.Main) {
bleScope = null
}
peripheral?.disconnect()
peripheral = null
}
}
I should change peripheral to an atomic reference; that could contribute to a race condition. Mainly sharing in case there is some library usage here that is wildly different from what you would expect as the library author.
I also saw this exception and it is reproducible reliably in my app, but it is a convoluted scenario. I worked around it. But I'll try to explain to the best of my ability:
Two pre-requisites had to be met for this to occur:
- A previous connect attempt failed with an IllegalStateException: "Cannot connect peripheral that has been cancelled". In my case this happened on iOS when I was trying to reconnect to a saved peripheral that was obtained using
Peripheral(identifier: Uuid)factory method and the peripheral was turned off. - I was not handling this failure attempt cleanly. Specifically, my
peripheral.connect()attempt was still active even after the previous failure. When the peripheral was turned ON, the connect attempt proceeded; but in the UI I tapped on "Connect" again. Anotherperipheral.connect()was attempted and this led to the crash when the state hitPeripheral.Services.
kotlin.ClassCastException: class com.juul.kable.PeripheralDelegate.Response.DidDiscoverServices cannot be cast to class com.juul.kable.PeripheralDelegate.Response.DidDiscoverCharacteristicsForService
My work-around was to cleanly abandon the connection attempt when the failure in Step 1 occurred.
I'm not sure if it makes sense for me to post my code since it is very specific to our app.