Skip to content

fix(blockvalidation): don't short-circuit catchup on forked peer's zero headers#718

Merged
freemans13 merged 6 commits into
bsv-blockchain:mainfrom
freemans13:stu/catchup-forked-peer-fix
May 14, 2026
Merged

fix(blockvalidation): don't short-circuit catchup on forked peer's zero headers#718
freemans13 merged 6 commits into
bsv-blockchain:mainfrom
freemans13:stu/catchup-forked-peer-fix

Conversation

@freemans13

Copy link
Copy Markdown
Collaborator

Summary

When the filterHeaders step leaves zero new headers to process, catchup() was returning nil. The outer peer-selection loop treats nil as success and stops trying other peers — so a peer on a dead or shorter fork can silently block the node from reaching blocks honest peers hold.

Fix: before declaring success when no new headers are left, verify blockUpTo is actually in our chain. If it is, legitimate "already synced". If it is not, return an error so the peer-selection loop tries the next peer.

Observed in the wild

On teratestnet, the node got stuck at height 15630 while the network advanced to 15637. Every new block announcement arrived on Kafka, was sent to catchup, fell through to a peer whose tip was on a 46-block-old fork, and that peer returned zero new headers after common-ancestor filtering:

[catchup][...] Fork detected (depth 46), clearing mined_set on old blocks
[catchup][...] Taking 0 headers after common ancestor (index 27)
[catchup][...] no new blocks to fetch - already synced
[catchup][...] starting catchup to https://teranode-eks-ttn-eu-1... DONE in 353ms

Catchup logged "already synced" and returned nil; the peer-selection loop saw success and never tried icellan (the peer that actually announced the block).

What changed

  • services/blockvalidation/catchup.go: extract the zero-headers early-exit into handleNoNewHeaders. Call GetBlockExists(ctx, blockUpTo.Hash()) there. Return an error if the target is not in our chain.
  • services/blockvalidation/catchup_forked_peer_test.go: 4 subtests covering target-in-chain, target-not-in-chain, existence-check error, and that the correct hash is being checked.

Test plan

  • Unit tests pass (go test -race -count=1 ./services/blockvalidation/)
  • go vet ./services/blockvalidation/... clean
  • Deploy to teratestnet and verify it advances past 15630

🤖 Generated with Claude Code

…ro headers

When the filterHeaders step leaves zero new headers to process, the catchup
function was returning nil. The outer peer-selection loop treats nil as
success and stops trying other peers — so a peer on a dead/shorter fork
could silently block the node from reaching blocks that honest peers have.

Observed on teratestnet: node stuck at height 15630 while the network was at
15637. Every new block announcement arrived on Kafka, was sent to catchup,
fell through to a peer whose tip was on a 46-block-old fork, and that peer
returned zero new headers after common-ancestor filtering. Catchup logged
"already synced" and returned nil, leaving the peer-selection loop happy
and the node frozen.

Fix: before declaring success when no new headers are left, verify blockUpTo
is actually in our chain. If it is, legitimate "already synced". If it is
not, return an error so the peer-selection loop tries the next peer.

The check is extracted into handleNoNewHeaders so it can be unit tested
directly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete

Current Review:

This PR fixes a peer-selection bug where a forked peer returning zero new headers would cause catchup to succeed without verifying the target block exists locally, preventing the node from trying other peers.

✅ All issues resolved:

  • Error formatting fixed (removed : %v placeholder)
  • Documentation wording updated to "known locally" (more accurate than "in our chain")
  • Test names updated to match implementation semantics

Analysis Summary:

The fix is well-reasoned: when filterHeaders returns zero headers, the new handleNoNewHeaders function checks whether the target block exists locally via GetBlockExists. If the block doesn't exist, it returns an error so the peer-selection loop tries another peer.

Design note: Using GetBlockExists (which checks all blocks in the blocks table) rather than main-chain membership is intentional and correct here. If we have the announced block in any form (including as a stale off-chain block), we've already advanced to or past it, so there's no need to fetch from another peer.

The tests cover all four critical paths: target exists locally (success), target not known (error), existence check fails (error), and verification that the correct hash is being checked.

No further issues found.

@github-actions

