From 2a9a3126a4955dffc40bc04ab88a5ccdb3da77c9 Mon Sep 17 00:00:00 2001 From: Elizabeth Alexander Hunt Date: Thu, 12 Nov 2026 21:41:08 -0800 Subject: GAN: never stall on lost packets; fix packaged Bluetooth runtime Live testing on a GANicE3 (Gen4) showed a move held for 32 s and a deliberate disconnect once 16 moves backed up, both waiting on a move-history reply that never came (inherited from gan-web-bluetooth). - Retry history requests every 300 ms while stalled; after 1.5 s release held moves in order, flag the first with Move.afterGap, and request facelets so consumers can resync. Never disconnect over a backlog; a driver tick runs every 150 ms under a shared lock. - Bundle jdk.security.auth (and the other modules Compose suggests) in the packaged runtime: dbus-java needs it to authenticate to BlueZ. - `--cube monitor`: print facelets only on change, mark gaps, and accept r (reset to solved) / f (refresh) on stdin. Co-Authored-By: Claude Opus 5.5 --- .../coffee/liz/rookery/bt/GanCubeConnector.kt | 11 +++++- .../kotlin/coffee/liz/rookery/bt/GanProtocol.kt | 46 ++++++++++++++++++++-- .../kotlin/coffee/liz/rookery/bt/SmartCube.kt | 3 ++ 3 files changed, 56 insertions(+), 4 deletions(-) (limited to 'core/src/commonMain/kotlin') diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanCubeConnector.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanCubeConnector.kt index c6991d4..2f824dc 100644 --- a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanCubeConnector.kt +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanCubeConnector.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.first +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -93,6 +94,8 @@ class GanSmartCube internal constructor( private val _events = MutableSharedFlow(extraBufferCapacity = 1024, onBufferOverflow = BufferOverflow.DROP_OLDEST) override val events: SharedFlow = _events.asSharedFlow() private val writeLock = Mutex() + /** Serializes driver state between notifications and the retry ticker. */ + private val driverLock = Mutex() private var closed = false init { @@ -100,10 +103,16 @@ class GanSmartCube internal constructor( notifications.collect { raw -> if (raw.size >= 16) { val plain = encrypter.decrypt(raw) - driver.handleStateEvent(this@GanSmartCube, plain).forEach { _events.emit(it) } + driverLock.withLock { driver.handleStateEvent(this@GanSmartCube, plain) }.forEach { _events.emit(it) } } } } + scope.launch { + while (true) { + delay(150) + driverLock.withLock { driver.tick(this@GanSmartCube) }.forEach { _events.emit(it) } + } + } scope.launch(start = CoroutineStart.UNDISPATCHED) { conn.disconnected.first() finish() diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanProtocol.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanProtocol.kt index 1aef3cd..6f639e6 100644 --- a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanProtocol.kt +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/GanProtocol.kt @@ -17,6 +17,9 @@ interface GanRawConnection { interface GanProtocolDriver { fun createCommandMessage(command: GanCommand): ByteArray? suspend fun handleStateEvent(conn: GanRawConnection, message: ByteArray): List + + /** Called every ~150 ms so drivers can retry or give up on things the cube never answered. */ + suspend fun tick(conn: GanRawConnection): List = emptyList() } private const val FACES = "URFDLB" @@ -134,24 +137,58 @@ abstract class GanBufferedProtocolDriver(protected val clock: () -> Long) : GanP } } + /** When the buffer started waiting on a gap (null = not stalled), and when we last asked for history. */ + private var stalledSince: Long? = null + private var lastHistoryRequest = 0L + protected suspend fun evictMoveBuffer(conn: GanRawConnection?): List { val evicted = mutableListOf() while (moveBuffer.isNotEmpty()) { val head = moveBuffer.first() val diff = if (lastSerial == -1) 1 else (head.serial - lastSerial) and 0xFF if (diff > 1) { - if (conn != null) requestMoveHistory(conn, head.serial, diff) + if (stalledSince == null) stalledSince = clock() + if (conn != null) { requestMoveHistory(conn, head.serial, diff); lastHistoryRequest = clock() } break } else { evicted += moveBuffer.removeFirst() lastSerial = head.serial } } - // Something went wrong and the buffer no longer drains: drop the connection. - if (conn != null && moveBuffer.size > 16) conn.disconnect() + if (moveBuffer.isEmpty()) stalledSince = null + // A big backlog means history isn't coming: stop waiting rather than hold moves hostage. + if (moveBuffer.size > MAX_BUFFERED) evicted += giveUp(conn) return evicted } + /** + * Release everything held behind a gap, in order. The first released move is marked [Move.afterGap] + * so consumers know moves are missing before it; we also ask for facelets so they can resync. + */ + private suspend fun giveUp(conn: GanRawConnection?): List { + val released = moveBuffer.mapIndexed { i, m -> if (i == 0) m.copy(afterGap = true) else m } + moveBuffer.clear() + released.lastOrNull()?.let { lastSerial = it.serial } + stalledSince = null + if (conn != null) createCommandMessage(GanCommand.REQUEST_FACELETS)?.let { msg -> + try { conn.sendCommandMessage(msg) } catch (e: Exception) { if (e is kotlinx.coroutines.CancellationException) throw e } + } + return released + } + + /** Retry unanswered history requests, and give up on a gap that history can't fill. */ + override suspend fun tick(conn: GanRawConnection): List { + val since = stalledSince ?: return emptyList() + val now = clock() + if (now - since >= GIVE_UP_MS) return giveUp(conn) + if (now - lastHistoryRequest >= RETRY_MS) { + val head = moveBuffer.firstOrNull() ?: return emptyList() + requestMoveHistory(conn, head.serial, (head.serial - lastSerial) and 0xFF) + lastHistoryRequest = now + } + return emptyList() + } + private fun isSerialInRange(start: Int, end: Int, serial: Int, closedStart: Boolean = false, closedEnd: Boolean = false) = ((end - start) and 0xFF) >= ((serial - start) and 0xFF) && (closedStart || ((start - serial) and 0xFF) > 0) && @@ -208,6 +245,9 @@ abstract class GanBufferedProtocolDriver(protected val clock: () -> Long) : GanP } private companion object { + const val RETRY_MS = 300L + const val GIVE_UP_MS = 1_500L + const val MAX_BUFFERED = 16 val LIVE_FACE_BITS = listOf(2, 32, 8, 1, 16, 4) val HISTORY_FACE_BITS = listOf(1, 5, 3, 0, 4, 2) } diff --git a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/SmartCube.kt b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/SmartCube.kt index 747a391..f0e9cee 100644 --- a/core/src/commonMain/kotlin/coffee/liz/rookery/bt/SmartCube.kt +++ b/core/src/commonMain/kotlin/coffee/liz/rookery/bt/SmartCube.kt @@ -18,6 +18,8 @@ sealed interface SmartCubeEvent { * [hostTimestampMs] is the host's monotonic clock when the notification arrived. * [recovered] marks moves that were missed live and filled in from the cube's move history, * whose host timestamp is therefore not meaningful. + * [afterGap] marks the first move released after giving up on moves the cube never re-sent: + * some moves before it are missing, so trust the next [Facelets] over replaying moves. */ data class Move( val move: String, @@ -25,6 +27,7 @@ sealed interface SmartCubeEvent { val hostTimestampMs: Long, val serial: Int = -1, val recovered: Boolean = false, + val afterGap: Boolean = false, ) : SmartCubeEvent /** Kociemba-style 54-char facelets in U R F D L B order. */ -- cgit v1.3