Skip to content

fix(utxopersister): nil-guard CreateUTXOSet against empty ConsolidateBlockRange#969

Merged
liam merged 1 commit into
bsv-blockchain:mainfrom
liam:liam/utxopersister-nil-on-startup
May 28, 2026
Merged

fix(utxopersister): nil-guard CreateUTXOSet against empty ConsolidateBlockRange#969
liam merged 1 commit into
bsv-blockchain:mainfrom
liam:liam/utxopersister-nil-on-startup

Conversation

@liam

@liam liam commented May 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

utxopersister.processNextBlock could crash with a nil-pointer SIGSEGV at UTXOSet.go:527 (c.lastBlockHash[:]) during a startup race between BlockPersister and UTXOPersister. The fix propagates errors correctly and gates downstream code on a valid lastBlockHash; a defensive check at the CreateUTXOSet entry covers any future caller.

Crash signature

ERROR: Processing block <nil> height 0
panic: runtime error: invalid memory address or nil pointer dereference
[signal SIGSEGV: segmentation violation code=0x1 addr=0x0 pc=...]

goroutine 627 [running]:
github.com/bsv-blockchain/teranode/services/utxopersister.(*UTXOSet).CreateUTXOSet(...)
	github.com/bsv-blockchain/teranode/services/utxopersister/UTXOSet.go:527 +0x29d
github.com/bsv-blockchain/teranode/services/utxopersister.(*Server).processNextBlock(...)
	github.com/bsv-blockchain/teranode/services/utxopersister/Server.go:499 +0x84d

Reproduces during 3-node split-mode bring-up: the height-1 probe block triggers processNextBlock on each node's core sidecar before BlockPersister has written the per-block UTXO additions file. The whole core process panics, taking RPC + ancillary services down with it.

Root cause

consolidator.ConsolidateBlockRange only assigns c.lastBlockHash at the bottom of its loop body (consolidator.go:234). The loop:

  • continues over the genesis block (consolidator.go:205)
  • return errs on per-block errors before line 234 — including errors.ErrNotFound from GetUTXOAdditionsReader when BlockPersister hasn't written that block's additions file yet.

processNextBlock had two issues compounding this:

  1. Swallowed real errors: if !errors.Is(err, errors.ErrNotFound) { return 0, nil } — non-NotFound errors were dropped, returning success.
  2. Fell through on ErrNotFound: code below assumed the consolidator was populated even when the loop bailed early, so CreateUTXOSet got a nil lastBlockHash and dereferenced it.

Fix

services/utxopersister/Server.go (processNextBlock):

  • Propagate non-ErrNotFound errors instead of swallowing them.
  • After ConsolidateBlockRange (either success or ErrNotFound), bail with errors.ErrNotFound if c.lastBlockHash is nil — caller treats that as 'no new block, wait for next trigger' (matches the existing if errors.Is(err, errors.ErrNotFound) branch at the call site).

services/utxopersister/UTXOSet.go (CreateUTXOSet):

  • Defensive nil-check alongside the other entry-guards. Future callers get a clear error instead of a SIGSEGV.

Test plan

  • go test ./services/utxopersister/... — full package suite passes
  • go test -race -run TestCreateUTXOSet_NilLastBlockHash ./services/utxopersister/ — new regression test passes under the race detector
  • go vet ./services/utxopersister/... clean
  • Code-review: the processNextBlock change is a small, mechanical error-propagation tightening; verifiable by inspection

The full integration repro lives in test/multinode_split/scenario_04_block_assembly_isolation, which is currently t.Skip-ed pending one other teranode robustness fix (legacy unknown magic parser). Once that lands, deleting the t.Skip line re-enables end-to-end coverage of this fix path.

…BlockRange

processNextBlock could crash with a nil-pointer SIGSEGV at
UTXOSet.go:527 (c.lastBlockHash[:]) during a startup race between
BlockPersister and UTXOPersister, manifesting as:

  ERROR: Processing block <nil> height 0
  panic: runtime error: invalid memory address or nil pointer dereference

