zebrad/components/notify.rs
1//! A task that runs an external command whenever the best chain tip changes.
2//!
3//! This is Zebra's port of zcashd's `-blocknotify`. Whenever the node's best chain tip changes,
4//! and the node is close to the network tip (Zebra's analogue of "not in initial block download"),
5//! the configured command is run via the system shell with every `%s` replaced by the new tip's
6//! block hash. The command is run detached and never blocks block validation.
7
8use std::process::Stdio;
9
10use thiserror::Error;
11use tokio::sync::watch;
12use tracing::Instrument;
13
14use zebra_chain::block;
15use zebra_state::ChainTipChange;
16
17use crate::components::sync::SyncStatus;
18
19#[cfg(test)]
20mod tests;
21
22/// Block notify configuration section.
23///
24/// Mirrors zcashd's `-blocknotify` option.
25#[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
26#[serde(deny_unknown_fields, default)]
27pub struct Config {
28 /// Command run whenever the best chain tip changes, mirroring zcashd's `-blocknotify`.
29 ///
30 /// Every `%s` in the command is replaced with the new tip's block hash in `getbestblockhash`
31 /// hex format. The command is run via the system shell (`/bin/sh -c` on Unix, `cmd /C` on
32 /// Windows), detached, and never blocks block validation. It is not run during initial block
33 /// download (gated on [`SyncStatus::wait_until_close_to_tip`]).
34 ///
35 /// Best-effort per tip change: during fast sync or reorgs, intermediate tip hashes may be
36 /// coalesced and skipped — only the current best tip hash fires. Disabled when `None`.
37 pub block_notify_command: Option<String>,
38}
39
40// we like our default configs to be explicit
41#[allow(unknown_lints)]
42#[allow(clippy::derivable_impls)]
43impl Default for Config {
44 fn default() -> Self {
45 Self {
46 block_notify_command: None,
47 }
48 }
49}
50
51/// Errors that can occur in the block notify task.
52#[derive(Error, Debug)]
53pub enum BlockNotifyError {
54 /// The chain tip sender was dropped, so we can't observe further tip changes.
55 #[error("chain tip sender was dropped")]
56 TipChange(watch::error::RecvError),
57
58 /// The sync status sender was dropped, so we can't tell when we're close to the tip.
59 #[error("sync status sender was dropped")]
60 SyncStatus(watch::error::RecvError),
61}
62
63/// Run continuously, executing `command` whenever the best chain tip changes.
64///
65/// Mirrors zcashd's `-blocknotify`: each `%s` in `command` is replaced with the new tip's block
66/// hash in `getbestblockhash` hex format, and the command is run detached via the system shell.
67///
68/// The command is only run once the node is close to the network tip, which is Zebra's analogue
69/// of zcashd suppressing the callback during initial block download. If a lot of blocks are
70/// committed at once, intermediate tip hashes are coalesced into a single [`Reset`] by
71/// [`ChainTipChange`], so only the current best tip hash fires.
72///
73/// Returns an error if communication with the state or the syncer is lost.
74///
75/// [`Reset`]: zebra_state::TipAction::Reset
76pub async fn run_block_notify(
77 command: String,
78 mut sync_status: SyncStatus,
79 mut chain_tip_change: ChainTipChange,
80) -> Result<(), BlockNotifyError> {
81 info!("initializing block notify task");
82
83 loop {
84 // Block until the tip changes. This always waits for a real change, so it paces the loop.
85 // The gate below can't pace the loop: it returns immediately when synced. It doesn't skip
86 // the initial sync (the tip changes throughout it); that's the gate's job.
87 let tip_action = chain_tip_change
88 .wait_for_tip_change()
89 .await
90 .map_err(BlockNotifyError::TipChange)?;
91
92 // Gate: hold until we're close to the tip (done catching up), suppressing notifications
93 // during sync. Must sit right before the spawn so close-to-tip still holds when we fire;
94 // gating earlier could fire on an intermediate block if we fall behind again first.
95 sync_status
96 .wait_until_close_to_tip()
97 .await
98 .map_err(BlockNotifyError::SyncStatus)?;
99
100 // Grab the freshest tip: catching up can take a while, during which newer blocks may have
101 // landed and made `tip_action` stale. This peek doesn't block; fall back to `tip_action`
102 // when nothing newer is pending.
103 let (hash, height) = chain_tip_change
104 .last_tip_change()
105 .unwrap_or(tip_action)
106 .best_tip_hash_and_height();
107
108 spawn_notify_command(&command, hash, height);
109 }
110}
111
112/// Renders `command` for `hash` and spawns it detached via the system shell.
113///
114/// The command never blocks the caller: it is spawned and reaped in a separate task, so a hung
115/// command cannot stall block validation.
116fn spawn_notify_command(command: &str, hash: block::Hash, height: block::Height) {
117 let rendered = render_command(command, hash);
118
119 // Run the command via the system shell, like zcashd's `system()`: `/bin/sh -c` on Unix, and
120 // `cmd /C` on Windows.
121 #[cfg(not(target_os = "windows"))]
122 let mut cmd = {
123 let mut cmd = tokio::process::Command::new("/bin/sh");
124 cmd.arg("-c").arg(&rendered);
125 cmd
126 };
127 #[cfg(target_os = "windows")]
128 let mut cmd = {
129 let mut cmd = tokio::process::Command::new("cmd");
130 cmd.arg("/C").arg(&rendered);
131 cmd
132 };
133
134 cmd.stdin(Stdio::null())
135 .stdout(Stdio::null())
136 .stderr(Stdio::null());
137
138 // Put the command in its own process group on Unix, so signals sent to zebrad's process group
139 // (such as Ctrl-C) don't also hit the notify command.
140 #[cfg(unix)]
141 cmd.process_group(0);
142
143 let span = info_span!("block_notify_command", %hash, ?height);
144
145 match cmd.spawn() {
146 Ok(mut child) => {
147 // Reap the child asynchronously to avoid zombies, and log non-zero exits like zcashd's
148 // `runCommand`. The main loop never awaits this, so it returns immediately to await the
149 // next tip change. stdout/stderr go to `/dev/null`, so we only need the exit status.
150 tokio::spawn(
151 async move {
152 match child.wait().await {
153 Ok(status) if !status.success() => {
154 warn!(?rendered, ?status, "block notify command exited non-zero");
155 }
156 Ok(_) => {}
157 Err(error) => {
158 warn!(?rendered, ?error, "failed to wait on block notify command");
159 }
160 }
161 }
162 .instrument(span),
163 );
164 }
165 Err(error) => warn!(?rendered, ?error, "failed to spawn block notify command"),
166 }
167}
168
169/// Replaces every `%s` in `command` with `hash` in `getbestblockhash` hex format.
170///
171/// [`block::Hash`]'s [`Display`](std::fmt::Display) impl already yields the `getbestblockhash`
172/// format (display-order hex), so the substituted value is a fixed 64-char `[0-9a-f]` string with
173/// no shell metacharacters.
174fn render_command(command: &str, hash: block::Hash) -> String {
175 command.replace("%s", &hash.to_string())
176}