You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The on-chain Wallet holds its inner (and persister) std::sync::Mutex across Runtime::block_on(locked_wallet.persist_async(...)) at 11 sites in src/wallet/mod.rs (e.g. apply_update, <Wallet as Listen>::block_connected, get_change_script_inner, select_confirmed_utxos_inner, get_new_address). Other async tasks on the node runtime acquire that same mutex synchronously from worker threads (e.g. <Wallet as Listen>::block_connected during bitcoind-RPC chain sync, list_confirmed_utxos_inner / get_change_script_inner from the anchor BumpTransactionEventHandler via the async WalletSource).
When the runtime is short on worker threads, this forms a three-party deadlock that halts the entire runtime:
Task A locks the wallet mutex, then parks in Runtime::block_on(persist_async) (block_in_place + Handle::block_on), waiting on a KV store write future.
Task B, polled on a runtime worker, blocks synchronously on the wallet mutex held by A, wedging its worker core. With one worker (worker_threads = 1, as most integration tests configure — Runtime::new picks up the ambient handle) the runtime is now fully halted: no task anywhere can be polled.
The write future A is waiting on is queued behind a shared async primitive in the store's write path whose current holder is a task on that same runtime. That task can never be re-polled because of (2), so it never releases the primitive; A never finishes persisting; A never releases the wallet mutex; B never unblocks.
Runtime::block_on's block_in_place hands the worker core off before parking, so A itself doesn't wedge the runtime — but it can't prevent B from doing so, and it holds the wallet mutex the whole time. This is the same failure class already described on StoreRuntime in src/runtime.rs, but the coupling resource here isn't a store's I/O driver — it's any tokio::sync primitive in any KVStore write path — so the store-runtime isolation doesn't cover it.
Evidence
Observed as an indefinite hang of a WIP integration test (rbf_splice_payment_reverts_after_deep_reorg, currently part of #962; it deep-reorgs an anchor channel into a force-close) when random_chain_source lands on TestChainSource::BitcoindRpcSync. Reproduced on the first or second try by pinning the chain source; macOS sample captured two hangs that are the same cycle with the roles of legs 1 and 2 swapped:
Hang 1 (legs: event handler holds the lock, chain sync wedges the core):
Chain-sync task, owning the only worker core, blocked forever: BitcoindChainSource::poll_and_update_listeners_inner → ChainNotifier::connect_blocks → <ChainListener as Listen>::block_connected → <Wallet as Listen>::block_connected → Mutex::lock on the wallet inner mutex.
Hang 2 (roles swapped):
Chain-sync task, off-core, parked forever: <Wallet as Listen>::block_connected (locks inner, applies the block, locks persister) → Runtime::block_on(persist_async) → parked.
Background-processor task, owning the only worker core, blocked forever: BumpTransactionEventHandler::handle_channel_close → select_confirmed_utxos → list_confirmed_utxos → Wallet::list_confirmed_utxos_inner → Mutex::lock on the wallet inner mutex.
In both samples the process contained only 4 threads: the two above plus the test-harness pair — with the single worker core wedged, the runtime had no thread left that could poll anything. (tokio 1.52 runs worker cores on blocking-pool threads and migrates them on block_in_place; previous carrier threads idle out after ~10s.)
Leg 3 evidence. In the test environment the coupling primitive is TestSyncStore's serializer: tokio::sync::RwLock<()> (tests/common/mod.rs), whose write guard is held across .awaits (spawn_blocking hops) inside every store op. Instrumenting all serializer acquisitions with a watchdog (std thread, so it keeps running while the runtime is dead; dumps ops outstanding >20s) captured a third hang, same shape as hang 1, with a stable set of exactly three stuck ops for the entire 7-minute observation window:
op702 is a channel-monitor persist task (spawned onto the node runtime via RuntimeSpawner, i.e. chainmonitor::AsyncPersister → FutureSpawner): it acquired the serializer write lock, hit its next .await, and can never be re-polled — the only worker core is wedged on the wallet mutex. op704 is the BDK wallet persist issued from inside get_change_script_inner's block_on(persist_async) — the wallet-mutex holder — queued behind it forever. (op703 is another monitor persist in the FIFO between them.) All three legs of the cycle are visible in this single capture.
Production stores are exposed to the same cycle whenever a write future awaits a primitive held by a task on the node runtime; and with an owned runtime (multiple workers) the wedge requires the wallet mutex to block that many workers at once — harder, but the ingredients (per-block Listen calls, force-close bump events, API calls all taking the wallet mutex) all rise together during reorg/force-close storms, and users can hand ldk-node a small external runtime via Builder.
Why only bitcoind sync hits it in tests
<Wallet as Listen>::block_connected is only driven by the bitcoind chain source (ChainNotifier-based sync); it both (a) takes the wallet mutex synchronously on a worker for every connected block and (b) itself persists under that mutex (src/wallet/mod.rsListen impl). Esplora/Electrum share the lock-across-block_on pattern via apply_update, but don't have the per-block synchronous listener path, so the contention window is far smaller.
Repro recipe
Pin random_chain_source to TestChainSource::BitcoindRpcSync.
Run an integration test that force-closes an anchor channel while blocks are being connected (we hit it with the deep-reorg splice test from Fix funding-payment reclassification downgrade and deep-reorg duplication #962; the deep reorg produces a BumpTransactionEvent::ChannelClose concurrently with Listen::block_connected calls and a stream of monitor persists). It hung on the first try, twice in a row, and also hangs at earlier points in the test (any overlap of a wallet persist, a wallet-mutex acquisition on the worker, and an in-flight store write suffices).
sample <pid> shows the stacks above.
When this was introduced
The cycle assembled over three changes, all long on main before the test that exposed it:
ed12e65 ("Bump to LDK main (4e32d85)", 2025-02) — the anchor BumpTransactionEventHandler starts driving the wallet through the async WalletSource (get_change_script_inner, list_confirmed_utxos_inner).
4879002 ("Introduce Runtime object allowing to detect outer runtime context", 2025-05) — Runtime::block_on becomes block_in_place + Handle::block_on on the ambient runtime.
5ac2ed1 ("Use BDK's async wallet persister") and b3f947c ("Move DataStore persistence onto async KV storage"), both 2026-06-03 — the final ingredient: wallet and payment-store persistence under the wallet mutex became async, bridged with block_on. Before this migration the wallet's critical sections were synchronous I/O that always completed on their own, so leg 3 of the cycle could not exist.
So the deadlock has been reachable since the June 2026 async-storage migration. #962 itself touches none of the involved code (verified against its head, f01e83e) — its new test merely lines the trigger up reliably, so hangs on that PR's CI (roughly 1 in 3 runs draw the bitcoind chain source) are this pre-existing bug, not the PR.
Where a fix could go
The root invariant violation is holding the wallet inner/persister mutexes across block_on(persist_async) (leg 1). E.g. extracting the staged changeset and persisting outside the wallet lock (the persister already merges changesets internally), or making these paths natively async so they await instead of block_on. This breaks the cycle at its strongest link.
Narrower mitigations look tempting but don't fully close it: wrapping the bitcoind listener dispatch in tokio::task::block_in_place unwedges hang 1, but hang 2's core-wedger is the async WalletSource path (list_confirmed_utxos_inner) — every synchronous wallet-mutex acquisition reachable from a runtime worker would need the same treatment.
Raising test worker_threads merely lowers the probability; the cycle only needs all workers to be blocked on the wallet mutex at once.
Environment
ldk-node main (deadlock-relevant code identical at f04fa4d), tokio 1.52.3, LDK rev 3dfcc4cca186, macOS arm64. Full sample outputs and the watchdog patch available on request.
This bug was investigated and this report drafted with an AI assistant (Claude Code); the stacks, instrumentation output, and reproduction are from real local runs.
Summary
The on-chain
Walletholds itsinner(andpersister)std::sync::MutexacrossRuntime::block_on(locked_wallet.persist_async(...))at 11 sites insrc/wallet/mod.rs(e.g.apply_update,<Wallet as Listen>::block_connected,get_change_script_inner,select_confirmed_utxos_inner,get_new_address). Other async tasks on the node runtime acquire that same mutex synchronously from worker threads (e.g.<Wallet as Listen>::block_connectedduring bitcoind-RPC chain sync,list_confirmed_utxos_inner/get_change_script_innerfrom the anchorBumpTransactionEventHandlervia the asyncWalletSource).When the runtime is short on worker threads, this forms a three-party deadlock that halts the entire runtime:
Runtime::block_on(persist_async)(block_in_place+Handle::block_on), waiting on a KV store write future.worker_threads = 1, as most integration tests configure —Runtime::newpicks up the ambient handle) the runtime is now fully halted: no task anywhere can be polled.Runtime::block_on'sblock_in_placehands the worker core off before parking, so A itself doesn't wedge the runtime — but it can't prevent B from doing so, and it holds the wallet mutex the whole time. This is the same failure class already described onStoreRuntimeinsrc/runtime.rs, but the coupling resource here isn't a store's I/O driver — it's anytokio::syncprimitive in anyKVStorewrite path — so the store-runtime isolation doesn't cover it.Evidence
Observed as an indefinite hang of a WIP integration test (
rbf_splice_payment_reverts_after_deep_reorg, currently part of #962; it deep-reorgs an anchor channel into a force-close) whenrandom_chain_sourcelands onTestChainSource::BitcoindRpcSync. Reproduced on the first or second try by pinning the chain source; macOSsamplecaptured two hangs that are the same cycle with the roles of legs 1 and 2 swapped:Hang 1 (legs: event handler holds the lock, chain sync wedges the core):
EventHandler::handle_event→BumpTransactionEventHandler::handle_channel_close→select_confirmed_utxos→WalletSource::get_change_script→Wallet::get_change_script_inner(locksinner, thenpersister) →Runtime::block_on→tokio::task::block_in_place→Handle::block_on(persist_async)→CachedParkThread::park.BitcoindChainSource::poll_and_update_listeners_inner→ChainNotifier::connect_blocks→<ChainListener as Listen>::block_connected→<Wallet as Listen>::block_connected→Mutex::lockon the walletinnermutex.Hang 2 (roles swapped):
<Wallet as Listen>::block_connected(locksinner, applies the block, lockspersister) →Runtime::block_on(persist_async)→ parked.BumpTransactionEventHandler::handle_channel_close→select_confirmed_utxos→list_confirmed_utxos→Wallet::list_confirmed_utxos_inner→Mutex::lockon the walletinnermutex.In both samples the process contained only 4 threads: the two above plus the test-harness pair — with the single worker core wedged, the runtime had no thread left that could poll anything. (tokio 1.52 runs worker cores on blocking-pool threads and migrates them on
block_in_place; previous carrier threads idle out after ~10s.)Leg 3 evidence. In the test environment the coupling primitive is
TestSyncStore'sserializer: tokio::sync::RwLock<()>(tests/common/mod.rs), whose write guard is held across.awaits (spawn_blockinghops) inside every store op. Instrumenting all serializer acquisitions with a watchdog (std thread, so it keeps running while the runtime is dead; dumps ops outstanding >20s) captured a third hang, same shape as hang 1, with a stable set of exactly three stuck ops for the entire 7-minute observation window:op702 is a channel-monitor persist task (spawned onto the node runtime via
RuntimeSpawner, i.e.chainmonitor::AsyncPersister→FutureSpawner): it acquired the serializer write lock, hit its next.await, and can never be re-polled — the only worker core is wedged on the wallet mutex. op704 is the BDK wallet persist issued from insideget_change_script_inner'sblock_on(persist_async)— the wallet-mutex holder — queued behind it forever. (op703 is another monitor persist in the FIFO between them.) All three legs of the cycle are visible in this single capture.Production stores are exposed to the same cycle whenever a write future awaits a primitive held by a task on the node runtime; and with an owned runtime (multiple workers) the wedge requires the wallet mutex to block that many workers at once — harder, but the ingredients (per-block
Listencalls, force-close bump events, API calls all taking the wallet mutex) all rise together during reorg/force-close storms, and users can hand ldk-node a small external runtime viaBuilder.Why only bitcoind sync hits it in tests
<Wallet as Listen>::block_connectedis only driven by the bitcoind chain source (ChainNotifier-based sync); it both (a) takes the wallet mutex synchronously on a worker for every connected block and (b) itself persists under that mutex (src/wallet/mod.rsListenimpl). Esplora/Electrum share the lock-across-block_onpattern viaapply_update, but don't have the per-block synchronous listener path, so the contention window is far smaller.Repro recipe
random_chain_sourcetoTestChainSource::BitcoindRpcSync.BumpTransactionEvent::ChannelCloseconcurrently withListen::block_connectedcalls and a stream of monitor persists). It hung on the first try, twice in a row, and also hangs at earlier points in the test (any overlap of a wallet persist, a wallet-mutex acquisition on the worker, and an in-flight store write suffices).sample <pid>shows the stacks above.When this was introduced
The cycle assembled over three changes, all long on
mainbefore the test that exposed it:BumpTransactionEventHandlerstarts driving the wallet through the asyncWalletSource(get_change_script_inner,list_confirmed_utxos_inner).Runtimeobject allowing to detect outer runtime context", 2025-05) —Runtime::block_onbecomesblock_in_place+Handle::block_onon the ambient runtime.block_on. Before this migration the wallet's critical sections were synchronous I/O that always completed on their own, so leg 3 of the cycle could not exist.So the deadlock has been reachable since the June 2026 async-storage migration. #962 itself touches none of the involved code (verified against its head, f01e83e) — its new test merely lines the trigger up reliably, so hangs on that PR's CI (roughly 1 in 3 runs draw the bitcoind chain source) are this pre-existing bug, not the PR.
Where a fix could go
inner/persistermutexes acrossblock_on(persist_async)(leg 1). E.g. extracting the staged changeset and persisting outside the wallet lock (the persister already merges changesets internally), or making these paths natively async so they await instead ofblock_on. This breaks the cycle at its strongest link.tokio::task::block_in_placeunwedges hang 1, but hang 2's core-wedger is the asyncWalletSourcepath (list_confirmed_utxos_inner) — every synchronous wallet-mutex acquisition reachable from a runtime worker would need the same treatment.worker_threadsmerely lowers the probability; the cycle only needs all workers to be blocked on the wallet mutex at once.Environment
ldk-node main (deadlock-relevant code identical at f04fa4d), tokio 1.52.3, LDK rev 3dfcc4cca186, macOS arm64. Full
sampleoutputs and the watchdog patch available on request.This bug was investigated and this report drafted with an AI assistant (Claude Code); the stacks, instrumentation output, and reproduction are from real local runs.