Root cause: consolidator.ConsolidateBlockRange only assigns
c.lastBlockHash at the bottom of its loop body (consolidator.go:234).
The loop:
  - 'continue's over the genesis block (consolidator.go:205), and
  - 'return err's on per-block errors before line 234, including
    errors.ErrNotFound from GetUTXOAdditionsReader when BlockPersister
    hasn't yet written that block's UTXO additions file.

processNextBlock then intentionally swallowed ErrNotFound from
ConsolidateBlockRange and fell through to CreateUTXOSet, which
dereferenced the still-nil lastBlockHash. Non-ErrNotFound errors were
also silently dropped via 'return 0, nil', hiding real failures.

Fix:
- processNextBlock now propagates non-ErrNotFound errors instead of
  swallowing them.
- After a successful (or ErrNotFound) ConsolidateBlockRange, gate on
  c.lastBlockHash != nil; if nil, return errors.ErrNotFound so the
  caller treats it as "no new block to process" and waits for the
  next trigger rather than driving downstream code with bad state.
- CreateUTXOSet rejects nil c.lastBlockHash explicitly at the entry
  alongside its other defensive nil checks, so any future caller
  gets a clear error instead of a SIGSEGV.

Regression test pins the CreateUTXOSet defensive check; the
processNextBlock change is small and verifiable by inspection,
exercised in practice by the split-mode chaos suite once that
suite's other teranode-side blocker (legacy 'unknown magic') lands.
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete


Current Review:

This PR correctly fixes a critical nil-pointer dereference bug with strong defensive programming:

✅ Core fix is sound:

  • Server.go:478-484 — Inverted logic correctly propagates errors: ErrNotFound returns gracefully (lets caller retry), all other errors bubble up (previously silently dropped with return 0, nil)
  • Server.go:487-494 — New nil-check after successful ConsolidateBlockRange guards against genesis-only ranges where the loop at consolidator.go:205 skips with continue and never assigns lastBlockHash
  • UTXOSet.go:530-532 — Defense-in-depth: explicit nil-guard at CreateUTXOSet entry prevents SIGSEGV on line 534 (c.lastBlockHash[:]) for any future caller

✅ Test coverage:

  • New regression test TestCreateUTXOSet_NilLastBlockHash directly pins the SIGSEGV scenario with race-detector coverage
  • Existing TestTrigger_ProcessNextBlockNotFound validates the ErrNotFound propagation path at the call site (Server.go:369-371)

✅ Minimal, focused diff:

  • 15 additions handle both halves of the original bug (swallowed errors + unchecked nil)
  • No scope creep or unrelated refactoring

No issues found. The fix is correct, well-tested, and follows the project's error-handling conventions.

@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
13.3% Coverage on New Code (required ≥ 80%)

See analysis details on SonarQube Cloud

@github-actions

Copy link
Copy Markdown
Contributor

Benchmark Comparison Report

Baseline: main (unknown)

Current: PR-969 (59da2a6)

Summary

  • Regressions: 0
  • Improvements: 0
  • Unchanged: 144
  • Significance level: p < 0.05
