From a10d4b9517deea5a69fbf4eee9f9647e866e9482 Mon Sep 17 00:00:00 2001 From: jjangddu Date: Tue, 7 Jul 2026 10:40:21 +0900 Subject: [PATCH] =?UTF-8?q?fix(stability):=20=EC=BD=94=EB=93=9C=EB=B2=A0?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=A7=84=EB=8B=A8=20HIGH=20=EC=9D=B4?= =?UTF-8?q?=EC=8A=88=206=EA=B1=B4=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 병렬 진단 (BLE/thread + Compose lifecycle + null safety) 결과 HIGH 심각도 12건 중 crash 유발 가능성 높고 저비용 고효과인 것 우선 처리. **H4 — coroutine leak** - PiezoMonitoringView.kt BleDebugPanel: `while(true) { delay/refresh }` → `while(isActive)`. LaunchedEffect 취소 후에도 refreshTick++ 이 계속 돌아 GC 방해. - BladdyRiveView.kt 2곳: 동일 패턴 → isActive 로 통일. **H1 — BleManager postDelayed 미취소** - `fwFallbackTimer` (4초 mfv? fallback) + `cccdRetryTimer` (500ms CCCD 재시도) 를 Runnable 참조로 저장. disconnect / GATT_ERROR / watchdog timeout 3곳에서 handler.removeCallbacks 로 명시적 취소. - 이전에는 disconnect 후에도 4초 후 sendFirmwareVersionQuery() 가 발동 → 이미 close 된 GATT 에 write → silent exception → state 오염. **H2 — MeasurementService 콜백 leak** - performNirsMeasurement/performPiezoMeasurement 는 singleton 에서 콜백 대입만 하고 정리 안 함. Self-clearing lambda 로 응답 1회 처리 후 자동 null. 시작 시 이전 stale 콜백도 clear. - stop() 에서도 대기 중 콜백 취소. **H3 — Watchdog race condition** - watchdog thread 가 GATT disconnect/close + characteristic null 을 binder thread 에서 직접 실행 → UI thread 의 sendRaw 와 race. - 정리 전부를 handler.post 로 UI thread 에 위임 → single-threaded. - fwFallback/cccdRetry timer 도 여기서 함께 취소. **H6 — PiezoMonitoringView measure() closure leak** - DisposableEffect 에 piezoCollector.onMultiChannelComplete 정리 추가. measure() 함수 안에서 이 콜백에 measureScope + channels + outer state 를 다수 capture → 화면 이탈 후에도 GC 방해 + 재진입 시 stale closure 가 새 상태 오염 위험. **H9 — UrineCameraScreen NPE 위험** - LaunchedEffect 안 while 루프에서 `currentDetection!!` 이 다른 recompose 가 detection 을 null 로 만들면 NPE. Local val snapshot 으로 fix. Co-Authored-By: Claude Opus 4.7 --- .../com/medithings/vesiscan/ble/BleManager.kt | 52 ++++++++++++++----- .../vesiscan/services/MeasurementService.kt | 10 ++++ .../vesiscan/ui/components/BladdyRiveView.kt | 9 ++-- .../views/monitoring/PiezoMonitoringView.kt | 11 +++- .../ui/views/monitoring/UrineCameraScreen.kt | 12 +++-- 5 files changed, 71 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt index 54cc755..43f57ec 100644 --- a/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt +++ b/app/src/main/java/com/medithings/vesiscan/ble/BleManager.kt @@ -76,6 +76,11 @@ class BleManager private constructor(private val context: Context) { @Volatile private var lastRxTimestamp: Long = 0 private var watchdogTimer: Runnable? = null private val watchdogTimeoutMs: Long = 15000 + // 2026-07-07 fix: disconnect 시 취소 위해 Runnable 참조 유지. + // fwFallback: onDescriptorWrite 성공 후 4초 mfv? fallback. + // cccdRetry: onDescriptorWrite 실패 후 500ms retry. + private var fwFallbackTimer: Runnable? = null + private var cccdRetryTimer: Runnable? = null val discoveredDevices = mutableStateListOf() val isScanning = mutableStateOf(false) @@ -708,16 +713,21 @@ class BleManager private constructor(private val context: Context) { if (silenceMs > watchdogTimeoutMs) { debugLogger.error("WATCHDOG_TIMEOUT silence=${silenceMs}ms — forcing reconnect") + // 2026-07-07 fix: watchdog thread 에서 직접 GATT 정리 + characteristic + // null 하면 UI thread 의 sendRaw 와 race — writeCharacteristic 이 이미 + // close 중인 GATT 에 접근. 모든 정리를 UI thread (handler.post) 로 위임하여 + // single-threaded 로 실행. watchdogRunning = false - try { - bluetoothGatt?.disconnect() - bluetoothGatt?.close() - } catch (_: Exception) {} - bluetoothGatt = null - txCharacteristic = null - rxCharacteristic = null - handler.post { + try { + bluetoothGatt?.disconnect() + bluetoothGatt?.close() + } catch (_: Exception) {} + bluetoothGatt = null + txCharacteristic = null + rxCharacteristic = null + fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null } + cccdRetryTimer?.let { handler.removeCallbacks(it); cccdRetryTimer = null } stopBatteryPolling() isConnected.value = false isServiceReady.value = false @@ -877,6 +887,9 @@ class BleManager private constructor(private val context: Context) { stopBatteryPolling() stopWatchdog() + // 2026-07-07 fix: 연결 실패 시 대기 중인 timer 취소. + fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null } + cccdRetryTimer?.let { handler.removeCallbacks(it); cccdRetryTimer = null } bluetoothGatt = null txCharacteristic = null rxCharacteristic = null @@ -948,6 +961,9 @@ class BleManager private constructor(private val context: Context) { logd { "Disconnected from ${gatt.device.address}" } isConnecting.value = false stopBatteryPolling() + // 2026-07-07 fix: disconnect 시 대기 중인 timer 취소. + fwFallbackTimer?.let { handler.removeCallbacks(it); fwFallbackTimer = null } + cccdRetryTimer?.let { handler.removeCallbacks(it); cccdRetryTimer = null } bluetoothGatt = null txCharacteristic = null rxCharacteristic = null @@ -1059,19 +1075,25 @@ class BleManager private constructor(private val context: Context) { startBatteryPolling() startWatchdog() sendDeviceInfoQuery() - // 4초 후에도 펌웨어 미수신이면 mfv? fallback (구 펌웨어는 mid? 미지원 가능성) - handler.postDelayed({ + // 4초 후에도 펌웨어 미수신이면 mfv? fallback (구 펌웨어는 mid? 미지원 가능성). + // 2026-07-07 fix: disconnect 시 취소되도록 Runnable 참조 유지. + fwFallbackTimer?.let { handler.removeCallbacks(it) } + val fwFallback = Runnable { if (firmwareVersion.value.isEmpty() && isConnected.value) { debugLogger.info("FW fallback: sending mfv?") sendFirmwareVersionQuery() } - }, 4_000) + } + fwFallbackTimer = fwFallback + handler.postDelayed(fwFallback, 4_000) logd { "Service ready! (CCCD written)" } } } else { loge { "CCCD write failed with status: $status — retrying in 500ms" } - // GATT 133 등 일시적 오류 → 500ms 후 재시도 - handler.postDelayed({ + // GATT 133 등 일시적 오류 → 500ms 후 재시도. + // 2026-07-07 fix: disconnect 시 취소되도록 Runnable 참조 유지. + cccdRetryTimer?.let { handler.removeCallbacks(it) } + val cccdRetry = Runnable { try { val rx = rxCharacteristic if (rx != null) { @@ -1092,7 +1114,9 @@ class BleManager private constructor(private val context: Context) { } catch (e: Exception) { connectionError.value = "Notification setup failed (status $status)" } - }, 500) + } + cccdRetryTimer = cccdRetry + handler.postDelayed(cccdRetry, 500) } } } diff --git a/app/src/main/java/com/medithings/vesiscan/services/MeasurementService.kt b/app/src/main/java/com/medithings/vesiscan/services/MeasurementService.kt index 86cb906..c7917d7 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/MeasurementService.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/MeasurementService.kt @@ -27,8 +27,13 @@ class MeasurementService private constructor(context: Context) { } } + // 2026-07-07 fix: MeasurementService 는 singleton. 콜백을 대입만 하고 정리하지 않으면 + // BleManager 에 stale closure 가 계속 살아있어 이후 응답 이벤트가 이미 완료된 + // measurement session 의 completion() 을 다시 호출. Self-clearing lambda 로 처리. private fun performNirsMeasurement(completion: (Boolean) -> Unit) { + bleManager.onNirsMcjReceived = null // 이전 stale 콜백 해제 bleManager.onNirsMcjReceived = { _ -> + bleManager.onNirsMcjReceived = null // 응답 1회 처리 후 자동 해제 isMeasuring.value = false completion(true) } @@ -36,7 +41,9 @@ class MeasurementService private constructor(context: Context) { } private fun performPiezoMeasurement(completion: (Boolean) -> Unit) { + bleManager.onPiezoDataReceived = null // 이전 stale 콜백 해제 bleManager.onPiezoDataReceived = { _ -> + bleManager.onPiezoDataReceived = null // 응답 1회 처리 후 자동 해제 isMeasuring.value = false completion(true) } @@ -45,6 +52,9 @@ class MeasurementService private constructor(context: Context) { fun stop() { isMeasuring.value = false + // 대기 중이던 콜백 취소. + bleManager.onNirsMcjReceived = null + bleManager.onPiezoDataReceived = null bleManager.sendPiezoStop() } } diff --git a/app/src/main/java/com/medithings/vesiscan/ui/components/BladdyRiveView.kt b/app/src/main/java/com/medithings/vesiscan/ui/components/BladdyRiveView.kt index 05f826e..c7459df 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/components/BladdyRiveView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/components/BladdyRiveView.kt @@ -16,6 +16,7 @@ import app.rive.runtime.kotlin.core.Fit import com.medithings.vesiscan.R import com.medithings.vesiscan.models.UrgencyLevel import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import kotlin.random.Random /** @@ -59,8 +60,8 @@ fun BladdyRiveView( when (urgency) { UrgencyLevel.SAFE -> { view.setNumberState("State Machine 1", "state", 0f) - // Random idle animations every 4 seconds - while (true) { + // Random idle animations every 4 seconds — isActive 로 취소 신호 존중. + while (isActive) { delay(4000) val randomState = listOf(0f, 1f, 5f).random() view.setNumberState("State Machine 1", "state", randomState) @@ -109,9 +110,9 @@ fun BladdyIdleRiveView( ) } - // Random idle animations every 5 seconds + // Random idle animations every 5 seconds — isActive 로 취소 신호 존중. LaunchedEffect(Unit) { - while (true) { + while (isActive) { delay(5000) val view = riveView.firstOrNull() ?: continue val randomState = listOf(0f, 1f, 5f).random() diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt index eb6aa01..ed9d9fc 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/PiezoMonitoringView.kt @@ -851,9 +851,13 @@ fun PiezoMonitoringView(appState: AppState) { // 2026-07-07 fix: BleManager 콜백 (onConnectionStateChanged 등) 3개도 정리. 이전에는 // 화면 전환 시 stale 콜백이 남아 새 화면 진입 후에도 이전 화면 로직 (startBatteryPolling // 등) 이 발동 → GATT operation queue 겹침 → 좀비 세션의 간접 원인. + // 2026-07-07 fix v2: piezoCollector.onMultiChannelComplete 도 정리. measure() 안에서 + // 대입하는 콜백이 measureScope / channels / 여러 outer state 를 capture 하고 있어 + // 이탈 후에도 GC 방해 + 이후 다시 진입 시 stale closure 가 새 상태를 오염시킬 위험. androidx.compose.runtime.DisposableEffect(Unit) { onDispose { bleManager.imuCollector.onComplete = null + bleManager.piezoCollector.onMultiChannelComplete = null bleManager.onConnectionStateChanged = null bleManager.onUnexpectedDisconnect = null bleManager.onReconnectionFailed = null @@ -2739,9 +2743,12 @@ private fun BleDebugPanel(bleManager: BleManager) { val context = LocalContext.current var refreshTick by remember { mutableIntStateOf(0) } - // Auto-refresh every 2 seconds + // Auto-refresh every 2 seconds. + // 2026-07-07 fix: while(true) → while(isActive). BleDebugPanel Composable 이 화면에서 + // 벗어나도 (개발자모드 이탈 등) coroutine 이 계속 살아있어 refreshTick++ 하며 + // GC 방해. isActive 로 취소 신호 전달 시 즉시 종료. LaunchedEffect(Unit) { - while (true) { + while (isActive) { kotlinx.coroutines.delay(2000) refreshTick++ } diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/UrineCameraScreen.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/UrineCameraScreen.kt index 4486ee2..ae3a00b 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/UrineCameraScreen.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/monitoring/UrineCameraScreen.kt @@ -117,12 +117,18 @@ fun UrineCameraScreen( }) } - // 자동 촬영 로직 + // 자동 촬영 로직. + // 2026-07-07 fix: currentDetection 은 mutableStateOf delegate — smart cast 안 되어 !! 로 unwrap + // 하는데, coroutine 안에서 첫 null-check 와 !! 사이에 다른 recomposition 이 detection 을 + // null 로 만들 수 있음 → NullPointerException. Local val 로 snapshot 후 사용. LaunchedEffect(currentDetection, isAutoCapturing, capturedBitmap) { if (capturedBitmap != null || isAutoCapturing) return@LaunchedEffect - if (currentDetection != null && currentDetection!!.confidence >= 0.90f) { + val initialDet = currentDetection + if (initialDet != null && initialDet.confidence >= 0.90f) { if (detectionStartTime == null) detectionStartTime = System.currentTimeMillis() - while (currentDetection != null && currentDetection!!.confidence >= 0.90f && !isAutoCapturing && capturedBitmap == null) { + while (true) { + val det = currentDetection ?: break + if (det.confidence < 0.90f || isAutoCapturing || capturedBitmap != null) break val elapsed = System.currentTimeMillis() - (detectionStartTime ?: System.currentTimeMillis()) autoCaptureProgress = (elapsed.toFloat() / autoCaptureDelay).coerceIn(0f, 1f) if (elapsed >= autoCaptureDelay) { doCapture(); break }