Skip to content

lwwallet: gap-fill skipped heights on next TipBlock event - #524

Merged
ellemouton merged 3 commits into
mainfrom
ellemouton/lwwallet-skip-block-fix
May 23, 2026
Merged

lwwallet: gap-fill skipped heights on next TipBlock event#524
ellemouton merged 3 commits into
mainfrom
ellemouton/lwwallet-skip-block-fix

Conversation

@ellemouton

Copy link
Copy Markdown
Member

Summary

Fix the regression reported in #369: a transient /block/:hash/raw
failure inside EsploraChainService.processTipEvent can permanently
strand a height between btcwallet's view and the chain tip.

When the shared TipPoller was introduced, it became the single source
of truth for the chain tip and only emits each height once. The
previous per-component poll loop owned its own bestBlock cursor and
would naturally retry the same height on the next tick after a fetch
failure. That invariant was lost on the subscriber side: a GetRawBlock
failure inside processTipEvent returned without sending
FilteredBlockConnected or BlockConnected, and TipPoller never
re-emits the same TipBlock event. btcwallet's sync height eventually
advanced past the skipped block via a later BlockConnected without
ever scanning it for relevant transactions.

This PR restores the cursor on the subscriber side. EsploraChainService
now treats s.bestBlock.Height as its canonical delivery cursor and
walks any gap between it and the latest TipBlock event before
processing the live event.

Changes

  • lwwallet: add regression test for transient block-fetch tip skip — the reproducer (gated on t.Skip so this commit alone does not introduce a failing test).
  • lwwallet: gap-fill skipped heights on next TipBlock event — the fix and the corresponding un-skip.

Mechanics of the fix:

  • processTipEvent snapshots s.bestBlock.Height and the watched
    pkScript set, drops duplicates (event.Height <= lastDelivered),
    and walks (lastDelivered, event.Height-1] via fillGap before
    processing the live event.
  • fillGap re-fetches hash + header for each intermediate height
    (both LRU-cached in EsploraClient, so the warm path costs no extra
    network round trips) and delegates per-height emission to
    deliverHeight. Work is bounded by defaultMaxGapFillPerTipEvent
    (256, documented at the const) so a deep outage doesn't hold the
    handleTipEvents goroutine for longer than one poll interval.
  • deliverHeight extracts the GetRawBlock -> filter -> emit pair
    and advances s.bestBlock only on full success. On any failure
    (raw-block fetch error, shutdown mid-send) s.bestBlock stays at
    the last successfully delivered height, so the next TipBlock event
    re-walks the unfinished stretch.

Same watched-scripts snapshot is passed to gap-fill and the live event
so every height processed in one invocation sees the same watch set.

Test plan

  • go test ./lwwallet/ -count=1 — passes.
  • go test ./lwwallet/ -race -count=1 — passes (~20s).
  • make fmt-changed + make lint-changed-local — clean (0 issues).
  • Reproduction confirmed on the pre-fix branch: the new test fails with observed=[102 103] (height 101 is missed).
  • Reproduction is fixed by the second commit: the test passes with observed=[101 102 103].

Closes #369.

@ellemouton

Copy link
Copy Markdown
Member Author

@claude review this

@claude

claude Bot commented May 22, 2026

Copy link
Copy Markdown

Claude finished @ellemouton's task in 3m 33s —— View job


Code Review

  • Gather context and understand the request
  • Read changed files
  • Review esplora_chain.go changes
  • Review esplora_chain_test.go
  • Post final review feedback

The fix is structurally correct and well-reasoned. A few things to look at, ranging from a real accuracy bug to nits.


1. Comment claims GetBlockHashByHeight is LRU-cached — it isn't

fillGap's doc comment says:

"Hash and header for each intermediate height are re-fetched from the EsploraClient, whose LRU caches make the warm path free (the TipPoller already populated both during its own per-height walk)."

But GetBlockHashByHeight (esplora.go:308) makes a live /block-height/:h HTTP request every time — there is no height→hash cache. Only GetBlockHeader results are memoized (in blockHeaderCache, keyed by hash). So during gap-fill, every missed height costs one uncached network round-trip for the height lookup, even if TipPoller already resolved that height seconds ago. The header fetch is free (warm cache); the hash fetch is not.

In practice this is fine — the gap is almost always 1 height and the request is tiny — but the comment is misleading. It should say something like:

"The block header for each intermediate hash is LRU-cached (TipPoller already populated blockHeaderCache); the height→hash lookup is a live request."

Fix this →


2. FilteredBlockConnected partial send on shutdown

