Skip to content

Commit 5d4afa3

Browse files
Jolah1claude
andcommitted
node: consolidate peer-store cleanup into ChannelClosed handler
Peer-store cleanup on channel closure was split across two places: close_channel_internal removed the peer on a cooperative close, while the ChannelClosed handler removed it for an allowlist of counterparty/on-chain reasons. That allowlist missed terminal cases such as a channel closing before funding (CounterpartyCoopClosedUnfundedChannel), leaving those peers in the store and the reconnection loop retrying them indefinitely. Make the ChannelClosed handler the single owner of the decision: retain the peer only for HolderForceClosed -- where we deliberately keep reconnecting so channel_reestablish can drive recovery, important against LND peers that don't always handle force-closure error messages -- and drop it for every other terminal reason once no other channel with the peer remains. close_channel_internal no longer touches the peer store. Add an integration test for the counterparty force-close path and keep the retain/remove assertions in do_channel_full_cycle. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 8a54260 commit 5d4afa3

4 files changed

Lines changed: 107 additions & 5 deletions

File tree

src/event.rs

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1613,10 +1613,45 @@ where
16131613
} => {
16141614
log_info!(self.logger, "Channel {} closed due to: {}", channel_id, reason);
16151615

1616+
// `counterparty_node_id` has been set on every `ChannelClosed` since LDK 0.0.117.
1617+
let counterparty_node_id = counterparty_node_id
1618+
.expect("counterparty_node_id is always set since LDK 0.0.117");
1619+
1620+
// Drop the peer once its last channel with us has reached a terminal state
1621+
// that reconnection cannot recover. Every closure reason is terminal except
1622+
// `HolderForceClosed`: when *we* force-close, we keep reconnecting so that
1623+
// `channel_reestablish` can drive recovery (see `Node::close_channel_internal`).
1624+
// This also cleans up peers persisted for a channel that closed before funding
1625+
// (e.g. `CounterpartyCoopClosedUnfundedChannel`), which would otherwise be
1626+
// retried forever.
1627+
// We exclude `channel_id` from the count because LDK emits `ChannelClosed`
1628+
// before removing it from its internal list.
1629+
let dont_reconnect = !matches!(reason, ClosureReason::HolderForceClosed { .. });
1630+
1631+
if dont_reconnect {
1632+
let has_other_channels = self
1633+
.channel_manager
1634+
.list_channels_with_counterparty(&counterparty_node_id)
1635+
.iter()
1636+
.any(|c| c.channel_id != channel_id);
1637+
1638+
if !has_other_channels {
1639+
if let Err(e) = self.peer_store.remove_peer(&counterparty_node_id).await {
1640+
log_error!(
1641+
self.logger,
1642+
"Failed to remove peer {} from peer store: {}",
1643+
counterparty_node_id,
1644+
e
1645+
);
1646+
return Err(ReplayEvent());
1647+
}
1648+
}
1649+
}
1650+
16161651
let event = Event::ChannelClosed {
16171652
channel_id,
16181653
user_channel_id: UserChannelId(user_channel_id),
1619-
counterparty_node_id,
1654+
counterparty_node_id: Some(counterparty_node_id),
16201655
reason: Some(reason),
16211656
};
16221657

src/lib.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1919,10 +1919,13 @@ impl Node {
19191919
})?;
19201920
}
19211921

1922-
// Check if this was the last open channel, if so, forget the peer.
1923-
if open_channels.len() == 1 {
1924-
self.runtime.block_on(self.peer_store.remove_peer(&counterparty_node_id))?;
1925-
}
1922+
// Peer store cleanup is handled centrally in the `ChannelClosed` event handler,
1923+
// which drops the peer once its last channel reaches a terminal state that
1924+
// reconnection cannot recover. We intentionally do nothing here so that a
1925+
// force-closed peer is retained, letting the background reconnection task keep
1926+
// firing and drive the `channel_reestablish` recovery flow. This is especially
1927+
// important against LND peers, which don't always handle force-closure error
1928+
// messages correctly.
19261929
}
19271930

