From 8eacce41ecdde76cf7d1cfc102e5c4fabedfde31 Mon Sep 17 00:00:00 2001 From: Matt Whitlock Date: Sat, 17 Jan 2026 06:11:47 -0500 Subject: [PATCH 1/2] lightningd: add "snub-idle-channels" dynamic config variable When "snub-idle-channels" is set to true, lightningd will no longer spawn channeld subdaemons for channels that have no outstanding HTLCs, and it will cease trying to auto-reconnect to peers with whom we have no outstanding HTLCs. Incoming channel_reestablish messages for these idle channels will cause lightningd to reply to the peer with a warning explaining that we are temporarily declining to reestablish the channel. Since we do not send our own channel_reestablish, the peer is unable to add any HTLCs to the channel (or make any other updates to the channel). The reason we might want to do this is so we can halt a node gracefully by progressively snubbing more and more channels as they become idle until eventually we have no outstanding HTLCs whatsoever and also no possibility of any new HTLCs being added. At that point, we can safely take our node offline for an extended duration with no possibility that any of our channels will be unilaterally closed due to HTLC deadlines while we are offline. Changelog-Added: New `snub-idle-channels` dynamic config variable makes CLN temporarily stop spawning channeld subdaemons for channels with no HTLCs, as a means to achieve a safe node shutdown. Issue: https://github.com/ElementsProject/lightning/issues/4842 --- lightningd/lightningd.c | 1 + lightningd/lightningd.h | 4 ++++ lightningd/options.c | 15 +++++++++++++++ lightningd/peer_control.c | 34 ++++++++++++++++++++++++++++++++-- 4 files changed, 52 insertions(+), 2 deletions(-) diff --git a/lightningd/lightningd.c b/lightningd/lightningd.c index 3e21caa69482..9f5a4257d413 100644 --- a/lightningd/lightningd.c +++ b/lightningd/lightningd.c @@ -253,6 +253,7 @@ static struct lightningd *new_lightningd(const tal_t *ctx) ld->discovered_ip_v6 = NULL; ld->listen = true; ld->autolisten = true; + ld->snub_idle_channels = false; ld->reconnect = true; ld->reconnect_private = true; ld->try_reexec = false; diff --git a/lightningd/lightningd.h b/lightningd/lightningd.h index 3b4e0e84d904..8c7e350be85c 100644 --- a/lightningd/lightningd.h +++ b/lightningd/lightningd.h @@ -179,6 +179,10 @@ struct lightningd { /* Do we want to guess addresses to listen and announce? */ bool autolisten; + /* Do we want to avoid reestablishing channels with zero outstanding HTLCs? + * This is useful for gracefully stopping the node. */ + bool snub_idle_channels; + /* Setup: Addresses to bind/announce to the network (tal_count()) */ struct wireaddr_internal *proposed_wireaddr; /* Setup: And the bitset for each, whether to listen, announce or both */ diff --git a/lightningd/options.c b/lightningd/options.c index b724b200a089..fbc85e5ad90e 100644 --- a/lightningd/options.c +++ b/lightningd/options.c @@ -109,6 +109,17 @@ static char *opt_set_s32(const char *arg, s32 *u) return NULL; } +static char *opt_set_bool_dynamic(const char *arg, bool *b) +{ + bool ignored; + + /* In case we're called for arg checking only */ + if (!b) + b = &ignored; + + return opt_set_bool_arg(arg, b); +} + char *opt_set_autobool_arg(const char *arg, enum opt_autobool *b) { if (!strcasecmp(arg, "yes") || @@ -1585,6 +1596,10 @@ static void register_opts(struct lightningd *ld) "Sets the public TCP port to use for announcing discovered IPs."); opt_register_noarg("--offline", opt_set_offline, ld, "Start in offline-mode (do not automatically reconnect and do not accept incoming connections)"); + clnopt_witharg("--snub-idle-channels", OPT_SHOWBOOL|OPT_DYNAMIC, + opt_set_bool_dynamic, opt_show_bool, + &ld->snub_idle_channels, + "If true, do not reestablish channels with zero outstanding HTLCs"); clnopt_witharg("--autolisten", OPT_SHOWBOOL, opt_set_bool_arg, opt_show_bool, &ld->autolisten, diff --git a/lightningd/peer_control.c b/lightningd/peer_control.c index ed00651814bc..57b563c0a709 100644 --- a/lightningd/peer_control.c +++ b/lightningd/peer_control.c @@ -1382,6 +1382,13 @@ peer_connected_serialize(struct peer_connected_hook_payload *payload, json_object_end(stream); /* .peer */ } +static bool should_snub_channel(const struct lightningd *ld, + /*const*/ struct channel *channel) +{ + return ld->snub_idle_channels && !channel_state_closed(channel->state) && + !channel_has_htlc_out(channel) && !channel_has_htlc_in(channel); +} + /* Talk to connectd about an active channel */ static void connect_activate_subd(struct lightningd *ld, struct channel *channel) { @@ -1558,6 +1565,12 @@ static void peer_connected_hook_final(struct peer_connected_hook_payload *payloa list_for_each(&peer->channels, channel, list) { /* FIXME: It can race by opening a channel before this! */ if (channel_state_wants_peercomms(channel->state) && !channel->owner) { + if (should_snub_channel(ld, channel)) { + log_debug(channel->log, + "Peer has reconnected, but channel is snubbed; " + "not connecting subd"); + continue; + } log_debug(channel->log, "Peer has reconnected, state %s: connecting subd", channel_state_name(channel)); @@ -2064,6 +2077,22 @@ void handle_peer_spoke(struct lightningd *ld, const u8 *msg) return; } + if (msgtype == WIRE_CHANNEL_REESTABLISH && + should_snub_channel(ld, channel)) { + log_debug(channel->log, + "Peer sent channel_reestablish, but channel is snubbed; " + "sending warning and ignoring"); + error = towire_warningfmt(tmpctx, &channel_id, + "Declining to reestablish idle channel " + "because this node will be halting soon."); + /* Don't goto send_error; we don't want to disconnect. */ + subd_send_msg(ld->connectd, + take(towire_connectd_peer_send_msg(NULL, &peer->id, + peer->connectd_counter, + error))); + return; + } + log_debug(channel->log, "channel already active"); if (channel->state == DUALOPEND_AWAITING_LOCKIN) { pfd = sockpair(tmpctx, channel, &other_fd, &error); @@ -2098,7 +2127,7 @@ void handle_peer_spoke(struct lightningd *ld, const u8 *msg) } if (peer->uncommitted_channel) { error = towire_errorfmt(tmpctx, &channel_id, - "Multiple simulteneous opens not supported"); + "Multiple simultaneous opens not supported"); goto send_error; } peer->uncommitted_channel = new_uncommitted_channel(peer); @@ -2868,7 +2897,8 @@ static void setup_peer(struct peer *peer) && !(channel->channel_flags & CHANNEL_FLAGS_ANNOUNCE_CHANNEL)) continue; - if (channel_state_wants_peercomms(channel->state)) + if (channel_state_wants_peercomms(channel->state) && + !should_snub_channel(ld, channel)) connect = true; if (channel_important_filter(channel, NULL)) important = true; From 1c7f46afb5f378da9d9c051345840992c7897237 Mon Sep 17 00:00:00 2001 From: Matt Whitlock Date: Sun, 18 Jan 2026 01:22:25 -0500 Subject: [PATCH 2/2] contrib: add lightning-graceful-stop.sh script This script utilizes the new "snub-idle-channels" knob to attempt to stop a CLN node gracefully. The script sets the snub flag and then starts forcibly disconnecting peers that have one or more reestablished channels but no outstanding HTLCs. When both the number of reestablished channels and the number of outstanding HTLCs reach zero, the script stops the node. If this does not occur before a user-specified timeout, then the script exits with an error and reports the block height and approximate time until the next outstanding HTLC expires. Changelog-Added: `contrib/lightning-graceful-stop.sh` attempts to stop a node without leaving any outstanding HTLCs. Closes: https://github.com/ElementsProject/lightning/issues/4842 --- contrib/lightning-graceful-stop.sh | 60 ++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100755 contrib/lightning-graceful-stop.sh diff --git a/contrib/lightning-graceful-stop.sh b/contrib/lightning-graceful-stop.sh new file mode 100755 index 000000000000..9a4b69db7b84 --- /dev/null +++ b/contrib/lightning-graceful-stop.sh @@ -0,0 +1,60 @@ +#!/bin/bash +set -e + +: "${TIMEOUT:=${1:-60}}" +let DEADLINE=EPOCHSECONDS+TIMEOUT + +lightning-cli() { + echo lightning-cli "${@@Q}" >&2 + command lightning-cli "${@}" >&2 +} + +lightning-cli setconfig snub-idle-channels true true +while (( EPOCHSECONDS < DEADLINE )) ; do + echo "Attempting graceful stop ($((DEADLINE - EPOCHSECONDS))s remaining) ..." + while read -r rpc ; do + if [[ "${rpc}" == '# '* ]] ; then + set ${rpc} + echo "# ${2} reestablished channels, ${3} outstanding HTLCs" >&2 + next_expiry=${4} + else + eval "lightning-cli ${rpc}" + if [[ "${rpc}" == stop ]] ; then + echo 'Graceful stop succeeded.' + exit 0 + fi + fi + done < <(command lightning-cli listpeerchannels | jq -r ' + reduce (.channels[] | select(.state | IN("CHANNELD_NORMAL", "CHANNELD_AWAITING_SPLICE"))) + as { $peer_id, $peer_connected, $reestablished, $state, $htlcs } + ( + {}; + .[$peer_id] |= ( + .connected |= . or $peer_connected | + .reestablished += if $reestablished then 1 else 0 end | + .htlcs += ($htlcs | length) | + .next_expiry |= ([. // empty, $htlcs[].expiry] | min) + ) + ) | + ( + "# \(map(.reestablished) | add) \(map(.htlcs) | add) \(map(.next_expiry // empty) | min)", + if all(.reestablished == 0) and all(.htlcs == 0) then + "stop" + else + to_entries[] | + select(.value | .connected and .reestablished > 0 and .htlcs == 0) | + @sh "disconnect \(.key) true" + end + ) + ') + sleep 1 +done +let headercount=$(command lightning-cli getchaininfo | jq '.headercount') +fmt --width="${COLUMNS:-80}" <