In deliverHeight (esplora_chain.go:865–882), FilteredBlockConnected is sent and then BlockConnected is sent. If quit fires between the two sends, FilteredBlockConnected is delivered but s.bestBlock is NOT advanced (because BlockConnected returns false before the mutex update). On the next startup, the same height gets re-delivered — meaning FilteredBlockConnected is seen twice for one block.

This is a pre-existing design issue (not introduced here) and the doc comment on deliverHeight acknowledges that "a shutdown mid-send returns without advancing s.bestBlock." But it doesn't mention the double-FilteredBlockConnected consequence. Worth calling out in the comment, or verifying that btcwallet handles this idempotently.


3. Test assertion doesn't verify ordering

drainBlockConnected collects notifications in arrival order and the comment says "Returns the set of heights actually observed, in arrival order." But the assertion:

require.ElementsMatch(t, []int32{101, 102, 103}, observed, ...)

only checks presence, not order. Since handleTipEvents is a single sequential goroutine, the ordering guarantee holds by design — but changing to:

require.Equal(t, []int32{101, 102, 103}, observed, ...)

would make the test a stronger regression guard against any future refactor that accidentally introduces concurrency that breaks in-order delivery. The drainBlockConnected helper already captures order, so there's no extra infrastructure needed.


4. Deep-gap case: live event is dropped for the invocation

When end - start > 256, fillGap successfully delivers 256 heights and returns false (because walkEnd != end). processTipEvent then returns without delivering the live event (event.Height). The live event eventually arrives via gap-fill on subsequent ticks, but it may be several poll intervals late.

This is correct (documented in the const comment and the PR description), and the alternative — delivering the live event out-of-sequence while the middle is still missing — would break btcwallet's contiguous sync assumption. Just noting it in case there's a desire to reduce latency in that edge case (e.g., a Rescan path that resets the cursor).


Minor nits

  • The cap constant comment (line 677–687) is excellent; consider adding a one-liner before the const itself so go doc lwwallet.defaultMaxGapFillPerTipEvent renders usefully.
  • processTipEvent (line 753): deliverHeight's return value for the live event is silently dropped. An explicit _ = would flag intent, though ignoring non-error returns is idiomatic Go.

Summary

The fix is correct. The cursor approach is the right design and the invariants (single writer, monotonic advance, retry on failure) hold. The test is well-constructed and confirmed the regression. The main issue worth addressing before merge is the inaccurate "LRU-cached" claim in the fillGap comment, which could mislead future readers into not optimising the height→hash lookup.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request addresses a bug in EsploraChainService where transient failures during block processing could lead to permanently skipped heights. The implementation introduces a gap-filling mechanism that ensures all blocks between the last delivered height and the current tip are processed sequentially. Additionally, a comprehensive regression test was added using a new mock Esplora server to verify recovery from transient failures. The review feedback suggests enhancing the robustness of notification delivery by adding context cancellation checks to the select blocks, preventing potential hangs if the notification channel is full during shutdown.