19281931
Ok(())

tests/common/mod.rs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1597,6 +1597,20 @@ pub(crate) async fn do_channel_full_cycle<E: ElectrumApi>(
15971597
assert!(node_b.list_balances().pending_balances_from_channel_closures.is_empty());
15981598
}
15991599

1600+
if force_close {
1601+
// Peer retained after local force-close to allow channel_reestablish recovery.
1602+
assert!(
1603+
node_a.list_peers().iter().any(|p| p.node_id == node_b.node_id() && p.is_persisted),
1604+
"node_b should remain persisted in node_a peer store after locally-initiated force-close"
1605+
);
1606+
} else {
1607+
// Peer removed after cooperative close — no further reason to reconnect.
1608+
assert!(
1609+
!node_a.list_peers().iter().any(|p| p.node_id == node_b.node_id() && p.is_persisted),
1610+
"node_b should be removed from node_a peer store after cooperative close"
1611+
);
1612+
}
1613+
16001614
let sum_of_all_payments_sat = (push_msat
16011615
+ invoice_amount_1_msat
16021616
+ overpaid_amount_msat

tests/integration_tests_rust.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,56 @@ async fn channel_full_cycle_force_close_trusted_no_reserve() {
9797
.await;
9898
}
9999

100+
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
101+
async fn peer_removed_when_counterparty_force_closes_last_channel() {
102+
// When we open a channel outbound, we persist the counterparty so the background
103+
// reconnection task can reach them. If the counterparty then force-closes what turns out
104+
// to be their last channel with us, the channel is terminal and there is nothing left for
105+
// `channel_reestablish` to recover, so the peer should be dropped from the store rather
106+
// than reconnected to forever.
107+
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();
108+
let chain_source = random_chain_source(&bitcoind, &electrsd);
109+
let (node_a, node_b) = setup_two_nodes(&chain_source, false, true, false);
110+
111+
let address_a = node_a.onchain_payment().new_address().unwrap();
112+
let premine_amount_sat = 5_000_000;
113+
premine_and_distribute_funds(
114+
&bitcoind.client,
115+
&electrsd.client,
116+
vec![address_a],
117+
Amount::from_sat(premine_amount_sat),
118+
)
119+
.await;
120+
node_a.sync_wallets().unwrap();
121+
122+
// node_a opens the channel, so node_a persists node_b in its peer store.
123+
open_channel(&node_a, &node_b, 4_000_000, false, &electrsd).await;
124+
generate_blocks_and_wait(&bitcoind.client, &electrsd.client, 6).await;
125+
node_a.sync_wallets().unwrap();
126+
node_b.sync_wallets().unwrap();
127+
128+
let _user_channel_id_a = expect_channel_ready_event!(node_a, node_b.node_id());
129+
let user_channel_id_b = expect_channel_ready_event!(node_b, node_a.node_id());
130+
131+
assert!(
132+
node_a.list_peers().iter().any(|p| p.node_id == node_b.node_id() && p.is_persisted),
133+
"node_a should persist node_b after opening a channel to it"
134+
);
135+
136+
// The counterparty force-closes their last channel with us.
137+
node_b.force_close_channel(&user_channel_id_b, node_a.node_id(), None).unwrap();
138+
139+
expect_event!(node_a, ChannelClosed);
140+
expect_event!(node_b, ChannelClosed);
141+
142+
// node_a should have dropped node_b from its peer store. We assert on `is_persisted` rather
143+
// than peer presence so a lingering transient TCP connection doesn't mask the removal.
144+
assert!(
145+
!node_a.list_peers().iter().any(|p| p.node_id == node_b.node_id() && p.is_persisted),
146+
"node_a should drop node_b from its peer store after node_b force-closed the last channel"
147+
);
148+
}
149+
100150
#[tokio::test(flavor = "multi_thread", worker_threads = 1)]
101151
async fn channel_full_cycle_0conf() {
102152
let (bitcoind, electrsd) = setup_bitcoind_and_electrsd();

0 commit comments

Comments
 (0)