Summary
<WalletKeysManager as SignerProvider>::get_shutdown_scriptpubkey (and get_destination_script) call Runtime::block_on(wallet.get_new_address()), which awaits the wallet persister lock and store I/O. LDK invokes these sync callbacks on runtime worker threads from inside the event handler (e.g., ChannelManager::do_accept_inbound_channel → ChannelContext::new_for_inbound_channel), while holding the per-peer channel Mutex. If the block_on future cannot complete without another runtime task being polled, and the remaining worker cores get captured by tasks blocking synchronously on the same channel locks, the runtime deadlocks permanently.
This is the same class as #978 (fixed by #980) but a different entry point: #980 stopped holding the sync BDK wallet lock across store awaits and isolated store I/O onto dedicated runtimes, but the SignerProvider callbacks still block a worker on wallet persistence while holding LDK channel locks — which lets unrelated runtime tasks wedge the scheduler through those locks.
Observed deadlock (thread samples)
Hit 2 of 2 full cargo test --test integration_tests_rust runs (macOS, 2026-07-30), striking whichever test was unlucky under whole-suite load — open_channel_variants_reserve_funds_for_anchor_peers in one run, split_underpaid_bolt11_payment in the other. Both use #[tokio::test(flavor = "multi_thread", worker_threads = 1)], so the node runs on the test's single-worker runtime. Not reproducible standalone without suite load (see deterministic repro below).
macOS sample shows the identical three-party cycle in both hangs:
Thread A — background processor task (holds the per-peer channel Mutex, parked forever):
lightning_background_processor::process_events_async
ChannelManager::process_pending_events_async
ldk_node::event::EventHandler::handle_event [OpenChannelRequest]
ChannelManager::accept_inbound_channel
ChannelManager::do_accept_inbound_channel <-- holds per-peer state Mutex
InboundV1Channel::new -> ChannelContext::new_for_inbound_channel
<SignerProvider>::get_shutdown_scriptpubkey
ldk_node::runtime::Runtime::block_on
tokio block_in_place <-- hands its worker core off
Handle::block_on -> CachedParkThread::park -> pthread_cond_wait
Thread B — the fresh thread that received Thread A's core via the block_in_place handoff (captures the core, blocked without handoff):
tokio worker::run (block_in_place continuation)
run_task: lightning_net_tokio::Connection::poll_event_process
PeerManager::process_events
ChannelManager::get_and_clear_pending_msg_events
PersistenceNotifierGuard::drop -> maybe_generate_initial_closing_signed
std::sync::Mutex::lock -> __psynch_mutexwait <-- blocks on Thread A's peer Mutex
Party 3 — the future Thread A waits on. Wallet::get_new_address awaits the wallet persister tokio::sync::Mutex and then persist_changeset (store I/O). Under load that mutex is routinely held by another runtime task (wallet sync / apply_update, block_connected, etc.). That task is queued on the runtime — but the only core is captive on Thread B, so it can never run to release the lock, so Thread A is never woken, so the peer Mutex is never released, so Thread B never unblocks. No party can make progress; the hang is permanent, and even tokio timers stop firing (no worker polls the timer wheel).
worker_threads = 1 is the minimal trigger, but any runtime deadlocks once N cores are simultaneously captured by tasks blocking synchronously on locks held by parked block_on callers.
Deterministic reproduction
Gate a node's bdk_wallet-namespace KVStore::writes (in-memory store whose wallet writes await an already-held RwLock) and have a peer open a channel to it on a worker_threads = 1 runtime. The acceptor's event handler enters get_shutdown_scriptpubkey → block_on → parks on the gated write while holding the peer Mutex; the lightning-net-tokio connection task then captures the handed-off core in maybe_generate_initial_closing_signed; timers die; the node never emits ChannelPending. A thread sample of the wedged test process shows exactly the same stacks as the organic hangs.
(Regression test included in the fix PR: channel_open_completes_while_wallet_persistence_is_stalled.)
Proposed fix
Deriving a fresh address does not need to wait for persistence — only the reveal must be ordered. Make the sync SignerProvider callbacks non-blocking:
- Reveal the next external address under the sync BDK wallet lock (in-memory operation, no awaits).
- Leave the resulting change set staged and flush it from a spawned background task that takes the persister lock and then
take_staged() — the same ordering every other persist path uses, so out-of-order last_revealed regressions are impossible.
Semantics change: a persistence failure no longer fails the channel open (previously Err(()) from the callback). If the node crashes before the flush lands, the revealed index is re-revealed after restart, potentially reusing the address; BDK's keychain lookahead still detects funds sent to it. This matches the durability LDK's own KeysManager provides for shutdown scripts (none — it derives a static script and persists nothing).
Related bridges not covered here
OnchainPayment::new_address and LSPS1Liquidity::request_channel still block_on(get_new_address()), but they are public sync API entry points invoked on user threads and do not hold LDK locks. They are the known/accepted sync-bridge pattern (cf. the StoreRuntime docs), though they remain susceptible if users call them from runtime context.
<Wallet as Listen>::block_connected also bridges via block_on while holding the wallet persister lock (bitcoind chain source only). It does not hold LDK channel locks, so it cannot form this cycle by itself, but it can be one of the parked parties in a multi-worker capture.
- Long-term, async
SignerProvider/ChangeDestinationSource callbacks in LDK would remove the need for the bridge entirely.
Provenance
Introduced by #919 (commit 5ac2ed1, "Use BDK's async wallet persister"): before it, get_new_address persisted through BDK's sync persister with no runtime involvement, so the sync SignerProvider callbacks could never park on the runtime. #919 bridged the still-sync wallet APIs via Runtime::block_on (flagged in its commit message as a stopgap). #980 later reshaped the bridge (moving the block_on into the callbacks themselves) while fixing #978, but this cycle runs through the LDK peer Mutex rather than the wallet lock, so it survived.
Environment
Summary
<WalletKeysManager as SignerProvider>::get_shutdown_scriptpubkey(andget_destination_script) callRuntime::block_on(wallet.get_new_address()), which awaits the wallet persister lock and store I/O. LDK invokes these sync callbacks on runtime worker threads from inside the event handler (e.g.,ChannelManager::do_accept_inbound_channel→ChannelContext::new_for_inbound_channel), while holding the per-peer channelMutex. If theblock_onfuture cannot complete without another runtime task being polled, and the remaining worker cores get captured by tasks blocking synchronously on the same channel locks, the runtime deadlocks permanently.This is the same class as #978 (fixed by #980) but a different entry point: #980 stopped holding the sync BDK wallet lock across store awaits and isolated store I/O onto dedicated runtimes, but the
SignerProvidercallbacks still block a worker on wallet persistence while holding LDK channel locks — which lets unrelated runtime tasks wedge the scheduler through those locks.Observed deadlock (thread samples)
Hit 2 of 2 full
cargo test --test integration_tests_rustruns (macOS, 2026-07-30), striking whichever test was unlucky under whole-suite load —open_channel_variants_reserve_funds_for_anchor_peersin one run,split_underpaid_bolt11_paymentin the other. Both use#[tokio::test(flavor = "multi_thread", worker_threads = 1)], so the node runs on the test's single-worker runtime. Not reproducible standalone without suite load (see deterministic repro below).macOS
sampleshows the identical three-party cycle in both hangs:Thread A — background processor task (holds the per-peer channel
Mutex, parked forever):Thread B — the fresh thread that received Thread A's core via the
block_in_placehandoff (captures the core, blocked without handoff):Party 3 — the future Thread A waits on.
Wallet::get_new_addressawaits the wallet persistertokio::sync::Mutexand thenpersist_changeset(store I/O). Under load that mutex is routinely held by another runtime task (wallet sync /apply_update,block_connected, etc.). That task is queued on the runtime — but the only core is captive on Thread B, so it can never run to release the lock, so Thread A is never woken, so the peerMutexis never released, so Thread B never unblocks. No party can make progress; the hang is permanent, and even tokio timers stop firing (no worker polls the timer wheel).worker_threads = 1is the minimal trigger, but any runtime deadlocks once N cores are simultaneously captured by tasks blocking synchronously on locks held by parkedblock_oncallers.Deterministic reproduction
Gate a node's
bdk_wallet-namespaceKVStore::writes (in-memory store whose wallet writes await an already-heldRwLock) and have a peer open a channel to it on aworker_threads = 1runtime. The acceptor's event handler entersget_shutdown_scriptpubkey→block_on→ parks on the gated write while holding the peerMutex; thelightning-net-tokioconnection task then captures the handed-off core inmaybe_generate_initial_closing_signed; timers die; the node never emitsChannelPending. A thread sample of the wedged test process shows exactly the same stacks as the organic hangs.(Regression test included in the fix PR:
channel_open_completes_while_wallet_persistence_is_stalled.)Proposed fix
Deriving a fresh address does not need to wait for persistence — only the reveal must be ordered. Make the sync
SignerProvidercallbacks non-blocking:take_staged()— the same ordering every other persist path uses, so out-of-orderlast_revealedregressions are impossible.Semantics change: a persistence failure no longer fails the channel open (previously
Err(())from the callback). If the node crashes before the flush lands, the revealed index is re-revealed after restart, potentially reusing the address; BDK's keychain lookahead still detects funds sent to it. This matches the durability LDK's ownKeysManagerprovides for shutdown scripts (none — it derives a static script and persists nothing).Related bridges not covered here
OnchainPayment::new_addressandLSPS1Liquidity::request_channelstillblock_on(get_new_address()), but they are public sync API entry points invoked on user threads and do not hold LDK locks. They are the known/accepted sync-bridge pattern (cf. theStoreRuntimedocs), though they remain susceptible if users call them from runtime context.<Wallet as Listen>::block_connectedalso bridges viablock_onwhile holding the wallet persister lock (bitcoind chain source only). It does not hold LDK channel locks, so it cannot form this cycle by itself, but it can be one of the parked parties in a multi-worker capture.SignerProvider/ChangeDestinationSourcecallbacks in LDK would remove the need for the bridge entirely.Provenance
Introduced by #919 (commit 5ac2ed1, "Use BDK's async wallet persister"): before it,
get_new_addresspersisted through BDK's sync persister with no runtime involvement, so the syncSignerProvidercallbacks could never park on the runtime. #919 bridged the still-sync wallet APIs viaRuntime::block_on(flagged in its commit message as a stopgap). #980 later reshaped the bridge (moving theblock_oninto the callbacks themselves) while fixing #978, but this cycle runs through the LDK peerMutexrather than the wallet lock, so it survived.Environment
main@ abbed3e, LDK0.3.0+git(lightningdevkit/rust-lightning@506cb91f)cargo testwhole-suite load