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:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* ImuSidecarTest — 사이드카가 겹쳐 걸려도 콜백 사슬이 맞는지 잠근다.
|
||||
*
|
||||
* 실기(A34, 2026-09-28)에서 확인된 순서: 쉬는 동안의 폴링 사이드카가 취소되기 **전에**
|
||||
* 측정 루프의 사이드카가 install 됐다. 옛 코드는 이때 restore 를 건너뛰어(SKIPPED)
|
||||
* 원래 콜백이 영영 안 돌아왔다. 여기서는 어느 순서로 겹쳐도
|
||||
* 1. 맨 위(살아 있는 쪽)가 샘플을 받고,
|
||||
* 2. 마지막 사이드카가 내려올 때 원래 콜백이 되돌아오는지
|
||||
* 본다.
|
||||
*/
|
||||
package com.medithings.vesiscan.ui.views.clinical
|
||||
|
||||
import com.medithings.vesiscan.ble.ImuPacketCollector
|
||||
import com.medithings.vesiscan.ble.ImuSample
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ImuSidecarTest {
|
||||
|
||||
private val collector = ImuPacketCollector()
|
||||
private val sample = listOf(ImuSample(0f, 0f, 1f, 0f, 0f, 0f))
|
||||
|
||||
/** 사이드카 이전에 걸려 있던 "원래" 콜백 — 일반 측정 화면의 자세 판정 같은 것. */
|
||||
private val receivedByOriginal = mutableListOf<List<ImuSample>>()
|
||||
private val original: (List<ImuSample>) -> Unit = { receivedByOriginal += it }
|
||||
|
||||
@Test
|
||||
fun `순서대로 걸고 풀면 원래 콜백이 돌아온다`() {
|
||||
collector.onComplete = original
|
||||
val a = ImuSidecar(collector)
|
||||
a.install()
|
||||
a.restore()
|
||||
assertSame(original, collector.onComplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `새 사이드카가 먼저 올라탄 뒤 옛것이 내려와도 원래 콜백이 돌아온다`() = runBlocking {
|
||||
collector.onComplete = original
|
||||
val old = ImuSidecar(collector)
|
||||
old.install()
|
||||
val new = ImuSidecar(collector)
|
||||
new.install() // Compose 가 취소된 효과의 finally 보다 먼저 돌린 새 효과
|
||||
|
||||
old.restore() // 맨 위가 아니다 — 사슬에서 자기만 빠져야 한다
|
||||
collector.onComplete!!.invoke(sample)
|
||||
assertEquals(sample, new.await(50)) // 살아 있는 쪽이 여전히 받는다
|
||||
assertTrue(receivedByOriginal.isEmpty())
|
||||
|
||||
new.restore()
|
||||
assertSame(original, collector.onComplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `셋이 겹쳐도 가운데가 먼저 빠지고 끝까지 풀면 원래 콜백이 돌아온다`() {
|
||||
collector.onComplete = original
|
||||
val a = ImuSidecar(collector); a.install()
|
||||
val b = ImuSidecar(collector); b.install()
|
||||
val c = ImuSidecar(collector); c.install()
|
||||
|
||||
b.restore() // 가운데
|
||||
a.restore() // 맨 아래
|
||||
c.restore() // 맨 위
|
||||
assertSame(original, collector.onComplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `사이드카 아닌 것이 위를 덮어썼으면 건드리지 않는다`() {
|
||||
collector.onComplete = original
|
||||
val a = ImuSidecar(collector)
|
||||
a.install()
|
||||
val someoneElse: (List<ImuSample>) -> Unit = { }
|
||||
collector.onComplete = someoneElse // 일반 화면이 들어와 자기 콜백을 걸었다
|
||||
a.restore()
|
||||
assertSame(someoneElse, collector.onComplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `원래 콜백이 없던 자리는 null 로 돌아간다`() {
|
||||
val a = ImuSidecar(collector)
|
||||
a.install()
|
||||
a.restore()
|
||||
assertNull(collector.onComplete)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `풀린 뒤에는 샘플을 받지 않는다`() = runBlocking {
|
||||
val a = ImuSidecar(collector)
|
||||
a.install()
|
||||
val hook = collector.onComplete!!
|
||||
a.restore()
|
||||
hook.invoke(sample) // 늦게 도착한 콜백 — 닫힌 채널에 버려진다
|
||||
assertNull(a.await(20))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user