Skip to content

Avoid wallet persistence deadlocks#980

Open
tnull wants to merge 2 commits into
lightningdevkit:mainfrom
tnull:2026-07-fix-wallet-persistence-deadlock
Open

Avoid wallet persistence deadlocks#980
tnull wants to merge 2 commits into
lightningdevkit:mainfrom
tnull:2026-07-fix-wallet-persistence-deadlock

Conversation

@tnull

@tnull tnull commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator

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.

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
@tnull tnull requested a review from jkczyz July 14, 2026 07:23
@ldk-reviews-bot

ldk-reviews-bot commented Jul 14, 2026

Copy link
Copy Markdown

👋 Thanks for assigning @jkczyz as a reviewer!
I'll wait for their review and will help manage the review process.
Once they submit their review, I'll check if a second reviewer would be helpful.

@joostjager

Copy link
Copy Markdown
Contributor

Could it be useful to enable await_holding_lock in clippy to catch these issues?

@tnull

tnull commented Jul 14, 2026

Copy link
Copy Markdown
Collaborator Author

Could it be useful to enable await_holding_lock in clippy to catch these issues?

No, wouldn't have caught it, the issue (as usual) are block_ons

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>

@joostjager

Copy link
Copy Markdown
Contributor

Or maybe a Send bound on block_on to avoid the mutex from being captured? Or some other way to install a trip wire for this class of bugs?

Perhaps the clippy switch is useful independently as a future guard for other async issues?

@jkczyz jkczyz left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some Fable findings below, though none critical. See inline comment for one bug that was identified.

🤖 A few smaller observations (none blocking):

  • select_confirmed_utxos skips retry flushes: when no change output exists, persist_changeset isn't called at all (src/wallet/mod.rs:1004-1013), so a pending_change_set retained 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_connected hold the persister lock across update_payment_store's payment-store I/O, so user-facing calls like new_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_inner unchanged); constructor plumbing is pub(crate). No FFI or migration concerns that I could find.

Comment thread src/wallet/persist.rs Outdated
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

jkczyz@161c6c2

@tnull tnull Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
@tnull tnull requested a review from jkczyz July 15, 2026 09:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Wallet mutex held across Runtime::block_on(persist_async) can deadlock the entire node runtime

4 participants