Skip to content

Runtime deadlock in sync SignerProvider callbacks #1010

Description

@jkczyz

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_channelChannelContext::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_scriptpubkeyblock_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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions