Skip to main content

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}