fix(lite-backend): serialize clear and rescan with a running sync

clear_state_from wiped the wallet's blocks, txs and mempool maps without
taking sync_lock. The console `clear` and `rescan` commands run
concurrently with the refresh worker's sync (litelib_execute drops the
client mutex before a command runs), so a clear could land after a sync
had already taken its nullifier snapshot. scan_block then unwrapped a
note that was no longer in the wallet and panicked under the txs write
guard, which poisons the wallet until restart.

- clear_state_from now takes sync_lock first (lock order sync_lock ->
  wallet, as in do_save and do_sync). No caller holds sync_lock, so it
  cannot self-deadlock; do_rescan holds no lock when it clears.
- clear_state reads the birthday into a local before calling
  clear_state_from. Before, the wallet read guard was a temporary that
  lived across the call, which read-locks the wallet again: with a
  writer (a save, an import, a new address) queued in between, the
  writer-preferring RwLock on Linux and Windows could deadlock. Holding
  it while waiting for sync_lock would also have inverted the lock order
  against do_save.
- scan_block resolves each spent note with and_then instead of three
  unwraps. A spend of a note that is no longer in the wallet logs a
  warning and is skipped.

Flagged behaviour changes:
- console clear/rescan and the GUI rescan wait for an in-flight sync or
  save to finish instead of racing it;
- the recursive wallet read lock that could deadlock a concurrent save
  is gone;
- a spend of a vanished note now warns and is skipped instead of
  panicking; it is counted after the next rescan.
No ABI, symbol or wallet format change.

Tests: clear_state_from waits for sync_lock, clear_state holds no wallet
guard while it waits, neither leaks the lock, and a 200x clear/write
loop finishes within a 20 s watchdog (a regression guard only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-12 13:22:00 -05:00
co-authored by Claude Opus 5.5
parent 0a72b47ca6
commit 026d0e92a6
2 changed files with 106 additions and 11 deletions
@@ -2371,18 +2371,21 @@ pub fn scan_full_mempool_tx(&self, tx: &Transaction, height: i32, _datetime: u64
//info!("Txid {} belongs to wallet", tx.txid);
for spend in &tx.shielded_spends {
let txid = nfs
// The note can be gone if the wallet was cleared or reorged after `nfs` was taken. Never panic
// here (it would poison `txs`): skip the spend with a warning; the next rescan counts it.
let spent_note = match nfs
.iter()
.find(|(nf, _, _)| &nf[..] == &spend.nf[..])
.unwrap()
.2;
let mut spent_note = txs
.get_mut(&txid)
.unwrap()
.notes
.iter_mut()
.find(|nd| &nd.nullifier[..] == &spend.nf[..])
.unwrap();
.and_then(|(_, _, txid)| txs.get_mut(txid))
.and_then(|wtx| wtx.notes.iter_mut().find(|nd| &nd.nullifier[..] == &spend.nf[..]))
{
Some(nd) => nd,
None => {
warn!("Block {}: tx {} spends a note that is no longer in the wallet; skipping it (a rescan counts it)",
block_data.height, tx.txid);
continue;
}
};
// Mark the note as spent, and remove the unconfirmed part of it
info!("Marked a note as spent");