Skip to content

Commit 9e29da5

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 0fea824 commit 9e29da5

3 files changed

Lines changed: 138 additions & 47 deletions

File tree

src/chain/mod.rs

Lines changed: 55 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,15 @@ use crate::config::{
2626
use crate::fee_estimator::OnchainFeeEstimator;
2727
use crate::logger::{log_debug, log_error, log_info, log_trace, LdkLogger, Logger};
2828
use crate::runtime::Runtime;
29+
use crate::tx_broadcaster::BroadcastPackage;
2930
use crate::types::{Broadcaster, ChainMonitor, ChannelManager, DynStore, Sweeper, Wallet};
3031
use crate::{Error, PersistedNodeMetrics};
3132

33+
/// How long to wait before re-classifying a package whose classification failed. Long enough to
34+
/// give a struggling store room to recover, short against the ~minutes until the transaction
35+
/// could confirm.
36+
const FAILED_CLASSIFY_RETRY_DELAY: Duration = Duration::from_secs(2);
37+
3238
/// We use this parent-child TRUC package to make sure the configured chain source supports
3339
/// broadcasting packages via the `submitpackage` Bitcoin Core RPC.
3440
const PARENT_TXID: &str = "9a015f93fac6cb203c2b994e18b85176eb0354a22a468255516f3c6002d3f696";
@@ -520,12 +526,50 @@ impl ChainSource {
520526
}
521527
}
522528

529+
/// Classifies the package's funding broadcasts into payment records, then broadcasts it.
530+
/// Returns the package back on classification failure so the caller can retry it after a
531+
/// delay: broadcasting a tx we failed to record would leave it on-chain without a payment,
532+
/// while dropping the package would not keep an interactively funded tx off-chain (the
533+
/// counterparty broadcasts it regardless), only leave it confirming without a recorded
534+
/// candidate.
535+
async fn classify_and_broadcast(
536+
&self, package: BroadcastPackage,
537+
) -> Result<(), BroadcastPackage> {
538+
if let Err(e) = self.tx_broadcaster.classify_package(&package).await {
539+
log_error!(
540+
self.logger,
541+
"Delaying broadcast: failed to persist payment records, will retry: {:?}",
542+
e,
543+
);
544+
return Err(package);
545+
}
546+
let package = package.into_sorted_transactions();
547+
match &self.kind {
548+
ChainSourceKind::Esplora(esplora_chain_source) => {
549+
esplora_chain_source.process_transaction_broadcast(package).await
550+
},
551+
ChainSourceKind::Electrum(electrum_chain_source) => {
552+
electrum_chain_source.process_transaction_broadcast(package).await
553+
},
554+
ChainSourceKind::Bitcoind(bitcoind_chain_source) => {
555+
bitcoind_chain_source.process_transaction_broadcast(package).await
556+
},
557+
}
558+
Ok(())
559+
}
560+
523561
pub(crate) async fn continuously_process_broadcast_queue(
524562
&self, mut stop_tx_bcast_receiver: tokio::sync::watch::Receiver<()>,
525563
) {
526564
let mut receiver = self.tx_broadcaster.get_broadcast_queue().await;
565+
// Packages whose classification failed, each waiting out FAILED_CLASSIFY_RETRY_DELAY
566+
// before its next attempt. New packages keep flowing while these wait, and pending
567+
// retries die with the loop on shutdown rather than resurfacing after a later start.
568+
let mut parked: Vec<(tokio::time::Instant, BroadcastPackage)> = Vec::new();
527569
loop {
528570
let tx_bcast_logger = Arc::clone(&self.logger);
571+
// Entries are appended with a fixed delay, so the first is always the next due.
572+
let next_retry_at = parked.first().map(|(deadline, _)| *deadline);
529573
tokio::select! {
530574
_ = stop_tx_bcast_receiver.changed() => {
531575
log_debug!(
@@ -535,32 +579,18 @@ impl ChainSource {
535579
return;
536580
}
537581
Some(next_package) = receiver.recv() => {
538-
// Classify funding broadcasts into payment records before sending. If
539-
// classification fails we delay the broadcast and retry, since broadcasting
540-
// a tx we failed to record would leave it on-chain without a payment —
541-
// while dropping the package would not keep an interactively funded tx
542-
// off-chain (the counterparty broadcasts it regardless), only leave it
543-
// confirming without a recorded candidate.
544-
if let Err(e) = self.tx_broadcaster.classify_package(&next_package).await {
545-
log_error!(
546-
tx_bcast_logger,
547-
"Delaying broadcast: failed to persist payment records, will retry: {:?}",
548-
e,
549-
);
550-
self.tx_broadcaster.requeue_failed_classify(next_package);
551-
continue;
582+
if let Err(package) = self.classify_and_broadcast(next_package).await {
583+
let retry_at = tokio::time::Instant::now() + FAILED_CLASSIFY_RETRY_DELAY;
584+
parked.push((retry_at, package));
552585
}
553-
let package = next_package.into_sorted_transactions();
554-
match &self.kind {
555-
ChainSourceKind::Esplora(esplora_chain_source) => {
556-
esplora_chain_source.process_transaction_broadcast(package).await
557-
},
558-
ChainSourceKind::Electrum(electrum_chain_source) => {
559-
electrum_chain_source.process_transaction_broadcast(package).await
560-
},
561-
ChainSourceKind::Bitcoind(bitcoind_chain_source) => {
562-
bitcoind_chain_source.process_transaction_broadcast(package).await
563-
},
586+
}
587+
_ = tokio::time::sleep_until(
588+
next_retry_at.unwrap_or_else(tokio::time::Instant::now)
589+
), if next_retry_at.is_some() => {
590+
let (_, package) = parked.remove(0);
591+
if let Err(package) = self.classify_and_broadcast(package).await {
592+
let retry_at = tokio::time::Instant::now() + FAILED_CLASSIFY_RETRY_DELAY;
593+
parked.push((retry_at, package));
564594
}
565595
}
566596
}

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
@@ -4318,6 +4318,88 @@ mod tests {
43184318
loop_task.await.unwrap();
43194319
}
43204320

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

0 commit comments

Comments
 (0)