bwatch: notify watchman on block_processed
What changed, and why it matters
This commit hardens crash recovery in Core Lightning's new block-watching plugin (bwatch). Previously, bwatch would immediately fetch the next Bitcoin block after saving one. Now it waits for lightningd's 'watchman' component to confirm it has also recorded the same block height before continuing. This prevents a situation where bwatch moves ahead of the main daemon, so if the node crashes and restarts, no block that bwatch acted on is invisible to the rest of the system.
No immediate action required. This is a defensive correctness fix. Operators and downstream integrators should ensure they run a version containing this commit if using the bwatch plugin, and monitor logs for 'block_processed RPC failed (watchman not ready?)' messages during startup to confirm graceful retry behavior.
Security signals we found
Crash-safety / recovery race condition addressed
Cross-component state synchronization added
Non-fatal retry on RPC failure to avoid startup deadlock or busy-loop
Evidence from the diff
The change serializes block processing between the bwatch plugin and lightningd’s watchman. After persisting a block to the datastore, bwatch now sends a block_processed RPC containing the height and hash, and only schedules the next poll after receiving an ack. If watchman is unavailable (e.g., during startup), the error is handled non-fatally and retried. This closes a crash-recovery race where bwatch’s tip could exceed watchman’s persisted height, causing blocks to be skipped or inconsistently replayed on restart.
Changed components
plugins/bwatch/bwatch.cplugins/bwatch/bwatch_interface.cplugins/bwatch/bwatch_interface.hInspect captured patch +87 / −16
diff --git a/plugins/bwatch/bwatch.c b/plugins/bwatch/bwatch.c
index 097375ed..d94328a7 100644
--- a/plugins/bwatch/bwatch.c
+++ b/plugins/bwatch/bwatch.c
@@ -79,20 +79,9 @@ static struct command_result *poll_finished(struct command *cmd)
return timer_complete(cmd);
}
-/* Just persisted a block — there may be more to catch up to, so poll again
- * immediately rather than waiting for the full interval. Once getchaininfo
- * reports no change, poll_finished resets us to the steady-state cadence. */
-static struct command_result *fetch_more(struct command *cmd)
-{
- struct bwatch *bwatch = bwatch_of(cmd->plugin);
-
- bwatch->poll_timer = global_timer(cmd->plugin, time_from_sec(0),
- bwatch_poll_chain, NULL);
- return timer_complete(cmd);
-}
-
/* Process one block fetched from bitcoind: update tip, append to history,
- * then persist; the poll is rescheduled once the datastore write completes. */
+ * then persist; once persisted we notify watchman, and the next poll is
+ * scheduled from the block_processed ack so we don't race ahead of it. */
static struct command_result *handle_block(struct command *cmd,
const char *method UNUSED,
const char *buf,
@@ -123,7 +112,8 @@ static struct command_result *handle_block(struct command *cmd,
bwatch->current_blockhash,
block->hdr.prev_hash,
};
- return bwatch_add_block_to_datastore(cmd, &br, fetch_more);
+ return bwatch_add_block_to_datastore(cmd, &br,
+ bwatch_send_block_processed);
}
/* getchaininfo response: pick the next block to fetch (or just reschedule). */
diff --git a/plugins/bwatch/bwatch_interface.c b/plugins/bwatch/bwatch_interface.c
index aba4a221..6b2893ba 100644
--- a/plugins/bwatch/bwatch_interface.c
+++ b/plugins/bwatch/bwatch_interface.c
@@ -1,2 +1,77 @@
#include "config.h"
+#include <common/json_param.h>
+#include <common/json_parse.h>
+#include <common/json_stream.h>
#include <plugins/bwatch/bwatch_interface.h>
+
+/*
+ * ============================================================================
+ * SENDING BLOCK_PROCESSED NOTIFICATION
+ *
+ * After bwatch has persisted a new tip, it tells watchman by sending the
+ * block_processed RPC. The next poll is scheduled from the ack callback,
+ * which guarantees watchman's persisted height is updated before bwatch
+ * looks for another block — important for crash safety: on restart we
+ * trust watchman's height as the floor and re-fetch anything above it.
+ * ============================================================================
+ */
+
+/* Watchman acked block_processed: safe to poll for the next block. */
+static struct command_result *block_processed_ack(struct command *cmd,
+ const char *method UNUSED,
+ const char *buf,
+ const jsmntok_t *result,
+ void *unused UNUSED)
+{
+ struct bwatch *bwatch = bwatch_of(cmd->plugin);
+ u32 acked_height;
+ const char *err;
+
+ err = json_scan(tmpctx, buf, result,
+ "{blockheight:%}",
+ JSON_SCAN(json_to_number, &acked_height));
+ if (err)
+ plugin_err(cmd->plugin, "block_processed ack '%.*s': %s",
+ json_tok_full_len(result),
+ json_tok_full(buf, result), err);
+
+ plugin_log(cmd->plugin, LOG_DBG,
+ "Received block_processed ack for height %u", acked_height);
+
+ bwatch->poll_timer = global_timer(cmd->plugin, time_from_sec(0),
+ bwatch_poll_chain, NULL);
+ return timer_complete(cmd);
+}
+
+/* Non-fatal: watchman may not be ready yet (e.g. lightningd still booting).
+ * Reschedule the poll anyway so we keep retrying without busy-looping. */
+static struct command_result *block_processed_err(struct command *cmd,
+ const char *method UNUSED,
+ const char *buf,
+ const jsmntok_t *result,
+ void *unused UNUSED)
+{
+ struct bwatch *bwatch = bwatch_of(cmd->plugin);
+
+ plugin_log(cmd->plugin, LOG_BROKEN,
+ "block_processed RPC failed (watchman not ready?): %.*s",
+ json_tok_full_len(result), json_tok_full(buf, result));
+
+ bwatch->poll_timer = global_timer(cmd->plugin, time_from_sec(0),
+ bwatch_poll_chain, NULL);
+ return timer_complete(cmd);
+}
+
+struct command_result *bwatch_send_block_processed(struct command *cmd)
+{
+ struct bwatch *bwatch = bwatch_of(cmd->plugin);
+ struct out_req *req;
+
+ req = jsonrpc_request_start(cmd, "block_processed",
+ block_processed_ack, block_processed_err,
+ NULL);
+ json_add_u32(req->js, "blockheight", bwatch->current_height);
+ json_add_string(req->js, "blockhash",
+ fmt_bitcoin_blkid(tmpctx, &bwatch->current_blockhash));
+ return send_outreq(req);
+}
diff --git a/plugins/bwatch/bwatch_interface.h b/plugins/bwatch/bwatch_interface.h
index 944a66f0..e3424885 100644
--- a/plugins/bwatch/bwatch_interface.h
+++ b/plugins/bwatch/bwatch_interface.h
@@ -6,7 +6,13 @@
/* Outward-facing interface from bwatch to lightningd.
*
- * Subsequent commits add the watch_found / watch_revert / block_processed
- * notifications and the addwatch / delwatch / listwatch RPC commands. */
+ * Subsequent commits add the watch_found / watch_revert notifications
+ * and the addwatch / delwatch / listwatch RPC commands. */
+
+/* Send a block_processed RPC to watchman after a new block has been
+ * persisted. The next poll is started from the ack callback so we don't
+ * race ahead of watchman's view of the chain. Chains on the same poll
+ * command so timer_complete fires once watchman has acknowledged. */
+struct command_result *bwatch_send_block_processed(struct command *cmd);
#endif /* LIGHTNING_PLUGINS_BWATCH_BWATCH_INTERFACE_H */
Why this scored 26/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.