diff --git a/Boss/Mod/AmountSettingsHandler.cpp b/Boss/Mod/AmountSettingsHandler.cpp index c7eaae9..9cc17a4 100644 --- a/Boss/Mod/AmountSettingsHandler.cpp +++ b/Boss/Mod/AmountSettingsHandler.cpp @@ -78,7 +78,26 @@ private: bus.subscribe([this](Msg::Option o) { - assert(settings); + /* Msg::Option was originally a startup-only signal + * (delivered once per registered option between + * Manifestation and EndOfOptions), and EndOfOptions + * moves `settings` away on line below. Now that + * SetConfigHandler can re-raise Msg::Option at + * runtime to deliver setconfig updates, this handler + * may receive events for unrelated names long after + * `settings` has been moved -- e.g. a runtime + * `setconfig clboss-xrebalance-age-secs ...` triggers + * a Msg::Option that hits every subscriber. + * + * Drop those silently: none of the options this + * handler owns is registered dynamic, so by the time + * we see a post-EndOfOptions event it cannot + * legitimately apply. This also covers a latent + * assertion crash that pre-existed dynamic options + * (any post-EOO Msg::Option for any name would have + * tripped the old assert(settings) here). */ + if (!settings) + return Ev::lift(); auto const& name = o.name; if (name == "clboss-min-onchain") { settings->reserve = parse_sats(o.value); diff --git a/Boss/Mod/Manifester.cpp b/Boss/Mod/Manifester.cpp index 1a3c53d..088d333 100644 --- a/Boss/Mod/Manifester.cpp +++ b/Boss/Mod/Manifester.cpp @@ -90,6 +90,9 @@ void Manifester::start() { .field( "description" , o.description ) + .field( "dynamic" + , o.dynamic + ) .end_object() ); } diff --git a/Boss/Mod/SetConfigHandler.cpp b/Boss/Mod/SetConfigHandler.cpp new file mode 100644 index 0000000..5cc5ec6 --- /dev/null +++ b/Boss/Mod/SetConfigHandler.cpp @@ -0,0 +1,100 @@ +#include"Boss/Mod/SetConfigHandler.hpp" +#include"Boss/Msg/CommandFail.hpp" +#include"Boss/Msg/CommandRequest.hpp" +#include"Boss/Msg/CommandResponse.hpp" +#include"Boss/Msg/ManifestOption.hpp" +#include"Boss/Msg/Option.hpp" +#include"Boss/log.hpp" +#include"Ev/Io.hpp" +#include"Jsmn/Object.hpp" +#include"Json/Out.hpp" +#include"S/Bus.hpp" + +namespace { + +/* JSON-RPC error code for malformed parameters, matching the + * JSONRPC2 invalid-params constant used elsewhere in clboss. */ +constexpr int RPC_INVALID_PARAMS = -32602; + +} + +namespace Boss { namespace Mod { + +void SetConfigHandler::start() { + bus.subscribe([this](Boss::Msg::ManifestOption const& o) { + options[o.name] = o.dynamic; + return Ev::lift(); + }); + bus.subscribe([this](Boss::Msg::CommandRequest const& m) { + if (m.command != "setconfig") + return Ev::lift(); + + auto id = m.id; + auto const& params = m.params; + + /* Extract `config` (required string). */ + if (!params.is_object() || !params.has("config") + || !params["config"].is_string()) { + return bus.raise(Boss::Msg::CommandFail{ + id, RPC_INVALID_PARAMS, + "setconfig: missing or non-string 'config' " + "parameter", + Json::Out::empty_object() + }); + } + auto name = std::string(params["config"]); + + /* Verify the option is one we registered, and is + * declared dynamic. Lightningd should never forward + * setconfig for a non-dynamic option (libplugin would + * refuse it on the receiving side too), but defending + * here keeps the error surface clear and prevents a + * surprise Msg::Option re-raise for an option whose + * handler may not expect runtime updates. */ + auto it = options.find(name); + if (it == options.end()) { + return bus.raise(Boss::Msg::CommandFail{ + id, RPC_INVALID_PARAMS, + "setconfig: unknown option '" + name + "'", + Json::Out::empty_object() + }); + } + if (!it->second) { + return bus.raise(Boss::Msg::CommandFail{ + id, RPC_INVALID_PARAMS, + "setconfig: option '" + name + + "' is not dynamic", + Json::Out::empty_object() + }); + } + + /* Forward the value as-is. Lightningd encodes the new + * value as a JSON string (see plugin_set_dynamic_opt in + * cln/lightningd/plugin.c), so handlers will see a + * Jsmn::Object with is_string() == true here even for + * numeric option types. The contract documented on + * SetConfigHandler covers this. + * + * Note: bus.raise(Msg::Option) broadcasts to every + * Msg::Option subscriber, not just the one that owns + * this option. Subscribers must filter by name and + * no-op on non-matches -- see the doc comment on + * Boss::Msg::Option for the full contract. */ + auto value = params.has("val") + ? params["val"] + : Jsmn::Object(); + return Boss::log( bus, Debug + , "SetConfigHandler: dispatching setconfig " + "'%s'" + , name.c_str() + ) + + bus.raise(Boss::Msg::Option{name, std::move(value)}) + + bus.raise(Boss::Msg::CommandResponse{ + id, Json::Out::empty_object() + }); + }); +} + +}} diff --git a/Boss/Mod/SetConfigHandler.hpp b/Boss/Mod/SetConfigHandler.hpp new file mode 100644 index 0000000..51def94 --- /dev/null +++ b/Boss/Mod/SetConfigHandler.hpp @@ -0,0 +1,53 @@ +#ifndef BOSS_MOD_SETCONFIGHANDLER_HPP +#define BOSS_MOD_SETCONFIGHANDLER_HPP + +#include +#include + +namespace S { class Bus; } + +namespace Boss { namespace Mod { + +/** class Boss::Mod::SetConfigHandler + * + * @brief Dispatches `setconfig` JSON-RPC calls from lightningd + * for options that were registered with `dynamic = true` on their + * Msg::ManifestOption. + * + * Lightningd routes `setconfig ` to the plugin that + * owns the option, as a JSON-RPC method call. We turn that into + * a fresh Msg::Option on the bus, so existing option handlers + * re-apply the new value without a plugin restart. + * + * Contract for module authors who mark an option `dynamic = true`: + * at startup lightningd delivers Int / Bool / Flag option values + * as JSON primitives, but at setconfig time lightningd encodes the + * value as a JSON string. Any module that opts in to dynamic + * updates MUST tolerate both shapes in its Msg::Option handler -- + * inspect `o.value.is_string()` and parse from the string form + * when appropriate. + */ +class SetConfigHandler { +private: + S::Bus& bus; + /* Name -> dynamic flag, populated from Msg::ManifestOption + * events during the Manifestation phase. Non-dynamic + * options are recorded too so we can return a clearer error + * than "unknown option" if lightningd ever forwards a + * setconfig for a non-dynamic name (which it should not). */ + std::map options; + + void start(); + +public: + SetConfigHandler() =delete; + SetConfigHandler(SetConfigHandler&&) =delete; + SetConfigHandler(SetConfigHandler const&) =delete; + + explicit + SetConfigHandler(S::Bus& bus_) : bus(bus_) { start(); } +}; + +}} + +#endif /* !defined(BOSS_MOD_SETCONFIGHANDLER_HPP) */ diff --git a/Boss/Mod/XMoveFunds/Main.cpp b/Boss/Mod/XMoveFunds/Main.cpp index 08d79ef..37572ea 100644 --- a/Boss/Mod/XMoveFunds/Main.cpp +++ b/Boss/Mod/XMoveFunds/Main.cpp @@ -7,7 +7,10 @@ #include"Boss/Msg/CommandResponse.hpp" #include"Boss/Msg/Init.hpp" #include"Boss/Msg/ManifestCommand.hpp" +#include"Boss/Msg/ManifestOption.hpp" #include"Boss/Msg/Manifestation.hpp" +#include"Boss/Msg/Option.hpp" +#include"Boss/Msg/TimerRandomHourly.hpp" #include"Boss/concurrent.hpp" #include"Boss/log.hpp" #include"Ev/Io.hpp" @@ -232,6 +235,14 @@ private: * wait_for_ready() so that command handling never tries to * use the layer before askrene is told about it. */ bool layer_ready; + /* Window for periodic askrene-age on the xrebalance layer. + * Tunable via the `clboss-xrebalance-age-secs` option (dynamic + * -- runtime mutable via `lightning-cli setconfig`). Default + * 3600 mirrors FundsMover's production value. Operators on + * networks with slower flows (signet) typically widen this + * via setconfig to keep accumulated capacity knowledge longer + * before constraints expire. */ + std::uint64_t aging_window_secs; /* For generating MPP groupids -- a u64 random value shared * across all parts of one xmovefunds invocation. */ std::mt19937_64 rng; @@ -379,6 +390,79 @@ private: }); } + /* Trim xrebalance-layer constraints older than + * aging_window_secs. Modeled on FundsMover's age_clboss_layer + * (Boss/Mod/FundsMover/Main.cpp). No self-loop guard refresh + * here -- xrebalance layer does not carry a self disable_node + * entry (its ephemeral per-request masking already excludes + * non-source/non-dest us-channels). + * + * RpcError taxonomy matches FundsMover: JSON-RPC -32601 + * (method not found) stays Debug for graceful degradation on + * CLN < v24.11 where askrene-age is absent; any other code is + * promoted to Warn since a sustained aging failure lets stale + * pessimism accumulate. + * + * channel_updates left to refresh-on-failure overwrite: askrene- + * age intentionally skips layer->local_updates, so this RPC + * only trims `constraints` written by inform_channel_*. Policy + * overrides written via askrene-update-channel refresh + * themselves whenever a fresh failure carries a new + * channel_update payload (gossmap_local_updatechan merges). + */ + Ev::Io age_xrebalance_layer() { + return Ev::lift().then([this]() { + auto cutoff = std::uint64_t(std::time(nullptr)) + - aging_window_secs; + auto parms = Json::Out() + .start_object() + .field( "layer" + , Boss::Mod::AskreneLayer:: + xrebalance_layer_name + ) + .field("cutoff", cutoff) + .end_object() + ; + return rpc->command( "askrene-age" + , std::move(parms) + ); + }).then([this](Jsmn::Object res) { + auto removed = std::uint64_t(0); + if (res.has("num_removed") + && res["num_removed"].is_number()) + removed = std::uint64_t(double(res["num_removed"])); + return Boss::log( bus, Debug + , "XMoveFunds: askrene-age (%s) " + "removed %" PRIu64 " stale entries." + , Boss::Mod::AskreneLayer:: + xrebalance_layer_name + .c_str() + , removed + ); + }).catching([this](RpcError const& e) { + auto code = int(0); + if (e.error.has("code") && e.error["code"].is_number()) + code = int(double(e.error["code"])); + auto is_method_missing = (code == -32601); + return Boss::log( bus + , is_method_missing ? Debug : Warn + , "XMoveFunds: askrene-age (%s) " + "failed: %s%s" + , Boss::Mod::AskreneLayer:: + xrebalance_layer_name + .c_str() + , Util::stringify(e.error).c_str() + , is_method_missing + ? " (RPC missing; aging " + "unavailable on this CLN)." + : " (unexpected; stale " + "entries will accumulate " + "until next successful " + "aging pass)." + ); + }); + } + /* Gate command handling on startup completion: rpc must have * arrived via Msg::Init, and create_xrebalance_layer() must * have completed (either successfully or via the logged- @@ -1380,6 +1464,7 @@ public: , rpc(nullptr) , claimer(bus_) , layer_ready(false) + , aging_window_secs(3600) , rng(static_cast( std::chrono::system_clock::now() .time_since_epoch().count())) { @@ -1416,6 +1501,82 @@ public: "xrebalance algorithm; the caller " "specifies the explicit channel set.", false + }) + bus.raise(Msg::ManifestOption{ + "clboss-xrebalance-age-secs", + Msg::OptionType_Int, + Json::Out::direct(aging_window_secs), + "Cutoff (seconds) for periodic askrene-age " + "on the persistent clboss-xrebalance " + "layer. Constraints older than this are " + "trimmed once per TimerRandomHourly tick " + "so stale capacity pessimism does not " + "accumulate forever. Dynamic: settable " + "at runtime via `lightning-cli setconfig " + "clboss-xrebalance-age-secs `. " + "Default 3600 (1h); operators on slower " + "networks (signet) typically widen this.", + /* dynamic = */ true + }); + }); + bus.subscribe([this](Msg::Option const& o) { + if (o.name != "clboss-xrebalance-age-secs") + return Ev::lift(); + /* At startup lightningd sends Int options as a + * JSON number primitive (Initiator forwards the + * value verbatim from the init request); at + * runtime lightningd's setconfig path encodes + * the value as a JSON string (see + * cln/lightningd/plugin.c + * plugin_set_dynamic_opt). Tolerate both. */ + auto secs = std::uint64_t(0); + try { + if (o.value.is_number()) { + secs = std::uint64_t(double(o.value)); + } else if (o.value.is_string()) { + secs = std::stoull(std::string(o.value)); + } else { + return Boss::log( bus, Warn + , "XMoveFunds: " + "clboss-xrebalance-" + "age-secs: " + "unsupported value " + "type; keeping " + "%" PRIu64 "." + , aging_window_secs + ); + } + } catch (std::exception const& e) { + return Boss::log( bus, Warn + , "XMoveFunds: clboss-" + "xrebalance-age-secs: " + "parse error '%s'; " + "keeping %" PRIu64 "." + , e.what() + , aging_window_secs + ); + } + if (secs == 0) { + return Boss::log( bus, Warn + , "XMoveFunds: clboss-" + "xrebalance-age-secs: " + "must be > 0; keeping " + "%" PRIu64 "." + , aging_window_secs + ); + } + aging_window_secs = secs; + return Boss::log( bus, Info + , "XMoveFunds: xrebalance layer " + "aging window = %" PRIu64 + " seconds" + , aging_window_secs + ); + }); + bus.subscribe([this](Msg::TimerRandomHourly const&) { + return wait_for_ready().then([this]() { + return age_xrebalance_layer(); }); }); bus.subscribe all( std::ostream& cout /* Startup. */ all->install(bus); all->install(bus, threadpool, std::move(open_rpc_socket)); + all->install(bus); /* General settings. */ all->install(bus); diff --git a/Boss/Msg/ManifestOption.hpp b/Boss/Msg/ManifestOption.hpp index 222e8e4..4883c58 100644 --- a/Boss/Msg/ManifestOption.hpp +++ b/Boss/Msg/ManifestOption.hpp @@ -17,6 +17,14 @@ struct ManifestOption { OptionType type; Json::Out default_value; std::string description; + /* If true, lightningd will accept `setconfig ` at + * runtime for this option and forward the new value to clboss + * via the `setconfig` JSON-RPC method. SetConfigHandler turns + * that into a fresh Msg::Option on the bus, so existing option + * handlers re-apply the new value without a plugin restart. + * Default false preserves the original startup-only contract. + */ + bool dynamic = false; }; }} diff --git a/Boss/Msg/Option.hpp b/Boss/Msg/Option.hpp index 8b4d946..6ab22b9 100644 --- a/Boss/Msg/Option.hpp +++ b/Boss/Msg/Option.hpp @@ -8,8 +8,21 @@ namespace Boss { namespace Msg { /** struct Boss::Msg::Option * - * @brief emitted during `init` handling, providing the value - * of an option that we registered. + * @brief providing the value of an option we registered. + * + * Emitted during `init` handling (one Msg::Option per option + * lightningd actually carried in the init request), AND re- + * emitted by Boss::Mod::SetConfigHandler at runtime when + * lightningd forwards a `setconfig` JSON-RPC call for an option + * we registered as `dynamic = true`. + * + * Subscribers MUST filter by `name` and no-op for names they do + * not own (the bus broadcasts to all Msg::Option subscribers, so + * a dynamic option update for module A will be delivered to + * module B as well). Subscribers MUST also tolerate post-init + * arrival -- any local invariants that were valid only "between + * Manifestation and EndOfOptions" must be re-checked rather than + * asserted. */ struct Option { std::string name; diff --git a/Makefile.am b/Makefile.am index 6d98cec..4197c88 100644 --- a/Makefile.am +++ b/Makefile.am @@ -258,6 +258,8 @@ libclboss_la_SOURCES = \ Boss/Mod/SelfUptimeMonitor.hpp \ Boss/Mod/SendpayResultMonitor.cpp \ Boss/Mod/SendpayResultMonitor.hpp \ + Boss/Mod/SetConfigHandler.cpp \ + Boss/Mod/SetConfigHandler.hpp \ Boss/Mod/StatusCommand.cpp \ Boss/Mod/StatusCommand.hpp \ Boss/Mod/SwapManager.cpp \