fix(ImuSidecar): 사이드카가 겹쳐 걸려도 원래 콜백이 되돌아가게 콜백을 사슬로 다룬다

A34 실기 로그(2026-09-28): 병원 화면의 기울기 폴링 사이드카가 취소되기 전에 정렬
화면의 사이드카가 install 됐다 — Compose 가 취소된 효과의 finally 를 새 효과 뒤에
돌린 것. 옛 restore 는 "자기 콜백일 때만 되돌림"이라 SKIPPED 로 끝났고, 그 뒤로
imuCollector.onComplete 가 닫힌 채널의 죽은 람다를 가리킨 채 원래 콜백이 영영
돌아오지 않았다. 일반 화면들은 들어올 때 자기 콜백을 다시 걸어 당장 깨지는 것은
없었지만, 사슬이 틀어진 채 쌓이는 구조였다.

  · 콜백을 Hook(owner) 클래스로 걸어 다른 사이드카가 주인을 알아본다.
  · restore: 맨 위면 내려오고, 아니면 위쪽 사이드카의 previous 에서 자기만 뺀다.
    사이드카 아닌 것이 덮어썼으면 건드리지 않는다.
  · await 는 restore 뒤에 불려도 null (닫힌 채널을 던지지 않는다).
  · 생성자를 ImuPacketCollector 로 받아 JVM 단위시험 6건으로 잠근다. BleManager
    생성자는 부생성자로 남겨 호출부 4곳은 그대로다.