Comment thread lwwallet/esplora_chain.go
Comment on lines 865 to 873
select {
case s.notifications <- chain.FilteredBlockConnected{
Block: &blockMeta,
RelevantTxs: relevantTxs,
}:

case <-s.quit:
return
return false
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The select block should also include a case for ctx.Done() to ensure that the goroutine can exit if the context is cancelled, even if the s.quit channel hasn't been closed yet. This prevents potential hangs if the notification channel is full during shutdown.

Suggested change
select {
case s.notifications <- chain.FilteredBlockConnected{
Block: &blockMeta,
RelevantTxs: relevantTxs,
}:
case <-s.quit:
return
return false
}
select {
case s.notifications <- chain.FilteredBlockConnected{
Block: &blockMeta,
RelevantTxs: relevantTxs,
}:
case <-ctx.Done():
return false
case <-s.quit:
return false
}

Comment thread lwwallet/esplora_chain.go
Comment on lines 878 to 882
select {
case s.notifications <- chain.BlockConnected(blockMeta):
case <-s.quit:
return
return false
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Similar to the previous notification send, this select block should also handle ctx.Done() to avoid blocking indefinitely if the context is cancelled while the notification channel is full.

Suggested change
select {
case s.notifications <- chain.BlockConnected(blockMeta):
case <-s.quit:
return
return false
}
select {
case s.notifications <- chain.BlockConnected(blockMeta):
case <-ctx.Done():
return false
case <-s.quit:
return false
}

Add a reproducer for the "Esplora failures can make lwwallet
permanently skip blocks" finding (darepo-client#369).

EsploraChainService.processTipEvent fetches the raw block via Esplora
when at least one address is being watched. When that single HTTP
request fails (transient 5xx, timeout), the function returns without
emitting FilteredBlockConnected or BlockConnected for the affected
height. The shared TipPoller has already advanced its cached tipHeight
past that block and will not re-emit the same TipBlock event, so the
height is permanently stranded between btcwallet's view and the chain
tip. Future TipBlock events deliver later heights but never re-walk
the missed one, and btcwallet's sync height eventually advances past
it via BlockConnected on a subsequent block.

The new test builds real wire.MsgBlock values per height (so
/block/:hash/raw can serve bytes that deserialize and hash-verify
under EsploraClient's integrity check) and injects a single 502 on
the raw-block fetch for height 101. It then advances the chain
100->103 and asserts every advanced-through height surfaces as a
BlockConnected notification.

The test is gated on t.Skip pending the gap-fill fix in the next
commit; without it, the test fails with observed=[102 103] (101
missing) within the drain deadline. The follow-up commit removes the
skip together with the fix that re-walks any heights between
s.bestBlock and the latest TipBlock event.
Fix EsploraChainService.processTipEvent so a transient
GetRawBlock failure no longer permanently strands a height between
btcwallet's view and the chain tip (darepo-client#369).

Previously, on a /block/:hash/raw failure the function would log and
return without sending FilteredBlockConnected or BlockConnected.
TipPoller had already advanced its cached tipHeight past that block
and never re-emits the same TipBlock event, so the missed height was
permanently lost and btcwallet would eventually be advanced past it
by a later block's BlockConnected without ever scanning the skipped
height for relevant transactions. The previous per-component poll
loop (replaced by the shared TipPoller) owned its own bestBlock and
naturally retried the same height on the next tick; this restores
the equivalent invariant on the subscriber side.

The chain service now treats s.bestBlock.Height as its canonical
delivery cursor:

  - processTipEvent snapshots s.bestBlock.Height and the watched
    pkScript set under the lock, drops duplicates
    (event.Height <= lastDelivered), and walks any gap between
    lastDelivered and event.Height-1 via fillGap before processing
    the live event.
  - fillGap re-fetches hash + header for each intermediate height
    (both LRU-cached on EsploraClient, so the warm path costs no
    extra network round trips) and delegates per-height emission to
    deliverHeight. Work is bounded by defaultMaxGapFillPerTipEvent
    (256, documented at the const) so a deep outage doesn't hold
    the handleTipEvents goroutine for longer than one poll
    interval.
  - deliverHeight extracts the GetRawBlock -> filter -> emit pair
    and advances s.bestBlock only on full success. Any failure
    (raw-block fetch error, shutdown mid-send) leaves s.bestBlock
    at the last successfully delivered height, so the next
    TipBlock event re-walks the unfinished stretch.

The same watched-scripts snapshot is passed to gap-fill and the live
event so every height processed in one invocation sees the same
watch set.

Re-enables the regression test added in the previous commit by
removing its t.Skip; the test now passes (101, 102, 103 all
delivered after one transient 502 on height 101's raw-block fetch).
@ellemouton
ellemouton force-pushed the ellemouton/lwwallet-skip-block-fix branch from d64db23 to c2fee6a Compare May 22, 2026 20:39
@ellemouton

Copy link
Copy Markdown
Member Author

Thanks @claude and @gemini-code-assist for the reviews. Pushed a squashed update (force-pushed onto the same two commits) addressing the substantive items:

Addressed

  • @claude lib: start adding lib helpers #1 — Inaccurate "LRU-cached" claim in fillGap's doc comment. Reworded to make clear the block header is served warm from blockHeaderCache, but the height→hash lookup is a live /block-height/:h request (EsploraClient deliberately does not cache mutable height→hash mappings).
  • @claude chainbackend+chainsource: add chainsource actor and backend impl #3 — Test now uses require.Equal instead of require.ElementsMatch so the in-order delivery contract is asserted, not just presence. Added a comment explaining why ordering matters (btcwallet's sync pipeline relies on contiguous, in-order BlockConnected delivery).
  • @claude harness: basic infrastructure test environment #5 nit — Live deliverHeight call in processTipEvent now reads _ = s.deliverHeight(...) with a one-line rationale, making the dropped return value explicit.
  • @gemini ×2 — Added <-ctx.Done() cases alongside <-s.quit in both notification sends inside deliverHeight. Goroutine now has a second exit so a full notifications buffer cannot wedge shutdown if the lifecycle context is cancelled without going through Stop().

Skipped (with rationale)

  • @claude multI: add initial repo scaffolding #2 (double FilteredBlockConnected on shutdown mid-send) — pre-existing design issue, not introduced by this PR. Better tracked as a separate issue than expanded in a comment here.
  • @claude multi: stage boarding actor client  #4 (deep-gap case drops live event for the invocation) — deliberate and already documented at the const and in the PR description; the alternative (delivering out-of-sequence while the middle is still missing) would break btcwallet's contiguous-sync assumption.
  • @claude godoc nit — the defaultMaxGapFillPerTipEvent const comment already begins with the identifier name, which is the form go doc picks up.

Local checks: make fmt-changed-check clean, make lint-changed-local 0 issues, go test ./lwwallet/ -race passes. CI re-running.

@gemini-code-assist

Copy link
Copy Markdown

Thanks for the update, @ellemouton. The changes look solid and address the points raised in the review. Specifically, adding <-ctx.Done() to the notification sends in deliverHeight is a good defensive measure for clean shutdowns, and the updated test with require.Equal correctly enforces the in-order delivery contract required by the sync pipeline. The documentation updates and the explicit handling of the dropped return value in processTipEvent also improve clarity. The logic for gap-filling and cursor management correctly addresses the stranded height issue. This looks ready for merge.

@claude

claude Bot commented May 22, 2026

Copy link
Copy Markdown

Claude finished @ellemouton's task in 1m 7s —— View job


Claude finished reviewing @ellemouton's updates


Follow-up Review

  • Gather context and understand the request
  • Read updated esplora_chain.go to verify addressed items
  • Read updated esplora_chain_test.go to verify ordering fix
  • Post final review feedback

All four addressed items look correct. Quick verification:

#1 — fillGap comment (esplora_chain.go:760–768): Accurately distinguishes warm header from live height→hash lookup now. The new wording ("EsploraClient deliberately does not cache mutable height→hash mappings") is precise and self-explanatory. ✅

#3 — Test ordering (esplora_chain_test.go:398–404): require.Equal is in place with a solid comment explaining the btcwallet contiguous-sync requirement. The comment also describes the pre-fix failure mode, which makes it a good regression anchor. ✅

#5 nit — _ = s.deliverHeight (esplora_chain.go:754): The inline rationale ("processTipEvent has no further work to do either way") makes the intent clear without requiring the reader to infer it. ✅

Gemini ×2 — ctx.Done() cases (esplora_chain.go:877–880, 889–893): Both selects have the correct three-way exit. The comment above the first select also correctly explains the relationship between lifecycle context cancellation and Stop(). ✅

The skipped items have good rationale. The pre-existing double-FilteredBlockConnected issue (#2) and the deep-gap drop behaviour (#4) are both documented well enough in the existing comments that a future reader won't be surprised.

LGTM. The fix is correct, the test is strong, and the follow-up changes address all the substantive feedback.

@ellemouton
ellemouton requested a review from Roasbeef May 22, 2026 21:02
The original regression test for the "Esplora failures permanently
skip blocks" finding only exercised the raw-block failure path
through fillGap. Three sibling branches were still uncovered:

  1. A header fetch failure inside fillGap. Reachable in production
     when blockHeaderCache has been evicted for a height that the
     chain service still needs to back-fill.
  2. The per-event walk cap. A pathological gap (deep Esplora
     outage with no Rescan) must bound a single processTipEvent
     invocation; the cursor advances by exactly the cap and the
     next TipBlock event picks up the remainder.
  3. Duplicate / out-of-order events. A subscribe-time race or
     a future retry path may hand the chain service an event at
     or below the cursor; processTipEvent must short-circuit
     before any HTTP fetch or notification send.

Add direct-invocation tests for each branch. To exercise the cap
without revealing 256+ heights of HTTP traffic, introduce the
WithMaxGapFillPerTipEvent functional option on
NewEsploraChainService — production callers leave the default
in place. Extend the rawBlockStubChain test fixture with a
header-failure injection knob symmetric to the existing raw-block
one, plus small helpers (seedCursor, tipEventFor, watchOne,
requireCursor, requireNoNotification) shared across the new tests.

@Roasbeef Roasbeef left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM added on some additional tests

🛼

@ellemouton
ellemouton merged commit 46543fc into main May 23, 2026
18 checks passed
@ellemouton
ellemouton deleted the ellemouton/lwwallet-skip-block-fix branch May 23, 2026 02:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[security][high] Esplora failures can make lwwallet permanently skip blocks

2 participants