]> Untitled Git - bdk/commitdiff
docs(core,chain): tighten the `ChainTask` driver contract
author志宇 <hello@evanlinjin.me>
Thu, 10 Sep 2026 10:58:30 +0000 (10:58 +0000)
committer志宇 <hello@evanlinjin.me>
Fri, 11 Sep 2026 02:00:25 +0000 (02:00 +0000)
Two gaps in the protocol between a task and its driver, one on each side.

Task side: `resolve_query(height, None)` means "the chain source has no
block at this height", and it is terminal — the height leaves the pending
set and is never requested again. A driver that passed `None` on an I/O
error would permanently record a real block as absent, turning a confirmed
transaction into an unconfirmed one with no error surfaced anywhere, i.e.
a silently wrong balance. The recovery path already exists: leave the
height unresolved and retry it, which is what `unresolved_queries` is for.

Driver side: `LocalChain::run_task` rejected `TaskProgress::Blocked` with
`unreachable!()`. That was right about the outcome — this driver resolves
every height a task announces via `Query` before polling again, so a task
that announces what it needs never has anything outstanding — but it gave
the reason as a property of the driver rather than of the task, and the
panic was undocumented. Reaching that arm means the task is waiting on
heights it never announced. Such a task would also hang a streaming
driver, which starts fetches only for announced heights, so serving it by
draining `unresolved_queries()` would hide the bug behind the one driver
everyone tests against. Panic with a message that names the violation,
document it under `# Panics`, and cover it with a test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NND8BhEmYTYdLRv4dbUUro

crates/chain/src/local_chain.rs
crates/chain/tests/test_local_chain.rs
crates/core/src/block_queries.rs
crates/core/src/chain_task.rs

index 0ff942ffaa45a1eb1ab397f25facee6a9ea5cf99..f9621af7ca91cb30cadab0c04530794a3d6e4e49 100644 (file)
@@ -419,6 +419,13 @@ where
     /// against this chain and returning the task's output. The chain responds with its data
     /// type `D`, so `LocalChain<Header>` responds with headers and `LocalChain<BlockHash>`
     /// responds with hashes.
+    ///
+    /// # Panics
+    ///
+    /// If the task returns [`TaskProgress::Blocked`]. This driver resolves every height a task
+    /// announces via [`TaskProgress::Query`] before polling it again, so a task that follows the
+    /// [`ChainTask`] contract never has anything outstanding here and never blocks. Reaching
+    /// this means the task is waiting on heights it never announced.
     pub fn run_task<Q>(&self, mut task: Q) -> Q::Output
     where
         Q: ChainTask<D>,
@@ -441,11 +448,18 @@ where
                         task.resolve_query(height, data);
                     }
                 }
-                // This is a synchronous driver: every `Query` height is resolved before the next
-                // poll, so the task never has an in-flight query and cannot return this variant.
+                // Every `Query` height is resolved before the next poll, so a task that announces
+                // what it needs never has anything outstanding here — `Blocked` means it is
+                // waiting on heights it never put in a `Query`.
+                //
+                // Serving that anyway (by draining `unresolved_queries()`) would be the wrong
+                // kindness: it hides the bug behind the one driver everyone tests against, while a
+                // streaming driver — which launches fetches only for announced heights — waits
+                // forever on I/O that was never started. Fail here, where it is cheap to find.
                 TaskProgress::Blocked => {
-                    unreachable!(
-                        "run_task resolves queries synchronously; nothing is ever in-flight"
+                    panic!(
+                        "ChainTask returned Blocked to a synchronous driver: it is waiting on \
+                         heights it never announced via TaskProgress::Query"
                     )
                 }
             }
index bc5151d9c5bb5c96d9d7b2f0db94b2aba2dccc01..7affe38e7619920aa5f6ad938fbdbe071b616a9d 100644 (file)
@@ -1311,3 +1311,44 @@ fn merge_chains_with_prev_blockhash() {
     .into_iter()
     .for_each(TestLocalChain::run);
 }
