From 4c13b2be5c6ced13f4ddca8a9ffc6194b4872bfa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 6 May 2026 22:34:35 +0000 Subject: [PATCH] fix(commons): dispose NestViewModel between tests so :commons:jvmTest stops hanging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NestViewModel.connect() launches an infinite cliff-detector loop in viewModelScope (`while(true) { delay(...) }`). The existing tests in NestViewModelTest call connect() but never disconnect/onCleared, so that loop stays alive. Under runTest's virtual scheduler each delay returns instantly, the loop spins millions of iterations per second of real time, and runTest never reaches the idle state — every test wedges until the per-test deadline (60 s × 17 tests => :commons:jvmTest hangs for ~17 minutes). Wrap each test body with runVmTest, which runs the body inside a try/finally that calls disconnect() on every VM created by newViewModel before runTest tries to drain. teardown() cancels cliffDetectorJob, the scheduler becomes idle, and the test returns. A 10 s runTest timeout is the safety net — healthy tests now finish in milliseconds, and tripping the timeout signals a new viewModelScope coroutine that needs its own teardown call. Verified: :commons:jvmTest --rerun-tasks now completes in 52 s (409 tests, 0 failures); NestViewModelTest's 17 tests run in 0.083 s total versus the previous indefinite hang. https://claude.ai/code/session_01R9wUxnRJrr299W8TzRyFs7 --- .../commons/viewmodels/NestViewModelTest.kt | 67 ++++++++++++++----- 1 file changed, 50 insertions(+), 17 deletions(-) diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt index 5c7415ab5..59b7a91ca 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/viewmodels/NestViewModelTest.kt @@ -55,6 +55,7 @@ import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertIs import kotlin.test.assertTrue +import kotlin.time.Duration.Companion.seconds /** * Drives [NestViewModel] with a fake [NestsListenerConnector] and @@ -72,6 +73,16 @@ import kotlin.test.assertTrue */ @OptIn(ExperimentalCoroutinesApi::class) class NestViewModelTest { + // Tracks every VM created by `newViewModel` so each test can dispose them + // before runTest exits. `connect()` starts an infinite cliff-detector + // loop in viewModelScope; without an explicit teardown that loop spins + // forever under runTest's virtual scheduler and the test wedges until + // the real-time runTest deadline (60 s × N tests => :commons:jvmTest + // hangs). [runVmTest] wraps each test body with a finally that calls + // `disconnect()` (which cancels cliffDetectorJob via teardown()) so the + // scheduler can become idle and runTest can return. + private val createdVms = mutableListOf() + @BeforeTest fun setupMainDispatcher() { Dispatchers.setMain(UnconfinedTestDispatcher()) @@ -79,12 +90,34 @@ class NestViewModelTest { @AfterTest fun resetMainDispatcher() { + // Belt-and-braces: if a test crashes before its finally runs, this + // still tears down so the next test starts clean. + createdVms.forEach { runCatching { it.disconnect() } } + createdVms.clear() Dispatchers.resetMain() } + /** + * Test wrapper that guarantees every VM created via [newViewModel] is + * disconnected before [runTest] tries to drain the test scheduler. The + * 10-second [runTest] timeout is a safety net — a healthy test in this + * class finishes in milliseconds; if we trip the timeout it almost + * certainly means a new code path is leaving viewModelScope coroutines + * running and needs its own teardown call. + */ + private fun runVmTest(body: suspend TestScope.() -> Unit) = + runTest(timeout = 10.seconds) { + try { + body() + } finally { + createdVms.forEach { runCatching { it.disconnect() } } + createdVms.clear() + } + } + @Test fun connectShowsConnectingThenConnected() = - runTest { + runVmTest { val fakeListener = FakeNestsListener() val vm = newViewModel { fakeListener } @@ -103,7 +136,7 @@ class NestViewModelTest { @Test fun listenerFailedSurfacesAsUiFailed() = - runTest { + runVmTest { val fakeListener = FakeNestsListener() val vm = newViewModel { fakeListener } @@ -117,7 +150,7 @@ class NestViewModelTest { @Test fun connectorThrowsBecomesUiFailed() = - runTest { + runVmTest { val vm = newViewModel { throw NestsException("dns blew up") } vm.connect() @@ -129,7 +162,7 @@ class NestViewModelTest { @Test fun setMutedFlipsUiStateAndIsRetained() = - runTest { + runVmTest { val vm = newViewModel { FakeNestsListener() } assertFalse(vm.uiState.value.isMuted) @@ -141,7 +174,7 @@ class NestViewModelTest { @Test fun onStageNowDefaultsTrueAndSetOnStageFlipsIt() = - runTest { + runVmTest { val vm = newViewModel { FakeNestsListener() } // Defaults to true so a freshly-joined speaker advertises @@ -156,7 +189,7 @@ class NestViewModelTest { @Test fun onPresenceEventPopulatesPresencesMapAndDedupesByPubkey() = - runTest { + runVmTest { val vm = newViewModel { FakeNestsListener() } val alice = "a".repeat(64) @@ -187,7 +220,7 @@ class NestViewModelTest { @Test fun evictStalePresencesDropsOldPeers() = - runTest { + runVmTest { val vm = newViewModel { FakeNestsListener() } val alice = "a".repeat(64) val bob = "b".repeat(64) @@ -219,7 +252,7 @@ class NestViewModelTest { @Test fun onChatEventAccumulatesMessagesSortedByCreatedAt() = - runTest { + runVmTest { val vm = newViewModel { FakeNestsListener() } val alice = "a".repeat(64) @@ -255,7 +288,7 @@ class NestViewModelTest { @Test fun onChatEventDedupesByEventId() = - runTest { + runVmTest { val vm = newViewModel { FakeNestsListener() } val alice = "a".repeat(64) val msg = @@ -281,7 +314,7 @@ class NestViewModelTest { @Test fun onReactionEventGroupsByTargetAndEvictsOnTick() = - runTest { + runVmTest { val vm = newViewModel { FakeNestsListener() } val alice = "a".repeat(64) val bob = "b".repeat(64) @@ -326,7 +359,7 @@ class NestViewModelTest { @Test fun onKickFlipsWasKickedAndDisconnects() = - runTest { + runVmTest { val fakeListener = FakeNestsListener() val vm = newViewModel { fakeListener } vm.connect() @@ -343,7 +376,7 @@ class NestViewModelTest { @Test fun onKickIsIdempotent() = - runTest { + runVmTest { val fakeListener = FakeNestsListener() val vm = newViewModel { fakeListener } vm.connect() @@ -377,7 +410,7 @@ class NestViewModelTest { @Test fun connectIsIdempotentWhileConnecting() = - runTest { + runVmTest { val fakeListener = FakeNestsListener() var connectCalls = 0 val vm = @@ -395,7 +428,7 @@ class NestViewModelTest { @Test fun disconnectReturnsToIdleAndClosesListener() = - runTest { + runVmTest { val fakeListener = FakeNestsListener() val vm = newViewModel { fakeListener } @@ -411,7 +444,7 @@ class NestViewModelTest { @Test fun connectingStepMapsThroughToUiStep() = - runTest { + runVmTest { val fakeListener = FakeNestsListener() val vm = newViewModel { fakeListener } @@ -429,7 +462,7 @@ class NestViewModelTest { @Test fun speakingNowClearsOnTeardown() = - runTest { + runVmTest { val fakeListener = FakeNestsListener() val vm = newViewModel { fakeListener } @@ -466,7 +499,7 @@ class NestViewModelTest { // Wire to the test's backgroundScope so close calls run during // the test rather than escaping to the real GlobalScope. cleanupScope = backgroundScope, - ) + ).also { createdVms.add(it) } private class FakeNestsListener : NestsListener { private val mutable = MutableStateFlow(NestsListenerState.Idle)