-
Notifications
You must be signed in to change notification settings - Fork 248
feat(zebrad): run a command on chain tip changes (block notify) #10726
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 3 commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
b40dfb0
feat(zebrad): run a command on chain tip changes (block notify)
upbqdn a9b8281
test(zebrad): gate Unix-shell block notify test to non-Windows
upbqdn 218902b
refactor(zebrad): reap notify child with wait() not wait_with_output()
upbqdn 731ae50
fix(zebrad): instrument block-notify reaper instead of entering span …
upbqdn 8902410
Merge branch 'main' into block-notify
upbqdn File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,174 @@ | ||
| //! A task that runs an external command whenever the best chain tip changes. | ||
| //! | ||
| //! This is Zebra's port of zcashd's `-blocknotify`. Whenever the node's best chain tip changes, | ||
| //! and the node is close to the network tip (Zebra's analogue of "not in initial block download"), | ||
| //! the configured command is run via the system shell with every `%s` replaced by the new tip's | ||
| //! block hash. The command is run detached and never blocks block validation. | ||
|
|
||
| use std::process::Stdio; | ||
|
|
||
| use thiserror::Error; | ||
| use tokio::sync::watch; | ||
|
|
||
| use zebra_chain::block; | ||
| use zebra_state::ChainTipChange; | ||
|
|
||
| use crate::components::sync::SyncStatus; | ||
|
|
||
| #[cfg(test)] | ||
| mod tests; | ||
|
|
||
| /// Block notify configuration section. | ||
| /// | ||
| /// Mirrors zcashd's `-blocknotify` option. | ||
| #[derive(Clone, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)] | ||
| #[serde(deny_unknown_fields, default)] | ||
| pub struct Config { | ||
| /// Command run whenever the best chain tip changes, mirroring zcashd's `-blocknotify`. | ||
| /// | ||
| /// Every `%s` in the command is replaced with the new tip's block hash in `getbestblockhash` | ||
| /// hex format. The command is run via the system shell (`/bin/sh -c` on Unix, `cmd /C` on | ||
| /// Windows), detached, and never blocks block validation. It is not run during initial block | ||
| /// download (gated on [`SyncStatus::wait_until_close_to_tip`]). | ||
| /// | ||
| /// Best-effort per tip change: during fast sync or reorgs, intermediate tip hashes may be | ||
| /// coalesced and skipped — only the current best tip hash fires. Disabled when `None`. | ||
| pub block_notify_command: Option<String>, | ||
| } | ||
|
|
||
| // we like our default configs to be explicit | ||
| #[allow(unknown_lints)] | ||
| #[allow(clippy::derivable_impls)] | ||
| impl Default for Config { | ||
| fn default() -> Self { | ||
| Self { | ||
| block_notify_command: None, | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Errors that can occur in the block notify task. | ||
| #[derive(Error, Debug)] | ||
| pub enum BlockNotifyError { | ||
| /// The chain tip sender was dropped, so we can't observe further tip changes. | ||
| #[error("chain tip sender was dropped")] | ||
| TipChange(watch::error::RecvError), | ||
|
|
||
| /// The sync status sender was dropped, so we can't tell when we're close to the tip. | ||
| #[error("sync status sender was dropped")] | ||
| SyncStatus(watch::error::RecvError), | ||
| } | ||
|
|
||
| /// Run continuously, executing `command` whenever the best chain tip changes. | ||
| /// | ||
| /// Mirrors zcashd's `-blocknotify`: each `%s` in `command` is replaced with the new tip's block | ||
| /// hash in `getbestblockhash` hex format, and the command is run detached via the system shell. | ||
| /// | ||
| /// The command is only run once the node is close to the network tip, which is Zebra's analogue | ||
| /// of zcashd suppressing the callback during initial block download. If a lot of blocks are | ||
| /// committed at once, intermediate tip hashes are coalesced into a single [`Reset`] by | ||
| /// [`ChainTipChange`], so only the current best tip hash fires. | ||
| /// | ||
| /// Returns an error if communication with the state or the syncer is lost. | ||
| /// | ||
| /// [`Reset`]: zebra_state::TipAction::Reset | ||
| pub async fn run_block_notify( | ||
| command: String, | ||
| mut sync_status: SyncStatus, | ||
| mut chain_tip_change: ChainTipChange, | ||
| ) -> Result<(), BlockNotifyError> { | ||
| info!("initializing block notify task"); | ||
|
|
||
| loop { | ||
| // Block until the tip changes. This always waits for a real change, so it paces the loop. | ||
| // The gate below can't pace the loop: it returns immediately when synced. It doesn't skip | ||
| // the initial sync (the tip changes throughout it); that's the gate's job. | ||
| let tip_action = chain_tip_change | ||
| .wait_for_tip_change() | ||
| .await | ||
| .map_err(BlockNotifyError::TipChange)?; | ||
|
|
||
| // Gate: hold until we're close to the tip (done catching up), suppressing notifications | ||
| // during sync. Must sit right before the spawn so close-to-tip still holds when we fire; | ||
| // gating earlier could fire on an intermediate block if we fall behind again first. | ||
| sync_status | ||
| .wait_until_close_to_tip() | ||
| .await | ||
| .map_err(BlockNotifyError::SyncStatus)?; | ||
|
|
||
| // Grab the freshest tip: catching up can take a while, during which newer blocks may have | ||
| // landed and made `tip_action` stale. This peek doesn't block; fall back to `tip_action` | ||
| // when nothing newer is pending. | ||
| let (hash, height) = chain_tip_change | ||
| .last_tip_change() | ||
| .unwrap_or(tip_action) | ||
| .best_tip_hash_and_height(); | ||
|
|
||
| spawn_notify_command(&command, hash, height); | ||
| } | ||
| } | ||
|
|
||
| /// Renders `command` for `hash` and spawns it detached via the system shell. | ||
| /// | ||
| /// The command never blocks the caller: it is spawned and reaped in a separate task, so a hung | ||
| /// command cannot stall block validation. | ||
| fn spawn_notify_command(command: &str, hash: block::Hash, height: block::Height) { | ||
| let rendered = render_command(command, hash); | ||
|
|
||
| // Run the command via the system shell, like zcashd's `system()`: `/bin/sh -c` on Unix, and | ||
| // `cmd /C` on Windows. | ||
| #[cfg(not(target_os = "windows"))] | ||
| let mut cmd = { | ||
| let mut cmd = tokio::process::Command::new("/bin/sh"); | ||
| cmd.arg("-c").arg(&rendered); | ||
| cmd | ||
| }; | ||
| #[cfg(target_os = "windows")] | ||
| let mut cmd = { | ||
| let mut cmd = tokio::process::Command::new("cmd"); | ||
| cmd.arg("/C").arg(&rendered); | ||
| cmd | ||
| }; | ||
|
|
||
| cmd.stdin(Stdio::null()) | ||
| .stdout(Stdio::null()) | ||
| .stderr(Stdio::null()); | ||
|
|
||
| // Put the command in its own process group on Unix, so signals sent to zebrad's process group | ||
| // (such as Ctrl-C) don't also hit the notify command. | ||
| #[cfg(unix)] | ||
| cmd.process_group(0); | ||
|
|
||
| let span = info_span!("block_notify_command", %hash, ?height); | ||
|
|
||
| match cmd.spawn() { | ||
| Ok(mut child) => { | ||
| // Reap the child asynchronously to avoid zombies, and log non-zero exits like zcashd's | ||
| // `runCommand`. The main loop never awaits this, so it returns immediately to await the | ||
| // next tip change. stdout/stderr go to `/dev/null`, so we only need the exit status. | ||
| tokio::spawn(async move { | ||
| let _enter = span.enter(); | ||
|
|
||
| match child.wait().await { | ||
| Ok(status) if !status.success() => { | ||
| warn!(?rendered, ?status, "block notify command exited non-zero"); | ||
| } | ||
| Ok(_) => {} | ||
| Err(error) => { | ||
| warn!(?rendered, ?error, "failed to wait on block notify command"); | ||
| } | ||
| } | ||
| }); | ||
|
upbqdn marked this conversation as resolved.
Outdated
|
||
| } | ||
| Err(error) => warn!(?rendered, ?error, "failed to spawn block notify command"), | ||
| } | ||
| } | ||
|
|
||
| /// Replaces every `%s` in `command` with `hash` in `getbestblockhash` hex format. | ||
| /// | ||
| /// [`block::Hash`]'s [`Display`](std::fmt::Display) impl already yields the `getbestblockhash` | ||
| /// format (display-order hex), so the substituted value is a fixed 64-char `[0-9a-f]` string with | ||
| /// no shell metacharacters. | ||
| fn render_command(command: &str, hash: block::Hash) -> String { | ||
| command.replace("%s", &hash.to_string()) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.