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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user