github-actions Bot commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

Benchmark Comparison Report

Baseline: main (unknown)

Current: PR-718 (16750e6)

Summary

  • Regressions: 0
  • Improvements: 0
  • Unchanged: 142
  • Significance level: p < 0.05
All benchmark results (sec/op)
Benchmark Baseline Current Change p-value
_NewBlockFromBytes-4 1.996µ 1.685µ ~ 0.200
SplitSyncedParentMap_SetIfNotExists/256_buckets-4 61.64n 61.70n ~ 0.300
SplitSyncedParentMap_SetIfNotExists/16_buckets-4 61.74n 61.55n ~ 0.700
SplitSyncedParentMap_SetIfNotExists/1_bucket-4 61.51n 61.46n ~ 0.700
SplitSyncedParentMap_ConcurrentSetIfNotExists/256_buckets... 31.85n 30.95n ~ 0.700
SplitSyncedParentMap_ConcurrentSetIfNotExists/16_buckets_... 53.95n 51.97n ~ 0.400
SplitSyncedParentMap_ConcurrentSetIfNotExists/1_bucket_pa... 108.3n 107.7n ~ 0.700
MiningCandidate_Stringify_Short-4 261.9n 262.6n ~ 0.100
MiningCandidate_Stringify_Long-4 1.910µ 1.950µ ~ 0.100
MiningSolution_Stringify-4 994.1n 1014.0n ~ 0.200
BlockInfo_MarshalJSON-4 1.787µ 1.829µ ~ 0.100
NewFromBytes-4 130.9n 139.2n ~ 0.400
Mine_EasyDifficulty-4 61.67µ 61.85µ ~ 0.100
Mine_WithAddress-4 6.892µ 6.923µ ~ 0.700
DirectSubtreeAdd/4_per_subtree-4 58.07n 57.14n ~ 0.700
DirectSubtreeAdd/64_per_subtree-4 28.50n 28.56n ~ 1.000
DirectSubtreeAdd/256_per_subtree-4 27.14n 27.40n ~ 0.100
DirectSubtreeAdd/1024_per_subtree-4 26.17n 26.31n ~ 0.100
DirectSubtreeAdd/2048_per_subtree-4 25.87n 25.88n ~ 0.700
SubtreeProcessorAdd/4_per_subtree-4 278.8n 280.3n ~ 0.400
SubtreeProcessorAdd/64_per_subtree-4 282.3n 273.7n ~ 0.200
SubtreeProcessorAdd/256_per_subtree-4 276.5n 275.0n ~ 0.400
SubtreeProcessorAdd/1024_per_subtree-4 269.4n 267.7n ~ 0.200
SubtreeProcessorAdd/2048_per_subtree-4 269.9n 267.7n ~ 0.100
SubtreeProcessorRotate/4_per_subtree-4 275.2n 274.3n ~ 0.700
SubtreeProcessorRotate/64_per_subtree-4 272.3n 271.1n ~ 0.700
SubtreeProcessorRotate/256_per_subtree-4 274.1n 272.0n ~ 0.200
SubtreeProcessorRotate/1024_per_subtree-4 275.1n 270.6n ~ 0.100
SubtreeNodeAddOnly/4_per_subtree-4 54.13n 55.34n ~ 0.200
SubtreeNodeAddOnly/64_per_subtree-4 34.26n 34.43n ~ 0.700
SubtreeNodeAddOnly/256_per_subtree-4 33.27n 33.46n ~ 0.200
SubtreeNodeAddOnly/1024_per_subtree-4 32.78n 32.70n ~ 0.800
SubtreeCreationOnly/4_per_subtree-4 113.4n 113.6n ~ 1.000
SubtreeCreationOnly/64_per_subtree-4 398.3n 393.1n ~ 0.700
SubtreeCreationOnly/256_per_subtree-4 1.320µ 1.431µ ~ 0.100
SubtreeCreationOnly/1024_per_subtree-4 4.385µ 4.577µ ~ 0.100
SubtreeCreationOnly/2048_per_subtree-4 7.873µ 8.056µ ~ 0.100
SubtreeProcessorOverheadBreakdown/64_per_subtree-4 270.2n 270.1n ~ 1.000
SubtreeProcessorOverheadBreakdown/1024_per_subtree-4 269.3n 271.4n ~ 0.100
ParallelGetAndSetIfNotExists/1k_nodes-4 802.7µ 853.8µ ~ 0.100
ParallelGetAndSetIfNotExists/10k_nodes-4 1.584m 1.631m ~ 0.100
ParallelGetAndSetIfNotExists/50k_nodes-4 6.784m 6.718m ~ 0.700
ParallelGetAndSetIfNotExists/100k_nodes-4 13.54m 13.45m ~ 0.400
SequentialGetAndSetIfNotExists/1k_nodes-4 664.6µ 660.3µ ~ 0.400
SequentialGetAndSetIfNotExists/10k_nodes-4 2.768m 2.803m ~ 0.400
SequentialGetAndSetIfNotExists/50k_nodes-4 10.30m 10.51m ~ 0.100
SequentialGetAndSetIfNotExists/100k_nodes-4 19.79m 20.11m ~ 0.200
ProcessOwnBlockSubtreeNodesParallel/1k_nodes-4 627.6µ 636.1µ ~ 0.700
ProcessOwnBlockSubtreeNodesParallel/10k_nodes-4 4.207m 4.204m ~ 1.000
ProcessOwnBlockSubtreeNodesParallel/100k_nodes-4 16.56m 16.59m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/1k_nodes-4 689.7µ 697.6µ ~ 0.700
ProcessOwnBlockSubtreeNodesSequential/10k_nodes-4 5.868m 5.691m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/100k_nodes-4 37.74m 37.59m ~ 0.400
DiskTxMap_SetIfNotExists-4 3.626µ 3.582µ ~ 1.000
DiskTxMap_SetIfNotExists_Parallel-4 3.273µ 3.285µ ~ 0.400
DiskTxMap_ExistenceOnly-4 288.2n 292.3n ~ 0.200
Queue-4 185.7n 187.2n ~ 0.100
AtomicPointer-4 4.663n 4.490n ~ 0.200
ReorgOptimizations/DedupFilterPipeline/Old/10K-4 859.3µ 857.8µ ~ 0.700
ReorgOptimizations/DedupFilterPipeline/New/10K-4 838.7µ 797.7µ ~ 0.100
ReorgOptimizations/AllMarkFalse/Old/10K-4 108.8µ 126.8µ ~ 0.100
ReorgOptimizations/AllMarkFalse/New/10K-4 62.79µ 62.56µ ~ 0.700
ReorgOptimizations/HashSlicePool/Old/10K-4 59.13µ 60.86µ ~ 0.400
ReorgOptimizations/HashSlicePool/New/10K-4 11.78µ 10.59µ ~ 0.100
ReorgOptimizations/NodeFlags/Old/10K-4 4.594µ 5.418µ ~ 0.100
ReorgOptimizations/NodeFlags/New/10K-4 1.615µ 2.253µ ~ 0.100
ReorgOptimizations/DedupFilterPipeline/Old/100K-4 9.118m 10.220m ~ 0.100
ReorgOptimizations/DedupFilterPipeline/New/100K-4 9.018m 9.509m ~ 0.100
ReorgOptimizations/AllMarkFalse/Old/100K-4 1.126m 1.145m ~ 0.700
ReorgOptimizations/AllMarkFalse/New/100K-4 680.4µ 688.5µ ~ 0.200
ReorgOptimizations/HashSlicePool/Old/100K-4 541.6µ 544.7µ ~ 0.700
ReorgOptimizations/HashSlicePool/New/100K-4 311.3µ 288.3µ ~ 0.700
ReorgOptimizations/NodeFlags/Old/100K-4 48.56µ 45.30µ ~ 0.200
ReorgOptimizations/NodeFlags/New/100K-4 16.83µ 15.74µ ~ 0.200
TxMapSetIfNotExists-4 52.47n 51.52n ~ 0.400
TxMapSetIfNotExistsDuplicate-4 38.10n 38.22n ~ 0.700
ChannelSendReceive-4 618.1n 603.7n ~ 0.700
BlockAssembler_AddTx-4 0.02872n 0.02792n ~ 1.000
AddNode-4 10.23 10.90 ~ 1.000
AddNodeWithMap-4 11.41 10.44 ~ 0.400
CalcBlockWork-4 499.8n 498.4n ~ 1.000
CalculateWork-4 677.4n 677.2n ~ 1.000
BuildBlockLocatorString_Helpers/Size_10-4 1.320µ 1.352µ ~ 0.500
BuildBlockLocatorString_Helpers/Size_100-4 14.59µ 16.12µ ~ 1.000
BuildBlockLocatorString_Helpers/Size_1000-4 124.9µ 127.4µ ~ 0.100
CatchupWithHeaderCache-4 104.3m 104.5m ~ 0.400
_BufferPoolAllocation/16KB-4 3.386µ 3.408µ ~ 0.400
_BufferPoolAllocation/32KB-4 8.140µ 9.189µ ~ 0.400
_BufferPoolAllocation/64KB-4 16.77µ 15.09µ ~ 0.100
_BufferPoolAllocation/128KB-4 30.82µ 30.63µ ~ 0.700
_BufferPoolAllocation/512KB-4 114.2µ 110.3µ ~ 0.200
_BufferPoolConcurrent/32KB-4 18.76µ 19.71µ ~ 0.100
_BufferPoolConcurrent/64KB-4 29.95µ 31.14µ ~ 0.100
_BufferPoolConcurrent/512KB-4 147.3µ 139.8µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/16KB-4 626.4µ 605.9µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/32KB-4 625.5µ 606.7µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/64KB-4 624.2µ 602.0µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/128KB-4 615.7µ 607.1µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/512KB-4 634.5µ 619.2µ ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/16KB-4 36.38m 35.51m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/32KB-4 35.78m 35.47m ~ 0.200
_SubtreeDataDeserializationWithBufferSizes/64KB-4 35.78m 35.61m ~ 0.400
_SubtreeDataDeserializationWithBufferSizes/128KB-4 36.12m 35.23m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/512KB-4 35.56m 35.09m ~ 0.400
_PooledVsNonPooled/Pooled-4 739.8n 736.5n ~ 0.100
_PooledVsNonPooled/NonPooled-4 6.620µ 7.307µ ~ 0.100
_MemoryFootprint/Current_512KB_32concurrent-4 6.908µ 6.881µ ~ 0.700
_MemoryFootprint/Proposed_32KB_32concurrent-4 10.201µ 9.123µ ~ 0.100
_MemoryFootprint/Alternative_64KB_32concurrent-4 10.400µ 8.830µ ~ 0.100
_prepareTxsPerLevel-4 407.7m 416.4m ~ 0.700
_prepareTxsPerLevelOrdered-4 3.622m 4.221m ~ 0.100
_prepareTxsPerLevel_Comparison/Original-4 415.7m 422.6m ~ 0.200
_prepareTxsPerLevel_Comparison/Optimized-4 3.534m 3.869m ~ 0.100
SubtreeSizes/10k_tx_4_per_subtree-4 1.415m 1.380m ~ 0.700
SubtreeSizes/10k_tx_16_per_subtree-4 334.2µ 328.3µ ~ 0.400
SubtreeSizes/10k_tx_64_per_subtree-4 79.38µ 77.94µ ~ 0.100
SubtreeSizes/10k_tx_256_per_subtree-4 19.64µ 19.67µ ~ 1.000
SubtreeSizes/10k_tx_512_per_subtree-4 9.793µ 9.847µ ~ 0.700
SubtreeSizes/10k_tx_1024_per_subtree-4 4.880µ 4.819µ ~ 1.000
SubtreeSizes/10k_tx_2k_per_subtree-4 2.405µ 2.439µ ~ 0.200
BlockSizeScaling/10k_tx_64_per_subtree-4 77.43µ 77.59µ ~ 1.000
BlockSizeScaling/10k_tx_256_per_subtree-4 19.33µ 19.38µ ~ 0.400
BlockSizeScaling/10k_tx_1024_per_subtree-4 4.863µ 4.828µ ~ 0.600
BlockSizeScaling/50k_tx_64_per_subtree-4 404.2µ 400.2µ ~ 0.200
BlockSizeScaling/50k_tx_256_per_subtree-4 97.23µ 97.00µ ~ 0.700
BlockSizeScaling/50k_tx_1024_per_subtree-4 23.82µ 23.96µ ~ 0.700
SubtreeAllocations/small_subtrees_exists_check-4 164.4µ 164.2µ ~ 0.700
SubtreeAllocations/small_subtrees_data_fetch-4 166.5µ 167.0µ ~ 0.700
SubtreeAllocations/small_subtrees_full_validation-4 332.3µ 337.4µ ~ 0.200
SubtreeAllocations/medium_subtrees_exists_check-4 9.747µ 9.722µ ~ 0.700
SubtreeAllocations/medium_subtrees_data_fetch-4 9.957µ 10.043µ ~ 0.200
SubtreeAllocations/medium_subtrees_full_validation-4 19.39µ 19.81µ ~ 0.100
SubtreeAllocations/large_subtrees_exists_check-4 2.362µ 2.350µ ~ 0.400
SubtreeAllocations/large_subtrees_data_fetch-4 2.416µ 2.452µ ~ 0.100
SubtreeAllocations/large_subtrees_full_validation-4 4.877µ 4.899µ ~ 0.100
StoreBlock_Sequential/BelowCSVHeight-4 330.1µ 327.5µ ~ 0.700
StoreBlock_Sequential/AboveCSVHeight-4 326.4µ 327.5µ ~ 0.700
GetUtxoHashes-4 272.1n 272.8n ~ 1.000
GetUtxoHashes_ManyOutputs-4 45.83µ 45.66µ ~ 0.400
_NewMetaDataFromBytes-4 230.7n 230.7n ~ 1.000
_Bytes-4 610.9n 610.6n ~ 0.400
_MetaBytes-4 560.7n 560.6n ~ 1.000