All benchmark results (sec/op)
Benchmark Baseline Current Change p-value
_NewBlockFromBytes-4 1.717µ 1.657µ ~ 0.400
SplitSyncedParentMap_SetIfNotExists/256_buckets-4 71.17n 71.11n ~ 1.000
SplitSyncedParentMap_SetIfNotExists/16_buckets-4 71.07n 71.15n ~ 1.000
SplitSyncedParentMap_SetIfNotExists/1_bucket-4 71.17n 71.13n ~ 1.000
SplitSyncedParentMap_ConcurrentSetIfNotExists/256_buckets... 38.86n 39.41n ~ 1.000
SplitSyncedParentMap_ConcurrentSetIfNotExists/16_buckets_... 59.44n 59.07n ~ 0.400
SplitSyncedParentMap_ConcurrentSetIfNotExists/1_bucket_pa... 209.1n 200.2n ~ 0.100
MiningCandidate_Stringify_Short-4 218.9n 220.9n ~ 0.400
MiningCandidate_Stringify_Long-4 1.648µ 1.685µ ~ 0.400
MiningSolution_Stringify-4 871.6n 879.3n ~ 0.100
BlockInfo_MarshalJSON-4 1.790µ 1.820µ ~ 0.200
NewFromBytes-4 130.3n 129.9n ~ 1.000
AddTxBatchColumnar_Validation-4 2.469µ 2.472µ ~ 1.000
OffsetValidationLoop-4 638.3n 643.9n ~ 0.400
Mine_EasyDifficulty-4 67.07µ 67.45µ ~ 0.700
Mine_WithAddress-4 7.006µ 7.005µ ~ 1.000
BlockAssembler_AddTx-4 0.02742n 0.02747n ~ 1.000
AddNode-4 11.23 10.97 ~ 0.700
AddNodeWithMap-4 11.97 11.71 ~ 0.100
DiskTxMap_SetIfNotExists-4 3.939µ 3.828µ ~ 0.700
DiskTxMap_SetIfNotExists_Parallel-4 3.829µ 3.527µ ~ 0.100
DiskTxMap_ExistenceOnly-4 395.9n 342.3n ~ 0.700
Queue-4 200.6n 196.0n ~ 0.200
AtomicPointer-4 4.649n 4.326n ~ 0.200
ReorgOptimizations/DedupFilterPipeline/Old/10K-4 956.9µ 961.1µ ~ 1.000
ReorgOptimizations/DedupFilterPipeline/New/10K-4 899.5µ 925.8µ ~ 0.100
ReorgOptimizations/AllMarkFalse/Old/10K-4 114.8µ 114.1µ ~ 1.000
ReorgOptimizations/AllMarkFalse/New/10K-4 63.64µ 62.61µ ~ 0.200
ReorgOptimizations/HashSlicePool/Old/10K-4 68.64µ 64.17µ ~ 0.400
ReorgOptimizations/HashSlicePool/New/10K-4 12.56µ 11.90µ ~ 0.400
ReorgOptimizations/NodeFlags/Old/10K-4 5.268µ 5.420µ ~ 0.100
ReorgOptimizations/NodeFlags/New/10K-4 1.777µ 1.792µ ~ 0.600
ReorgOptimizations/DedupFilterPipeline/Old/100K-4 10.98m 12.11m ~ 0.200
ReorgOptimizations/DedupFilterPipeline/New/100K-4 11.18m 11.55m ~ 0.200
ReorgOptimizations/AllMarkFalse/Old/100K-4 1.164m 1.211m ~ 0.400
ReorgOptimizations/AllMarkFalse/New/100K-4 692.3µ 685.2µ ~ 0.100
ReorgOptimizations/HashSlicePool/Old/100K-4 603.8µ 616.3µ ~ 0.100
ReorgOptimizations/HashSlicePool/New/100K-4 301.6µ 294.5µ ~ 0.700
ReorgOptimizations/NodeFlags/Old/100K-4 51.02µ 57.13µ ~ 0.100
ReorgOptimizations/NodeFlags/New/100K-4 18.52µ 19.42µ ~ 0.100
TxMapSetIfNotExists-4 53.35n 53.42n ~ 0.600
TxMapSetIfNotExistsDuplicate-4 40.37n 40.31n ~ 1.000
ChannelSendReceive-4 597.6n 604.9n ~ 0.700
DirectSubtreeAdd/4_per_subtree-4 57.18n 58.91n ~ 0.700
DirectSubtreeAdd/64_per_subtree-4 29.19n 28.93n ~ 0.100
DirectSubtreeAdd/256_per_subtree-4 27.77n 27.87n ~ 0.400
DirectSubtreeAdd/1024_per_subtree-4 26.49n 26.67n ~ 0.100
DirectSubtreeAdd/2048_per_subtree-4 26.16n 26.16n ~ 0.800
SubtreeProcessorAdd/4_per_subtree-4 288.9n 294.6n ~ 0.200
SubtreeProcessorAdd/64_per_subtree-4 286.3n 287.6n ~ 0.500
SubtreeProcessorAdd/256_per_subtree-4 287.2n 288.4n ~ 0.700
SubtreeProcessorAdd/1024_per_subtree-4 282.8n 288.9n ~ 0.400
SubtreeProcessorAdd/2048_per_subtree-4 284.4n 284.5n ~ 1.000
SubtreeProcessorRotate/4_per_subtree-4 284.4n 282.1n ~ 0.400
SubtreeProcessorRotate/64_per_subtree-4 286.0n 279.3n ~ 0.400
SubtreeProcessorRotate/256_per_subtree-4 277.7n 279.4n ~ 0.100
SubtreeProcessorRotate/1024_per_subtree-4 279.3n 280.2n ~ 0.100
SubtreeNodeAddOnly/4_per_subtree-4 55.20n 54.71n ~ 0.200
SubtreeNodeAddOnly/64_per_subtree-4 36.07n 35.97n ~ 0.200
SubtreeNodeAddOnly/256_per_subtree-4 35.08n 34.99n ~ 0.100
SubtreeNodeAddOnly/1024_per_subtree-4 34.54n 34.43n ~ 0.700
SubtreeCreationOnly/4_per_subtree-4 111.1n 111.9n ~ 0.200
SubtreeCreationOnly/64_per_subtree-4 354.5n 351.3n ~ 0.200
SubtreeCreationOnly/256_per_subtree-4 1.239µ 1.239µ ~ 1.000
SubtreeCreationOnly/1024_per_subtree-4 3.812µ 3.791µ ~ 0.700
SubtreeCreationOnly/2048_per_subtree-4 6.793µ 6.785µ ~ 0.500
SubtreeProcessorOverheadBreakdown/64_per_subtree-4 283.3n 275.9n ~ 0.100
SubtreeProcessorOverheadBreakdown/1024_per_subtree-4 282.7n 277.2n ~ 0.100
ParallelGetAndSetIfNotExists/1k_nodes-4 2.028m 2.008m ~ 0.100
ParallelGetAndSetIfNotExists/10k_nodes-4 5.283m 5.257m ~ 0.200
ParallelGetAndSetIfNotExists/50k_nodes-4 7.176m 7.186m ~ 1.000
ParallelGetAndSetIfNotExists/100k_nodes-4 9.790m 9.800m ~ 1.000
SequentialGetAndSetIfNotExists/1k_nodes-4 1.789m 1.791m ~ 0.700
SequentialGetAndSetIfNotExists/10k_nodes-4 4.511m 4.547m ~ 1.000
SequentialGetAndSetIfNotExists/50k_nodes-4 13.71m 13.86m ~ 0.700
SequentialGetAndSetIfNotExists/100k_nodes-4 25.28m 25.50m ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/1k_nodes-4 2.072m 2.055m ~ 0.400
ProcessOwnBlockSubtreeNodesParallel/10k_nodes-4 8.518m 8.519m ~ 0.700
ProcessOwnBlockSubtreeNodesParallel/100k_nodes-4 13.44m 13.28m ~ 0.200
ProcessOwnBlockSubtreeNodesSequential/1k_nodes-4 1.838m 1.810m ~ 0.700
ProcessOwnBlockSubtreeNodesSequential/10k_nodes-4 8.594m 8.095m ~ 0.200
ProcessOwnBlockSubtreeNodesSequential/100k_nodes-4 45.08m 43.91m ~ 0.200
CalcBlockWork-4 479.3n 482.7n ~ 0.700
CalculateWork-4 652.8n 662.3n ~ 1.000
BuildBlockLocatorString_Helpers/Size_10-4 1.362µ 1.360µ ~ 1.000
BuildBlockLocatorString_Helpers/Size_100-4 15.12µ 15.71µ ~ 1.000
BuildBlockLocatorString_Helpers/Size_1000-4 129.0µ 128.9µ ~ 1.000
CatchupWithHeaderCache-4 104.4m 104.6m ~ 0.700
_BufferPoolAllocation/16KB-4 3.973µ 3.939µ ~ 0.100
_BufferPoolAllocation/32KB-4 9.140µ 9.358µ ~ 0.700
_BufferPoolAllocation/64KB-4 22.54µ 17.52µ ~ 0.700
_BufferPoolAllocation/128KB-4 37.94µ 32.97µ ~ 0.100
_BufferPoolAllocation/512KB-4 123.5µ 132.0µ ~ 0.700
_BufferPoolConcurrent/32KB-4 19.80µ 19.70µ ~ 1.000
_BufferPoolConcurrent/64KB-4 30.81µ 32.68µ ~ 0.700
_BufferPoolConcurrent/512KB-4 152.2µ 158.4µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/16KB-4 705.2µ 655.3µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/32KB-4 690.0µ 650.0µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/64KB-4 672.8µ 633.8µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/128KB-4 674.4µ 644.2µ ~ 0.200
_SubtreeDeserializationWithBufferSizes/512KB-4 609.8µ 669.9µ ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/16KB-4 37.10m 36.64m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/32KB-4 36.97m 36.50m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/64KB-4 36.56m 36.42m ~ 0.400
_SubtreeDataDeserializationWithBufferSizes/128KB-4 36.83m 36.40m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/512KB-4 36.40m 36.19m ~ 0.700
_PooledVsNonPooled/Pooled-4 831.2n 833.7n ~ 0.200
_PooledVsNonPooled/NonPooled-4 9.053µ 8.386µ ~ 0.400
_MemoryFootprint/Current_512KB_32concurrent-4 8.203µ 7.978µ ~ 0.100
_MemoryFootprint/Proposed_32KB_32concurrent-4 10.38µ 10.01µ ~ 0.700
_MemoryFootprint/Alternative_64KB_32concurrent-4 10.05µ 10.16µ ~ 0.200
_prepareTxsPerLevel-4 403.6m 404.0m ~ 0.700
_prepareTxsPerLevelOrdered-4 3.973m 3.549m ~ 0.200
_prepareTxsPerLevel_Comparison/Original-4 403.4m 396.4m ~ 0.100
_prepareTxsPerLevel_Comparison/Optimized-4 3.955m 3.557m ~ 0.100
SubtreeSizes/10k_tx_4_per_subtree-4 997.9µ 968.5µ ~ 0.200
SubtreeSizes/10k_tx_16_per_subtree-4 228.0µ 229.0µ ~ 1.000
SubtreeSizes/10k_tx_64_per_subtree-4 56.08µ 55.07µ ~ 0.400
SubtreeSizes/10k_tx_256_per_subtree-4 14.12µ 13.85µ ~ 0.100
SubtreeSizes/10k_tx_512_per_subtree-4 6.961µ 6.924µ ~ 0.600
SubtreeSizes/10k_tx_1024_per_subtree-4 3.399µ 3.411µ ~ 0.400
SubtreeSizes/10k_tx_2k_per_subtree-4 1.733µ 1.727µ ~ 0.700
BlockSizeScaling/10k_tx_64_per_subtree-4 54.76µ 54.38µ ~ 0.200
BlockSizeScaling/10k_tx_256_per_subtree-4 13.86µ 13.76µ ~ 1.000
BlockSizeScaling/10k_tx_1024_per_subtree-4 3.434µ 3.384µ ~ 0.400
BlockSizeScaling/50k_tx_64_per_subtree-4 288.9µ 289.5µ ~ 1.000
BlockSizeScaling/50k_tx_256_per_subtree-4 68.75µ 69.29µ ~ 0.700
BlockSizeScaling/50k_tx_1024_per_subtree-4 17.14µ 17.10µ ~ 1.000
SubtreeAllocations/small_subtrees_exists_check-4 116.5µ 119.0µ ~ 0.700
SubtreeAllocations/small_subtrees_data_fetch-4 124.7µ 124.2µ ~ 0.400
SubtreeAllocations/small_subtrees_full_validation-4 242.0µ 240.4µ ~ 0.100
SubtreeAllocations/medium_subtrees_exists_check-4 7.081µ 7.086µ ~ 1.000
SubtreeAllocations/medium_subtrees_data_fetch-4 7.438µ 7.403µ ~ 0.400
SubtreeAllocations/medium_subtrees_full_validation-4 13.93µ 13.99µ ~ 1.000
SubtreeAllocations/large_subtrees_exists_check-4 1.684µ 1.699µ ~ 0.400
SubtreeAllocations/large_subtrees_data_fetch-4 1.771µ 1.796µ ~ 0.700
SubtreeAllocations/large_subtrees_full_validation-4 3.461µ 3.450µ ~ 0.400
StoreBlock_Sequential/BelowCSVHeight-4 341.2µ 330.7µ ~ 0.100
StoreBlock_Sequential/AboveCSVHeight-4 336.1µ 333.6µ ~ 0.100
GetUtxoHashes-4 256.6n 258.8n ~ 0.800
GetUtxoHashes_ManyOutputs-4 42.71µ 42.33µ ~ 0.700
_NewMetaDataFromBytes-4 228.1n 227.8n ~ 0.700
_Bytes-4 402.5n 403.0n ~ 1.000
_MetaBytes-4 138.5n 138.2n ~ 0.700

