Skip to content

Commit fb85dd0

Browse files
jkczyzclaude
andcommitted
f - Keep classification retries inside the broadcast loop
The retry for a failed classification was a detached tokio::spawn that outlived the node. Its comment claimed a re-send after shutdown would fail because the queue had closed, but the queue receiver lives in the broadcaster and is only dropped with the node, so the re-send succeeded and a stale package would be classified and broadcast after a stop()/start() cycle. Park failed packages inside the broadcast loop instead and retry them from a timer branch of the same select. New packages keep flowing while a retry waits, and pending retries are dropped when the loop stops. Implemented with Claude Code. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent bbcf5c4 commit fb85dd0

3 files changed

Lines changed: 141 additions & 50 deletions

File tree

src/chain/mod.rs

Lines changed: 58 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,15 @@ use crate::config::{BackgroundSyncConfig, Config, WALLET_SYNC_INTERVAL_MINIMUM_S
3737
use crate::fee_estimator::OnchainFeeEstimator;
3838
use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger};
3939
use crate::runtime::Runtime;
40+
use crate::tx_broadcaster::BroadcastPackage;
4041
use crate::types::{Broadcaster, ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet};
4142
use crate::{Error, PersistedNodeMetrics};
4243

44+
/// How long to wait before re-classifying a package whose classification failed. Long enough to
45+
/// give a struggling store room to recover, short against the ~minutes until the transaction
46+
/// could confirm.
47+
const FAILED_CLASSIFY_RETRY_DELAY: Duration = Duration::from_secs(2);
48+
4349
/// We use this parent-child TRUC package to make sure the configured chain source supports
4450
/// broadcasting packages via the `submitpackage` Bitcoin Core RPC.
4551
const PARENT_TXID: &str = "9a015f93fac6cb203c2b994e18b85176eb0354a22a468255516f3c6002d3f696";
@@ -562,12 +568,53 @@ impl ChainSource {
562568
}
563569
}
564570

