Skip to content

Commit e122d66

Browse files
committed
Make Zcash recovery test deterministic: bound self-heal restart drains
The rewritten ZcashAdapterCorruptionRecoveryTest hung under advanceUntilIdle() because the mock synchronizer's status/transactions flows never self-heal: - flowCorruption_triggersRecovery re-subscribed to a permanently throwing allTransactions flow after each recovery -> infinite recovery loop. Made the corruption one-shot so the recovered synchronizer resubscribes to a healthy flow. - The STOPPED self-heal tests left the shared status flow at STOPPED, so every resubscription re-observed STOPPED and rescheduled another restart forever. Replaced unbounded advanceUntilIdle() with a precise runCurrent/advanceTimeBy drain to the first restart, then moved status off STOPPED to cancel the pending self-heal job. All test cases and assertions are preserved.
1 parent b5333e9 commit e122d66

1 file changed

Lines changed: 80 additions & 10 deletions

File tree

app/src/test/java/cash/p/terminal/core/adapters/zcash/ZcashAdapterCorruptionRecoveryTest.kt

Lines changed: 80 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)