Threshold: >10% with p < 0.05 | Generated: 2026-05-28 10:04 UTC

@ordishs ordishs left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approve. Surgical fix for the SIGSEGV with appropriate defense-in-depth: caller gates in processNextBlock and callee adds a defensive nil-check in CreateUTXOSet. The error-propagation tightening also fixes a latent swallowed-error bug. Verified locally: new regression test passes under -race, full package suite green, go vet clean.

Follow-ups worth tracking (non-blocking):

  • ConsolidateBlockRange's contract is still fragile — it can return nil while leaving c.lastBlockHash == nil. Consider tightening the contract so future callers don't need to repeat the nil check.
  • No unit test covers the new processNextBlock paths (error propagation + empty-range sentinel); the integration repro in scenario_04 is currently t.Skip-ed.

@liam liam requested a review from oskarszoon May 28, 2026 10:38
@icellan icellan self-requested a review May 28, 2026 11:29

@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. Root cause and fix both correct.

ConsolidateBlockRange (consolidator.go:176) leaves c.lastBlockHash == nil when the range contains only genesis (loop skips at :206) or when GetBlockHeadersByHeight returns zero headers. Pre-PR code fell through to CreateUTXOSetc.lastBlockHash[:] → SIGSEGV. Hits on first startup when the last written UTXO set is genesis — common case.

Inverted error handler also fixed: if !errors.Is(err, errors.ErrNotFound) { return 0, nil } was swallowing real errors as success AND letting ErrNotFound fall through to the nil deref. New version propagates both cases correctly.

Defense-in-depth at both layers is right: caller (Server.go:487-494) returns errors.ErrNotFound, callee (UTXOSet.go:530-532) catches future bypassers.

Nit: UTXOSet_test.go:45 uses assert.Contains; repo convention is require.Contains. Same issue as PR #971.

Minor cleanup candidate (not a blocker): Server.go:478-484 both branches of the if errors.Is(err, errors.ErrNotFound) block now return 0, err. The two-branch form implies a distinction that doesn't exist anymore — fold into one return.

@liam liam merged commit f88c14a into bsv-blockchain:main May 28, 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