571+
/// Classifies the package's funding broadcasts into payment records, then broadcasts it.
572+
/// Returns the package back on classification failure so the caller can retry it after a
573+
/// delay: broadcasting a tx we failed to record would leave it on-chain without a payment,
574+
/// while dropping the package would not keep an interactively funded tx off-chain (the
575+
/// counterparty broadcasts it regardless), only leave it confirming without a recorded
576+
/// candidate.
577+
async fn classify_and_broadcast(
578+
&self, package: BroadcastPackage,
579+
) -> Result<(), BroadcastPackage> {
580+
if let Err(e) = self.tx_broadcaster.classify_package(&package).await {
581+
log_error!(
582+
self.logger,
583+
"Delaying broadcast: failed to persist payment records, will retry: {:?}",
584+
e,
585+
);
586+
return Err(package);
587+
}
588+
let package = package.into_sorted_transactions();
589+
match &self.kind {
590+
#[cfg(feature = "chain-esplora")]
591+
ChainSourceKind::Esplora(esplora_chain_source) => {
592+
esplora_chain_source.process_transaction_broadcast(package).await
593+
},
594+
#[cfg(feature = "chain-electrum")]
595+
ChainSourceKind::Electrum(electrum_chain_source) => {
596+
electrum_chain_source.process_transaction_broadcast(package).await
597+
},
598+
#[cfg(feature = "chain-bitcoind")]
599+
ChainSourceKind::Bitcoind(bitcoind_chain_source) => {
600+
bitcoind_chain_source.process_transaction_broadcast(package).await
601+
},
602+
}
603+
Ok(())
604+
}
605+
565606
pub(crate) async fn continuously_process_broadcast_queue(
566607
&self, mut stop_tx_bcast_receiver: tokio::sync::watch::Receiver<()>,
567608
) {
568609
let mut receiver = self.tx_broadcaster.get_broadcast_queue().await;
610+
// Packages whose classification failed, each waiting out FAILED_CLASSIFY_RETRY_DELAY
611+
// before its next attempt. New packages keep flowing while these wait, and pending
612+
// retries die with the loop on shutdown rather than resurfacing after a later start.
613+
let mut parked: Vec<(tokio::time::Instant, BroadcastPackage)> = Vec::new();
569614
loop {
570615
let tx_bcast_logger = Arc::clone(&self.logger);
616+
// Entries are appended with a fixed delay, so the first is always the next due.
617+
let next_retry_at = parked.first().map(|(deadline, _)| *deadline);
571618
tokio::select! {
572619
_ = stop_tx_bcast_receiver.changed() => {
573620
log_debug!(
@@ -577,35 +624,18 @@ impl ChainSource {
577624
return;
578625
}
579626
Some(next_package) = receiver.recv() => {
580-
// Classify funding broadcasts into payment records before sending. If
581-
// classification fails we delay the broadcast and retry, since broadcasting
582-
// a tx we failed to record would leave it on-chain without a payment —
583-
// while dropping the package would not keep an interactively funded tx
584-
// off-chain (the counterparty broadcasts it regardless), only leave it
585-
// confirming without a recorded candidate.
586-
if let Err(e) = self.tx_broadcaster.classify_package(&next_package).await {
587-
log_error!(
588-
tx_bcast_logger,
589-
"Delaying broadcast: failed to persist payment records, will retry: {:?}",
590-
e,
591-
);
592-
self.tx_broadcaster.requeue_failed_classify(next_package);
593-
continue;
627+
if let Err(package) = self.classify_and_broadcast(next_package).await {
628+
let retry_at = tokio::time::Instant::now() + FAILED_CLASSIFY_RETRY_DELAY;
629+
parked.push((retry_at, package));
594630
}
595-
let package = next_package.into_sorted_transactions();
596-
match &self.kind {
597-
#[cfg(feature = "chain-esplora")]
598-
ChainSourceKind::Esplora(esplora_chain_source) => {
599-
esplora_chain_source.process_transaction_broadcast(package).await
600-
},
601-
#[cfg(feature = "chain-electrum")]
602-
ChainSourceKind::Electrum(electrum_chain_source) => {
603-
electrum_chain_source.process_transaction_broadcast(package).await
604-
},
605-
#[cfg(feature = "chain-bitcoind")]
606-
ChainSourceKind::Bitcoind(bitcoind_chain_source) => {
607-
bitcoind_chain_source.process_transaction_broadcast(package).await
608-
},
631+
}
632+
_ = tokio::time::sleep_until(
633+
next_retry_at.unwrap_or_else(tokio::time::Instant::now)
634+
), if next_retry_at.is_some() => {
635+
let (_, package) = parked.remove(0);
636+
if let Err(package) = self.classify_and_broadcast(package).await {
637+
let retry_at = tokio::time::Instant::now() + FAILED_CLASSIFY_RETRY_DELAY;
638+
parked.push((retry_at, package));
609639
}
610640
}
611641
}

src/tx_broadcaster.rs

Lines changed: 1 addition & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77

88
use std::ops::Deref;
99
use std::sync::{Mutex as StdMutex, Weak};
10-
use std::time::Duration;
1110

1211
use bitcoin::Transaction;
1312
use lightning::chain::chaininterface::{
@@ -21,11 +20,6 @@ use crate::Error;
2120

2221
const BCAST_PACKAGE_QUEUE_SIZE: usize = 256;
2322

24-
/// How long to wait before re-classifying a package whose classification failed. Long enough to
25-
/// give a struggling store room to recover, short against the ~minutes until the transaction
26-
/// could confirm.
27-
const FAILED_CLASSIFY_RETRY_DELAY: Duration = Duration::from_secs(2);
28-
2923
/// A package of transactions that LDK handed to the broadcaster in one `broadcast_transactions`
3024
/// call, along with each transaction's type. Queued until the background task classifies and
3125
/// broadcasts it. Built only via [`BroadcastPackage::new`] from such a call, so unrelated
@@ -141,8 +135,7 @@ where
141135

142136
/// Classifies a queued package into payment records. Returns `Err` if any classification
143137
/// fails; callers must not broadcast the package in that case, since a crash would leave the
144-
/// transaction on-chain without a record — but must requeue it via
145-
/// [`Self::requeue_failed_classify`] rather than drop it.
138+
/// transaction on-chain without a record — but must retry it later rather than drop it.
146139
pub(crate) async fn classify_package(&self, package: &BroadcastPackage) -> Result<(), Error> {
147140
let wallet_opt = self.wallet.lock().expect("lock").as_ref().and_then(Weak::upgrade);
148141
if let Some(wallet) = wallet_opt {
@@ -155,20 +148,6 @@ where
155148
Ok(())
156149
}
157150

158-
/// Re-sends a package whose classification failed back into the queue after a delay, so a
159-
/// transient persistence failure delays the broadcast instead of dropping the package.
160-
/// Dropping an interactive-funding package would not even keep its transaction off-chain —
161-
/// the counterparty broadcasts it regardless — it would only leave the transaction
162-
/// confirming without a recorded candidate. If the queue has closed by the time the delay
163-
/// elapses, the node is shutting down and the package is dropped with it.
164-
pub(crate) fn requeue_failed_classify(&self, package: BroadcastPackage) {
165-
let sender = self.queue_sender.clone();
166-
tokio::spawn(async move {
167-
tokio::time::sleep(FAILED_CLASSIFY_RETRY_DELAY).await;
168-
let _ = sender.send(package).await;
169-
});
170-
}
171-
172151
pub(crate) fn broadcast_unclassified_transaction(&self, tx: Transaction) {
173152
self.queue_sender.try_send(BroadcastPackage::unclassified(tx)).unwrap_or_else(|e| {
174153
log_error!(self.logger, "Failed to broadcast transactions: {}", e);

src/wallet/mod.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4337,6 +4337,88 @@ mod tests {
43374337
loop_task.await.unwrap();
43384338
}
43394339

4340+
/// A package awaiting a classification retry must die when the node stops. When the retry
4341+
/// was a detached task, it outlived the broadcast loop: its re-send into the still-open
4342+
/// queue succeeded after `stop()`, so a later `start()` would classify and broadcast the
4343+
/// stale package.
4344+
#[tokio::test]
4345+
async fn failed_classification_retry_dies_at_stop() {
4346+
use lightning::chain::chaininterface::BroadcasterInterface;
4347+
4348+
let fail_store = FailSwitchStore::new();
4349+
let store: Arc<DynStore> = Arc::new(DynStoreWrapper(fail_store.clone()));
4350+
let wallet = new_test_wallet(Arc::clone(&store), false).await;
4351+
wallet.broadcaster.set_wallet(Arc::downgrade(&wallet));
4352+
4353+
let (stop_sender, stop_receiver) = tokio::sync::watch::channel(());
4354+
let chain_source = Arc::clone(&wallet.chain_source);
4355+
let loop_task = tokio::spawn(async move {
4356+
chain_source.continuously_process_broadcast_queue(stop_receiver).await
4357+
});
4358+
4359+
let script_pubkey = wallet
4360+
.inner
4361+
.lock()
4362+
.unwrap()
4363+
.reveal_next_address(KeychainKind::External)
4364+
.address
4365+
.script_pubkey();
4366+
let tx = Transaction {
4367+
version: bitcoin::transaction::Version::TWO,
4368+
lock_time: LockTime::ZERO,
4369+
input: Vec::new(),
4370+
output: vec![TxOut { value: Amount::from_sat(10_000), script_pubkey }],
4371+
};
4372+
let counterparty_node_id = PublicKey::from_str(
4373+
"0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798",
4374+
)
4375+
.unwrap();
4376+
4377+
// Queue the broadcast while payment persistence is failing and wait for the loop to
4378+
// fail a classification attempt, leaving a retry pending.
4379+
fail_store.fail_writes.store(true, Ordering::Release);
4380+
wallet.broadcaster.broadcast_transactions(&[(
4381+
&tx,
4382+
LdkTransactionType::Funding {
4383+
channels: vec![(counterparty_node_id, ChannelId([7u8; 32]))],
4384+
},
4385+
)]);
4386+
let mut failed_writes = 0;
4387+
for _ in 0..100 {
4388+
tokio::time::sleep(Duration::from_millis(100)).await;
4389+
failed_writes = fail_store.failed_writes.load(Ordering::Acquire);
4390+
if failed_writes > 0 {
4391+
break;
4392+
}
4393+
}
4394+
assert!(failed_writes > 0, "classification never attempted a payment-store write");
4395+
4396+
// Stop the node with the retry still pending, then bring the loop back up with
4397+
// working persistence, as a stop()/start() cycle would.
4398+
stop_sender.send(()).unwrap();
4399+
loop_task.await.unwrap();
4400+
fail_store.fail_writes.store(false, Ordering::Release);
4401+
4402+
let (stop_sender, stop_receiver) = tokio::sync::watch::channel(());
4403+
let chain_source = Arc::clone(&wallet.chain_source);
4404+
let loop_task = tokio::spawn(async move {
4405+
chain_source.continuously_process_broadcast_queue(stop_receiver).await
4406+
});
4407+
4408+
// Watch well past the retry delay: the package from before the stop must not be
4409+
// classified or broadcast by the restarted loop.
4410+
for _ in 0..40 {
4411+
tokio::time::sleep(Duration::from_millis(100)).await;
4412+
assert!(
4413+
wallet.payment_store.list_page(None).await.unwrap().objects.is_empty(),
4414+
"a package from before stop() resurfaced after restart"
4415+
);
4416+
}
4417+
4418+
stop_sender.send(()).unwrap();
4419+
loop_task.await.unwrap();
4420+
}
4421+
43404422
/// Barrier test, classification-first ordering: wallet sync's confirmation handling must
43414423
/// wait for classification's two-store write pair. Classification is parked between its
43424424
/// payment-store and pending-store writes (the torn window) and only then is the

0 commit comments

Comments
 (0)