Avoid wallet persistence deadlocks#980
Conversation
Release the synchronous wallet lock before awaiting store I/O while serializing wallet mutations with an asynchronous persistence gate. Retain failed change sets so retries preserve BDK persistence semantics. Co-Authored-By: HAL 9000
|
👋 Thanks for assigning @jkczyz as a reviewer! |
|
Could it be useful to enable |
No, wouldn't have caught it, the issue (as usual) are main ~/workspace/ldk-node> cargo clippy -- -A warnings -W clippy::await_holding_lock
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.21s
main ~/workspace/ldk-node> |
|
Or maybe a Perhaps the clippy switch is useful independently as a future guard for other async issues? |
jkczyz
left a comment
There was a problem hiding this comment.
Some Fable findings below, though none critical. See inline comment for one bug that was identified.
🤖 A few smaller observations (none blocking):
select_confirmed_utxosskips retry flushes: when no change output exists,persist_changesetisn't called at all (src/wallet/mod.rs:1004-1013), so apending_change_setretained from an earlier failure isn't retried at that opportunity. Correct as written — just a missed retry point.- Persister lock scope:
apply_update/apply_mempool_txs/block_connectedhold the persister lock acrossupdate_payment_store's payment-store I/O, so user-facing calls likenew_address()queue behind a full sync round's store writes. Seems acceptable (it preserves event ordering against wallet state), but worth being deliberate about. - Relaxed atomicity is intentional but observable: readers (e.g.
list_balances) can now see wallet state whose payment-store counterpart hasn't been written yet — previously the wallet lock made these atomic (that atomicity was the deadlock). I didn't find any code relying on the old atomicity. - Commit structure: one commit mixes the mechanical sync→async conversion, the semantic persistence-model change (
take_staged+ retention), and tests. Two or three commits (persister model → wallet/caller conversion → tests) would be easier to review safely, since the risky part is a few dozen lines inside ~900 changed ones. - API/compatibility: public API signatures unchanged; on-disk format untouched (
persist_innerunchanged); constructor plumbing ispub(crate). No FFI or migration concerns that I could find.
| pub(super) async fn persist_changeset( | ||
| &mut self, change_set: ChangeSet, | ||
| ) -> Result<(), std::io::Error> { | ||
| let mut pending_change_set = std::mem::take(&mut self.pending_change_set); |
There was a problem hiding this comment.
Fable caught a bug here:
Stopping the node while a wallet persist is in flight — routinely in bitcoind mode, where stop() drops the in-progress poll, or on any backend when a slow store (e.g. VSS on flaky mobile connectivity) makes shutdown hit its 5s/30s abort timeouts — silently destroys the changeset that was already taken from the wallet, so after restart the wallet can, for example, hand out an already-used address.
Here's a failing test:
There was a problem hiding this comment.
My bad, I think I introduced this bug while trying to avoid some clones. I guess we can't get away with not cloning the pending changeset here. Will shortly push a fixup.
Keep wallet changes queued until persistence completes so dropped futures cannot discard state already taken from BDK. Cover cancellation during store I/O with a regression test. Co-Authored-By: HAL 9000
Fixes #978.
Release the synchronous wallet lock before awaiting store I/O while serializing wallet mutations with an asynchronous persistence gate. Retain failed change sets so retries preserve BDK persistence semantics.