Threshold: >10% with p < 0.05 | Generated: 2026-05-14 08:48 UTC

@freemans13 freemans13 self-assigned this Apr 20, 2026
@freemans13 freemans13 requested a review from icellan April 21, 2026 06:30

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Fixes catchup peer-selection behavior so a forked/short peer that yields zero headers after filtering no longer short-circuits catchup as “success” unless the target block is already known locally.

Changes:

  • Refactors the “zero headers after filterHeaders” early-exit into handleNoNewHeaders.
  • Adds an existence check for blockUpTo before returning success; returns an error when the target is not known.
  • Adds unit tests covering the new helper behavior and arguments passed to GetBlockExists.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
services/blockvalidation/catchup.go Routes the zero-headers early-exit through handleNoNewHeaders and adds the target-existence verification.
services/blockvalidation/catchup_forked_peer_test.go Adds test coverage for the new handleNoNewHeaders behavior and hash used in the existence check.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread services/blockvalidation/catchup.go Outdated
func (u *Server) handleNoNewHeaders(ctx context.Context, blockUpTo *model.Block) error {
exists, err := u.blockchainClient.GetBlockExists(ctx, blockUpTo.Hash())
if err != nil {
return errors.NewProcessingError("[catchup][%s] peer returned no new headers and block existence check failed: %v", blockUpTo.Hash().String(), err)

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

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

errors.NewProcessingError treats a trailing error argument as the wrapped error and removes it from the format params. Because the format string still includes : %v, this will produce %!v(MISSING) in the message and lose the intended formatting. Prefer omitting the : %v placeholder and pass err as the wrapped error (or include err.Error() as a non-error param if you need it in the formatted message).

Suggested change
return errors.NewProcessingError("[catchup][%s] peer returned no new headers and block existence check failed: %v", blockUpTo.Hash().String(), err)
return errors.NewProcessingError("[catchup][%s] peer returned no new headers and block existence check failed", blockUpTo.Hash().String(), err)

✓ Fixed - The : %v placeholder has been removed and the error is correctly passed as a wrapped error.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch — errors.NewProcessingError/errors.New strip the trailing error before calling fmt.Errorf, so : %v would have rendered as %!v(MISSING). Removed the : %v placeholder and kept err as the wrapped error (it still appears in the wrapped chain via Error()).

Comment thread services/blockvalidation/catchup.go Outdated
Comment on lines +269 to +273
// A peer on a dead or shorter fork can legitimately return zero new headers even when
// blockUpTo is unknown to us. Returning nil unconditionally would make the outer
// peer-selection loop treat this as success and stop trying other peers, leaving the
// node unable to sync past the announced block. Only treat it as "already synced"
// when blockUpTo is actually in our chain; otherwise surface an error so the caller

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

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

The comment says to verify blockUpTo is "in our chain", but GetBlockExists only checks for presence in the blocks table (see stores/blockchain/sql/GetBlockExists.go), which can be true even for non-main-chain blocks. Either tighten the check to main-chain membership (e.g., fetch the block meta/ID then call CheckBlockIsInCurrentChain) or adjust the wording/error messages to reflect that you're only checking local existence.

✓ Fixed - The wording has been updated to "known locally" throughout (doc comment, log message, error text) and includes a note explaining why local existence is sufficient for this use case.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

You're right — GetBlockExists is a blocks-table presence check, not main-chain membership. I went with adjusting the wording rather than tightening the check, because for this specific path (decide whether to keep trying other peers), local presence is actually what we want: if we already have the announced block in any form, including as a stale off-chain block, we have already advanced to or past it, so there is nothing to catch up to on another peer. I updated the doc comment, log message, and error text to say "known locally" instead of "in our chain" and added a note explaining why local existence is sufficient.

Comment on lines +15 to +21
// TestHandleNoNewHeaders covers the peer-selection short-circuit fix.
// When a peer returns zero new headers after common-ancestor filtering, the
// outer peer-selection loop uses the return value of catchup() to decide
// whether to try other peers. A peer on a dead fork can legitimately return
// zero new headers while blockUpTo is still unknown to us — in that case we
// must surface an error so the caller tries the next peer, not pretend we
// are already synced.

Copilot AI Apr 22, 2026

Copy link

Choose a reason for hiding this comment

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

This test (and the surrounding comment) talks about the target being "in our chain", but the mocked call is GetBlockExists, which is an existence check and not necessarily a main-chain membership check. Consider renaming the test/subtests and adjusting the wording to "target exists locally" unless the production code is updated to check current-chain membership.

✓ Fixed - The subtests have been renamed (TargetExistsLocallyReturnsNil, TargetNotKnownLocallyReturnsError) and test comments updated to match the production semantics.

Copilot uses AI. Check for mistakes.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed — renamed the subtests (TargetExistsLocallyReturnsNil, TargetNotKnownLocallyReturnsError) and updated the surrounding test comments to match the production semantics (local existence, not main-chain membership).

- Remove stray `: %v` from NewProcessingError format string; the errors
  package strips trailing errors from params before fmt.Errorf, so `: %v`
  was rendering `%!v(MISSING)`. Keep err as the wrapped error.
- Clarify that handleNoNewHeaders uses a local-existence check, not main-
  chain membership. Update doc comment, log message, error text, and test
  subtests/comments to say "known locally" instead of "in our chain".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.


💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@sonarqubecloud

Copy link
Copy Markdown

@oskarszoon oskarszoon left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Approve. The fix is the right shape and the prod trigger (teratestnet stuck at 15630 while honest peers were at 15637) is exactly the kind of silent freeze that the old return nil was hiding. Returning nil only when the target is locally known (main chain OR off-chain) and ProcessingError otherwise so the peer-selection loop tries the next candidate is the safe choice. Tests are tight — UsesTargetBlockHash in particular guards the right hash from being silently swapped in a refactor.

Two confirmations before merge:

  • test CI job is failing on the latest run while everything else is green. Same flake pattern seen on a couple of unrelated PRs this week — worth a re-run to confirm flake vs real (the 4 new tests do exercise new code paths).
  • Test plan's last item is unchecked: "Deploy to teratestnet and verify it advances past 15630". Has that validation been done? Given the PR claims to fix that exact incident, worth confirming the fix actually unblocks the stuck environment before merge.

@freemans13 freemans13 requested a review from liam May 14, 2026 10:34
@freemans13 freemans13 merged commit c4fa275 into bsv-blockchain:main May 14, 2026
25 checks passed
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.

4 participants