graceful: add a timeout for user simplicity.
What changed, and why it matters
This commit adds an optional timeout to the 'graceful' RPC command in Core Lightning, which operators use when preparing a node to shut down. Previously, graceful would wait indefinitely until all payment commitments (HTLCs) finished and idle peers disconnected. Now, if a timeout is provided, the command returns after that many seconds and reports any still-pending HTLC expiry heights and connected peers. This is a usability improvement for scripting shutdowns, not a security fix. The code changes are straightforward and do not appear to introduce a vulnerability.
No security action required. Treat as a normal feature commit. Reviewers may optionally verify that graceful_timeout cannot be re-entered and that the timer is canceled when the command completes normally, but the destructor addition already mitigates the main lifetime concern.
Security signals we found
No security-relevant signal: change is a feature/usability enhancement to an existing RPC command.
New RPC parameter is optional and typed u32/u64, bounded by JSON-RPC input parsing.
Destructor addition prevents use-after-free/list corruption if a graceful waiter is freed before completion, which is a robustness improvement rather than a confirmed vulnerability fix.
No mention of CVE, security advisory, bug bounty, or vulnerability disclosure in commit message or diff.
Evidence from the diff
The patch extends json_graceful in lightningd/peer_control.c to accept an optional ‘timeout’ parameter (u64 seconds). When supplied, it arms a relative timer that calls graceful_timeout, which collects unresolved htlc_out and htlc_in CLTV expiries, sorts them, gathers still-connected peer node IDs, and returns them in a JSON response. A destructor was added to remove graceful_waiter entries from the linked list on free. Tests were updated to exercise the timeout path and verify pending_htlc_expiries/pending_peers output. The schema and msggen schema were updated accordingly.
Changed components
lightningd/peer_control.c: graceful RPC implementationdoc/schemas/graceful.json: RPC schema documentationcontrib/msggen/msggen/schema.json: generated schematests/test_misc.py: graceful testsInspect captured patch +154 / −11
diff --git a/contrib/msggen/msggen/schema.json b/contrib/msggen/msggen/schema.json
index 16dcee18..59969400 100644
--- a/contrib/msggen/msggen/schema.json
+++ b/contrib/msggen/msggen/schema.json
@@ -16869,17 +16869,44 @@
"added": "v26.06",
"title": "Command to prepare Core Lightning node for stopping.",
"description": [
- "**graceful** is a RPC command to prevent further htlcs, and disconnect all idle peers. It returns when all HTLCs are complete, and all peers disconnected: then you can shutdown. It also sends information about HTLC expiry, so you can judge how long it is safe to be offline."
+ "**graceful** is a RPC command to prevent further htlcs, and disconnect all idle peers. It returns when all HTLCs are complete, and all peers disconnected: then you can shutdown. It also sends notifications about HTLC expiry, so you can judge how long it is safe to be offline.",
+ "With a timeout, it always returns after that many seconds: if any peer connections or HTLCs are still pending, those are returned. An empty response means nothing is pending"
],
"request": {
"required": [],
"additionalProperties": false,
- "properties": {}
+ "properties": {
+ "timeout": {
+ "type": "u32",
+ "description": [
+ "If set, the graceful command will return after this time even if not all HTLCs have terminated. Useful for scripting, where you may want to follow with `stop`."
+ ]
+ }
+ }
},
"response": {
"required": [],
"additionalProperties": false,
- "properties": {}
+ "properties": {
+ "pending_htlc_expiries": {
+ "type": "array",
+ "items": {
+ "type": "u32"
+ },
+ "description": [
+ "The (sorted) expiry blockheights of all HTLCs which are not resolved"
+ ]
+ },
+ "pending_peers": {
+ "type": "array",
+ "items": {
+ "type": "pubkey"
+ },
+ "description": [
+ "Any peers still connected (presumably because they have outstanding HTLCs)"
+ ]
+ }
+ }
},
"author": [
"Rusty Russell [rusty@rustcorp.com.au](mailto:rusty@rustcorp.com.au) is mainly responsible."
diff --git a/doc/schemas/graceful.json b/doc/schemas/graceful.json
index 04a7acd4..da05c97d 100644
--- a/doc/schemas/graceful.json
+++ b/doc/schemas/graceful.json
@@ -5,17 +5,44 @@
"added": "v26.06",
"title": "Command to prepare Core Lightning node for stopping.",
"description": [
- "**graceful** is a RPC command to prevent further htlcs, and disconnect all idle peers. It returns when all HTLCs are complete, and all peers disconnected: then you can shutdown. It also sends information about HTLC expiry, so you can judge how long it is safe to be offline."
+ "**graceful** is a RPC command to prevent further htlcs, and disconnect all idle peers. It returns when all HTLCs are complete, and all peers disconnected: then you can shutdown. It also sends notifications about HTLC expiry, so you can judge how long it is safe to be offline.",
+ "With a timeout, it always returns after that many seconds: if any peer connections or HTLCs are still pending, those are returned. An empty response means nothing is pending"
],
"request": {
"required": [],
"additionalProperties": false,
- "properties": {}
+ "properties": {
+ "timeout": {
+ "type": "u32",
+ "description": [
+ "If set, the graceful command will return after this time even if not all HTLCs have terminated. Useful for scripting, where you may want to follow with `stop`."
+ ]
+ }
+ }
},
"response": {
"required": [],
"additionalProperties": false,
- "properties": {}
+ "properties": {
+ "pending_htlc_expiries": {
+ "type": "array",
+ "items": {
+ "type": "u32"
+ },
+ "description": [
+ "The (sorted) expiry blockheights of all HTLCs which are not resolved"
+ ]
+ },
+ "pending_peers": {
+ "type": "array",
+ "items": {
+ "type": "pubkey"
+ },
+ "description": [
+ "Any peers still connected (presumably because they have outstanding HTLCs)"
+ ]
+ }
+ }
},
"author": [
"Rusty Russell [rusty@rustcorp.com.au](mailto:rusty@rustcorp.com.au) is mainly responsible."
diff --git a/lightningd/peer_control.c b/lightningd/peer_control.c
index 42ab3e9d..81af0e8a 100644
--- a/lightningd/peer_control.c
+++ b/lightningd/peer_control.c
@@ -3045,8 +3045,14 @@ struct graceful_waiter {
struct list_node list;
struct command *cmd;
const char *last_msg;
+ struct oneshot *timeout;
};
+static void destroy_graceful_waiter(struct graceful_waiter *gw)
+{
+ list_del(&gw->list);
+}
+
static struct command_result *check_graceful_shutdown_progress(struct lightningd *ld, struct command *cmd)
{
struct htlc_out_map_iter outi;
@@ -3103,10 +3109,12 @@ static struct command_result *check_graceful_shutdown_progress(struct lightningd
} else if (num_connected) {
msg = tal_fmt(tmpctx, "%zu peers still connected", num_connected);
} else {
+ struct graceful_waiter *next;
/* All finished! */
if (cmd)
return command_success(cmd, json_stream_success(cmd));
- while ((w = list_pop(&ld->graceful_commands, struct graceful_waiter, list)) != NULL)
+ /* destroy_graceful_waiter removes them from list. */
+ list_for_each_safe(&ld->graceful_commands, w, next, list)
was_pending(command_success(w->cmd, json_stream_success(w->cmd)));
return NULL;
}
@@ -3124,6 +3132,67 @@ static struct command_result *check_graceful_shutdown_progress(struct lightningd
return NULL;
}
+static int cmp_height(const u32 *a,
+ const u32 *b,
+ void *unused)
+{
+ if (*a > *b)
+ return 1;
+ if (*b < *a)
+ return -1;
+ return 0;
+}
+
+static void graceful_timeout(struct graceful_waiter *gw)
+{
+ struct htlc_out_map_iter outi;
+ const struct htlc_out *hout;
+ struct htlc_in_map_iter ini;
+ const struct htlc_in *hin;
+ struct peer *peer;
+ struct peer_node_id_map_iter it;
+ struct lightningd *ld = gw->cmd->ld;
+ u32 *heights = tal_arr(tmpctx, u32, 0);
+ struct node_id *peers = tal_arr(tmpctx, struct node_id, 0);
+ struct json_stream *result;
+
+ /* Report on any remaining htlcs */
+ for (hout = htlc_out_map_first(ld->htlcs_out, &outi);
+ hout;
+ hout = htlc_out_map_next(ld->htlcs_out, &outi)) {
+ tal_arr_expand(&heights, hout->cltv_expiry);
+ }
+ for (hin = htlc_in_map_first(ld->htlcs_in, &ini);
+ hin;
+ hin = htlc_in_map_next(ld->htlcs_in, &ini)) {
+ tal_arr_expand(&heights, hin->cltv_expiry);
+ }
+
+ asort(heights, tal_count(heights), cmp_height, NULL);
+
+ for (peer = peer_node_id_map_first(ld->peers, &it);
+ peer;
+ peer = peer_node_id_map_next(ld->peers, &it)) {
+ if (peer->connected != PEER_DISCONNECTED)
+ tal_arr_expand(&peers, peer->id);
+ }
+
+ result = json_stream_success(gw->cmd);
+ if (tal_count(heights)) {
+ json_array_start(result, "pending_htlc_expiries");
+ for (size_t i = 0; i < tal_count(heights); i++)
+ json_add_u32(result, NULL, heights[i]);
+ json_array_end(result);
+ }
+ if (tal_count(peers)) {
+ json_array_start(result, "pending_peers");
+ for (size_t i = 0; i < tal_count(peers); i++)
+ json_add_node_id(result, NULL, &peers[i]);
+ json_array_end(result);
+ }
+ was_pending(command_success(gw->cmd, result));
+}
+
void check_graceful_shutdown(struct lightningd *ld)
{
check_graceful_shutdown_progress(ld, NULL);
@@ -3135,8 +3204,11 @@ static struct command_result *json_graceful(struct command *cmd,
const jsmntok_t *params)
{
struct graceful_waiter *gw = tal(cmd, struct graceful_waiter);
+ u64 *timeout;
- if (!param(cmd, buffer, params, NULL))
+ if (!param(cmd, buffer, params,
+ p_opt("timeout", param_u64, &timeout),
+ NULL))
return command_param_failed();
log_unusual(cmd->ld->log, "JSON-RPC graceful: preventing more connections");
@@ -3144,7 +3216,12 @@ static struct command_result *json_graceful(struct command *cmd,
gw->cmd = cmd;
gw->last_msg = NULL;
+ if (timeout)
+ gw->timeout = new_reltimer(cmd->ld->timers, gw,
+ time_from_sec(*timeout),
+ graceful_timeout, gw);
list_add_tail(&cmd->ld->graceful_commands, &gw->list);
+ tal_add_destructor(gw, destroy_graceful_waiter);
return check_graceful_shutdown_progress(cmd->ld, cmd);
}
diff --git a/tests/test_misc.py b/tests/test_misc.py
index 2cdaa5df..0d0bd201 100644
--- a/tests/test_misc.py
+++ b/tests/test_misc.py
@@ -4303,6 +4303,8 @@ def test_graceful_no_peers(node_factory):
"""graceful with no channels returns immediately"""
l1 = node_factory.get_node()
assert l1.rpc.graceful() == {}
+ # Returns instantly even with timeout.
+ assert l1.rpc.graceful(10000) == {}
def test_graceful_idle_peer(node_factory, executor):
@@ -4352,17 +4354,27 @@ def test_graceful_htlc(node_factory, executor):
# Wait until graceful has sent at least one HTLC expiry notification
wait_for(lambda: len(notifications) == 1)
+ wait_for(lambda: notifications[0] == f'Next HTLC SENT_ADD_ACK_REVOCATION expires at block #118 (10 blocks from now) going to peer {l3.info["id"]} (connected)')
+
+ # This will tell us about htlcs and the peers (peers unordered)
+ ret = l2.rpc.graceful(1)
+ assert ret in ({'pending_htlc_expiries': [118, 124],
+ 'pending_peers': [l1.info['id'], l3.info['id']]},
+ {'pending_htlc_expiries': [118, 124],
+ 'pending_peers': [l3.info['id'], l1.info['id']]})
# Close incoming connection, so incoming HTLC gets stuck.
l1.rpc.disconnect(l2.info['id'], force=True)
+ wait_for(lambda: notifications[-1] == f'Next HTLC SENT_ADD_ACK_REVOCATION expires at block #118 (10 blocks from now) going to peer {l3.info["id"]} (connected)')
# Release the hold so the *outgoing* HTLC resolves
open(os.path.join(l3.daemon.lightning_dir, TEST_NETWORK, "unhold"), "w").close()
- wait_for(lambda: notifications[0] == f'Next HTLC SENT_ADD_ACK_REVOCATION expires at block #118 (10 blocks from now) going to peer {l3.info["id"]} (connected)')
- wait_for(lambda: len(notifications) == 2)
+ wait_for(lambda: notifications[-1] == f'Next HTLC SENT_REMOVE_HTLC expires at block #124 (16 blocks from now) coming from peer {l1.info["id"]} (disconnected)')
+
+ ret = l2.rpc.graceful(1)
+ assert ret == {'pending_htlc_expiries': [124]}
- wait_for(lambda: notifications[1] == f'Next HTLC SENT_REMOVE_HTLC expires at block #124 (16 blocks from now) coming from peer {l1.info["id"]} (disconnected)')
# Reconnect and it will settle.
l1.rpc.connect(l2.info['id'], 'localhost', l2.port)
Why this scored 18/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.