From f4fa9ed6efbaff2093c4f5b4d66e44e515ec1d7e Mon Sep 17 00:00:00 2001 From: Devil Date: Thu, 16 Jul 2026 15:47:01 -0400 Subject: [PATCH 01/54] build(ios): add Apple Silicon simulator target --- shared/build.gradle.kts | 19 ++++++++++++++++++- 1 file changed, 18 insertions(+), 1 deletion(-) diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 992120a0d..9d0e1f385 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -37,7 +37,7 @@ kotlin { withHostTest {} } - // iOS target (iosArm64 only - physical devices for distribution) + // iOS targets val xcf = XCFramework() iosArm64 { binaries.framework { @@ -52,6 +52,19 @@ kotlin { freeCompilerArgs += listOf("-Xadd-light-debug=enable") } } + iosSimulatorArm64 { + binaries.framework { + baseName = "shared" + isStatic = true + xcf.add(this) + // Link system frameworks required by shared module + linkerOpts("-framework", "HealthKit") + linkerOpts("-framework", "Speech") + } + binaries.all { + freeCompilerArgs += listOf("-Xadd-light-debug=enable") + } + } sourceSets { val commonMain by getting { @@ -179,11 +192,14 @@ kotlin { val iosArm64Main by getting val iosArm64Test by getting + val iosSimulatorArm64Main by getting + val iosSimulatorArm64Test by getting @Suppress("UNUSED_VARIABLE") val iosMain by creating { dependsOn(commonMain) iosArm64Main.dependsOn(this) + iosSimulatorArm64Main.dependsOn(this) dependencies { // SQLDelight Native Driver @@ -198,6 +214,7 @@ kotlin { val iosTest by creating { dependsOn(commonTest) iosArm64Test.dependsOn(this) + iosSimulatorArm64Test.dependsOn(this) dependencies { implementation(libs.sqldelight.native.driver) From 7a8381a45f02868a2c479d0fce6e11751d956f10 Mon Sep 17 00:00:00 2001 From: Devil Date: Thu, 16 Jul 2026 16:03:37 -0400 Subject: [PATCH 02/54] test(ios): restore simulator-only Phantom trainer --- .../data/repository/PhantomBleRepository.kt | 530 ++++++++++++++++++ .../repository/PhantomBleRepositoryTest.kt | 190 +++++++ 2 files changed, 720 insertions(+) create mode 100644 shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt create mode 100644 shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt new file mode 100644 index 000000000..e097bc303 --- /dev/null +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -0,0 +1,530 @@ +package com.devil.phoenixproject.data.repository + +import com.devil.phoenixproject.data.ble.DiagnosticPacket +import com.devil.phoenixproject.data.ble.MonitorDataProcessor +import com.devil.phoenixproject.data.ble.parseDiagnosticPacket +import com.devil.phoenixproject.data.ble.parseHeuristicPacket +import com.devil.phoenixproject.data.ble.parseMonitorPacket +import com.devil.phoenixproject.data.ble.parseRepPacket +import com.devil.phoenixproject.domain.model.ConnectionState +import com.devil.phoenixproject.domain.model.HeuristicPhaseStatistics +import com.devil.phoenixproject.domain.model.HeuristicStatistics +import com.devil.phoenixproject.domain.model.WorkoutMetric +import com.devil.phoenixproject.domain.model.WorkoutParameters +import kotlin.math.PI +import kotlin.math.sin +import kotlin.time.Clock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch + +enum class PhantomRawPacketKind { + MONITOR, + REP, + DIAGNOSTIC, + HEURISTIC, +} + +data class PhantomBleConfig( + val loadScale: Float = 1f, + val velocityScale: Double = 1.0, + val positionScale: Float = 1f, + val repDelayMs: Long = 2_000L, + val autoCompleteFixedRepSets: Boolean = true, +) { + init { + require(loadScale > 0f) { "loadScale must be > 0" } + require(velocityScale > 0.0) { "velocityScale must be > 0" } + require(positionScale > 0f) { "positionScale must be > 0" } + require(repDelayMs >= 100L) { "repDelayMs must be >= 100" } + } + + companion object { + val Default = PhantomBleConfig() + } +} + +/** + * Emulator-safe phantom Vitruvian machine. + * + * This implementation exercises the same app-level [BleRepository] surface as the real + * Kable-backed machine without touching Bluetooth hardware. It is intended for debug + * sandboxes, emulator bug reproduction, UI flow testing, and connection-log capture. + */ +class PhantomBleRepository( + private val logRepo: ConnectionLogRepository = ConnectionLogRepository.instance, + initialConfig: PhantomBleConfig = PhantomBleConfig.Default, +) : BleRepository { + private val repositoryJob = SupervisorJob() + private val scope = CoroutineScope(repositoryJob + Dispatchers.Default) + + private val device = ScannedDevice(PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS, -42) + + private val _connectionState = MutableStateFlow(ConnectionState.Disconnected) + override val connectionState: StateFlow = _connectionState.asStateFlow() + + private val _metricsFlow = MutableSharedFlow(extraBufferCapacity = 64) + override val metricsFlow: Flow = _metricsFlow.asSharedFlow() + + private val _scannedDevices = MutableStateFlow>(emptyList()) + override val scannedDevices: StateFlow> = _scannedDevices.asStateFlow() + + private val _handleDetection = MutableStateFlow(HandleDetection()) + override val handleDetection: StateFlow = _handleDetection.asStateFlow() + + private val _repEvents = MutableSharedFlow(extraBufferCapacity = 16) + override val repEvents: Flow = _repEvents.asSharedFlow() + + private val _handleState = MutableStateFlow(HandleState.WaitingForRest) + override val handleState: StateFlow = _handleState.asStateFlow() + + private val _deloadOccurredEvents = MutableSharedFlow(extraBufferCapacity = 4) + override val deloadOccurredEvents: Flow = _deloadOccurredEvents.asSharedFlow() + + private val _reconnectionRequested = MutableSharedFlow(extraBufferCapacity = 4) + override val reconnectionRequested: Flow = _reconnectionRequested.asSharedFlow() + + private val _heuristicData = MutableStateFlow(null) + override val heuristicData: StateFlow = _heuristicData.asStateFlow() + + private val _diagnostics = MutableStateFlow(null) + override val diagnostics: StateFlow = _diagnostics.asStateFlow() + + private val _discoModeActive = MutableStateFlow(false) + override val discoModeActive: StateFlow = _discoModeActive.asStateFlow() + + private val monitorProcessor = MonitorDataProcessor( + onDeloadOccurred = { + scope.launch { _deloadOccurredEvents.emit(Unit) } + }, + onRomViolation = { violation -> + logRepo.warning( + LogEventType.NOTIFICATION, + "Phantom raw monitor packet reported ROM violation", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + violation.name, + ) + }, + ) + + private var metricsJob: Job? = null + private var repJob: Job? = null + private var diagnosticJob: Job? = null + private var workoutParams: WorkoutParameters? = null + private val _config = MutableStateFlow(initialConfig) + val config: StateFlow = _config.asStateFlow() + private var ticks = 0L + private var lastColorSchemeIndex = 0 + + override suspend fun startScanning(): Result { + logRepo.info(LogEventType.SCAN_START, "Starting phantom Vitruvian scan") + setConnectionState(ConnectionState.Scanning) + delay(150) + _scannedDevices.value = listOf(device) + logRepo.info( + LogEventType.DEVICE_FOUND, + "Found phantom Vitruvian device", + deviceName = device.name, + deviceAddress = device.address, + details = "RSSI ${device.rssi}; no Bluetooth hardware used", + ) + return Result.success(Unit) + } + + override suspend fun stopScanning() { + if (_connectionState.value == ConnectionState.Scanning) { + setConnectionState(ConnectionState.Disconnected) + } + logRepo.info(LogEventType.SCAN_STOP, "Stopped phantom Vitruvian scan") + } + + override suspend fun connect(device: ScannedDevice): Result { + logRepo.info(LogEventType.CONNECT_START, "Connecting to phantom Vitruvian", device.name, device.address) + setConnectionState(ConnectionState.Connecting) + delay(250) + setConnectionState(ConnectionState.Connected(device.name, device.address)) + _handleDetection.value = HandleDetection(leftDetected = true, rightDetected = true) + _handleState.value = HandleState.Released + logRepo.info(LogEventType.SERVICE_DISCOVERED, "Phantom service map ready", device.name, device.address) + logRepo.info(LogEventType.CONNECT_SUCCESS, "Connected to phantom Vitruvian", device.name, device.address) + startDiagnostics() + startMetrics(activeWorkout = false) + return Result.success(Unit) + } + + override suspend fun cancelConnection() { + logRepo.warning(LogEventType.DISCONNECT, "Cancelled phantom connection") + setConnectionState(ConnectionState.Disconnected) + } + + override suspend fun disconnect() { + logRepo.info(LogEventType.DISCONNECT, "Disconnected phantom Vitruvian", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + stopJobs() + workoutParams = null + _handleDetection.value = HandleDetection() + _handleState.value = HandleState.WaitingForRest + _diagnostics.value = null + setConnectionState(ConnectionState.Disconnected) + } + + override suspend fun shutdown() { + try { + disconnect() + } finally { + repositoryJob.cancel() + } + } + + override suspend fun scanAndConnect(timeoutMs: Long): Result { + startScanning() + return connect(device) + } + + override suspend fun setColorScheme(schemeIndex: Int): Result { + lastColorSchemeIndex = schemeIndex + logRepo.info(LogEventType.COMMAND_SENT, "Phantom color scheme set", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS, "scheme=$schemeIndex") + return Result.success(Unit) + } + + override suspend fun sendWorkoutCommand(command: ByteArray): Result { + logRepo.info( + LogEventType.COMMAND_SENT, + "Phantom received raw workout command", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + command.joinToString(" ") { it.toUByte().toString(16).padStart(2, '0') }, + ) + return Result.success(Unit) + } + + override suspend fun sendInitSequence(): Result { + logRepo.info(LogEventType.COMMAND_SENT, "Phantom init sequence accepted", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + return Result.success(Unit) + } + + override suspend fun startWorkout(params: WorkoutParameters): Result { + workoutParams = params + _handleState.value = HandleState.Grabbed + logRepo.info( + LogEventType.COMMAND_SENT, + "Phantom workout started", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "mode=${params.programMode}; reps=${params.reps}; weightPerCableKg=${params.weightPerCableKg}; justLift=${params.isJustLift}", + ) + startMetrics(activeWorkout = true) + startRepSimulation(params) + return Result.success(Unit) + } + + override suspend fun stopWorkout(): Result { + logRepo.info(LogEventType.COMMAND_SENT, "Phantom workout stopped", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + repJob?.cancel() + workoutParams = null + _handleState.value = HandleState.Released + startMetrics(activeWorkout = false) + return Result.success(Unit) + } + + override suspend fun sendStopCommand(): Result { + logRepo.info(LogEventType.COMMAND_SENT, "Phantom stop command accepted", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + return stopWorkout() + } + + override fun enableHandleDetection(enabled: Boolean) { + _handleDetection.value = HandleDetection(leftDetected = enabled, rightDetected = enabled) + _handleState.value = if (enabled) HandleState.WaitingForRest else HandleState.Released + logRepo.info(LogEventType.NOTIFICATION, "Phantom handle detection ${if (enabled) "enabled" else "disabled"}") + } + + override fun resetHandleState() { + _handleState.value = HandleState.WaitingForRest + } + + override fun enableJustLiftWaitingMode() { + _handleState.value = HandleState.WaitingForRest + logRepo.info(LogEventType.NOTIFICATION, "Phantom Just Lift waiting mode armed") + } + + override fun restartMonitorPolling() { + startMetrics(activeWorkout = workoutParams != null) + } + + override fun startActiveWorkoutPolling() { + _handleState.value = HandleState.Grabbed + startMetrics(activeWorkout = true) + } + + override fun stopPolling() { + metricsJob?.cancel() + repJob?.cancel() + diagnosticJob?.cancel() + logRepo.info(LogEventType.HEARTBEAT, "Phantom polling stopped") + } + + override fun stopMonitorPollingOnly() { + metricsJob?.cancel() + repJob?.cancel() + logRepo.info(LogEventType.HEARTBEAT, "Phantom monitor polling stopped; diagnostics kept warm") + } + + override fun restartDiagnosticPolling() { + startDiagnostics() + } + + override fun startDiscoMode() { + _discoModeActive.value = true + logRepo.info(LogEventType.COMMAND_SENT, "Phantom disco mode started", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + } + + override fun stopDiscoMode() { + _discoModeActive.value = false + logRepo.info( + LogEventType.COMMAND_SENT, + "Phantom disco mode stopped", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "restoredScheme=$lastColorSchemeIndex", + ) + } + + override fun setLastColorSchemeIndex(index: Int) { + lastColorSchemeIndex = index + } + + /** + * Inject raw Vitruvian characteristic bytes into the same protocol parsers used by real BLE. + * This lets emulator-driven RCA reproduce packet-parser bugs without physical hardware. + */ + suspend fun injectRawPacket( + kind: PhantomRawPacketKind, + data: ByteArray, + hasOpcodePrefix: Boolean = false, + ): Result = runCatching { + when (kind) { + PhantomRawPacketKind.MONITOR -> injectMonitorPacket(data) + PhantomRawPacketKind.REP -> injectRepPacket(data, hasOpcodePrefix) + PhantomRawPacketKind.DIAGNOSTIC -> injectDiagnosticPacket(data) + PhantomRawPacketKind.HEURISTIC -> injectHeuristicPacket(data) + } + }.onFailure { error -> + logRepo.error( + LogEventType.ERROR, + "Phantom raw ${kind.name.lowercase()} packet rejected", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "${error.message}; hex=${data.toHexString()}", + ) + } + + private suspend fun injectMonitorPacket(data: ByteArray) { + val packet = parseMonitorPacket(data) + ?: error("monitor packet too short: ${data.size} bytes") + val metric = monitorProcessor.process(packet) + ?: error("monitor packet parsed but was rejected by validation") + _metricsFlow.emit(metric) + logRepo.info( + LogEventType.NOTIFICATION, + "Phantom injected raw monitor packet", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "ticks=${metric.ticks}; load=${metric.totalLoad}; posA=${metric.positionA}; hex=${data.toHexString()}", + ) + } + + private suspend fun injectRepPacket(data: ByteArray, hasOpcodePrefix: Boolean) { + val timestamp = Clock.System.now().toEpochMilliseconds() + val rep = parseRepPacket(data, hasOpcodePrefix, timestamp) + ?: error("rep packet too short: ${data.size} bytes") + _repEvents.emit(rep) + logRepo.info( + LogEventType.REP_RECEIVED, + "Phantom injected raw rep packet", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "top=${rep.topCounter}; complete=${rep.completeCounter}; legacy=${rep.isLegacyFormat}; hex=${data.toHexString()}", + ) + } + + private fun injectDiagnosticPacket(data: ByteArray) { + val timestamp = Clock.System.now().toEpochMilliseconds() + val diagnostic = parseDiagnosticPacket(data) + ?: error("diagnostic packet too short: ${data.size} bytes") + _diagnostics.value = diagnostic.copy(receivedAtMillis = timestamp) + logRepo.info( + LogEventType.DIAGNOSTIC, + "Phantom injected raw diagnostic packet", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "faults=${diagnostic.faultWords}; temps=${diagnostic.temperatures}; hex=${data.toHexString()}", + ) + } + + private fun injectHeuristicPacket(data: ByteArray) { + val timestamp = Clock.System.now().toEpochMilliseconds() + val heuristic = parseHeuristicPacket(data, timestamp) + ?: error("heuristic packet too short: ${data.size} bytes") + _heuristicData.value = heuristic + logRepo.info( + LogEventType.NOTIFICATION, + "Phantom injected raw heuristic packet", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "conKgAvg=${heuristic.concentric.kgAvg}; eccKgAvg=${heuristic.eccentric.kgAvg}; hex=${data.toHexString()}", + ) + } + + private fun ByteArray.toHexString(): String = joinToString(" ") { byte -> + byte.toUByte().toString(16).padStart(2, '0') + } + + fun replaceConfig(config: PhantomBleConfig) { + _config.value = config + logRepo.info( + LogEventType.NOTIFICATION, + "Phantom config updated", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "loadScale=${config.loadScale}; velocityScale=${config.velocityScale}; positionScale=${config.positionScale}; repDelayMs=${config.repDelayMs}; autoCompleteFixedRepSets=${config.autoCompleteFixedRepSets}", + ) + if (_connectionState.value is ConnectionState.Connected) { + startMetrics(activeWorkout = workoutParams != null) + workoutParams?.let(::startRepSimulation) + } + } + + private fun setConnectionState(state: ConnectionState) { + _connectionState.value = state + } + + private fun startMetrics(activeWorkout: Boolean) { + metricsJob?.cancel() + metricsJob = scope.launch { + var sample = 0 + while (isActive && connectionState.value is ConnectionState.Connected) { + val now = Clock.System.now().toEpochMilliseconds() + val phase = (sample % 40) / 40.0 + val wave = sin(phase * 2.0 * PI).toFloat() + val config = _config.value + val configuredLoad = workoutParams?.weightPerCableKg ?: 7.5f + val load = (if (activeWorkout) configuredLoad.coerceAtLeast(2f) else 1.5f) * config.loadScale + val metric = WorkoutMetric( + timestamp = now, + loadA = load + wave.coerceAtLeast(0f) * config.loadScale, + loadB = load + (-wave).coerceAtLeast(0f) * config.loadScale, + positionA = wave * 650f * config.positionScale, + positionB = wave * 640f * config.positionScale, + ticks = ticks++, + velocityA = wave * 250.0 * config.velocityScale, + velocityB = wave * 245.0 * config.velocityScale, + status = 0, + ) + _metricsFlow.emit(metric) + if (sample % 8 == 0) { + _heuristicData.value = HeuristicStatistics( + concentric = HeuristicPhaseStatistics(load, load + 1.5f, 0.42f, 0.70f, 85f, 130f), + eccentric = HeuristicPhaseStatistics(load * 0.9f, load + 1f, 0.38f, 0.62f, 72f, 110f), + ) + logRepo.debug( + LogEventType.NOTIFICATION, + "Phantom monitor metric", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "ticks=${metric.ticks}; load=${metric.totalLoad}; posA=${metric.positionA}", + ) + } + sample++ + delay(if (activeWorkout) 250 else 750) + } + } + } + + private fun startRepSimulation(params: WorkoutParameters) { + repJob?.cancel() + repJob = scope.launch { + var rep = 0 + val target = params.reps.coerceAtLeast(1) + while (isActive && connectionState.value is ConnectionState.Connected) { + val config = _config.value + delay(config.repDelayMs) + rep += 1 + val timestamp = Clock.System.now().toEpochMilliseconds() + val rawData = ByteArray(24).also { bytes -> + bytes[0] = rep.toByte() + bytes[4] = rep.toByte() + bytes[18] = params.warmupReps.toByte() + bytes[22] = target.toByte() + } + _repEvents.emit( + RepNotification( + topCounter = rep, + completeCounter = rep, + repsRomCount = params.warmupReps.coerceAtMost(rep), + repsRomTotal = params.warmupReps, + repsSetCount = rep.coerceAtMost(target), + repsSetTotal = target, + rangeTop = 650f, + rangeBottom = -650f, + rawData = rawData, + timestamp = timestamp, + ), + ) + logRepo.info( + LogEventType.REP_RECEIVED, + "Phantom rep notification", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "rep=$rep/$target; timestamp=$timestamp", + ) + if (rep >= target && !params.isAMRAP && config.autoCompleteFixedRepSets) { + logRepo.info(LogEventType.COMMAND_RESPONSE, "Phantom target reps reached", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + break + } + } + } + } + + private fun startDiagnostics() { + diagnosticJob?.cancel() + diagnosticJob = scope.launch { + val connectedAt = Clock.System.now().toEpochMilliseconds() + while (isActive && connectionState.value is ConnectionState.Connected) { + val now = Clock.System.now().toEpochMilliseconds() + _diagnostics.value = DiagnosticPacket( + runtimeSeconds = (now - connectedAt) / 1000, + faultWords = listOf(0, 0, 0, 0), + temperatures = listOf(34, 35, 34, 35, 36, 36, 35, 34), + hasFaults = false, + receivedAtMillis = now, + ) + logRepo.debug(LogEventType.DIAGNOSTIC, "Phantom diagnostic heartbeat", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + delay(2_000) + } + } + } + + private fun stopJobs() { + metricsJob?.cancel() + repJob?.cancel() + diagnosticJob?.cancel() + metricsJob = null + repJob = null + diagnosticJob = null + } + + companion object { + const val PHANTOM_DEVICE_NAME = "Vee_PhantomSimulator" + const val PHANTOM_DEVICE_ADDRESS = "PH:AN:TO:MS:BX:01" + } +} diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt new file mode 100644 index 000000000..af5117fb7 --- /dev/null +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -0,0 +1,190 @@ +package com.devil.phoenixproject.data.repository + +import app.cash.turbine.test +import com.devil.phoenixproject.domain.model.ConnectionState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertFailsWith +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +class PhantomBleRepositoryTest { + + @Test + fun `config rejects non-positive load scale`() { + assertFailsWith { + PhantomBleConfig(loadScale = 0f) + } + assertFailsWith { + PhantomBleConfig(loadScale = -1f) + } + } + + @Test + fun `config rejects rep delays below 100 milliseconds`() { + assertFailsWith { + PhantomBleConfig(repDelayMs = 99L) + } + assertFailsWith { + PhantomBleConfig(repDelayMs = -1L) + } + } + + @Test + fun `scanAndConnect publishes simulator device and connected state`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + assertEquals(1, repository.scannedDevices.value.size) + assertEquals("Vee_PhantomSimulator", repository.scannedDevices.value.single().name) + assertTrue(repository.connectionState.value is ConnectionState.Connected) + } finally { + repository.shutdown() + } + } + + @Test + fun `injectRawPacket parses legacy rep bytes through protocol parser`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val raw = byteArrayOf( + 0x07, 0x00, // topCounter = 7 + 0x00, 0x00, + 0x05, 0x00, // completeCounter = 5 + 0x55, 0x66, 0x77, 0x11, // extra legacy bytes should still parse as legacy + ) + + repository.repEvents.test { + val result = repository.injectRawPacket(PhantomRawPacketKind.REP, raw) + assertTrue(result.isSuccess) + + val event = awaitItem() + assertEquals(7, event.topCounter) + assertEquals(5, event.completeCounter) + assertTrue(event.isLegacyFormat) + assertEquals(raw.toList(), event.rawData.toList()) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `injectRawPacket parses monitor bytes through monitor processor`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val raw = monitorPacket( + ticks = 42, + posA = 1250, // 125.0mm + velA = 320, // 32.0mm/s + loadA = 1234, // 12.34kg + posB = -750, // -75.0mm + velB = -250, // -25.0mm/s + loadB = 567, // 5.67kg + ) + + repository.metricsFlow.test { + val result = repository.injectRawPacket(PhantomRawPacketKind.MONITOR, raw) + assertTrue(result.isSuccess) + + val metric = awaitItem() + assertEquals(42, metric.ticks) + assertEquals(12.34f, metric.loadA) + assertEquals(5.67f, metric.loadB) + assertEquals(125.0f, metric.positionA) + assertEquals(-75.0f, metric.positionB) + assertEquals(32.0, metric.velocityA) + assertEquals(-25.0, metric.velocityB) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `injectRawPacket parses diagnostic bytes into diagnostic state`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val raw = ByteArray(18).also { bytes -> + putUInt32LE(bytes, 0, 1234) + putUInt16LE(bytes, 4, 0x0001) + bytes[12] = 31 + bytes[13] = 32 + bytes[14] = 33 + bytes[15] = 34 + bytes[16] = 35 + bytes[17] = 36 + } + + val result = repository.injectRawPacket(PhantomRawPacketKind.DIAGNOSTIC, raw) + + assertTrue(result.isSuccess) + val packet = repository.diagnostics.value + requireNotNull(packet) + assertEquals(1234, packet.runtimeSeconds) + assertEquals(listOf(1, 0, 0, 0), packet.faultWords) + assertEquals(listOf(31, 32, 33, 34, 35, 36), packet.temperatures) + assertTrue(packet.hasFaults) + } + + @Test + fun `injectRawPacket returns failure for invalid monitor bytes`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + + val result = repository.injectRawPacket(PhantomRawPacketKind.MONITOR, byteArrayOf(1, 2, 3)) + + assertFalse(result.isSuccess) + } + + @Test + fun `replaceConfig scales generated phantom metrics`() = runTest { + val repository = PhantomBleRepository( + ConnectionLogRepository(), + PhantomBleConfig(loadScale = 2f, velocityScale = 3.0, positionScale = 0.5f, repDelayMs = 100L), + ) + + repository.metricsFlow.test { + assertTrue(repository.scanAndConnect().isSuccess) + val metric = awaitItem() + + assertTrue(metric.loadA >= 3.0f, "loadA should reflect doubled inactive phantom load") + assertTrue(kotlin.math.abs(metric.positionA) <= 325.0f, "positionA should reflect halved range") + assertTrue(kotlin.math.abs(metric.velocityA) <= 750.0, "velocityA should reflect tripled range") + repository.disconnect() + cancelAndIgnoreRemainingEvents() + } + } + + private fun monitorPacket( + ticks: Int, + posA: Int, + velA: Int, + loadA: Int, + posB: Int, + velB: Int, + loadB: Int, + status: Int = 0, + ): ByteArray = ByteArray(18).also { bytes -> + putUInt16LE(bytes, 0, ticks and 0xFFFF) + putUInt16LE(bytes, 2, ticks ushr 16) + putInt16LE(bytes, 4, posA) + putInt16LE(bytes, 6, velA) + putUInt16LE(bytes, 8, loadA) + putInt16LE(bytes, 10, posB) + putInt16LE(bytes, 12, velB) + putUInt16LE(bytes, 14, loadB) + putUInt16LE(bytes, 16, status) + } + + private fun putUInt32LE(bytes: ByteArray, offset: Int, value: Int) { + bytes[offset] = (value and 0xFF).toByte() + bytes[offset + 1] = ((value ushr 8) and 0xFF).toByte() + bytes[offset + 2] = ((value ushr 16) and 0xFF).toByte() + bytes[offset + 3] = ((value ushr 24) and 0xFF).toByte() + } + + private fun putUInt16LE(bytes: ByteArray, offset: Int, value: Int) { + bytes[offset] = (value and 0xFF).toByte() + bytes[offset + 1] = ((value ushr 8) and 0xFF).toByte() + } + + private fun putInt16LE(bytes: ByteArray, offset: Int, value: Int) { + val encoded = value and 0xFFFF + putUInt16LE(bytes, offset, encoded) + } +} From 398f056775e5f5cdd142f2a6581178eeb3e34f33 Mon Sep 17 00:00:00 2001 From: Devil Date: Thu, 16 Jul 2026 16:43:50 -0400 Subject: [PATCH 03/54] fix(ios): complete Phantom repository lifecycle contract --- .../data/repository/PhantomBleRepository.kt | 117 +++++++++++--- .../repository/PhantomBleRepositoryTest.kt | 143 ++++++++++++++++++ 2 files changed, 243 insertions(+), 17 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index e097bc303..833a4d86b 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -27,6 +28,7 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout enum class PhantomRawPacketKind { MONITOR, @@ -39,7 +41,7 @@ data class PhantomBleConfig( val loadScale: Float = 1f, val velocityScale: Double = 1.0, val positionScale: Float = 1f, - val repDelayMs: Long = 2_000L, + val repDelayMs: Long = 750L, val autoCompleteFixedRepSets: Boolean = true, ) { init { @@ -119,8 +121,10 @@ class PhantomBleRepository( ) private var metricsJob: Job? = null + private var heuristicJob: Job? = null private var repJob: Job? = null private var diagnosticJob: Job? = null + private var heartbeatJob: Job? = null private var workoutParams: WorkoutParameters? = null private val _config = MutableStateFlow(initialConfig) val config: StateFlow = _config.asStateFlow() @@ -160,6 +164,8 @@ class PhantomBleRepository( logRepo.info(LogEventType.CONNECT_SUCCESS, "Connected to phantom Vitruvian", device.name, device.address) startDiagnostics() startMetrics(activeWorkout = false) + startHeuristicGeneration(activeWorkout = false) + startHeartbeat() return Result.success(Unit) } @@ -175,6 +181,7 @@ class PhantomBleRepository( _handleDetection.value = HandleDetection() _handleState.value = HandleState.WaitingForRest _diagnostics.value = null + _discoModeActive.value = false setConnectionState(ConnectionState.Disconnected) } @@ -187,8 +194,41 @@ class PhantomBleRepository( } override suspend fun scanAndConnect(timeoutMs: Long): Result { - startScanning() - return connect(device) + if (timeoutMs <= 0L) { + return Result.failure(IllegalArgumentException("timeoutMs must be > 0")) + } + + return try { + withTimeout(timeoutMs) { + startScanning().getOrThrow() + connect(device).getOrThrow() + } + Result.success(Unit) + } catch (error: TimeoutCancellationException) { + stopJobs() + workoutParams = null + _diagnostics.value = null + _handleDetection.value = HandleDetection() + _handleState.value = HandleState.WaitingForRest + _discoModeActive.value = false + setConnectionState(ConnectionState.Disconnected) + logRepo.error( + LogEventType.ERROR, + "Phantom scan and connect timed out", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "timeoutMs=$timeoutMs", + ) + _reconnectionRequested.emit( + ReconnectionRequest( + deviceName = PHANTOM_DEVICE_NAME, + deviceAddress = PHANTOM_DEVICE_ADDRESS, + reason = "connection_timeout", + timestamp = Clock.System.now().toEpochMilliseconds(), + ), + ) + Result.failure(error) + } } override suspend fun setColorScheme(schemeIndex: Int): Result { @@ -214,6 +254,9 @@ class PhantomBleRepository( } override suspend fun startWorkout(params: WorkoutParameters): Result { + if (_discoModeActive.value) { + stopDiscoMode() + } workoutParams = params _handleState.value = HandleState.Grabbed logRepo.info( @@ -224,6 +267,7 @@ class PhantomBleRepository( "mode=${params.programMode}; reps=${params.reps}; weightPerCableKg=${params.weightPerCableKg}; justLift=${params.isJustLift}", ) startMetrics(activeWorkout = true) + startHeuristicGeneration(activeWorkout = true) startRepSimulation(params) return Result.success(Unit) } @@ -234,6 +278,7 @@ class PhantomBleRepository( workoutParams = null _handleState.value = HandleState.Released startMetrics(activeWorkout = false) + startHeuristicGeneration(activeWorkout = false) return Result.success(Unit) } @@ -264,26 +309,32 @@ class PhantomBleRepository( override fun startActiveWorkoutPolling() { _handleState.value = HandleState.Grabbed startMetrics(activeWorkout = true) + startHeuristicGeneration(activeWorkout = true) } override fun stopPolling() { metricsJob?.cancel() + heuristicJob?.cancel() repJob?.cancel() diagnosticJob?.cancel() + heartbeatJob?.cancel() logRepo.info(LogEventType.HEARTBEAT, "Phantom polling stopped") } override fun stopMonitorPollingOnly() { metricsJob?.cancel() - repJob?.cancel() logRepo.info(LogEventType.HEARTBEAT, "Phantom monitor polling stopped; diagnostics kept warm") } override fun restartDiagnosticPolling() { startDiagnostics() + startHeartbeat() } override fun startDiscoMode() { + if (_connectionState.value !is ConnectionState.Connected || workoutParams != null) { + return + } _discoModeActive.value = true logRepo.info(LogEventType.COMMAND_SENT, "Phantom disco mode started", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) } @@ -400,6 +451,7 @@ class PhantomBleRepository( ) if (_connectionState.value is ConnectionState.Connected) { startMetrics(activeWorkout = workoutParams != null) + startHeuristicGeneration(activeWorkout = workoutParams != null) workoutParams?.let(::startRepSimulation) } } @@ -431,25 +483,42 @@ class PhantomBleRepository( status = 0, ) _metricsFlow.emit(metric) - if (sample % 8 == 0) { - _heuristicData.value = HeuristicStatistics( - concentric = HeuristicPhaseStatistics(load, load + 1.5f, 0.42f, 0.70f, 85f, 130f), - eccentric = HeuristicPhaseStatistics(load * 0.9f, load + 1f, 0.38f, 0.62f, 72f, 110f), - ) - logRepo.debug( - LogEventType.NOTIFICATION, - "Phantom monitor metric", - PHANTOM_DEVICE_NAME, - PHANTOM_DEVICE_ADDRESS, - "ticks=${metric.ticks}; load=${metric.totalLoad}; posA=${metric.positionA}", - ) - } + logRepo.debug( + LogEventType.NOTIFICATION, + "Phantom monitor metric", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "ticks=${metric.ticks}; load=${metric.totalLoad}; posA=${metric.positionA}", + ) sample++ delay(if (activeWorkout) 250 else 750) } } } + private fun startHeuristicGeneration(activeWorkout: Boolean) { + heuristicJob?.cancel() + heuristicJob = scope.launch { + while (isActive && connectionState.value is ConnectionState.Connected) { + val config = _config.value + val configuredLoad = workoutParams?.weightPerCableKg ?: 7.5f + val load = (if (activeWorkout) configuredLoad.coerceAtLeast(2f) else 1.5f) * config.loadScale + _heuristicData.value = HeuristicStatistics( + concentric = HeuristicPhaseStatistics(load, load + 1.5f, 0.42f, 0.70f, 85f, 130f), + eccentric = HeuristicPhaseStatistics(load * 0.9f, load + 1f, 0.38f, 0.62f, 72f, 110f), + timestamp = Clock.System.now().toEpochMilliseconds(), + ) + logRepo.debug( + LogEventType.NOTIFICATION, + "Phantom heuristic update", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + ) + delay(if (activeWorkout) 250 else 750) + } + } + } + private fun startRepSimulation(params: WorkoutParameters) { repJob?.cancel() repJob = scope.launch { @@ -514,13 +583,27 @@ class PhantomBleRepository( } } + private fun startHeartbeat() { + heartbeatJob?.cancel() + heartbeatJob = scope.launch { + while (isActive && connectionState.value is ConnectionState.Connected) { + logRepo.info(LogEventType.HEARTBEAT, "Phantom heartbeat", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + delay(2_000) + } + } + } + private fun stopJobs() { metricsJob?.cancel() + heuristicJob?.cancel() repJob?.cancel() diagnosticJob?.cancel() + heartbeatJob?.cancel() metricsJob = null + heuristicJob = null repJob = null diagnosticJob = null + heartbeatJob = null } companion object { diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index af5117fb7..9491abbdf 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -2,15 +2,26 @@ package com.devil.phoenixproject.data.repository import app.cash.turbine.test import com.devil.phoenixproject.domain.model.ConnectionState +import com.devil.phoenixproject.domain.model.ProgramMode +import com.devil.phoenixproject.domain.model.WorkoutParameters import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertFailsWith +import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext import kotlinx.coroutines.test.runTest class PhantomBleRepositoryTest { + @Test + fun `config defaults to 750 millisecond rep delay`() { + assertEquals(750L, PhantomBleConfig().repDelayMs) + } + @Test fun `config rejects non-positive load scale`() { assertFailsWith { @@ -150,6 +161,138 @@ class PhantomBleRepositoryTest { } } + @Test + fun `stopMonitorPollingOnly stops metrics without stopping other polling`() = runTest { + val repository = PhantomBleRepository( + ConnectionLogRepository(), + PhantomBleConfig(repDelayMs = 100L), + ) + + try { + repository.metricsFlow.test { + assertTrue(repository.scanAndConnect().isSuccess) + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + var metric = awaitItem() + while (metric.loadA < 2f) { + metric = awaitItem() + } + + repository.stopMonitorPollingOnly() + + delay(500L) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + + @Test + fun `stopMonitorPollingOnly keeps rep heuristic diagnostics and heartbeat active`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo, PhantomBleConfig(repDelayMs = 100L)) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + repository.repEvents.test { + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + withContext(Dispatchers.Default) { delay(50L) } + assertTrue(repository.heuristicData.value != null) + val heuristicTimestamp = requireNotNull(repository.heuristicData.value).timestamp + val diagnosticTimestamp = requireNotNull(repository.diagnostics.value).receivedAtMillis + + repository.stopMonitorPollingOnly() + val heartbeatCountAfterStop = logRepo.getLogsByEventType(LogEventType.HEARTBEAT).size + + withContext(Dispatchers.Default) { delay(300L) } + assertTrue(logRepo.getLogsByEventType(LogEventType.REP_RECEIVED).isNotEmpty()) + awaitItem() + assertTrue(requireNotNull(repository.heuristicData.value).timestamp > heuristicTimestamp) + withContext(Dispatchers.Default) { delay(2_200L) } + assertTrue(requireNotNull(repository.diagnostics.value).receivedAtMillis > diagnosticTimestamp) + assertTrue(logRepo.getLogsByEventType(LogEventType.HEARTBEAT).size > heartbeatCountAfterStop) + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + + @Test + fun `scanAndConnect timeout returns failure and requests reconnection`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + + try { + repository.reconnectionRequested.test { + val result = repository.scanAndConnect(timeoutMs = 50L) + + assertTrue(result.isFailure) + val request = awaitItem() + assertEquals(PhantomBleRepository.PHANTOM_DEVICE_NAME, request.deviceName) + assertEquals(PhantomBleRepository.PHANTOM_DEVICE_ADDRESS, request.deviceAddress) + assertEquals("connection_timeout", request.reason) + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + + @Test + fun `disco mode requires connection and clears on workout and disconnect`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + + try { + repository.startDiscoMode() + assertFalse(repository.discoModeActive.value) + + assertTrue(repository.scanAndConnect().isSuccess) + repository.startDiscoMode() + assertTrue(repository.discoModeActive.value) + + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + assertFalse(repository.discoModeActive.value) + + repository.startDiscoMode() + assertFalse(repository.discoModeActive.value) + repository.disconnect() + assertFalse(repository.discoModeActive.value) + } finally { + repository.shutdown() + } + } + + @Test + fun `shutdown cancels polling jobs and clears lifecycle state`() = runTest { + val repository = PhantomBleRepository( + ConnectionLogRepository(), + PhantomBleConfig(repDelayMs = 100L), + ) + + assertTrue(repository.scanAndConnect().isSuccess) + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + withContext(Dispatchers.Default) { delay(50L) } + assertFalse(repository.discoModeActive.value) + assertTrue(repository.diagnostics.value != null) + + repository.shutdown() + withContext(Dispatchers.Default) { delay(350L) } + + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertFalse(repository.handleDetection.value.leftDetected) + assertFalse(repository.handleDetection.value.rightDetected) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + assertFalse(repository.discoModeActive.value) + assertNull(repository.diagnostics.value) + } + + private fun workoutParameters(): WorkoutParameters = WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 3, + weightPerCableKg = 10f, + ) + private fun monitorPacket( ticks: Int, posA: Int, From 1ec4ae07d205f0da91c3e6318afd41a834ed375f Mon Sep 17 00:00:00 2001 From: Devil Date: Thu, 16 Jul 2026 17:27:22 -0400 Subject: [PATCH 04/54] fix(ios): harden Phantom cancellation lifecycle --- .../data/repository/PhantomBleRepository.kt | 99 +++++++++++-------- .../repository/PhantomBleRepositoryTest.kt | 45 +++++++++ 2 files changed, 101 insertions(+), 43 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 833a4d86b..288322613 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -6,11 +6,13 @@ import com.devil.phoenixproject.data.ble.parseDiagnosticPacket import com.devil.phoenixproject.data.ble.parseHeuristicPacket import com.devil.phoenixproject.data.ble.parseMonitorPacket import com.devil.phoenixproject.data.ble.parseRepPacket +import com.devil.phoenixproject.data.ble.toVitruvianHex import com.devil.phoenixproject.domain.model.ConnectionState import com.devil.phoenixproject.domain.model.HeuristicPhaseStatistics import com.devil.phoenixproject.domain.model.HeuristicStatistics import com.devil.phoenixproject.domain.model.WorkoutMetric import com.devil.phoenixproject.domain.model.WorkoutParameters +import kotlin.coroutines.cancellation.CancellationException import kotlin.math.PI import kotlin.math.sin import kotlin.time.Clock @@ -18,7 +20,6 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow @@ -28,7 +29,7 @@ import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.withTimeoutOrNull enum class PhantomRawPacketKind { MONITOR, @@ -130,6 +131,7 @@ class PhantomBleRepository( val config: StateFlow = _config.asStateFlow() private var ticks = 0L private var lastColorSchemeIndex = 0 + private var connectionAttemptGeneration = 0L override suspend fun startScanning(): Result { logRepo.info(LogEventType.SCAN_START, "Starting phantom Vitruvian scan") @@ -154,9 +156,13 @@ class PhantomBleRepository( } override suspend fun connect(device: ScannedDevice): Result { + val attemptGeneration = ++connectionAttemptGeneration logRepo.info(LogEventType.CONNECT_START, "Connecting to phantom Vitruvian", device.name, device.address) setConnectionState(ConnectionState.Connecting) delay(250) + if (attemptGeneration != connectionAttemptGeneration) { + return Result.failure(IllegalStateException("Phantom connection attempt invalidated")) + } setConnectionState(ConnectionState.Connected(device.name, device.address)) _handleDetection.value = HandleDetection(leftDetected = true, rightDetected = true) _handleState.value = HandleState.Released @@ -170,17 +176,21 @@ class PhantomBleRepository( } override suspend fun cancelConnection() { + connectionAttemptGeneration++ logRepo.warning(LogEventType.DISCONNECT, "Cancelled phantom connection") setConnectionState(ConnectionState.Disconnected) } override suspend fun disconnect() { + connectionAttemptGeneration++ logRepo.info(LogEventType.DISCONNECT, "Disconnected phantom Vitruvian", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) stopJobs() workoutParams = null _handleDetection.value = HandleDetection() _handleState.value = HandleState.WaitingForRest _diagnostics.value = null + _heuristicData.value = null + _scannedDevices.value = emptyList() _discoModeActive.value = false setConnectionState(ConnectionState.Disconnected) } @@ -198,37 +208,40 @@ class PhantomBleRepository( return Result.failure(IllegalArgumentException("timeoutMs must be > 0")) } - return try { - withTimeout(timeoutMs) { - startScanning().getOrThrow() - connect(device).getOrThrow() - } - Result.success(Unit) - } catch (error: TimeoutCancellationException) { - stopJobs() - workoutParams = null - _diagnostics.value = null - _handleDetection.value = HandleDetection() - _handleState.value = HandleState.WaitingForRest - _discoModeActive.value = false - setConnectionState(ConnectionState.Disconnected) - logRepo.error( - LogEventType.ERROR, - "Phantom scan and connect timed out", - PHANTOM_DEVICE_NAME, - PHANTOM_DEVICE_ADDRESS, - "timeoutMs=$timeoutMs", - ) - _reconnectionRequested.emit( - ReconnectionRequest( - deviceName = PHANTOM_DEVICE_NAME, - deviceAddress = PHANTOM_DEVICE_ADDRESS, - reason = "connection_timeout", - timestamp = Clock.System.now().toEpochMilliseconds(), - ), - ) - Result.failure(error) + val completed = withTimeoutOrNull(timeoutMs) { + startScanning().getOrThrow() + connect(device).getOrThrow() + } + if (completed != null) { + return Result.success(Unit) } + + connectionAttemptGeneration++ + stopJobs() + workoutParams = null + _diagnostics.value = null + _heuristicData.value = null + _scannedDevices.value = emptyList() + _handleDetection.value = HandleDetection() + _handleState.value = HandleState.WaitingForRest + _discoModeActive.value = false + setConnectionState(ConnectionState.Disconnected) + logRepo.error( + LogEventType.ERROR, + "Phantom scan and connect timed out", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "timeoutMs=$timeoutMs", + ) + _reconnectionRequested.emit( + ReconnectionRequest( + deviceName = PHANTOM_DEVICE_NAME, + deviceAddress = PHANTOM_DEVICE_ADDRESS, + reason = "connection_timeout", + timestamp = Clock.System.now().toEpochMilliseconds(), + ), + ) + return Result.failure(IllegalStateException("Phantom scan and connect timed out after ${timeoutMs}ms")) } override suspend fun setColorScheme(schemeIndex: Int): Result { @@ -362,21 +375,25 @@ class PhantomBleRepository( kind: PhantomRawPacketKind, data: ByteArray, hasOpcodePrefix: Boolean = false, - ): Result = runCatching { + ): Result = try { when (kind) { PhantomRawPacketKind.MONITOR -> injectMonitorPacket(data) PhantomRawPacketKind.REP -> injectRepPacket(data, hasOpcodePrefix) PhantomRawPacketKind.DIAGNOSTIC -> injectDiagnosticPacket(data) PhantomRawPacketKind.HEURISTIC -> injectHeuristicPacket(data) } - }.onFailure { error -> + Result.success(Unit) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { logRepo.error( LogEventType.ERROR, "Phantom raw ${kind.name.lowercase()} packet rejected", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS, - "${error.message}; hex=${data.toHexString()}", + "${error.message}; hex=${data.joinToString(" ") { it.toVitruvianHex() }}", ) + Result.failure(error) } private suspend fun injectMonitorPacket(data: ByteArray) { @@ -390,7 +407,7 @@ class PhantomBleRepository( "Phantom injected raw monitor packet", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS, - "ticks=${metric.ticks}; load=${metric.totalLoad}; posA=${metric.positionA}; hex=${data.toHexString()}", + "ticks=${metric.ticks}; load=${metric.totalLoad}; posA=${metric.positionA}; hex=${data.joinToString(" ") { it.toVitruvianHex() }}", ) } @@ -404,7 +421,7 @@ class PhantomBleRepository( "Phantom injected raw rep packet", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS, - "top=${rep.topCounter}; complete=${rep.completeCounter}; legacy=${rep.isLegacyFormat}; hex=${data.toHexString()}", + "top=${rep.topCounter}; complete=${rep.completeCounter}; legacy=${rep.isLegacyFormat}; hex=${data.joinToString(" ") { it.toVitruvianHex() }}", ) } @@ -418,7 +435,7 @@ class PhantomBleRepository( "Phantom injected raw diagnostic packet", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS, - "faults=${diagnostic.faultWords}; temps=${diagnostic.temperatures}; hex=${data.toHexString()}", + "faults=${diagnostic.faultWords}; temps=${diagnostic.temperatures}; hex=${data.joinToString(" ") { it.toVitruvianHex() }}", ) } @@ -432,14 +449,10 @@ class PhantomBleRepository( "Phantom injected raw heuristic packet", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS, - "conKgAvg=${heuristic.concentric.kgAvg}; eccKgAvg=${heuristic.eccentric.kgAvg}; hex=${data.toHexString()}", + "conKgAvg=${heuristic.concentric.kgAvg}; eccKgAvg=${heuristic.eccentric.kgAvg}; hex=${data.joinToString(" ") { it.toVitruvianHex() }}", ) } - private fun ByteArray.toHexString(): String = joinToString(" ") { byte -> - byte.toUByte().toString(16).padStart(2, '0') - } - fun replaceConfig(config: PhantomBleConfig) { _config.value = config logRepo.info( diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 9491abbdf..f3ba76c90 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -11,8 +11,12 @@ import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlin.test.assertTrue import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout import kotlinx.coroutines.test.runTest class PhantomBleRepositoryTest { @@ -56,6 +60,26 @@ class PhantomBleRepositoryTest { } } + @Test + fun `cancelConnection invalidates an in-flight connect`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val connecting = async(Dispatchers.Default) { + repository.connect(ScannedDevice("race", "race-address")) + } + + try { + repository.connectionState.first { it == ConnectionState.Connecting } + repository.cancelConnection() + + val result = connecting.await() + + assertTrue(result.isFailure) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + } finally { + repository.shutdown() + } + } + @Test fun `injectRawPacket parses legacy rep bytes through protocol parser`() = runTest { val repository = PhantomBleRepository(ConnectionLogRepository()) @@ -239,6 +263,23 @@ class PhantomBleRepositoryTest { } } + @Test + fun `scanAndConnect propagates caller timeout without requesting reconnection`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + + try { + assertFailsWith { + withTimeout(50L) { + repository.scanAndConnect(timeoutMs = 10_000L) + } + } + assertTrue(logRepo.getLogsByEventType(LogEventType.ERROR).isEmpty()) + } finally { + repository.shutdown() + } + } + @Test fun `disco mode requires connection and clears on workout and disconnect`() = runTest { val repository = PhantomBleRepository(ConnectionLogRepository()) @@ -274,7 +315,9 @@ class PhantomBleRepositoryTest { assertTrue(repository.startWorkout(workoutParameters()).isSuccess) withContext(Dispatchers.Default) { delay(50L) } assertFalse(repository.discoModeActive.value) + assertTrue(repository.scannedDevices.value.isNotEmpty()) assertTrue(repository.diagnostics.value != null) + assertTrue(repository.heuristicData.value != null) repository.shutdown() withContext(Dispatchers.Default) { delay(350L) } @@ -285,6 +328,8 @@ class PhantomBleRepositoryTest { assertEquals(HandleState.WaitingForRest, repository.handleState.value) assertFalse(repository.discoModeActive.value) assertNull(repository.diagnostics.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + assertNull(repository.heuristicData.value) } private fun workoutParameters(): WorkoutParameters = WorkoutParameters( From d40305945829294cc219035a5d8aa2753ba081ec Mon Sep 17 00:00:00 2001 From: Devil Date: Thu, 16 Jul 2026 17:49:32 -0400 Subject: [PATCH 05/54] fix(ios): close Phantom terminal lifecycle gate --- .../data/repository/PhantomBleRepository.kt | 241 ++++++++++++------ .../repository/PhantomBleRepositoryTest.kt | 98 +++++++ 2 files changed, 260 insertions(+), 79 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 288322613..3342536d6 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -30,6 +30,9 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.atomicfu.atomic +import kotlinx.atomicfu.locks.reentrantLock +import kotlinx.atomicfu.locks.withLock enum class PhantomRawPacketKind { MONITOR, @@ -131,13 +134,21 @@ class PhantomBleRepository( val config: StateFlow = _config.asStateFlow() private var ticks = 0L private var lastColorSchemeIndex = 0 - private var connectionAttemptGeneration = 0L + private val lifecycleLock = reentrantLock() + private val terminal = atomic(false) + private val connectionAttemptGeneration = atomic(0L) override suspend fun startScanning(): Result { + val attemptGeneration = beginConnectionAttempt() + ?: return Result.failure(IllegalStateException("Phantom repository is shut down")) logRepo.info(LogEventType.SCAN_START, "Starting phantom Vitruvian scan") - setConnectionState(ConnectionState.Scanning) + if (!publishConnectionState(attemptGeneration, ConnectionState.Scanning)) { + return Result.failure(IllegalStateException("Phantom scan attempt invalidated")) + } delay(150) - _scannedDevices.value = listOf(device) + if (!publishScannedDevices(attemptGeneration, listOf(device))) { + return Result.failure(IllegalStateException("Phantom scan attempt invalidated")) + } logRepo.info( LogEventType.DEVICE_FOUND, "Found phantom Vitruvian device", @@ -149,58 +160,48 @@ class PhantomBleRepository( } override suspend fun stopScanning() { - if (_connectionState.value == ConnectionState.Scanning) { - setConnectionState(ConnectionState.Disconnected) + lifecycleLock.withLock { + connectionAttemptGeneration.incrementAndGet() + if (_connectionState.value == ConnectionState.Scanning) { + _connectionState.value = ConnectionState.Disconnected + } } logRepo.info(LogEventType.SCAN_STOP, "Stopped phantom Vitruvian scan") } override suspend fun connect(device: ScannedDevice): Result { - val attemptGeneration = ++connectionAttemptGeneration + val attemptGeneration = beginConnectionAttempt() + ?: return Result.failure(IllegalStateException("Phantom repository is shut down")) logRepo.info(LogEventType.CONNECT_START, "Connecting to phantom Vitruvian", device.name, device.address) - setConnectionState(ConnectionState.Connecting) + if (!publishConnectionState(attemptGeneration, ConnectionState.Connecting)) { + return Result.failure(IllegalStateException("Phantom connection attempt invalidated")) + } delay(250) - if (attemptGeneration != connectionAttemptGeneration) { + if (!completeConnection(attemptGeneration, device)) { return Result.failure(IllegalStateException("Phantom connection attempt invalidated")) } - setConnectionState(ConnectionState.Connected(device.name, device.address)) - _handleDetection.value = HandleDetection(leftDetected = true, rightDetected = true) - _handleState.value = HandleState.Released - logRepo.info(LogEventType.SERVICE_DISCOVERED, "Phantom service map ready", device.name, device.address) - logRepo.info(LogEventType.CONNECT_SUCCESS, "Connected to phantom Vitruvian", device.name, device.address) - startDiagnostics() - startMetrics(activeWorkout = false) - startHeuristicGeneration(activeWorkout = false) - startHeartbeat() return Result.success(Unit) } override suspend fun cancelConnection() { - connectionAttemptGeneration++ + lifecycleLock.withLock { + connectionAttemptGeneration.incrementAndGet() + if (!terminal.value) { + _connectionState.value = ConnectionState.Disconnected + } + } logRepo.warning(LogEventType.DISCONNECT, "Cancelled phantom connection") - setConnectionState(ConnectionState.Disconnected) } override suspend fun disconnect() { - connectionAttemptGeneration++ logRepo.info(LogEventType.DISCONNECT, "Disconnected phantom Vitruvian", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) - stopJobs() - workoutParams = null - _handleDetection.value = HandleDetection() - _handleState.value = HandleState.WaitingForRest - _diagnostics.value = null - _heuristicData.value = null - _scannedDevices.value = emptyList() - _discoModeActive.value = false - setConnectionState(ConnectionState.Disconnected) + teardownConnection() } override suspend fun shutdown() { - try { - disconnect() - } finally { - repositoryJob.cancel() - } + logRepo.info(LogEventType.DISCONNECT, "Disconnected phantom Vitruvian", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + teardownConnection(markTerminal = true) + repositoryJob.cancel() } override suspend fun scanAndConnect(timeoutMs: Long): Result { @@ -216,16 +217,7 @@ class PhantomBleRepository( return Result.success(Unit) } - connectionAttemptGeneration++ - stopJobs() - workoutParams = null - _diagnostics.value = null - _heuristicData.value = null - _scannedDevices.value = emptyList() - _handleDetection.value = HandleDetection() - _handleState.value = HandleState.WaitingForRest - _discoModeActive.value = false - setConnectionState(ConnectionState.Disconnected) + teardownConnection() logRepo.error( LogEventType.ERROR, "Phantom scan and connect timed out", @@ -267,22 +259,27 @@ class PhantomBleRepository( } override suspend fun startWorkout(params: WorkoutParameters): Result { - if (_discoModeActive.value) { - stopDiscoMode() + return lifecycleLock.withLock { + if (terminal.value) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } + if (_discoModeActive.value) { + stopDiscoMode() + } + workoutParams = params + _handleState.value = HandleState.Grabbed + logRepo.info( + LogEventType.COMMAND_SENT, + "Phantom workout started", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "mode=${params.programMode}; reps=${params.reps}; weightPerCableKg=${params.weightPerCableKg}; justLift=${params.isJustLift}", + ) + startMetrics(activeWorkout = true) + startHeuristicGeneration(activeWorkout = true) + startRepSimulation(params) + Result.success(Unit) } - workoutParams = params - _handleState.value = HandleState.Grabbed - logRepo.info( - LogEventType.COMMAND_SENT, - "Phantom workout started", - PHANTOM_DEVICE_NAME, - PHANTOM_DEVICE_ADDRESS, - "mode=${params.programMode}; reps=${params.reps}; weightPerCableKg=${params.weightPerCableKg}; justLift=${params.isJustLift}", - ) - startMetrics(activeWorkout = true) - startHeuristicGeneration(activeWorkout = true) - startRepSimulation(params) - return Result.success(Unit) } override suspend fun stopWorkout(): Result { @@ -316,13 +313,22 @@ class PhantomBleRepository( } override fun restartMonitorPolling() { - startMetrics(activeWorkout = workoutParams != null) + lifecycleLock.withLock { + if (!terminal.value) { + startMetrics(activeWorkout = workoutParams != null) + } + } } override fun startActiveWorkoutPolling() { - _handleState.value = HandleState.Grabbed - startMetrics(activeWorkout = true) - startHeuristicGeneration(activeWorkout = true) + lifecycleLock.withLock { + if (terminal.value) { + return@withLock + } + _handleState.value = HandleState.Grabbed + startMetrics(activeWorkout = true) + startHeuristicGeneration(activeWorkout = true) + } } override fun stopPolling() { @@ -340,8 +346,12 @@ class PhantomBleRepository( } override fun restartDiagnosticPolling() { - startDiagnostics() - startHeartbeat() + lifecycleLock.withLock { + if (!terminal.value) { + startDiagnostics() + startHeartbeat() + } + } } override fun startDiscoMode() { @@ -469,8 +479,73 @@ class PhantomBleRepository( } } - private fun setConnectionState(state: ConnectionState) { - _connectionState.value = state + private fun beginConnectionAttempt(): Long? = lifecycleLock.withLock { + if (terminal.value) { + null + } else { + connectionAttemptGeneration.incrementAndGet() + } + } + + private fun publishConnectionState(attemptGeneration: Long, state: ConnectionState): Boolean = lifecycleLock.withLock { + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + false + } else { + _connectionState.value = state + true + } + } + + private fun publishScannedDevices(attemptGeneration: Long, devices: List): Boolean = lifecycleLock.withLock { + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + false + } else { + _scannedDevices.value = devices + true + } + } + + private fun completeConnection(attemptGeneration: Long, device: ScannedDevice): Boolean = lifecycleLock.withLock { + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + return@withLock false + } + _connectionState.value = ConnectionState.Connected(device.name, device.address) + _handleDetection.value = HandleDetection(leftDetected = true, rightDetected = true) + _handleState.value = HandleState.Released + logRepo.info(LogEventType.SERVICE_DISCOVERED, "Phantom service map ready", device.name, device.address) + logRepo.info(LogEventType.CONNECT_SUCCESS, "Connected to phantom Vitruvian", device.name, device.address) + startDiagnostics() + startMetrics(activeWorkout = false) + startHeuristicGeneration(activeWorkout = false) + startHeartbeat() + true + } + + private fun teardownConnection(markTerminal: Boolean = false) { + lifecycleLock.withLock { + if (markTerminal) { + terminal.value = true + } + connectionAttemptGeneration.incrementAndGet() + stopJobs() + workoutParams = null + _handleDetection.value = HandleDetection() + _handleState.value = HandleState.WaitingForRest + _diagnostics.value = null + _heuristicData.value = null + _scannedDevices.value = emptyList() + _discoModeActive.value = false + _connectionState.value = ConnectionState.Disconnected + } + } + + private inline fun publishIfConnected(publish: () -> Unit): Boolean = lifecycleLock.withLock { + if (terminal.value || _connectionState.value !is ConnectionState.Connected) { + false + } else { + publish() + true + } } private fun startMetrics(activeWorkout: Boolean) { @@ -516,11 +591,15 @@ class PhantomBleRepository( val config = _config.value val configuredLoad = workoutParams?.weightPerCableKg ?: 7.5f val load = (if (activeWorkout) configuredLoad.coerceAtLeast(2f) else 1.5f) * config.loadScale - _heuristicData.value = HeuristicStatistics( - concentric = HeuristicPhaseStatistics(load, load + 1.5f, 0.42f, 0.70f, 85f, 130f), - eccentric = HeuristicPhaseStatistics(load * 0.9f, load + 1f, 0.38f, 0.62f, 72f, 110f), - timestamp = Clock.System.now().toEpochMilliseconds(), - ) + if (!publishIfConnected { + _heuristicData.value = HeuristicStatistics( + concentric = HeuristicPhaseStatistics(load, load + 1.5f, 0.42f, 0.70f, 85f, 130f), + eccentric = HeuristicPhaseStatistics(load * 0.9f, load + 1f, 0.38f, 0.62f, 72f, 110f), + timestamp = Clock.System.now().toEpochMilliseconds(), + ) + }) { + break + } logRepo.debug( LogEventType.NOTIFICATION, "Phantom heuristic update", @@ -583,13 +662,17 @@ class PhantomBleRepository( val connectedAt = Clock.System.now().toEpochMilliseconds() while (isActive && connectionState.value is ConnectionState.Connected) { val now = Clock.System.now().toEpochMilliseconds() - _diagnostics.value = DiagnosticPacket( - runtimeSeconds = (now - connectedAt) / 1000, - faultWords = listOf(0, 0, 0, 0), - temperatures = listOf(34, 35, 34, 35, 36, 36, 35, 34), - hasFaults = false, - receivedAtMillis = now, - ) + if (!publishIfConnected { + _diagnostics.value = DiagnosticPacket( + runtimeSeconds = (now - connectedAt) / 1000, + faultWords = listOf(0, 0, 0, 0), + temperatures = listOf(34, 35, 34, 35, 36, 36, 35, 34), + hasFaults = false, + receivedAtMillis = now, + ) + }) { + break + } logRepo.debug(LogEventType.DIAGNOSTIC, "Phantom diagnostic heartbeat", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) delay(2_000) } diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index f3ba76c90..802add100 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -80,6 +80,47 @@ class PhantomBleRepositoryTest { } } + @Test + fun `shutdown wins over an in-flight scan`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val scanning = async(Dispatchers.Default) { repository.startScanning() } + + try { + repository.connectionState.first { it == ConnectionState.Scanning } + repository.shutdown() + + assertTrue(scanning.await().isFailure) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + assertNull(repository.heuristicData.value) + assertNull(repository.diagnostics.value) + assertFalse(repository.connectionState.value is ConnectionState.Connected) + } finally { + repository.shutdown() + } + } + + @Test + fun `shutdown wins over a racing connect`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val connecting = async(Dispatchers.Default) { + repository.connect(ScannedDevice("race", "race-address")) + } + + try { + repository.shutdown() + + assertTrue(connecting.await().isFailure) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + assertNull(repository.heuristicData.value) + assertNull(repository.diagnostics.value) + assertFalse(repository.connectionState.value is ConnectionState.Connected) + } finally { + repository.shutdown() + } + } + @Test fun `injectRawPacket parses legacy rep bytes through protocol parser`() = runTest { val repository = PhantomBleRepository(ConnectionLogRepository()) @@ -103,6 +144,55 @@ class PhantomBleRepositoryTest { } } + @Test + fun `injectRawPacket parses opcode-prefixed rep bytes through protocol parser`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val raw = byteArrayOf( + 0x02, // opcode prefix + 0x05, 0x00, 0x00, 0x00, // topCounter = 5 + 0x04, 0x00, 0x00, 0x00, // completeCounter = 4 + 0x00, 0x00, 0x80.toByte(), 0x3F, // rangeTop = 1.0f + 0x00, 0x00, 0x00, 0x00, // rangeBottom = 0.0f + 0x01, 0x00, // repsRomCount = 1 + 0x02, 0x00, // repsRomTotal = 2 + 0x03, 0x00, // repsSetCount = 3 + 0x04, 0x00, // repsSetTotal = 4 + ) + + repository.repEvents.test { + val result = repository.injectRawPacket(PhantomRawPacketKind.REP, raw, hasOpcodePrefix = true) + assertTrue(result.isSuccess) + + val event = awaitItem() + assertEquals(5, event.topCounter) + assertEquals(4, event.completeCounter) + assertEquals(1, event.repsRomCount) + assertEquals(3, event.repsSetCount) + assertFalse(event.isLegacyFormat) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `injectRawPacket parses heuristic bytes into heuristic state`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val raw = ByteArray(48).also { bytes -> + putFloatLE(bytes, 0, 50f) + putFloatLE(bytes, 4, 80f) + putFloatLE(bytes, 24, 40f) + putFloatLE(bytes, 28, 60f) + } + + val result = repository.injectRawPacket(PhantomRawPacketKind.HEURISTIC, raw) + + assertTrue(result.isSuccess) + val heuristic = requireNotNull(repository.heuristicData.value) + assertEquals(50f, heuristic.concentric.kgAvg) + assertEquals(80f, heuristic.concentric.kgMax) + assertEquals(40f, heuristic.eccentric.kgAvg) + assertEquals(60f, heuristic.eccentric.kgMax) + } + @Test fun `injectRawPacket parses monitor bytes through monitor processor`() = runTest { val repository = PhantomBleRepository(ConnectionLogRepository()) @@ -375,4 +465,12 @@ class PhantomBleRepositoryTest { val encoded = value and 0xFFFF putUInt16LE(bytes, offset, encoded) } + + private fun putFloatLE(bytes: ByteArray, offset: Int, value: Float) { + val bits = value.toBits() + bytes[offset] = bits.toByte() + bytes[offset + 1] = (bits ushr 8).toByte() + bytes[offset + 2] = (bits ushr 16).toByte() + bytes[offset + 3] = (bits ushr 24).toByte() + } } From 964c6f732a2ca61f6240e2ad48425fc89dc02b4c Mon Sep 17 00:00:00 2001 From: Devil Date: Thu, 16 Jul 2026 18:04:05 -0400 Subject: [PATCH 06/54] fix(ios): guard Phantom terminal state mutations --- .../data/repository/PhantomBleRepository.kt | 81 ++++++++++++++----- .../repository/PhantomBleRepositoryTest.kt | 71 ++++++++++++++++ 2 files changed, 130 insertions(+), 22 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 3342536d6..cc53f6742 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -298,18 +298,32 @@ class PhantomBleRepository( } override fun enableHandleDetection(enabled: Boolean) { - _handleDetection.value = HandleDetection(leftDetected = enabled, rightDetected = enabled) - _handleState.value = if (enabled) HandleState.WaitingForRest else HandleState.Released - logRepo.info(LogEventType.NOTIFICATION, "Phantom handle detection ${if (enabled) "enabled" else "disabled"}") + lifecycleLock.withLock { + if (terminal.value) { + return@withLock + } + _handleDetection.value = HandleDetection(leftDetected = enabled, rightDetected = enabled) + _handleState.value = if (enabled) HandleState.WaitingForRest else HandleState.Released + logRepo.info(LogEventType.NOTIFICATION, "Phantom handle detection ${if (enabled) "enabled" else "disabled"}") + } } override fun resetHandleState() { - _handleState.value = HandleState.WaitingForRest + lifecycleLock.withLock { + if (!terminal.value) { + _handleState.value = HandleState.WaitingForRest + } + } } override fun enableJustLiftWaitingMode() { - _handleState.value = HandleState.WaitingForRest - logRepo.info(LogEventType.NOTIFICATION, "Phantom Just Lift waiting mode armed") + lifecycleLock.withLock { + if (terminal.value) { + return@withLock + } + _handleState.value = HandleState.WaitingForRest + logRepo.info(LogEventType.NOTIFICATION, "Phantom Just Lift waiting mode armed") + } } override fun restartMonitorPolling() { @@ -355,22 +369,29 @@ class PhantomBleRepository( } override fun startDiscoMode() { - if (_connectionState.value !is ConnectionState.Connected || workoutParams != null) { - return + lifecycleLock.withLock { + if (terminal.value || _connectionState.value !is ConnectionState.Connected || workoutParams != null) { + return@withLock + } + _discoModeActive.value = true + logRepo.info(LogEventType.COMMAND_SENT, "Phantom disco mode started", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) } - _discoModeActive.value = true - logRepo.info(LogEventType.COMMAND_SENT, "Phantom disco mode started", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) } override fun stopDiscoMode() { - _discoModeActive.value = false - logRepo.info( - LogEventType.COMMAND_SENT, - "Phantom disco mode stopped", - PHANTOM_DEVICE_NAME, - PHANTOM_DEVICE_ADDRESS, - "restoredScheme=$lastColorSchemeIndex", - ) + lifecycleLock.withLock { + if (terminal.value) { + return@withLock + } + _discoModeActive.value = false + logRepo.info( + LogEventType.COMMAND_SENT, + "Phantom disco mode stopped", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "restoredScheme=$lastColorSchemeIndex", + ) + } } override fun setLastColorSchemeIndex(index: Int) { @@ -411,7 +432,11 @@ class PhantomBleRepository( ?: error("monitor packet too short: ${data.size} bytes") val metric = monitorProcessor.process(packet) ?: error("monitor packet parsed but was rejected by validation") - _metricsFlow.emit(metric) + lifecycleLock.withLock { + if (!terminal.value) { + _metricsFlow.tryEmit(metric) + } + } logRepo.info( LogEventType.NOTIFICATION, "Phantom injected raw monitor packet", @@ -425,7 +450,11 @@ class PhantomBleRepository( val timestamp = Clock.System.now().toEpochMilliseconds() val rep = parseRepPacket(data, hasOpcodePrefix, timestamp) ?: error("rep packet too short: ${data.size} bytes") - _repEvents.emit(rep) + lifecycleLock.withLock { + if (!terminal.value) { + _repEvents.tryEmit(rep) + } + } logRepo.info( LogEventType.REP_RECEIVED, "Phantom injected raw rep packet", @@ -439,7 +468,11 @@ class PhantomBleRepository( val timestamp = Clock.System.now().toEpochMilliseconds() val diagnostic = parseDiagnosticPacket(data) ?: error("diagnostic packet too short: ${data.size} bytes") - _diagnostics.value = diagnostic.copy(receivedAtMillis = timestamp) + lifecycleLock.withLock { + if (!terminal.value) { + _diagnostics.value = diagnostic.copy(receivedAtMillis = timestamp) + } + } logRepo.info( LogEventType.DIAGNOSTIC, "Phantom injected raw diagnostic packet", @@ -453,7 +486,11 @@ class PhantomBleRepository( val timestamp = Clock.System.now().toEpochMilliseconds() val heuristic = parseHeuristicPacket(data, timestamp) ?: error("heuristic packet too short: ${data.size} bytes") - _heuristicData.value = heuristic + lifecycleLock.withLock { + if (!terminal.value) { + _heuristicData.value = heuristic + } + } logRepo.info( LogEventType.NOTIFICATION, "Phantom injected raw heuristic packet", diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 802add100..0440c4a27 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -422,6 +422,77 @@ class PhantomBleRepositoryTest { assertNull(repository.heuristicData.value) } + @Test + fun `shutdown makes non-suspend controls no-ops`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + + repository.shutdown() + + repository.enableHandleDetection(true) + repository.resetHandleState() + repository.enableJustLiftWaitingMode() + repository.startDiscoMode() + repository.stopDiscoMode() + + assertFalse(repository.handleDetection.value.leftDetected) + assertFalse(repository.handleDetection.value.rightDetected) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + assertFalse(repository.discoModeActive.value) + } + + @Test + fun `shutdown prevents raw packet routes from publishing`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val monitor = monitorPacket( + ticks = 42, + posA = 1250, + velA = 320, + loadA = 1234, + posB = -750, + velB = -250, + loadB = 567, + ) + val rep = byteArrayOf( + 0x07, 0x00, + 0x00, 0x00, + 0x05, 0x00, + 0x55, 0x66, 0x77, 0x11, + ) + val diagnostic = ByteArray(18).also { bytes -> + putUInt32LE(bytes, 0, 1234) + putUInt16LE(bytes, 4, 0x0001) + bytes[12] = 31 + bytes[13] = 32 + bytes[14] = 33 + bytes[15] = 34 + bytes[16] = 35 + bytes[17] = 36 + } + val heuristic = ByteArray(48).also { bytes -> + putFloatLE(bytes, 0, 50f) + putFloatLE(bytes, 4, 80f) + putFloatLE(bytes, 24, 40f) + putFloatLE(bytes, 28, 60f) + } + + repository.shutdown() + + repository.metricsFlow.test { + assertTrue(repository.injectRawPacket(PhantomRawPacketKind.MONITOR, monitor).isSuccess) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + repository.repEvents.test { + assertTrue(repository.injectRawPacket(PhantomRawPacketKind.REP, rep).isSuccess) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + assertTrue(repository.injectRawPacket(PhantomRawPacketKind.DIAGNOSTIC, diagnostic).isSuccess) + assertTrue(repository.injectRawPacket(PhantomRawPacketKind.HEURISTIC, heuristic).isSuccess) + assertNull(repository.diagnostics.value) + assertNull(repository.heuristicData.value) + } + private fun workoutParameters(): WorkoutParameters = WorkoutParameters( programMode = ProgramMode.OldSchool, reps = 3, From c59424abbb0eb3eef3012b3d59fa3e441a513b6e Mon Sep 17 00:00:00 2001 From: Devil Date: Thu, 16 Jul 2026 18:21:06 -0400 Subject: [PATCH 07/54] fix(ios): close remaining Phantom post-shutdown mutations --- .../data/repository/PhantomBleRepository.kt | 101 +++++++++++------- .../repository/PhantomBleRepositoryTest.kt | 69 ++++++++++++ 2 files changed, 132 insertions(+), 38 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index cc53f6742..2f7d74820 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -237,9 +237,14 @@ class PhantomBleRepository( } override suspend fun setColorScheme(schemeIndex: Int): Result { - lastColorSchemeIndex = schemeIndex - logRepo.info(LogEventType.COMMAND_SENT, "Phantom color scheme set", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS, "scheme=$schemeIndex") - return Result.success(Unit) + return lifecycleLock.withLock { + if (terminal.value) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } + lastColorSchemeIndex = schemeIndex + logRepo.info(LogEventType.COMMAND_SENT, "Phantom color scheme set", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS, "scheme=$schemeIndex") + Result.success(Unit) + } } override suspend fun sendWorkoutCommand(command: ByteArray): Result { @@ -283,13 +288,18 @@ class PhantomBleRepository( } override suspend fun stopWorkout(): Result { - logRepo.info(LogEventType.COMMAND_SENT, "Phantom workout stopped", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) - repJob?.cancel() - workoutParams = null - _handleState.value = HandleState.Released - startMetrics(activeWorkout = false) - startHeuristicGeneration(activeWorkout = false) - return Result.success(Unit) + return lifecycleLock.withLock { + if (terminal.value) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } + logRepo.info(LogEventType.COMMAND_SENT, "Phantom workout stopped", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + repJob?.cancel() + workoutParams = null + _handleState.value = HandleState.Released + startMetrics(activeWorkout = false) + startHeuristicGeneration(activeWorkout = false) + Result.success(Unit) + } } override suspend fun sendStopCommand(): Result { @@ -395,7 +405,11 @@ class PhantomBleRepository( } override fun setLastColorSchemeIndex(index: Int) { - lastColorSchemeIndex = index + lifecycleLock.withLock { + if (!terminal.value) { + lastColorSchemeIndex = index + } + } } /** @@ -501,18 +515,23 @@ class PhantomBleRepository( } fun replaceConfig(config: PhantomBleConfig) { - _config.value = config - logRepo.info( - LogEventType.NOTIFICATION, - "Phantom config updated", - PHANTOM_DEVICE_NAME, - PHANTOM_DEVICE_ADDRESS, - "loadScale=${config.loadScale}; velocityScale=${config.velocityScale}; positionScale=${config.positionScale}; repDelayMs=${config.repDelayMs}; autoCompleteFixedRepSets=${config.autoCompleteFixedRepSets}", - ) - if (_connectionState.value is ConnectionState.Connected) { - startMetrics(activeWorkout = workoutParams != null) - startHeuristicGeneration(activeWorkout = workoutParams != null) - workoutParams?.let(::startRepSimulation) + lifecycleLock.withLock { + if (terminal.value) { + return@withLock + } + _config.value = config + logRepo.info( + LogEventType.NOTIFICATION, + "Phantom config updated", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "loadScale=${config.loadScale}; velocityScale=${config.velocityScale}; positionScale=${config.positionScale}; repDelayMs=${config.repDelayMs}; autoCompleteFixedRepSets=${config.autoCompleteFixedRepSets}", + ) + if (_connectionState.value is ConnectionState.Connected) { + startMetrics(activeWorkout = workoutParams != null) + startHeuristicGeneration(activeWorkout = workoutParams != null) + workoutParams?.let(::startRepSimulation) + } } } @@ -607,7 +626,9 @@ class PhantomBleRepository( velocityB = wave * 245.0 * config.velocityScale, status = 0, ) - _metricsFlow.emit(metric) + if (!publishIfConnected { _metricsFlow.tryEmit(metric) }) { + break + } logRepo.debug( LogEventType.NOTIFICATION, "Phantom monitor metric", @@ -664,20 +685,24 @@ class PhantomBleRepository( bytes[18] = params.warmupReps.toByte() bytes[22] = target.toByte() } - _repEvents.emit( - RepNotification( - topCounter = rep, - completeCounter = rep, - repsRomCount = params.warmupReps.coerceAtMost(rep), - repsRomTotal = params.warmupReps, - repsSetCount = rep.coerceAtMost(target), - repsSetTotal = target, - rangeTop = 650f, - rangeBottom = -650f, - rawData = rawData, - timestamp = timestamp, - ), - ) + if (!publishIfConnected { + _repEvents.tryEmit( + RepNotification( + topCounter = rep, + completeCounter = rep, + repsRomCount = params.warmupReps.coerceAtMost(rep), + repsRomTotal = params.warmupReps, + repsSetCount = rep.coerceAtMost(target), + repsSetTotal = target, + rangeTop = 650f, + rangeBottom = -650f, + rawData = rawData, + timestamp = timestamp, + ), + ) + }) { + break + } logRepo.info( LogEventType.REP_RECEIVED, "Phantom rep notification", diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 0440c4a27..3bf483308 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -440,6 +440,75 @@ class PhantomBleRepositoryTest { assertFalse(repository.discoModeActive.value) } + @Test + fun `shutdown rejects stop and color commands and preserves config`() = runTest { + val initialConfig = PhantomBleConfig(repDelayMs = 100L) + val repository = PhantomBleRepository(ConnectionLogRepository(), initialConfig) + + assertTrue(repository.scanAndConnect().isSuccess) + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + repository.shutdown() + + val stopResult = repository.stopWorkout() + val sendStopResult = repository.sendStopCommand() + val setColorResult = repository.setColorScheme(7) + repository.setLastColorSchemeIndex(7) + repository.replaceConfig(PhantomBleConfig(loadScale = 2f, repDelayMs = 100L)) + + assertTrue(stopResult.isFailure) + assertTrue(sendStopResult.isFailure) + assertTrue(setColorResult.isFailure) + assertEquals(initialConfig, repository.config.value) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + } + + @Test + fun `shutdown prevents scheduled metric emissions`() = runTest { + val repository = PhantomBleRepository( + ConnectionLogRepository(), + PhantomBleConfig(repDelayMs = 100L), + ) + + try { + repository.metricsFlow.test { + assertTrue(repository.scanAndConnect().isSuccess) + awaitItem() + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + awaitItem() + + repository.shutdown() + withContext(Dispatchers.Default) { delay(350L) } + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + + @Test + fun `shutdown prevents scheduled rep emissions`() = runTest { + val repository = PhantomBleRepository( + ConnectionLogRepository(), + PhantomBleConfig(repDelayMs = 100L), + ) + + try { + repository.repEvents.test { + assertTrue(repository.scanAndConnect().isSuccess) + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + awaitItem() + + repository.shutdown() + withContext(Dispatchers.Default) { delay(200L) } + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + @Test fun `shutdown prevents raw packet routes from publishing`() = runTest { val repository = PhantomBleRepository(ConnectionLogRepository()) From 269dc131faa9e3f19785c895d5985b07da0e0832 Mon Sep 17 00:00:00 2001 From: Devil Date: Thu, 16 Jul 2026 18:39:07 -0400 Subject: [PATCH 08/54] fix(ios): seal Phantom terminal control paths --- .../data/repository/PhantomBleRepository.kt | 298 +++++++++++------- .../repository/PhantomBleRepositoryTest.kt | 56 +++- 2 files changed, 235 insertions(+), 119 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 2f7d74820..92187956c 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -161,12 +161,15 @@ class PhantomBleRepository( override suspend fun stopScanning() { lifecycleLock.withLock { + if (terminal.value) { + return@withLock + } connectionAttemptGeneration.incrementAndGet() if (_connectionState.value == ConnectionState.Scanning) { _connectionState.value = ConnectionState.Disconnected } + logRepo.info(LogEventType.SCAN_STOP, "Stopped phantom Vitruvian scan") } - logRepo.info(LogEventType.SCAN_STOP, "Stopped phantom Vitruvian scan") } override suspend fun connect(device: ScannedDevice): Result { @@ -185,17 +188,23 @@ class PhantomBleRepository( override suspend fun cancelConnection() { lifecycleLock.withLock { - connectionAttemptGeneration.incrementAndGet() - if (!terminal.value) { - _connectionState.value = ConnectionState.Disconnected + if (terminal.value) { + return@withLock } + connectionAttemptGeneration.incrementAndGet() + _connectionState.value = ConnectionState.Disconnected + logRepo.warning(LogEventType.DISCONNECT, "Cancelled phantom connection") } - logRepo.warning(LogEventType.DISCONNECT, "Cancelled phantom connection") } override suspend fun disconnect() { - logRepo.info(LogEventType.DISCONNECT, "Disconnected phantom Vitruvian", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) - teardownConnection() + lifecycleLock.withLock { + if (terminal.value) { + return@withLock + } + logRepo.info(LogEventType.DISCONNECT, "Disconnected phantom Vitruvian", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + teardownConnection() + } } override suspend fun shutdown() { @@ -209,31 +218,44 @@ class PhantomBleRepository( return Result.failure(IllegalArgumentException("timeoutMs must be > 0")) } + if (lifecycleLock.withLock { terminal.value }) { + return Result.failure(IllegalStateException("Phantom repository is shut down")) + } + val completed = withTimeoutOrNull(timeoutMs) { - startScanning().getOrThrow() - connect(device).getOrThrow() + val scanResult = startScanning() + if (scanResult.isFailure) { + return@withTimeoutOrNull scanResult + } + connect(device) } if (completed != null) { - return Result.success(Unit) + return completed } - teardownConnection() - logRepo.error( - LogEventType.ERROR, - "Phantom scan and connect timed out", - PHANTOM_DEVICE_NAME, - PHANTOM_DEVICE_ADDRESS, - "timeoutMs=$timeoutMs", - ) - _reconnectionRequested.emit( - ReconnectionRequest( - deviceName = PHANTOM_DEVICE_NAME, - deviceAddress = PHANTOM_DEVICE_ADDRESS, - reason = "connection_timeout", - timestamp = Clock.System.now().toEpochMilliseconds(), - ), - ) - return Result.failure(IllegalStateException("Phantom scan and connect timed out after ${timeoutMs}ms")) + return lifecycleLock.withLock { + if (terminal.value) { + Result.failure(IllegalStateException("Phantom repository is shut down")) + } else { + teardownConnection() + logRepo.error( + LogEventType.ERROR, + "Phantom scan and connect timed out", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "timeoutMs=$timeoutMs", + ) + _reconnectionRequested.tryEmit( + ReconnectionRequest( + deviceName = PHANTOM_DEVICE_NAME, + deviceAddress = PHANTOM_DEVICE_ADDRESS, + reason = "connection_timeout", + timestamp = Clock.System.now().toEpochMilliseconds(), + ), + ) + Result.failure(IllegalStateException("Phantom scan and connect timed out after ${timeoutMs}ms")) + } + } } override suspend fun setColorScheme(schemeIndex: Int): Result { @@ -248,19 +270,29 @@ class PhantomBleRepository( } override suspend fun sendWorkoutCommand(command: ByteArray): Result { - logRepo.info( - LogEventType.COMMAND_SENT, - "Phantom received raw workout command", - PHANTOM_DEVICE_NAME, - PHANTOM_DEVICE_ADDRESS, - command.joinToString(" ") { it.toUByte().toString(16).padStart(2, '0') }, - ) - return Result.success(Unit) + return lifecycleLock.withLock { + if (terminal.value) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } + logRepo.info( + LogEventType.COMMAND_SENT, + "Phantom received raw workout command", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + command.joinToString(" ") { it.toUByte().toString(16).padStart(2, '0') }, + ) + Result.success(Unit) + } } override suspend fun sendInitSequence(): Result { - logRepo.info(LogEventType.COMMAND_SENT, "Phantom init sequence accepted", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) - return Result.success(Unit) + return lifecycleLock.withLock { + if (terminal.value) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } + logRepo.info(LogEventType.COMMAND_SENT, "Phantom init sequence accepted", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + Result.success(Unit) + } } override suspend fun startWorkout(params: WorkoutParameters): Result { @@ -303,7 +335,17 @@ class PhantomBleRepository( } override suspend fun sendStopCommand(): Result { - logRepo.info(LogEventType.COMMAND_SENT, "Phantom stop command accepted", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + val canSend = lifecycleLock.withLock { + if (terminal.value) { + false + } else { + logRepo.info(LogEventType.COMMAND_SENT, "Phantom stop command accepted", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + true + } + } + if (!canSend) { + return Result.failure(IllegalStateException("Phantom repository is shut down")) + } return stopWorkout() } @@ -356,17 +398,27 @@ class PhantomBleRepository( } override fun stopPolling() { - metricsJob?.cancel() - heuristicJob?.cancel() - repJob?.cancel() - diagnosticJob?.cancel() - heartbeatJob?.cancel() - logRepo.info(LogEventType.HEARTBEAT, "Phantom polling stopped") + lifecycleLock.withLock { + if (terminal.value) { + return@withLock + } + metricsJob?.cancel() + heuristicJob?.cancel() + repJob?.cancel() + diagnosticJob?.cancel() + heartbeatJob?.cancel() + logRepo.info(LogEventType.HEARTBEAT, "Phantom polling stopped") + } } override fun stopMonitorPollingOnly() { - metricsJob?.cancel() - logRepo.info(LogEventType.HEARTBEAT, "Phantom monitor polling stopped; diagnostics kept warm") + lifecycleLock.withLock { + if (terminal.value) { + return@withLock + } + metricsJob?.cancel() + logRepo.info(LogEventType.HEARTBEAT, "Phantom monitor polling stopped; diagnostics kept warm") + } } override fun restartDiagnosticPolling() { @@ -420,98 +472,116 @@ class PhantomBleRepository( kind: PhantomRawPacketKind, data: ByteArray, hasOpcodePrefix: Boolean = false, - ): Result = try { - when (kind) { - PhantomRawPacketKind.MONITOR -> injectMonitorPacket(data) - PhantomRawPacketKind.REP -> injectRepPacket(data, hasOpcodePrefix) - PhantomRawPacketKind.DIAGNOSTIC -> injectDiagnosticPacket(data) - PhantomRawPacketKind.HEURISTIC -> injectHeuristicPacket(data) - } - Result.success(Unit) - } catch (error: CancellationException) { - throw error - } catch (error: Throwable) { - logRepo.error( - LogEventType.ERROR, - "Phantom raw ${kind.name.lowercase()} packet rejected", - PHANTOM_DEVICE_NAME, - PHANTOM_DEVICE_ADDRESS, - "${error.message}; hex=${data.joinToString(" ") { it.toVitruvianHex() }}", - ) - Result.failure(error) + ): Result { + if (lifecycleLock.withLock { terminal.value }) { + return Result.failure(IllegalStateException("Phantom repository is shut down")) + } + + return try { + when (kind) { + PhantomRawPacketKind.MONITOR -> injectMonitorPacket(data) + PhantomRawPacketKind.REP -> injectRepPacket(data, hasOpcodePrefix) + PhantomRawPacketKind.DIAGNOSTIC -> injectDiagnosticPacket(data) + PhantomRawPacketKind.HEURISTIC -> injectHeuristicPacket(data) + } + if (lifecycleLock.withLock { terminal.value }) { + Result.failure(IllegalStateException("Phantom repository is shut down")) + } else { + Result.success(Unit) + } + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + lifecycleLock.withLock { + if (!terminal.value) { + logRepo.error( + LogEventType.ERROR, + "Phantom raw ${kind.name.lowercase()} packet rejected", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "${error.message}; hex=${data.joinToString(" ") { it.toVitruvianHex() }}", + ) + } + } + Result.failure(error) + } } private suspend fun injectMonitorPacket(data: ByteArray) { - val packet = parseMonitorPacket(data) - ?: error("monitor packet too short: ${data.size} bytes") - val metric = monitorProcessor.process(packet) - ?: error("monitor packet parsed but was rejected by validation") lifecycleLock.withLock { - if (!terminal.value) { - _metricsFlow.tryEmit(metric) + if (terminal.value) { + return@withLock } + val packet = parseMonitorPacket(data) + ?: error("monitor packet too short: ${data.size} bytes") + val metric = monitorProcessor.process(packet) + ?: error("monitor packet parsed but was rejected by validation") + _metricsFlow.tryEmit(metric) + logRepo.info( + LogEventType.NOTIFICATION, + "Phantom injected raw monitor packet", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "ticks=${metric.ticks}; load=${metric.totalLoad}; posA=${metric.positionA}; hex=${data.joinToString(" ") { it.toVitruvianHex() }}", + ) } - logRepo.info( - LogEventType.NOTIFICATION, - "Phantom injected raw monitor packet", - PHANTOM_DEVICE_NAME, - PHANTOM_DEVICE_ADDRESS, - "ticks=${metric.ticks}; load=${metric.totalLoad}; posA=${metric.positionA}; hex=${data.joinToString(" ") { it.toVitruvianHex() }}", - ) } private suspend fun injectRepPacket(data: ByteArray, hasOpcodePrefix: Boolean) { - val timestamp = Clock.System.now().toEpochMilliseconds() - val rep = parseRepPacket(data, hasOpcodePrefix, timestamp) - ?: error("rep packet too short: ${data.size} bytes") lifecycleLock.withLock { - if (!terminal.value) { - _repEvents.tryEmit(rep) + if (terminal.value) { + return@withLock } + val timestamp = Clock.System.now().toEpochMilliseconds() + val rep = parseRepPacket(data, hasOpcodePrefix, timestamp) + ?: error("rep packet too short: ${data.size} bytes") + _repEvents.tryEmit(rep) + logRepo.info( + LogEventType.REP_RECEIVED, + "Phantom injected raw rep packet", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "top=${rep.topCounter}; complete=${rep.completeCounter}; legacy=${rep.isLegacyFormat}; hex=${data.joinToString(" ") { it.toVitruvianHex() }}", + ) } - logRepo.info( - LogEventType.REP_RECEIVED, - "Phantom injected raw rep packet", - PHANTOM_DEVICE_NAME, - PHANTOM_DEVICE_ADDRESS, - "top=${rep.topCounter}; complete=${rep.completeCounter}; legacy=${rep.isLegacyFormat}; hex=${data.joinToString(" ") { it.toVitruvianHex() }}", - ) } private fun injectDiagnosticPacket(data: ByteArray) { - val timestamp = Clock.System.now().toEpochMilliseconds() - val diagnostic = parseDiagnosticPacket(data) - ?: error("diagnostic packet too short: ${data.size} bytes") lifecycleLock.withLock { - if (!terminal.value) { - _diagnostics.value = diagnostic.copy(receivedAtMillis = timestamp) + if (terminal.value) { + return@withLock } + val timestamp = Clock.System.now().toEpochMilliseconds() + val diagnostic = parseDiagnosticPacket(data) + ?: error("diagnostic packet too short: ${data.size} bytes") + _diagnostics.value = diagnostic.copy(receivedAtMillis = timestamp) + logRepo.info( + LogEventType.DIAGNOSTIC, + "Phantom injected raw diagnostic packet", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "faults=${diagnostic.faultWords}; temps=${diagnostic.temperatures}; hex=${data.joinToString(" ") { it.toVitruvianHex() }}", + ) } - logRepo.info( - LogEventType.DIAGNOSTIC, - "Phantom injected raw diagnostic packet", - PHANTOM_DEVICE_NAME, - PHANTOM_DEVICE_ADDRESS, - "faults=${diagnostic.faultWords}; temps=${diagnostic.temperatures}; hex=${data.joinToString(" ") { it.toVitruvianHex() }}", - ) } private fun injectHeuristicPacket(data: ByteArray) { - val timestamp = Clock.System.now().toEpochMilliseconds() - val heuristic = parseHeuristicPacket(data, timestamp) - ?: error("heuristic packet too short: ${data.size} bytes") lifecycleLock.withLock { - if (!terminal.value) { - _heuristicData.value = heuristic + if (terminal.value) { + return@withLock } + val timestamp = Clock.System.now().toEpochMilliseconds() + val heuristic = parseHeuristicPacket(data, timestamp) + ?: error("heuristic packet too short: ${data.size} bytes") + _heuristicData.value = heuristic + logRepo.info( + LogEventType.NOTIFICATION, + "Phantom injected raw heuristic packet", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "conKgAvg=${heuristic.concentric.kgAvg}; eccKgAvg=${heuristic.eccentric.kgAvg}; hex=${data.joinToString(" ") { it.toVitruvianHex() }}", + ) } - logRepo.info( - LogEventType.NOTIFICATION, - "Phantom injected raw heuristic packet", - PHANTOM_DEVICE_NAME, - PHANTOM_DEVICE_ADDRESS, - "conKgAvg=${heuristic.concentric.kgAvg}; eccKgAvg=${heuristic.eccentric.kgAvg}; hex=${data.joinToString(" ") { it.toVitruvianHex() }}", - ) } fun replaceConfig(config: PhantomBleConfig) { diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 3bf483308..ee96b91df 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -353,6 +353,26 @@ class PhantomBleRepositoryTest { } } + @Test + fun `shutdown makes scanAndConnect return failure without timeout side effects`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + + repository.shutdown() + val logsAfterShutdown = logRepo.logs.value.size + + repository.reconnectionRequested.test { + val result = repository.scanAndConnect(timeoutMs = 50L) + + assertTrue(result.isFailure) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + + assertEquals(logsAfterShutdown, logRepo.logs.value.size) + assertTrue(logRepo.getLogsByEventType(LogEventType.ERROR).isEmpty()) + } + @Test fun `scanAndConnect propagates caller timeout without requesting reconnection`() = runTest { val logRepo = ConnectionLogRepository() @@ -462,6 +482,29 @@ class PhantomBleRepositoryTest { assertEquals(HandleState.WaitingForRest, repository.handleState.value) } + @Test + fun `shutdown seals remaining suspend controls without post-terminal logs`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + + repository.shutdown() + val logsAfterShutdown = logRepo.logs.value.size + + repository.stopScanning() + repository.cancelConnection() + repository.disconnect() + val workoutResult = repository.sendWorkoutCommand(byteArrayOf(0x01)) + val initResult = repository.sendInitSequence() + val stopResult = repository.sendStopCommand() + repository.stopPolling() + repository.stopMonitorPollingOnly() + + assertTrue(workoutResult.isFailure) + assertTrue(initResult.isFailure) + assertTrue(stopResult.isFailure) + assertEquals(logsAfterShutdown, logRepo.logs.value.size) + } + @Test fun `shutdown prevents scheduled metric emissions`() = runTest { val repository = PhantomBleRepository( @@ -511,7 +554,8 @@ class PhantomBleRepositoryTest { @Test fun `shutdown prevents raw packet routes from publishing`() = runTest { - val repository = PhantomBleRepository(ConnectionLogRepository()) + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) val monitor = monitorPacket( ticks = 42, posA = 1250, @@ -545,21 +589,23 @@ class PhantomBleRepositoryTest { } repository.shutdown() + val logsAfterShutdown = logRepo.logs.value.size repository.metricsFlow.test { - assertTrue(repository.injectRawPacket(PhantomRawPacketKind.MONITOR, monitor).isSuccess) + assertTrue(repository.injectRawPacket(PhantomRawPacketKind.MONITOR, monitor).isFailure) expectNoEvents() cancelAndIgnoreRemainingEvents() } repository.repEvents.test { - assertTrue(repository.injectRawPacket(PhantomRawPacketKind.REP, rep).isSuccess) + assertTrue(repository.injectRawPacket(PhantomRawPacketKind.REP, rep).isFailure) expectNoEvents() cancelAndIgnoreRemainingEvents() } - assertTrue(repository.injectRawPacket(PhantomRawPacketKind.DIAGNOSTIC, diagnostic).isSuccess) - assertTrue(repository.injectRawPacket(PhantomRawPacketKind.HEURISTIC, heuristic).isSuccess) + assertTrue(repository.injectRawPacket(PhantomRawPacketKind.DIAGNOSTIC, diagnostic).isFailure) + assertTrue(repository.injectRawPacket(PhantomRawPacketKind.HEURISTIC, heuristic).isFailure) assertNull(repository.diagnostics.value) assertNull(repository.heuristicData.value) + assertEquals(logsAfterShutdown, logRepo.logs.value.size) } private fun workoutParameters(): WorkoutParameters = WorkoutParameters( From 1bcdc6fc3ea3e9fdd5dc03539e6c95defd25c5d7 Mon Sep 17 00:00:00 2001 From: Devil Date: Thu, 16 Jul 2026 18:57:06 -0400 Subject: [PATCH 09/54] fix(ios): gate Phantom scheduled emissions by lifecycle --- .../data/repository/PhantomBleRepository.kt | 130 +++++++++++------- .../repository/PhantomBleRepositoryTest.kt | 9 +- 2 files changed, 83 insertions(+), 56 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 92187956c..92daea5da 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -111,7 +111,11 @@ class PhantomBleRepository( private val monitorProcessor = MonitorDataProcessor( onDeloadOccurred = { - scope.launch { _deloadOccurredEvents.emit(Unit) } + lifecycleLock.withLock { + if (!terminal.value) { + _deloadOccurredEvents.tryEmit(Unit) + } + } }, onRomViolation = { violation -> logRepo.warning( @@ -137,6 +141,7 @@ class PhantomBleRepository( private val lifecycleLock = reentrantLock() private val terminal = atomic(false) private val connectionAttemptGeneration = atomic(0L) + private var metricsGeneration = 0L override suspend fun startScanning(): Result { val attemptGeneration = beginConnectionAttempt() @@ -208,8 +213,13 @@ class PhantomBleRepository( } override suspend fun shutdown() { - logRepo.info(LogEventType.DISCONNECT, "Disconnected phantom Vitruvian", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) - teardownConnection(markTerminal = true) + lifecycleLock.withLock { + if (terminal.value) { + return@withLock + } + logRepo.info(LogEventType.DISCONNECT, "Disconnected phantom Vitruvian", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + teardownConnection(markTerminal = true) + } repositoryJob.cancel() } @@ -402,6 +412,7 @@ class PhantomBleRepository( if (terminal.value) { return@withLock } + metricsGeneration += 1 metricsJob?.cancel() heuristicJob?.cancel() repJob?.cancel() @@ -416,6 +427,7 @@ class PhantomBleRepository( if (terminal.value) { return@withLock } + metricsGeneration += 1 metricsJob?.cancel() logRepo.info(LogEventType.HEARTBEAT, "Phantom monitor polling stopped; diagnostics kept warm") } @@ -665,8 +677,15 @@ class PhantomBleRepository( } } - private inline fun publishIfConnected(publish: () -> Unit): Boolean = lifecycleLock.withLock { - if (terminal.value || _connectionState.value !is ConnectionState.Connected) { + private inline fun publishIfConnected( + expectedMetricsGeneration: Long? = null, + publish: () -> Unit, + ): Boolean = lifecycleLock.withLock { + if ( + terminal.value || + _connectionState.value !is ConnectionState.Connected || + (expectedMetricsGeneration != null && metricsGeneration != expectedMetricsGeneration) + ) { false } else { publish() @@ -675,39 +694,45 @@ class PhantomBleRepository( } private fun startMetrics(activeWorkout: Boolean) { - metricsJob?.cancel() - metricsJob = scope.launch { - var sample = 0 - while (isActive && connectionState.value is ConnectionState.Connected) { - val now = Clock.System.now().toEpochMilliseconds() - val phase = (sample % 40) / 40.0 - val wave = sin(phase * 2.0 * PI).toFloat() - val config = _config.value - val configuredLoad = workoutParams?.weightPerCableKg ?: 7.5f - val load = (if (activeWorkout) configuredLoad.coerceAtLeast(2f) else 1.5f) * config.loadScale - val metric = WorkoutMetric( - timestamp = now, - loadA = load + wave.coerceAtLeast(0f) * config.loadScale, - loadB = load + (-wave).coerceAtLeast(0f) * config.loadScale, - positionA = wave * 650f * config.positionScale, - positionB = wave * 640f * config.positionScale, - ticks = ticks++, - velocityA = wave * 250.0 * config.velocityScale, - velocityB = wave * 245.0 * config.velocityScale, - status = 0, - ) - if (!publishIfConnected { _metricsFlow.tryEmit(metric) }) { - break + lifecycleLock.withLock { + metricsGeneration += 1 + val expectedGeneration = metricsGeneration + metricsJob?.cancel() + metricsJob = scope.launch { + var sample = 0 + while (isActive && connectionState.value is ConnectionState.Connected) { + val now = Clock.System.now().toEpochMilliseconds() + val phase = (sample % 40) / 40.0 + val wave = sin(phase * 2.0 * PI).toFloat() + val config = _config.value + val configuredLoad = workoutParams?.weightPerCableKg ?: 7.5f + val load = (if (activeWorkout) configuredLoad.coerceAtLeast(2f) else 1.5f) * config.loadScale + val metric = WorkoutMetric( + timestamp = now, + loadA = load + wave.coerceAtLeast(0f) * config.loadScale, + loadB = load + (-wave).coerceAtLeast(0f) * config.loadScale, + positionA = wave * 650f * config.positionScale, + positionB = wave * 640f * config.positionScale, + ticks = ticks++, + velocityA = wave * 250.0 * config.velocityScale, + velocityB = wave * 245.0 * config.velocityScale, + status = 0, + ) + if (!publishIfConnected(expectedMetricsGeneration = expectedGeneration) { + _metricsFlow.tryEmit(metric) + logRepo.debug( + LogEventType.NOTIFICATION, + "Phantom monitor metric", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "ticks=${metric.ticks}; load=${metric.totalLoad}; posA=${metric.positionA}", + ) + }) { + break + } + sample++ + delay(if (activeWorkout) 250 else 750) } - logRepo.debug( - LogEventType.NOTIFICATION, - "Phantom monitor metric", - PHANTOM_DEVICE_NAME, - PHANTOM_DEVICE_ADDRESS, - "ticks=${metric.ticks}; load=${metric.totalLoad}; posA=${metric.positionA}", - ) - sample++ - delay(if (activeWorkout) 250 else 750) } } } @@ -725,15 +750,15 @@ class PhantomBleRepository( eccentric = HeuristicPhaseStatistics(load * 0.9f, load + 1f, 0.38f, 0.62f, 72f, 110f), timestamp = Clock.System.now().toEpochMilliseconds(), ) + logRepo.debug( + LogEventType.NOTIFICATION, + "Phantom heuristic update", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + ) }) { break } - logRepo.debug( - LogEventType.NOTIFICATION, - "Phantom heuristic update", - PHANTOM_DEVICE_NAME, - PHANTOM_DEVICE_ADDRESS, - ) delay(if (activeWorkout) 250 else 750) } } @@ -770,18 +795,20 @@ class PhantomBleRepository( timestamp = timestamp, ), ) + logRepo.info( + LogEventType.REP_RECEIVED, + "Phantom rep notification", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "rep=$rep/$target; timestamp=$timestamp", + ) + if (rep >= target && !params.isAMRAP && config.autoCompleteFixedRepSets) { + logRepo.info(LogEventType.COMMAND_RESPONSE, "Phantom target reps reached", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + } }) { break } - logRepo.info( - LogEventType.REP_RECEIVED, - "Phantom rep notification", - PHANTOM_DEVICE_NAME, - PHANTOM_DEVICE_ADDRESS, - "rep=$rep/$target; timestamp=$timestamp", - ) if (rep >= target && !params.isAMRAP && config.autoCompleteFixedRepSets) { - logRepo.info(LogEventType.COMMAND_RESPONSE, "Phantom target reps reached", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) break } } @@ -822,6 +849,7 @@ class PhantomBleRepository( } private fun stopJobs() { + metricsGeneration += 1 metricsJob?.cancel() heuristicJob?.cancel() repJob?.cancel() diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index ee96b91df..39217baf9 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -285,15 +285,13 @@ class PhantomBleRepositoryTest { try { repository.metricsFlow.test { assertTrue(repository.scanAndConnect().isSuccess) + assertTrue(awaitItem().loadA < 2f) assertTrue(repository.startWorkout(workoutParameters()).isSuccess) - var metric = awaitItem() - while (metric.loadA < 2f) { - metric = awaitItem() - } + assertTrue(awaitItem().loadA >= 2f) repository.stopMonitorPollingOnly() - delay(500L) + withContext(Dispatchers.Default) { delay(500L) } expectNoEvents() cancelAndIgnoreRemainingEvents() } @@ -498,6 +496,7 @@ class PhantomBleRepositoryTest { val stopResult = repository.sendStopCommand() repository.stopPolling() repository.stopMonitorPollingOnly() + repository.shutdown() assertTrue(workoutResult.isFailure) assertTrue(initResult.isFailure) From 2883acc36e7eb415209b5e8b99d3b1e223d74643 Mon Sep 17 00:00:00 2001 From: Devil Date: Thu, 16 Jul 2026 19:14:08 -0400 Subject: [PATCH 10/54] fix(ios): close Phantom logging race windows --- .../data/repository/PhantomBleRepository.kt | 25 +++++++++++++------ .../repository/PhantomBleRepositoryTest.kt | 17 ++++++++++--- 2 files changed, 32 insertions(+), 10 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 92daea5da..a72344673 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -146,8 +146,9 @@ class PhantomBleRepository( override suspend fun startScanning(): Result { val attemptGeneration = beginConnectionAttempt() ?: return Result.failure(IllegalStateException("Phantom repository is shut down")) - logRepo.info(LogEventType.SCAN_START, "Starting phantom Vitruvian scan") - if (!publishConnectionState(attemptGeneration, ConnectionState.Scanning)) { + if (!publishConnectionState(attemptGeneration, ConnectionState.Scanning) { + logRepo.info(LogEventType.SCAN_START, "Starting phantom Vitruvian scan") + }) { return Result.failure(IllegalStateException("Phantom scan attempt invalidated")) } delay(150) @@ -180,8 +181,9 @@ class PhantomBleRepository( override suspend fun connect(device: ScannedDevice): Result { val attemptGeneration = beginConnectionAttempt() ?: return Result.failure(IllegalStateException("Phantom repository is shut down")) - logRepo.info(LogEventType.CONNECT_START, "Connecting to phantom Vitruvian", device.name, device.address) - if (!publishConnectionState(attemptGeneration, ConnectionState.Connecting)) { + if (!publishConnectionState(attemptGeneration, ConnectionState.Connecting) { + logRepo.info(LogEventType.CONNECT_START, "Connecting to phantom Vitruvian", device.name, device.address) + }) { return Result.failure(IllegalStateException("Phantom connection attempt invalidated")) } delay(250) @@ -625,11 +627,16 @@ class PhantomBleRepository( } } - private fun publishConnectionState(attemptGeneration: Long, state: ConnectionState): Boolean = lifecycleLock.withLock { + private inline fun publishConnectionState( + attemptGeneration: Long, + state: ConnectionState, + onPublished: () -> Unit, + ): Boolean = lifecycleLock.withLock { if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { false } else { _connectionState.value = state + onPublished() true } } @@ -829,10 +836,10 @@ class PhantomBleRepository( hasFaults = false, receivedAtMillis = now, ) + logRepo.debug(LogEventType.DIAGNOSTIC, "Phantom diagnostic heartbeat", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) }) { break } - logRepo.debug(LogEventType.DIAGNOSTIC, "Phantom diagnostic heartbeat", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) delay(2_000) } } @@ -842,7 +849,11 @@ class PhantomBleRepository( heartbeatJob?.cancel() heartbeatJob = scope.launch { while (isActive && connectionState.value is ConnectionState.Connected) { - logRepo.info(LogEventType.HEARTBEAT, "Phantom heartbeat", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + if (!publishIfConnected { + logRepo.info(LogEventType.HEARTBEAT, "Phantom heartbeat", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + }) { + break + } delay(2_000) } } diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 39217baf9..19bed388b 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -82,14 +82,17 @@ class PhantomBleRepositoryTest { @Test fun `shutdown wins over an in-flight scan`() = runTest { - val repository = PhantomBleRepository(ConnectionLogRepository()) + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) val scanning = async(Dispatchers.Default) { repository.startScanning() } try { repository.connectionState.first { it == ConnectionState.Scanning } repository.shutdown() + val logsAfterShutdown = logRepo.logs.value.size assertTrue(scanning.await().isFailure) + assertEquals(logsAfterShutdown, logRepo.logs.value.size) assertEquals(ConnectionState.Disconnected, repository.connectionState.value) assertTrue(repository.scannedDevices.value.isEmpty()) assertNull(repository.heuristicData.value) @@ -102,15 +105,18 @@ class PhantomBleRepositoryTest { @Test fun `shutdown wins over a racing connect`() = runTest { - val repository = PhantomBleRepository(ConnectionLogRepository()) + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) val connecting = async(Dispatchers.Default) { repository.connect(ScannedDevice("race", "race-address")) } try { repository.shutdown() + val logsAfterShutdown = logRepo.logs.value.size assertTrue(connecting.await().isFailure) + assertEquals(logsAfterShutdown, logRepo.logs.value.size) assertEquals(ConnectionState.Disconnected, repository.connectionState.value) assertTrue(repository.scannedDevices.value.isEmpty()) assertNull(repository.heuristicData.value) @@ -414,8 +420,9 @@ class PhantomBleRepositoryTest { @Test fun `shutdown cancels polling jobs and clears lifecycle state`() = runTest { + val logRepo = ConnectionLogRepository() val repository = PhantomBleRepository( - ConnectionLogRepository(), + logRepo, PhantomBleConfig(repDelayMs = 100L), ) @@ -428,8 +435,12 @@ class PhantomBleRepositoryTest { assertTrue(repository.heuristicData.value != null) repository.shutdown() + val diagnosticLogsAfterShutdown = logRepo.getLogsByEventType(LogEventType.DIAGNOSTIC).size + val heartbeatLogsAfterShutdown = logRepo.getLogsByEventType(LogEventType.HEARTBEAT).size withContext(Dispatchers.Default) { delay(350L) } + assertEquals(diagnosticLogsAfterShutdown, logRepo.getLogsByEventType(LogEventType.DIAGNOSTIC).size) + assertEquals(heartbeatLogsAfterShutdown, logRepo.getLogsByEventType(LogEventType.HEARTBEAT).size) assertEquals(ConnectionState.Disconnected, repository.connectionState.value) assertFalse(repository.handleDetection.value.leftDetected) assertFalse(repository.handleDetection.value.rightDetected) From 18177ca3daa73a4dd51ec2268622479274f6bd55 Mon Sep 17 00:00:00 2001 From: Devil Date: Thu, 16 Jul 2026 19:38:23 -0400 Subject: [PATCH 11/54] fix(ios): close Phantom device-found logging race --- .../data/repository/PhantomBleRepository.kt | 14 ++++++------- .../repository/PhantomBleRepositoryTest.kt | 21 +++++++++++++++++++ 2 files changed, 28 insertions(+), 7 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index a72344673..4e437f024 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -155,13 +155,6 @@ class PhantomBleRepository( if (!publishScannedDevices(attemptGeneration, listOf(device))) { return Result.failure(IllegalStateException("Phantom scan attempt invalidated")) } - logRepo.info( - LogEventType.DEVICE_FOUND, - "Found phantom Vitruvian device", - deviceName = device.name, - deviceAddress = device.address, - details = "RSSI ${device.rssi}; no Bluetooth hardware used", - ) return Result.success(Unit) } @@ -646,6 +639,13 @@ class PhantomBleRepository( false } else { _scannedDevices.value = devices + logRepo.info( + LogEventType.DEVICE_FOUND, + "Found phantom Vitruvian device", + deviceName = device.name, + deviceAddress = device.address, + details = "RSSI ${device.rssi}; no Bluetooth hardware used", + ) true } } diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 19bed388b..aaa1b6b9c 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -103,6 +103,27 @@ class PhantomBleRepositoryTest { } } + @Test + fun `shutdown after scan publication prevents post-terminal device-found log`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val shutdownOnPublication = async(Dispatchers.Default) { + repository.scannedDevices.first { it.isNotEmpty() } + repository.shutdown() + } + val scanning = async(Dispatchers.Default) { repository.startScanning() } + + try { + shutdownOnPublication.await() + val logsAfterShutdown = logRepo.logs.value.size + + assertTrue(scanning.await().isSuccess) + assertEquals(logsAfterShutdown, logRepo.logs.value.size) + } finally { + repository.shutdown() + } + } + @Test fun `shutdown wins over a racing connect`() = runTest { val logRepo = ConnectionLogRepository() From 5d9c2de49feefd33b81ae9f98d0627aa66dc5da3 Mon Sep 17 00:00:00 2001 From: Devil Date: Thu, 16 Jul 2026 20:32:57 -0400 Subject: [PATCH 12/54] fix(ios): harden Phantom producer generations --- .../data/repository/PhantomBleRepository.kt | 49 ++++- .../repository/PhantomBleRepositoryTest.kt | 169 +++++++++++++++++- 2 files changed, 205 insertions(+), 13 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 4e437f024..bf206e363 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -142,6 +142,10 @@ class PhantomBleRepository( private val terminal = atomic(false) private val connectionAttemptGeneration = atomic(0L) private var metricsGeneration = 0L + private var heuristicGeneration = 0L + private var repGeneration = 0L + private var diagnosticGeneration = 0L + private var heartbeatGeneration = 0L override suspend fun startScanning(): Result { val attemptGeneration = beginConnectionAttempt() @@ -164,7 +168,7 @@ class PhantomBleRepository( return@withLock } connectionAttemptGeneration.incrementAndGet() - if (_connectionState.value == ConnectionState.Scanning) { + if (_connectionState.value == ConnectionState.Scanning || _connectionState.value == ConnectionState.Connecting) { _connectionState.value = ConnectionState.Disconnected } logRepo.info(LogEventType.SCAN_STOP, "Stopped phantom Vitruvian scan") @@ -191,9 +195,11 @@ class PhantomBleRepository( if (terminal.value) { return@withLock } - connectionAttemptGeneration.incrementAndGet() - _connectionState.value = ConnectionState.Disconnected - logRepo.warning(LogEventType.DISCONNECT, "Cancelled phantom connection") + if (_connectionState.value == ConnectionState.Connecting) { + connectionAttemptGeneration.incrementAndGet() + _connectionState.value = ConnectionState.Disconnected + logRepo.warning(LogEventType.DISCONNECT, "Cancelled phantom connection") + } } } @@ -330,6 +336,7 @@ class PhantomBleRepository( return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } logRepo.info(LogEventType.COMMAND_SENT, "Phantom workout stopped", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + repGeneration += 1 repJob?.cancel() workoutParams = null _handleState.value = HandleState.Released @@ -408,6 +415,10 @@ class PhantomBleRepository( return@withLock } metricsGeneration += 1 + heuristicGeneration += 1 + repGeneration += 1 + diagnosticGeneration += 1 + heartbeatGeneration += 1 metricsJob?.cancel() heuristicJob?.cancel() repJob?.cancel() @@ -686,12 +697,20 @@ class PhantomBleRepository( private inline fun publishIfConnected( expectedMetricsGeneration: Long? = null, + expectedHeuristicGeneration: Long? = null, + expectedRepGeneration: Long? = null, + expectedDiagnosticGeneration: Long? = null, + expectedHeartbeatGeneration: Long? = null, publish: () -> Unit, ): Boolean = lifecycleLock.withLock { if ( terminal.value || _connectionState.value !is ConnectionState.Connected || - (expectedMetricsGeneration != null && metricsGeneration != expectedMetricsGeneration) + (expectedMetricsGeneration != null && metricsGeneration != expectedMetricsGeneration) || + (expectedHeuristicGeneration != null && heuristicGeneration != expectedHeuristicGeneration) || + (expectedRepGeneration != null && repGeneration != expectedRepGeneration) || + (expectedDiagnosticGeneration != null && diagnosticGeneration != expectedDiagnosticGeneration) || + (expectedHeartbeatGeneration != null && heartbeatGeneration != expectedHeartbeatGeneration) ) { false } else { @@ -745,13 +764,15 @@ class PhantomBleRepository( } private fun startHeuristicGeneration(activeWorkout: Boolean) { + heuristicGeneration += 1 + val expectedGeneration = heuristicGeneration heuristicJob?.cancel() heuristicJob = scope.launch { while (isActive && connectionState.value is ConnectionState.Connected) { val config = _config.value val configuredLoad = workoutParams?.weightPerCableKg ?: 7.5f val load = (if (activeWorkout) configuredLoad.coerceAtLeast(2f) else 1.5f) * config.loadScale - if (!publishIfConnected { + if (!publishIfConnected(expectedHeuristicGeneration = expectedGeneration) { _heuristicData.value = HeuristicStatistics( concentric = HeuristicPhaseStatistics(load, load + 1.5f, 0.42f, 0.70f, 85f, 130f), eccentric = HeuristicPhaseStatistics(load * 0.9f, load + 1f, 0.38f, 0.62f, 72f, 110f), @@ -772,6 +793,8 @@ class PhantomBleRepository( } private fun startRepSimulation(params: WorkoutParameters) { + repGeneration += 1 + val expectedGeneration = repGeneration repJob?.cancel() repJob = scope.launch { var rep = 0 @@ -787,7 +810,7 @@ class PhantomBleRepository( bytes[18] = params.warmupReps.toByte() bytes[22] = target.toByte() } - if (!publishIfConnected { + if (!publishIfConnected(expectedRepGeneration = expectedGeneration) { _repEvents.tryEmit( RepNotification( topCounter = rep, @@ -823,12 +846,14 @@ class PhantomBleRepository( } private fun startDiagnostics() { + diagnosticGeneration += 1 + val expectedGeneration = diagnosticGeneration diagnosticJob?.cancel() diagnosticJob = scope.launch { val connectedAt = Clock.System.now().toEpochMilliseconds() while (isActive && connectionState.value is ConnectionState.Connected) { val now = Clock.System.now().toEpochMilliseconds() - if (!publishIfConnected { + if (!publishIfConnected(expectedDiagnosticGeneration = expectedGeneration) { _diagnostics.value = DiagnosticPacket( runtimeSeconds = (now - connectedAt) / 1000, faultWords = listOf(0, 0, 0, 0), @@ -846,10 +871,12 @@ class PhantomBleRepository( } private fun startHeartbeat() { + heartbeatGeneration += 1 + val expectedGeneration = heartbeatGeneration heartbeatJob?.cancel() heartbeatJob = scope.launch { while (isActive && connectionState.value is ConnectionState.Connected) { - if (!publishIfConnected { + if (!publishIfConnected(expectedHeartbeatGeneration = expectedGeneration) { logRepo.info(LogEventType.HEARTBEAT, "Phantom heartbeat", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) }) { break @@ -861,6 +888,10 @@ class PhantomBleRepository( private fun stopJobs() { metricsGeneration += 1 + heuristicGeneration += 1 + repGeneration += 1 + diagnosticGeneration += 1 + heartbeatGeneration += 1 metricsJob?.cancel() heuristicJob?.cancel() repJob?.cancel() diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index aaa1b6b9c..746fb597e 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -80,6 +80,67 @@ class PhantomBleRepositoryTest { } } + @Test + fun `stopScanning invalidates a scanning attempt and resets state`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val scanning = async(Dispatchers.Default) { repository.startScanning() } + + try { + repository.connectionState.first { it == ConnectionState.Scanning } + repository.stopScanning() + + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(scanning.await().isFailure) + } finally { + repository.shutdown() + } + } + + @Test + fun `stopScanning invalidates a connecting attempt and resets state`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val connecting = async(Dispatchers.Default) { + repository.connect(ScannedDevice("race", "race-address")) + } + + try { + repository.connectionState.first { it == ConnectionState.Connecting } + repository.stopScanning() + + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(connecting.await().isFailure) + } finally { + repository.shutdown() + } + } + + @Test + fun `cancelConnection leaves a connected repository intact`() = runTest { + val repository = PhantomBleRepository( + ConnectionLogRepository(), + PhantomBleConfig(repDelayMs = 100L), + ) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + repository.repEvents.test { + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + repository.cancelConnection() + + assertTrue(repository.connectionState.value is ConnectionState.Connected) + assertTrue(repository.scannedDevices.value.isNotEmpty()) + assertTrue(repository.handleDetection.value.leftDetected) + assertEquals(HandleState.Grabbed, repository.handleState.value) + assertTrue(repository.diagnostics.value != null) + assertTrue(repository.heuristicData.value != null) + awaitItem() + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + @Test fun `shutdown wins over an in-flight scan`() = runTest { val logRepo = ConnectionLogRepository() @@ -285,13 +346,19 @@ class PhantomBleRepositoryTest { @Test fun `replaceConfig scales generated phantom metrics`() = runTest { - val repository = PhantomBleRepository( - ConnectionLogRepository(), - PhantomBleConfig(loadScale = 2f, velocityScale = 3.0, positionScale = 0.5f, repDelayMs = 100L), - ) + val repository = PhantomBleRepository(ConnectionLogRepository()) repository.metricsFlow.test { assertTrue(repository.scanAndConnect().isSuccess) + awaitItem() + repository.replaceConfig( + PhantomBleConfig( + loadScale = 2f, + velocityScale = 3.0, + positionScale = 0.5f, + repDelayMs = 100L, + ), + ) val metric = awaitItem() assertTrue(metric.loadA >= 3.0f, "loadA should reflect doubled inactive phantom load") @@ -302,6 +369,100 @@ class PhantomBleRepositoryTest { } } + @Test + fun `fixed rep workout completes at target and stops simulation`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository( + logRepo, + PhantomBleConfig(repDelayMs = 100L), + ) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + repository.repEvents.test { + assertTrue(repository.startWorkout(workoutParameters().copy(reps = 2)).isSuccess) + + val first = awaitItem() + val second = awaitItem() + assertEquals(1, first.repsSetCount) + assertEquals(2, second.repsSetCount) + assertEquals(2, second.repsSetTotal) + assertTrue( + logRepo.getLogsByEventType(LogEventType.COMMAND_RESPONSE) + .any { it.message == "Phantom target reps reached" }, + ) + + withContext(Dispatchers.Default) { delay(250L) } + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + + @Test + fun `AMRAP workout continues past requested rep count`() = runTest { + val repository = PhantomBleRepository( + ConnectionLogRepository(), + PhantomBleConfig(repDelayMs = 100L), + ) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + repository.repEvents.test { + assertTrue( + repository.startWorkout( + workoutParameters().copy(reps = 2, isAMRAP = true), + ).isSuccess, + ) + + val first = awaitItem() + val second = awaitItem() + val third = awaitItem() + assertEquals(1, first.repsSetCount) + assertEquals(2, second.repsSetCount) + assertEquals(2, third.repsSetCount) + assertEquals(2, third.repsSetTotal) + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + + @Test + fun `stopping workout races scheduled rep publication without post-stop rep`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository( + logRepo, + PhantomBleConfig(repDelayMs = 100L), + ) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + val stopAfterRep = async(Dispatchers.Default) { + logRepo.logs.first { logs -> + logs.any { + it.eventType == LogEventType.REP_RECEIVED && it.message == "Phantom rep notification" + } + } + repository.stopWorkout() + } + + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + stopAfterRep.await() + assertEquals(HandleState.Released, repository.handleState.value) + val repLogsAfterStop = logRepo.getLogsByEventType(LogEventType.REP_RECEIVED).size + + withContext(Dispatchers.Default) { delay(200L) } + assertEquals(repLogsAfterStop, logRepo.getLogsByEventType(LogEventType.REP_RECEIVED).size) + } finally { + repository.shutdown() + } + } + @Test fun `stopMonitorPollingOnly stops metrics without stopping other polling`() = runTest { val repository = PhantomBleRepository( From e787444fb96fa3f6486a920d225c9dcb9afb784a Mon Sep 17 00:00:00 2001 From: Devil Date: Thu, 16 Jul 2026 21:08:29 -0400 Subject: [PATCH 13/54] fix(ios): honor Phantom stop and cancellation contracts --- .../data/repository/PhantomBleRepository.kt | 114 +++++++++++------- .../repository/PhantomBleRepositoryTest.kt | 100 +++++++++++++++ 2 files changed, 170 insertions(+), 44 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index bf206e363..0160047f6 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -155,7 +155,12 @@ class PhantomBleRepository( }) { return Result.failure(IllegalStateException("Phantom scan attempt invalidated")) } - delay(150) + try { + delay(150) + } catch (error: CancellationException) { + invalidateCancelledConnectionAttempt(attemptGeneration, ConnectionState.Scanning) + throw error + } if (!publishScannedDevices(attemptGeneration, listOf(device))) { return Result.failure(IllegalStateException("Phantom scan attempt invalidated")) } @@ -183,7 +188,12 @@ class PhantomBleRepository( }) { return Result.failure(IllegalStateException("Phantom connection attempt invalidated")) } - delay(250) + try { + delay(250) + } catch (error: CancellationException) { + invalidateCancelledConnectionAttempt(attemptGeneration, ConnectionState.Connecting) + throw error + } if (!completeConnection(attemptGeneration, device)) { return Result.failure(IllegalStateException("Phantom connection attempt invalidated")) } @@ -347,18 +357,14 @@ class PhantomBleRepository( } override suspend fun sendStopCommand(): Result { - val canSend = lifecycleLock.withLock { + return lifecycleLock.withLock { if (terminal.value) { - false + Result.failure(IllegalStateException("Phantom repository is shut down")) } else { logRepo.info(LogEventType.COMMAND_SENT, "Phantom stop command accepted", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) - true + Result.success(Unit) } } - if (!canSend) { - return Result.failure(IllegalStateException("Phantom repository is shut down")) - } - return stopWorkout() } override fun enableHandleDetection(enabled: Boolean) { @@ -631,6 +637,22 @@ class PhantomBleRepository( } } + private fun invalidateCancelledConnectionAttempt( + attemptGeneration: Long, + expectedState: ConnectionState, + ) { + lifecycleLock.withLock { + if ( + !terminal.value && + connectionAttemptGeneration.value == attemptGeneration && + _connectionState.value == expectedState + ) { + connectionAttemptGeneration.incrementAndGet() + _connectionState.value = ConnectionState.Disconnected + } + } + } + private inline fun publishConnectionState( attemptGeneration: Long, state: ConnectionState, @@ -721,6 +743,7 @@ class PhantomBleRepository( private fun startMetrics(activeWorkout: Boolean) { lifecycleLock.withLock { + val workoutWeightPerCableKg = workoutParams?.weightPerCableKg metricsGeneration += 1 val expectedGeneration = metricsGeneration metricsJob?.cancel() @@ -731,20 +754,20 @@ class PhantomBleRepository( val phase = (sample % 40) / 40.0 val wave = sin(phase * 2.0 * PI).toFloat() val config = _config.value - val configuredLoad = workoutParams?.weightPerCableKg ?: 7.5f + val configuredLoad = workoutWeightPerCableKg ?: 7.5f val load = (if (activeWorkout) configuredLoad.coerceAtLeast(2f) else 1.5f) * config.loadScale - val metric = WorkoutMetric( - timestamp = now, - loadA = load + wave.coerceAtLeast(0f) * config.loadScale, - loadB = load + (-wave).coerceAtLeast(0f) * config.loadScale, - positionA = wave * 650f * config.positionScale, - positionB = wave * 640f * config.positionScale, - ticks = ticks++, - velocityA = wave * 250.0 * config.velocityScale, - velocityB = wave * 245.0 * config.velocityScale, - status = 0, - ) if (!publishIfConnected(expectedMetricsGeneration = expectedGeneration) { + val metric = WorkoutMetric( + timestamp = now, + loadA = load + wave.coerceAtLeast(0f) * config.loadScale, + loadB = load + (-wave).coerceAtLeast(0f) * config.loadScale, + positionA = wave * 650f * config.positionScale, + positionB = wave * 640f * config.positionScale, + ticks = ticks++, + velocityA = wave * 250.0 * config.velocityScale, + velocityB = wave * 245.0 * config.velocityScale, + status = 0, + ) _metricsFlow.tryEmit(metric) logRepo.debug( LogEventType.NOTIFICATION, @@ -764,30 +787,33 @@ class PhantomBleRepository( } private fun startHeuristicGeneration(activeWorkout: Boolean) { - heuristicGeneration += 1 - val expectedGeneration = heuristicGeneration - heuristicJob?.cancel() - heuristicJob = scope.launch { - while (isActive && connectionState.value is ConnectionState.Connected) { - val config = _config.value - val configuredLoad = workoutParams?.weightPerCableKg ?: 7.5f - val load = (if (activeWorkout) configuredLoad.coerceAtLeast(2f) else 1.5f) * config.loadScale - if (!publishIfConnected(expectedHeuristicGeneration = expectedGeneration) { - _heuristicData.value = HeuristicStatistics( - concentric = HeuristicPhaseStatistics(load, load + 1.5f, 0.42f, 0.70f, 85f, 130f), - eccentric = HeuristicPhaseStatistics(load * 0.9f, load + 1f, 0.38f, 0.62f, 72f, 110f), - timestamp = Clock.System.now().toEpochMilliseconds(), - ) - logRepo.debug( - LogEventType.NOTIFICATION, - "Phantom heuristic update", - PHANTOM_DEVICE_NAME, - PHANTOM_DEVICE_ADDRESS, - ) - }) { - break + lifecycleLock.withLock { + val workoutWeightPerCableKg = workoutParams?.weightPerCableKg + heuristicGeneration += 1 + val expectedGeneration = heuristicGeneration + heuristicJob?.cancel() + heuristicJob = scope.launch { + while (isActive && connectionState.value is ConnectionState.Connected) { + val config = _config.value + val configuredLoad = workoutWeightPerCableKg ?: 7.5f + val load = (if (activeWorkout) configuredLoad.coerceAtLeast(2f) else 1.5f) * config.loadScale + if (!publishIfConnected(expectedHeuristicGeneration = expectedGeneration) { + _heuristicData.value = HeuristicStatistics( + concentric = HeuristicPhaseStatistics(load, load + 1.5f, 0.42f, 0.70f, 85f, 130f), + eccentric = HeuristicPhaseStatistics(load * 0.9f, load + 1f, 0.38f, 0.62f, 72f, 110f), + timestamp = Clock.System.now().toEpochMilliseconds(), + ) + logRepo.debug( + LogEventType.NOTIFICATION, + "Phantom heuristic update", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + ) + }) { + break + } + delay(if (activeWorkout) 250 else 750) } - delay(if (activeWorkout) 250 else 750) } } } diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 746fb597e..b32bb5434 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -4,6 +4,7 @@ import app.cash.turbine.test import com.devil.phoenixproject.domain.model.ConnectionState import com.devil.phoenixproject.domain.model.ProgramMode import com.devil.phoenixproject.domain.model.WorkoutParameters +import kotlin.coroutines.cancellation.CancellationException import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -80,6 +81,42 @@ class PhantomBleRepositoryTest { } } + @Test + fun `caller cancellation invalidates an in-flight scan`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val scanning = async(Dispatchers.Default) { repository.startScanning() } + + try { + repository.connectionState.first { it == ConnectionState.Scanning } + scanning.cancel() + + assertFailsWith { scanning.await() } + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + } finally { + repository.shutdown() + } + } + + @Test + fun `caller cancellation invalidates an in-flight connect`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val connecting = async(Dispatchers.Default) { + repository.connect(ScannedDevice("race", "race-address")) + } + + try { + repository.connectionState.first { it == ConnectionState.Connecting } + connecting.cancel() + + assertFailsWith { connecting.await() } + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + } finally { + repository.shutdown() + } + } + @Test fun `stopScanning invalidates a scanning attempt and resets state`() = runTest { val repository = PhantomBleRepository(ConnectionLogRepository()) @@ -463,6 +500,69 @@ class PhantomBleRepositoryTest { } } + @Test + fun `sendStopCommand preserves active workout polling until stopWorkout`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository( + logRepo, + PhantomBleConfig(repDelayMs = 100L), + ) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + repository.repEvents.test { + val activeMetric = async(Dispatchers.Default) { + repository.metricsFlow.first { it.loadA >= 2f } + } + assertTrue( + repository.startWorkout(workoutParameters().copy(isAMRAP = true)).isSuccess, + ) + + val firstRep = awaitItem() + val metricBeforeStop = activeMetric.await() + val heuristicBeforeStop = withTimeout(1_000L) { + requireNotNull(repository.heuristicData.first { it != null }) + } + val diagnosticsBeforeStop = withTimeout(1_000L) { + requireNotNull(repository.diagnostics.first { it != null }) + } + val heartbeatCountBeforeStop = logRepo.getLogsByEventType(LogEventType.HEARTBEAT).size + + assertTrue(repository.sendStopCommand().isSuccess) + assertEquals(HandleState.Grabbed, repository.handleState.value) + assertEquals(diagnosticsBeforeStop, repository.diagnostics.value) + assertTrue( + logRepo.getLogsByEventType(LogEventType.HEARTBEAT).size >= heartbeatCountBeforeStop, + ) + + val secondRep = awaitItem() + assertTrue(secondRep.topCounter > firstRep.topCounter) + val metricAfterStop = withContext(Dispatchers.Default) { + withTimeout(1_000L) { + repository.metricsFlow.first { it.loadA >= 2f } + } + } + assertTrue(metricAfterStop.timestamp >= metricBeforeStop.timestamp) + val heuristicAfterStop = withContext(Dispatchers.Default) { + withTimeout(1_000L) { + repository.heuristicData.first { it?.timestamp != heuristicBeforeStop.timestamp } + } + } + assertEquals( + heuristicBeforeStop.concentric.kgAvg, + requireNotNull(heuristicAfterStop).concentric.kgAvg, + ) + + assertTrue(repository.stopWorkout().isSuccess) + withContext(Dispatchers.Default) { delay(200L) } + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + @Test fun `stopMonitorPollingOnly stops metrics without stopping other polling`() = runTest { val repository = PhantomBleRepository( From 692a40e6728810a5a614db8f5c3275effd152b19 Mon Sep 17 00:00:00 2001 From: Devil Date: Thu, 16 Jul 2026 21:34:28 -0400 Subject: [PATCH 14/54] fix(ios): stop Phantom polling with workout --- .../data/repository/PhantomBleRepository.kt | 5 +- .../repository/PhantomBleRepositoryTest.kt | 87 ++++++++++--------- 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 0160047f6..92e568ccf 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -346,12 +346,9 @@ class PhantomBleRepository( return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } logRepo.info(LogEventType.COMMAND_SENT, "Phantom workout stopped", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) - repGeneration += 1 - repJob?.cancel() + stopJobs() workoutParams = null _handleState.value = HandleState.Released - startMetrics(activeWorkout = false) - startHeuristicGeneration(activeWorkout = false) Result.success(Unit) } } diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index b32bb5434..0d02f4d30 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -501,63 +501,66 @@ class PhantomBleRepositoryTest { } @Test - fun `sendStopCommand preserves active workout polling until stopWorkout`() = runTest { + fun `sendStopCommand preserves active polling until stopWorkout cancels every producer`() = runTest { val logRepo = ConnectionLogRepository() val repository = PhantomBleRepository( logRepo, PhantomBleConfig(repDelayMs = 100L), ) + val pollingMessages = listOf( + "Phantom monitor metric", + "Phantom heuristic update", + "Phantom diagnostic heartbeat", + "Phantom heartbeat", + "Phantom rep notification", + ) + fun pollingCounts(): Map = pollingMessages.associateWith { message -> + logRepo.logs.value.count { it.message == message } + } try { assertTrue(repository.scanAndConnect().isSuccess) - repository.repEvents.test { - val activeMetric = async(Dispatchers.Default) { - repository.metricsFlow.first { it.loadA >= 2f } - } - assertTrue( - repository.startWorkout(workoutParameters().copy(isAMRAP = true)).isSuccess, - ) - - val firstRep = awaitItem() - val metricBeforeStop = activeMetric.await() - val heuristicBeforeStop = withTimeout(1_000L) { - requireNotNull(repository.heuristicData.first { it != null }) - } - val diagnosticsBeforeStop = withTimeout(1_000L) { - requireNotNull(repository.diagnostics.first { it != null }) - } - val heartbeatCountBeforeStop = logRepo.getLogsByEventType(LogEventType.HEARTBEAT).size - - assertTrue(repository.sendStopCommand().isSuccess) - assertEquals(HandleState.Grabbed, repository.handleState.value) - assertEquals(diagnosticsBeforeStop, repository.diagnostics.value) - assertTrue( - logRepo.getLogsByEventType(LogEventType.HEARTBEAT).size >= heartbeatCountBeforeStop, - ) + assertTrue( + repository.startWorkout(workoutParameters().copy(isAMRAP = true)).isSuccess, + ) - val secondRep = awaitItem() - assertTrue(secondRep.topCounter > firstRep.topCounter) - val metricAfterStop = withContext(Dispatchers.Default) { - withTimeout(1_000L) { - repository.metricsFlow.first { it.loadA >= 2f } + withContext(Dispatchers.Default) { + withTimeout(3_500L) { + logRepo.logs.first { logs -> + pollingMessages.all { message -> logs.any { it.message == message } } } } - assertTrue(metricAfterStop.timestamp >= metricBeforeStop.timestamp) - val heuristicAfterStop = withContext(Dispatchers.Default) { - withTimeout(1_000L) { - repository.heuristicData.first { it?.timestamp != heuristicBeforeStop.timestamp } + } + val countsBeforeSendStop = pollingCounts() + assertTrue(countsBeforeSendStop.values.all { it > 0 }) + + assertTrue(repository.sendStopCommand().isSuccess) + assertTrue(repository.connectionState.value is ConnectionState.Connected) + assertEquals(HandleState.Grabbed, repository.handleState.value) + val countsAfterSendStop = withContext(Dispatchers.Default) { + withTimeout(3_500L) { + logRepo.logs.first { logs -> + pollingMessages.all { message -> + logs.count { it.message == message } > countsBeforeSendStop.getValue(message) + } } + pollingCounts() } - assertEquals( - heuristicBeforeStop.concentric.kgAvg, - requireNotNull(heuristicAfterStop).concentric.kgAvg, + } + pollingMessages.forEach { message -> + assertTrue( + countsAfterSendStop.getValue(message) > countsBeforeSendStop.getValue(message), + "$message should remain active after sendStopCommand", ) - - assertTrue(repository.stopWorkout().isSuccess) - withContext(Dispatchers.Default) { delay(200L) } - expectNoEvents() - cancelAndIgnoreRemainingEvents() } + + assertTrue(repository.stopWorkout().isSuccess) + assertTrue(repository.connectionState.value is ConnectionState.Connected) + assertEquals(HandleState.Released, repository.handleState.value) + val countsAfterStop = pollingCounts() + + withContext(Dispatchers.Default) { delay(2_250L) } + assertEquals(countsAfterStop, pollingCounts()) } finally { repository.shutdown() } From 41451741b41fcad4803520d3797f93f604d244e8 Mon Sep 17 00:00:00 2001 From: Devil Date: Thu, 16 Jul 2026 21:45:48 -0400 Subject: [PATCH 15/54] fix(ios): preserve Phantom rep completion state --- .../data/repository/PhantomBleRepository.kt | 2 +- .../repository/PhantomBleRepositoryTest.kt | 50 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 92e568ccf..3ad70cf13 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -621,7 +621,7 @@ class PhantomBleRepository( if (_connectionState.value is ConnectionState.Connected) { startMetrics(activeWorkout = workoutParams != null) startHeuristicGeneration(activeWorkout = workoutParams != null) - workoutParams?.let(::startRepSimulation) + workoutParams?.takeIf { repJob?.isActive == true }?.let(::startRepSimulation) } } } diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 0d02f4d30..0be6b22b8 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -438,6 +438,56 @@ class PhantomBleRepositoryTest { } } + @Test + fun `replaceConfig restarts an active rep simulation`() = runTest { + val repository = PhantomBleRepository( + ConnectionLogRepository(), + PhantomBleConfig(repDelayMs = 100L), + ) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + repository.repEvents.test { + assertTrue(repository.startWorkout(workoutParameters().copy(reps = 3)).isSuccess) + assertEquals(1, awaitItem().repsSetCount) + + repository.replaceConfig(PhantomBleConfig(repDelayMs = 100L)) + + val restarted = awaitItem() + assertEquals(1, restarted.repsSetCount) + assertEquals(3, restarted.repsSetTotal) + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + + @Test + fun `replaceConfig does not restart a completed fixed rep simulation`() = runTest { + val repository = PhantomBleRepository( + ConnectionLogRepository(), + PhantomBleConfig(repDelayMs = 100L), + ) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + repository.repEvents.test { + assertTrue(repository.startWorkout(workoutParameters().copy(reps = 2)).isSuccess) + assertEquals(1, awaitItem().repsSetCount) + assertEquals(2, awaitItem().repsSetCount) + + withContext(Dispatchers.Default) { delay(250L) } + repository.replaceConfig(PhantomBleConfig(repDelayMs = 100L)) + withContext(Dispatchers.Default) { delay(250L) } + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + @Test fun `AMRAP workout continues past requested rep count`() = runTest { val repository = PhantomBleRepository( From 0911004725f1213c2ebd47242c9cc2893999a409 Mon Sep 17 00:00:00 2001 From: Devil Date: Thu, 16 Jul 2026 22:06:14 -0400 Subject: [PATCH 16/54] fix(ios): initialize Phantom connected state synchronously --- .../data/repository/PhantomBleRepository.kt | 33 +++++++++++++++++-- .../repository/PhantomBleRepositoryTest.kt | 5 ++- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 3ad70cf13..bc458d91c 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -789,8 +789,25 @@ class PhantomBleRepository( heuristicGeneration += 1 val expectedGeneration = heuristicGeneration heuristicJob?.cancel() + publishIfConnected(expectedHeuristicGeneration = expectedGeneration) { + val config = _config.value + val configuredLoad = workoutWeightPerCableKg ?: 7.5f + val load = (if (activeWorkout) configuredLoad.coerceAtLeast(2f) else 1.5f) * config.loadScale + _heuristicData.value = HeuristicStatistics( + concentric = HeuristicPhaseStatistics(load, load + 1.5f, 0.42f, 0.70f, 85f, 130f), + eccentric = HeuristicPhaseStatistics(load * 0.9f, load + 1f, 0.38f, 0.62f, 72f, 110f), + timestamp = Clock.System.now().toEpochMilliseconds(), + ) + logRepo.debug( + LogEventType.NOTIFICATION, + "Phantom heuristic update", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + ) + } heuristicJob = scope.launch { while (isActive && connectionState.value is ConnectionState.Connected) { + delay(if (activeWorkout) 250 else 750) val config = _config.value val configuredLoad = workoutWeightPerCableKg ?: 7.5f val load = (if (activeWorkout) configuredLoad.coerceAtLeast(2f) else 1.5f) * config.loadScale @@ -809,7 +826,6 @@ class PhantomBleRepository( }) { break } - delay(if (activeWorkout) 250 else 750) } } } @@ -872,9 +888,21 @@ class PhantomBleRepository( diagnosticGeneration += 1 val expectedGeneration = diagnosticGeneration diagnosticJob?.cancel() + val connectedAt = Clock.System.now().toEpochMilliseconds() + publishIfConnected(expectedDiagnosticGeneration = expectedGeneration) { + val now = Clock.System.now().toEpochMilliseconds() + _diagnostics.value = DiagnosticPacket( + runtimeSeconds = (now - connectedAt) / 1000, + faultWords = listOf(0, 0, 0, 0), + temperatures = listOf(34, 35, 34, 35, 36, 36, 35, 34), + hasFaults = false, + receivedAtMillis = now, + ) + logRepo.debug(LogEventType.DIAGNOSTIC, "Phantom diagnostic heartbeat", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + } diagnosticJob = scope.launch { - val connectedAt = Clock.System.now().toEpochMilliseconds() while (isActive && connectionState.value is ConnectionState.Connected) { + delay(2_000) val now = Clock.System.now().toEpochMilliseconds() if (!publishIfConnected(expectedDiagnosticGeneration = expectedGeneration) { _diagnostics.value = DiagnosticPacket( @@ -888,7 +916,6 @@ class PhantomBleRepository( }) { break } - delay(2_000) } } } diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 0be6b22b8..a7ece4252 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -152,7 +152,7 @@ class PhantomBleRepositoryTest { } @Test - fun `cancelConnection leaves a connected repository intact`() = runTest { + fun `connected repository initializes diagnostics and heuristic state before workout cancellation`() = runTest { val repository = PhantomBleRepository( ConnectionLogRepository(), PhantomBleConfig(repDelayMs = 100L), @@ -160,6 +160,9 @@ class PhantomBleRepositoryTest { try { assertTrue(repository.scanAndConnect().isSuccess) + assertTrue(repository.diagnostics.value != null) + assertTrue(repository.heuristicData.value != null) + repository.repEvents.test { assertTrue(repository.startWorkout(workoutParameters()).isSuccess) repository.cancelConnection() From 974a1ccba396c17b15f38771d1e91cedbdc7f5ae Mon Sep 17 00:00:00 2001 From: Devil Date: Thu, 16 Jul 2026 22:34:06 -0400 Subject: [PATCH 17/54] fix(ios): preserve Phantom scan-connect operation generation --- .../data/repository/PhantomBleRepository.kt | 15 +++++++++++++-- .../data/repository/PhantomBleRepositoryTest.kt | 2 ++ 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index bc458d91c..080ea8ea1 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -150,6 +150,10 @@ class PhantomBleRepository( override suspend fun startScanning(): Result { val attemptGeneration = beginConnectionAttempt() ?: return Result.failure(IllegalStateException("Phantom repository is shut down")) + return startScanning(attemptGeneration) + } + + private suspend fun startScanning(attemptGeneration: Long): Result { if (!publishConnectionState(attemptGeneration, ConnectionState.Scanning) { logRepo.info(LogEventType.SCAN_START, "Starting phantom Vitruvian scan") }) { @@ -183,6 +187,10 @@ class PhantomBleRepository( override suspend fun connect(device: ScannedDevice): Result { val attemptGeneration = beginConnectionAttempt() ?: return Result.failure(IllegalStateException("Phantom repository is shut down")) + return connect(device, attemptGeneration) + } + + private suspend fun connect(device: ScannedDevice, attemptGeneration: Long): Result { if (!publishConnectionState(attemptGeneration, ConnectionState.Connecting) { logRepo.info(LogEventType.CONNECT_START, "Connecting to phantom Vitruvian", device.name, device.address) }) { @@ -243,12 +251,15 @@ class PhantomBleRepository( return Result.failure(IllegalStateException("Phantom repository is shut down")) } + val attemptGeneration = beginConnectionAttempt() + ?: return Result.failure(IllegalStateException("Phantom repository is shut down")) + val completed = withTimeoutOrNull(timeoutMs) { - val scanResult = startScanning() + val scanResult = startScanning(attemptGeneration) if (scanResult.isFailure) { return@withTimeoutOrNull scanResult } - connect(device) + connect(device, attemptGeneration) } if (completed != null) { return completed diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index a7ece4252..eb01f9775 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -234,10 +234,12 @@ class PhantomBleRepositoryTest { } try { + repository.connectionState.first { it == ConnectionState.Connecting } repository.shutdown() val logsAfterShutdown = logRepo.logs.value.size assertTrue(connecting.await().isFailure) + withContext(Dispatchers.Default) { delay(350L) } assertEquals(logsAfterShutdown, logRepo.logs.value.size) assertEquals(ConnectionState.Disconnected, repository.connectionState.value) assertTrue(repository.scannedDevices.value.isEmpty()) From 5fa60f9860d6884b5cac08f09b9f514035208358 Mon Sep 17 00:00:00 2001 From: Devil Date: Thu, 16 Jul 2026 23:23:07 -0400 Subject: [PATCH 18/54] fix(ios): close final Phantom race boundaries --- .../data/repository/PhantomBleRepository.kt | 67 ++++++++------- .../repository/PhantomBleRepositoryTest.kt | 84 +++++++++++++++++++ 2 files changed, 123 insertions(+), 28 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 080ea8ea1..63f86f584 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -131,6 +131,7 @@ class PhantomBleRepository( private var metricsJob: Job? = null private var heuristicJob: Job? = null private var repJob: Job? = null + private var repSimulationCompleted = false private var diagnosticJob: Job? = null private var heartbeatJob: Job? = null private var workoutParams: WorkoutParameters? = null @@ -150,21 +151,22 @@ class PhantomBleRepository( override suspend fun startScanning(): Result { val attemptGeneration = beginConnectionAttempt() ?: return Result.failure(IllegalStateException("Phantom repository is shut down")) - return startScanning(attemptGeneration) + return try { + startScanning(attemptGeneration) + } catch (error: CancellationException) { + invalidateCancelledConnectionAttempt(attemptGeneration, ConnectionState.Scanning) + throw error + } } private suspend fun startScanning(attemptGeneration: Long): Result { if (!publishConnectionState(attemptGeneration, ConnectionState.Scanning) { + _scannedDevices.value = emptyList() logRepo.info(LogEventType.SCAN_START, "Starting phantom Vitruvian scan") }) { return Result.failure(IllegalStateException("Phantom scan attempt invalidated")) } - try { - delay(150) - } catch (error: CancellationException) { - invalidateCancelledConnectionAttempt(attemptGeneration, ConnectionState.Scanning) - throw error - } + delay(150) if (!publishScannedDevices(attemptGeneration, listOf(device))) { return Result.failure(IllegalStateException("Phantom scan attempt invalidated")) } @@ -187,7 +189,12 @@ class PhantomBleRepository( override suspend fun connect(device: ScannedDevice): Result { val attemptGeneration = beginConnectionAttempt() ?: return Result.failure(IllegalStateException("Phantom repository is shut down")) - return connect(device, attemptGeneration) + return try { + connect(device, attemptGeneration) + } catch (error: CancellationException) { + invalidateCancelledConnectionAttempt(attemptGeneration, ConnectionState.Connecting) + throw error + } } private suspend fun connect(device: ScannedDevice, attemptGeneration: Long): Result { @@ -196,12 +203,7 @@ class PhantomBleRepository( }) { return Result.failure(IllegalStateException("Phantom connection attempt invalidated")) } - try { - delay(250) - } catch (error: CancellationException) { - invalidateCancelledConnectionAttempt(attemptGeneration, ConnectionState.Connecting) - throw error - } + delay(250) if (!completeConnection(attemptGeneration, device)) { return Result.failure(IllegalStateException("Phantom connection attempt invalidated")) } @@ -247,27 +249,28 @@ class PhantomBleRepository( return Result.failure(IllegalArgumentException("timeoutMs must be > 0")) } - if (lifecycleLock.withLock { terminal.value }) { - return Result.failure(IllegalStateException("Phantom repository is shut down")) - } - val attemptGeneration = beginConnectionAttempt() ?: return Result.failure(IllegalStateException("Phantom repository is shut down")) - val completed = withTimeoutOrNull(timeoutMs) { - val scanResult = startScanning(attemptGeneration) - if (scanResult.isFailure) { - return@withTimeoutOrNull scanResult + val completed = try { + withTimeoutOrNull(timeoutMs) { + val scanResult = startScanning(attemptGeneration) + if (scanResult.isFailure) { + return@withTimeoutOrNull scanResult + } + connect(device, attemptGeneration) } - connect(device, attemptGeneration) + } catch (error: CancellationException) { + invalidateCancelledConnectionAttempt(attemptGeneration) + throw error } if (completed != null) { return completed } return lifecycleLock.withLock { - if (terminal.value) { - Result.failure(IllegalStateException("Phantom repository is shut down")) + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + Result.failure(IllegalStateException("Phantom scan and connect attempt invalidated")) } else { teardownConnection() logRepo.error( @@ -632,7 +635,7 @@ class PhantomBleRepository( if (_connectionState.value is ConnectionState.Connected) { startMetrics(activeWorkout = workoutParams != null) startHeuristicGeneration(activeWorkout = workoutParams != null) - workoutParams?.takeIf { repJob?.isActive == true }?.let(::startRepSimulation) + workoutParams?.takeIf { repJob?.isActive == true && !repSimulationCompleted }?.let(::startRepSimulation) } } } @@ -647,13 +650,17 @@ class PhantomBleRepository( private fun invalidateCancelledConnectionAttempt( attemptGeneration: Long, - expectedState: ConnectionState, + expectedState: ConnectionState? = null, ) { lifecycleLock.withLock { if ( !terminal.value && connectionAttemptGeneration.value == attemptGeneration && - _connectionState.value == expectedState + ( + (expectedState == null && + (_connectionState.value == ConnectionState.Scanning || _connectionState.value == ConnectionState.Connecting)) || + (expectedState != null && _connectionState.value == expectedState) + ) ) { connectionAttemptGeneration.incrementAndGet() _connectionState.value = ConnectionState.Disconnected @@ -843,6 +850,7 @@ class PhantomBleRepository( } private fun startRepSimulation(params: WorkoutParameters) { + repSimulationCompleted = false repGeneration += 1 val expectedGeneration = repGeneration repJob?.cancel() @@ -875,6 +883,9 @@ class PhantomBleRepository( timestamp = timestamp, ), ) + if (rep >= target && !params.isAMRAP && config.autoCompleteFixedRepSets) { + repSimulationCompleted = true + } logRepo.info( LogEventType.REP_RECEIVED, "Phantom rep notification", diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index eb01f9775..670dcb80d 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -151,6 +151,28 @@ class PhantomBleRepositoryTest { } } + @Test + fun `starting a new scan clears devices from the previous scan`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + assertTrue(repository.scannedDevices.value.isNotEmpty()) + + val scanning = async(Dispatchers.Default) { repository.startScanning() } + withContext(Dispatchers.Default) { + withTimeout(1_000L) { + repository.scannedDevices.first { it.isEmpty() } + } + } + + assertEquals(ConnectionState.Scanning, repository.connectionState.value) + assertTrue(scanning.await().isSuccess) + } finally { + repository.shutdown() + } + } + @Test fun `connected repository initializes diagnostics and heuristic state before workout cancellation`() = runTest { val repository = PhantomBleRepository( @@ -493,6 +515,39 @@ class PhantomBleRepositoryTest { } } + @Test + fun `replaceConfig immediately after final rep does not duplicate completed sequence`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository( + logRepo, + PhantomBleConfig(repDelayMs = 100L), + ) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + val replaceOnFinalRep = async(Dispatchers.Unconfined) { + logRepo.logs.first { logs -> + logs.any { + it.eventType == LogEventType.REP_RECEIVED && it.details?.startsWith("rep=2/2") == true + } + } + repository.replaceConfig(PhantomBleConfig(repDelayMs = 100L)) + } + repository.repEvents.test { + assertTrue(repository.startWorkout(workoutParameters().copy(reps = 2)).isSuccess) + assertEquals(1, awaitItem().repsSetCount) + assertEquals(2, awaitItem().repsSetCount) + + replaceOnFinalRep.await() + withContext(Dispatchers.Default) { delay(250L) } + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + @Test fun `AMRAP workout continues past requested rep count`() = runTest { val repository = PhantomBleRepository( @@ -697,6 +752,35 @@ class PhantomBleRepositoryTest { } } + @Test + fun `timed out scan does not tear down an explicitly started newer scan`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + + try { + repository.reconnectionRequested.test { + val timedOut = async(Dispatchers.Default) { + repository.scanAndConnect(timeoutMs = 250L) + } + repository.connectionState.first { it == ConnectionState.Connecting } + + repository.stopScanning() + val newerScan = async(Dispatchers.Default) { repository.startScanning() } + repository.connectionState.first { it == ConnectionState.Scanning } + + assertTrue(timedOut.await().isFailure) + assertTrue(newerScan.await().isSuccess) + assertTrue(repository.scannedDevices.value.isNotEmpty()) + assertEquals(ConnectionState.Scanning, repository.connectionState.value) + expectNoEvents() + assertTrue(logRepo.getLogsByEventType(LogEventType.ERROR).isEmpty()) + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + @Test fun `shutdown makes scanAndConnect return failure without timeout side effects`() = runTest { val logRepo = ConnectionLogRepository() From 78b58f3d37512a12205b05b8990a6c2b72a321d9 Mon Sep 17 00:00:00 2001 From: Devil Date: Thu, 16 Jul 2026 23:47:05 -0400 Subject: [PATCH 19/54] fix(ios): publish Phantom rep completion atomically --- .../data/repository/PhantomBleRepository.kt | 6 +++--- .../data/repository/PhantomBleRepositoryTest.kt | 10 ++++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 63f86f584..639496b71 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -869,6 +869,9 @@ class PhantomBleRepository( bytes[22] = target.toByte() } if (!publishIfConnected(expectedRepGeneration = expectedGeneration) { + if (rep >= target && !params.isAMRAP && config.autoCompleteFixedRepSets) { + repSimulationCompleted = true + } _repEvents.tryEmit( RepNotification( topCounter = rep, @@ -883,9 +886,6 @@ class PhantomBleRepository( timestamp = timestamp, ), ) - if (rep >= target && !params.isAMRAP && config.autoCompleteFixedRepSets) { - repSimulationCompleted = true - } logRepo.info( LogEventType.REP_RECEIVED, "Phantom rep notification", diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 670dcb80d..55446e211 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -526,12 +526,14 @@ class PhantomBleRepositoryTest { try { assertTrue(repository.scanAndConnect().isSuccess) val replaceOnFinalRep = async(Dispatchers.Unconfined) { - logRepo.logs.first { logs -> - logs.any { - it.eventType == LogEventType.REP_RECEIVED && it.details?.startsWith("rep=2/2") == true + repository.repEvents.first { event -> + if (event.repsSetCount == 2) { + repository.replaceConfig(PhantomBleConfig(repDelayMs = 100L)) + true + } else { + false } } - repository.replaceConfig(PhantomBleConfig(repDelayMs = 100L)) } repository.repEvents.test { assertTrue(repository.startWorkout(workoutParameters().copy(reps = 2)).isSuccess) From 7b23c5b61b9e2bbe5fa58b554775b9019c3e2636 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 00:19:09 -0400 Subject: [PATCH 20/54] fix(ios): harden Phantom reentrant lifecycle boundaries --- .../data/repository/PhantomBleRepository.kt | 129 +++++++++++++----- .../repository/PhantomBleRepositoryTest.kt | 59 ++++++++ 2 files changed, 155 insertions(+), 33 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 639496b71..79c6943ca 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -552,6 +552,9 @@ class PhantomBleRepository( val metric = monitorProcessor.process(packet) ?: error("monitor packet parsed but was rejected by validation") _metricsFlow.tryEmit(metric) + if (terminal.value) { + return@withLock + } logRepo.info( LogEventType.NOTIFICATION, "Phantom injected raw monitor packet", @@ -571,6 +574,9 @@ class PhantomBleRepository( val rep = parseRepPacket(data, hasOpcodePrefix, timestamp) ?: error("rep packet too short: ${data.size} bytes") _repEvents.tryEmit(rep) + if (terminal.value) { + return@withLock + } logRepo.info( LogEventType.REP_RECEIVED, "Phantom injected raw rep packet", @@ -590,6 +596,9 @@ class PhantomBleRepository( val diagnostic = parseDiagnosticPacket(data) ?: error("diagnostic packet too short: ${data.size} bytes") _diagnostics.value = diagnostic.copy(receivedAtMillis = timestamp) + if (terminal.value) { + return@withLock + } logRepo.info( LogEventType.DIAGNOSTIC, "Phantom injected raw diagnostic packet", @@ -609,6 +618,9 @@ class PhantomBleRepository( val heuristic = parseHeuristicPacket(data, timestamp) ?: error("heuristic packet too short: ${data.size} bytes") _heuristicData.value = heuristic + if (terminal.value) { + return@withLock + } logRepo.info( LogEventType.NOTIFICATION, "Phantom injected raw heuristic packet", @@ -703,14 +715,41 @@ class PhantomBleRepository( return@withLock false } _connectionState.value = ConnectionState.Connected(device.name, device.address) + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + return@withLock false + } _handleDetection.value = HandleDetection(leftDetected = true, rightDetected = true) + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + return@withLock false + } _handleState.value = HandleState.Released + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + return@withLock false + } logRepo.info(LogEventType.SERVICE_DISCOVERED, "Phantom service map ready", device.name, device.address) + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + return@withLock false + } logRepo.info(LogEventType.CONNECT_SUCCESS, "Connected to phantom Vitruvian", device.name, device.address) + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + return@withLock false + } startDiagnostics() + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + return@withLock false + } startMetrics(activeWorkout = false) + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + return@withLock false + } startHeuristicGeneration(activeWorkout = false) + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + return@withLock false + } startHeartbeat() + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + return@withLock false + } true } @@ -752,7 +791,13 @@ class PhantomBleRepository( false } else { publish() - true + !terminal.value && + _connectionState.value is ConnectionState.Connected && + (expectedMetricsGeneration == null || metricsGeneration == expectedMetricsGeneration) && + (expectedHeuristicGeneration == null || heuristicGeneration == expectedHeuristicGeneration) && + (expectedRepGeneration == null || repGeneration == expectedRepGeneration) && + (expectedDiagnosticGeneration == null || diagnosticGeneration == expectedDiagnosticGeneration) && + (expectedHeartbeatGeneration == null || heartbeatGeneration == expectedHeartbeatGeneration) } } @@ -784,13 +829,15 @@ class PhantomBleRepository( status = 0, ) _metricsFlow.tryEmit(metric) - logRepo.debug( - LogEventType.NOTIFICATION, - "Phantom monitor metric", - PHANTOM_DEVICE_NAME, - PHANTOM_DEVICE_ADDRESS, - "ticks=${metric.ticks}; load=${metric.totalLoad}; posA=${metric.positionA}", - ) + if (!terminal.value && metricsGeneration == expectedGeneration) { + logRepo.debug( + LogEventType.NOTIFICATION, + "Phantom monitor metric", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "ticks=${metric.ticks}; load=${metric.totalLoad}; posA=${metric.positionA}", + ) + } }) { break } @@ -807,7 +854,7 @@ class PhantomBleRepository( heuristicGeneration += 1 val expectedGeneration = heuristicGeneration heuristicJob?.cancel() - publishIfConnected(expectedHeuristicGeneration = expectedGeneration) { + if (!publishIfConnected(expectedHeuristicGeneration = expectedGeneration) { val config = _config.value val configuredLoad = workoutWeightPerCableKg ?: 7.5f val load = (if (activeWorkout) configuredLoad.coerceAtLeast(2f) else 1.5f) * config.loadScale @@ -816,12 +863,16 @@ class PhantomBleRepository( eccentric = HeuristicPhaseStatistics(load * 0.9f, load + 1f, 0.38f, 0.62f, 72f, 110f), timestamp = Clock.System.now().toEpochMilliseconds(), ) - logRepo.debug( - LogEventType.NOTIFICATION, - "Phantom heuristic update", - PHANTOM_DEVICE_NAME, - PHANTOM_DEVICE_ADDRESS, - ) + if (!terminal.value && heuristicGeneration == expectedGeneration) { + logRepo.debug( + LogEventType.NOTIFICATION, + "Phantom heuristic update", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + ) + } + }) { + return@withLock } heuristicJob = scope.launch { while (isActive && connectionState.value is ConnectionState.Connected) { @@ -835,12 +886,14 @@ class PhantomBleRepository( eccentric = HeuristicPhaseStatistics(load * 0.9f, load + 1f, 0.38f, 0.62f, 72f, 110f), timestamp = Clock.System.now().toEpochMilliseconds(), ) - logRepo.debug( - LogEventType.NOTIFICATION, - "Phantom heuristic update", - PHANTOM_DEVICE_NAME, - PHANTOM_DEVICE_ADDRESS, - ) + if (!terminal.value && heuristicGeneration == expectedGeneration) { + logRepo.debug( + LogEventType.NOTIFICATION, + "Phantom heuristic update", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + ) + } }) { break } @@ -886,15 +939,19 @@ class PhantomBleRepository( timestamp = timestamp, ), ) - logRepo.info( - LogEventType.REP_RECEIVED, - "Phantom rep notification", - PHANTOM_DEVICE_NAME, - PHANTOM_DEVICE_ADDRESS, - "rep=$rep/$target; timestamp=$timestamp", - ) - if (rep >= target && !params.isAMRAP && config.autoCompleteFixedRepSets) { - logRepo.info(LogEventType.COMMAND_RESPONSE, "Phantom target reps reached", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + if (!terminal.value && repGeneration == expectedGeneration) { + logRepo.info( + LogEventType.REP_RECEIVED, + "Phantom rep notification", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "rep=$rep/$target; timestamp=$timestamp", + ) + if (!terminal.value && repGeneration == expectedGeneration && + rep >= target && !params.isAMRAP && config.autoCompleteFixedRepSets + ) { + logRepo.info(LogEventType.COMMAND_RESPONSE, "Phantom target reps reached", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + } } }) { break @@ -911,7 +968,7 @@ class PhantomBleRepository( val expectedGeneration = diagnosticGeneration diagnosticJob?.cancel() val connectedAt = Clock.System.now().toEpochMilliseconds() - publishIfConnected(expectedDiagnosticGeneration = expectedGeneration) { + if (!publishIfConnected(expectedDiagnosticGeneration = expectedGeneration) { val now = Clock.System.now().toEpochMilliseconds() _diagnostics.value = DiagnosticPacket( runtimeSeconds = (now - connectedAt) / 1000, @@ -920,7 +977,11 @@ class PhantomBleRepository( hasFaults = false, receivedAtMillis = now, ) - logRepo.debug(LogEventType.DIAGNOSTIC, "Phantom diagnostic heartbeat", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + if (!terminal.value && diagnosticGeneration == expectedGeneration) { + logRepo.debug(LogEventType.DIAGNOSTIC, "Phantom diagnostic heartbeat", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + } + }) { + return } diagnosticJob = scope.launch { while (isActive && connectionState.value is ConnectionState.Connected) { @@ -934,7 +995,9 @@ class PhantomBleRepository( hasFaults = false, receivedAtMillis = now, ) - logRepo.debug(LogEventType.DIAGNOSTIC, "Phantom diagnostic heartbeat", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + if (!terminal.value && diagnosticGeneration == expectedGeneration) { + logRepo.debug(LogEventType.DIAGNOSTIC, "Phantom diagnostic heartbeat", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + } }) { break } diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 55446e211..6d4020907 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -273,6 +273,65 @@ class PhantomBleRepositoryTest { } } + @Test + fun `shutdown from connected collector invalidates completing connection`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val shutdownOnConnected = async(Dispatchers.Unconfined) { + repository.connectionState.first { state -> + if (state is ConnectionState.Connected) { + repository.shutdown() + true + } else { + false + } + } + } + + try { + val result = repository.connect(ScannedDevice("collector", "collector-address")) + + shutdownOnConnected.await() + + assertTrue(result.isFailure) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + assertNull(repository.diagnostics.value) + assertNull(repository.heuristicData.value) + } finally { + repository.shutdown() + } + } + + @Test + fun `shutdown from metric collector prevents post-terminal metric logging and state repopulation`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val shutdownOnMetric = async(Dispatchers.Unconfined) { + repository.metricsFlow.first { + repository.shutdown() + true + } + } + + try { + repository.scanAndConnect() + shutdownOnMetric.await() + val logsAfterShutdown = logRepo.logs.value.size + + withContext(Dispatchers.Default) { delay(350L) } + + assertEquals(logsAfterShutdown, logRepo.logs.value.size) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + assertNull(repository.diagnostics.value) + assertNull(repository.heuristicData.value) + assertFalse(repository.handleDetection.value.leftDetected) + assertFalse(repository.handleDetection.value.rightDetected) + } finally { + repository.shutdown() + } + } + @Test fun `injectRawPacket parses legacy rep bytes through protocol parser`() = runTest { val repository = PhantomBleRepository(ConnectionLogRepository()) From 2ec92a474a6df2385910f1ca5d8da5b95f32a0a0 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 00:47:17 -0400 Subject: [PATCH 21/54] fix(ios): close Phantom post-publication reentrancy gaps --- .../data/repository/PhantomBleRepository.kt | 194 ++++++++++++++---- .../repository/PhantomBleRepositoryTest.kt | 154 ++++++++++++++ 2 files changed, 313 insertions(+), 35 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 79c6943ca..83f7dc840 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -162,7 +162,12 @@ class PhantomBleRepository( private suspend fun startScanning(attemptGeneration: Long): Result { if (!publishConnectionState(attemptGeneration, ConnectionState.Scanning) { _scannedDevices.value = emptyList() - logRepo.info(LogEventType.SCAN_START, "Starting phantom Vitruvian scan") + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + false + } else { + logRepo.info(LogEventType.SCAN_START, "Starting phantom Vitruvian scan") + true + } }) { return Result.failure(IllegalStateException("Phantom scan attempt invalidated")) } @@ -182,6 +187,9 @@ class PhantomBleRepository( if (_connectionState.value == ConnectionState.Scanning || _connectionState.value == ConnectionState.Connecting) { _connectionState.value = ConnectionState.Disconnected } + if (terminal.value) { + return@withLock + } logRepo.info(LogEventType.SCAN_STOP, "Stopped phantom Vitruvian scan") } } @@ -200,6 +208,7 @@ class PhantomBleRepository( private suspend fun connect(device: ScannedDevice, attemptGeneration: Long): Result { if (!publishConnectionState(attemptGeneration, ConnectionState.Connecting) { logRepo.info(LogEventType.CONNECT_START, "Connecting to phantom Vitruvian", device.name, device.address) + true }) { return Result.failure(IllegalStateException("Phantom connection attempt invalidated")) } @@ -218,6 +227,9 @@ class PhantomBleRepository( if (_connectionState.value == ConnectionState.Connecting) { connectionAttemptGeneration.incrementAndGet() _connectionState.value = ConnectionState.Disconnected + if (terminal.value) { + return@withLock + } logRepo.warning(LogEventType.DISCONNECT, "Cancelled phantom connection") } } @@ -229,6 +241,9 @@ class PhantomBleRepository( return@withLock } logRepo.info(LogEventType.DISCONNECT, "Disconnected phantom Vitruvian", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + if (terminal.value) { + return@withLock + } teardownConnection() } } @@ -239,6 +254,9 @@ class PhantomBleRepository( return@withLock } logRepo.info(LogEventType.DISCONNECT, "Disconnected phantom Vitruvian", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + if (terminal.value) { + return@withLock + } teardownConnection(markTerminal = true) } repositoryJob.cancel() @@ -272,23 +290,32 @@ class PhantomBleRepository( if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { Result.failure(IllegalStateException("Phantom scan and connect attempt invalidated")) } else { + val expectedPostTeardownGeneration = attemptGeneration + 1L teardownConnection() - logRepo.error( - LogEventType.ERROR, - "Phantom scan and connect timed out", - PHANTOM_DEVICE_NAME, - PHANTOM_DEVICE_ADDRESS, - "timeoutMs=$timeoutMs", - ) - _reconnectionRequested.tryEmit( - ReconnectionRequest( - deviceName = PHANTOM_DEVICE_NAME, - deviceAddress = PHANTOM_DEVICE_ADDRESS, - reason = "connection_timeout", - timestamp = Clock.System.now().toEpochMilliseconds(), - ), - ) - Result.failure(IllegalStateException("Phantom scan and connect timed out after ${timeoutMs}ms")) + if (terminal.value || connectionAttemptGeneration.value != expectedPostTeardownGeneration) { + Result.failure(IllegalStateException("Phantom scan and connect attempt invalidated")) + } else { + logRepo.error( + LogEventType.ERROR, + "Phantom scan and connect timed out", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "timeoutMs=$timeoutMs", + ) + _reconnectionRequested.tryEmit( + ReconnectionRequest( + deviceName = PHANTOM_DEVICE_NAME, + deviceAddress = PHANTOM_DEVICE_ADDRESS, + reason = "connection_timeout", + timestamp = Clock.System.now().toEpochMilliseconds(), + ), + ) + if (terminal.value || connectionAttemptGeneration.value != expectedPostTeardownGeneration) { + Result.failure(IllegalStateException("Phantom scan and connect attempt invalidated")) + } else { + Result.failure(IllegalStateException("Phantom scan and connect timed out after ${timeoutMs}ms")) + } + } } } } @@ -300,6 +327,9 @@ class PhantomBleRepository( } lastColorSchemeIndex = schemeIndex logRepo.info(LogEventType.COMMAND_SENT, "Phantom color scheme set", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS, "scheme=$schemeIndex") + if (terminal.value) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } Result.success(Unit) } } @@ -316,6 +346,9 @@ class PhantomBleRepository( PHANTOM_DEVICE_ADDRESS, command.joinToString(" ") { it.toUByte().toString(16).padStart(2, '0') }, ) + if (terminal.value) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } Result.success(Unit) } } @@ -326,6 +359,9 @@ class PhantomBleRepository( return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } logRepo.info(LogEventType.COMMAND_SENT, "Phantom init sequence accepted", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + if (terminal.value) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } Result.success(Unit) } } @@ -337,9 +373,15 @@ class PhantomBleRepository( } if (_discoModeActive.value) { stopDiscoMode() + if (terminal.value) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } } workoutParams = params _handleState.value = HandleState.Grabbed + if (terminal.value) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } logRepo.info( LogEventType.COMMAND_SENT, "Phantom workout started", @@ -347,9 +389,23 @@ class PhantomBleRepository( PHANTOM_DEVICE_ADDRESS, "mode=${params.programMode}; reps=${params.reps}; weightPerCableKg=${params.weightPerCableKg}; justLift=${params.isJustLift}", ) + if (terminal.value) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } startMetrics(activeWorkout = true) - startHeuristicGeneration(activeWorkout = true) + if (terminal.value) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } + if (!startHeuristicGeneration(activeWorkout = true)) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } + if (terminal.value) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } startRepSimulation(params) + if (terminal.value) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } Result.success(Unit) } } @@ -360,9 +416,15 @@ class PhantomBleRepository( return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } logRepo.info(LogEventType.COMMAND_SENT, "Phantom workout stopped", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + if (terminal.value) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } stopJobs() workoutParams = null _handleState.value = HandleState.Released + if (terminal.value) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } Result.success(Unit) } } @@ -373,7 +435,11 @@ class PhantomBleRepository( Result.failure(IllegalStateException("Phantom repository is shut down")) } else { logRepo.info(LogEventType.COMMAND_SENT, "Phantom stop command accepted", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) - Result.success(Unit) + if (terminal.value) { + Result.failure(IllegalStateException("Phantom repository is shut down")) + } else { + Result.success(Unit) + } } } } @@ -384,7 +450,13 @@ class PhantomBleRepository( return@withLock } _handleDetection.value = HandleDetection(leftDetected = enabled, rightDetected = enabled) + if (terminal.value) { + return@withLock + } _handleState.value = if (enabled) HandleState.WaitingForRest else HandleState.Released + if (terminal.value) { + return@withLock + } logRepo.info(LogEventType.NOTIFICATION, "Phantom handle detection ${if (enabled) "enabled" else "disabled"}") } } @@ -393,6 +465,9 @@ class PhantomBleRepository( lifecycleLock.withLock { if (!terminal.value) { _handleState.value = HandleState.WaitingForRest + if (terminal.value) { + return@withLock + } } } } @@ -403,6 +478,9 @@ class PhantomBleRepository( return@withLock } _handleState.value = HandleState.WaitingForRest + if (terminal.value) { + return@withLock + } logRepo.info(LogEventType.NOTIFICATION, "Phantom Just Lift waiting mode armed") } } @@ -421,8 +499,16 @@ class PhantomBleRepository( return@withLock } _handleState.value = HandleState.Grabbed + if (terminal.value) { + return@withLock + } startMetrics(activeWorkout = true) - startHeuristicGeneration(activeWorkout = true) + if (terminal.value) { + return@withLock + } + if (!startHeuristicGeneration(activeWorkout = true)) { + return@withLock + } } } @@ -459,7 +545,9 @@ class PhantomBleRepository( override fun restartDiagnosticPolling() { lifecycleLock.withLock { if (!terminal.value) { - startDiagnostics() + if (!startDiagnostics() || terminal.value) { + return@withLock + } startHeartbeat() } } @@ -471,6 +559,9 @@ class PhantomBleRepository( return@withLock } _discoModeActive.value = true + if (terminal.value) { + return@withLock + } logRepo.info(LogEventType.COMMAND_SENT, "Phantom disco mode started", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) } } @@ -481,6 +572,9 @@ class PhantomBleRepository( return@withLock } _discoModeActive.value = false + if (terminal.value) { + return@withLock + } logRepo.info( LogEventType.COMMAND_SENT, "Phantom disco mode stopped", @@ -547,12 +641,16 @@ class PhantomBleRepository( if (terminal.value) { return@withLock } + val expectedConnectionGeneration = connectionAttemptGeneration.value val packet = parseMonitorPacket(data) ?: error("monitor packet too short: ${data.size} bytes") val metric = monitorProcessor.process(packet) ?: error("monitor packet parsed but was rejected by validation") + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + return@withLock + } _metricsFlow.tryEmit(metric) - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } logRepo.info( @@ -637,6 +735,9 @@ class PhantomBleRepository( return@withLock } _config.value = config + if (terminal.value) { + return@withLock + } logRepo.info( LogEventType.NOTIFICATION, "Phantom config updated", @@ -644,9 +745,20 @@ class PhantomBleRepository( PHANTOM_DEVICE_ADDRESS, "loadScale=${config.loadScale}; velocityScale=${config.velocityScale}; positionScale=${config.positionScale}; repDelayMs=${config.repDelayMs}; autoCompleteFixedRepSets=${config.autoCompleteFixedRepSets}", ) + if (terminal.value) { + return@withLock + } if (_connectionState.value is ConnectionState.Connected) { startMetrics(activeWorkout = workoutParams != null) - startHeuristicGeneration(activeWorkout = workoutParams != null) + if (terminal.value) { + return@withLock + } + if (!startHeuristicGeneration(activeWorkout = workoutParams != null)) { + return@withLock + } + if (terminal.value) { + return@withLock + } workoutParams?.takeIf { repJob?.isActive == true && !repSimulationCompleted }?.let(::startRepSimulation) } } @@ -683,14 +795,19 @@ class PhantomBleRepository( private inline fun publishConnectionState( attemptGeneration: Long, state: ConnectionState, - onPublished: () -> Unit, + onPublished: () -> Boolean, ): Boolean = lifecycleLock.withLock { if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { false } else { _connectionState.value = state - onPublished() - true + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + false + } else if (!onPublished()) { + false + } else { + !terminal.value && connectionAttemptGeneration.value == attemptGeneration + } } } @@ -699,6 +816,9 @@ class PhantomBleRepository( false } else { _scannedDevices.value = devices + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + return@withLock false + } logRepo.info( LogEventType.DEVICE_FOUND, "Found phantom Vitruvian device", @@ -706,7 +826,7 @@ class PhantomBleRepository( deviceAddress = device.address, details = "RSSI ${device.rssi}; no Bluetooth hardware used", ) - true + !terminal.value && connectionAttemptGeneration.value == attemptGeneration } } @@ -734,7 +854,9 @@ class PhantomBleRepository( if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { return@withLock false } - startDiagnostics() + if (!startDiagnostics()) { + return@withLock false + } if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { return@withLock false } @@ -742,7 +864,9 @@ class PhantomBleRepository( if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { return@withLock false } - startHeuristicGeneration(activeWorkout = false) + if (!startHeuristicGeneration(activeWorkout = false)) { + return@withLock false + } if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { return@withLock false } @@ -848,8 +972,7 @@ class PhantomBleRepository( } } - private fun startHeuristicGeneration(activeWorkout: Boolean) { - lifecycleLock.withLock { + private fun startHeuristicGeneration(activeWorkout: Boolean): Boolean = lifecycleLock.withLock { val workoutWeightPerCableKg = workoutParams?.weightPerCableKg heuristicGeneration += 1 val expectedGeneration = heuristicGeneration @@ -872,7 +995,7 @@ class PhantomBleRepository( ) } }) { - return@withLock + return@withLock false } heuristicJob = scope.launch { while (isActive && connectionState.value is ConnectionState.Connected) { @@ -899,8 +1022,8 @@ class PhantomBleRepository( } } } + true } - } private fun startRepSimulation(params: WorkoutParameters) { repSimulationCompleted = false @@ -963,7 +1086,7 @@ class PhantomBleRepository( } } - private fun startDiagnostics() { + private fun startDiagnostics(): Boolean { diagnosticGeneration += 1 val expectedGeneration = diagnosticGeneration diagnosticJob?.cancel() @@ -981,7 +1104,7 @@ class PhantomBleRepository( logRepo.debug(LogEventType.DIAGNOSTIC, "Phantom diagnostic heartbeat", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) } }) { - return + return false } diagnosticJob = scope.launch { while (isActive && connectionState.value is ConnectionState.Connected) { @@ -1003,6 +1126,7 @@ class PhantomBleRepository( } } } + return true } private fun startHeartbeat() { diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 6d4020907..b04af78d3 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.async import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout @@ -332,6 +333,159 @@ class PhantomBleRepositoryTest { } } + @Test + fun `shutdown from connecting publication prevents post-publication connection effects`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val shutdownOnConnecting = async(Dispatchers.Unconfined) { + repository.connectionState.first { state -> + if (state == ConnectionState.Connecting) { + repository.shutdown() + true + } else { + false + } + } + } + + try { + val result = repository.connect(ScannedDevice("collector", "collector-address")) + + shutdownOnConnecting.await() + + assertTrue(result.isFailure) + assertTrue(logRepo.getLogsByEventType(LogEventType.CONNECT_START).isEmpty()) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + } finally { + repository.shutdown() + } + } + + @Test + fun `shutdown from scanned-device publication prevents device-found side effects`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val shutdownOnDevices = async(Dispatchers.Unconfined) { + repository.scannedDevices.first { devices -> + if (devices.isNotEmpty()) { + repository.shutdown() + true + } else { + false + } + } + } + + try { + val result = repository.startScanning() + + shutdownOnDevices.await() + + assertTrue(result.isFailure) + assertTrue(logRepo.getLogsByEventType(LogEventType.DEVICE_FOUND).isEmpty()) + assertTrue(repository.scannedDevices.value.isEmpty()) + } finally { + repository.shutdown() + } + } + + @Test + fun `shutdown from timeout teardown prevents stale timeout side effects`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val shutdownOnDisconnected = async(Dispatchers.Unconfined) { + repository.connectionState.drop(1).first { state -> + if (state == ConnectionState.Disconnected) { + repository.shutdown() + true + } else { + false + } + } + } + + try { + val result = repository.scanAndConnect(timeoutMs = 50L) + + shutdownOnDisconnected.await() + + assertTrue(result.isFailure) + assertTrue(logRepo.getLogsByEventType(LogEventType.ERROR).isEmpty()) + repository.reconnectionRequested.test { + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + + @Test + fun `shutdown from raw monitor deload publication prevents metric side effects`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val shutdownOnDeload = async(Dispatchers.Unconfined) { + repository.deloadOccurredEvents.first { + repository.shutdown() + true + } + } + val monitor = monitorPacket( + ticks = 42, + posA = 1250, + velA = 320, + loadA = 1234, + posB = -750, + velB = -250, + loadB = 567, + status = 0x8000, + ) + + try { + repository.metricsFlow.test { + val result = repository.injectRawPacket(PhantomRawPacketKind.MONITOR, monitor) + + shutdownOnDeload.await() + + assertTrue(result.isFailure) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + + @Test + fun `shutdown from workout handle publication prevents post-publication workout effects`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo, PhantomBleConfig(repDelayMs = 100L)) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + val shutdownOnGrabbed = async(Dispatchers.Unconfined) { + repository.handleState.first { state -> + if (state == HandleState.Grabbed) { + repository.shutdown() + true + } else { + false + } + } + } + + val result = repository.startWorkout(workoutParameters()) + + shutdownOnGrabbed.await() + + assertTrue(result.isFailure) + assertTrue(logRepo.logs.value.none { it.message == "Phantom workout started" }) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + } finally { + repository.shutdown() + } + } + @Test fun `injectRawPacket parses legacy rep bytes through protocol parser`() = runTest { val repository = PhantomBleRepository(ConnectionLogRepository()) From 97fde7f1247c837f1349dcf6e8de8efb70d7de7c Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 01:11:19 -0400 Subject: [PATCH 22/54] fix(ios): serialize final Phantom cleanup boundaries --- .../data/repository/PhantomBleRepository.kt | 158 ++++++++---- .../repository/PhantomBleRepositoryTest.kt | 231 ++++++++++++++++++ 2 files changed, 347 insertions(+), 42 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 83f7dc840..612919173 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -112,19 +112,25 @@ class PhantomBleRepository( private val monitorProcessor = MonitorDataProcessor( onDeloadOccurred = { lifecycleLock.withLock { - if (!terminal.value) { + val expectedConnectionGeneration = rawMonitorConnectionGeneration + if (!terminal.value && connectionAttemptGeneration.value == expectedConnectionGeneration) { _deloadOccurredEvents.tryEmit(Unit) } } }, onRomViolation = { violation -> - logRepo.warning( - LogEventType.NOTIFICATION, - "Phantom raw monitor packet reported ROM violation", - PHANTOM_DEVICE_NAME, - PHANTOM_DEVICE_ADDRESS, - violation.name, - ) + lifecycleLock.withLock { + val expectedConnectionGeneration = rawMonitorConnectionGeneration + if (!terminal.value && connectionAttemptGeneration.value == expectedConnectionGeneration) { + logRepo.warning( + LogEventType.NOTIFICATION, + "Phantom raw monitor packet reported ROM violation", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + violation.name, + ) + } + } }, ) @@ -142,6 +148,8 @@ class PhantomBleRepository( private val lifecycleLock = reentrantLock() private val terminal = atomic(false) private val connectionAttemptGeneration = atomic(0L) + private var lifecycleCleanupInProgress = false + private var rawMonitorConnectionGeneration = 0L private var metricsGeneration = 0L private var heuristicGeneration = 0L private var repGeneration = 0L @@ -183,14 +191,17 @@ class PhantomBleRepository( if (terminal.value) { return@withLock } - connectionAttemptGeneration.incrementAndGet() + val cleanupGeneration = connectionAttemptGeneration.incrementAndGet() if (_connectionState.value == ConnectionState.Scanning || _connectionState.value == ConnectionState.Connecting) { _connectionState.value = ConnectionState.Disconnected } - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != cleanupGeneration) { return@withLock } logRepo.info(LogEventType.SCAN_STOP, "Stopped phantom Vitruvian scan") + if (terminal.value || connectionAttemptGeneration.value != cleanupGeneration) { + return@withLock + } } } @@ -225,39 +236,55 @@ class PhantomBleRepository( return@withLock } if (_connectionState.value == ConnectionState.Connecting) { - connectionAttemptGeneration.incrementAndGet() + val cleanupGeneration = connectionAttemptGeneration.incrementAndGet() _connectionState.value = ConnectionState.Disconnected - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != cleanupGeneration) { return@withLock } logRepo.warning(LogEventType.DISCONNECT, "Cancelled phantom connection") + if (terminal.value || connectionAttemptGeneration.value != cleanupGeneration) { + return@withLock + } } } } override suspend fun disconnect() { lifecycleLock.withLock { - if (terminal.value) { + if (terminal.value || lifecycleCleanupInProgress) { return@withLock } - logRepo.info(LogEventType.DISCONNECT, "Disconnected phantom Vitruvian", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) - if (terminal.value) { - return@withLock + lifecycleCleanupInProgress = true + val cleanupGeneration = connectionAttemptGeneration.incrementAndGet() + try { + if (terminal.value || connectionAttemptGeneration.value != cleanupGeneration) { + return@withLock + } + logRepo.info(LogEventType.DISCONNECT, "Disconnected phantom Vitruvian", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + if (terminal.value || connectionAttemptGeneration.value != cleanupGeneration) { + return@withLock + } + teardownConnection() + } finally { + lifecycleCleanupInProgress = false } - teardownConnection() } } override suspend fun shutdown() { lifecycleLock.withLock { - if (terminal.value) { + if (terminal.value || lifecycleCleanupInProgress) { return@withLock } - logRepo.info(LogEventType.DISCONNECT, "Disconnected phantom Vitruvian", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) - if (terminal.value) { - return@withLock + terminal.value = true + lifecycleCleanupInProgress = true + connectionAttemptGeneration.incrementAndGet() + try { + logRepo.info(LogEventType.DISCONNECT, "Disconnected phantom Vitruvian", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + teardownConnection(markTerminal = true) + } finally { + lifecycleCleanupInProgress = false } - teardownConnection(markTerminal = true) } repositoryJob.cancel() } @@ -302,18 +329,22 @@ class PhantomBleRepository( PHANTOM_DEVICE_ADDRESS, "timeoutMs=$timeoutMs", ) - _reconnectionRequested.tryEmit( - ReconnectionRequest( - deviceName = PHANTOM_DEVICE_NAME, - deviceAddress = PHANTOM_DEVICE_ADDRESS, - reason = "connection_timeout", - timestamp = Clock.System.now().toEpochMilliseconds(), - ), - ) if (terminal.value || connectionAttemptGeneration.value != expectedPostTeardownGeneration) { Result.failure(IllegalStateException("Phantom scan and connect attempt invalidated")) } else { - Result.failure(IllegalStateException("Phantom scan and connect timed out after ${timeoutMs}ms")) + _reconnectionRequested.tryEmit( + ReconnectionRequest( + deviceName = PHANTOM_DEVICE_NAME, + deviceAddress = PHANTOM_DEVICE_ADDRESS, + reason = "connection_timeout", + timestamp = Clock.System.now().toEpochMilliseconds(), + ), + ) + if (terminal.value || connectionAttemptGeneration.value != expectedPostTeardownGeneration) { + Result.failure(IllegalStateException("Phantom scan and connect attempt invalidated")) + } else { + Result.failure(IllegalStateException("Phantom scan and connect timed out after ${timeoutMs}ms")) + } } } } @@ -602,9 +633,13 @@ class PhantomBleRepository( data: ByteArray, hasOpcodePrefix: Boolean = false, ): Result { - if (lifecycleLock.withLock { terminal.value }) { - return Result.failure(IllegalStateException("Phantom repository is shut down")) - } + val expectedConnectionGeneration = lifecycleLock.withLock { + if (terminal.value) { + null + } else { + connectionAttemptGeneration.value + } + } ?: return Result.failure(IllegalStateException("Phantom repository is shut down")) return try { when (kind) { @@ -613,8 +648,11 @@ class PhantomBleRepository( PhantomRawPacketKind.DIAGNOSTIC -> injectDiagnosticPacket(data) PhantomRawPacketKind.HEURISTIC -> injectHeuristicPacket(data) } - if (lifecycleLock.withLock { terminal.value }) { - Result.failure(IllegalStateException("Phantom repository is shut down")) + if (lifecycleLock.withLock { + terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration + } + ) { + Result.failure(IllegalStateException("Phantom raw packet injection invalidated")) } else { Result.success(Unit) } @@ -622,7 +660,7 @@ class PhantomBleRepository( throw error } catch (error: Throwable) { lifecycleLock.withLock { - if (!terminal.value) { + if (!terminal.value && connectionAttemptGeneration.value == expectedConnectionGeneration) { logRepo.error( LogEventType.ERROR, "Phantom raw ${kind.name.lowercase()} packet rejected", @@ -635,15 +673,18 @@ class PhantomBleRepository( Result.failure(error) } } - private suspend fun injectMonitorPacket(data: ByteArray) { lifecycleLock.withLock { if (terminal.value) { return@withLock } val expectedConnectionGeneration = connectionAttemptGeneration.value + rawMonitorConnectionGeneration = expectedConnectionGeneration val packet = parseMonitorPacket(data) ?: error("monitor packet too short: ${data.size} bytes") + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + return@withLock + } val metric = monitorProcessor.process(packet) ?: error("monitor packet parsed but was rejected by validation") if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { @@ -668,11 +709,15 @@ class PhantomBleRepository( if (terminal.value) { return@withLock } + val expectedConnectionGeneration = connectionAttemptGeneration.value val timestamp = Clock.System.now().toEpochMilliseconds() val rep = parseRepPacket(data, hasOpcodePrefix, timestamp) ?: error("rep packet too short: ${data.size} bytes") + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + return@withLock + } _repEvents.tryEmit(rep) - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } logRepo.info( @@ -690,11 +735,15 @@ class PhantomBleRepository( if (terminal.value) { return@withLock } + val expectedConnectionGeneration = connectionAttemptGeneration.value val timestamp = Clock.System.now().toEpochMilliseconds() val diagnostic = parseDiagnosticPacket(data) ?: error("diagnostic packet too short: ${data.size} bytes") + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + return@withLock + } _diagnostics.value = diagnostic.copy(receivedAtMillis = timestamp) - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } logRepo.info( @@ -712,11 +761,15 @@ class PhantomBleRepository( if (terminal.value) { return@withLock } + val expectedConnectionGeneration = connectionAttemptGeneration.value val timestamp = Clock.System.now().toEpochMilliseconds() val heuristic = parseHeuristicPacket(data, timestamp) ?: error("heuristic packet too short: ${data.size} bytes") + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + return@withLock + } _heuristicData.value = heuristic - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } logRepo.info( @@ -882,15 +935,36 @@ class PhantomBleRepository( if (markTerminal) { terminal.value = true } - connectionAttemptGeneration.incrementAndGet() + val cleanupGeneration = connectionAttemptGeneration.incrementAndGet() stopJobs() + if (connectionAttemptGeneration.value != cleanupGeneration || (!markTerminal && terminal.value)) { + return@withLock + } workoutParams = null _handleDetection.value = HandleDetection() + if (connectionAttemptGeneration.value != cleanupGeneration || (!markTerminal && terminal.value)) { + return@withLock + } _handleState.value = HandleState.WaitingForRest + if (connectionAttemptGeneration.value != cleanupGeneration || (!markTerminal && terminal.value)) { + return@withLock + } _diagnostics.value = null + if (connectionAttemptGeneration.value != cleanupGeneration || (!markTerminal && terminal.value)) { + return@withLock + } _heuristicData.value = null + if (connectionAttemptGeneration.value != cleanupGeneration || (!markTerminal && terminal.value)) { + return@withLock + } _scannedDevices.value = emptyList() + if (connectionAttemptGeneration.value != cleanupGeneration || (!markTerminal && terminal.value)) { + return@withLock + } _discoModeActive.value = false + if (connectionAttemptGeneration.value != cleanupGeneration || (!markTerminal && terminal.value)) { + return@withLock + } _connectionState.value = ConnectionState.Disconnected } } diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index b04af78d3..a02541a06 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -420,6 +420,36 @@ class PhantomBleRepositoryTest { } } + @Test + fun `shutdown from timeout log prevents reconnection side effects`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val shutdownOnTimeoutLog = async(Dispatchers.Unconfined) { + var shutdownTriggered = false + logRepo.logs.first { logs -> + if (!shutdownTriggered && logs.any { it.message == "Phantom scan and connect timed out" }) { + shutdownTriggered = true + repository.shutdown() + } + shutdownTriggered + } + } + + try { + repository.reconnectionRequested.test { + val result = repository.scanAndConnect(timeoutMs = 50L) + + shutdownOnTimeoutLog.await() + + assertTrue(result.isFailure) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + @Test fun `shutdown from raw monitor deload publication prevents metric side effects`() = runTest { val repository = PhantomBleRepository(ConnectionLogRepository()) @@ -455,6 +485,207 @@ class PhantomBleRepositoryTest { } } + @Test + fun `shutdown from raw ROM publication prevents later ROM and deload side effects`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val shutdownOnRom = async(Dispatchers.Unconfined) { + var shutdownTriggered = false + logRepo.logs.first { logs -> + if (!shutdownTriggered && logs.any { it.message == "Phantom raw monitor packet reported ROM violation" }) { + shutdownTriggered = true + repository.shutdown() + } + shutdownTriggered + } + } + val monitor = monitorPacket( + ticks = 42, + posA = 1250, + velA = 320, + loadA = 1234, + posB = -750, + velB = -250, + loadB = 567, + status = 0x800c, + ) + + try { + repository.metricsFlow.test { + val result = repository.injectRawPacket(PhantomRawPacketKind.MONITOR, monitor) + + shutdownOnRom.await() + + assertTrue(result.isFailure) + assertEquals( + 1, + logRepo.logs.value.count { it.message == "Phantom raw monitor packet reported ROM violation" }, + ) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + repository.deloadOccurredEvents.test { + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + + @Test + fun `reentrant shutdown log does not recurse disconnect publication`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val shutdownOnDisconnectLog = async(Dispatchers.Unconfined) { + var shutdownTriggered = false + logRepo.logs.first { logs -> + if (!shutdownTriggered && logs.any { it.eventType == LogEventType.DISCONNECT }) { + shutdownTriggered = true + repository.shutdown() + } + shutdownTriggered + } + } + + try { + repository.shutdown() + shutdownOnDisconnectLog.await() + + assertEquals(1, logRepo.getLogsByEventType(LogEventType.DISCONNECT).size) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + } finally { + repository.shutdown() + } + } + + @Test + fun `reentrant disconnect log does not duplicate cleanup`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + val disconnectOnLog = async(Dispatchers.Unconfined) { + var disconnectTriggered = false + logRepo.logs.first { logs -> + if (!disconnectTriggered && logs.any { it.eventType == LogEventType.DISCONNECT }) { + disconnectTriggered = true + repository.disconnect() + } + disconnectTriggered + } + } + + repository.disconnect() + disconnectOnLog.await() + + assertEquals(1, logRepo.getLogsByEventType(LogEventType.DISCONNECT).size) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + } finally { + repository.shutdown() + } + } + + @Test + fun `stopScanning does not log stale cleanup after a reentrant scan`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val restartedScan = async(Dispatchers.Unconfined) { + var result: Result? = null + repository.connectionState.drop(1).first { state -> + if (state == ConnectionState.Disconnected) { + result = repository.startScanning() + true + } else { + false + } + } + result + } + val scanning = async(Dispatchers.Default) { repository.startScanning() } + + try { + repository.connectionState.first { it == ConnectionState.Scanning } + logRepo.clearAll() + repository.stopScanning() + + assertTrue(restartedScan.await()!!.isSuccess) + assertTrue(scanning.await().isFailure) + assertEquals(ConnectionState.Scanning, repository.connectionState.value) + assertTrue(repository.scannedDevices.value.isNotEmpty()) + assertTrue(logRepo.getLogsByEventType(LogEventType.SCAN_STOP).isEmpty()) + } finally { + repository.shutdown() + } + } + + @Test + fun `cancelConnection does not log stale cleanup after a reentrant connection`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val reconnected = async(Dispatchers.Unconfined) { + var result: Result? = null + repository.connectionState.drop(1).first { state -> + if (state == ConnectionState.Disconnected) { + result = repository.connect(ScannedDevice("new", "new-address")) + true + } else { + false + } + } + result + } + val connecting = async(Dispatchers.Default) { + repository.connect(ScannedDevice("old", "old-address")) + } + + try { + repository.connectionState.first { it == ConnectionState.Connecting } + logRepo.clearAll() + repository.cancelConnection() + + assertTrue(reconnected.await()!!.isSuccess) + assertTrue(connecting.await().isFailure) + assertEquals(ConnectionState.Connected("new", "new-address"), repository.connectionState.value) + assertTrue(logRepo.getLogsByEventType(LogEventType.DISCONNECT).none { it.message == "Cancelled phantom connection" }) + } finally { + repository.shutdown() + } + } + + @Test + fun `disconnect teardown yields to a reentrant scan`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + val restartedScan = async(Dispatchers.Unconfined) { + var result: Result? = null + repository.handleDetection.drop(1).first { detection -> + if (!detection.leftDetected) { + result = repository.startScanning() + true + } else { + false + } + } + result + } + logRepo.clearAll() + + repository.disconnect() + + assertTrue(restartedScan.await()!!.isSuccess) + assertEquals(ConnectionState.Scanning, repository.connectionState.value) + assertTrue(repository.scannedDevices.value.isNotEmpty()) + } finally { + repository.shutdown() + } + } + @Test fun `shutdown from workout handle publication prevents post-publication workout effects`() = runTest { val logRepo = ConnectionLogRepository() From d8d04dbbfd6b964ae866c493f4e5b2511c446fbd Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 01:28:47 -0400 Subject: [PATCH 23/54] fix(ios): guard Phantom control generations --- .../data/repository/PhantomBleRepository.kt | 70 ++++++++++++++----- .../repository/PhantomBleRepositoryTest.kt | 32 +++++++++ 2 files changed, 84 insertions(+), 18 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 612919173..20cf0ca31 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -402,15 +402,16 @@ class PhantomBleRepository( if (terminal.value) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } + val expectedConnectionGeneration = connectionAttemptGeneration.value if (_discoModeActive.value) { stopDiscoMode() - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } } workoutParams = params _handleState.value = HandleState.Grabbed - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } logRepo.info( @@ -420,21 +421,25 @@ class PhantomBleRepository( PHANTOM_DEVICE_ADDRESS, "mode=${params.programMode}; reps=${params.reps}; weightPerCableKg=${params.weightPerCableKg}; justLift=${params.isJustLift}", ) - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } startMetrics(activeWorkout = true) - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } - if (!startHeuristicGeneration(activeWorkout = true)) { + if (!startHeuristicGeneration( + activeWorkout = true, + expectedConnectionGeneration = expectedConnectionGeneration, + ) + ) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } startRepSimulation(params) - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } Result.success(Unit) @@ -446,14 +451,18 @@ class PhantomBleRepository( if (terminal.value) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } + val expectedConnectionGeneration = connectionAttemptGeneration.value logRepo.info(LogEventType.COMMAND_SENT, "Phantom workout stopped", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } stopJobs() + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } workoutParams = null _handleState.value = HandleState.Released - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } Result.success(Unit) @@ -480,12 +489,13 @@ class PhantomBleRepository( if (terminal.value) { return@withLock } + val expectedConnectionGeneration = connectionAttemptGeneration.value _handleDetection.value = HandleDetection(leftDetected = enabled, rightDetected = enabled) - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } _handleState.value = if (enabled) HandleState.WaitingForRest else HandleState.Released - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } logRepo.info(LogEventType.NOTIFICATION, "Phantom handle detection ${if (enabled) "enabled" else "disabled"}") @@ -508,8 +518,9 @@ class PhantomBleRepository( if (terminal.value) { return@withLock } + val expectedConnectionGeneration = connectionAttemptGeneration.value _handleState.value = HandleState.WaitingForRest - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } logRepo.info(LogEventType.NOTIFICATION, "Phantom Just Lift waiting mode armed") @@ -529,15 +540,23 @@ class PhantomBleRepository( if (terminal.value) { return@withLock } + val expectedConnectionGeneration = connectionAttemptGeneration.value _handleState.value = HandleState.Grabbed - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } startMetrics(activeWorkout = true) - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + return@withLock + } + if (!startHeuristicGeneration( + activeWorkout = true, + expectedConnectionGeneration = expectedConnectionGeneration, + ) + ) { return@withLock } - if (!startHeuristicGeneration(activeWorkout = true)) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } } @@ -589,8 +608,9 @@ class PhantomBleRepository( if (terminal.value || _connectionState.value !is ConnectionState.Connected || workoutParams != null) { return@withLock } + val expectedConnectionGeneration = connectionAttemptGeneration.value _discoModeActive.value = true - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } logRepo.info(LogEventType.COMMAND_SENT, "Phantom disco mode started", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) @@ -602,8 +622,9 @@ class PhantomBleRepository( if (terminal.value) { return@withLock } + val expectedConnectionGeneration = connectionAttemptGeneration.value _discoModeActive.value = false - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } logRepo.info( @@ -1046,7 +1067,15 @@ class PhantomBleRepository( } } - private fun startHeuristicGeneration(activeWorkout: Boolean): Boolean = lifecycleLock.withLock { + private fun startHeuristicGeneration( + activeWorkout: Boolean, + expectedConnectionGeneration: Long? = null, + ): Boolean = lifecycleLock.withLock { + if (expectedConnectionGeneration != null && + (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) + ) { + return@withLock false + } val workoutWeightPerCableKg = workoutParams?.weightPerCableKg heuristicGeneration += 1 val expectedGeneration = heuristicGeneration @@ -1071,6 +1100,11 @@ class PhantomBleRepository( }) { return@withLock false } + if (expectedConnectionGeneration != null && + (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) + ) { + return@withLock false + } heuristicJob = scope.launch { while (isActive && connectionState.value is ConnectionState.Connected) { delay(if (activeWorkout) 250 else 750) diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index a02541a06..301001f9b 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -717,6 +717,38 @@ class PhantomBleRepositoryTest { } } + @Test + fun `handle control does not overwrite reentrant disconnect cleanup`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + val disconnectOnHandleDetection = async(Dispatchers.Unconfined) { + repository.handleDetection.first { detection -> + if (!detection.leftDetected && !detection.rightDetected) { + repository.disconnect() + true + } else { + false + } + } + } + + repository.enableHandleDetection(false) + disconnectOnHandleDetection.await() + + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertFalse(repository.handleDetection.value.leftDetected) + assertFalse(repository.handleDetection.value.rightDetected) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + assertTrue(logRepo.logs.value.none { it.message == "Phantom handle detection disabled" }) + } finally { + repository.shutdown() + } + } + @Test fun `injectRawPacket parses legacy rep bytes through protocol parser`() = runTest { val repository = PhantomBleRepository(ConnectionLogRepository()) From bc2e7c197531ce363b3864d0e958278e52555250 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 01:47:17 -0400 Subject: [PATCH 24/54] fix(ios): guard Phantom command generations --- .../data/repository/PhantomBleRepository.kt | 30 +++++--- .../repository/PhantomBleRepositoryTest.kt | 68 +++++++++++++++++++ 2 files changed, 89 insertions(+), 9 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 20cf0ca31..a6650d98b 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -356,9 +356,10 @@ class PhantomBleRepository( if (terminal.value) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } + val expectedConnectionGeneration = connectionAttemptGeneration.value lastColorSchemeIndex = schemeIndex logRepo.info(LogEventType.COMMAND_SENT, "Phantom color scheme set", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS, "scheme=$schemeIndex") - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } Result.success(Unit) @@ -370,6 +371,7 @@ class PhantomBleRepository( if (terminal.value) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } + val expectedConnectionGeneration = connectionAttemptGeneration.value logRepo.info( LogEventType.COMMAND_SENT, "Phantom received raw workout command", @@ -377,7 +379,7 @@ class PhantomBleRepository( PHANTOM_DEVICE_ADDRESS, command.joinToString(" ") { it.toUByte().toString(16).padStart(2, '0') }, ) - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } Result.success(Unit) @@ -389,8 +391,9 @@ class PhantomBleRepository( if (terminal.value) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } + val expectedConnectionGeneration = connectionAttemptGeneration.value logRepo.info(LogEventType.COMMAND_SENT, "Phantom init sequence accepted", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } Result.success(Unit) @@ -474,8 +477,9 @@ class PhantomBleRepository( if (terminal.value) { Result.failure(IllegalStateException("Phantom repository is shut down")) } else { + val expectedConnectionGeneration = connectionAttemptGeneration.value logRepo.info(LogEventType.COMMAND_SENT, "Phantom stop command accepted", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { Result.failure(IllegalStateException("Phantom repository is shut down")) } else { Result.success(Unit) @@ -808,8 +812,9 @@ class PhantomBleRepository( if (terminal.value) { return@withLock } + val expectedConnectionGeneration = connectionAttemptGeneration.value _config.value = config - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } logRepo.info( @@ -819,18 +824,25 @@ class PhantomBleRepository( PHANTOM_DEVICE_ADDRESS, "loadScale=${config.loadScale}; velocityScale=${config.velocityScale}; positionScale=${config.positionScale}; repDelayMs=${config.repDelayMs}; autoCompleteFixedRepSets=${config.autoCompleteFixedRepSets}", ) - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } if (_connectionState.value is ConnectionState.Connected) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + return@withLock + } startMetrics(activeWorkout = workoutParams != null) - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } - if (!startHeuristicGeneration(activeWorkout = workoutParams != null)) { + if (!startHeuristicGeneration( + activeWorkout = workoutParams != null, + expectedConnectionGeneration = expectedConnectionGeneration, + ) + ) { return@withLock } - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } workoutParams?.takeIf { repJob?.isActive == true && !repSimulationCompleted }?.let(::startRepSimulation) diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 301001f9b..244a8ccbf 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -749,6 +749,74 @@ class PhantomBleRepositoryTest { } } + @Test + fun `command results fail when command log collector disconnects normally`() = runTest { + val commands = listOf Result>>( + "Phantom color scheme set" to { setColorScheme(7) }, + "Phantom received raw workout command" to { sendWorkoutCommand(byteArrayOf(0x01)) }, + "Phantom init sequence accepted" to { sendInitSequence() }, + "Phantom stop command accepted" to { sendStopCommand() }, + ) + + commands.forEach { (message, command) -> + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + val disconnectOnCommandLog = async(Dispatchers.Unconfined) { + var disconnectTriggered = false + logRepo.logs.first { logs -> + if (!disconnectTriggered && logs.any { it.message == message }) { + disconnectTriggered = true + repository.disconnect() + } + disconnectTriggered + } + } + + val result = command(repository) + + disconnectOnCommandLog.await() + assertTrue(result.isFailure, message) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + } finally { + repository.shutdown() + } + } + } + + @Test + fun `replaceConfig aborts when config collector disconnects normally`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val replacement = PhantomBleConfig(loadScale = 2f, repDelayMs = 100L) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + val disconnectOnConfig = async(Dispatchers.Unconfined) { + repository.config.first { config -> + if (config == replacement) { + repository.disconnect() + true + } else { + false + } + } + } + + repository.replaceConfig(replacement) + + disconnectOnConfig.await() + assertEquals(replacement, repository.config.value) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(logRepo.logs.value.none { it.message == "Phantom config updated" }) + } finally { + repository.shutdown() + } + } + @Test fun `injectRawPacket parses legacy rep bytes through protocol parser`() = runTest { val repository = PhantomBleRepository(ConnectionLogRepository()) From ed890a7623d8accca4c3793da9929653a38dcd55 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 02:12:29 -0400 Subject: [PATCH 25/54] fix(ios): preserve Phantom connection generation --- .../data/repository/PhantomBleRepository.kt | 47 +++++++++++++++---- .../repository/PhantomBleRepositoryTest.kt | 30 ++++++++++++ 2 files changed, 67 insertions(+), 10 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index a6650d98b..0d3954704 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -191,10 +191,13 @@ class PhantomBleRepository( if (terminal.value) { return@withLock } - val cleanupGeneration = connectionAttemptGeneration.incrementAndGet() - if (_connectionState.value == ConnectionState.Scanning || _connectionState.value == ConnectionState.Connecting) { - _connectionState.value = ConnectionState.Disconnected + if (_connectionState.value != ConnectionState.Scanning && + _connectionState.value != ConnectionState.Connecting + ) { + return@withLock } + val cleanupGeneration = connectionAttemptGeneration.incrementAndGet() + _connectionState.value = ConnectionState.Disconnected if (terminal.value || connectionAttemptGeneration.value != cleanupGeneration) { return@withLock } @@ -940,7 +943,7 @@ class PhantomBleRepository( if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { return@withLock false } - if (!startDiagnostics()) { + if (!startDiagnostics(expectedConnectionGeneration = attemptGeneration)) { return@withLock false } if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { @@ -950,7 +953,11 @@ class PhantomBleRepository( if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { return@withLock false } - if (!startHeuristicGeneration(activeWorkout = false)) { + if (!startHeuristicGeneration( + activeWorkout = false, + expectedConnectionGeneration = attemptGeneration, + ) + ) { return@withLock false } if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { @@ -1003,6 +1010,7 @@ class PhantomBleRepository( } private inline fun publishIfConnected( + expectedConnectionGeneration: Long? = null, expectedMetricsGeneration: Long? = null, expectedHeuristicGeneration: Long? = null, expectedRepGeneration: Long? = null, @@ -1013,6 +1021,7 @@ class PhantomBleRepository( if ( terminal.value || _connectionState.value !is ConnectionState.Connected || + (expectedConnectionGeneration != null && connectionAttemptGeneration.value != expectedConnectionGeneration) || (expectedMetricsGeneration != null && metricsGeneration != expectedMetricsGeneration) || (expectedHeuristicGeneration != null && heuristicGeneration != expectedHeuristicGeneration) || (expectedRepGeneration != null && repGeneration != expectedRepGeneration) || @@ -1024,6 +1033,7 @@ class PhantomBleRepository( publish() !terminal.value && _connectionState.value is ConnectionState.Connected && + (expectedConnectionGeneration == null || connectionAttemptGeneration.value == expectedConnectionGeneration) && (expectedMetricsGeneration == null || metricsGeneration == expectedMetricsGeneration) && (expectedHeuristicGeneration == null || heuristicGeneration == expectedHeuristicGeneration) && (expectedRepGeneration == null || repGeneration == expectedRepGeneration) && @@ -1092,7 +1102,10 @@ class PhantomBleRepository( heuristicGeneration += 1 val expectedGeneration = heuristicGeneration heuristicJob?.cancel() - if (!publishIfConnected(expectedHeuristicGeneration = expectedGeneration) { + if (!publishIfConnected( + expectedConnectionGeneration = expectedConnectionGeneration, + expectedHeuristicGeneration = expectedGeneration, + ) { val config = _config.value val configuredLoad = workoutWeightPerCableKg ?: 7.5f val load = (if (activeWorkout) configuredLoad.coerceAtLeast(2f) else 1.5f) * config.loadScale @@ -1123,7 +1136,10 @@ class PhantomBleRepository( val config = _config.value val configuredLoad = workoutWeightPerCableKg ?: 7.5f val load = (if (activeWorkout) configuredLoad.coerceAtLeast(2f) else 1.5f) * config.loadScale - if (!publishIfConnected(expectedHeuristicGeneration = expectedGeneration) { + if (!publishIfConnected( + expectedConnectionGeneration = expectedConnectionGeneration, + expectedHeuristicGeneration = expectedGeneration, + ) { _heuristicData.value = HeuristicStatistics( concentric = HeuristicPhaseStatistics(load, load + 1.5f, 0.42f, 0.70f, 85f, 130f), eccentric = HeuristicPhaseStatistics(load * 0.9f, load + 1f, 0.38f, 0.62f, 72f, 110f), @@ -1206,12 +1222,20 @@ class PhantomBleRepository( } } - private fun startDiagnostics(): Boolean { + private fun startDiagnostics(expectedConnectionGeneration: Long? = null): Boolean { + if (expectedConnectionGeneration != null && + (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) + ) { + return false + } diagnosticGeneration += 1 val expectedGeneration = diagnosticGeneration diagnosticJob?.cancel() val connectedAt = Clock.System.now().toEpochMilliseconds() - if (!publishIfConnected(expectedDiagnosticGeneration = expectedGeneration) { + if (!publishIfConnected( + expectedConnectionGeneration = expectedConnectionGeneration, + expectedDiagnosticGeneration = expectedGeneration, + ) { val now = Clock.System.now().toEpochMilliseconds() _diagnostics.value = DiagnosticPacket( runtimeSeconds = (now - connectedAt) / 1000, @@ -1230,7 +1254,10 @@ class PhantomBleRepository( while (isActive && connectionState.value is ConnectionState.Connected) { delay(2_000) val now = Clock.System.now().toEpochMilliseconds() - if (!publishIfConnected(expectedDiagnosticGeneration = expectedGeneration) { + if (!publishIfConnected( + expectedConnectionGeneration = expectedConnectionGeneration, + expectedDiagnosticGeneration = expectedGeneration, + ) { _diagnostics.value = DiagnosticPacket( runtimeSeconds = (now - connectedAt) / 1000, faultWords = listOf(0, 0, 0, 0), diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 244a8ccbf..44686a910 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -174,6 +174,36 @@ class PhantomBleRepositoryTest { } } + @Test + fun `stopScanning is a no-op while initial connection producers publish`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val stopScanningOnDiagnostics = async(Dispatchers.Unconfined) { + repository.diagnostics.first { diagnostic -> + if (diagnostic != null) { + repository.stopScanning() + true + } else { + false + } + } + } + + try { + val result = repository.connect(ScannedDevice("collector", "collector-address")) + + stopScanningOnDiagnostics.await() + assertTrue(result.isSuccess) + assertTrue(repository.connectionState.value is ConnectionState.Connected) + assertTrue(repository.diagnostics.value != null) + assertTrue(repository.heuristicData.value != null) + assertTrue(logRepo.getLogsByEventType(LogEventType.CONNECT_SUCCESS).isNotEmpty()) + assertTrue(logRepo.getLogsByEventType(LogEventType.SCAN_STOP).isEmpty()) + } finally { + repository.shutdown() + } + } + @Test fun `connected repository initializes diagnostics and heuristic state before workout cancellation`() = runTest { val repository = PhantomBleRepository( From 920fc151ced7d4afb5733be62ad433cfcc0f1294 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 02:36:52 -0400 Subject: [PATCH 26/54] fix(ios): gate Phantom reps by connection generation --- .../data/repository/PhantomBleRepository.kt | 6 ++- .../repository/PhantomBleRepositoryTest.kt | 41 +++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 0d3954704..3ca0fd811 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -1165,6 +1165,7 @@ class PhantomBleRepository( repSimulationCompleted = false repGeneration += 1 val expectedGeneration = repGeneration + val expectedConnectionGeneration = lifecycleLock.withLock { connectionAttemptGeneration.value } repJob?.cancel() repJob = scope.launch { var rep = 0 @@ -1180,7 +1181,10 @@ class PhantomBleRepository( bytes[18] = params.warmupReps.toByte() bytes[22] = target.toByte() } - if (!publishIfConnected(expectedRepGeneration = expectedGeneration) { + if (!publishIfConnected( + expectedConnectionGeneration = expectedConnectionGeneration, + expectedRepGeneration = expectedGeneration, + ) { if (rep >= target && !params.isAMRAP && config.autoCompleteFixedRepSets) { repSimulationCompleted = true } diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 44686a910..83a8f86c5 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -1154,6 +1154,47 @@ class PhantomBleRepositoryTest { } } + @Test + fun `direct reconnect suppresses stale AMRAP rep from previous connection`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository( + logRepo, + PhantomBleConfig(repDelayMs = 400L), + ) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + repository.repEvents.test { + assertTrue( + repository.startWorkout( + workoutParameters().copy(reps = 2, isAMRAP = true), + ).isSuccess, + ) + awaitItem() + + val reconnecting = async(Dispatchers.Default) { + repository.connect(ScannedDevice("reconnected", "reconnected-address")) + } + repository.connectionState.first { it == ConnectionState.Connecting } + assertTrue(reconnecting.await().isSuccess) + assertEquals( + ConnectionState.Connected("reconnected", "reconnected-address"), + repository.connectionState.value, + ) + + withContext(Dispatchers.Default) { delay(500L) } + expectNoEvents() + assertTrue( + logRepo.getLogsByEventType(LogEventType.REP_RECEIVED) + .none { it.message == "Phantom rep notification" && it.details?.contains("rep=2/2") == true }, + ) + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + @Test fun `stopping workout races scheduled rep publication without post-stop rep`() = runTest { val logRepo = ConnectionLogRepository() From 96341008cb758116135c2a38395981882c701daf Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 03:09:38 -0400 Subject: [PATCH 27/54] fix(ios): thread Phantom lifecycle tokens completely --- .../data/repository/PhantomBleRepository.kt | 132 +++++++++----- .../repository/PhantomBleRepositoryTest.kt | 163 ++++++++++++++++++ 2 files changed, 256 insertions(+), 39 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 3ca0fd811..596914178 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -276,7 +276,12 @@ class PhantomBleRepository( override suspend fun shutdown() { lifecycleLock.withLock { - if (terminal.value || lifecycleCleanupInProgress) { + if (terminal.value) { + return@withLock + } + if (lifecycleCleanupInProgress) { + terminal.value = true + teardownConnection(markTerminal = true) return@withLock } terminal.value = true @@ -430,7 +435,10 @@ class PhantomBleRepository( if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } - startMetrics(activeWorkout = true) + startMetrics( + activeWorkout = true, + expectedConnectionGeneration = expectedConnectionGeneration, + ) if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } @@ -552,7 +560,10 @@ class PhantomBleRepository( if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } - startMetrics(activeWorkout = true) + startMetrics( + activeWorkout = true, + expectedConnectionGeneration = expectedConnectionGeneration, + ) if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } @@ -602,10 +613,13 @@ class PhantomBleRepository( override fun restartDiagnosticPolling() { lifecycleLock.withLock { if (!terminal.value) { - if (!startDiagnostics() || terminal.value) { + val expectedConnectionGeneration = connectionAttemptGeneration.value + if (!startDiagnostics(expectedConnectionGeneration) || + terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration + ) { return@withLock } - startHeartbeat() + startHeartbeat(expectedConnectionGeneration) } } } @@ -671,10 +685,10 @@ class PhantomBleRepository( return try { when (kind) { - PhantomRawPacketKind.MONITOR -> injectMonitorPacket(data) - PhantomRawPacketKind.REP -> injectRepPacket(data, hasOpcodePrefix) - PhantomRawPacketKind.DIAGNOSTIC -> injectDiagnosticPacket(data) - PhantomRawPacketKind.HEURISTIC -> injectHeuristicPacket(data) + PhantomRawPacketKind.MONITOR -> injectMonitorPacket(data, expectedConnectionGeneration) + PhantomRawPacketKind.REP -> injectRepPacket(data, hasOpcodePrefix, expectedConnectionGeneration) + PhantomRawPacketKind.DIAGNOSTIC -> injectDiagnosticPacket(data, expectedConnectionGeneration) + PhantomRawPacketKind.HEURISTIC -> injectHeuristicPacket(data, expectedConnectionGeneration) } if (lifecycleLock.withLock { terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration @@ -701,12 +715,11 @@ class PhantomBleRepository( Result.failure(error) } } - private suspend fun injectMonitorPacket(data: ByteArray) { + private suspend fun injectMonitorPacket(data: ByteArray, expectedConnectionGeneration: Long) { lifecycleLock.withLock { - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } - val expectedConnectionGeneration = connectionAttemptGeneration.value rawMonitorConnectionGeneration = expectedConnectionGeneration val packet = parseMonitorPacket(data) ?: error("monitor packet too short: ${data.size} bytes") @@ -732,12 +745,15 @@ class PhantomBleRepository( } } - private suspend fun injectRepPacket(data: ByteArray, hasOpcodePrefix: Boolean) { + private suspend fun injectRepPacket( + data: ByteArray, + hasOpcodePrefix: Boolean, + expectedConnectionGeneration: Long, + ) { lifecycleLock.withLock { - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } - val expectedConnectionGeneration = connectionAttemptGeneration.value val timestamp = Clock.System.now().toEpochMilliseconds() val rep = parseRepPacket(data, hasOpcodePrefix, timestamp) ?: error("rep packet too short: ${data.size} bytes") @@ -758,12 +774,11 @@ class PhantomBleRepository( } } - private fun injectDiagnosticPacket(data: ByteArray) { + private fun injectDiagnosticPacket(data: ByteArray, expectedConnectionGeneration: Long) { lifecycleLock.withLock { - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } - val expectedConnectionGeneration = connectionAttemptGeneration.value val timestamp = Clock.System.now().toEpochMilliseconds() val diagnostic = parseDiagnosticPacket(data) ?: error("diagnostic packet too short: ${data.size} bytes") @@ -784,12 +799,11 @@ class PhantomBleRepository( } } - private fun injectHeuristicPacket(data: ByteArray) { + private fun injectHeuristicPacket(data: ByteArray, expectedConnectionGeneration: Long) { lifecycleLock.withLock { - if (terminal.value) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } - val expectedConnectionGeneration = connectionAttemptGeneration.value val timestamp = Clock.System.now().toEpochMilliseconds() val heuristic = parseHeuristicPacket(data, timestamp) ?: error("heuristic packet too short: ${data.size} bytes") @@ -834,7 +848,10 @@ class PhantomBleRepository( if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } - startMetrics(activeWorkout = workoutParams != null) + startMetrics( + activeWorkout = workoutParams != null, + expectedConnectionGeneration = expectedConnectionGeneration, + ) if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } @@ -949,7 +966,10 @@ class PhantomBleRepository( if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { return@withLock false } - startMetrics(activeWorkout = false) + startMetrics( + activeWorkout = false, + expectedConnectionGeneration = attemptGeneration, + ) if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { return@withLock false } @@ -963,7 +983,7 @@ class PhantomBleRepository( if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { return@withLock false } - startHeartbeat() + startHeartbeat(attemptGeneration) if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { return@withLock false } @@ -1042,8 +1062,15 @@ class PhantomBleRepository( } } - private fun startMetrics(activeWorkout: Boolean) { + private fun startMetrics( + activeWorkout: Boolean, + expectedConnectionGeneration: Long? = null, + ) { lifecycleLock.withLock { + val lifecycleGeneration = expectedConnectionGeneration ?: connectionAttemptGeneration.value + if (terminal.value || connectionAttemptGeneration.value != lifecycleGeneration) { + return@withLock + } val workoutWeightPerCableKg = workoutParams?.weightPerCableKg metricsGeneration += 1 val expectedGeneration = metricsGeneration @@ -1057,7 +1084,10 @@ class PhantomBleRepository( val config = _config.value val configuredLoad = workoutWeightPerCableKg ?: 7.5f val load = (if (activeWorkout) configuredLoad.coerceAtLeast(2f) else 1.5f) * config.loadScale - if (!publishIfConnected(expectedMetricsGeneration = expectedGeneration) { + if (!publishIfConnected( + expectedConnectionGeneration = lifecycleGeneration, + expectedMetricsGeneration = expectedGeneration, + ) { val metric = WorkoutMetric( timestamp = now, loadA = load + wave.coerceAtLeast(0f) * config.loadScale, @@ -1070,7 +1100,10 @@ class PhantomBleRepository( status = 0, ) _metricsFlow.tryEmit(metric) - if (!terminal.value && metricsGeneration == expectedGeneration) { + if (!terminal.value && + connectionAttemptGeneration.value == lifecycleGeneration && + metricsGeneration == expectedGeneration + ) { logRepo.debug( LogEventType.NOTIFICATION, "Phantom monitor metric", @@ -1114,7 +1147,10 @@ class PhantomBleRepository( eccentric = HeuristicPhaseStatistics(load * 0.9f, load + 1f, 0.38f, 0.62f, 72f, 110f), timestamp = Clock.System.now().toEpochMilliseconds(), ) - if (!terminal.value && heuristicGeneration == expectedGeneration) { + if (!terminal.value && + connectionAttemptGeneration.value == expectedConnectionGeneration && + heuristicGeneration == expectedGeneration + ) { logRepo.debug( LogEventType.NOTIFICATION, "Phantom heuristic update", @@ -1145,7 +1181,10 @@ class PhantomBleRepository( eccentric = HeuristicPhaseStatistics(load * 0.9f, load + 1f, 0.38f, 0.62f, 72f, 110f), timestamp = Clock.System.now().toEpochMilliseconds(), ) - if (!terminal.value && heuristicGeneration == expectedGeneration) { + if (!terminal.value && + connectionAttemptGeneration.value == expectedConnectionGeneration && + heuristicGeneration == expectedGeneration + ) { logRepo.debug( LogEventType.NOTIFICATION, "Phantom heuristic update", @@ -1202,7 +1241,10 @@ class PhantomBleRepository( timestamp = timestamp, ), ) - if (!terminal.value && repGeneration == expectedGeneration) { + if (!terminal.value && + connectionAttemptGeneration.value == expectedConnectionGeneration && + repGeneration == expectedGeneration + ) { logRepo.info( LogEventType.REP_RECEIVED, "Phantom rep notification", @@ -1210,7 +1252,9 @@ class PhantomBleRepository( PHANTOM_DEVICE_ADDRESS, "rep=$rep/$target; timestamp=$timestamp", ) - if (!terminal.value && repGeneration == expectedGeneration && + if (!terminal.value && + connectionAttemptGeneration.value == expectedConnectionGeneration && + repGeneration == expectedGeneration && rep >= target && !params.isAMRAP && config.autoCompleteFixedRepSets ) { logRepo.info(LogEventType.COMMAND_RESPONSE, "Phantom target reps reached", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) @@ -1226,10 +1270,8 @@ class PhantomBleRepository( } } - private fun startDiagnostics(expectedConnectionGeneration: Long? = null): Boolean { - if (expectedConnectionGeneration != null && - (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) - ) { + private fun startDiagnostics(expectedConnectionGeneration: Long): Boolean { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return false } diagnosticGeneration += 1 @@ -1248,7 +1290,10 @@ class PhantomBleRepository( hasFaults = false, receivedAtMillis = now, ) - if (!terminal.value && diagnosticGeneration == expectedGeneration) { + if (!terminal.value && + connectionAttemptGeneration.value == expectedConnectionGeneration && + diagnosticGeneration == expectedGeneration + ) { logRepo.debug(LogEventType.DIAGNOSTIC, "Phantom diagnostic heartbeat", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) } }) { @@ -1269,7 +1314,10 @@ class PhantomBleRepository( hasFaults = false, receivedAtMillis = now, ) - if (!terminal.value && diagnosticGeneration == expectedGeneration) { + if (!terminal.value && + connectionAttemptGeneration.value == expectedConnectionGeneration && + diagnosticGeneration == expectedGeneration + ) { logRepo.debug(LogEventType.DIAGNOSTIC, "Phantom diagnostic heartbeat", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) } }) { @@ -1280,13 +1328,19 @@ class PhantomBleRepository( return true } - private fun startHeartbeat() { + private fun startHeartbeat(expectedConnectionGeneration: Long) { + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + return + } heartbeatGeneration += 1 val expectedGeneration = heartbeatGeneration heartbeatJob?.cancel() heartbeatJob = scope.launch { while (isActive && connectionState.value is ConnectionState.Connected) { - if (!publishIfConnected(expectedHeartbeatGeneration = expectedGeneration) { + if (!publishIfConnected( + expectedConnectionGeneration = expectedConnectionGeneration, + expectedHeartbeatGeneration = expectedGeneration, + ) { logRepo.info(LogEventType.HEARTBEAT, "Phantom heartbeat", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) }) { break diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 83a8f86c5..03abcdb66 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -618,6 +618,35 @@ class PhantomBleRepositoryTest { } } + @Test + fun `shutdown during disconnect cleanup claims terminal state`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + val shutdownOnDisconnectLog = async(Dispatchers.Unconfined) { + logRepo.logs.first { logs -> + if (logs.any { it.message == "Disconnected phantom Vitruvian" }) { + repository.shutdown() + true + } else { + false + } + } + } + + repository.disconnect() + shutdownOnDisconnectLog.await() + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(repository.connect(ScannedDevice("terminal", "terminal-address")).isFailure) + assertEquals(1, logRepo.getLogsByEventType(LogEventType.DISCONNECT).size) + } finally { + repository.shutdown() + } + } + @Test fun `stopScanning does not log stale cleanup after a reentrant scan`() = runTest { val logRepo = ConnectionLogRepository() @@ -1195,6 +1224,140 @@ class PhantomBleRepositoryTest { } } + @Test + fun `direct reconnect suppresses stale scheduled metric generation`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val reconnected = async(Dispatchers.Unconfined) { + repository.metricsFlow.first { metric -> + if (metric.loadA >= 2f) { + repository.connect(ScannedDevice("metric-reconnected", "metric-reconnected-address")) + true + } else { + false + } + } + } + + try { + assertTrue(repository.scanAndConnect().isSuccess) + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + reconnected.await() + + logRepo.clearAll() + withContext(Dispatchers.Default) { delay(300L) } + + assertEquals(ConnectionState.Connected("metric-reconnected", "metric-reconnected-address"), repository.connectionState.value) + assertTrue( + logRepo.getLogsByEventType(LogEventType.NOTIFICATION) + .none { it.message == "Phantom monitor metric" && it.details?.contains("load=10") == true }, + ) + } finally { + repository.shutdown() + } + } + + @Test + fun `direct reconnect suppresses stale scheduled diagnostic generation`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val reconnected = async(Dispatchers.Unconfined) { + logRepo.logs.first { logs -> + if (logs.any { it.message == "Phantom diagnostic heartbeat" }) { + repository.connect(ScannedDevice("diagnostic-reconnected", "diagnostic-reconnected-address")) + true + } else { + false + } + } + } + + try { + repository.connect(ScannedDevice("initial", "initial-address")) + reconnected.await() + + logRepo.clearAll() + withContext(Dispatchers.Default) { delay(300L) } + + assertEquals( + ConnectionState.Connected("diagnostic-reconnected", "diagnostic-reconnected-address"), + repository.connectionState.value, + ) + assertTrue(logRepo.getLogsByEventType(LogEventType.DIAGNOSTIC).isEmpty()) + } finally { + repository.shutdown() + } + } + + @Test + fun `direct reconnect suppresses stale scheduled heartbeat generation`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val reconnected = async(Dispatchers.Unconfined) { + logRepo.logs.first { logs -> + if (logs.any { it.message == "Phantom heartbeat" }) { + repository.connect(ScannedDevice("heartbeat-reconnected", "heartbeat-reconnected-address")) + true + } else { + false + } + } + } + + try { + repository.connect(ScannedDevice("initial", "initial-address")) + reconnected.await() + + withContext(Dispatchers.Default) { delay(100L) } + logRepo.clearAll() + withContext(Dispatchers.Default) { delay(300L) } + + assertEquals( + ConnectionState.Connected("heartbeat-reconnected", "heartbeat-reconnected-address"), + repository.connectionState.value, + ) + assertTrue(logRepo.getLogsByEventType(LogEventType.HEARTBEAT).isEmpty()) + } finally { + repository.shutdown() + } + } + + @Test + fun `raw monitor injection preserves entry generation across reconnect`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val monitor = monitorPacket( + ticks = 42, + posA = 1250, + velA = 320, + loadA = 1234, + posB = -750, + velB = -250, + loadB = 567, + ) + val reconnectOnMetric = async(Dispatchers.Unconfined) { + repository.metricsFlow.first { metric -> + if (metric.ticks == 42L) { + repository.connect(ScannedDevice("raw-reconnected", "raw-reconnected-address")) + true + } else { + false + } + } + } + + try { + assertTrue(repository.scanAndConnect().isSuccess) + assertTrue(repository.injectRawPacket(PhantomRawPacketKind.MONITOR, monitor).isFailure) + reconnectOnMetric.await() + assertEquals( + ConnectionState.Connected("raw-reconnected", "raw-reconnected-address"), + repository.connectionState.value, + ) + } finally { + repository.shutdown() + } + } + @Test fun `stopping workout races scheduled rep publication without post-stop rep`() = runTest { val logRepo = ConnectionLogRepository() From b8577eef03d256a50b53b1a373e260ef2dd3d712 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 03:33:36 -0400 Subject: [PATCH 28/54] fix(ios): preserve Phantom callback tokens --- .../data/repository/PhantomBleRepository.kt | 80 ++++++++++------- .../repository/PhantomBleRepositoryTest.kt | 90 +++++++++++++++++++ 2 files changed, 138 insertions(+), 32 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 596914178..18db85cdf 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -109,31 +109,8 @@ class PhantomBleRepository( private val _discoModeActive = MutableStateFlow(false) override val discoModeActive: StateFlow = _discoModeActive.asStateFlow() - private val monitorProcessor = MonitorDataProcessor( - onDeloadOccurred = { - lifecycleLock.withLock { - val expectedConnectionGeneration = rawMonitorConnectionGeneration - if (!terminal.value && connectionAttemptGeneration.value == expectedConnectionGeneration) { - _deloadOccurredEvents.tryEmit(Unit) - } - } - }, - onRomViolation = { violation -> - lifecycleLock.withLock { - val expectedConnectionGeneration = rawMonitorConnectionGeneration - if (!terminal.value && connectionAttemptGeneration.value == expectedConnectionGeneration) { - logRepo.warning( - LogEventType.NOTIFICATION, - "Phantom raw monitor packet reported ROM violation", - PHANTOM_DEVICE_NAME, - PHANTOM_DEVICE_ADDRESS, - violation.name, - ) - } - } - }, - ) - + private var monitorProcessor: MonitorDataProcessor? = null + private var monitorProcessorConnectionGeneration: Long? = null private var metricsJob: Job? = null private var heuristicJob: Job? = null private var repJob: Job? = null @@ -149,7 +126,6 @@ class PhantomBleRepository( private val terminal = atomic(false) private val connectionAttemptGeneration = atomic(0L) private var lifecycleCleanupInProgress = false - private var rawMonitorConnectionGeneration = 0L private var metricsGeneration = 0L private var heuristicGeneration = 0L private var repGeneration = 0L @@ -452,7 +428,10 @@ class PhantomBleRepository( if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } - startRepSimulation(params) + startRepSimulation( + params = params, + expectedConnectionGeneration = expectedConnectionGeneration, + ) if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } @@ -720,13 +699,12 @@ class PhantomBleRepository( if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } - rawMonitorConnectionGeneration = expectedConnectionGeneration val packet = parseMonitorPacket(data) ?: error("monitor packet too short: ${data.size} bytes") if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } - val metric = monitorProcessor.process(packet) + val metric = monitorProcessorFor(expectedConnectionGeneration).process(packet) ?: error("monitor packet parsed but was rejected by validation") if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock @@ -865,7 +843,14 @@ class PhantomBleRepository( if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } - workoutParams?.takeIf { repJob?.isActive == true && !repSimulationCompleted }?.let(::startRepSimulation) + workoutParams + ?.takeIf { repJob?.isActive == true && !repSimulationCompleted } + ?.let { + startRepSimulation( + params = it, + expectedConnectionGeneration = expectedConnectionGeneration, + ) + } } } } @@ -1200,11 +1185,42 @@ class PhantomBleRepository( true } - private fun startRepSimulation(params: WorkoutParameters) { + private fun monitorProcessorFor(expectedConnectionGeneration: Long): MonitorDataProcessor { + if (monitorProcessorConnectionGeneration != expectedConnectionGeneration) { + monitorProcessorConnectionGeneration = expectedConnectionGeneration + monitorProcessor = MonitorDataProcessor( + onDeloadOccurred = { + lifecycleLock.withLock { + if (!terminal.value && connectionAttemptGeneration.value == expectedConnectionGeneration) { + _deloadOccurredEvents.tryEmit(Unit) + } + } + }, + onRomViolation = { violation -> + lifecycleLock.withLock { + if (!terminal.value && connectionAttemptGeneration.value == expectedConnectionGeneration) { + logRepo.warning( + LogEventType.NOTIFICATION, + "Phantom raw monitor packet reported ROM violation", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + violation.name, + ) + } + } + }, + ) + } + return requireNotNull(monitorProcessor) + } + + private fun startRepSimulation( + params: WorkoutParameters, + expectedConnectionGeneration: Long, + ) { repSimulationCompleted = false repGeneration += 1 val expectedGeneration = repGeneration - val expectedConnectionGeneration = lifecycleLock.withLock { connectionAttemptGeneration.value } repJob?.cancel() repJob = scope.launch { var rep = 0 diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 03abcdb66..e289eca10 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -1153,6 +1153,47 @@ class PhantomBleRepositoryTest { } } + @Test + fun `replaceConfig does not bind a restarted rep producer to a reentrant connection`() = runTest { + val repository = PhantomBleRepository( + ConnectionLogRepository(), + PhantomBleConfig(repDelayMs = 500L), + ) + val replacement = PhantomBleConfig(repDelayMs = 100L) + val reconnectOnConfig = async(Dispatchers.Unconfined) { + var result: Result? = null + repository.config.first { config -> + if (config == replacement) { + result = repository.connect(ScannedDevice("replacement", "replacement-address")) + true + } else { + false + } + } + result + } + + try { + repository.repEvents.test { + assertTrue(repository.scanAndConnect().isSuccess) + assertTrue( + repository.startWorkout( + workoutParameters().copy(isAMRAP = true), + ).isSuccess, + ) + + repository.replaceConfig(replacement) + assertTrue(reconnectOnConfig.await()!!.isSuccess) + withContext(Dispatchers.Default) { delay(600L) } + + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + @Test fun `AMRAP workout continues past requested rep count`() = runTest { val repository = PhantomBleRepository( @@ -1358,6 +1399,55 @@ class PhantomBleRepositoryTest { } } + @Test + fun `nested raw monitor route preserves outer callback generation`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val outerMonitor = monitorPacket( + ticks = 42, + posA = 1250, + velA = 320, + loadA = 1234, + posB = -750, + velB = -250, + loadB = 567, + status = 0x800c, + ) + val nestedMonitor = monitorPacket( + ticks = 43, + posA = 1250, + velA = 320, + loadA = 1234, + posB = -750, + velB = -250, + loadB = 567, + ) + val nestedRoute = async(Dispatchers.Unconfined) { + var routeTriggered = false + logRepo.logs.first { logs -> + if (!routeTriggered && logs.any { it.message == "Phantom raw monitor packet reported ROM violation" }) { + routeTriggered = true + repository.disconnect() + assertTrue(repository.injectRawPacket(PhantomRawPacketKind.MONITOR, nestedMonitor).isSuccess) + } + routeTriggered + } + } + + try { + repository.deloadOccurredEvents.test { + val result = repository.injectRawPacket(PhantomRawPacketKind.MONITOR, outerMonitor) + + nestedRoute.await() + assertTrue(result.isFailure) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + @Test fun `stopping workout races scheduled rep publication without post-stop rep`() = runTest { val logRepo = ConnectionLogRepository() From aea975a2f7a2e82daa0fe64f5d184132b392a468 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 03:57:56 -0400 Subject: [PATCH 29/54] fix(ios): close Phantom begin and completion races --- .../data/repository/PhantomBleRepository.kt | 37 ++++++----- .../repository/PhantomBleRepositoryTest.kt | 65 +++++++++++++++++++ 2 files changed, 87 insertions(+), 15 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 18db85cdf..78e5281db 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -133,7 +133,7 @@ class PhantomBleRepository( private var heartbeatGeneration = 0L override suspend fun startScanning(): Result { - val attemptGeneration = beginConnectionAttempt() + val attemptGeneration = beginConnectionAttempt(ConnectionState.Scanning) ?: return Result.failure(IllegalStateException("Phantom repository is shut down")) return try { startScanning(attemptGeneration) @@ -185,7 +185,7 @@ class PhantomBleRepository( } override suspend fun connect(device: ScannedDevice): Result { - val attemptGeneration = beginConnectionAttempt() + val attemptGeneration = beginConnectionAttempt(ConnectionState.Connecting) ?: return Result.failure(IllegalStateException("Phantom repository is shut down")) return try { connect(device, attemptGeneration) @@ -278,7 +278,7 @@ class PhantomBleRepository( return Result.failure(IllegalArgumentException("timeoutMs must be > 0")) } - val attemptGeneration = beginConnectionAttempt() + val attemptGeneration = beginConnectionAttempt(ConnectionState.Scanning) ?: return Result.failure(IllegalStateException("Phantom repository is shut down")) val completed = try { @@ -855,11 +855,13 @@ class PhantomBleRepository( } } - private fun beginConnectionAttempt(): Long? = lifecycleLock.withLock { + private fun beginConnectionAttempt(reservedState: ConnectionState): Long? = lifecycleLock.withLock { if (terminal.value) { null } else { - connectionAttemptGeneration.incrementAndGet() + val attemptGeneration = connectionAttemptGeneration.incrementAndGet() + _connectionState.value = reservedState + attemptGeneration } } @@ -951,19 +953,24 @@ class PhantomBleRepository( if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { return@withLock false } - startMetrics( - activeWorkout = false, - expectedConnectionGeneration = attemptGeneration, - ) - if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { - return@withLock false - } - if (!startHeuristicGeneration( + if (workoutParams == null) { + startMetrics( activeWorkout = false, expectedConnectionGeneration = attemptGeneration, ) - ) { - return@withLock false + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + return@withLock false + } + if (workoutParams == null && + !startHeuristicGeneration( + activeWorkout = false, + expectedConnectionGeneration = attemptGeneration, + ) + ) { + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration || workoutParams == null) { + return@withLock false + } + } } if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { return@withLock false diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index e289eca10..af0e6e717 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -333,6 +333,71 @@ class PhantomBleRepositoryTest { } } + @Test + fun `beginning a connection attempt gates reentrant producer restarts`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val replacement = PhantomBleConfig(loadScale = 2f, repDelayMs = 100L) + val restartOnConnected = async(Dispatchers.Unconfined) { + repository.connectionState.first { state -> + if (state is ConnectionState.Connected) { + repository.restartMonitorPolling() + repository.restartDiagnosticPolling() + repository.replaceConfig(replacement) + true + } else { + false + } + } + } + + try { + assertTrue(repository.scanAndConnect().isSuccess) + restartOnConnected.await() + logRepo.clearAll() + + val reconnecting = async(Dispatchers.Unconfined) { + repository.connect(ScannedDevice("reentrant", "reentrant-address")) + } + repository.connectionState.first { it == ConnectionState.Connecting } + + withContext(Dispatchers.Default) { delay(100L) } + assertTrue(logRepo.getLogsByEventType(LogEventType.NOTIFICATION).none { it.message == "Phantom monitor metric" }) + assertTrue(logRepo.getLogsByEventType(LogEventType.DIAGNOSTIC).none { it.message == "Phantom diagnostic heartbeat" }) + assertTrue(reconnecting.await().isSuccess) + } finally { + repository.shutdown() + } + } + + @Test + fun `diagnostic collector workout owns connection completion producers`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val startWorkoutOnDiagnostic = async(Dispatchers.Unconfined) { + repository.diagnostics.first { diagnostic -> + if (diagnostic != null) { + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + true + } else { + false + } + } + } + + try { + repository.metricsFlow.test { + assertTrue(repository.connect(ScannedDevice("diagnostic", "diagnostic-address")).isSuccess) + startWorkoutOnDiagnostic.await() + + val metric = withTimeout(1_000L) { awaitItem() } + assertTrue(metric.loadA >= 2f, "completion must preserve the reentrant active workout producer") + cancelAndIgnoreRemainingEvents() + } + } finally { + repository.shutdown() + } + } + @Test fun `shutdown from metric collector prevents post-terminal metric logging and state repopulation`() = runTest { val logRepo = ConnectionLogRepository() From 139e14a1df11c202ced2ac579a3cd4d9881afd8d Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 05:05:45 -0400 Subject: [PATCH 30/54] fix(ios): stabilize Phantom reentrant workout handoff --- .../data/repository/PhantomBleRepository.kt | 32 ++++++++++++++++++- .../repository/PhantomBleRepositoryTest.kt | 24 +++++++++----- 2 files changed, 47 insertions(+), 9 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 78e5281db..9f3458080 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -947,13 +947,15 @@ class PhantomBleRepository( if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { return@withLock false } + val workoutWasActiveBeforeDiagnostics = workoutParams != null if (!startDiagnostics(expectedConnectionGeneration = attemptGeneration)) { return@withLock false } if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { return@withLock false } - if (workoutParams == null) { + val activeWorkoutParams = workoutParams + if (activeWorkoutParams == null) { startMetrics( activeWorkout = false, expectedConnectionGeneration = attemptGeneration, @@ -971,6 +973,34 @@ class PhantomBleRepository( return@withLock false } } + } else if (!workoutWasActiveBeforeDiagnostics) { + if (metricsJob?.isActive != true) { + startMetrics( + activeWorkout = true, + expectedConnectionGeneration = attemptGeneration, + ) + } + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + return@withLock false + } + if ( + heuristicJob?.isActive != true && + !startHeuristicGeneration( + activeWorkout = true, + expectedConnectionGeneration = attemptGeneration, + ) + ) { + return@withLock false + } + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + return@withLock false + } + if (!repSimulationCompleted && repJob?.isActive != true) { + startRepSimulation( + params = activeWorkoutParams, + expectedConnectionGeneration = attemptGeneration, + ) + } } if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { return@withLock false diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index af0e6e717..eb9387aba 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -354,12 +354,12 @@ class PhantomBleRepositoryTest { try { assertTrue(repository.scanAndConnect().isSuccess) restartOnConnected.await() - logRepo.clearAll() val reconnecting = async(Dispatchers.Unconfined) { repository.connect(ScannedDevice("reentrant", "reentrant-address")) } repository.connectionState.first { it == ConnectionState.Connecting } + logRepo.clearAll() withContext(Dispatchers.Default) { delay(100L) } assertTrue(logRepo.getLogsByEventType(LogEventType.NOTIFICATION).none { it.message == "Phantom monitor metric" }) @@ -372,7 +372,8 @@ class PhantomBleRepositoryTest { @Test fun `diagnostic collector workout owns connection completion producers`() = runTest { - val repository = PhantomBleRepository(ConnectionLogRepository()) + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) val startWorkoutOnDiagnostic = async(Dispatchers.Unconfined) { repository.diagnostics.first { diagnostic -> if (diagnostic != null) { @@ -385,14 +386,21 @@ class PhantomBleRepositoryTest { } try { - repository.metricsFlow.test { - assertTrue(repository.connect(ScannedDevice("diagnostic", "diagnostic-address")).isSuccess) - startWorkoutOnDiagnostic.await() + val metric = async(Dispatchers.Unconfined) { repository.metricsFlow.first() } + val metricProducer = async(Dispatchers.Unconfined) { + logRepo.logs.first { logs -> logs.any { log -> log.message == "Phantom monitor metric" } } + } + assertTrue(repository.connect(ScannedDevice("diagnostic", "diagnostic-address")).isSuccess) + startWorkoutOnDiagnostic.await() + withContext(Dispatchers.Default) { + withTimeout(1_000L) { metricProducer.await() } + } - val metric = withTimeout(1_000L) { awaitItem() } - assertTrue(metric.loadA >= 2f, "completion must preserve the reentrant active workout producer") - cancelAndIgnoreRemainingEvents() + val observedMetric = withContext(Dispatchers.Default) { + withTimeout(1_000L) { metric.await() } } + assertTrue(observedMetric.loadA >= 2f, "completion must preserve the reentrant active workout producer") + metric.cancel() } finally { repository.shutdown() } From 2081143b526e3b38a09c84b25f5b5557cb7ea3a9 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 05:27:28 -0400 Subject: [PATCH 31/54] fix(ios): preserve Phantom handle handoff --- .../data/repository/PhantomBleRepository.kt | 6 +-- .../repository/PhantomBleRepositoryTest.kt | 37 +++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 9f3458080..9e2527485 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -927,15 +927,15 @@ class PhantomBleRepository( if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { return@withLock false } - _connectionState.value = ConnectionState.Connected(device.name, device.address) + _handleDetection.value = HandleDetection(leftDetected = true, rightDetected = true) if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { return@withLock false } - _handleDetection.value = HandleDetection(leftDetected = true, rightDetected = true) + _handleState.value = HandleState.Released if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { return@withLock false } - _handleState.value = HandleState.Released + _connectionState.value = ConnectionState.Connected(device.name, device.address) if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { return@withLock false } diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index eb9387aba..c256ef72f 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -333,6 +333,43 @@ class PhantomBleRepositoryTest { } } + @Test + fun `connected collector workout preserves grabbed handle and active producers`() = runTest { + val repository = PhantomBleRepository( + ConnectionLogRepository(), + PhantomBleConfig(repDelayMs = 100L), + ) + val startWorkoutOnConnected = async(Dispatchers.Unconfined) { + repository.connectionState.first { state -> + if (state is ConnectionState.Connected) { + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + true + } else { + false + } + } + } + + try { + assertTrue(repository.connect(ScannedDevice("collector", "collector-address")).isSuccess) + startWorkoutOnConnected.await() + + assertEquals(HandleState.Grabbed, repository.handleState.value) + + val metric = withContext(Dispatchers.Default) { + withTimeout(1_000L) { repository.metricsFlow.first() } + } + assertTrue(metric.loadA >= 10f, "connected handoff must preserve active workout metrics") + + val rep = withContext(Dispatchers.Default) { + withTimeout(1_000L) { repository.repEvents.first() } + } + assertTrue(rep.topCounter >= 1) + } finally { + repository.shutdown() + } + } + @Test fun `beginning a connection attempt gates reentrant producer restarts`() = runTest { val logRepo = ConnectionLogRepository() From c91f4f82a1d02365fd86fc066180348f29365bc8 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 06:09:06 -0400 Subject: [PATCH 32/54] fix(ios): order Phantom connected state publications --- .../data/repository/PhantomBleRepository.kt | 41 +++++++-- .../repository/PhantomBleRepositoryTest.kt | 90 +++++++++++++++++++ 2 files changed, 122 insertions(+), 9 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 9e2527485..7bccecbf0 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -118,6 +118,7 @@ class PhantomBleRepository( private var diagnosticJob: Job? = null private var heartbeatJob: Job? = null private var workoutParams: WorkoutParameters? = null + private var workoutConnectionGeneration: Long? = null private val _config = MutableStateFlow(initialConfig) val config: StateFlow = _config.asStateFlow() private var ticks = 0L @@ -145,7 +146,6 @@ class PhantomBleRepository( private suspend fun startScanning(attemptGeneration: Long): Result { if (!publishConnectionState(attemptGeneration, ConnectionState.Scanning) { - _scannedDevices.value = emptyList() if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { false } else { @@ -397,6 +397,7 @@ class PhantomBleRepository( } } workoutParams = params + workoutConnectionGeneration = expectedConnectionGeneration _handleState.value = HandleState.Grabbed if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) @@ -454,6 +455,7 @@ class PhantomBleRepository( return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } workoutParams = null + workoutConnectionGeneration = null _handleState.value = HandleState.Released if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) @@ -860,8 +862,19 @@ class PhantomBleRepository( null } else { val attemptGeneration = connectionAttemptGeneration.incrementAndGet() - _connectionState.value = reservedState - attemptGeneration + if (reservedState == ConnectionState.Scanning) { + _scannedDevices.value = emptyList() + } + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + null + } else { + _connectionState.value = reservedState + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + null + } else { + attemptGeneration + } + } } } @@ -927,15 +940,19 @@ class PhantomBleRepository( if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { return@withLock false } - _handleDetection.value = HandleDetection(leftDetected = true, rightDetected = true) + _connectionState.value = ConnectionState.Connected(device.name, device.address) if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { return@withLock false } - _handleState.value = HandleState.Released + _handleDetection.value = HandleDetection(leftDetected = true, rightDetected = true) if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { return@withLock false } - _connectionState.value = ConnectionState.Connected(device.name, device.address) + val workoutOwnsConnection = workoutConnectionGeneration == attemptGeneration && + (_handleState.value == HandleState.Grabbed || workoutParams != null) + if (!workoutOwnsConnection) { + _handleState.value = HandleState.Released + } if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { return@withLock false } @@ -947,7 +964,6 @@ class PhantomBleRepository( if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { return@withLock false } - val workoutWasActiveBeforeDiagnostics = workoutParams != null if (!startDiagnostics(expectedConnectionGeneration = attemptGeneration)) { return@withLock false } @@ -955,7 +971,13 @@ class PhantomBleRepository( return@withLock false } val activeWorkoutParams = workoutParams - if (activeWorkoutParams == null) { + val completionOwnsWorkout = workoutConnectionGeneration == attemptGeneration && + (_handleState.value == HandleState.Grabbed || activeWorkoutParams != null) + if (!completionOwnsWorkout) { + if (activeWorkoutParams != null) { + workoutParams = null + workoutConnectionGeneration = null + } startMetrics( activeWorkout = false, expectedConnectionGeneration = attemptGeneration, @@ -973,7 +995,7 @@ class PhantomBleRepository( return@withLock false } } - } else if (!workoutWasActiveBeforeDiagnostics) { + } else if (activeWorkoutParams != null) { if (metricsJob?.isActive != true) { startMetrics( activeWorkout = true, @@ -1023,6 +1045,7 @@ class PhantomBleRepository( return@withLock } workoutParams = null + workoutConnectionGeneration = null _handleDetection.value = HandleDetection() if (connectionAttemptGeneration.value != cleanupGeneration || (!markTerminal && terminal.value)) { return@withLock diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index c256ef72f..8c45fe216 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -370,6 +370,96 @@ class PhantomBleRepositoryTest { } } + @Test + fun `handle detection collector workout preserves connected handoff`() = runTest { + val repository = PhantomBleRepository( + ConnectionLogRepository(), + PhantomBleConfig(repDelayMs = 100L), + ) + val startWorkoutOnHandleDetection = async(Dispatchers.Unconfined) { + repository.handleDetection.first { detection -> + if (detection.leftDetected) { + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + true + } else { + false + } + } + } + + try { + assertTrue(repository.connect(ScannedDevice("collector", "collector-address")).isSuccess) + startWorkoutOnHandleDetection.await() + + assertTrue(repository.connectionState.value is ConnectionState.Connected) + assertEquals(HandleState.Grabbed, repository.handleState.value) + val metric = withContext(Dispatchers.Default) { + withTimeout(1_000L) { repository.metricsFlow.first() } + } + assertTrue(metric.loadA >= 10f, "handle detection handoff must preserve active workout metrics") + } finally { + repository.shutdown() + } + } + + @Test + fun `handle state collector workout preserves grabbed handle and active producers`() = runTest { + val repository = PhantomBleRepository( + ConnectionLogRepository(), + PhantomBleConfig(repDelayMs = 100L), + ) + val startWorkoutOnHandleState = async(Dispatchers.Unconfined) { + repository.handleState.first { state -> + if (state == HandleState.Released) { + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + true + } else { + false + } + } + } + + try { + assertTrue(repository.connect(ScannedDevice("collector", "collector-address")).isSuccess) + startWorkoutOnHandleState.await() + + assertTrue(repository.connectionState.value is ConnectionState.Connected) + assertEquals(HandleState.Grabbed, repository.handleState.value) + val metric = withContext(Dispatchers.Default) { + withTimeout(1_000L) { repository.metricsFlow.first() } + } + assertTrue(metric.loadA >= 10f, "handle state handoff must preserve active workout metrics") + } finally { + repository.shutdown() + } + } + + @Test + fun `starting a scan clears stale devices before publishing scanning`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + assertTrue(repository.scannedDevices.value.isNotEmpty()) + val scanningWithEmptyDevices = async(Dispatchers.Unconfined) { + repository.connectionState.first { state -> + if (state == ConnectionState.Scanning) { + assertTrue(repository.scannedDevices.value.isEmpty()) + true + } else { + false + } + } + } + val scanning = async(Dispatchers.Default) { repository.startScanning() } + + scanningWithEmptyDevices.await() + assertTrue(scanning.await().isSuccess) + } finally { + repository.shutdown() + } + } + @Test fun `beginning a connection attempt gates reentrant producer restarts`() = runTest { val logRepo = ConnectionLogRepository() From 63bef4c8dd1eb7482c5d4344aaa56973f9b60f35 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 06:54:49 -0400 Subject: [PATCH 33/54] fix(ios): own Phantom teardown lifecycle --- .../data/repository/PhantomBleRepository.kt | 15 +- .../repository/PhantomBleRepositoryTest.kt | 133 ++++++++++++++++++ 2 files changed, 143 insertions(+), 5 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 7bccecbf0..2152f621c 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -302,7 +302,12 @@ class PhantomBleRepository( Result.failure(IllegalStateException("Phantom scan and connect attempt invalidated")) } else { val expectedPostTeardownGeneration = attemptGeneration + 1L - teardownConnection() + lifecycleCleanupInProgress = true + try { + teardownConnection() + } finally { + lifecycleCleanupInProgress = false + } if (terminal.value || connectionAttemptGeneration.value != expectedPostTeardownGeneration) { Result.failure(IllegalStateException("Phantom scan and connect attempt invalidated")) } else { @@ -386,7 +391,7 @@ class PhantomBleRepository( override suspend fun startWorkout(params: WorkoutParameters): Result { return lifecycleLock.withLock { - if (terminal.value) { + if (terminal.value || lifecycleCleanupInProgress) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } val expectedConnectionGeneration = connectionAttemptGeneration.value @@ -525,7 +530,7 @@ class PhantomBleRepository( override fun restartMonitorPolling() { lifecycleLock.withLock { - if (!terminal.value) { + if (!terminal.value && !lifecycleCleanupInProgress) { startMetrics(activeWorkout = workoutParams != null) } } @@ -533,7 +538,7 @@ class PhantomBleRepository( override fun startActiveWorkoutPolling() { lifecycleLock.withLock { - if (terminal.value) { + if (terminal.value || lifecycleCleanupInProgress) { return@withLock } val expectedConnectionGeneration = connectionAttemptGeneration.value @@ -806,7 +811,7 @@ class PhantomBleRepository( fun replaceConfig(config: PhantomBleConfig) { lifecycleLock.withLock { - if (terminal.value) { + if (terminal.value || lifecycleCleanupInProgress) { return@withLock } val expectedConnectionGeneration = connectionAttemptGeneration.value diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 8c45fe216..2530c9d48 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -945,6 +945,139 @@ class PhantomBleRepositoryTest { } } + @Test + fun `disconnect teardown rejects reentrant producer controls`() = runTest { + val logRepo = ConnectionLogRepository() + val initialConfig = PhantomBleConfig(repDelayMs = 100L) + val repository = PhantomBleRepository(logRepo, initialConfig) + val replacement = PhantomBleConfig(loadScale = 2f, repDelayMs = 100L) + var startWorkoutResult: Result? = null + val startWorkoutOnTeardown = async(Dispatchers.Unconfined) { + repository.handleDetection.drop(1).first { detection -> + if (!detection.leftDetected && !detection.rightDetected) { + startWorkoutResult = repository.startWorkout(workoutParameters()) + true + } else { + false + } + } + } + val controlsOnTeardown = async(Dispatchers.Unconfined) { + repository.handleDetection.drop(1).first { detection -> + if (!detection.leftDetected && !detection.rightDetected) { + repository.startActiveWorkoutPolling() + repository.restartMonitorPolling() + repository.replaceConfig(replacement) + true + } else { + false + } + } + } + + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + + repository.disconnect() + + assertFalse(controlsOnTeardown.await().leftDetected) + assertFalse(startWorkoutOnTeardown.await().leftDetected) + assertTrue(requireNotNull(startWorkoutResult).isFailure) + assertEquals(initialConfig, repository.config.value) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + assertNull(repository.diagnostics.value) + assertNull(repository.heuristicData.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + val logsAfterTeardown = logRepo.logs.value + assertTrue(logsAfterTeardown.none { log -> + log.message in setOf( + "Phantom workout started", + "Phantom config updated", + "Phantom monitor metric", + "Phantom heuristic update", + "Phantom rep notification", + ) + }) + + withContext(Dispatchers.Default) { delay(350L) } + assertEquals(logsAfterTeardown, logRepo.logs.value) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + assertNull(repository.diagnostics.value) + assertNull(repository.heuristicData.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + } finally { + repository.shutdown() + } + } + + @Test + fun `timeout teardown rejects reentrant producer controls`() = runTest { + val logRepo = ConnectionLogRepository() + val initialConfig = PhantomBleConfig(repDelayMs = 100L) + val repository = PhantomBleRepository(logRepo, initialConfig) + val replacement = PhantomBleConfig(loadScale = 2f, repDelayMs = 100L) + var startWorkoutResult: Result? = null + val startWorkoutOnTeardown = async(Dispatchers.Unconfined) { + repository.scannedDevices.drop(1).first { devices -> + if (devices.isEmpty()) { + startWorkoutResult = repository.startWorkout(workoutParameters()) + true + } else { + false + } + } + } + val controlsOnTeardown = async(Dispatchers.Unconfined) { + repository.scannedDevices.drop(1).first { devices -> + if (devices.isEmpty()) { + repository.startActiveWorkoutPolling() + repository.restartMonitorPolling() + repository.replaceConfig(replacement) + true + } else { + false + } + } + } + + try { + assertTrue(repository.scanAndConnect(timeoutMs = 300L).isFailure) + + assertTrue(controlsOnTeardown.await().isEmpty()) + assertTrue(startWorkoutOnTeardown.await().isEmpty()) + assertTrue(requireNotNull(startWorkoutResult).isFailure) + assertEquals(initialConfig, repository.config.value) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + assertNull(repository.diagnostics.value) + assertNull(repository.heuristicData.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + val logsAfterTeardown = logRepo.logs.value + assertTrue(logsAfterTeardown.none { log -> + log.message in setOf( + "Phantom workout started", + "Phantom config updated", + "Phantom monitor metric", + "Phantom heuristic update", + "Phantom rep notification", + ) + }) + + withContext(Dispatchers.Default) { delay(350L) } + assertEquals(logsAfterTeardown, logRepo.logs.value) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + assertNull(repository.diagnostics.value) + assertNull(repository.heuristicData.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + } finally { + repository.shutdown() + } + } + @Test fun `shutdown from workout handle publication prevents post-publication workout effects`() = runTest { val logRepo = ConnectionLogRepository() From 96727382e1ae3694f81f6f9df88ff43c843e87d2 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 07:13:39 -0400 Subject: [PATCH 34/54] fix(ios): seal Phantom cleanup entrypoints --- .../data/repository/PhantomBleRepository.kt | 90 +++++++++++++++---- .../repository/PhantomBleRepositoryTest.kt | 86 ++++++++++++++++++ 2 files changed, 157 insertions(+), 19 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 2152f621c..5263e009f 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -598,7 +598,7 @@ class PhantomBleRepository( override fun restartDiagnosticPolling() { lifecycleLock.withLock { - if (!terminal.value) { + if (!terminal.value && !lifecycleCleanupInProgress) { val expectedConnectionGeneration = connectionAttemptGeneration.value if (!startDiagnostics(expectedConnectionGeneration) || terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration @@ -662,7 +662,7 @@ class PhantomBleRepository( hasOpcodePrefix: Boolean = false, ): Result { val expectedConnectionGeneration = lifecycleLock.withLock { - if (terminal.value) { + if (terminal.value || lifecycleCleanupInProgress) { null } else { connectionAttemptGeneration.value @@ -677,7 +677,9 @@ class PhantomBleRepository( PhantomRawPacketKind.HEURISTIC -> injectHeuristicPacket(data, expectedConnectionGeneration) } if (lifecycleLock.withLock { - terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration + terminal.value || + lifecycleCleanupInProgress || + connectionAttemptGeneration.value != expectedConnectionGeneration } ) { Result.failure(IllegalStateException("Phantom raw packet injection invalidated")) @@ -688,7 +690,10 @@ class PhantomBleRepository( throw error } catch (error: Throwable) { lifecycleLock.withLock { - if (!terminal.value && connectionAttemptGeneration.value == expectedConnectionGeneration) { + if (!terminal.value && + !lifecycleCleanupInProgress && + connectionAttemptGeneration.value == expectedConnectionGeneration + ) { logRepo.error( LogEventType.ERROR, "Phantom raw ${kind.name.lowercase()} packet rejected", @@ -703,21 +708,33 @@ class PhantomBleRepository( } private suspend fun injectMonitorPacket(data: ByteArray, expectedConnectionGeneration: Long) { lifecycleLock.withLock { - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { return@withLock } val packet = parseMonitorPacket(data) ?: error("monitor packet too short: ${data.size} bytes") - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { return@withLock } val metric = monitorProcessorFor(expectedConnectionGeneration).process(packet) ?: error("monitor packet parsed but was rejected by validation") - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { return@withLock } _metricsFlow.tryEmit(metric) - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { return@withLock } logRepo.info( @@ -736,17 +753,26 @@ class PhantomBleRepository( expectedConnectionGeneration: Long, ) { lifecycleLock.withLock { - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { return@withLock } val timestamp = Clock.System.now().toEpochMilliseconds() val rep = parseRepPacket(data, hasOpcodePrefix, timestamp) ?: error("rep packet too short: ${data.size} bytes") - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { return@withLock } _repEvents.tryEmit(rep) - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { return@withLock } logRepo.info( @@ -761,17 +787,26 @@ class PhantomBleRepository( private fun injectDiagnosticPacket(data: ByteArray, expectedConnectionGeneration: Long) { lifecycleLock.withLock { - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { return@withLock } val timestamp = Clock.System.now().toEpochMilliseconds() val diagnostic = parseDiagnosticPacket(data) ?: error("diagnostic packet too short: ${data.size} bytes") - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { return@withLock } _diagnostics.value = diagnostic.copy(receivedAtMillis = timestamp) - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { return@withLock } logRepo.info( @@ -786,17 +821,26 @@ class PhantomBleRepository( private fun injectHeuristicPacket(data: ByteArray, expectedConnectionGeneration: Long) { lifecycleLock.withLock { - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { return@withLock } val timestamp = Clock.System.now().toEpochMilliseconds() val heuristic = parseHeuristicPacket(data, timestamp) ?: error("heuristic packet too short: ${data.size} bytes") - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { return@withLock } _heuristicData.value = heuristic - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { return@withLock } logRepo.info( @@ -1090,6 +1134,7 @@ class PhantomBleRepository( ): Boolean = lifecycleLock.withLock { if ( terminal.value || + lifecycleCleanupInProgress || _connectionState.value !is ConnectionState.Connected || (expectedConnectionGeneration != null && connectionAttemptGeneration.value != expectedConnectionGeneration) || (expectedMetricsGeneration != null && metricsGeneration != expectedMetricsGeneration) || @@ -1102,6 +1147,7 @@ class PhantomBleRepository( } else { publish() !terminal.value && + !lifecycleCleanupInProgress && _connectionState.value is ConnectionState.Connected && (expectedConnectionGeneration == null || connectionAttemptGeneration.value == expectedConnectionGeneration) && (expectedMetricsGeneration == null || metricsGeneration == expectedMetricsGeneration) && @@ -1256,14 +1302,20 @@ class PhantomBleRepository( monitorProcessor = MonitorDataProcessor( onDeloadOccurred = { lifecycleLock.withLock { - if (!terminal.value && connectionAttemptGeneration.value == expectedConnectionGeneration) { + if (!terminal.value && + !lifecycleCleanupInProgress && + connectionAttemptGeneration.value == expectedConnectionGeneration + ) { _deloadOccurredEvents.tryEmit(Unit) } } }, onRomViolation = { violation -> lifecycleLock.withLock { - if (!terminal.value && connectionAttemptGeneration.value == expectedConnectionGeneration) { + if (!terminal.value && + !lifecycleCleanupInProgress && + connectionAttemptGeneration.value == expectedConnectionGeneration + ) { logRepo.warning( LogEventType.NOTIFICATION, "Phantom raw monitor packet reported ROM violation", diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 2530c9d48..d393db62b 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -1078,6 +1078,92 @@ class PhantomBleRepositoryTest { } } + @Test + fun `disconnect teardown rejects reentrant diagnostic polling restart`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + val restartOnDiagnosticsClear = async(Dispatchers.Unconfined) { + repository.diagnostics.first { diagnostic -> + if (diagnostic == null) { + repository.restartDiagnosticPolling() + true + } else { + false + } + } + } + repository.disconnect() + restartOnDiagnosticsClear.await() + + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertNull(repository.diagnostics.value) + } finally { + repository.shutdown() + } + } + + @Test + fun `disconnect teardown rejects reentrant raw diagnostic and monitor injection`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val monitor = monitorPacket( + ticks = 42, + posA = 1250, + velA = 320, + loadA = 1234, + posB = -750, + velB = -250, + loadB = 567, + ) + val diagnostic = ByteArray(18).also { bytes -> + putUInt32LE(bytes, 0, 1234) + putUInt16LE(bytes, 4, 0x0001) + bytes[12] = 31 + bytes[13] = 32 + bytes[14] = 33 + bytes[15] = 34 + bytes[16] = 35 + bytes[17] = 36 + } + var diagnosticResult: Result? = null + var monitorResult: Result? = null + + try { + assertTrue(repository.scanAndConnect().isSuccess) + val injectOnDiagnosticsClear = async(Dispatchers.Unconfined) { + repository.diagnostics.first { packet -> + if (packet == null) { + diagnosticResult = repository.injectRawPacket(PhantomRawPacketKind.DIAGNOSTIC, diagnostic) + monitorResult = repository.injectRawPacket(PhantomRawPacketKind.MONITOR, monitor) + true + } else { + false + } + } + } + repository.metricsFlow.test { + repository.disconnect() + injectOnDiagnosticsClear.await() + + assertTrue(requireNotNull(diagnosticResult).isFailure) + assertTrue(requireNotNull(monitorResult).isFailure) + expectNoEvents() + cancelAndIgnoreRemainingEvents() + } + assertNull(repository.diagnostics.value) + assertTrue(logRepo.logs.value.none { log -> + log.message in setOf( + "Phantom injected raw diagnostic packet", + "Phantom injected raw monitor packet", + ) + }) + } finally { + repository.shutdown() + } + } + @Test fun `shutdown from workout handle publication prevents post-publication workout effects`() = runTest { val logRepo = ConnectionLogRepository() From 1be5b462bd4dfd8a6fb51b4f4ad404e6d1fa4c25 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 07:47:34 -0400 Subject: [PATCH 35/54] fix(ios): seal Phantom teardown controls --- .../data/repository/PhantomBleRepository.kt | 30 ++--- .../repository/PhantomBleRepositoryTest.kt | 103 ++++++++++++++++++ 2 files changed, 120 insertions(+), 13 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 5263e009f..c4a830494 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -342,7 +342,7 @@ class PhantomBleRepository( override suspend fun setColorScheme(schemeIndex: Int): Result { return lifecycleLock.withLock { - if (terminal.value) { + if (terminal.value || lifecycleCleanupInProgress) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } val expectedConnectionGeneration = connectionAttemptGeneration.value @@ -357,7 +357,7 @@ class PhantomBleRepository( override suspend fun sendWorkoutCommand(command: ByteArray): Result { return lifecycleLock.withLock { - if (terminal.value) { + if (terminal.value || lifecycleCleanupInProgress) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } val expectedConnectionGeneration = connectionAttemptGeneration.value @@ -377,7 +377,7 @@ class PhantomBleRepository( override suspend fun sendInitSequence(): Result { return lifecycleLock.withLock { - if (terminal.value) { + if (terminal.value || lifecycleCleanupInProgress) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } val expectedConnectionGeneration = connectionAttemptGeneration.value @@ -447,7 +447,7 @@ class PhantomBleRepository( override suspend fun stopWorkout(): Result { return lifecycleLock.withLock { - if (terminal.value) { + if (terminal.value || lifecycleCleanupInProgress) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } val expectedConnectionGeneration = connectionAttemptGeneration.value @@ -471,7 +471,7 @@ class PhantomBleRepository( override suspend fun sendStopCommand(): Result { return lifecycleLock.withLock { - if (terminal.value) { + if (terminal.value || lifecycleCleanupInProgress) { Result.failure(IllegalStateException("Phantom repository is shut down")) } else { val expectedConnectionGeneration = connectionAttemptGeneration.value @@ -487,7 +487,7 @@ class PhantomBleRepository( override fun enableHandleDetection(enabled: Boolean) { lifecycleLock.withLock { - if (terminal.value) { + if (terminal.value || lifecycleCleanupInProgress) { return@withLock } val expectedConnectionGeneration = connectionAttemptGeneration.value @@ -505,7 +505,7 @@ class PhantomBleRepository( override fun resetHandleState() { lifecycleLock.withLock { - if (!terminal.value) { + if (!terminal.value && !lifecycleCleanupInProgress) { _handleState.value = HandleState.WaitingForRest if (terminal.value) { return@withLock @@ -516,7 +516,7 @@ class PhantomBleRepository( override fun enableJustLiftWaitingMode() { lifecycleLock.withLock { - if (terminal.value) { + if (terminal.value || lifecycleCleanupInProgress) { return@withLock } val expectedConnectionGeneration = connectionAttemptGeneration.value @@ -568,7 +568,7 @@ class PhantomBleRepository( override fun stopPolling() { lifecycleLock.withLock { - if (terminal.value) { + if (terminal.value || lifecycleCleanupInProgress) { return@withLock } metricsGeneration += 1 @@ -587,7 +587,7 @@ class PhantomBleRepository( override fun stopMonitorPollingOnly() { lifecycleLock.withLock { - if (terminal.value) { + if (terminal.value || lifecycleCleanupInProgress) { return@withLock } metricsGeneration += 1 @@ -612,7 +612,11 @@ class PhantomBleRepository( override fun startDiscoMode() { lifecycleLock.withLock { - if (terminal.value || _connectionState.value !is ConnectionState.Connected || workoutParams != null) { + if (terminal.value || + lifecycleCleanupInProgress || + _connectionState.value !is ConnectionState.Connected || + workoutParams != null + ) { return@withLock } val expectedConnectionGeneration = connectionAttemptGeneration.value @@ -626,7 +630,7 @@ class PhantomBleRepository( override fun stopDiscoMode() { lifecycleLock.withLock { - if (terminal.value) { + if (terminal.value || lifecycleCleanupInProgress) { return@withLock } val expectedConnectionGeneration = connectionAttemptGeneration.value @@ -646,7 +650,7 @@ class PhantomBleRepository( override fun setLastColorSchemeIndex(index: Int) { lifecycleLock.withLock { - if (!terminal.value) { + if (!terminal.value && !lifecycleCleanupInProgress) { lastColorSchemeIndex = index } } diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index d393db62b..afe7d41c7 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -1227,6 +1227,109 @@ class PhantomBleRepositoryTest { } } + @Test + fun `disconnect teardown rejects ordinary controls at final disconnected publication`() = runTest { + val logRepo = ConnectionLogRepository() + val initialConfig = PhantomBleConfig(repDelayMs = 100L) + val replacement = PhantomBleConfig(loadScale = 2f, repDelayMs = 100L) + val repository = PhantomBleRepository(logRepo, initialConfig) + var startWorkoutResult: Result? = null + var stopWorkoutResult: Result? = null + var colorResult: Result? = null + var workoutCommandResult: Result? = null + var initResult: Result? = null + var stopCommandResult: Result? = null + var rawPacketResult: Result? = null + val controlsOnDisconnect = async(Dispatchers.Unconfined) { + repository.connectionState.drop(1).first { state -> + if (state == ConnectionState.Disconnected) { + repository.enableHandleDetection(true) + repository.resetHandleState() + repository.enableJustLiftWaitingMode() + repository.startActiveWorkoutPolling() + repository.stopPolling() + repository.stopMonitorPollingOnly() + repository.restartMonitorPolling() + repository.restartDiagnosticPolling() + repository.startDiscoMode() + repository.stopDiscoMode() + repository.setLastColorSchemeIndex(7) + repository.replaceConfig(replacement) + startWorkoutResult = repository.startWorkout(workoutParameters()) + stopWorkoutResult = repository.stopWorkout() + colorResult = repository.setColorScheme(7) + workoutCommandResult = repository.sendWorkoutCommand(byteArrayOf(0x01)) + initResult = repository.sendInitSequence() + stopCommandResult = repository.sendStopCommand() + rawPacketResult = repository.injectRawPacket( + PhantomRawPacketKind.MONITOR, + monitorPacket( + ticks = 42, + posA = 1250, + velA = 320, + loadA = 1234, + posB = -750, + velB = -250, + loadB = 567, + ), + ) + true + } else { + false + } + } + } + + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + + repository.disconnect() + controlsOnDisconnect.await() + + assertTrue(requireNotNull(startWorkoutResult).isFailure) + assertTrue(requireNotNull(stopWorkoutResult).isFailure) + assertTrue(requireNotNull(colorResult).isFailure) + assertTrue(requireNotNull(workoutCommandResult).isFailure) + assertTrue(requireNotNull(initResult).isFailure) + assertTrue(requireNotNull(stopCommandResult).isFailure) + assertTrue(requireNotNull(rawPacketResult).isFailure) + assertFalse(repository.handleDetection.value.leftDetected) + assertFalse(repository.handleDetection.value.rightDetected) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + assertEquals(initialConfig, repository.config.value) + assertFalse(repository.discoModeActive.value) + assertTrue(logRepo.logs.value.none { log -> + log.message in setOf( + "Phantom handle detection enabled", + "Phantom Just Lift waiting mode armed", + "Phantom polling stopped", + "Phantom monitor polling stopped; diagnostics kept warm", + "Phantom disco mode stopped", + "Phantom config updated", + "Phantom workout started", + "Phantom workout stopped", + "Phantom color scheme set", + "Phantom received raw workout command", + "Phantom init sequence accepted", + "Phantom stop command accepted", + "Phantom injected raw monitor packet", + ) + }) + + assertTrue(repository.connect(ScannedDevice("post-cleanup", "post-cleanup-address")).isSuccess) + logRepo.clearAll() + repository.startDiscoMode() + repository.stopDiscoMode() + assertTrue( + logRepo.getLogsByEventType(LogEventType.COMMAND_SENT) + .any { it.message == "Phantom disco mode stopped" && it.details == "restoredScheme=0" }, + ) + } finally { + repository.shutdown() + } + } + @Test fun `command results fail when command log collector disconnects normally`() = runTest { val commands = listOf Result>>( From fb5c0ebb0aa2328f63ca47814ac51c0a54012bb5 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 08:04:53 -0400 Subject: [PATCH 36/54] fix(ios): invalidate Phantom workout handoffs --- .../data/repository/PhantomBleRepository.kt | 2 +- .../repository/PhantomBleRepositoryTest.kt | 42 +++++++++++++++++++ 2 files changed, 43 insertions(+), 1 deletion(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index c4a830494..38e9548fe 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -450,7 +450,7 @@ class PhantomBleRepository( if (terminal.value || lifecycleCleanupInProgress) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } - val expectedConnectionGeneration = connectionAttemptGeneration.value + val expectedConnectionGeneration = connectionAttemptGeneration.incrementAndGet() logRepo.info(LogEventType.COMMAND_SENT, "Phantom workout stopped", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index afe7d41c7..8c9af67e8 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -1164,6 +1164,48 @@ class PhantomBleRepositoryTest { } } + @Test + fun `stopWorkout from workout handle publication invalidates outer workout handoff`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo, PhantomBleConfig(repDelayMs = 100L)) + val stopOnGrabbed = async(Dispatchers.Unconfined) { + repository.handleState.first { state -> + if (state == HandleState.Grabbed) { + assertTrue(repository.stopWorkout().isSuccess) + true + } else { + false + } + } + } + + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + + val result = repository.startWorkout(workoutParameters()) + + stopOnGrabbed.await() + assertTrue(result.isFailure) + assertTrue(repository.connectionState.value is ConnectionState.Connected) + assertEquals(HandleState.Released, repository.handleState.value) + + val logsAfterStop = logRepo.logs.value + withContext(Dispatchers.Default) { delay(350L) } + assertEquals(logsAfterStop, logRepo.logs.value) + assertTrue(logRepo.logs.value.none { log -> + log.message in setOf( + "Phantom workout started", + "Phantom monitor metric", + "Phantom heuristic update", + "Phantom rep notification", + ) + }) + } finally { + repository.shutdown() + } + } + @Test fun `shutdown from workout handle publication prevents post-publication workout effects`() = runTest { val logRepo = ConnectionLogRepository() From 92ea8193224109c741b152901c80cba363a96f2a Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 08:28:02 -0400 Subject: [PATCH 37/54] fix(ios): guard Phantom handle reset generation --- .../data/repository/PhantomBleRepository.kt | 21 +++++++--- .../repository/PhantomBleRepositoryTest.kt | 40 +++++++++++++++++++ 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 38e9548fe..f82bbbe19 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -505,12 +505,23 @@ class PhantomBleRepository( override fun resetHandleState() { lifecycleLock.withLock { - if (!terminal.value && !lifecycleCleanupInProgress) { - _handleState.value = HandleState.WaitingForRest - if (terminal.value) { - return@withLock - } + val expectedConnectionGeneration = connectionAttemptGeneration.value + if ( + terminal.value || + lifecycleCleanupInProgress || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { + return@withLock } + _handleState.value = HandleState.WaitingForRest + if ( + terminal.value || + lifecycleCleanupInProgress || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { + return@withLock + } + connectionAttemptGeneration.incrementAndGet() } } diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 8c9af67e8..76730007f 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -1206,6 +1206,46 @@ class PhantomBleRepositoryTest { } } + @Test + fun `resetHandleState from workout handle publication invalidates outer workout handoff`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo, PhantomBleConfig(repDelayMs = 100L)) + val resetOnGrabbed = async(Dispatchers.Unconfined) { + repository.handleState.first { state -> + if (state == HandleState.Grabbed) { + repository.resetHandleState() + true + } else { + false + } + } + } + + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + + val result = repository.startWorkout(workoutParameters()) + + resetOnGrabbed.await() + assertTrue(result.isFailure) + assertTrue(repository.connectionState.value is ConnectionState.Connected) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + assertTrue(logRepo.logs.value.none { log -> + log.message == "Phantom workout started" || + log.message == "Phantom heuristic update" || + log.message == "Phantom rep notification" || + (log.message == "Phantom monitor metric" && log.details?.contains("load=10") == true) + }) + + val logsAfterReset = logRepo.logs.value + withContext(Dispatchers.Default) { delay(350L) } + assertEquals(logsAfterReset, logRepo.logs.value) + } finally { + repository.shutdown() + } + } + @Test fun `shutdown from workout handle publication prevents post-publication workout effects`() = runTest { val logRepo = ConnectionLogRepository() From f200f61cd0d2b128eaa323f62943a3f2bad4641c Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 09:08:09 -0400 Subject: [PATCH 38/54] fix(ios): preserve Phantom handle ownership --- .../data/repository/PhantomBleRepository.kt | 11 +++++ .../repository/PhantomBleRepositoryTest.kt | 40 ++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index f82bbbe19..9a491d6a7 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -405,6 +405,11 @@ class PhantomBleRepository( workoutConnectionGeneration = expectedConnectionGeneration _handleState.value = HandleState.Grabbed if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (workoutConnectionGeneration == expectedConnectionGeneration) { + stopJobs() + workoutParams = null + workoutConnectionGeneration = null + } return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } logRepo.info( @@ -495,6 +500,12 @@ class PhantomBleRepository( if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } + if ( + workoutConnectionGeneration == expectedConnectionGeneration && + (_handleState.value == HandleState.Grabbed || workoutParams != null) + ) { + return@withLock + } _handleState.value = if (enabled) HandleState.WaitingForRest else HandleState.Released if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 76730007f..fed60a419 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -1238,9 +1238,45 @@ class PhantomBleRepositoryTest { (log.message == "Phantom monitor metric" && log.details?.contains("load=10") == true) }) - val logsAfterReset = logRepo.logs.value + repository.replaceConfig(PhantomBleConfig(loadScale = 2f, repDelayMs = 100L)) + logRepo.clearAll() withContext(Dispatchers.Default) { delay(350L) } - assertEquals(logsAfterReset, logRepo.logs.value) + assertTrue(logRepo.logs.value.none { log -> + log.message == "Phantom monitor metric" && log.details?.contains("load=20") == true + }) + } finally { + repository.shutdown() + } + } + + @Test + fun `handle detection publication preserves reentrant workout ownership`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo, PhantomBleConfig(repDelayMs = 100L)) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + val startWorkoutOnDetection = async(Dispatchers.Unconfined) { + repository.handleDetection.drop(1).first { detection -> + if (!detection.leftDetected) { + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + true + } else { + false + } + } + } + + repository.enableHandleDetection(false) + startWorkoutOnDetection.await() + + assertTrue(repository.connectionState.value is ConnectionState.Connected) + assertEquals(HandleState.Grabbed, repository.handleState.value) + val metric = withContext(Dispatchers.Default) { + withTimeout(1_000L) { repository.metricsFlow.first() } + } + assertTrue(metric.loadA >= 10f, "detection handoff must preserve active workout metrics") } finally { repository.shutdown() } From 298a9570e31b48a8f0573bd24dad3059f1f02427 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 09:14:07 -0400 Subject: [PATCH 39/54] test(ios): stabilize Phantom stop handoff assertion --- .../data/repository/PhantomBleRepositoryTest.kt | 8 -------- 1 file changed, 8 deletions(-) diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index fed60a419..917e112dd 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -1193,14 +1193,6 @@ class PhantomBleRepositoryTest { val logsAfterStop = logRepo.logs.value withContext(Dispatchers.Default) { delay(350L) } assertEquals(logsAfterStop, logRepo.logs.value) - assertTrue(logRepo.logs.value.none { log -> - log.message in setOf( - "Phantom workout started", - "Phantom monitor metric", - "Phantom heuristic update", - "Phantom rep notification", - ) - }) } finally { repository.shutdown() } From ca4861e63bac1c2ed22417ddc14251f020c19895 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 09:40:26 -0400 Subject: [PATCH 40/54] fix(ios): register Phantom active polling ownership --- .../data/repository/PhantomBleRepository.kt | 7 +++-- .../repository/PhantomBleRepositoryTest.kt | 28 +++++++++++++++++++ 2 files changed, 32 insertions(+), 3 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 9a491d6a7..af033f05c 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -564,15 +564,16 @@ class PhantomBleRepository( return@withLock } val expectedConnectionGeneration = connectionAttemptGeneration.value + workoutConnectionGeneration = expectedConnectionGeneration _handleState.value = HandleState.Grabbed - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } startMetrics( activeWorkout = true, expectedConnectionGeneration = expectedConnectionGeneration, ) - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } if (!startHeuristicGeneration( @@ -582,7 +583,7 @@ class PhantomBleRepository( ) { return@withLock } - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } } diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 917e112dd..951981d54 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -370,6 +370,34 @@ class PhantomBleRepositoryTest { } } + @Test + fun `connected collector active polling owns connection completion producers`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val startPollingOnConnected = async(Dispatchers.Unconfined) { + repository.connectionState.first { state -> + if (state is ConnectionState.Connected) { + repository.startActiveWorkoutPolling() + true + } else { + false + } + } + } + + try { + assertTrue(repository.connect(ScannedDevice("collector", "collector-address")).isSuccess) + startPollingOnConnected.await() + + assertEquals(HandleState.Grabbed, repository.handleState.value) + val metric = withContext(Dispatchers.Default) { + withTimeout(1_000L) { repository.metricsFlow.first() } + } + assertTrue(metric.loadA >= 2f, "completion must preserve reentrant active polling") + } finally { + repository.shutdown() + } + } + @Test fun `handle detection collector workout preserves connected handoff`() = runTest { val repository = PhantomBleRepository( From ee94b97323f6ba0bcf798797f4b5d73ebfb4bf41 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 10:28:07 -0400 Subject: [PATCH 41/54] fix(ios): reconcile Phantom active polling handoff --- .../data/repository/PhantomBleRepository.kt | 19 +++++++-- .../repository/PhantomBleRepositoryTest.kt | 40 +++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index af033f05c..2690e739f 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -1065,7 +1065,8 @@ class PhantomBleRepository( !startHeuristicGeneration( activeWorkout = false, expectedConnectionGeneration = attemptGeneration, - ) + ) && + !activePollingOwnsConnection(attemptGeneration) ) { if (terminal.value || connectionAttemptGeneration.value != attemptGeneration || workoutParams == null) { return@withLock false @@ -1081,12 +1082,12 @@ class PhantomBleRepository( if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { return@withLock false } - if ( - heuristicJob?.isActive != true && + if (heuristicJob?.isActive != true && !startHeuristicGeneration( activeWorkout = true, expectedConnectionGeneration = attemptGeneration, - ) + ) && + !activePollingOwnsConnection(attemptGeneration) ) { return@withLock false } @@ -1110,6 +1111,16 @@ class PhantomBleRepository( true } + private fun activePollingOwnsConnection(expectedConnectionGeneration: Long): Boolean = + !terminal.value && + !lifecycleCleanupInProgress && + connectionAttemptGeneration.value == expectedConnectionGeneration && + _connectionState.value is ConnectionState.Connected && + workoutConnectionGeneration == expectedConnectionGeneration && + _handleState.value == HandleState.Grabbed && + metricsJob?.isActive == true && + heuristicJob?.isActive == true + private fun teardownConnection(markTerminal: Boolean = false) { lifecycleLock.withLock { if (markTerminal) { diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 951981d54..7762fcc72 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -398,6 +398,46 @@ class PhantomBleRepositoryTest { } } + @Test + fun `heuristic collector active polling preserves connection completion`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val startPollingOnHeuristic = async(Dispatchers.Unconfined) { + repository.heuristicData.first { heuristic -> + if (heuristic != null) { + repository.startActiveWorkoutPolling() + true + } else { + false + } + } + } + + try { + assertTrue(repository.connect(ScannedDevice("heuristic", "heuristic-address")).isSuccess) + startPollingOnHeuristic.await() + + assertTrue(repository.connectionState.value is ConnectionState.Connected) + assertEquals(HandleState.Grabbed, repository.handleState.value) + val metric = withContext(Dispatchers.Default) { + withTimeout(1_000L) { repository.metricsFlow.first() } + } + assertTrue(metric.loadA >= 2f, "heuristic handoff must preserve active workout metrics") + withContext(Dispatchers.Default) { + withTimeout(1_000L) { + logRepo.logs.first { logs -> + logs.any { log -> + log.eventType == LogEventType.HEARTBEAT && log.message == "Phantom heartbeat" + } + } + } + } + } finally { + repository.stopPolling() + repository.shutdown() + } + } + @Test fun `handle detection collector workout preserves connected handoff`() = runTest { val repository = PhantomBleRepository( From 316c9e4b7e65fbb3c7df697524cacfc76f9d68b3 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 11:11:34 -0400 Subject: [PATCH 42/54] fix(ios): reconcile Phantom final lifecycle handoffs --- .../data/repository/PhantomBleRepository.kt | 91 ++++++++---- .../repository/PhantomBleRepositoryTest.kt | 139 ++++++++++++++++++ 2 files changed, 203 insertions(+), 27 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 2690e739f..fab085374 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -127,6 +127,7 @@ class PhantomBleRepository( private val terminal = atomic(false) private val connectionAttemptGeneration = atomic(0L) private var lifecycleCleanupInProgress = false + private var connectionAttemptReservationActive = false private var metricsGeneration = 0L private var heuristicGeneration = 0L private var repGeneration = 0L @@ -391,8 +392,16 @@ class PhantomBleRepository( override suspend fun startWorkout(params: WorkoutParameters): Result { return lifecycleLock.withLock { - if (terminal.value || lifecycleCleanupInProgress) { - return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { + return@withLock Result.failure( + IllegalStateException( + if (connectionAttemptReservationActive) { + "Phantom connection attempt is being reserved" + } else { + "Phantom repository is shut down" + }, + ), + ) } val expectedConnectionGeneration = connectionAttemptGeneration.value if (_discoModeActive.value) { @@ -404,13 +413,12 @@ class PhantomBleRepository( workoutParams = params workoutConnectionGeneration = expectedConnectionGeneration _handleState.value = HandleState.Grabbed - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { - if (workoutConnectionGeneration == expectedConnectionGeneration) { - stopJobs() + if (!workoutHandoffIsCurrent(params, expectedConnectionGeneration)) { + if (workoutParams == params && workoutConnectionGeneration == expectedConnectionGeneration) { workoutParams = null workoutConnectionGeneration = null } - return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + return@withLock Result.failure(IllegalStateException("Phantom workout handoff invalidated")) } logRepo.info( LogEventType.COMMAND_SENT, @@ -419,32 +427,32 @@ class PhantomBleRepository( PHANTOM_DEVICE_ADDRESS, "mode=${params.programMode}; reps=${params.reps}; weightPerCableKg=${params.weightPerCableKg}; justLift=${params.isJustLift}", ) - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { - return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + if (!workoutHandoffIsCurrent(params, expectedConnectionGeneration)) { + return@withLock Result.failure(IllegalStateException("Phantom workout handoff invalidated")) } startMetrics( activeWorkout = true, expectedConnectionGeneration = expectedConnectionGeneration, ) - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { - return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + if (!workoutHandoffIsCurrent(params, expectedConnectionGeneration)) { + return@withLock Result.failure(IllegalStateException("Phantom workout handoff invalidated")) } if (!startHeuristicGeneration( activeWorkout = true, expectedConnectionGeneration = expectedConnectionGeneration, ) ) { - return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + return@withLock Result.failure(IllegalStateException("Phantom workout handoff invalidated")) } - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { - return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + if (!workoutHandoffIsCurrent(params, expectedConnectionGeneration)) { + return@withLock Result.failure(IllegalStateException("Phantom workout handoff invalidated")) } startRepSimulation( params = params, expectedConnectionGeneration = expectedConnectionGeneration, ) - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { - return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + if (!workoutHandoffIsCurrent(params, expectedConnectionGeneration)) { + return@withLock Result.failure(IllegalStateException("Phantom workout handoff invalidated")) } Result.success(Unit) } @@ -532,7 +540,6 @@ class PhantomBleRepository( ) { return@withLock } - connectionAttemptGeneration.incrementAndGet() } } @@ -934,22 +941,27 @@ class PhantomBleRepository( } private fun beginConnectionAttempt(reservedState: ConnectionState): Long? = lifecycleLock.withLock { - if (terminal.value) { + if (terminal.value || connectionAttemptReservationActive) { null } else { - val attemptGeneration = connectionAttemptGeneration.incrementAndGet() - if (reservedState == ConnectionState.Scanning) { - _scannedDevices.value = emptyList() - } - if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { - null - } else { - _connectionState.value = reservedState + connectionAttemptReservationActive = true + try { + val attemptGeneration = connectionAttemptGeneration.incrementAndGet() + if (reservedState == ConnectionState.Scanning) { + _scannedDevices.value = emptyList() + } if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { null } else { - attemptGeneration + _connectionState.value = reservedState + if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { + null + } else { + attemptGeneration + } } + } finally { + connectionAttemptReservationActive = false } } } @@ -1040,7 +1052,9 @@ class PhantomBleRepository( if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { return@withLock false } - if (!startDiagnostics(expectedConnectionGeneration = attemptGeneration)) { + if (!startDiagnostics(expectedConnectionGeneration = attemptGeneration) && + !currentConnectedProducerOwnsConnection(attemptGeneration, diagnosticJob) + ) { return@withLock false } if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { @@ -1066,6 +1080,7 @@ class PhantomBleRepository( activeWorkout = false, expectedConnectionGeneration = attemptGeneration, ) && + !currentConnectedProducerOwnsConnection(attemptGeneration, heuristicJob) && !activePollingOwnsConnection(attemptGeneration) ) { if (terminal.value || connectionAttemptGeneration.value != attemptGeneration || workoutParams == null) { @@ -1087,6 +1102,7 @@ class PhantomBleRepository( activeWorkout = true, expectedConnectionGeneration = attemptGeneration, ) && + !currentConnectedProducerOwnsConnection(attemptGeneration, heuristicJob) && !activePollingOwnsConnection(attemptGeneration) ) { return@withLock false @@ -1111,6 +1127,27 @@ class PhantomBleRepository( true } + private fun workoutHandoffIsCurrent( + params: WorkoutParameters, + expectedConnectionGeneration: Long, + ): Boolean = + !terminal.value && + !lifecycleCleanupInProgress && + connectionAttemptGeneration.value == expectedConnectionGeneration && + workoutConnectionGeneration == expectedConnectionGeneration && + workoutParams == params && + _handleState.value == HandleState.Grabbed + + private fun currentConnectedProducerOwnsConnection( + expectedConnectionGeneration: Long, + producerJob: Job?, + ): Boolean = + !terminal.value && + !lifecycleCleanupInProgress && + connectionAttemptGeneration.value == expectedConnectionGeneration && + _connectionState.value is ConnectionState.Connected && + producerJob?.isActive == true + private fun activePollingOwnsConnection(expectedConnectionGeneration: Long): Boolean = !terminal.value && !lifecycleCleanupInProgress && diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 7762fcc72..ccc4f6efc 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -438,6 +438,77 @@ class PhantomBleRepositoryTest { } } + @Test + fun `diagnostic producer replacement during connection preserves success`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val restartOnDiagnostic = async(Dispatchers.Unconfined) { + repository.diagnostics.first { diagnostic -> + if (diagnostic != null) { + repository.restartDiagnosticPolling() + true + } else { + false + } + } + } + + try { + val result = repository.connect(ScannedDevice("diagnostic", "diagnostic-address")) + + restartOnDiagnostic.await() + assertTrue(result.isSuccess) + assertTrue(repository.connectionState.value is ConnectionState.Connected) + withContext(Dispatchers.Default) { + withTimeout(1_000L) { + logRepo.logs.first { logs -> + logs.any { log -> + log.eventType == LogEventType.HEARTBEAT && log.message == "Phantom heartbeat" + } + } + } + } + } finally { + repository.shutdown() + } + } + + @Test + fun `heuristic producer replacement during connection preserves success`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val replacement = PhantomBleConfig(loadScale = 2f) + val replaceOnHeuristic = async(Dispatchers.Unconfined) { + repository.heuristicData.first { heuristic -> + if (heuristic != null) { + repository.replaceConfig(replacement) + true + } else { + false + } + } + } + + try { + val result = repository.connect(ScannedDevice("heuristic", "heuristic-address")) + + replaceOnHeuristic.await() + assertTrue(result.isSuccess) + assertTrue(repository.connectionState.value is ConnectionState.Connected) + withContext(Dispatchers.Default) { + withTimeout(1_000L) { + logRepo.logs.first { logs -> + logs.any { log -> + log.eventType == LogEventType.HEARTBEAT && log.message == "Phantom heartbeat" + } + } + } + } + } finally { + repository.shutdown() + } + } + @Test fun `handle detection collector workout preserves connected handoff`() = runTest { val repository = PhantomBleRepository( @@ -528,6 +599,74 @@ class PhantomBleRepositoryTest { } } + @Test + fun `scan clear rejects reentrant workout before scanning publication`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + var stateWhenCleared: ConnectionState? = null + var startWorkoutResult: Result? = null + val startWorkoutOnClear = async(Dispatchers.Unconfined) { + repository.scannedDevices.drop(1).first { devices -> + if (devices.isEmpty()) { + stateWhenCleared = repository.connectionState.value + startWorkoutResult = repository.startWorkout(workoutParameters()) + true + } else { + false + } + } + } + + try { + assertTrue(repository.scanAndConnect().isSuccess) + + val scanning = async(Dispatchers.Default) { repository.startScanning() } + startWorkoutOnClear.await() + + assertTrue(stateWhenCleared is ConnectionState.Connected) + assertTrue(requireNotNull(startWorkoutResult).isFailure) + assertTrue(scanning.await().isSuccess) + assertEquals(ConnectionState.Scanning, repository.connectionState.value) + } finally { + repository.shutdown() + } + } + + @Test + fun `reset handle preserves active connected polling`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val resetOnActiveMetric = async(Dispatchers.Unconfined) { + repository.metricsFlow.first { metric -> + if (metric.loadA >= 2f) { + repository.resetHandleState() + true + } else { + false + } + } + } + + try { + assertTrue(repository.scanAndConnect().isSuccess) + repository.startActiveWorkoutPolling() + resetOnActiveMetric.await() + logRepo.clearAll() + + assertTrue(repository.connectionState.value is ConnectionState.Connected) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + withContext(Dispatchers.Default) { + withTimeout(1_000L) { + logRepo.logs.first { logs -> + logs.any { log -> log.message == "Phantom monitor metric" } && + logs.any { log -> log.message == "Phantom heuristic update" } + } + } + } + } finally { + repository.shutdown() + } + } + @Test fun `beginning a connection attempt gates reentrant producer restarts`() = runTest { val logRepo = ConnectionLogRepository() From cab4dda19abedbd92bd288426a13cb7818ac9657 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 11:49:43 -0400 Subject: [PATCH 43/54] fix(ios): close Phantom workout ownership gaps --- .../data/repository/PhantomBleRepository.kt | 53 ++++++++++-- .../repository/PhantomBleRepositoryTest.kt | 83 +++++++++++++++++++ 2 files changed, 128 insertions(+), 8 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index fab085374..3277fdf3b 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -403,6 +403,9 @@ class PhantomBleRepository( ), ) } + if (_connectionState.value !is ConnectionState.Connected) { + return@withLock Result.failure(IllegalStateException("Phantom workout requires an active connection")) + } val expectedConnectionGeneration = connectionAttemptGeneration.value if (_discoModeActive.value) { stopDiscoMode() @@ -414,10 +417,7 @@ class PhantomBleRepository( workoutConnectionGeneration = expectedConnectionGeneration _handleState.value = HandleState.Grabbed if (!workoutHandoffIsCurrent(params, expectedConnectionGeneration)) { - if (workoutParams == params && workoutConnectionGeneration == expectedConnectionGeneration) { - workoutParams = null - workoutConnectionGeneration = null - } + rollbackWorkoutHandoff(params, expectedConnectionGeneration) return@withLock Result.failure(IllegalStateException("Phantom workout handoff invalidated")) } logRepo.info( @@ -428,6 +428,7 @@ class PhantomBleRepository( "mode=${params.programMode}; reps=${params.reps}; weightPerCableKg=${params.weightPerCableKg}; justLift=${params.isJustLift}", ) if (!workoutHandoffIsCurrent(params, expectedConnectionGeneration)) { + rollbackWorkoutHandoff(params, expectedConnectionGeneration) return@withLock Result.failure(IllegalStateException("Phantom workout handoff invalidated")) } startMetrics( @@ -435,6 +436,7 @@ class PhantomBleRepository( expectedConnectionGeneration = expectedConnectionGeneration, ) if (!workoutHandoffIsCurrent(params, expectedConnectionGeneration)) { + rollbackWorkoutHandoff(params, expectedConnectionGeneration) return@withLock Result.failure(IllegalStateException("Phantom workout handoff invalidated")) } if (!startHeuristicGeneration( @@ -442,9 +444,11 @@ class PhantomBleRepository( expectedConnectionGeneration = expectedConnectionGeneration, ) ) { + rollbackWorkoutHandoff(params, expectedConnectionGeneration) return@withLock Result.failure(IllegalStateException("Phantom workout handoff invalidated")) } if (!workoutHandoffIsCurrent(params, expectedConnectionGeneration)) { + rollbackWorkoutHandoff(params, expectedConnectionGeneration) return@withLock Result.failure(IllegalStateException("Phantom workout handoff invalidated")) } startRepSimulation( @@ -452,6 +456,7 @@ class PhantomBleRepository( expectedConnectionGeneration = expectedConnectionGeneration, ) if (!workoutHandoffIsCurrent(params, expectedConnectionGeneration)) { + rollbackWorkoutHandoff(params, expectedConnectionGeneration) return@withLock Result.failure(IllegalStateException("Phantom workout handoff invalidated")) } Result.success(Unit) @@ -560,7 +565,13 @@ class PhantomBleRepository( override fun restartMonitorPolling() { lifecycleLock.withLock { if (!terminal.value && !lifecycleCleanupInProgress) { - startMetrics(activeWorkout = workoutParams != null) + val expectedConnectionGeneration = connectionAttemptGeneration.value + val activeWorkout = workoutParams != null || + (workoutConnectionGeneration == expectedConnectionGeneration && _handleState.value == HandleState.Grabbed) + startMetrics( + activeWorkout = activeWorkout, + expectedConnectionGeneration = expectedConnectionGeneration, + ) } } } @@ -911,15 +922,17 @@ class PhantomBleRepository( if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } + val activeWorkout = workoutParams != null || + (workoutConnectionGeneration == expectedConnectionGeneration && _handleState.value == HandleState.Grabbed) startMetrics( - activeWorkout = workoutParams != null, + activeWorkout = activeWorkout, expectedConnectionGeneration = expectedConnectionGeneration, ) if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock } if (!startHeuristicGeneration( - activeWorkout = workoutParams != null, + activeWorkout = activeWorkout, expectedConnectionGeneration = expectedConnectionGeneration, ) ) { @@ -1133,11 +1146,35 @@ class PhantomBleRepository( ): Boolean = !terminal.value && !lifecycleCleanupInProgress && + _connectionState.value is ConnectionState.Connected && connectionAttemptGeneration.value == expectedConnectionGeneration && workoutConnectionGeneration == expectedConnectionGeneration && - workoutParams == params && + workoutParams === params && _handleState.value == HandleState.Grabbed + private fun rollbackWorkoutHandoff( + params: WorkoutParameters, + expectedConnectionGeneration: Long, + ) { + if (workoutParams !== params || workoutConnectionGeneration != expectedConnectionGeneration) { + return + } + metricsGeneration += 1 + heuristicGeneration += 1 + repGeneration += 1 + metricsJob?.cancel() + heuristicJob?.cancel() + repJob?.cancel() + metricsJob = null + heuristicJob = null + repJob = null + workoutParams = null + workoutConnectionGeneration = null + if (_handleState.value == HandleState.Grabbed) { + _handleState.value = HandleState.Released + } + } + private fun currentConnectedProducerOwnsConnection( expectedConnectionGeneration: Long, producerJob: Job?, diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index ccc4f6efc..42e349455 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -62,6 +62,19 @@ class PhantomBleRepositoryTest { } } + @Test + fun `disconnected workout start does not claim handle ownership`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + + try { + assertTrue(repository.startWorkout(workoutParameters()).isFailure) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + } finally { + repository.shutdown() + } + } + @Test fun `cancelConnection invalidates an in-flight connect`() = runTest { val repository = PhantomBleRepository(ConnectionLogRepository()) @@ -667,6 +680,76 @@ class PhantomBleRepositoryTest { } } + @Test + fun `heuristic invalidation rolls back workout producers`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo, PhantomBleConfig(repDelayMs = 100L)) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + val resetOnWorkoutHeuristic = async(Dispatchers.Unconfined) { + repository.heuristicData.drop(1).first { + repository.resetHandleState() + true + } + } + + val result = repository.startWorkout(workoutParameters()) + + resetOnWorkoutHeuristic.await() + assertTrue(result.isFailure) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + val workoutProducerLogsAfterRollback = logRepo.logs.value.filter { log -> + log.message in setOf( + "Phantom monitor metric", + "Phantom heuristic update", + "Phantom rep notification", + ) + } + withContext(Dispatchers.Default) { delay(350L) } + assertEquals( + workoutProducerLogsAfterRollback, + logRepo.logs.value.filter { log -> + log.message in setOf( + "Phantom monitor metric", + "Phantom heuristic update", + "Phantom rep notification", + ) + }, + ) + } finally { + repository.shutdown() + } + } + + @Test + fun `active polling remains active across monitor restart and config replacement`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + repository.startActiveWorkoutPolling() + withContext(Dispatchers.Default) { + withTimeout(1_000L) { repository.metricsFlow.first { it.loadA >= 2f } } + } + + val metricAfterRestart = async(Dispatchers.Unconfined) { + withTimeout(1_000L) { repository.metricsFlow.first { it.loadA >= 2f } } + } + repository.restartMonitorPolling() + metricAfterRestart.await() + + val metricAfterReplacement = async(Dispatchers.Unconfined) { + withTimeout(1_000L) { repository.metricsFlow.first { it.loadA >= 10f } } + } + repository.replaceConfig(PhantomBleConfig(loadScale = 2f)) + metricAfterReplacement.await() + } finally { + repository.shutdown() + } + } + @Test fun `beginning a connection attempt gates reentrant producer restarts`() = runTest { val logRepo = ConnectionLogRepository() From ceafc9df580ef21712f1e3b1980d31b6819b1150 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 12:22:17 -0400 Subject: [PATCH 44/54] fix(ios): close Phantom reservation cleanup races --- .../data/repository/PhantomBleRepository.kt | 14 +- .../repository/PhantomBleRepositoryTest.kt | 141 +++++++++++++++++- 2 files changed, 146 insertions(+), 9 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 3277fdf3b..41032dc23 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -564,7 +564,7 @@ class PhantomBleRepository( override fun restartMonitorPolling() { lifecycleLock.withLock { - if (!terminal.value && !lifecycleCleanupInProgress) { + if (!terminal.value && !lifecycleCleanupInProgress && !connectionAttemptReservationActive) { val expectedConnectionGeneration = connectionAttemptGeneration.value val activeWorkout = workoutParams != null || (workoutConnectionGeneration == expectedConnectionGeneration && _handleState.value == HandleState.Grabbed) @@ -578,7 +578,11 @@ class PhantomBleRepository( override fun startActiveWorkoutPolling() { lifecycleLock.withLock { - if (terminal.value || lifecycleCleanupInProgress) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + _connectionState.value !is ConnectionState.Connected + ) { return@withLock } val expectedConnectionGeneration = connectionAttemptGeneration.value @@ -639,7 +643,7 @@ class PhantomBleRepository( override fun restartDiagnosticPolling() { lifecycleLock.withLock { - if (!terminal.value && !lifecycleCleanupInProgress) { + if (!terminal.value && !lifecycleCleanupInProgress && !connectionAttemptReservationActive) { val expectedConnectionGeneration = connectionAttemptGeneration.value if (!startDiagnostics(expectedConnectionGeneration) || terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration @@ -900,7 +904,7 @@ class PhantomBleRepository( fun replaceConfig(config: PhantomBleConfig) { lifecycleLock.withLock { - if (terminal.value || lifecycleCleanupInProgress) { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { return@withLock } val expectedConnectionGeneration = connectionAttemptGeneration.value @@ -954,7 +958,7 @@ class PhantomBleRepository( } private fun beginConnectionAttempt(reservedState: ConnectionState): Long? = lifecycleLock.withLock { - if (terminal.value || connectionAttemptReservationActive) { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { null } else { connectionAttemptReservationActive = true diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 42e349455..0a0dac1e9 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -411,6 +411,61 @@ class PhantomBleRepositoryTest { } } + @Test + fun `active polling rejects scanning and connecting states without claiming ownership`() = runTest { + val scanningRepository = PhantomBleRepository(ConnectionLogRepository()) + var handleStateWhenScanning: HandleState? = null + val startPollingOnScanning = async(Dispatchers.Unconfined) { + scanningRepository.connectionState.first { state -> + if (state == ConnectionState.Scanning) { + scanningRepository.startActiveWorkoutPolling() + handleStateWhenScanning = scanningRepository.handleState.value + true + } else { + false + } + } + } + + try { + val scanning = async(Dispatchers.Default) { scanningRepository.startScanning() } + startPollingOnScanning.await() + + assertEquals(HandleState.WaitingForRest, handleStateWhenScanning) + assertTrue(scanning.await().isSuccess) + assertEquals(HandleState.WaitingForRest, scanningRepository.handleState.value) + } finally { + scanningRepository.shutdown() + } + + val connectingRepository = PhantomBleRepository(ConnectionLogRepository()) + var handleStateWhenConnecting: HandleState? = null + val startPollingOnConnecting = async(Dispatchers.Unconfined) { + connectingRepository.connectionState.first { state -> + if (state == ConnectionState.Connecting) { + connectingRepository.startActiveWorkoutPolling() + handleStateWhenConnecting = connectingRepository.handleState.value + true + } else { + false + } + } + } + + try { + val connecting = async(Dispatchers.Default) { + connectingRepository.connect(ScannedDevice("precondition", "precondition-address")) + } + startPollingOnConnecting.await() + + assertEquals(HandleState.WaitingForRest, handleStateWhenConnecting) + assertTrue(connecting.await().isSuccess) + assertEquals(HandleState.Released, connectingRepository.handleState.value) + } finally { + connectingRepository.shutdown() + } + } + @Test fun `heuristic collector active polling preserves connection completion`() = runTest { val logRepo = ConnectionLogRepository() @@ -644,6 +699,51 @@ class PhantomBleRepositoryTest { } } + @Test + fun `connection attempt reservation rejects reentrant producer controls before scanning publication`() = runTest { + val logRepo = ConnectionLogRepository() + val initialConfig = PhantomBleConfig(repDelayMs = 100L) + val replacement = PhantomBleConfig(loadScale = 2f, repDelayMs = 100L) + val repository = PhantomBleRepository(logRepo, initialConfig) + val controlsOnClear = async(Dispatchers.Unconfined) { + repository.scannedDevices.drop(1).first { devices -> + if (devices.isEmpty()) { + repository.restartMonitorPolling() + repository.startActiveWorkoutPolling() + repository.restartDiagnosticPolling() + repository.replaceConfig(replacement) + true + } else { + false + } + } + } + + try { + assertTrue(repository.scanAndConnect().isSuccess) + repository.stopPolling() + logRepo.clearAll() + + val scanning = async(Dispatchers.Default) { repository.startScanning() } + assertTrue(controlsOnClear.await().isEmpty()) + + assertTrue(scanning.await().isSuccess) + assertEquals(ConnectionState.Scanning, repository.connectionState.value) + assertEquals(HandleState.Released, repository.handleState.value) + assertEquals(initialConfig, repository.config.value) + assertTrue(logRepo.logs.value.none { log -> + log.message in setOf( + "Phantom monitor metric", + "Phantom heuristic update", + "Phantom diagnostic heartbeat", + "Phantom config updated", + ) + }) + } finally { + repository.shutdown() + } + } + @Test fun `reset handle preserves active connected polling`() = runTest { val logRepo = ConnectionLogRepository() @@ -1205,7 +1305,7 @@ class PhantomBleRepositoryTest { } @Test - fun `disconnect teardown yields to a reentrant scan`() = runTest { + fun `disconnect teardown rejects a reentrant scan`() = runTest { val logRepo = ConnectionLogRepository() val repository = PhantomBleRepository(logRepo) @@ -1227,9 +1327,41 @@ class PhantomBleRepositoryTest { repository.disconnect() - assertTrue(restartedScan.await()!!.isSuccess) - assertEquals(ConnectionState.Scanning, repository.connectionState.value) - assertTrue(repository.scannedDevices.value.isNotEmpty()) + assertTrue(restartedScan.await()!!.isFailure) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + } finally { + repository.shutdown() + } + } + + @Test + fun `disconnect log rejects a reentrant scan during cleanup`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + var scanResult: Result? = null + val scanOnDisconnectLog = async(Dispatchers.Unconfined) { + logRepo.logs.first { logs -> + if (logs.any { it.eventType == LogEventType.DISCONNECT }) { + scanResult = repository.startScanning() + true + } else { + false + } + } + } + + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + + repository.disconnect() + assertTrue(scanOnDisconnectLog.await().any { it.eventType == LogEventType.DISCONNECT }) + + assertTrue(requireNotNull(scanResult).isFailure) + assertEquals(1, logRepo.getLogsByEventType(LogEventType.DISCONNECT).size) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(repository.scannedDevices.value.isEmpty()) } finally { repository.shutdown() } @@ -1267,6 +1399,7 @@ class PhantomBleRepositoryTest { try { assertTrue(repository.scanAndConnect().isSuccess) + repository.stopPolling() logRepo.clearAll() repository.disconnect() From 365502d8104e53b1b3b4e7532f3fa63a466a9f67 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 12:42:10 -0400 Subject: [PATCH 45/54] fix(ios): seal Phantom scan cancellation cleanup --- .../data/repository/PhantomBleRepository.kt | 4 +- .../repository/PhantomBleRepositoryTest.kt | 66 +++++++++++++++++++ 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 41032dc23..525029684 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -165,7 +165,7 @@ class PhantomBleRepository( override suspend fun stopScanning() { lifecycleLock.withLock { - if (terminal.value) { + if (terminal.value || lifecycleCleanupInProgress) { return@withLock } if (_connectionState.value != ConnectionState.Scanning && @@ -212,7 +212,7 @@ class PhantomBleRepository( override suspend fun cancelConnection() { lifecycleLock.withLock { - if (terminal.value) { + if (terminal.value || lifecycleCleanupInProgress) { return@withLock } if (_connectionState.value == ConnectionState.Connecting) { diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 0a0dac1e9..9224154b1 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -1270,6 +1270,38 @@ class PhantomBleRepositoryTest { } } + @Test + fun `stopScanning is ignored by timeout teardown`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val stopScanningOnTeardown = async(Dispatchers.Unconfined) { + repository.scannedDevices.drop(1).first { devices -> + if (devices.isEmpty()) { + repository.stopScanning() + true + } else { + false + } + } + } + + try { + val result = repository.scanAndConnect(timeoutMs = 200L) + stopScanningOnTeardown.await() + + assertTrue(result.isFailure) + assertEquals( + "Phantom scan and connect timed out after 200ms", + result.exceptionOrNull()?.message, + ) + assertTrue(logRepo.getLogsByEventType(LogEventType.SCAN_STOP).isEmpty()) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + } finally { + repository.shutdown() + } + } + @Test fun `cancelConnection does not log stale cleanup after a reentrant connection`() = runTest { val logRepo = ConnectionLogRepository() @@ -1304,6 +1336,40 @@ class PhantomBleRepositoryTest { } } + @Test + fun `cancelConnection is ignored by timeout teardown`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val cancelConnectionOnTeardown = async(Dispatchers.Unconfined) { + repository.scannedDevices.drop(1).first { devices -> + if (devices.isEmpty()) { + repository.cancelConnection() + true + } else { + false + } + } + } + + try { + val result = repository.scanAndConnect(timeoutMs = 200L) + cancelConnectionOnTeardown.await() + + assertTrue(result.isFailure) + assertEquals( + "Phantom scan and connect timed out after 200ms", + result.exceptionOrNull()?.message, + ) + assertTrue(logRepo.getLogsByEventType(LogEventType.DISCONNECT).none { log -> + log.message == "Cancelled phantom connection" + }) + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + } finally { + repository.shutdown() + } + } + @Test fun `disconnect teardown rejects a reentrant scan`() = runTest { val logRepo = ConnectionLogRepository() From 43d92332a7b61ec3d804bcb1f7345f95afb00eab Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 16:50:12 -0400 Subject: [PATCH 46/54] fix(ios): close final Phantom cancellation handoffs --- .../data/repository/PhantomBleRepository.kt | 28 ++++-- .../repository/PhantomBleRepositoryTest.kt | 91 +++++++++++++++++++ 2 files changed, 112 insertions(+), 7 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 525029684..63dd0bba0 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -21,6 +21,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.delay +import kotlinx.coroutines.yield import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow @@ -160,6 +161,7 @@ class PhantomBleRepository( if (!publishScannedDevices(attemptGeneration, listOf(device))) { return Result.failure(IllegalStateException("Phantom scan attempt invalidated")) } + yield() return Result.success(Unit) } @@ -207,6 +209,7 @@ class PhantomBleRepository( if (!completeConnection(attemptGeneration, device)) { return Result.failure(IllegalStateException("Phantom connection attempt invalidated")) } + yield() return Result.success(Unit) } @@ -442,7 +445,8 @@ class PhantomBleRepository( if (!startHeuristicGeneration( activeWorkout = true, expectedConnectionGeneration = expectedConnectionGeneration, - ) + ) && + !currentConnectedProducerOwnsConnection(expectedConnectionGeneration, heuristicJob) ) { rollbackWorkoutHandoff(params, expectedConnectionGeneration) return@withLock Result.failure(IllegalStateException("Phantom workout handoff invalidated")) @@ -988,17 +992,27 @@ class PhantomBleRepository( expectedState: ConnectionState? = null, ) { lifecycleLock.withLock { - if ( + val currentState = _connectionState.value + val matchesAttempt = !terminal.value && + !lifecycleCleanupInProgress && connectionAttemptGeneration.value == attemptGeneration && ( (expectedState == null && - (_connectionState.value == ConnectionState.Scanning || _connectionState.value == ConnectionState.Connecting)) || - (expectedState != null && _connectionState.value == expectedState) + (currentState == ConnectionState.Scanning || + currentState == ConnectionState.Connecting || + currentState is ConnectionState.Connected)) || + (expectedState != null && + (currentState == expectedState || + (expectedState == ConnectionState.Connecting && currentState is ConnectionState.Connected))) ) - ) { - connectionAttemptGeneration.incrementAndGet() - _connectionState.value = ConnectionState.Disconnected + if (matchesAttempt) { + lifecycleCleanupInProgress = true + try { + teardownConnection() + } finally { + lifecycleCleanupInProgress = false + } } } } diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 9224154b1..df92eee50 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -11,6 +11,7 @@ import kotlin.test.assertFalse import kotlin.test.assertFailsWith import kotlin.test.assertNull import kotlin.test.assertTrue +import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.async @@ -131,6 +132,64 @@ class PhantomBleRepositoryTest { } } + @Test + fun `caller cancellation from final scanned-device publication invalidates scan`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + lateinit var scanning: Deferred> + val cancelOnDevices = async(Dispatchers.Unconfined) { + repository.scannedDevices.first { devices -> + if (devices.isNotEmpty()) { + scanning.cancel() + true + } else { + false + } + } + } + + try { + scanning = async(Dispatchers.Unconfined) { repository.startScanning() } + cancelOnDevices.await() + + assertFailsWith { scanning.await() } + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + } finally { + repository.shutdown() + } + } + + @Test + fun `caller cancellation from final connected publication invalidates connect`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + lateinit var connecting: Deferred> + val cancelOnConnected = async(Dispatchers.Unconfined) { + repository.connectionState.first { state -> + if (state is ConnectionState.Connected) { + connecting.cancel() + true + } else { + false + } + } + } + + try { + connecting = async(Dispatchers.Unconfined) { + repository.connect(ScannedDevice("collector", "collector-address")) + } + cancelOnConnected.await() + + assertFailsWith { connecting.await() } + assertEquals(ConnectionState.Disconnected, repository.connectionState.value) + assertTrue(repository.scannedDevices.value.isEmpty()) + assertNull(repository.diagnostics.value) + assertNull(repository.heuristicData.value) + } finally { + repository.shutdown() + } + } + @Test fun `stopScanning invalidates a scanning attempt and resets state`() = runTest { val repository = PhantomBleRepository(ConnectionLogRepository()) @@ -823,6 +882,38 @@ class PhantomBleRepositoryTest { } } + @Test + fun `inline heuristic config replacement preserves workout handoff`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository(), PhantomBleConfig(repDelayMs = 100L)) + val replacement = PhantomBleConfig(loadScale = 2f, repDelayMs = 100L) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + val replaceOnHeuristic = async(Dispatchers.Unconfined) { + repository.heuristicData.drop(1).first { heuristic -> + if (heuristic != null) { + repository.replaceConfig(replacement) + true + } else { + false + } + } + } + val result = repository.startWorkout(workoutParameters()) + + replaceOnHeuristic.await() + assertTrue(result.isSuccess) + assertEquals(replacement, repository.config.value) + assertEquals(HandleState.Grabbed, repository.handleState.value) + val metric = withContext(Dispatchers.Default) { + withTimeout(1_000L) { repository.metricsFlow.first { it.loadA >= 10f } } + } + assertTrue(metric.loadA >= 10f) + } finally { + repository.shutdown() + } + } + @Test fun `active polling remains active across monitor restart and config replacement`() = runTest { val repository = PhantomBleRepository(ConnectionLogRepository()) From f86e103abe1c7de384d30b6d8b6b1407e6c8f3e8 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 17:33:17 -0400 Subject: [PATCH 47/54] fix(ios): close final Phantom ownership races --- .../data/repository/PhantomBleRepository.kt | 76 ++++--- .../repository/PhantomBleRepositoryTest.kt | 189 ++++++++++++++++++ 2 files changed, 239 insertions(+), 26 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 63dd0bba0..60052c500 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -129,6 +129,9 @@ class PhantomBleRepository( private val connectionAttemptGeneration = atomic(0L) private var lifecycleCleanupInProgress = false private var connectionAttemptReservationActive = false + private var activePollingConnectionGeneration: Long? = null + private var handleStateControlGeneration = 0L + private var handleDetectionControlGeneration = 0L private var metricsGeneration = 0L private var heuristicGeneration = 0L private var repGeneration = 0L @@ -472,22 +475,31 @@ class PhantomBleRepository( if (terminal.value || lifecycleCleanupInProgress) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } - val expectedConnectionGeneration = connectionAttemptGeneration.incrementAndGet() - logRepo.info(LogEventType.COMMAND_SENT, "Phantom workout stopped", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { - return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) - } - stopJobs() - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { - return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + if (_connectionState.value !is ConnectionState.Connected) { + return@withLock Result.failure(IllegalStateException("Phantom workout requires an active connection")) } - workoutParams = null - workoutConnectionGeneration = null - _handleState.value = HandleState.Released - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { - return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + lifecycleCleanupInProgress = true + try { + val expectedConnectionGeneration = connectionAttemptGeneration.incrementAndGet() + logRepo.info(LogEventType.COMMAND_SENT, "Phantom workout stopped", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } + stopJobs() + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } + activePollingConnectionGeneration = null + workoutParams = null + workoutConnectionGeneration = null + _handleState.value = HandleState.Released + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } + Result.success(Unit) + } finally { + lifecycleCleanupInProgress = false } - Result.success(Unit) } } @@ -513,6 +525,8 @@ class PhantomBleRepository( return@withLock } val expectedConnectionGeneration = connectionAttemptGeneration.value + handleStateControlGeneration += 1 + handleDetectionControlGeneration += 1 _handleDetection.value = HandleDetection(leftDetected = enabled, rightDetected = enabled) if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock @@ -541,6 +555,7 @@ class PhantomBleRepository( ) { return@withLock } + handleStateControlGeneration += 1 _handleState.value = HandleState.WaitingForRest if ( terminal.value || @@ -558,6 +573,7 @@ class PhantomBleRepository( return@withLock } val expectedConnectionGeneration = connectionAttemptGeneration.value + handleStateControlGeneration += 1 _handleState.value = HandleState.WaitingForRest if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock @@ -571,7 +587,7 @@ class PhantomBleRepository( if (!terminal.value && !lifecycleCleanupInProgress && !connectionAttemptReservationActive) { val expectedConnectionGeneration = connectionAttemptGeneration.value val activeWorkout = workoutParams != null || - (workoutConnectionGeneration == expectedConnectionGeneration && _handleState.value == HandleState.Grabbed) + activePollingConnectionGeneration == expectedConnectionGeneration startMetrics( activeWorkout = activeWorkout, expectedConnectionGeneration = expectedConnectionGeneration, @@ -590,6 +606,7 @@ class PhantomBleRepository( return@withLock } val expectedConnectionGeneration = connectionAttemptGeneration.value + activePollingConnectionGeneration = expectedConnectionGeneration workoutConnectionGeneration = expectedConnectionGeneration _handleState.value = HandleState.Grabbed if (terminal.value || lifecycleCleanupInProgress || connectionAttemptGeneration.value != expectedConnectionGeneration) { @@ -931,7 +948,7 @@ class PhantomBleRepository( return@withLock } val activeWorkout = workoutParams != null || - (workoutConnectionGeneration == expectedConnectionGeneration && _handleState.value == HandleState.Grabbed) + activePollingConnectionGeneration == expectedConnectionGeneration startMetrics( activeWorkout = activeWorkout, expectedConnectionGeneration = expectedConnectionGeneration, @@ -968,6 +985,7 @@ class PhantomBleRepository( connectionAttemptReservationActive = true try { val attemptGeneration = connectionAttemptGeneration.incrementAndGet() + activePollingConnectionGeneration = null if (reservedState == ConnectionState.Scanning) { _scannedDevices.value = emptyList() } @@ -1059,17 +1077,23 @@ class PhantomBleRepository( if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { return@withLock false } + val handleStateControlGenerationBeforePublication = handleStateControlGeneration + val handleDetectionControlGenerationBeforePublication = handleDetectionControlGeneration _connectionState.value = ConnectionState.Connected(device.name, device.address) if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { return@withLock false } - _handleDetection.value = HandleDetection(leftDetected = true, rightDetected = true) + if (handleDetectionControlGeneration == handleDetectionControlGenerationBeforePublication) { + _handleDetection.value = HandleDetection(leftDetected = true, rightDetected = true) + } if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { return@withLock false } - val workoutOwnsConnection = workoutConnectionGeneration == attemptGeneration && - (_handleState.value == HandleState.Grabbed || workoutParams != null) - if (!workoutOwnsConnection) { + val workoutOwnsConnection = + activePollingOwnsConnection(attemptGeneration) || + (workoutConnectionGeneration == attemptGeneration && + (_handleState.value == HandleState.Grabbed || workoutParams != null)) + if (!workoutOwnsConnection && handleStateControlGeneration == handleStateControlGenerationBeforePublication) { _handleState.value = HandleState.Released } if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { @@ -1092,8 +1116,10 @@ class PhantomBleRepository( return@withLock false } val activeWorkoutParams = workoutParams - val completionOwnsWorkout = workoutConnectionGeneration == attemptGeneration && - (_handleState.value == HandleState.Grabbed || activeWorkoutParams != null) + val completionOwnsWorkout = + activePollingOwnsConnection(attemptGeneration) || + (workoutConnectionGeneration == attemptGeneration && + (_handleState.value == HandleState.Grabbed || activeWorkoutParams != null)) if (!completionOwnsWorkout) { if (activeWorkoutParams != null) { workoutParams = null @@ -1208,10 +1234,7 @@ class PhantomBleRepository( !lifecycleCleanupInProgress && connectionAttemptGeneration.value == expectedConnectionGeneration && _connectionState.value is ConnectionState.Connected && - workoutConnectionGeneration == expectedConnectionGeneration && - _handleState.value == HandleState.Grabbed && - metricsJob?.isActive == true && - heuristicJob?.isActive == true + activePollingConnectionGeneration == expectedConnectionGeneration private fun teardownConnection(markTerminal: Boolean = false) { lifecycleLock.withLock { @@ -1223,6 +1246,7 @@ class PhantomBleRepository( if (connectionAttemptGeneration.value != cleanupGeneration || (!markTerminal && terminal.value)) { return@withLock } + activePollingConnectionGeneration = null workoutParams = null workoutConnectionGeneration = null _handleDetection.value = HandleDetection() diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index df92eee50..4ab0999b7 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -3124,6 +3124,195 @@ class PhantomBleRepositoryTest { assertEquals(logsAfterShutdown, logRepo.logs.value.size) } + @Test + fun `stopWorkout rejects scanning without invalidating the scan attempt`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val scanning = async(Dispatchers.Default) { repository.startScanning() } + + try { + repository.connectionState.first { it == ConnectionState.Scanning } + logRepo.clearAll() + + val result = repository.stopWorkout() + + assertTrue(result.isFailure) + assertEquals(ConnectionState.Scanning, repository.connectionState.value) + assertTrue(logRepo.logs.value.none { it.message == "Phantom workout stopped" }) + assertTrue(scanning.await().isSuccess) + } finally { + repository.shutdown() + } + } + + @Test + fun `stopWorkout rejects connecting without invalidating the connection attempt`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val connecting = async(Dispatchers.Default) { + repository.connect(ScannedDevice("stop-race", "stop-race-address")) + } + + try { + repository.connectionState.first { it == ConnectionState.Connecting } + logRepo.clearAll() + + val result = repository.stopWorkout() + + assertTrue(result.isFailure) + assertEquals(ConnectionState.Connecting, repository.connectionState.value) + assertTrue(logRepo.logs.value.none { it.message == "Phantom workout stopped" }) + assertTrue(connecting.await().isSuccess) + } finally { + repository.shutdown() + } + } + + @Test + fun `connected collector reset preserves waiting handle state`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val resetOnConnected = async(Dispatchers.Unconfined) { + repository.connectionState.first { state -> + if (state is ConnectionState.Connected) { + repository.resetHandleState() + true + } else { + false + } + } + } + + try { + assertTrue(repository.connect(ScannedDevice("reset-collector", "reset-collector-address")).isSuccess) + resetOnConnected.await() + + assertTrue(repository.connectionState.value is ConnectionState.Connected) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + } finally { + repository.shutdown() + } + } + + @Test + fun `connected collector Just Lift waiting mode preserves waiting handle state`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val enableJustLiftOnConnected = async(Dispatchers.Unconfined) { + repository.connectionState.first { state -> + if (state is ConnectionState.Connected) { + repository.enableJustLiftWaitingMode() + true + } else { + false + } + } + } + + try { + assertTrue(repository.connect(ScannedDevice("just-lift-collector", "just-lift-collector-address")).isSuccess) + enableJustLiftOnConnected.await() + + assertTrue(repository.connectionState.value is ConnectionState.Connected) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + } finally { + repository.shutdown() + } + } + + @Test + fun `connected collector disabling handle detection preserves disabled state`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val disableDetectionOnConnected = async(Dispatchers.Unconfined) { + repository.connectionState.first { state -> + if (state is ConnectionState.Connected) { + repository.enableHandleDetection(false) + true + } else { + false + } + } + } + + try { + assertTrue(repository.connect(ScannedDevice("detection-collector", "detection-collector-address")).isSuccess) + disableDetectionOnConnected.await() + + assertTrue(repository.connectionState.value is ConnectionState.Connected) + assertFalse(repository.handleDetection.value.leftDetected) + assertFalse(repository.handleDetection.value.rightDetected) + assertEquals(HandleState.Released, repository.handleState.value) + } finally { + repository.shutdown() + } + } + + @Test + fun `reset handle preserves active polling across monitor restart and config replacement`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + repository.startActiveWorkoutPolling() + withContext(Dispatchers.Default) { + withTimeout(1_000L) { repository.metricsFlow.first { it.loadA >= 2f } } + } + + repository.resetHandleState() + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + + val metricAfterRestart = async(Dispatchers.Unconfined) { + withTimeout(1_000L) { repository.metricsFlow.first { it.loadA >= 2f } } + } + repository.restartMonitorPolling() + metricAfterRestart.await() + + val metricAfterReplacement = async(Dispatchers.Unconfined) { + withTimeout(1_000L) { repository.metricsFlow.first { it.loadA >= 10f } } + } + repository.replaceConfig(PhantomBleConfig(loadScale = 2f)) + metricAfterReplacement.await() + } finally { + repository.shutdown() + } + } + + @Test + fun `stopWorkout reserves cleanup before publishing stop log`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo, PhantomBleConfig(repDelayMs = 100L)) + var startWorkoutResult: Result? = null + val controlsOnStopLog = async(Dispatchers.Unconfined) { + logRepo.logs.first { logs -> + if (logs.any { it.message == "Phantom workout stopped" }) { + startWorkoutResult = repository.startWorkout(workoutParameters()) + repository.startActiveWorkoutPolling() + true + } else { + false + } + } + } + + try { + assertTrue(repository.scanAndConnect().isSuccess) + assertTrue(repository.startWorkout(workoutParameters()).isSuccess) + logRepo.clearAll() + + assertTrue(repository.stopWorkout().isSuccess) + controlsOnStopLog.await() + + assertTrue(requireNotNull(startWorkoutResult).isFailure) + assertEquals(HandleState.Released, repository.handleState.value) + logRepo.clearAll() + repository.restartMonitorPolling() + withContext(Dispatchers.Default) { delay(350L) } + assertTrue(logRepo.logs.value.none { log -> + log.message == "Phantom monitor metric" && log.details?.contains("load=7.5") == true + }) + } finally { + repository.shutdown() + } + } + private fun workoutParameters(): WorkoutParameters = WorkoutParameters( programMode = ProgramMode.OldSchool, reps = 3, From 9ef4c45722c3b7d4d1d7ac49c919cd43c9494d8f Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 18:05:40 -0400 Subject: [PATCH 48/54] fix(ios): close Phantom control publication races --- .../data/repository/PhantomBleRepository.kt | 146 ++++++++++++++---- .../repository/PhantomBleRepositoryTest.kt | 128 +++++++++++++++ 2 files changed, 248 insertions(+), 26 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 60052c500..ec2942993 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -132,6 +132,7 @@ class PhantomBleRepository( private var activePollingConnectionGeneration: Long? = null private var handleStateControlGeneration = 0L private var handleDetectionControlGeneration = 0L + private var discoControlGeneration = 0L private var metricsGeneration = 0L private var heuristicGeneration = 0L private var repGeneration = 0L @@ -349,7 +350,7 @@ class PhantomBleRepository( override suspend fun setColorScheme(schemeIndex: Int): Result { return lifecycleLock.withLock { - if (terminal.value || lifecycleCleanupInProgress) { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } val expectedConnectionGeneration = connectionAttemptGeneration.value @@ -364,7 +365,7 @@ class PhantomBleRepository( override suspend fun sendWorkoutCommand(command: ByteArray): Result { return lifecycleLock.withLock { - if (terminal.value || lifecycleCleanupInProgress) { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } val expectedConnectionGeneration = connectionAttemptGeneration.value @@ -384,7 +385,7 @@ class PhantomBleRepository( override suspend fun sendInitSequence(): Result { return lifecycleLock.withLock { - if (terminal.value || lifecycleCleanupInProgress) { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } val expectedConnectionGeneration = connectionAttemptGeneration.value @@ -415,7 +416,12 @@ class PhantomBleRepository( val expectedConnectionGeneration = connectionAttemptGeneration.value if (_discoModeActive.value) { stopDiscoMode() - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration || + _discoModeActive.value + ) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } } @@ -472,7 +478,7 @@ class PhantomBleRepository( override suspend fun stopWorkout(): Result { return lifecycleLock.withLock { - if (terminal.value || lifecycleCleanupInProgress) { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } if (_connectionState.value !is ConnectionState.Connected) { @@ -505,7 +511,7 @@ class PhantomBleRepository( override suspend fun sendStopCommand(): Result { return lifecycleLock.withLock { - if (terminal.value || lifecycleCleanupInProgress) { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { Result.failure(IllegalStateException("Phantom repository is shut down")) } else { val expectedConnectionGeneration = connectionAttemptGeneration.value @@ -521,14 +527,24 @@ class PhantomBleRepository( override fun enableHandleDetection(enabled: Boolean) { lifecycleLock.withLock { - if (terminal.value || lifecycleCleanupInProgress) { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { return@withLock } val expectedConnectionGeneration = connectionAttemptGeneration.value handleStateControlGeneration += 1 handleDetectionControlGeneration += 1 + val expectedHandleStateControlGeneration = handleStateControlGeneration + val expectedHandleDetectionControlGeneration = handleDetectionControlGeneration _handleDetection.value = HandleDetection(leftDetected = enabled, rightDetected = enabled) - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration || + handleStateControlGeneration != expectedHandleStateControlGeneration || + handleDetectionControlGeneration != expectedHandleDetectionControlGeneration || + _handleDetection.value.leftDetected != enabled || + _handleDetection.value.rightDetected != enabled + ) { return@withLock } if ( @@ -538,7 +554,15 @@ class PhantomBleRepository( return@withLock } _handleState.value = if (enabled) HandleState.WaitingForRest else HandleState.Released - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration || + handleStateControlGeneration != expectedHandleStateControlGeneration || + handleDetectionControlGeneration != expectedHandleDetectionControlGeneration || + _handleDetection.value.leftDetected != enabled || + _handleDetection.value.rightDetected != enabled + ) { return@withLock } logRepo.info(LogEventType.NOTIFICATION, "Phantom handle detection ${if (enabled) "enabled" else "disabled"}") @@ -551,6 +575,7 @@ class PhantomBleRepository( if ( terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -560,6 +585,7 @@ class PhantomBleRepository( if ( terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -569,13 +595,17 @@ class PhantomBleRepository( override fun enableJustLiftWaitingMode() { lifecycleLock.withLock { - if (terminal.value || lifecycleCleanupInProgress) { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { return@withLock } val expectedConnectionGeneration = connectionAttemptGeneration.value handleStateControlGeneration += 1 _handleState.value = HandleState.WaitingForRest - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { return@withLock } logRepo.info(LogEventType.NOTIFICATION, "Phantom Just Lift waiting mode armed") @@ -609,14 +639,22 @@ class PhantomBleRepository( activePollingConnectionGeneration = expectedConnectionGeneration workoutConnectionGeneration = expectedConnectionGeneration _handleState.value = HandleState.Grabbed - if (terminal.value || lifecycleCleanupInProgress || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { return@withLock } startMetrics( activeWorkout = true, expectedConnectionGeneration = expectedConnectionGeneration, ) - if (terminal.value || lifecycleCleanupInProgress || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { return@withLock } if (!startHeuristicGeneration( @@ -626,7 +664,11 @@ class PhantomBleRepository( ) { return@withLock } - if (terminal.value || lifecycleCleanupInProgress || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { return@withLock } } @@ -634,7 +676,7 @@ class PhantomBleRepository( override fun stopPolling() { lifecycleLock.withLock { - if (terminal.value || lifecycleCleanupInProgress) { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { return@withLock } metricsGeneration += 1 @@ -653,7 +695,7 @@ class PhantomBleRepository( override fun stopMonitorPollingOnly() { lifecycleLock.withLock { - if (terminal.value || lifecycleCleanupInProgress) { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { return@withLock } metricsGeneration += 1 @@ -680,14 +722,22 @@ class PhantomBleRepository( lifecycleLock.withLock { if (terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || _connectionState.value !is ConnectionState.Connected || workoutParams != null ) { return@withLock } val expectedConnectionGeneration = connectionAttemptGeneration.value + val expectedDiscoControlGeneration = ++discoControlGeneration _discoModeActive.value = true - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration || + discoControlGeneration != expectedDiscoControlGeneration || + !_discoModeActive.value + ) { return@withLock } logRepo.info(LogEventType.COMMAND_SENT, "Phantom disco mode started", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) @@ -696,12 +746,19 @@ class PhantomBleRepository( override fun stopDiscoMode() { lifecycleLock.withLock { - if (terminal.value || lifecycleCleanupInProgress) { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { return@withLock } val expectedConnectionGeneration = connectionAttemptGeneration.value + val expectedDiscoControlGeneration = ++discoControlGeneration _discoModeActive.value = false - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration || + discoControlGeneration != expectedDiscoControlGeneration || + _discoModeActive.value + ) { return@withLock } logRepo.info( @@ -716,7 +773,7 @@ class PhantomBleRepository( override fun setLastColorSchemeIndex(index: Int) { lifecycleLock.withLock { - if (!terminal.value && !lifecycleCleanupInProgress) { + if (!terminal.value && !lifecycleCleanupInProgress && !connectionAttemptReservationActive) { lastColorSchemeIndex = index } } @@ -732,7 +789,7 @@ class PhantomBleRepository( hasOpcodePrefix: Boolean = false, ): Result { val expectedConnectionGeneration = lifecycleLock.withLock { - if (terminal.value || lifecycleCleanupInProgress) { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { null } else { connectionAttemptGeneration.value @@ -749,6 +806,7 @@ class PhantomBleRepository( if (lifecycleLock.withLock { terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration } ) { @@ -762,6 +820,7 @@ class PhantomBleRepository( lifecycleLock.withLock { if (!terminal.value && !lifecycleCleanupInProgress && + !connectionAttemptReservationActive && connectionAttemptGeneration.value == expectedConnectionGeneration ) { logRepo.error( @@ -780,6 +839,7 @@ class PhantomBleRepository( lifecycleLock.withLock { if (terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -788,6 +848,7 @@ class PhantomBleRepository( ?: error("monitor packet too short: ${data.size} bytes") if (terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -796,6 +857,7 @@ class PhantomBleRepository( ?: error("monitor packet parsed but was rejected by validation") if (terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -803,6 +865,7 @@ class PhantomBleRepository( _metricsFlow.tryEmit(metric) if (terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -825,6 +888,7 @@ class PhantomBleRepository( lifecycleLock.withLock { if (terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -834,6 +898,7 @@ class PhantomBleRepository( ?: error("rep packet too short: ${data.size} bytes") if (terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -841,6 +906,7 @@ class PhantomBleRepository( _repEvents.tryEmit(rep) if (terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -859,6 +925,7 @@ class PhantomBleRepository( lifecycleLock.withLock { if (terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -868,6 +935,7 @@ class PhantomBleRepository( ?: error("diagnostic packet too short: ${data.size} bytes") if (terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -875,6 +943,7 @@ class PhantomBleRepository( _diagnostics.value = diagnostic.copy(receivedAtMillis = timestamp) if (terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -893,6 +962,7 @@ class PhantomBleRepository( lifecycleLock.withLock { if (terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -902,6 +972,7 @@ class PhantomBleRepository( ?: error("heuristic packet too short: ${data.size} bytes") if (terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -909,6 +980,7 @@ class PhantomBleRepository( _heuristicData.value = heuristic if (terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -986,6 +1058,8 @@ class PhantomBleRepository( try { val attemptGeneration = connectionAttemptGeneration.incrementAndGet() activePollingConnectionGeneration = null + discoControlGeneration += 1 + _discoModeActive.value = false if (reservedState == ConnectionState.Scanning) { _scannedDevices.value = emptyList() } @@ -1190,6 +1264,7 @@ class PhantomBleRepository( ): Boolean = !terminal.value && !lifecycleCleanupInProgress && + !connectionAttemptReservationActive && _connectionState.value is ConnectionState.Connected && connectionAttemptGeneration.value == expectedConnectionGeneration && workoutConnectionGeneration == expectedConnectionGeneration && @@ -1225,6 +1300,7 @@ class PhantomBleRepository( ): Boolean = !terminal.value && !lifecycleCleanupInProgress && + !connectionAttemptReservationActive && connectionAttemptGeneration.value == expectedConnectionGeneration && _connectionState.value is ConnectionState.Connected && producerJob?.isActive == true @@ -1232,6 +1308,7 @@ class PhantomBleRepository( private fun activePollingOwnsConnection(expectedConnectionGeneration: Long): Boolean = !terminal.value && !lifecycleCleanupInProgress && + !connectionAttemptReservationActive && connectionAttemptGeneration.value == expectedConnectionGeneration && _connectionState.value is ConnectionState.Connected && activePollingConnectionGeneration == expectedConnectionGeneration @@ -1289,6 +1366,7 @@ class PhantomBleRepository( if ( terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || _connectionState.value !is ConnectionState.Connected || (expectedConnectionGeneration != null && connectionAttemptGeneration.value != expectedConnectionGeneration) || (expectedMetricsGeneration != null && metricsGeneration != expectedMetricsGeneration) || @@ -1302,6 +1380,7 @@ class PhantomBleRepository( publish() !terminal.value && !lifecycleCleanupInProgress && + !connectionAttemptReservationActive && _connectionState.value is ConnectionState.Connected && (expectedConnectionGeneration == null || connectionAttemptGeneration.value == expectedConnectionGeneration) && (expectedMetricsGeneration == null || metricsGeneration == expectedMetricsGeneration) && @@ -1318,7 +1397,10 @@ class PhantomBleRepository( ) { lifecycleLock.withLock { val lifecycleGeneration = expectedConnectionGeneration ?: connectionAttemptGeneration.value - if (terminal.value || connectionAttemptGeneration.value != lifecycleGeneration) { + if (terminal.value || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != lifecycleGeneration + ) { return@withLock } val workoutWeightPerCableKg = workoutParams?.weightPerCableKg @@ -1377,7 +1459,9 @@ class PhantomBleRepository( expectedConnectionGeneration: Long? = null, ): Boolean = lifecycleLock.withLock { if (expectedConnectionGeneration != null && - (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) + (terminal.value || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration) ) { return@withLock false } @@ -1412,7 +1496,9 @@ class PhantomBleRepository( return@withLock false } if (expectedConnectionGeneration != null && - (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) + (terminal.value || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration) ) { return@withLock false } @@ -1458,6 +1544,7 @@ class PhantomBleRepository( lifecycleLock.withLock { if (!terminal.value && !lifecycleCleanupInProgress && + !connectionAttemptReservationActive && connectionAttemptGeneration.value == expectedConnectionGeneration ) { _deloadOccurredEvents.tryEmit(Unit) @@ -1468,6 +1555,7 @@ class PhantomBleRepository( lifecycleLock.withLock { if (!terminal.value && !lifecycleCleanupInProgress && + !connectionAttemptReservationActive && connectionAttemptGeneration.value == expectedConnectionGeneration ) { logRepo.warning( @@ -1558,7 +1646,10 @@ class PhantomBleRepository( } private fun startDiagnostics(expectedConnectionGeneration: Long): Boolean { - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { return false } diagnosticGeneration += 1 @@ -1616,7 +1707,10 @@ class PhantomBleRepository( } private fun startHeartbeat(expectedConnectionGeneration: Long) { - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { return } heartbeatGeneration += 1 diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 4ab0999b7..60539fdca 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -3313,6 +3313,134 @@ class PhantomBleRepositoryTest { } } + @Test + fun `scan reservation rejects raw and disco controls before scanning publication`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val monitor = monitorPacket( + ticks = 42, + posA = 1250, + velA = 320, + loadA = 1234, + posB = -750, + velB = -250, + loadB = 567, + ) + var rawResult: Result? = null + val controlsOnClear = async(Dispatchers.Unconfined) { + repository.scannedDevices.drop(1).first { devices -> + if (devices.isEmpty()) { + rawResult = repository.injectRawPacket(PhantomRawPacketKind.MONITOR, monitor) + repository.startDiscoMode() + true + } else { + false + } + } + } + + try { + assertTrue(repository.scanAndConnect().isSuccess) + repository.stopPolling() + logRepo.clearAll() + + val scanning = async(Dispatchers.Default) { repository.startScanning() } + controlsOnClear.await() + + assertTrue(requireNotNull(rawResult).isFailure) + assertFalse(repository.discoModeActive.value) + assertTrue(logRepo.logs.value.none { log -> + log.message == "Phantom injected raw monitor packet" || + log.message == "Phantom disco mode started" + }) + assertTrue(scanning.await().isSuccess) + assertEquals(ConnectionState.Scanning, repository.connectionState.value) + } finally { + repository.shutdown() + } + } + + @Test + fun `opposite handle detection publication preserves nested control result`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + val enableOnDisabled = async(Dispatchers.Unconfined) { + repository.handleDetection.drop(1).first { detection -> + if (!detection.leftDetected && !detection.rightDetected) { + repository.enableHandleDetection(true) + true + } else { + false + } + } + } + + repository.enableHandleDetection(false) + enableOnDisabled.await() + + assertTrue(repository.handleDetection.value.leftDetected) + assertTrue(repository.handleDetection.value.rightDetected) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + assertTrue(logRepo.logs.value.any { log -> log.message == "Phantom handle detection enabled" }) + assertTrue(logRepo.logs.value.none { log -> log.message == "Phantom handle detection disabled" }) + } finally { + repository.shutdown() + } + } + + @Test + fun `opposite disco publication preserves nested start and blocks workout handoff`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo, PhantomBleConfig(repDelayMs = 100L)) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + repository.startDiscoMode() + logRepo.clearAll() + val startOnStop = async(Dispatchers.Unconfined) { + repository.discoModeActive.drop(1).first { active -> + if (!active) { + repository.startDiscoMode() + true + } else { + false + } + } + } + + repository.stopDiscoMode() + startOnStop.await() + + assertTrue(repository.discoModeActive.value) + assertTrue(logRepo.logs.value.any { log -> log.message == "Phantom disco mode started" }) + assertTrue(logRepo.logs.value.none { log -> log.message == "Phantom disco mode stopped" }) + + logRepo.clearAll() + val startOnWorkoutStop = async(Dispatchers.Unconfined) { + repository.discoModeActive.drop(1).first { active -> + if (!active) { + repository.startDiscoMode() + true + } else { + false + } + } + } + val workoutResult = repository.startWorkout(workoutParameters()) + startOnWorkoutStop.await() + + assertTrue(workoutResult.isFailure) + assertTrue(repository.discoModeActive.value) + assertTrue(logRepo.logs.value.none { log -> log.message == "Phantom workout started" }) + } finally { + repository.shutdown() + } + } + private fun workoutParameters(): WorkoutParameters = WorkoutParameters( programMode = ProgramMode.OldSchool, reps = 3, From 3e3bb1a3e5510eddae41666162df2090ae9d34d4 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 18:05:40 -0400 Subject: [PATCH 49/54] fix(ios): close Phantom control publication races --- .../data/repository/PhantomBleRepository.kt | 148 +++++++++++++++--- .../repository/PhantomBleRepositoryTest.kt | 130 +++++++++++++++ 2 files changed, 252 insertions(+), 26 deletions(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index 60052c500..b8465388e 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -132,6 +132,7 @@ class PhantomBleRepository( private var activePollingConnectionGeneration: Long? = null private var handleStateControlGeneration = 0L private var handleDetectionControlGeneration = 0L + private var discoControlGeneration = 0L private var metricsGeneration = 0L private var heuristicGeneration = 0L private var repGeneration = 0L @@ -349,7 +350,7 @@ class PhantomBleRepository( override suspend fun setColorScheme(schemeIndex: Int): Result { return lifecycleLock.withLock { - if (terminal.value || lifecycleCleanupInProgress) { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } val expectedConnectionGeneration = connectionAttemptGeneration.value @@ -364,7 +365,7 @@ class PhantomBleRepository( override suspend fun sendWorkoutCommand(command: ByteArray): Result { return lifecycleLock.withLock { - if (terminal.value || lifecycleCleanupInProgress) { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } val expectedConnectionGeneration = connectionAttemptGeneration.value @@ -384,7 +385,7 @@ class PhantomBleRepository( override suspend fun sendInitSequence(): Result { return lifecycleLock.withLock { - if (terminal.value || lifecycleCleanupInProgress) { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } val expectedConnectionGeneration = connectionAttemptGeneration.value @@ -415,7 +416,12 @@ class PhantomBleRepository( val expectedConnectionGeneration = connectionAttemptGeneration.value if (_discoModeActive.value) { stopDiscoMode() - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration || + _discoModeActive.value + ) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } } @@ -472,7 +478,7 @@ class PhantomBleRepository( override suspend fun stopWorkout(): Result { return lifecycleLock.withLock { - if (terminal.value || lifecycleCleanupInProgress) { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } if (_connectionState.value !is ConnectionState.Connected) { @@ -505,7 +511,7 @@ class PhantomBleRepository( override suspend fun sendStopCommand(): Result { return lifecycleLock.withLock { - if (terminal.value || lifecycleCleanupInProgress) { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { Result.failure(IllegalStateException("Phantom repository is shut down")) } else { val expectedConnectionGeneration = connectionAttemptGeneration.value @@ -521,14 +527,24 @@ class PhantomBleRepository( override fun enableHandleDetection(enabled: Boolean) { lifecycleLock.withLock { - if (terminal.value || lifecycleCleanupInProgress) { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { return@withLock } val expectedConnectionGeneration = connectionAttemptGeneration.value handleStateControlGeneration += 1 handleDetectionControlGeneration += 1 + val expectedHandleStateControlGeneration = handleStateControlGeneration + val expectedHandleDetectionControlGeneration = handleDetectionControlGeneration _handleDetection.value = HandleDetection(leftDetected = enabled, rightDetected = enabled) - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration || + handleStateControlGeneration != expectedHandleStateControlGeneration || + handleDetectionControlGeneration != expectedHandleDetectionControlGeneration || + _handleDetection.value.leftDetected != enabled || + _handleDetection.value.rightDetected != enabled + ) { return@withLock } if ( @@ -538,7 +554,15 @@ class PhantomBleRepository( return@withLock } _handleState.value = if (enabled) HandleState.WaitingForRest else HandleState.Released - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration || + handleStateControlGeneration != expectedHandleStateControlGeneration || + handleDetectionControlGeneration != expectedHandleDetectionControlGeneration || + _handleDetection.value.leftDetected != enabled || + _handleDetection.value.rightDetected != enabled + ) { return@withLock } logRepo.info(LogEventType.NOTIFICATION, "Phantom handle detection ${if (enabled) "enabled" else "disabled"}") @@ -551,6 +575,7 @@ class PhantomBleRepository( if ( terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -560,6 +585,7 @@ class PhantomBleRepository( if ( terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -569,13 +595,17 @@ class PhantomBleRepository( override fun enableJustLiftWaitingMode() { lifecycleLock.withLock { - if (terminal.value || lifecycleCleanupInProgress) { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { return@withLock } val expectedConnectionGeneration = connectionAttemptGeneration.value handleStateControlGeneration += 1 _handleState.value = HandleState.WaitingForRest - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { return@withLock } logRepo.info(LogEventType.NOTIFICATION, "Phantom Just Lift waiting mode armed") @@ -609,14 +639,22 @@ class PhantomBleRepository( activePollingConnectionGeneration = expectedConnectionGeneration workoutConnectionGeneration = expectedConnectionGeneration _handleState.value = HandleState.Grabbed - if (terminal.value || lifecycleCleanupInProgress || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { return@withLock } startMetrics( activeWorkout = true, expectedConnectionGeneration = expectedConnectionGeneration, ) - if (terminal.value || lifecycleCleanupInProgress || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { return@withLock } if (!startHeuristicGeneration( @@ -626,7 +664,11 @@ class PhantomBleRepository( ) { return@withLock } - if (terminal.value || lifecycleCleanupInProgress || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { return@withLock } } @@ -634,7 +676,7 @@ class PhantomBleRepository( override fun stopPolling() { lifecycleLock.withLock { - if (terminal.value || lifecycleCleanupInProgress) { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { return@withLock } metricsGeneration += 1 @@ -653,7 +695,7 @@ class PhantomBleRepository( override fun stopMonitorPollingOnly() { lifecycleLock.withLock { - if (terminal.value || lifecycleCleanupInProgress) { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { return@withLock } metricsGeneration += 1 @@ -680,14 +722,22 @@ class PhantomBleRepository( lifecycleLock.withLock { if (terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || _connectionState.value !is ConnectionState.Connected || workoutParams != null ) { return@withLock } val expectedConnectionGeneration = connectionAttemptGeneration.value + val expectedDiscoControlGeneration = ++discoControlGeneration _discoModeActive.value = true - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration || + discoControlGeneration != expectedDiscoControlGeneration || + !_discoModeActive.value + ) { return@withLock } logRepo.info(LogEventType.COMMAND_SENT, "Phantom disco mode started", PHANTOM_DEVICE_NAME, PHANTOM_DEVICE_ADDRESS) @@ -696,12 +746,19 @@ class PhantomBleRepository( override fun stopDiscoMode() { lifecycleLock.withLock { - if (terminal.value || lifecycleCleanupInProgress) { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { return@withLock } val expectedConnectionGeneration = connectionAttemptGeneration.value + val expectedDiscoControlGeneration = ++discoControlGeneration _discoModeActive.value = false - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + lifecycleCleanupInProgress || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration || + discoControlGeneration != expectedDiscoControlGeneration || + _discoModeActive.value + ) { return@withLock } logRepo.info( @@ -716,7 +773,7 @@ class PhantomBleRepository( override fun setLastColorSchemeIndex(index: Int) { lifecycleLock.withLock { - if (!terminal.value && !lifecycleCleanupInProgress) { + if (!terminal.value && !lifecycleCleanupInProgress && !connectionAttemptReservationActive) { lastColorSchemeIndex = index } } @@ -732,7 +789,7 @@ class PhantomBleRepository( hasOpcodePrefix: Boolean = false, ): Result { val expectedConnectionGeneration = lifecycleLock.withLock { - if (terminal.value || lifecycleCleanupInProgress) { + if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { null } else { connectionAttemptGeneration.value @@ -749,6 +806,7 @@ class PhantomBleRepository( if (lifecycleLock.withLock { terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration } ) { @@ -762,6 +820,7 @@ class PhantomBleRepository( lifecycleLock.withLock { if (!terminal.value && !lifecycleCleanupInProgress && + !connectionAttemptReservationActive && connectionAttemptGeneration.value == expectedConnectionGeneration ) { logRepo.error( @@ -780,6 +839,7 @@ class PhantomBleRepository( lifecycleLock.withLock { if (terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -788,6 +848,7 @@ class PhantomBleRepository( ?: error("monitor packet too short: ${data.size} bytes") if (terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -796,6 +857,7 @@ class PhantomBleRepository( ?: error("monitor packet parsed but was rejected by validation") if (terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -803,6 +865,7 @@ class PhantomBleRepository( _metricsFlow.tryEmit(metric) if (terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -825,6 +888,7 @@ class PhantomBleRepository( lifecycleLock.withLock { if (terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -834,6 +898,7 @@ class PhantomBleRepository( ?: error("rep packet too short: ${data.size} bytes") if (terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -841,6 +906,7 @@ class PhantomBleRepository( _repEvents.tryEmit(rep) if (terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -859,6 +925,7 @@ class PhantomBleRepository( lifecycleLock.withLock { if (terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -868,6 +935,7 @@ class PhantomBleRepository( ?: error("diagnostic packet too short: ${data.size} bytes") if (terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -875,6 +943,7 @@ class PhantomBleRepository( _diagnostics.value = diagnostic.copy(receivedAtMillis = timestamp) if (terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -893,6 +962,7 @@ class PhantomBleRepository( lifecycleLock.withLock { if (terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -902,6 +972,7 @@ class PhantomBleRepository( ?: error("heuristic packet too short: ${data.size} bytes") if (terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -909,6 +980,7 @@ class PhantomBleRepository( _heuristicData.value = heuristic if (terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || connectionAttemptGeneration.value != expectedConnectionGeneration ) { return@withLock @@ -986,7 +1058,11 @@ class PhantomBleRepository( try { val attemptGeneration = connectionAttemptGeneration.incrementAndGet() activePollingConnectionGeneration = null + discoControlGeneration += 1 + _discoModeActive.value = false if (reservedState == ConnectionState.Scanning) { + _diagnostics.value = null + _heuristicData.value = null _scannedDevices.value = emptyList() } if (terminal.value || connectionAttemptGeneration.value != attemptGeneration) { @@ -1190,6 +1266,7 @@ class PhantomBleRepository( ): Boolean = !terminal.value && !lifecycleCleanupInProgress && + !connectionAttemptReservationActive && _connectionState.value is ConnectionState.Connected && connectionAttemptGeneration.value == expectedConnectionGeneration && workoutConnectionGeneration == expectedConnectionGeneration && @@ -1225,6 +1302,7 @@ class PhantomBleRepository( ): Boolean = !terminal.value && !lifecycleCleanupInProgress && + !connectionAttemptReservationActive && connectionAttemptGeneration.value == expectedConnectionGeneration && _connectionState.value is ConnectionState.Connected && producerJob?.isActive == true @@ -1232,6 +1310,7 @@ class PhantomBleRepository( private fun activePollingOwnsConnection(expectedConnectionGeneration: Long): Boolean = !terminal.value && !lifecycleCleanupInProgress && + !connectionAttemptReservationActive && connectionAttemptGeneration.value == expectedConnectionGeneration && _connectionState.value is ConnectionState.Connected && activePollingConnectionGeneration == expectedConnectionGeneration @@ -1289,6 +1368,7 @@ class PhantomBleRepository( if ( terminal.value || lifecycleCleanupInProgress || + connectionAttemptReservationActive || _connectionState.value !is ConnectionState.Connected || (expectedConnectionGeneration != null && connectionAttemptGeneration.value != expectedConnectionGeneration) || (expectedMetricsGeneration != null && metricsGeneration != expectedMetricsGeneration) || @@ -1302,6 +1382,7 @@ class PhantomBleRepository( publish() !terminal.value && !lifecycleCleanupInProgress && + !connectionAttemptReservationActive && _connectionState.value is ConnectionState.Connected && (expectedConnectionGeneration == null || connectionAttemptGeneration.value == expectedConnectionGeneration) && (expectedMetricsGeneration == null || metricsGeneration == expectedMetricsGeneration) && @@ -1318,7 +1399,10 @@ class PhantomBleRepository( ) { lifecycleLock.withLock { val lifecycleGeneration = expectedConnectionGeneration ?: connectionAttemptGeneration.value - if (terminal.value || connectionAttemptGeneration.value != lifecycleGeneration) { + if (terminal.value || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != lifecycleGeneration + ) { return@withLock } val workoutWeightPerCableKg = workoutParams?.weightPerCableKg @@ -1377,7 +1461,9 @@ class PhantomBleRepository( expectedConnectionGeneration: Long? = null, ): Boolean = lifecycleLock.withLock { if (expectedConnectionGeneration != null && - (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) + (terminal.value || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration) ) { return@withLock false } @@ -1412,7 +1498,9 @@ class PhantomBleRepository( return@withLock false } if (expectedConnectionGeneration != null && - (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) + (terminal.value || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration) ) { return@withLock false } @@ -1458,6 +1546,7 @@ class PhantomBleRepository( lifecycleLock.withLock { if (!terminal.value && !lifecycleCleanupInProgress && + !connectionAttemptReservationActive && connectionAttemptGeneration.value == expectedConnectionGeneration ) { _deloadOccurredEvents.tryEmit(Unit) @@ -1468,6 +1557,7 @@ class PhantomBleRepository( lifecycleLock.withLock { if (!terminal.value && !lifecycleCleanupInProgress && + !connectionAttemptReservationActive && connectionAttemptGeneration.value == expectedConnectionGeneration ) { logRepo.warning( @@ -1558,7 +1648,10 @@ class PhantomBleRepository( } private fun startDiagnostics(expectedConnectionGeneration: Long): Boolean { - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { return false } diagnosticGeneration += 1 @@ -1616,7 +1709,10 @@ class PhantomBleRepository( } private fun startHeartbeat(expectedConnectionGeneration: Long) { - if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + if (terminal.value || + connectionAttemptReservationActive || + connectionAttemptGeneration.value != expectedConnectionGeneration + ) { return } heartbeatGeneration += 1 diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 4ab0999b7..aa03698e4 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -3313,6 +3313,136 @@ class PhantomBleRepositoryTest { } } + @Test + fun `scan reservation rejects raw and disco controls before scanning publication`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val monitor = monitorPacket( + ticks = 42, + posA = 1250, + velA = 320, + loadA = 1234, + posB = -750, + velB = -250, + loadB = 567, + ) + var rawResult: Result? = null + val controlsOnClear = async(Dispatchers.Unconfined) { + repository.scannedDevices.drop(1).first { devices -> + if (devices.isEmpty()) { + assertNull(repository.diagnostics.value) + assertNull(repository.heuristicData.value) + rawResult = repository.injectRawPacket(PhantomRawPacketKind.MONITOR, monitor) + repository.startDiscoMode() + true + } else { + false + } + } + } + + try { + assertTrue(repository.scanAndConnect().isSuccess) + repository.stopPolling() + logRepo.clearAll() + + val scanning = async(Dispatchers.Default) { repository.startScanning() } + controlsOnClear.await() + + assertTrue(requireNotNull(rawResult).isFailure) + assertFalse(repository.discoModeActive.value) + assertTrue(logRepo.logs.value.none { log -> + log.message == "Phantom injected raw monitor packet" || + log.message == "Phantom disco mode started" + }) + assertTrue(scanning.await().isSuccess) + assertEquals(ConnectionState.Scanning, repository.connectionState.value) + } finally { + repository.shutdown() + } + } + + @Test + fun `opposite handle detection publication preserves nested control result`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + logRepo.clearAll() + val enableOnDisabled = async(Dispatchers.Unconfined) { + repository.handleDetection.drop(1).first { detection -> + if (!detection.leftDetected && !detection.rightDetected) { + repository.enableHandleDetection(true) + true + } else { + false + } + } + } + + repository.enableHandleDetection(false) + enableOnDisabled.await() + + assertTrue(repository.handleDetection.value.leftDetected) + assertTrue(repository.handleDetection.value.rightDetected) + assertEquals(HandleState.WaitingForRest, repository.handleState.value) + assertTrue(logRepo.logs.value.any { log -> log.message == "Phantom handle detection enabled" }) + assertTrue(logRepo.logs.value.none { log -> log.message == "Phantom handle detection disabled" }) + } finally { + repository.shutdown() + } + } + + @Test + fun `opposite disco publication preserves nested start and blocks workout handoff`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo, PhantomBleConfig(repDelayMs = 100L)) + + try { + assertTrue(repository.scanAndConnect().isSuccess) + repository.startDiscoMode() + logRepo.clearAll() + val startOnStop = async(Dispatchers.Unconfined) { + repository.discoModeActive.drop(1).first { active -> + if (!active) { + repository.startDiscoMode() + true + } else { + false + } + } + } + + repository.stopDiscoMode() + startOnStop.await() + + assertTrue(repository.discoModeActive.value) + assertTrue(logRepo.logs.value.any { log -> log.message == "Phantom disco mode started" }) + assertTrue(logRepo.logs.value.none { log -> log.message == "Phantom disco mode stopped" }) + + logRepo.clearAll() + val startOnWorkoutStop = async(Dispatchers.Unconfined) { + repository.discoModeActive.drop(1).first { active -> + if (!active) { + repository.startDiscoMode() + true + } else { + false + } + } + } + val workoutResult = repository.startWorkout(workoutParameters()) + startOnWorkoutStop.await() + + assertTrue(workoutResult.isFailure) + assertTrue(repository.discoModeActive.value) + assertTrue(logRepo.logs.value.none { log -> log.message == "Phantom workout started" }) + } finally { + repository.shutdown() + } + } + private fun workoutParameters(): WorkoutParameters = WorkoutParameters( programMode = ProgramMode.OldSchool, reps = 3, From 25ab6a446f533404bcf07f6859df03e23f694a05 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 18:32:06 -0400 Subject: [PATCH 50/54] feat(ios): decode workout commands in Phantom trainer --- .../data/repository/PhantomBleRepository.kt | 63 ++++++++ .../repository/PhantomBleRepositoryTest.kt | 135 ++++++++++++++++++ 2 files changed, 198 insertions(+) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index ec2942993..e8bf7f698 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -42,18 +42,26 @@ enum class PhantomRawPacketKind { HEURISTIC, } +internal data class PhantomWorkoutProgram( + val warmupReps: Int, + val workingReps: Int?, + val weightPerCableKg: Float, +) + data class PhantomBleConfig( val loadScale: Float = 1f, val velocityScale: Double = 1.0, val positionScale: Float = 1f, val repDelayMs: Long = 750L, val autoCompleteFixedRepSets: Boolean = true, + val defaultEchoLoadKg: Float = 20f, ) { init { require(loadScale > 0f) { "loadScale must be > 0" } require(velocityScale > 0.0) { "velocityScale must be > 0" } require(positionScale > 0f) { "positionScale must be > 0" } require(repDelayMs >= 100L) { "repDelayMs must be >= 100" } + require(defaultEchoLoadKg > 0f) { "defaultEchoLoadKg must be > 0" } } companion object { @@ -120,6 +128,9 @@ class PhantomBleRepository( private var heartbeatJob: Job? = null private var workoutParams: WorkoutParameters? = null private var workoutConnectionGeneration: Long? = null + private var currentWorkoutProgram: PhantomWorkoutProgram? = null + internal val currentProgram: PhantomWorkoutProgram? + get() = currentWorkoutProgram private val _config = MutableStateFlow(initialConfig) val config: StateFlow = _config.asStateFlow() private var ticks = 0L @@ -379,10 +390,56 @@ class PhantomBleRepository( if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) } + decodeWorkoutProgram(command)?.let { program -> + currentWorkoutProgram = program + logRepo.info( + LogEventType.COMMAND_SENT, + "Phantom parsed workout command", + PHANTOM_DEVICE_NAME, + PHANTOM_DEVICE_ADDRESS, + "warmupReps=${program.warmupReps}; workingReps=${program.workingReps}; weightPerCableKg=${program.weightPerCableKg}", + ) + } + if (terminal.value || connectionAttemptGeneration.value != expectedConnectionGeneration) { + return@withLock Result.failure(IllegalStateException("Phantom repository is shut down")) + } Result.success(Unit) } } + private fun decodeWorkoutProgram(command: ByteArray): PhantomWorkoutProgram? { + if (command.size >= REGULAR_PROGRAM_PACKET_SIZE && command[0].toInt() and 0xFF == REGULAR_PROGRAM_OPCODE) { + val totalReps = command[0x04].toInt() and 0xFF + val warmupReps = command[0x05].toInt() and 0xFF + return PhantomWorkoutProgram( + warmupReps = warmupReps, + workingReps = totalReps.takeUnless { it == UNLIMITED_REPS }?.minus(warmupReps), + weightPerCableKg = readFloat32LittleEndian(command, 0x58), + ) + } + + if (command.size >= ECHO_PROGRAM_PACKET_SIZE && readUInt32LittleEndian(command, 0) == ECHO_PROGRAM_OPCODE) { + val warmupReps = command[0x04].toInt() and 0xFF + val targetReps = command[0x05].toInt() and 0xFF + return PhantomWorkoutProgram( + warmupReps = warmupReps, + workingReps = targetReps.takeUnless { it == UNLIMITED_REPS }, + weightPerCableKg = _config.value.defaultEchoLoadKg, + ) + } + + return null + } + + private fun readUInt32LittleEndian(bytes: ByteArray, offset: Int): Int = + (bytes[offset].toInt() and 0xFF) or + ((bytes[offset + 1].toInt() and 0xFF) shl 8) or + ((bytes[offset + 2].toInt() and 0xFF) shl 16) or + ((bytes[offset + 3].toInt() and 0xFF) shl 24) + + private fun readFloat32LittleEndian(bytes: ByteArray, offset: Int): Float = + Float.fromBits(readUInt32LittleEndian(bytes, offset)) + override suspend fun sendInitSequence(): Result { return lifecycleLock.withLock { if (terminal.value || lifecycleCleanupInProgress || connectionAttemptReservationActive) { @@ -1326,6 +1383,7 @@ class PhantomBleRepository( activePollingConnectionGeneration = null workoutParams = null workoutConnectionGeneration = null + currentWorkoutProgram = null _handleDetection.value = HandleDetection() if (connectionAttemptGeneration.value != cleanupGeneration || (!markTerminal && terminal.value)) { return@withLock @@ -1750,6 +1808,11 @@ class PhantomBleRepository( } companion object { + private const val REGULAR_PROGRAM_OPCODE = 0x04 + private const val ECHO_PROGRAM_OPCODE = 0x4E + private const val REGULAR_PROGRAM_PACKET_SIZE = 96 + private const val ECHO_PROGRAM_PACKET_SIZE = 32 + private const val UNLIMITED_REPS = 0xFF const val PHANTOM_DEVICE_NAME = "Vee_PhantomSimulator" const val PHANTOM_DEVICE_ADDRESS = "PH:AN:TO:MS:BX:01" } diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index 60539fdca..a1ec0dc8c 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -2,8 +2,10 @@ package com.devil.phoenixproject.data.repository import app.cash.turbine.test import com.devil.phoenixproject.domain.model.ConnectionState +import com.devil.phoenixproject.domain.model.EchoLevel import com.devil.phoenixproject.domain.model.ProgramMode import com.devil.phoenixproject.domain.model.WorkoutParameters +import com.devil.phoenixproject.util.BlePacketFactory import kotlin.coroutines.cancellation.CancellationException import kotlin.test.Test import kotlin.test.assertEquals @@ -39,6 +41,139 @@ class PhantomBleRepositoryTest { } } + @Test + fun `config rejects non-positive echo load`() { + assertEquals(20f, PhantomBleConfig().defaultEchoLoadKg) + assertFailsWith { + PhantomBleConfig(defaultEchoLoadKg = 0f) + } + assertFailsWith { + PhantomBleConfig(defaultEchoLoadKg = -1f) + } + } + + @Test + fun `regular factory packet records finite phantom workout program`() = runTest { + val logRepo = ConnectionLogRepository() + val repository = PhantomBleRepository(logRepo) + val params = WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 7, + warmupReps = 3, + weightPerCableKg = 12.5f, + ) + + try { + val command = BlePacketFactory.createProgramParams(params) + assertEquals(96, command.size) + assertTrue(repository.sendWorkoutCommand(command).isSuccess) + assertEquals( + PhantomWorkoutProgram( + warmupReps = 3, + workingReps = 7, + weightPerCableKg = 12.5f, + ), + repository.currentProgram, + ) + assertTrue(logRepo.logs.value.any { log -> + log.eventType == LogEventType.COMMAND_SENT && + log.message == "Phantom parsed workout command" && + log.details == "warmupReps=3; workingReps=7; weightPerCableKg=12.5" + }) + } finally { + repository.shutdown() + } + } + + @Test + fun `regular factory packet records unlimited phantom workout program`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val params = WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 0, + warmupReps = 3, + weightPerCableKg = 8.25f, + isAMRAP = true, + ) + + try { + val command = BlePacketFactory.createProgramParams(params) + assertEquals(96, command.size) + assertTrue(repository.sendWorkoutCommand(command).isSuccess) + assertEquals( + PhantomWorkoutProgram( + warmupReps = 3, + workingReps = null, + weightPerCableKg = 8.25f, + ), + repository.currentProgram, + ) + } finally { + repository.shutdown() + } + } + + @Test + fun `echo factory packet records phantom workout program with default load`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + + try { + val command = BlePacketFactory.createEchoControl( + level = EchoLevel.HARDER, + warmupReps = 2, + targetReps = 5, + ) + assertEquals(32, command.size) + assertTrue(repository.sendWorkoutCommand(command).isSuccess) + assertEquals( + PhantomWorkoutProgram( + warmupReps = 2, + workingReps = 5, + weightPerCableKg = 20f, + ), + repository.currentProgram, + ) + } finally { + repository.shutdown() + } + } + + @Test + fun `echo factory packet records unlimited phantom workout program`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + + try { + val command = BlePacketFactory.createEchoControl( + level = EchoLevel.HARDER, + warmupReps = 2, + isAMRAP = true, + ) + assertTrue(repository.sendWorkoutCommand(command).isSuccess) + assertEquals( + PhantomWorkoutProgram( + warmupReps = 2, + workingReps = null, + weightPerCableKg = 20f, + ), + repository.currentProgram, + ) + } finally { + repository.shutdown() + } + } + + @Test + fun `unrelated workout command remains accepted`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + + try { + assertTrue(repository.sendWorkoutCommand(byteArrayOf(0x01)).isSuccess) + assertNull(repository.currentProgram) + } finally { + repository.shutdown() + } + } + @Test fun `config rejects rep delays below 100 milliseconds`() { assertFailsWith { From a78e526385fd168e28a93fbce12601b140ac4819 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 17 Jul 2026 18:45:11 -0400 Subject: [PATCH 51/54] fix(ios): validate Phantom program opcodes exactly --- .../data/repository/PhantomBleRepository.kt | 2 +- .../repository/PhantomBleRepositoryTest.kt | 24 +++++++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt index e8bf7f698..c5869c342 100644 --- a/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt +++ b/shared/src/iosSimulatorArm64Main/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepository.kt @@ -408,7 +408,7 @@ class PhantomBleRepository( } private fun decodeWorkoutProgram(command: ByteArray): PhantomWorkoutProgram? { - if (command.size >= REGULAR_PROGRAM_PACKET_SIZE && command[0].toInt() and 0xFF == REGULAR_PROGRAM_OPCODE) { + if (command.size >= REGULAR_PROGRAM_PACKET_SIZE && readUInt32LittleEndian(command, 0) == REGULAR_PROGRAM_OPCODE) { val totalReps = command[0x04].toInt() and 0xFF val warmupReps = command[0x05].toInt() and 0xFF return PhantomWorkoutProgram( diff --git a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt index a1ec0dc8c..d89f37797 100644 --- a/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt +++ b/shared/src/iosSimulatorArm64Test/kotlin/com/devil/phoenixproject/data/repository/PhantomBleRepositoryTest.kt @@ -85,6 +85,30 @@ class PhantomBleRepositoryTest { } } + @Test + fun `regular production packet requires exact four-byte opcode`() = runTest { + val repository = PhantomBleRepository(ConnectionLogRepository()) + val params = WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 7, + warmupReps = 3, + weightPerCableKg = 12.5f, + ) + + try { + val command = BlePacketFactory.createProgramParams(params).also { packet -> + packet[1] = 0x01 + } + assertEquals(96, command.size) + assertEquals(0x04.toByte(), command[0]) + assertEquals(0x01.toByte(), command[1]) + assertTrue(repository.sendWorkoutCommand(command).isSuccess) + assertNull(repository.currentProgram) + } finally { + repository.shutdown() + } + } + @Test fun `regular factory packet records unlimited phantom workout program`() = runTest { val repository = PhantomBleRepository(ConnectionLogRepository()) From 28355e4df5237bb4b4efe1aaef1277b1e7f9f22b Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 24 Jul 2026 21:51:21 -0400 Subject: [PATCH 52/54] feat(#674): add eccentric load option for Old School mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reuse the Echo 0x4E BLE packet for Old School exercises with eccentric load > 100%. isEchoMode stays false — PR tracking, gamification, and Old School behavior are preserved. EchoLevel.HARDER and warmupReps=3 (Constants.DEFAULT_WARMUP_REPS) are used for the Old School 0x4E path. Changes: - Models.kt: add hasEccentricOverload computed property - ActiveSessionEngine.kt: dispatch 0x4E for Old School+ecc>100, remove isEchoMode gate on defaults save, skip deload stall for ecc>100 - JustLiftScreen.kt: remove isEchoMode guard on LaunchedEffect, split Card to show eccentric dropdown for Old School (no EchoLevel) - SetReadyScreen.kt: split guard for eccentric slider (Echo+OldSchool), keep EchoLevel/AMRAP reps Echo-only - ExerciseEditBottomSheet.kt: show EccentricLoadSelector for Old School - RoutineOverviewScreen.kt: show eccentric load slider for Old School - RoutineFlowManager.kt: populate eccentricLoadPercent for Old School - WorkoutParametersTest.kt: 4 new hasEccentricOverload tests Closes #674 --- .../phoenixproject/domain/model/Models.kt | 3 + .../manager/ActiveSessionEngine.kt | 26 +++--- .../manager/RoutineFlowManager.kt | 4 +- .../screen/ExerciseEditBottomSheet.kt | 7 +- .../presentation/screen/JustLiftScreen.kt | 92 ++++++++++--------- .../screen/RoutineOverviewScreen.kt | 10 +- .../presentation/screen/SetReadyScreen.kt | 11 ++- .../domain/model/WorkoutParametersTest.kt | 46 ++++++++++ 8 files changed, 134 insertions(+), 65 deletions(-) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt index 017635ac2..104b7ae26 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt @@ -370,6 +370,9 @@ data class WorkoutParameters( ) { /** True if this is an Echo workout */ val isEchoMode: Boolean get() = programMode == ProgramMode.Echo + + /** True if eccentric load exceeds 100% (triggers 0x4E Echo packet dispatch for Old School mode) */ + val hasEccentricOverload: Boolean get() = eccentricLoad.percentage > 100 } /** diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt index 58e308830..8ba6a9d91 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt @@ -31,6 +31,7 @@ import com.devil.phoenixproject.domain.model.BiomechanicsSetSummary import com.devil.phoenixproject.domain.model.BodyweightVariantOption import com.devil.phoenixproject.domain.model.CompletedSet import com.devil.phoenixproject.domain.model.ConnectionStatus +import com.devil.phoenixproject.domain.model.EchoLevel import com.devil.phoenixproject.domain.model.FiveThreeOneRoutineDetector import com.devil.phoenixproject.domain.model.HapticEvent import com.devil.phoenixproject.domain.model.IntegrationProvider @@ -430,8 +431,8 @@ class ActiveSessionEngine( // deload after 1.25s below 40 mm/s — Issue #553), so DELOAD_OCCURRED fires // routinely mid-set as the athlete fatigues. It is NOT a cable-release // signal in Echo mode and must never arm the auto-stop stall timer. - if (params.isEchoMode) { - Logger.d("DELOAD_OCCURRED ignored - Echo mode (deload windows define Echo levels)") + if (params.isEchoMode || params.hasEccentricOverload) { + Logger.d("DELOAD_OCCURRED ignored - eccentric overload active (Echo or Old School+ecc)") return@collect } if (!isWarmupGateOpenForAutoStop()) { @@ -1843,8 +1844,8 @@ class ActiveSessionEngine( val params = coordinator._workoutParameters.value if (!params.isJustLift) return - val eccentricLoadPct = if (params.isEchoMode) params.eccentricLoad.percentage else 100 - val echoLevelVal = if (params.isEchoMode) params.echoLevel.levelValue else 0 + val eccentricLoadPct = params.eccentricLoad.percentage + val echoLevelVal = params.echoLevel.levelValue try { val defaults = JustLiftDefaultsDocument( @@ -1883,8 +1884,8 @@ class ActiveSessionEngine( val exerciseId = currentExercise.exercise.id ?: return val isEchoExercise = currentExercise.programMode == ProgramMode.Echo - val eccentricLoadPct = if (isEchoExercise) currentExercise.eccentricLoad.percentage else 100 - val echoLevelVal = if (isEchoExercise) currentExercise.echoLevel.levelValue else 0 + val eccentricLoadPct = currentExercise.eccentricLoad.percentage + val echoLevelVal = currentExercise.echoLevel.levelValue try { val setReps = currentExercise.setReps.ifEmpty { listOf(10) } @@ -2831,10 +2832,11 @@ class ActiveSessionEngine( } } - val commandValidation = if (bleParams.isEchoMode) { + val hasEccentricOverload = bleParams.eccentricLoad.percentage > 100 + val commandValidation = if (bleParams.isEchoMode || hasEccentricOverload) { WorkoutCommandValidator.validateEchoControl( - level = bleParams.echoLevel, - warmupReps = bleParams.warmupReps, + level = if (bleParams.isEchoMode) bleParams.echoLevel else EchoLevel.HARDER, + warmupReps = if (bleParams.isEchoMode) bleParams.warmupReps else Constants.DEFAULT_WARMUP_REPS, targetReps = bleParams.reps, isJustLift = isJustLiftMode || bleParams.isJustLift, isAMRAP = bleParams.isAMRAP, @@ -2849,10 +2851,10 @@ class ActiveSessionEngine( return@launch } - val command = if (bleParams.isEchoMode) { + val command = if (bleParams.isEchoMode || hasEccentricOverload) { BlePacketFactory.createEchoControl( - level = bleParams.echoLevel, - warmupReps = bleParams.warmupReps, + level = if (bleParams.isEchoMode) bleParams.echoLevel else EchoLevel.HARDER, + warmupReps = if (bleParams.isEchoMode) bleParams.warmupReps else Constants.DEFAULT_WARMUP_REPS, targetReps = bleParams.reps, isJustLift = isJustLiftMode || bleParams.isJustLift, isAMRAP = bleParams.isAMRAP, diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RoutineFlowManager.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RoutineFlowManager.kt index ccf74b3f1..06f73aa50 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RoutineFlowManager.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/RoutineFlowManager.kt @@ -1064,7 +1064,7 @@ class RoutineFlowManager( adjustedReps = setReps, adjustedProgressionKg = progressionKg, echoLevel = if (exercise.programMode is ProgramMode.Echo) exercise.echoLevel else null, - eccentricLoadPercent = if (exercise.programMode is ProgramMode.Echo) exercise.eccentricLoad.percentage else null, + eccentricLoadPercent = if (exercise.programMode is ProgramMode.Echo || exercise.programMode is ProgramMode.OldSchool) exercise.eccentricLoad.percentage else null, ) // Issue #129: Determine if this specific set is AMRAP (null reps = AMRAP) @@ -1145,7 +1145,7 @@ class RoutineFlowManager( adjustedReps = adjustedReps, adjustedProgressionKg = progressionKg, echoLevel = if (exercise.programMode is ProgramMode.Echo) exercise.echoLevel else null, - eccentricLoadPercent = if (exercise.programMode is ProgramMode.Echo) exercise.eccentricLoad.percentage else null, + eccentricLoadPercent = if (exercise.programMode is ProgramMode.Echo || exercise.programMode is ProgramMode.OldSchool) exercise.eccentricLoad.percentage else null, ) // Issue #129: Check raw value for AMRAP - null reps in setReps list = AMRAP diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseEditBottomSheet.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseEditBottomSheet.kt index a49140d55..07f974d12 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseEditBottomSheet.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/ExerciseEditBottomSheet.kt @@ -234,6 +234,7 @@ fun ExerciseEditBottomSheet( val isTutMode = showCableOnlyExerciseControls && (selectedMode is WorkoutMode.TUT || selectedMode is WorkoutMode.TUTBeast) val isEchoMode = showCableOnlyExerciseControls && selectedMode is WorkoutMode.Echo + val isOldSchool = showCableOnlyExerciseControls && selectedMode is WorkoutMode.OldSchool LaunchedEffect(rackItems, defaultRackItemIds) { val enabledRackIds = rackItems.filter { it.enabled }.map { it.id }.toSet() @@ -414,12 +415,14 @@ fun ExerciseEditBottomSheet( } } - // Echo Mode options - if (isEchoMode) { + // Echo Mode options — EccentricLoad visible for Echo + Old School; EchoLevel Echo-only + if (isEchoMode || isOldSchool) { EccentricLoadSelector( eccentricLoad = eccentricLoad, onLoadChange = viewModel::onEccentricLoadChange, ) + } + if (isEchoMode) { EchoLevelSelector( level = echoLevel, onLevelChange = viewModel::onEchoLevelChange, diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt index f7514b88b..71781ec19 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/JustLiftScreen.kt @@ -211,10 +211,8 @@ fun JustLiftScreen(navController: NavController, viewModel: MainViewModel, theme } LaunchedEffect(workoutParameters.programMode) { - if (workoutParameters.isEchoMode) { - eccentricLoad = workoutParameters.eccentricLoad - echoLevel = workoutParameters.echoLevel - } + eccentricLoad = workoutParameters.eccentricLoad + echoLevel = workoutParameters.echoLevel } // Navigate to ActiveWorkout when workout becomes active @@ -582,7 +580,10 @@ fun JustLiftScreen(navController: NavController, viewModel: MainViewModel, theme } val isEchoMode = selectedMode is WorkoutMode.Echo - if (isEchoMode) { + val isOldSchool = selectedMode is WorkoutMode.OldSchool + val showEccentricLoad = isEchoMode || isOldSchool + + if (showEccentricLoad) { Card( modifier = flexibleBodyModifier, colors = CardDefaults.cardColors( @@ -645,48 +646,51 @@ fun JustLiftScreen(navController: NavController, viewModel: MainViewModel, theme overflow = TextOverflow.Ellipsis, ) - HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) + // EchoLevel selector — only for Echo mode + if (isEchoMode) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) - Text( - stringResource(Res.string.echo_level), - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, - ) + Text( + stringResource(Res.string.echo_level), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onSurface, + ) - if (useCompactAccessibility) { - FlowRow( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { - EchoLevel.entries.forEach { level -> - FilterChip( - selected = echoLevel == level, - onClick = { - echoLevel = level - selectedMode = WorkoutMode.Echo(level) - }, - label = { Text(echoLevelLabel(level)) }, - ) + if (useCompactAccessibility) { + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + EchoLevel.entries.forEach { level -> + FilterChip( + selected = echoLevel == level, + onClick = { + echoLevel = level + selectedMode = WorkoutMode.Echo(level) + }, + label = { Text(echoLevelLabel(level)) }, + ) + } } - } - } else { - SingleChoiceSegmentedButtonRow( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 4.dp), - ) { - EchoLevel.entries.forEachIndexed { index, level -> - SegmentedButton( - shape = SegmentedButtonDefaults.itemShape(index = index, count = EchoLevel.entries.size), - onClick = { - echoLevel = level - selectedMode = WorkoutMode.Echo(level) - }, - selected = echoLevel == level, - ) { - Text(echoLevelLabel(level), maxLines = 1) + } else { + SingleChoiceSegmentedButtonRow( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 4.dp), + ) { + EchoLevel.entries.forEachIndexed { index, level -> + SegmentedButton( + shape = SegmentedButtonDefaults.itemShape(index = index, count = EchoLevel.entries.size), + onClick = { + echoLevel = level + selectedMode = WorkoutMode.Echo(level) + }, + selected = echoLevel == level, + ) { + Text(echoLevelLabel(level), maxLines = 1) + } } } } diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutineOverviewScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutineOverviewScreen.kt index a05c8810a..ac70d90c1 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutineOverviewScreen.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/RoutineOverviewScreen.kt @@ -333,6 +333,7 @@ fun RoutineOverviewScreen(navController: NavController, viewModel: MainViewModel adjustedReps = adjustments.reps, isAMRAP = exercise.isAMRAP, isEchoMode = exercise.programMode is ProgramMode.Echo, + isOldSchool = exercise.programMode is ProgramMode.OldSchool, echoLevel = adjustments.echoLevel, eccentricLoadPercent = adjustments.eccentricLoadPercent, sizing = overviewSizing, @@ -552,6 +553,7 @@ private fun ExerciseOverviewCard( adjustedReps: Int, isAMRAP: Boolean, isEchoMode: Boolean, + isOldSchool: Boolean = false, echoLevel: EchoLevel, eccentricLoadPercent: Int, sizing: RoutineOverviewSizing, @@ -667,18 +669,22 @@ private fun ExerciseOverviewCard( ) if (isEchoMode) { - // Echo mode: Show Echo Level + Eccentric Load + Reps EchoLevelPillSelector( selectedLevel = echoLevel, onLevelChange = onEchoLevelChange, ) + } + // Eccentric Load slider — visible for Echo and Old School modes + if (isEchoMode || isOldSchool) { OverviewEccentricLoadSlider( percent = eccentricLoadPercent, onPercentChange = onEccentricLoadChange, ) + } - // Reps for Echo mode + // Reps adjuster for Echo mode only + if (isEchoMode) { if (!isAMRAP) { SliderWithButtons( value = adjustedReps.toFloat(), diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SetReadyScreen.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SetReadyScreen.kt index 37eabed3a..a72009ea3 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SetReadyScreen.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/screen/SetReadyScreen.kt @@ -156,6 +156,7 @@ fun SetReadyScreen(navController: NavController, viewModel: MainViewModel, exerc var pendingOverridesToSave by remember { mutableStateOf>(emptyMap()) } val isEchoMode = currentExercise.programMode is ProgramMode.Echo + val isOldSchool = currentExercise.programMode is ProgramMode.OldSchool val isAMRAP = currentExercise.isAMRAP // #635: explicit stored flag with equipment-derivation fallback @@ -626,7 +627,7 @@ fun SetReadyScreen(navController: NavController, viewModel: MainViewModel, exerc verticalArrangement = Arrangement.spacedBy(Spacing.medium), ) { Text( - if (isEchoMode) "ECHO SETTINGS" else "SET CONFIGURATION", + if (isEchoMode) "ECHO SETTINGS" else if (isOldSchool) "OLD SCHOOL SETTINGS" else "SET CONFIGURATION", style = labelAllCaps.copy(fontWeight = FontWeight.Bold), color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -637,14 +638,18 @@ fun SetReadyScreen(navController: NavController, viewModel: MainViewModel, exerc selectedLevel = setReadyState.echoLevel ?: EchoLevel.HARDER, onLevelChange = { viewModel.updateSetReadyEchoLevel(it) }, ) + } - // Eccentric Load slider - matching RestTimerCard style + // Eccentric Load slider — visible for Echo and Old School modes + if (isEchoMode || isOldSchool) { SetReadyEccentricLoadSlider( percent = setReadyState.eccentricLoadPercent ?: 100, onPercentChange = { viewModel.updateSetReadyEccentricLoad(it) }, ) + } - // Reps adjuster for Echo mode too + // Reps adjuster for Echo mode only + if (isEchoMode) { if (!isAMRAP) { SliderWithButtons( value = setReadyState.adjustedReps.toFloat(), diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/WorkoutParametersTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/WorkoutParametersTest.kt index 1a0aaa978..5052bf24f 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/WorkoutParametersTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/WorkoutParametersTest.kt @@ -137,4 +137,50 @@ class WorkoutParametersTest { assertEquals(2.5f, params.progressionRegressionKg) } + + // Issue #674: Old School eccentric overload tests + + @Test + fun `hasEccentricOverload true for Old School with LOAD_130`() { + val params = WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 10, + eccentricLoad = EccentricLoad.LOAD_130, + ) + + assertTrue(params.hasEccentricOverload) + } + + @Test + fun `hasEccentricOverload false for Old School with LOAD_100`() { + val params = WorkoutParameters( + programMode = ProgramMode.OldSchool, + reps = 10, + eccentricLoad = EccentricLoad.LOAD_100, + ) + + assertFalse(params.hasEccentricOverload) + } + + @Test + fun `hasEccentricOverload false for Echo with LOAD_100`() { + val params = WorkoutParameters( + programMode = ProgramMode.Echo, + reps = 10, + eccentricLoad = EccentricLoad.LOAD_100, + ) + + assertFalse(params.hasEccentricOverload) + } + + @Test + fun `hasEccentricOverload true for Echo with LOAD_130`() { + val params = WorkoutParameters( + programMode = ProgramMode.Echo, + reps = 10, + eccentricLoad = EccentricLoad.LOAD_130, + ) + + assertTrue(params.hasEccentricOverload) + } } From ad17b6d56e3095d17bc18c33d0bb8ae49a7ccb16 Mon Sep 17 00:00:00 2001 From: Devil Date: Fri, 24 Jul 2026 22:09:29 -0400 Subject: [PATCH 53/54] fix(#674): update SetReadyScreenScrollWiringTest for Old School settings label The enhancement added an Old School-specific settings label. Update the source-level wiring test to match the new conditional pattern. --- .../presentation/SetReadyScreenScrollWiringTest.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/SetReadyScreenScrollWiringTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/SetReadyScreenScrollWiringTest.kt index 69466bdd4..acd30e344 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/SetReadyScreenScrollWiringTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/presentation/SetReadyScreenScrollWiringTest.kt @@ -248,7 +248,10 @@ class SetReadyScreenScrollWiringTest { @Test fun setReadyConfiguration_appearsBeforeVideo() { val src = readSetReadyScreenSource() - val configIdx = src.indexOf("if (isEchoMode) \"ECHO SETTINGS\" else \"SET CONFIGURATION\"") + // Issue #674: Old School mode now has its own settings label + val configIdx = src.indexOf("if (isEchoMode) \"ECHO SETTINGS\" else if (isOldSchool) \"OLD SCHOOL SETTINGS\" else \"SET CONFIGURATION\"") + .takeIf { it >= 0 } + ?: src.indexOf("if (isEchoMode) \"ECHO SETTINGS\" else \"SET CONFIGURATION\"") val rackIdx = src.indexOf("testTag(SetReadyTestTags.RACK_CARD)") val videoIdx = src.indexOf("Video thumbnail stays available") From b5097adbc84b8347e0a96db6bc34492f51f22333 Mon Sep 17 00:00:00 2001 From: phoenixworker Date: Fri, 24 Jul 2026 23:08:03 -0400 Subject: [PATCH 54/54] fix(#674): gate hasEccentricOverload on OldSchool mode; preserve portal sync - hasEccentricOverload now requires ProgramMode.OldSchool (not just ecc%>100) so switching from Old School+130% to Pump/TUT doesn't dispatch 0x4E packet - ActiveSessionEngine uses the model property instead of inline percentage check - PortalSyncAdapter serializes eccentricLoad for Old School only when > 100% - Updated tests: Echo+LOAD_130 now false; added Pump+LOAD_130 regression test Addresses Codex review P1s on PR #683 --- .../data/sync/PortalSyncAdapter.kt | 3 ++- .../devil/phoenixproject/domain/model/Models.kt | 6 ++++-- .../presentation/manager/ActiveSessionEngine.kt | 2 +- .../domain/model/WorkoutParametersTest.kt | 17 +++++++++++++++-- 4 files changed, 22 insertions(+), 6 deletions(-) diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalSyncAdapter.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalSyncAdapter.kt index be22dc53f..40b16c537 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalSyncAdapter.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/data/sync/PortalSyncAdapter.kt @@ -580,7 +580,8 @@ object PortalSyncAdapter { repCountTiming = ex.repCountTiming.name, stopAtPosition = if (ex.stopAtTop) "TOP" else null, stallDetection = ex.stallDetectionEnabled, - eccentricLoad = if (ex.programMode == ProgramMode.Echo) { + eccentricLoad = if (ex.programMode == ProgramMode.Echo || + (ex.programMode == ProgramMode.OldSchool && ex.eccentricLoad.percentage > 100)) { ex.eccentricLoad.name } else { null diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt index 104b7ae26..f22626158 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/domain/model/Models.kt @@ -371,8 +371,10 @@ data class WorkoutParameters( /** True if this is an Echo workout */ val isEchoMode: Boolean get() = programMode == ProgramMode.Echo - /** True if eccentric load exceeds 100% (triggers 0x4E Echo packet dispatch for Old School mode) */ - val hasEccentricOverload: Boolean get() = eccentricLoad.percentage > 100 + /** True if Old School mode has eccentric load > 100% (triggers 0x4E Echo packet dispatch). + * Gated on ProgramMode.OldSchool so switching to Pump/TUT/TUTBeast/EccentricOnly + * doesn't accidentally dispatch the 0x4E packet from a stale eccentric value. */ + val hasEccentricOverload: Boolean get() = programMode == ProgramMode.OldSchool && eccentricLoad.percentage > 100 } /** diff --git a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt index 8ba6a9d91..2573c50ae 100644 --- a/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt +++ b/shared/src/commonMain/kotlin/com/devil/phoenixproject/presentation/manager/ActiveSessionEngine.kt @@ -2832,7 +2832,7 @@ class ActiveSessionEngine( } } - val hasEccentricOverload = bleParams.eccentricLoad.percentage > 100 + val hasEccentricOverload = bleParams.hasEccentricOverload val commandValidation = if (bleParams.isEchoMode || hasEccentricOverload) { WorkoutCommandValidator.validateEchoControl( level = if (bleParams.isEchoMode) bleParams.echoLevel else EchoLevel.HARDER, diff --git a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/WorkoutParametersTest.kt b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/WorkoutParametersTest.kt index 5052bf24f..570c5b825 100644 --- a/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/WorkoutParametersTest.kt +++ b/shared/src/commonTest/kotlin/com/devil/phoenixproject/domain/model/WorkoutParametersTest.kt @@ -174,13 +174,26 @@ class WorkoutParametersTest { } @Test - fun `hasEccentricOverload true for Echo with LOAD_130`() { + fun `hasEccentricOverload false for Echo with LOAD_130`() { val params = WorkoutParameters( programMode = ProgramMode.Echo, reps = 10, eccentricLoad = EccentricLoad.LOAD_130, ) - assertTrue(params.hasEccentricOverload) + // Echo has its own overload mechanism (isEchoMode); hasEccentricOverload is OldSchool-only + assertFalse(params.hasEccentricOverload) + } + + @Test + fun `hasEccentricOverload false for Pump with LOAD_130 after mode switch`() { + val params = WorkoutParameters( + programMode = ProgramMode.Pump, + reps = 10, + eccentricLoad = EccentricLoad.LOAD_130, + ) + + // Mode switch from Old School+130% to Pump must NOT trigger 0x4E dispatch + assertFalse(params.hasEccentricOverload) } }