실기 확인: 병원 → 정렬 → 병원 전환에서 install/spliced out 만 찍히고 SKIPPED·
closed=true 는 0건.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 16:17:15 +09:00
parent c667597e2b
commit c8cf982b71
2 changed files with 163 additions and 26 deletions
@@ -19,16 +19,24 @@
* 측정을 실패로 돌리면 기존에 되던 일이 안 되게 된다. [await] 는 null 을 돌려주고
* 호출부는 IMU 없이 저장한다.
*
* ## 2026-09-28 진단 로그 · 방어적 restore
* A34 실기에서 0cm 정렬 20회가 `rim:` 을 전부 받았는데(BLE 로그 · parseRim 로그 확인)
* `align_0cm_imu.csv` 가 안 생겼다. 콜백 사슬 어디서 끊기는지 보려고 단계마다 로그를
* 남긴다(태그 `ImuSidecar`). 아울러 [restore] 는 **지금 걸린 콜백이 자기 것일 때만**
* 되돌린다 — 옛 인스턴스의 restore 가 새 인스턴스의 install 을 덮는 순서가 가능하다.
* ## 겹쳐 걸리는 순서 (2026-09-28)
* 한 화면에 사이드카가 둘이다 — 쉬는 동안의 기울기 폴링([pollLiveTilt])과 측정 루프.
* 폴링 효과가 취소되고 루프가 시작될 때 Compose 는 **취소된 쪽의 restore 를 새 쪽의
* install 뒤에** 돌리기도 한다(A34 실기 로그로 확인). 단순 "되돌리기"면 새 사이드카를
* 옛 콜백으로 덮어 루프가 IMU 를 못 받는다.
*
* 그래서 콜백을 **사슬**로 다룬다. install 은 지금 것 위에 올라타고([Hook] 이 주인을
* 안다), restore 는 자기가 맨 위면 내려오고, 아니면 위쪽 사슬에서 **자기만 빼고** 자기
* 이전을 물려준다. 어느 순서로 겹쳐도 맨 위는 살아 있는 쪽이고, 마지막이 내려올 때
* 사이드카 이전의 원래 콜백이 되돌아간다.
*
* 단계마다 로그를 남긴다(태그 `ImuSidecar`) — 콜백 사슬 문제는 로그 없이는 못 본다.
*/
package com.medithings.vesiscan.ui.views.clinical
import android.util.Log
import com.medithings.vesiscan.ble.BleManager
import com.medithings.vesiscan.ble.ImuPacketCollector
import com.medithings.vesiscan.ble.ImuSample
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.withTimeoutOrNull
@@ -36,31 +44,37 @@ import kotlinx.coroutines.withTimeoutOrNull
/** piezo 를 받은 뒤 IMU 를 기다리는 시간. 같은 응답의 뒷부분이라 길 필요가 없다. */
const val IMU_GRACE_MS = 700L
class ImuSidecar(private val ble: BleManager) {
class ImuSidecar(private val collector: ImuPacketCollector) {
constructor(ble: BleManager) : this(ble.imuCollector)
/**
* 우리가 건 콜백. 람다가 아니라 클래스인 이유: 다른 사이드카가 "이건 사이드카 것이고
* 주인은 누구"를 알아봐야 사슬을 고칠 수 있다.
*/
private class Hook(val owner: ImuSidecar) : (List<ImuSample>) -> Unit {
override fun invoke(samples: List<ImuSample>) = owner.receive(samples)
}
private val inbox = Channel<List<ImuSample>>(Channel.CONFLATED)
private val mine: Hook = Hook(this)
/** install 당시 걸려 있던 콜백. 위쪽 사이드카가 restore 로 나를 빼면 여기가 바뀐다. */
private var previous: ((List<ImuSample>) -> Unit)? = null
private var installed = false
private val id = Integer.toHexString(System.identityHashCode(this))
/** 우리가 건 콜백. restore 때 "아직 우리 것인가"를 참조로 비교한다. */
private val mine: (List<ImuSample>) -> Unit = { samples ->
private fun receive(samples: List<ImuSample>) {
val r = inbox.trySend(samples)
Log.d(TAG, "[$id] onComplete <- ${samples.size} samples · trySend ok=${r.isSuccess} closed=${r.isClosed}")
}
/** 콜백을 가로챈다. 원래 콜백은 [restore] 에서 되돌린다. */
/** 지금 걸린 콜백 위에 올라탄다. 원래 콜백은 [restore] 에서 되돌린다. */
fun install() {
if (installed) return
previous = ble.imuCollector.onComplete
ble.imuCollector.onComplete = mine
previous = collector.onComplete
collector.onComplete = mine
installed = true
Log.d(
TAG,
"[$id] install · previous=${previous?.javaClass?.name ?: "null"}" +
" · ble=${Integer.toHexString(System.identityHashCode(ble))}" +
" · collector=${Integer.toHexString(System.identityHashCode(ble.imuCollector))}",
)
Log.d(TAG, "[$id] install · previous=${describe(previous)}")
}
/** 직전 회차에서 늦게 도착한 것을 버린다. piezo 쪽 비우기와 같은 자리에서 부른다. */
@@ -70,29 +84,54 @@ class ImuSidecar(private val ble: BleManager) {
if (n > 0) Log.d(TAG, "[$id] drain · dropped=$n")
}
/** piezo 를 받은 뒤 부른다. 시간 안에 안 오면 null — 호출부는 IMU 없이 진행한다. */
/**
* piezo 를 받은 뒤 부른다. 시간 안에 안 오면 null — 호출부는 IMU 없이 진행한다.
* [restore] 뒤에 불려도 null 이다(닫힌 채널을 던지게 두지 않는다).
*/
suspend fun await(graceMs: Long = IMU_GRACE_MS): List<ImuSample>? {
val hookIsMine = ble.imuCollector.onComplete === mine
val got = withTimeoutOrNull(graceMs) { inbox.receive() }
val hookIsMine = collector.onComplete === mine
val got = withTimeoutOrNull(graceMs) { inbox.receiveCatching().getOrNull() }
Log.d(TAG, "[$id] await -> ${got?.size?.toString() ?: "TIMEOUT"} · hookIsMine=$hookIsMine")
return got
}
/**
* 사슬에서 빠진다. 맨 위면 내려오고, 위에 다른 사이드카가 있으면 그쪽의 `previous`
* 에서 나를 건너뛰게 한다. 어느 쪽도 아니면(사이드카 아닌 것이 그 사이에 덮어썼다)
* 건드리지 않는다 — 그 사람 것을 되돌리면 안 된다.
*/
fun restore() {
if (!installed) return
val current = ble.imuCollector.onComplete
if (current === mine) {
ble.imuCollector.onComplete = previous
Log.d(TAG, "[$id] restore · ok")
val top = collector.onComplete
if (top === mine) {
collector.onComplete = previous
Log.d(TAG, "[$id] restore · popped · now=${describe(previous)}")
} else {
// 우리 뒤에 누군가 다시 걸었다 — 그 사람 것을 되돌리면 안 된다.
Log.w(TAG, "[$id] restore · SKIPPED — hook is not mine (${current?.javaClass?.name ?: "null"})")
var h = top
var spliced = false
while (h is Hook) {
val above = h.owner
if (above.previous === mine) {
above.previous = previous
spliced = true
break
}
h = above.previous
}
if (spliced) Log.d(TAG, "[$id] restore · spliced out under ${describe(top)}")
else Log.w(TAG, "[$id] restore · SKIPPED — not in chain (top=${describe(top)})")
}
previous = null
installed = false
inbox.close()
}
private fun describe(hook: ((List<ImuSample>) -> Unit)?): String = when (hook) {
null -> "null"
is Hook -> "sidecar[${hook.owner.id}]"
else -> hook.javaClass.name
}
private companion object {
const val TAG = "ImuSidecar"
}