kable icon indicating copy to clipboard operation
kable copied to clipboard

ClassCastException when instantiating Peripheral

Open brendanw opened this issue 8 months ago • 2 comments

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

brendanw avatar Mar 28 '25 13:03 brendanw

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 avatar Mar 30 '25 01:03 twyatt

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

brendanw avatar Mar 30 '25 03:03 brendanw

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:

  1. 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.
  2. 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. Another peripheral.connect() was attempted and this led to the crash when the state hit Peripheral.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.

curioustechizen avatar Sep 03 '25 14:09 curioustechizen