+
+/// A [`ChainTask`] that reports [`TaskProgress::Blocked`] without ever having announced the
+/// height it is waiting on in a [`TaskProgress::Query`].
+///
+/// This is the only way a task reaches `run_task`'s `Blocked` arm: the driver resolves every
+/// announced height before polling again, so a task that announces what it needs never has
+/// anything outstanding. Such a task would also hang a streaming driver, which starts fetches
+/// only for announced heights — so the synchronous driver panics rather than covering for it.
+struct BlockedTask {
+    tip: BlockId,
+}
+
+impl bdk_chain::ChainTask<BlockHash> for BlockedTask {
+    type Output = ();
+
+    fn tip(&self) -> BlockId {
+        self.tip
+    }
+
+    fn poll(&mut self) -> bdk_chain::TaskProgress {
+        bdk_chain::TaskProgress::Blocked
+    }
+
+    fn resolve_query(&mut self, _height: u32, _response: Option<BlockHash>) {}
+
+    fn unresolved_queries<'a>(&'a self) -> impl Iterator<Item = u32> + 'a {
+        core::iter::empty()
+    }
+
+    fn finish(self) -> Self::Output {}
+}
+
+#[test]
+#[should_panic(expected = "heights it never announced")]
+fn run_task_panics_when_a_task_blocks_without_querying() {
+    let chain = local_chain![(0, hash!("_")), (1, hash!("A"))];
+
+    chain.run_task(BlockedTask {
+        tip: chain.tip().block_id(),
+    });
+}
index b6eb04169a247de09565866e8bd9600921bfc7cb..48d1a69423e56ad5f012b2fa4a90cb163e538ff3 100644 (file)
@@ -41,6 +41,11 @@ impl<B> BlockQueries<B> {
     }
 
     /// Marks a height as resolved. Removes from pending and inserts into blocks.
+    ///
+    /// `block` is `None` when the chain source **has no block** at `height`. This is terminal:
+    /// the height leaves [`unresolved`](Self::unresolved) and [`request`](Self::request) will
+    /// never return it again. So do not pass `None` for a *failed* fetch — leave the height
+    /// pending and retry it, or the failure is silently recorded as "no such block".
     pub fn resolve(&mut self, height: u32, block: Option<B>) {
         let was_pending = self.pending.remove(&height);
         self.blocks.insert(height, block);
index 8cd7f6be588c3297d88f79c59fb7f606e6558031..d1109a4e2e636015919de19443d9d236461f7c17 100644 (file)
@@ -117,14 +117,25 @@ pub trait ChainTask<B = BlockHash> {
 
     /// Provides the block data (or `None`) for a previously requested `height`.
     ///
-    /// A response of `None` signals that the chain source has no block at that
-    /// height. The task records this so it can distinguish "not yet queried"
-    /// from "queried but absent."
+    /// A response of `None` signals that the chain source **has no block** at that height —
+    /// it is an answer, not an error. The task records it so it can distinguish "not yet
+    /// queried" from "queried but absent", and treats every anchor at that height as not
+    /// in-chain.
+    ///
+    /// Resolving is terminal: a height is answered once, and the task will not ask for it
+    /// again. A driver that *failed* to fetch a height (I/O error, timeout) must therefore
+    /// **not** call this with `None` — that would silently record "no such block" and, for an
+    /// anchor that is really in-chain, downgrade a confirmed transaction to unconfirmed with
+    /// no error surfaced anywhere. Leave the height unresolved instead and retry it; see
+    /// [`unresolved_queries`](Self::unresolved_queries).
     fn resolve_query(&mut self, height: u32, response: Option<B>);
 
     /// Returns heights that have been requested but not yet resolved.
     ///
-    /// This is useful for retrying failed queries or reporting progress.
+    /// This is the retry set: a height a fetch failed for stays here until the driver
+    /// resolves it, so a driver can re-issue those fetches (and report progress) after an
+    /// error. Once [`resolve_query`](Self::resolve_query) is called for a height it leaves
+    /// this set permanently.
     fn unresolved_queries<'a>(&'a self) -> impl Iterator<Item = u32> + 'a;
 
     /// Consumes the task and returns the final output.