wake a silent payments history waiter when a failed widening restores a completed scan, rather than leaving it parked for the session
What changed, and why it matters
This commit fixes a bug in Sparrow Wallet's silent-payments scanning cache. If a background scan had already finished, then a later 'widening' request to extend the scan failed and rolled back, any history request that arrived during the failed widening would wait forever instead of being told the scan was already complete. The fix makes the rollback path signal those waiting threads so the wallet history can return promptly. It is a reliability/availability bug, not a direct theft-of-funds vulnerability.
Treat as a normal bug-fix commit. No urgent security response is indicated, but ensure the fix is included in the next release so silent-payments wallet history remains responsive after failed scan widenings.
Security signals we found
Concurrency / condition-variable waiter starvation
Silent-payments history lookup hang / wallet UI unresponsiveness
Failure-recovery path missing signal on rollback
Evidence from the diff
SilentPaymentsScanCache tracks a BIP352-style silent-payments scan. When a widening RPC is issued, restartScan() resets state so the cache looks like it is scanning again. If the RPC fails, restoreFromSnapshot() rolls back to the pre-widening state. Before this commit, restoreFromSnapshot() only signalled subscriptionComplete, not scanComplete. A ‘history’ waiter that began waiting after restartScan() (because the scan appeared in-progress) would therefore remain parked on scanComplete if the restored scan was already completed and no other completion path existed. The patch adds scanComplete.signalAll() in restoreFromSnapshot() and updates comments/tests accordingly.
Changed components
src/main/java/com/sparrowwallet/sparrow/net/SilentPaymentsScanCache.javasrc/test/java/com/sparrowwallet/sparrow/net/SilentPaymentsScanCacheTest.javaInspect captured patch +100 / −12
### src/main/java/com/sparrowwallet/sparrow/net/SilentPaymentsScanCache.java
@@ -95,14 +95,17 @@ void complete() {
* {@link #setServerStart} call (after the widening RPC returns) will signal them.</li>
* <li><b>scanComplete</b> waiters require {@code isScanning()}. After restartScan, isScanning is still true
* (transitioning {@code SCANNING → SCANNING} for mid-scan widening, or {@code COMPLETED → SCANNING} for
- * post-scan widening — and in the latter case no scanComplete waiters can exist because they would have
- * returned when the prior {@link #complete} signalled them). Existing waiters should keep waiting; they'll
- * wake when the new scan reaches a terminal state via {@link #complete} or {@link #cancel}.</li>
+ * post-scan widening — and in the latter case no scanComplete waiters from before the restart can exist because
+ * they would have returned when the prior {@link #complete} signalled them). Existing waiters should keep waiting;
+ * they'll wake when the new scan reaches a terminal state via {@link #complete} or {@link #cancel}. Waiters can
+ * also arrive after the restart, since a post-scan widening makes a completed scan look in progress again while
+ * the widening RPC is outstanding; they wait on the same terms, and are woken as below if the RPC fails.</li>
* </ul>
* <b>Maintenance note:</b> if a future change adds a new wait condition that depends on state-not-being-SCANNING,
* serverStart-being-non-null, or entries being non-empty, this method must be updated to signal the new condition.
- * The widening RPC's failure path is covered separately — {@link #cancel} fires both signals — so callers do not
- * rely on restartScan having signalled.
+ * The widening RPC's failure path is covered separately, so callers do not rely on restartScan having signalled:
+ * where no other holder remains {@link #cancel} fires both signals, and where others do {@link #restoreFromSnapshot}
+ * fires both, which is what wakes a scanComplete waiter that arrived after the restart of a completed scan.
*/
void restartScan() {
assert lock.isHeldByCurrentThread();
@@ -231,7 +234,9 @@ Snapshot captureSnapshot() {
/**
* Restores the cache's state from a previously captured {@link Snapshot} and signals condition
* waiters whose conditions may have become re-evaluable. Used by the widening-failure recovery path
- * to restore an in-progress scan when the widening RPC fails but other holders still depend on the cache.
+ * to restore the scan the widening replaced, whether still in progress or completed, when the widening RPC fails
+ * but other holders still depend on the cache. Restoring a completed scan finishes it again for any waiter that
+ * arrived while the widening was outstanding, so both conditions are signalled.
*/
List<Notified> restoreFromSnapshot(Snapshot snapshot) {
assert lock.isHeldByCurrentThread();
@@ -246,10 +251,11 @@ List<Notified> restoreFromSnapshot(Snapshot snapshot) {
serverStart = snapshot.serverStart;
entries.clear();
entries.addAll(snapshot.entries);
- //Wake hold-side waiters who may have been blocked on serverStart==null during the failed widening.
- //scanComplete waiters whose state-condition was unchanged during the widening don't need a signal,
- //but signalling is harmless (they re-check isScanning() and re-await if still scanning).
+ //Wake hold-side waiters who may have been blocked on serverStart==null during the failed widening, and history
+ //waiters who began waiting after the widening reset a completed scan: restoring that scan finishes it again, and
+ //nothing else will. Waiters on a scan still in progress re-check isScanning() and keep waiting for its completion
subscriptionComplete.signalAll();
+ scanComplete.signalAll();
//A notification held while the widening RPC was in flight belongs to the subscription just restored, that RPC
//having established nothing, so there is no response for the server to have written them before
### src/test/java/com/sparrowwallet/sparrow/net/SilentPaymentsScanCacheTest.java
@@ -4,10 +4,9 @@
import java.util.Collections;
import java.util.List;
+import java.util.concurrent.*;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertFalse;
-import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assertions.*;
public class SilentPaymentsScanCacheTest {
private static final int SERVER_START = 800000;
@@ -246,6 +245,89 @@ public void testWideningFailureAppliesNotificationsHeldForTheRestoredSubscriptio
assertTrue(replayed.getFirst().historyUpdated(), "History arriving after the restored scan completed must be reported");
}
+ private Future<List<SilentPaymentsTx>> awaitScan(ExecutorService executor, SilentPaymentsScanCache cache) throws InterruptedException {
+ CountDownLatch waiting = new CountDownLatch(1);
+ Future<List<SilentPaymentsTx>> result = executor.submit(() -> {
+ cache.lock();
+ try {
+ while(cache.isScanning()) {
+ waiting.countDown();
+ cache.awaitScanComplete();
+ }
+ return cache.snapshotEntries();
+ } finally {
+ cache.unlock();
+ }
+ });
+
+ //The waiter counts down while holding the lock, so taking the lock after this returns means it is inside the await
+ assertTrue(waiting.await(5, TimeUnit.SECONDS), "The waiter must reach the scan wait");
+ return result;
+ }
+
+ private SilentPaymentsScanCache.Snapshot widen(SilentPaymentsScanCache cache) {
+ cache.lock();
+ try {
+ SilentPaymentsScanCache.Snapshot snapshot = cache.captureSnapshot();
+ cache.restartScan();
+ return snapshot;
+ } finally {
+ cache.unlock();
+ }
+ }
+
+ private void restore(SilentPaymentsScanCache cache, SilentPaymentsScanCache.Snapshot snapshot) {
+ cache.lock();
+ try {
+ cache.restoreFromSnapshot(snapshot);
+ } finally {
+ cache.unlock();
+ }
+ }
+
+ @Test
+ public void testWideningFailureWakesHistoryWaiterOnRestoredCompletedScan() throws Exception {
+ SilentPaymentsScanCache cache = new SilentPaymentsScanCache();
+ setServerStart(cache, SERVER_START);
+ applyOrHold(cache, SERVER_START, 1.0, history("scanned"));
+ assertTrue(cache.isCompleted());
+
+ ExecutorService executor = Executors.newSingleThreadExecutor();
+ try {
+ SilentPaymentsScanCache.Snapshot snapshot = widen(cache);
+
+ //A history request made while the widening RPC is outstanding sees the reset scan and waits for it
+ Future<List<SilentPaymentsTx>> result = awaitScan(executor, cache);
+ restore(cache, snapshot);
+
+ assertEquals(List.of("scanned"), result.get(5, TimeUnit.SECONDS).stream().map(tx -> tx.tx_hash).toList(),
+ "Restoring a completed scan must wake a waiter that began waiting after the widening reset it");
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
+ @Test
+ public void testWideningFailureLeavesHistoryWaiterOnRestoredScanInProgress() throws Exception {
+ SilentPaymentsScanCache cache = new SilentPaymentsScanCache();
+ setServerStart(cache, SERVER_START);
+ applyOrHold(cache, SERVER_START, 0.5, history("scanned"));
+
+ ExecutorService executor = Executors.newSingleThreadExecutor();
+ try {
+ SilentPaymentsScanCache.Snapshot snapshot = widen(cache);
+ Future<List<SilentPaymentsTx>> result = awaitScan(executor, cache);
+ restore(cache, snapshot);
+
+ assertThrows(TimeoutException.class, () -> result.get(200, TimeUnit.MILLISECONDS), "A waiter on a scan still in progress must keep waiting");
+
+ applyOrHold(cache, SERVER_START, 1.0, history("completed"));
+ assertEquals(List.of("scanned", "completed"), result.get(5, TimeUnit.SECONDS).stream().map(tx -> tx.tx_hash).toList());
+ } finally {
+ executor.shutdownNow();
+ }
+ }
+
@Test
public void testCancelledCacheHoldsNothingFurther() {
SilentPaymentsScanCache cache = new SilentPaymentsScanCache();Why this scored 27/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.