Skip to content

Commit 273e4d0

Browse files
jkczyzclaude
andcommitted
Derive shutdown scripts without blocking on wallet persistence
LDK invokes the sync `SignerProvider::get_shutdown_scriptpubkey` and `get_destination_script` callbacks on runtime worker threads while holding channel locks, e.g. when accepting an inbound channel. Blocking there on wallet persistence could deadlock the runtime: the parked callback still held the channel locks, other tasks blocking synchronously on those locks captured the remaining worker cores, and the persistence future the callback waited on could then never be polled. Observed as a permanent hang of integration test runs. Instead, reveal the address without waiting and persist the staged change set in the background. A persistence failure therefore no longer rejects the channel; if the node crashes before the flush lands, the revealed index may be handed out again after restart, which BDK's keychain lookahead tolerates. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent abbed3e commit 273e4d0

2 files changed

Lines changed: 199 additions & 11 deletions

File tree

src/wallet/mod.rs

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,36 @@ impl Wallet {
518518
Ok(address_info.address)
519519
}
520520

521+
/// Returns a new address, persisting the revealed derivation index in the background rather
522+
/// than waiting on it.
523+
///
524+
/// This exists for sync callbacks (e.g., [`SignerProvider`]) that LDK invokes on runtime
525+
/// worker threads while holding channel locks. Blocking such a callback on persistence can
526+
/// deadlock the runtime: other tasks blocking synchronously on the same channel locks capture
527+
/// the remaining workers, leaving none to drive the persistence future the callback waits on.
528+
///
529+
/// If the node crashes before the background flush lands, the revealed index is lost and the
530+
/// address may be handed out again after restart. BDK's keychain lookahead still detects any
531+
/// funds it receives.
532+
pub(crate) fn get_new_address_deferring_persist(self: &Arc<Self>) -> bitcoin::Address {
533+
let address_info =
534+
self.inner.lock().expect("lock").reveal_next_address(KeychainKind::External);
535+
536+
// Leave the change set staged: whichever flow next takes the persister lock and calls
537+
// `take_staged` (possibly the task spawned here) persists the reveal, preserving the
538+
// ordering that serializing those two steps under the persister lock establishes.
539+
let wallet = Arc::clone(self);
540+
self.runtime.spawn_background_task(async move {
541+
let mut locked_persister = wallet.persister.lock().await;
542+
let change_set = wallet.inner.lock().expect("lock").take_staged().unwrap_or_default();
543+
if let Err(e) = locked_persister.persist_changeset(change_set).await {
544+
log_error!(wallet.logger, "Failed to persist wallet: {}", e);
545+
}
546+
});
547+
548+
address_info.address
549+
}
550+
521551
pub(crate) async fn get_new_internal_address(&self) -> Result<bitcoin::Address, Error> {
522552
let mut locked_persister = self.persister.lock().await;
523553
let (address_info, change_set) = {
@@ -2090,16 +2120,16 @@ impl SignerProvider for WalletKeysManager {
20902120
}
20912121

20922122
fn get_destination_script(&self, _channel_keys_id: [u8; 32]) -> Result<ScriptBuf, ()> {
2093-
let address = self.wallet.runtime.block_on(self.wallet.get_new_address()).map_err(|e| {
2094-
log_error!(self.logger, "Failed to retrieve new address from wallet: {}", e);
2095-
})?;
2123+
// LDK may invoke this callback on a runtime worker thread while holding channel locks.
2124+
// It must not block on the runtime, or the runtime can deadlock.
2125+
let address = self.wallet.get_new_address_deferring_persist();
20962126
Ok(address.script_pubkey())
20972127
}
20982128

20992129
fn get_shutdown_scriptpubkey(&self) -> Result<ShutdownScript, ()> {
2100-
let address = self.wallet.runtime.block_on(self.wallet.get_new_address()).map_err(|e| {
2101-
log_error!(self.logger, "Failed to retrieve new address from wallet: {}", e);
2102-
})?;
2130+
// LDK may invoke this callback on a runtime worker thread while holding channel locks.
2131+
// It must not block on the runtime, or the runtime can deadlock.
2132+
let address = self.wallet.get_new_address_deferring_persist();
21032133

21042134
match address.witness_program() {
21052135
Some(program) => ShutdownScript::new_witness_program(&program).map_err(|e| {

tests/integration_tests_rust.rs

Lines changed: 163 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ mod common;
1010
use std::collections::HashSet;
1111
use std::future::Future;
1212
use std::str::FromStr;
13-
use std::sync::atomic::{AtomicBool, Ordering};
13+
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
1414
use std::sync::{mpsc, Arc};
1515
use std::time::Duration;
1616

@@ -24,10 +24,10 @@ use common::{
2424
expect_channel_pending_event, expect_channel_ready_event, expect_channel_ready_events,
2525
expect_event, expect_payment_claimable_event, expect_payment_received_event,
2626
expect_payment_successful_event, expect_splice_negotiated_event, generate_blocks_and_wait,
27-
generate_listening_addresses, invalidate_blocks, open_channel, open_channel_push_amt,
28-
open_channel_with_all, premine_and_distribute_funds, premine_blocks, prepare_rbf,
29-
random_chain_source, random_config, setup_bitcoind_and_electrsd, setup_builder, setup_node,
30-
setup_two_nodes, splice_in_with_all, wait_for_block, wait_for_tx, InMemoryStore,
27+
generate_listening_addresses, invalidate_blocks, open_channel, open_channel_no_wait,
28+
open_channel_push_amt, open_channel_with_all, premine_and_distribute_funds, premine_blocks,
29+
prepare_rbf, random_chain_source, random_config, setup_bitcoind_and_electrsd, setup_builder,
30+
setup_node, setup_two_nodes, splice_in_with_all, wait_for_block, wait_for_tx, InMemoryStore,
3131
TestChainSource, TestConfig, TestStoreType, TestSyncStore,
3232
};
3333
use electrsd::corepc_node::{self, Node as BitcoinD};
@@ -216,6 +216,164 @@ fn wallet_store_contention_does_not_stall_runtime() {
216216
result.unwrap_or_else(|e| panic!("wallet contention test failed: {e}"));
217217
}
218218

219+
#[derive(Clone)]
220+
struct WalletPersistGatedStore {
221+
inner: Arc<InMemoryStore>,
222+
wallet_write_gate: Arc<tokio::sync::RwLock<()>>,
223+
gate_engaged: Arc<AtomicBool>,
224+
wallet_writes_completed: Arc<AtomicUsize>,
225+
}
226+
227+
impl WalletPersistGatedStore {
228+
fn new() -> Self {
229+
Self {
230+
inner: Arc::new(InMemoryStore::new()),
231+
wallet_write_gate: Arc::new(tokio::sync::RwLock::new(())),
232+
gate_engaged: Arc::new(AtomicBool::new(false)),
233+
wallet_writes_completed: Arc::new(AtomicUsize::new(0)),
234+
}
235+
}
236+
}
237+
238+
impl KVStore for WalletPersistGatedStore {
239+
fn read(
240+
&self, primary_namespace: &str, secondary_namespace: &str, key: &str,
241+
) -> impl Future<Output = Result<Vec<u8>, lightning::io::Error>> + 'static + Send {
242+
KVStore::read(&*self.inner, primary_namespace, secondary_namespace, key)
243+
}
244+
245+
fn write(
246+
&self, primary_namespace: &str, secondary_namespace: &str, key: &str, buf: Vec<u8>,
247+
) -> impl Future<Output = Result<(), lightning::io::Error>> + 'static + Send {
248+
let inner = Arc::clone(&self.inner);
249+
let wallet_write_gate = Arc::clone(&self.wallet_write_gate);
250+
let gate_engaged = Arc::clone(&self.gate_engaged);
251+
let wallet_writes_completed = Arc::clone(&self.wallet_writes_completed);
252+
let primary_namespace = primary_namespace.to_string();
253+
let secondary_namespace = secondary_namespace.to_string();
254+
let key = key.to_string();
255+
async move {
256+
let is_wallet_write = primary_namespace == "bdk_wallet";
257+
if is_wallet_write && gate_engaged.load(Ordering::Acquire) {
258+
let _guard = wallet_write_gate.read().await;
259+
}
260+
let res =
261+
KVStore::write(&*inner, &primary_namespace, &secondary_namespace, &key, buf).await;
262+
if is_wallet_write && res.is_ok() {
263+
wallet_writes_completed.fetch_add(1, Ordering::AcqRel);
264+
}
265+
res
266+
}
267+
}
268+
269+
fn remove(
270+
&self, primary_namespace: &str, secondary_namespace: &str, key: &str, lazy: bool,
271+
) -> impl Future<Output = Result<(), lightning::io::Error>> + 'static + Send {
272+
KVStore::remove(&*self.inner, primary_namespace, secondary_namespace, key, lazy)
273+
}
274+
275+
fn list(
276+
&self, primary_namespace: &str, secondary_namespace: &str,
277+
) -> impl Future<Output = Result<Vec<String>, lightning::io::Error>> + 'static + Send {
278+
KVStore::list(&*self.inner, primary_namespace, secondary_namespace)
279+
}
280+
}
281+
282+
impl PaginatedKVStore for WalletPersistGatedStore {
283+
fn list_paginated(
284+
&self, primary_namespace: &str, secondary_namespace: &str, page_token: Option<PageToken>,
285+
) -> impl Future<Output = Result<PaginatedListResponse, lightning::io::Error>> + 'static + Send
286+
{
287+
PaginatedKVStore::list_paginated(
288+
&*self.inner,
289+
primary_namespace,
290+
secondary_namespace,
291+
page_token,
292+
)
293+
}
294+
}
295+
296+
// LDK invokes the sync `SignerProvider::get_shutdown_scriptpubkey` callback on a runtime worker
297+
// thread while holding channel locks when a node accepts (or opens) a channel. If deriving the
298+
// shutdown script waits on wallet persistence, a contended wallet store wedges the event handler
299+
// while it holds those locks, and other runtime tasks blocking on the same locks can capture the
300+
// remaining workers, deadlocking the runtime. Gate node B's BDK wallet writes and assert the
301+
// channel open still completes, with the revealed address persisted once the store recovers.
302+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
303+
async fn channel_open_completes_while_wallet_persistence_is_stalled() {
304+
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();
305+
let esplora_url = format!("http://{}", electrsd.esplora_url.as_ref().unwrap());
306+
let chain_source = TestChainSource::Esplora(&electrsd);
307+
308+
let config_a = random_config();
309+
let node_a = setup_node(&chain_source, config_a);
310+
311+
let config_b = random_config();
312+
setup_builder!(builder_b, config_b.node_config);
313+
let mut sync_config = EsploraSyncConfig::default();
314+
sync_config.background_sync_config = None;
315+
builder_b.set_chain_source_esplora(esplora_url, Some(sync_config));
316+
let store = WalletPersistGatedStore::new();
317+
let node_b = builder_b.build_with_store(config_b.node_entropy.into(), store.clone()).unwrap();
318+
node_b.start().unwrap();
319+
320+
// Fund both nodes so node B passes the anchor reserve check on the accept path.
321+
let address_a = node_a.onchain_payment().new_address().unwrap();
322+
let address_b = node_b.onchain_payment().new_address().unwrap();
323+
premine_and_distribute_funds(
324+
&bitcoind.client,
325+
&electrsd.client,
326+
vec![address_a, address_b],
327+
Amount::from_sat(5_000_000),
328+
)
329+
.await;
330+
node_a.sync_wallets().unwrap();
331+
node_b.sync_wallets().unwrap();
332+
333+
// Stall writes of node B's BDK wallet data before the channel open reaches the accept path.
334+
// The gate guard lives on a plain thread with a deadline: if the gated write wedges the
335+
// runtime (the bug under test captures all workers, so even timers stop firing), the gate
336+
// force-reopens after the event timeouts below have expired, letting them fail the test
337+
// cleanly instead of hanging it.
338+
let (release_gate_sender, release_gate_receiver) = mpsc::sync_channel::<()>(1);
339+
let (gate_held_sender, gate_held_receiver) = mpsc::sync_channel::<()>(1);
340+
let gate = Arc::clone(&store.wallet_write_gate);
341+
std::thread::spawn(move || {
342+
let _guard = gate.blocking_write();
343+
let _ = gate_held_sender.send(());
344+
let _ = release_gate_receiver.recv_timeout(Duration::from_secs(90));
345+
});
346+
gate_held_receiver.recv().unwrap();
347+
store.gate_engaged.store(true, Ordering::Release);
348+
let wallet_writes_before = store.wallet_writes_completed.load(Ordering::Acquire);
349+
350+
// Accepting the channel must not wait on wallet persistence: `open_channel_no_wait` times out
351+
// waiting for the `ChannelPending` events otherwise.
352+
let funding_txo = open_channel_no_wait(&node_a, &node_b, 500_000, None, false).await;
353+
354+
// Reopen the gate and verify the deferred persist of the revealed shutdown-script address
355+
// eventually lands.
356+
store.gate_engaged.store(false, Ordering::Release);
357+
// The watchdog thread may have force-released the gate already on a slow run.
358+
let _ = release_gate_sender.send(());
359+
let persisted = async {
360+
while store.wallet_writes_completed.load(Ordering::Acquire) <= wallet_writes_before {
361+
tokio::time::sleep(Duration::from_millis(50)).await;
362+
}
363+
};
364+
tokio::time::timeout(Duration::from_secs(common::INTEROP_TIMEOUT_SECS), persisted)
365+
.await
366+
.expect("timed out waiting for the deferred wallet persist");
367+
368+
// The channel and both nodes remain fully functional.
369+
wait_for_tx(&electrsd.client, funding_txo.txid).await;
370+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await;
371+
node_a.sync_wallets().unwrap();
372+
node_b.sync_wallets().unwrap();
373+
expect_channel_ready_event!(node_a, node_b.node_id());
374+
expect_channel_ready_event!(node_b, node_a.node_id());
375+
}
376+
219377
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
220378
async fn channel_full_cycle() {
221379
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();

0 commit comments

Comments
 (0)