From 262afe82efc8a52caf92ed958113f4bd52c297b3 Mon Sep 17 00:00:00 2001 From: Ken Sedgwick Date: Fri, 7 Aug 2026 12:27:09 -0700 Subject: [PATCH] contrib: cln-plugin-bounce picks up config edits on restart lightningd parses its config files once, at its own startup; a dynamic "plugin start" hands the plugin the option values memorized back then, so bouncing a plugin after a config edit silently restarted it on stale settings. Snapshot listconfigs before stopping anything to learn which options belong to each bounced plugin and which config files lightningd actually loaded (main, network, includes, config.setconfig), then re-read those files and compare each plugin's current file values against its running values. The restart is two-phase because "plugin start" with option parameters segfaults lightningd through at least v26.04 whenever a configvar names an unregistered option -- precisely the state while a sibling plugin is stopped (configvar_finalize_overrides dereferences opt_find_long() without a NULL check; fix pending as "setconfig: fix crash when a configvar outlives its plugin option"). Phase one is the classic bounce -- ordered stops, reverse bare starts -- which cannot trip the bug. Phase two, only for plugins whose file values differ, stops and restarts each such plugin alone with its file options passed in keyword form on the "plugin start" line, while every other plugin is up. With no edits pending the bounce is exactly the classic single pass. As a final guard, the apply phase is refused with a warning when any option named in the config files is unregistered (e.g. a plugin stopped by hand), and an optioned start that is rejected degrades to a bare restart with a warning: a running plugin on stale values beats a stopped one. Field note: the first, single-phase version of this change crashed prod1's lightningd (v26.04.1) through exactly this path -- plugin_add_params -> configvar_finalize_overrides -- a second reproduction route for the pending fix, needing no version skew, just a multi-plugin bounce with a pending config edit. --- contrib/cln-plugin-bounce | 249 +++++++++++++++++++++++++++++++++++--- 1 file changed, 232 insertions(+), 17 deletions(-) diff --git a/contrib/cln-plugin-bounce b/contrib/cln-plugin-bounce index d5c8a43..b2f20ec 100755 --- a/contrib/cln-plugin-bounce +++ b/contrib/cln-plugin-bounce @@ -14,6 +14,36 @@ # the restart uses that, so a repointed symlink brings up the new # version. # +# lightningd parses its config files once, at its own startup; a +# bare "plugin start" hands the restarted plugin the option values +# lightningd memorized back then, not what the files say now. So +# that a bounce picks up config edits, this script re-reads each +# plugin's own options from the config files lightningd loaded (as +# reported by listconfigs) and, where they differ from the running +# values, passes them on the "plugin start" line, where they +# override the memorized ones. The files are concatenated in +# sorted-path order and the last occurrence of an option wins; a +# bare flag line is passed as "name=true". +# +# The restart is two-phase because of a lightningd bug present +# through at least v26.04 (fix pending: "setconfig: fix crash when a +# configvar outlives its plugin option"): a "plugin start" WITH +# option parameters segfaults lightningd when any option named in +# its configvars is unregistered, which is exactly the state while a +# sibling plugin is stopped. Phase one is the classic bounce -- +# ordered stops, reverse bare starts -- which cannot trip the bug. +# Phase two then, for each plugin whose file values differ from its +# running values, stops and restarts just that plugin with the +# differing values passed as options, while every other plugin is +# up. When nothing differs, phase two is a no-op and the bounce is +# exactly the classic single pass. As a belt-and-suspenders check, +# phase two is skipped entirely (with a warning) if any option named +# in the config files is not currently registered -- e.g. some other +# plugin was stopped by hand -- since that is the state that crashes +# unfixed lightningd. If an optioned start is rejected, the plugin +# is restarted bare with a warning: a running plugin on stale values +# beats a stopped one. +# # Plugin names are the arguments that do not start with "-"; each is # a short name ("clboss", "xrebalance"), or a full versioned basename # if the short name matches more than one running plugin. All other @@ -77,6 +107,109 @@ done <&2 + fi + done >"$tmpd/cfg" + +while read -r n path; do + [ -n "$n" ] || continue + optnames=$(printf '%s\n' "$configs" \ + | jq -r --arg p "$path" '.configs | to_entries[] + | select((.value.plugin? // "") == $p) | .key' \ + | tr '\n' ' ') + awk -v names="$optnames" ' + BEGIN { + n = split(names, a, " ") + for (i = 1; i <= n; i++) + if (a[i] != "") want[a[i]] = 1 + } + { + sub(/^[ \t]+/, "") + if ($0 == "" || $0 ~ /^#/) next + eq = index($0, "=") + name = (eq ? substr($0, 1, eq - 1) : $0) + if (!(name in want)) next + if (!(name in val)) order[++cnt] = name + val[name] = (eq ? $0 : $0 "=true") + } + END { for (i = 1; i <= cnt; i++) print val[order[i]] } + ' <"$tmpd/cfg" >"$tmpd/$n.opts" +done < $v" + edits=1 + fi + done <"$tmpd/$n.opts" + [ -z "$edits" ] || : >"$tmpd/$n.apply" +done </dev/null; then + if [ -z "$unsafe" ]; then + echo "$me: warning: config option(s) not registered (a plugin may be stopped); config edits will not be applied this run:" >&2 + unsafe=1 + fi + echo " $k" >&2 + fi +done </dev/null +} + +# Start plugin (short name $1, stopped path $2), preferring the +# unversioned sibling and falling back to the exact path; append the +# options in file $3 when non-empty, degrading to a bare start with a +# warning when the optioned starts are rejected. The rest of the +# arguments are the lightning-cli options. +start_plugin() { + sp_n=$1 + sp_path=$2 + sp_optfile=$3 + shift 3 + sp_start=$(dirname "$sp_path")/$sp_n + [ -e "$sp_start" ] || sp_start=$sp_path + echo "starting $sp_start" + [ -z "$sp_optfile" ] || sed 's/^/ /' "$sp_optfile" + if try_start "$sp_start" "$sp_optfile" "$@"; then + return 0 + fi + if [ "$sp_start" != "$sp_path" ]; then + echo "$me: start failed; retrying with $sp_path" >&2 + if try_start "$sp_path" "$sp_optfile" "$@"; then + return 0 + fi + fi + if [ -n "$sp_optfile" ]; then + echo "$me: start failed; retrying without config options" >&2 + if try_start "$sp_start" "" "$@" \ + || { [ "$sp_start" != "$sp_path" ] \ + && try_start "$sp_path" "" "$@"; }; then + echo "$me: warning: $sp_n started without re-read config options" >&2 + return 0 + fi + fi + echo "$me: failed to start $sp_n" >&2 + return 1 +} + +# Phase one: start in reverse order, bare. failed= while read -r n path; do [ -n "$n" ] || continue - start=$(dirname "$path")/$n - [ -e "$start" ] || start=$path - echo "starting $start" - if ! "$lcli" "$@" plugin start "$start" >/dev/null; then - ok= - if [ "$start" != "$path" ]; then - echo "$me: start failed; retrying with $path" >&2 - if "$lcli" "$@" plugin start "$path" >/dev/null; then - ok=1 - fi - fi - if [ -z "$ok" ]; then - echo "$me: failed to start $n" >&2 - failed=1 - fi + if ! start_plugin "$n" "$path" "" "$@"; then + failed=1 + : >"$tmpd/$n.failed" fi done <&2 + continue + fi + if [ -n "$unsafe" ]; then + echo "$me: not applying config edits to $n (see warning above)" >&2 + failed=1 + continue + fi + echo "applying config edits to $n" + plugins=$("$lcli" "$@" plugin list) + cur=$(printf '%s\n' "$plugins" | jq -r --arg n "$n" "$match") + if [ -z "$cur" ] \ + || [ "$(printf '%s\n' "$cur" | wc -l)" -gt 1 ]; then + echo "$me: cannot re-resolve '$n'; config edits not applied" >&2 + failed=1 + continue + fi + echo "stopping $cur" + if ! "$lcli" "$@" plugin stop "$cur" >/dev/null; then + echo "$me: stop failed for $cur; config edits not applied" >&2 + failed=1 + continue + fi + if ! start_plugin "$n" "$cur" "$tmpd/$n.opts" "$@"; then + failed=1 + fi +done <