fix(stability): 코드베이스 진단 HIGH 이슈 6건 fix

병렬 진단 (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 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 10:40:21 +09:00
parent f4f5407894
commit a10d4b9517
5 changed files with 71 additions and 23 deletions
@@ -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<BleDevice>()
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)
}
}
}