@@ -298,7 +298,13 @@ class ZcashAdapterCorruptionRecoveryTest {
298298 val corruptFlow = flow<List <TransactionOverview >> {
299299 throw SQLiteDatabaseCorruptException (" database disk image is malformed" )
300300 }
301- every { mockSynchronizer.allTransactions } returns corruptFlow
301+ // One-shot corruption: the first collection throws (triggering recovery), then the recovered
302+ // synchronizer resubscribes to a healthy flow. Otherwise recovery re-subscribes to the same
303+ // permanently corrupt flow and loops forever, hanging advanceUntilIdle().
304+ var allTransactionsReads = 0
305+ every { mockSynchronizer.allTransactions } answers {
306+ if (allTransactionsReads++ == 0 ) corruptFlow else allTransactionsFlow
307+ }
302308
303309 adapter = createAdapter()
304310 adapter.start()
@@ -967,8 +973,13 @@ class ZcashAdapterCorruptionRecoveryTest {
967973 adapter.start()
968974 advanceAndAssertState { it is AdapterState .Syncing }
969975
976+ // Bounded drain to the first restart: the mock synchronizer's status flow never changes
977+ // on its own, so an unbounded advanceUntilIdle() here would busy-loop forever through
978+ // repeated self-heal restarts (each resubscription re-observes STOPPED and reschedules).
970979 statusFlow.value = Synchronizer .Status .STOPPED
971- advanceUntilIdle()
980+ runCurrent()
981+ advanceTimeBy(20L )
982+ runCurrent()
972983
973984 coVerify {
974985 Synchronizer .new(
@@ -977,6 +988,12 @@ class ZcashAdapterCorruptionRecoveryTest {
977988 setup = any(), isTorEnabled = any(), isExchangeRateEnabled = any()
978989 )
979990 }
991+
992+ // The resubscription above re-observes the still-STOPPED status and schedules another
993+ // self-heal restart. Move off STOPPED so that pending job is cancelled instead of firing
994+ // indefinitely (and potentially hanging runTest's own end-of-test scheduler drain).
995+ statusFlow.value = Synchronizer .Status .SYNCED
996+ advanceAndAssertState { it is AdapterState .Synced }
980997 }
981998
982999 @Test
@@ -998,8 +1015,13 @@ class ZcashAdapterCorruptionRecoveryTest {
9981015 adapter.start()
9991016 advanceAndAssertState { it is AdapterState .Syncing }
10001017
1018+ // Bounded drain to the first restart (see stopped_whileForeground_restartsSynchronizer):
1019+ // an unbounded advanceUntilIdle() here would busy-loop forever, since the resubscription
1020+ // keeps re-observing the still-STOPPED status and rescheduling.
10011021 statusFlow.value = Synchronizer .Status .STOPPED
1002- advanceUntilIdle()
1022+ runCurrent()
1023+ advanceTimeBy(20L )
1024+ runCurrent()
10031025 coVerify {
10041026 Synchronizer .new(
10051027 context = any(), zcashNetwork = any(), alias = any(),
@@ -1011,8 +1033,11 @@ class ZcashAdapterCorruptionRecoveryTest {
10111033 statusFlow.value = Synchronizer .Status .SYNCED
10121034 advanceAndAssertState { it is AdapterState .Synced }
10131035
1036+ // SYNCED reset the backoff attempt, so the next restart is due after 20ms again.
10141037 statusFlow.value = Synchronizer .Status .STOPPED
1015- advanceUntilIdle()
1038+ runCurrent()
1039+ advanceTimeBy(20L )
1040+ runCurrent()
10161041 coVerify(atLeast = 2 ) {
10171042 Synchronizer .new(
10181043 context = any(), zcashNetwork = any(), alias = any(),
@@ -1021,6 +1046,11 @@ class ZcashAdapterCorruptionRecoveryTest {
10211046 )
10221047 }
10231048 assertTrue(" Expected at least 2 restarts, got $restartCount " , restartCount >= 2 )
1049+
1050+ // Move off STOPPED so the pending self-heal restart scheduled by the resubscription
1051+ // above is cancelled instead of firing indefinitely.
1052+ statusFlow.value = Synchronizer .Status .SYNCED
1053+ advanceAndAssertState { it is AdapterState .Synced }
10241054 }
10251055
10261056 @Test
@@ -1040,9 +1070,14 @@ class ZcashAdapterCorruptionRecoveryTest {
10401070 adapter.start()
10411071 advanceAndAssertState { it is AdapterState .Syncing }
10421072
1043- // First STOPPED -> restart cycle.
1073+ // First STOPPED -> restart cycle. Bounded drain (see
1074+ // stopped_whileForeground_restartsSynchronizer): an unbounded advanceUntilIdle() here
1075+ // would busy-loop forever, since the resubscription keeps re-observing the still-STOPPED
1076+ // status and rescheduling.
10441077 statusFlow.value = Synchronizer .Status .STOPPED
1045- advanceUntilIdle()
1078+ runCurrent()
1079+ advanceTimeBy(20L )
1080+ runCurrent()
10461081 coVerify {
10471082 Synchronizer .new(
10481083 context = any(), zcashNetwork = any(), alias = any(),
@@ -1052,10 +1087,14 @@ class ZcashAdapterCorruptionRecoveryTest {
10521087 }
10531088
10541089 // Move off STOPPED (without resetting backoff, unlike SYNCING/SYNCED) so the next
1055- // STOPPED value change is a genuinely new emission, driving a second restart cycle.
1090+ // STOPPED value change is a genuinely new emission, driving a second restart cycle. The
1091+ // resubscription after the first restart already scheduled a second restart job with a
1092+ // 40ms backoff (attempt 1); advance exactly to it instead of draining unbounded.
10561093 statusFlow.value = Synchronizer .Status .DISCONNECTED
10571094 statusFlow.value = Synchronizer .Status .STOPPED
1058- advanceUntilIdle()
1095+ runCurrent()
1096+ advanceTimeBy(40L )
1097+ runCurrent()
10591098 coVerify(atLeast = 2 ) {
10601099 Synchronizer .new(
10611100 context = any(), zcashNetwork = any(), alias = any(),
@@ -1064,6 +1103,8 @@ class ZcashAdapterCorruptionRecoveryTest {
10641103 )
10651104 }
10661105
1106+ // Moving to SYNCED cancels the pending self-heal restart scheduled by the resubscription
1107+ // above (instead of letting it fire indefinitely) and resets the backoff attempt.
10671108 statusFlow.value = Synchronizer .Status .SYNCED
10681109 advanceAndAssertState { it is AdapterState .Synced }
10691110
@@ -1141,8 +1182,14 @@ class ZcashAdapterCorruptionRecoveryTest {
11411182 adapter.startForPolling()
11421183 advanceAndAssertState { it is AdapterState .Syncing }
11431184
1185+ // Bounded drain (see stopped_whileForeground_restartsSynchronizer): the mocked
1186+ // Synchronizer.new() returns the same mockSynchronizer whose status is still STOPPED, so
1187+ // an unbounded advanceUntilIdle() here would busy-loop forever on self-perpetuating
1188+ // restarts.
11441189 statusFlow.value = Synchronizer .Status .STOPPED
1145- advanceUntilIdle()
1190+ runCurrent()
1191+ advanceTimeBy(20L )
1192+ runCurrent()
11461193
11471194 coVerify {
11481195 Synchronizer .new(
@@ -1151,6 +1198,11 @@ class ZcashAdapterCorruptionRecoveryTest {
11511198 setup = any(), isTorEnabled = any(), isExchangeRateEnabled = any()
11521199 )
11531200 }
1201+
1202+ // Neutralize the still-armed self-heal chain so runTest's implicit end-of-test drain
1203+ // doesn't hang: moving to SYNCED cancels the pending restart job and resets the backoff.
1204+ statusFlow.value = Synchronizer .Status .SYNCED
1205+ advanceAndAssertState { it is AdapterState .Synced }
11541206 }
11551207
11561208 @Test
@@ -1162,8 +1214,14 @@ class ZcashAdapterCorruptionRecoveryTest {
11621214 adapter.start()
11631215 advanceAndAssertState { it is AdapterState .Syncing }
11641216
1217+ // Bounded drain (see stopped_whileForeground_restartsSynchronizer): the mocked
1218+ // Synchronizer.new() returns the same mockSynchronizer whose status is still STOPPED, so
1219+ // an unbounded advanceUntilIdle() here would busy-loop forever on self-perpetuating
1220+ // restarts.
11651221 statusFlow.value = Synchronizer .Status .STOPPED
1166- advanceUntilIdle()
1222+ runCurrent()
1223+ advanceTimeBy(20L )
1224+ runCurrent()
11671225
11681226 coVerify {
11691227 Synchronizer .new(
@@ -1172,6 +1230,11 @@ class ZcashAdapterCorruptionRecoveryTest {
11721230 setup = any(), isTorEnabled = any(), isExchangeRateEnabled = any()
11731231 )
11741232 }
1233+
1234+ // Neutralize the still-armed self-heal chain so runTest's implicit end-of-test drain
1235+ // doesn't hang: moving to SYNCED cancels the pending restart job and resets the backoff.
1236+ statusFlow.value = Synchronizer .Status .SYNCED
1237+ advanceAndAssertState { it is AdapterState .Synced }
11751238 }
11761239
11771240 @Test
@@ -1216,6 +1279,13 @@ class ZcashAdapterCorruptionRecoveryTest {
12161279 statusFlow.value = Synchronizer .Status .STOPPED
12171280 advanceUntilIdle()
12181281
1282+ // Move the shared status off STOPPED before letting recovery finish installing its new
1283+ // synchronizer. Otherwise recovery's own resubscription (which attaches once
1284+ // createNewSynchronizer() completes, after recovering is cleared) would immediately
1285+ // re-observe the stale STOPPED value and arm a genuine competing restart - both hanging
1286+ // the drain below and breaking the exactly=1 assertion. Flipping to SYNCING here only
1287+ // makes the resubscription call the harmless resetRestart() no-op.
1288+ statusFlow.value = Synchronizer .Status .SYNCING
12191289 releaseGetAccounts.complete(Unit )
12201290 advanceUntilIdle()
12211291
0 commit comments