Skip to content

fix(blockvalidation): release subtrees on the valid-block error paths in setTxMinedStatus - #2044

Open
codenlighten wants to merge 4 commits into
bsv-blockchain:mainfrom
codenlighten:fix/2029-settxmined-subtree-leak
Open

codenlighten wants to merge 4 commits into
bsv-blockchain:mainfrom
codenlighten:fix/2029-settxmined-subtree-leak

Conversation

@codenlighten

@codenlighten codenlighten commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #2029.

Problem

setTxMinedStatus (services/blockvalidation/BlockValidation.go) owns the block it works on: fetched fresh from the blockchain, or taken from and deleted out of lastValidatedBlocks. A cached block can carry mmap-backed subtrees (set when it reached the cache through the catchup quick-validate local-failure fallback with blockvalidation_subtreeMmapDir set), and an mmap-backed subtree has no finalizer. Before this change the subtrees were released only after a successful model.UpdateTxMinedStatus, so every earlier error return left that one set of mappings and temp files behind until process exit:

  • the GetBlockHeader and CheckBlockIsInCurrentChain failures, which are reached before the valid path's load (the cached-block case),
  • the GetBlockHeaderIDs failure,
  • ErrBlockInvalid (markBlockAsInvalid), ErrBlockParentNotMined, and the generic error updating tx mined status.

A freshly fetched block loads its subtrees on the heap, so the leak is bounded to the cached mmap set, not one per retry.

Fix

One ReleaseSubtreeNodes defer, registered as soon as the block is owned (after the cache Delete or the fresh fetch). ReleaseSubtreeNodes on a nil or already-released slice is a no-op, so the defer is a safety net on every exit. The success path still releases explicitly before SetBlockMinedSet, so the subtrees are not held across that RPC. The unset-mined path still releases the cached subtrees before reloadSubtreesForInvalidBlock swaps in a fresh set.

model/update-tx-mined.go: the missing-subtree return now joins the goroutines already started (g.Wait()), so the caller's release cannot race a worker still reading Nodes.

Tests

  • TestSetTxMinedStatus_CachedBlockReleasesMmapSubtreesWhenHeaderFetchFails: a cached mmap block, GetBlockHeader fails; the backing files must be gone.
  • TestSetTxMinedStatus_ValidPathReleasesMmapSubtreesOnMinedUpdateError: the generic mined-update error path, same proof.
  • TestSetTxMinedStatus_ReleasesMmapSubtreesBeforeSettingMinedSet: the mmap directory is empty at the moment SetBlockMinedSet is called.
  • TestUpdateTxMinedStatus_MissingSubtree: holds a worker inside SetMinedMulti, requires the call not to have returned, then releases it; it fails when the g.Wait() is removed.

The first two fail against the base BlockValidation.go.

Merge order

#2018 changes SetBlockMinedSet to (ctx, hash, minedSetGeneration); whichever of #2018 and this PR lands second updates the new calls and minedSetProbeClient. #2065 touches the same ErrBlockInvalid branch, so a text conflict is likely.

… in setTxMinedStatus

setTxMinedStatus's valid-block (else) branch loads the block's subtrees via
GetSubtrees but only releases them with ReleaseSubtreeNodes after a successful
UpdateTxMinedStatus. The error returns below — ErrBlockInvalid ->
markBlockAsInvalid, ErrBlockParentNotMined -> nil, the generic update error, and
the GetBlockHeaderIDs failure — all skip that release. The block is owned by
this call (fetched fresh, or taken from and deleted out of lastValidatedBlocks),
and an mmap-backed subtree has no finalizer, so each failed attempt leaks one
set of mmap mappings and temp files; the setTxMined retry loop runs up to 10
times and the mined-not-set sweep retries beyond that.

Release on every exit with a defer, mirroring the unset-mined reload path in the
same function. On success the explicit release still runs first and the defer
finds only nil entries.

Adds a regression test: a failed mined update on the valid path must still leave
the block's subtrees released (nil SubtreeSlices entries).

Fixes bsv-blockchain#2029.
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

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

See analysis details on SonarQube Cloud

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

Benchmark Comparison Report

Baseline: main (unknown)

Current: PR-2044 (13192af)

Summary

  • Regressions: 0
  • Improvements: 0
  • Unchanged: 185
  • Significance level: p < 0.05
All benchmark results (sec/op)
Benchmark Baseline Current Change p-value
_NewBlockFromBytes-4 957.1n 1038.0n ~ 0.100
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=1024/memory-4 11.31m 11.64m ~ 0.100
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=1024/disk_1-4 12.27m 12.35m ~ 0.400
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=1024/disk_2-4 13.03m 13.72m ~ 0.100
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=16384/memo... 21.77m 22.23m ~ 0.100
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=16384/disk... 29.24m 30.86m ~ 0.100
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=16384/disk... 29.03m 30.92m ~ 0.100
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=131072/mem... 79.85m 81.60m ~ 0.400
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=131072/dis... 208.8m 217.2m ~ 0.200
Block_ValidOrderAndBlessed_DiskVsMemory/leaves=131072/dis... 210.3m 216.7m ~ 0.700
BlockTxMap/txs=16384/nodeindex-4 1.566m 1.608m ~ 0.100
BlockTxMap/txs=16384/swiss-4 8.521m 8.683m ~ 1.000
BlockTxMap/txs=262144/nodeindex-4 12.43m 12.21m ~ 0.700
BlockTxMap/txs=262144/swiss-4 34.12m 33.90m ~ 1.000
BlockTxMap/txs=4194304/nodeindex-4 376.0m 366.5m ~ 0.200
BlockTxMap/txs=4194304/swiss-4 718.5m 729.1m ~ 0.100
TxMapPut_MemoryVsDisk/txs=100000/memory-4 4.585m 4.958m ~ 0.700
TxMapPut_MemoryVsDisk/txs=100000/disk_1-4 8.412m 8.161m ~ 0.100
TxMapPut_MemoryVsDisk/txs=100000/disk_2-4 7.628m 7.600m ~ 0.700
TxMapGetFrozen_MemoryVsDisk/txs=100000/memory/hit-4 2.736m 2.890m ~ 0.100
TxMapGetFrozen_MemoryVsDisk/txs=100000/memory/miss-4 2.191m 2.328m ~ 0.100
TxMapGetFrozen_MemoryVsDisk/txs=100000/disk_1/hit-4 1.167m 1.205m ~ 0.100
TxMapGetFrozen_MemoryVsDisk/txs=100000/disk_1/miss-4 1.296m 1.319m ~ 0.100
TxMapGetFrozen_MemoryVsDisk/txs=100000/disk_2/hit-4 1.176m 1.251m ~ 0.400
TxMapGetFrozen_MemoryVsDisk/txs=100000/disk_2/miss-4 1.335m 1.313m ~ 0.400
ParentSpendsSetIfNotExists_MemoryVsDisk/inputs=200000/dis... 20.34m 19.54m ~ 0.700
ParentSpendsSetIfNotExists_MemoryVsDisk/inputs=200000/dis... 19.22m 19.40m ~ 0.400
InpointDedup/txs=16K-4 3.471m 3.618m ~ 0.100
InpointDedup/txs=128K-4 13.16m 13.65m ~ 0.100
InpointDedup/txs=1M-4 98.86m 101.18m ~ 0.200
InpointDedup_TinyRuns-4 34.21m 35.69m ~ 0.700
MiningCandidate_Stringify_Short-4 108.2n 109.7n ~ 0.700
MiningCandidate_Stringify_Long-4 801.8n 859.0n ~ 0.100
MiningSolution_Stringify-4 405.6n 414.3n ~ 0.400
BlockInfo_MarshalJSON-4 1.104µ 1.120µ ~ 0.400
DecodeSubtreeMeta/go-subtree-4 110.0m 112.2m ~ 0.700
DecodeSubtreeMeta/packed-4 30.52m 31.24m ~ 0.400
DecodeSubtreeMeta/go-subtree/parallel-4 61.30m 63.16m ~ 0.200
DecodeSubtreeMeta/packed/parallel-4 18.28m 19.54m ~ 0.700
ValidateSubtreeNodesMatchKey/leaves=1024-4 182.6µ 190.6µ ~ 0.100
ValidateSubtreeNodesMatchKey/leaves=1048576-4 56.71m 57.27m ~ 0.100
NewFromBytes-4 94.42n 94.76n ~ 1.000
AddTxBatchColumnar_Validation-4 1.593µ 1.507µ ~ 0.100
OffsetValidationLoop-4 422.3n 427.2n ~ 1.000
BuildSubtreeMeta/serialized-4 11.57m 27.39m ~ 0.100
BuildSubtreeMeta/decoded-4 21.60m 43.54m ~ 0.100
Mine_EasyDifficulty-4 59.94µ 59.34µ ~ 0.700
Mine_WithAddress-4 7.940µ 7.244µ ~ 1.000
DiskTxMap_SetIfNotExists-4 473.9n 481.8n ~ 1.000
DiskTxMap_SetIfNotExists_Parallel-4 215.7n 178.8n ~ 1.000
DiskTxMap_ExistenceOnly-4 238.7n 238.9n ~ 1.000
Queue-4 168.6n 169.6n ~ 0.100
AtomicPointer-4 4.544n 4.947n ~ 0.200
TxMapSetIfNotExists-4 62.62n 62.61n ~ 1.000
TxMapSetIfNotExistsDuplicate-4 46.90n 46.53n ~ 0.400
ChannelSendReceive-4 597.8n 586.1n ~ 0.100
DirectSubtreeAdd/4_per_subtree-4 44.70n 45.03n ~ 0.700
DirectSubtreeAdd/64_per_subtree-4 24.66n 25.75n ~ 0.700
DirectSubtreeAdd/256_per_subtree-4 23.83n 23.69n ~ 0.700
DirectSubtreeAdd/1024_per_subtree-4 23.14n 22.68n ~ 0.400
DirectSubtreeAdd/2048_per_subtree-4 22.42n 22.55n ~ 0.200
SubtreeProcessorAdd/4_per_subtree-4 147.5n 145.1n ~ 0.200
SubtreeProcessorAdd/64_per_subtree-4 144.8n 143.2n ~ 0.700
SubtreeProcessorAdd/256_per_subtree-4 148.7n 146.0n ~ 0.100
SubtreeProcessorAdd/1024_per_subtree-4 149.1n 146.0n ~ 0.200
SubtreeProcessorAdd/2048_per_subtree-4 150.7n 155.5n ~ 0.400
SubtreeProcessorRotate/4_per_subtree-4 149.2n 160.0n ~ 0.200
SubtreeProcessorRotate/64_per_subtree-4 151.9n 164.1n ~ 0.200
SubtreeProcessorRotate/256_per_subtree-4 147.8n 159.3n ~ 0.700
SubtreeProcessorRotate/1024_per_subtree-4 150.6n 160.9n ~ 0.100
SubtreeNodeAddOnly/4_per_subtree-4 56.24n 56.24n ~ 0.800
SubtreeNodeAddOnly/64_per_subtree-4 44.24n 44.32n ~ 0.700
SubtreeNodeAddOnly/256_per_subtree-4 44.01n 43.11n ~ 0.100
SubtreeNodeAddOnly/1024_per_subtree-4 43.11n 42.46n ~ 0.100
SubtreeCreationOnly/4_per_subtree-4 79.41n 89.06n ~ 0.100
SubtreeCreationOnly/64_per_subtree-4 290.9n 282.0n ~ 0.700
SubtreeCreationOnly/256_per_subtree-4 813.9n 882.9n ~ 0.100
SubtreeCreationOnly/1024_per_subtree-4 2.550µ 2.568µ ~ 0.700
SubtreeCreationOnly/2048_per_subtree-4 4.398µ 4.294µ ~ 0.100
SubtreeProcessorOverheadBreakdown/64_per_subtree-4 148.6n 136.3n ~ 0.100
SubtreeProcessorOverheadBreakdown/1024_per_subtree-4 147.9n 145.2n ~ 0.100
ParallelGetAndSetIfNotExists/1k_nodes-4 499.0µ 577.1µ ~ 0.700
ParallelGetAndSetIfNotExists/10k_nodes-4 3.790m 3.740m ~ 1.000
ParallelGetAndSetIfNotExists/50k_nodes-4 4.993m 4.549m ~ 0.100
ParallelGetAndSetIfNotExists/100k_nodes-4 6.954m 6.017m ~ 0.100
SequentialGetAndSetIfNotExists/1k_nodes-4 451.4µ 404.6µ ~ 0.700
SequentialGetAndSetIfNotExists/10k_nodes-4 6.750m 3.378m ~ 0.100
SequentialGetAndSetIfNotExists/50k_nodes-4 22.09m 13.22m ~ 0.100
SequentialGetAndSetIfNotExists/100k_nodes-4 29.67m 19.47m ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/1k_nodes-4 507.7µ 532.6µ ~ 1.000
ProcessOwnBlockSubtreeNodesParallel/10k_nodes-4 6.046m 5.384m ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/100k_nodes-4 9.693m 8.139m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/1k_nodes-4 687.2µ 4436.7µ ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/10k_nodes-4 8.765m 12.376m ~ 0.400
ProcessOwnBlockSubtreeNodesSequential/100k_nodes-4 54.51m 31.54m ~ 0.100
BlockAssembler_AddTx-4 0.01453n 0.01481n ~ 0.400
AddNode-4 8.544 8.554 ~ 0.400
AddNodeWithMap-4 9.046 8.885 ~ 0.100
CalcBlockWork-4 513.9n 515.2n ~ 0.700
CalculateWork-4 684.8n 690.7n ~ 0.700
CheckOldBlockIDs/on-chain-prefetch/1000-4 58.10µ 60.66µ ~ 0.100
CheckOldBlockIDs/in-memory-chain-check/1000-4 1.432m 1.460m ~ 0.700
CheckOldBlockIDs/on-chain-prefetch/10000-4 458.9µ 458.4µ ~ 1.000
CheckOldBlockIDs/in-memory-chain-check/10000-4 2.176m 2.151m ~ 0.700
BuildBlockLocatorString_Helpers/Size_10-4 1.408µ 1.326µ ~ 0.100
BuildBlockLocatorString_Helpers/Size_100-4 13.40µ 12.58µ ~ 0.100
BuildBlockLocatorString_Helpers/Size_1000-4 133.1µ 124.6µ ~ 0.100
CatchupWithHeaderCache-4 107.1m 106.7m ~ 0.400
CatchupSubtreeDataPrefetch/even/budget=off-4 500.3m 499.2m ~ 0.700
CatchupSubtreeDataPrefetch/even/budget=256Mi-4 492.3m 506.0m ~ 0.100
CatchupSubtreeDataPrefetch/even/budget=128Mi-4 492.1m 490.4m ~ 0.700
CatchupSubtreeDataPrefetch/skewed/budget=off-4 507.2m 508.2m ~ 0.700
CatchupSubtreeDataPrefetch/skewed/budget=256Mi-4 503.5m 503.5m ~ 1.000
CatchupSubtreeDataPrefetch/skewed/budget=128Mi-4 517.6m 519.1m ~ 0.700
CatchupSubtreeDataPrefetch/oversized/budget=off-4 1.327 1.316 ~ 0.700
CatchupSubtreeDataPrefetch/oversized/budget=256Mi-4 1.471 1.492 ~ 0.700
CatchupSubtreeDataPrefetch/oversized/budget=128Mi-4 1.495 1.528 ~ 0.200
CatchupSubtreeDataPrefetchLatency/latency=0s-4 206.9m 210.2m ~ 0.700
CatchupSubtreeDataPrefetchLatency/latency=20ms-4 2.891 2.914 ~ 0.100
CatchupSubtreeDataPrefetchLatency/latency=200ms-4 26.69 26.69 ~ 0.100
TxIDTwiceUncached/400B_small-4 2.018µ 2.104µ ~ 0.100
TxIDTwiceUncached/34KB_mainnet_shape-4 78.50µ 76.98µ ~ 0.400
TxIDOnceCached/400B_small-4 1.335µ 1.389µ ~ 0.100
TxIDOnceCached/34KB_mainnet_shape-4 43.98µ 43.33µ ~ 0.100
_BufferPoolAllocation/16KB-4 4.424µ 4.519µ ~ 0.100
_BufferPoolAllocation/32KB-4 9.730µ 11.475µ ~ 0.400
_BufferPoolAllocation/64KB-4 21.99µ 18.24µ ~ 0.700
_BufferPoolAllocation/128KB-4 20.20µ 21.55µ ~ 0.200
_BufferPoolAllocation/512KB-4 78.41µ 93.82µ ~ 0.100
_BufferPoolConcurrent/32KB-4 20.28µ 20.24µ ~ 1.000
_BufferPoolConcurrent/64KB-4 32.86µ 33.70µ ~ 0.700
_BufferPoolConcurrent/512KB-4 181.0µ 172.1µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/16KB-4 662.4µ 672.7µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/32KB-4 663.6µ 675.5µ ~ 0.200
_SubtreeDeserializationWithBufferSizes/64KB-4 663.9µ 684.4µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/128KB-4 660.8µ 670.1µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/512KB-4 667.4µ 673.3µ ~ 0.400
_SubtreeDataDeserializationWithBufferSizes/16KB-4 34.88m 35.66m ~ 0.200
_SubtreeDataDeserializationWithBufferSizes/32KB-4 35.22m 35.38m ~ 0.400
_SubtreeDataDeserializationWithBufferSizes/64KB-4 35.15m 35.31m ~ 0.400
_SubtreeDataDeserializationWithBufferSizes/128KB-4 34.79m 35.28m ~ 1.000
_SubtreeDataDeserializationWithBufferSizes/512KB-4 35.02m 34.76m ~ 1.000
_PooledVsNonPooled/Pooled-4 730.5n 729.0n ~ 0.200
_PooledVsNonPooled/NonPooled-4 9.059µ 9.070µ ~ 1.000
_MemoryFootprint/Current_512KB_32concurrent-4 7.263µ 7.371µ ~ 0.500
_MemoryFootprint/Proposed_32KB_32concurrent-4 11.98µ 10.64µ ~ 0.100
_MemoryFootprint/Alternative_64KB_32concurrent-4 10.567µ 9.827µ ~ 0.100
_prepareTxsPerLevel-4 429.7m 420.7m ~ 0.700
_prepareTxsPerLevelOrdered-4 3.970m 3.902m ~ 1.000
_prepareTxsPerLevel_Comparison/Original-4 425.2m 424.7m ~ 1.000
_prepareTxsPerLevel_Comparison/Optimized-4 4.339m 3.947m ~ 0.200
SubtreeSizes/10k_tx_4_per_subtree-4 1.483m 1.429m ~ 0.400
SubtreeSizes/10k_tx_16_per_subtree-4 336.8µ 325.4µ ~ 0.100
SubtreeSizes/10k_tx_64_per_subtree-4 80.40µ 77.05µ ~ 0.100
SubtreeSizes/10k_tx_256_per_subtree-4 20.47µ 19.38µ ~ 0.100
SubtreeSizes/10k_tx_512_per_subtree-4 9.918µ 9.662µ ~ 0.100
SubtreeSizes/10k_tx_1024_per_subtree-4 4.953µ 4.704µ ~ 0.100
SubtreeSizes/10k_tx_2k_per_subtree-4 2.469µ 2.381µ ~ 0.100
BlockSizeScaling/10k_tx_64_per_subtree-4 78.08µ 75.58µ ~ 0.100
BlockSizeScaling/10k_tx_256_per_subtree-4 19.85µ 18.90µ ~ 0.100
BlockSizeScaling/10k_tx_1024_per_subtree-4 4.907µ 4.699µ ~ 0.100
BlockSizeScaling/50k_tx_64_per_subtree-4 411.9µ 394.4µ ~ 0.100
BlockSizeScaling/50k_tx_256_per_subtree-4 99.32µ 95.29µ ~ 0.100
BlockSizeScaling/50k_tx_1024_per_subtree-4 24.58µ 23.36µ ~ 0.100
SubtreeAllocations/small_subtrees_exists_check-4 166.8µ 160.7µ ~ 0.100
SubtreeAllocations/small_subtrees_data_fetch-4 174.2µ 167.9µ ~ 0.400
SubtreeAllocations/small_subtrees_full_validation-4 336.0µ 330.4µ ~ 0.100
SubtreeAllocations/medium_subtrees_exists_check-4 9.605µ 9.429µ ~ 0.100
SubtreeAllocations/medium_subtrees_data_fetch-4 10.212µ 9.635µ ~ 0.100
SubtreeAllocations/medium_subtrees_full_validation-4 19.89µ 18.90µ ~ 0.100
SubtreeAllocations/large_subtrees_exists_check-4 2.307µ 2.298µ ~ 0.700
SubtreeAllocations/large_subtrees_data_fetch-4 2.549µ 2.315µ ~ 0.100
SubtreeAllocations/large_subtrees_full_validation-4 5.000µ 4.676µ ~ 0.100
StoreBlockForkPath/inMemoryChainCheck=false-4 589.9µ 603.2µ ~ 0.700
StoreBlockForkPath/inMemoryChainCheck=true-4 1.222m 1.231m ~ 0.200
StoreBlock_Sequential/BelowCSVHeight-4 189.0µ 187.9µ ~ 0.400
StoreBlock_Sequential/AboveCSVHeight-4 188.7µ 186.5µ ~ 0.400
CheckSpendAndCreateMultiList_FanIn/parents=80000-4 85.76m 61.31m ~ 0.100
CheckSpendAndCreateMultiList_FanIn/parents=160000-4 211.0m 161.9m ~ 0.100
GetUtxoHashes-4 265.4n 263.0n ~ 0.300
GetUtxoHashes_ManyOutputs-4 55.13µ 54.12µ ~ 0.700
MetaBytes-4 69.27n 68.52n ~ 0.100
_NewMetaDataFromBytes-4 238.9n 234.4n ~ 0.700
_Bytes-4 385.9n 380.4n ~ 0.100
_MetaBytes-4 111.9n 110.5n ~ 0.100

Threshold: >10% with p < 0.05 | Generated: 2026-10-26 19:01 UTC

@icellan icellan 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.

The defer lands too late to close the leak 2029 describes, and the premise is inverted: the fresh-fetch block is heap-only, the cached block is the one that can hold mmap.

Not in the diff, so here:

  • services/blockvalidation/BlockValidation.go:1625 — this comment says the block "may still be reachable via lastValidatedBlocks". That contradicts the new "owned by this call" comment at :1569, and the new one is right (the Delete at :1504). Remove the stale one in this PR.
  • model/update-tx-mined.go:236 — pre-existing: the "missing subtree" return skips g.Wait(), so already-started goroutines keep reading Nodes / passing &Nodes[idx].Hash to SetMinedMulti after the call returns. The new defer assumes they are joined. Add g.Wait() before returning.

Comment thread services/blockvalidation/BlockValidation.go Outdated
return errors.NewProcessingError("[setTxMined][%s] failed to get subtrees from block", block.Hash().String(), err)
}

// Release the subtrees on EVERY exit, not just the success path below.

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.

"each failed attempt leaks one set of mmap mappings ... retries up to 10 times" is wrong for this path. GetSubtrees loads via getAndValidateSubtrees -> DeserializeFromReaderWithAllocator, which is heap-only, and retries 2-10 fetch a fresh block. At most one mmap set leaks, and only when the block came from the cache after the quick-validate local-failure fallback at catchup.go:2099 (with blockvalidation_subtreeMmapDir set). Fix the comment and the PR body, otherwise the next person reasoning about ownership here gets misled.

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.

Code comment is fixed. The PR body and the test doc comment at set_tx_mined_revert_test.go:264 still carry the retry claim, so leaving this open.

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.

Code side is done; only the test doc comment at :264 and the PR body still carry it.

Comment thread services/blockvalidation/set_tx_mined_revert_test.go Outdated
…, join tx-mined goroutines before the missing-subtree return

Addresses review of bsv-blockchain#2044 (issue bsv-blockchain#2029).

setTxMinedStatus takes a block from lastValidatedBlocks (owned once Deleted
from the cache) that can hold mmap-backed subtrees, but released them only
after a successful UpdateTxMinedStatus. The GetBlockHeader and
CheckBlockIsInCurrentChain error returns run before that release, so a cached
block's one mmap mapping and its temp backing file leaked on those paths (for
example during a blockchain service restart). Register a single
ReleaseSubtreeNodes defer as soon as the block is owned; it covers those
returns, the valid path's heap-only GetSubtrees, the unset path's reload, and
success. The per-branch defers and the explicit success-path release the defer
now subsumes are removed, along with the stale "may still be reachable via
lastValidatedBlocks" comment (the block is owned once deleted from the cache).

The unset path still releases the cached subtrees before
reloadSubtreesForInvalidBlock swaps in a fresh set with ReplaceSubtreeSlices,
since the single defer only sees the final slice.

model.updateTxMinedStatus returned on a missing subtree (!unsetMined) from
inside the subtree loop, before g.Wait(), so goroutines already started for
earlier subtrees kept reading &subtree.Nodes[idx].Hash. With the caller now
releasing (closing mmap-backed Nodes) on that error, that was a use-after-free;
join the group before returning.

The tests use mmap-backed subtrees and assert the backing files are gone, which
a nil-entry check cannot see (the pinned release path nils entries without
closing). They cover the cached-block GetBlockHeader-failure path that leaked,
the generic mined-update error path, and the goroutine join.
@codenlighten

Copy link
Copy Markdown
Contributor Author

Thanks @icellan — this was the right catch; the premise was inverted and the defer landed too late. Reworked in c3366c3, addressing every point:

Defer placement (BlockValidation.go). Replaced the two per-branch defers with a single ReleaseSubtreeNodes defer registered as soon as the block is owned (right after the cache Delete / fresh fetch, ~:1516). It now covers the GetBlockHeader and CheckBlockIsInCurrentChain error returns — the cached-block leak you identified — plus the GetSubtrees partial-load return, GetBlockHeaderIDs, the UpdateTxMinedStatus errors, and success.

Inverted premise / stale comments. Corrected the comments and the PR body: the valid path's GetSubtrees is heap-only (getAndValidateSubtrees → DeserializeFromReaderWithAllocator), fetch retries 2–10 pull a fresh heap block, so at most one mmap set can leak, and only for a block that reached lastValidatedBlocks via the catchup quick-validate local-failure fallback with blockvalidation_subtreeMmapDir set. Removed the stale "may still be reachable via lastValidatedBlocks" comment at the old :1625 — the block is owned once deleted from the cache, matching the :1569 note.

Pre-reload release (unset path) kept. The single defer only sees the final slice, and reloadSubtreesForInvalidBlock swaps in a fresh set via ReplaceSubtreeSlices, so the cached subtrees are still released explicitly before the reload; the defer then releases the reloaded set.

g.Wait() before the missing-subtree return (model/update-tx-mined.go:236). Added _ = g.Wait() before that !unsetMined return, so goroutines started for earlier subtrees are joined before the caller releases (and closes) their Nodes. The group's error is discarded in favour of the specific missing-subtree error (workers log their own failures via setMinedErrorCount).

Tests. Rewritten to use mmap-backed subtrees and assert the backing files are gone (os.ReadDir(mmapDir) empty) — a nil-entry check can't see the leak, since the pinned release path nils entries without Close. Coverage:

  • …CachedBlockReleasesMmapSubtreesWhenHeaderFetchFails — the case that leaked today (cached mmap block, GetBlockHeader fails before the valid-path load). Confirmed it fails on the previous revision and passes now.
  • …ValidPathReleasesMmapSubtreesOnMinedUpdateError — the generic mined-update error path, with the mmap-release proof.
  • a TestUpdateTxMinedStatus_MissingSubtree subtest that spawns a goroutine for a valid subtree ahead of the missing one, exercising the join (run under -race).

One honest limitation: a deterministic use-after-free reproduction for the g.Wait fix isn't practical (it needs the caller's release to interleave with an in-flight worker, which a unit test can't force), so that line is covered and the join exercised, but the race itself is guarded by -race in CI rather than a dedicated failing-without-the-fix test.

Verified on a Go 1.27 host: go vet clean; go test -race green for model and services/blockvalidation.

@icellan icellan 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.

Round 2 at c3366c3. The single early defer, the stale-comment removal, g.Wait() and the mmap-dir tests are in, and both blockvalidation tests fail against the base BlockValidation.go. Blocking on the release-timing change below. The PR body still carries the retry-leak claim and describes TestSetTxMinedStatus_ValidPathReleasesSubtreesOnError with a nil-entry check, which no longer exists — please update it.

Comment thread services/blockvalidation/BlockValidation.go Outdated
Comment thread model/update-tx-mined_test.go
// successful UpdateTxMinedStatus. The error returns (ErrBlockInvalid,
// ErrBlockParentNotMined, the generic update error, and the GetBlockHeaderIDs
// failure) skipped that release, so the one owned mmap mapping and its temp
// backing file leaked, and the setTxMined retry loop runs up to 10 times. Here a

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.

"the setTxMined retry loop runs up to 10 times" repeats the claim from the last round. Retries fetch a fresh block whose GetSubtrees load is heap-only, so at most one cached mmap set can leak. Drop it here and in the PR body.

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.

Still at :264 in 22e631c ("the setTxMined retry loop runs up to 10 times"). Non-blocking, leaving open.

// headerFailingClient fails GetBlockHeader on demand. On the cached-block path
// only GetBlockHeader is reached before the failure, so the embedded nil ClientI
// is never called.
type headerFailingClient struct {

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.

Embedding a nil blockchain.ClientI goes against AGENTS.md ("Don't mock the blockchain client/store — use the sqlitememory store") and the package's own decorators (setMinedFaultClient, trackingBlockchainClient), which wrap a real sqlitememory client. If another client call is added before GetBlockHeader this panics on a nil interface instead of failing cleanly. fail is always true, so drop the flag or use it.

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.

Still embeds a nil ClientI in 22e631c, and fail is still always true. Non-blocking, leaving open.

@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.

The cleanup now covers the cached-block header failure and mined-update errors, but the added goroutine-join test does not protect the new synchronization guard. I would hold this until the test catches removal of the wait.

  • model/update-tx-mined_test.go:756 — The new subtest passes under -race with _ = g.Wait() removed from model/update-tx-mined.go:242. It checks only the missing-subtree error, and SetMinedMulti is optional. Block the first worker in SetMinedMulti, prove the update has not returned, then release the worker and check the error. This needs to fail when the join is removed.

@freemans13 freemans13 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.

Request changes at c3366c3. I independently confirmed the two existing concerns below and found no additional defects. Linking the existing threads to keep the discussion together.

  1. Release subtrees before publishing mined_set. Removing the explicit success-path release retains the full subtree set throughout SetBlockMinedSet. A slow RPC extends that residency, and publishing the flag lets child validation proceed before the parent's cleanup finishes. Keep the early defer for error paths and restore the explicit release before the RPC. Existing discussion.
  2. Make the join test detect a missing wait. With only _ = g.Wait() removed through a Go build overlay, the new subtest passes all five runs under -race. Its optional mock call and error assertion do not establish that the worker finished. Block SetMinedMulti with a channel, verify the update has not returned, then release the worker and check the error. That test must fail when the wait is removed. Existing discussion.

Verification used Go 1.27.3 on darwin/arm64, SETTINGS_CONTEXT=test, -race, and -tags testtxmetacache:

  • TestUpdateTxMinedStatus_MissingSubtree and TestSetTxMinedStatus* passed with -count=2.
  • Both new mmap cleanup tests failed twice with the base BlockValidation.go overlaid, leaving backing files behind. They therefore detect the original leaks.
  • The new join subtest passed with -count=5 after removing the wait through an overlay.
  • CI's failing TestShouldHandleReorgWithLongerChain passed twice in isolation. I have not established that the CI failure is caused by this PR.

I did not rerun the full suite or lint locally. The checkout is unchanged.

…n test fail without the wait

Review follow-ups on the setTxMinedStatus subtree release.

The success path releases the block's subtrees again before SetBlockMinedSet. With only
the deferred release, the full subtree set stayed resident for the duration of that RPC,
and publishing mined_set let child validation proceed before this block's cleanup had
finished. The defer stays for the error returns; on a slice already released it is a
no-op, because a release nils every entry it frees.

TestSetTxMinedStatus_ReleasesMmapSubtreesBeforeSettingMinedSet records the mmap
directory at the moment SetBlockMinedSet is called and requires it empty, then releases
once more to show the repeat is a clean no-op. Without the early release the directory
still holds the backing file at that point and the test fails; the other tests here only
look after the function returns, so they pass either way.

The join subtest in TestUpdateTxMinedStatus_MissingSubtree now holds the worker inside
SetMinedMulti, requires updateTxMinedStatus not to have returned while it is there, then
releases it and checks the missing-subtree error. Removing the g.Wait before the
missing-subtree return fails it, without -race. The not-returned check is a bounded
250 ms window, so it can miss a regression on a very slow scheduler but cannot fail a
correct implementation.

@icellan icellan 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.

Round 3 at 22e631c. Fix is correct: one defer from the point of ownership, success path still releases before SetBlockMinedSet, and the join test now blocks the worker so it can actually fail. Approving.

Before merge:

  • Merge order with #2018 (approved). It changes SetBlockMinedSet to (ctx, hash, minedSetGeneration); whichever lands second has to update the new calls and minedSetProbeClient here, or the blockvalidation test package stops compiling. #2065 touches the same ErrBlockInvalid branch, so expect a text conflict there too.
  • The PR body still names TestSetTxMinedStatus_ValidPathReleasesSubtreesOnError and a per-branch defer, neither of which exists. Update it so the squash message matches the code.
  • Two round-2 threads are still open (retry claim at set_tx_mined_revert_test.go:264, nil ClientI embed in headerFailingClient). Not blocking.

readErr error
}

func (c *minedSetProbeClient) SetBlockMinedSet(ctx context.Context, blockHash *chainhash.Hash) error {

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.

#2018 changes ClientI.SetBlockMinedSet to take minedSetGeneration uint64. Once both are in, this override no longer satisfies the interface and the 2-arg calls at :351 and :504 don't compile. Agree merge order with #2018 and update whichever goes second.

Comment thread model/update-tx-mined.go
// goroutine still in flight here reads &subtree.Nodes[idx].Hash and
// would race that release or read an unmapped region. g.Wait's own
// error is discarded in favour of the specific missing-subtree error.
_ = g.Wait()

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.

#2042 fixes the same return-before-g.Wait class by rejecting a nil entry before any g.Go. Here the earlier subtrees still get SetMinedMulti with this blockID (gCtx is never cancelled) and then the call errors, leaving the block partially tagged. A nil pre-scan before the loop when !unsetMined returns the same error with no goroutines, no wasted writes and no mid-loop wait, and keeps one pattern for one bug.

} else {
// All subtrees should already be available for fully processed blocks
// All subtrees should already be available for fully processed blocks.
// This load is heap-only (getAndValidateSubtrees ->

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.

Not always heap-only: for a cached block whose subtrees are already loaded mmap-backed (the case your tests build), getAndValidateSubtrees takes the already-loaded early return and keeps the mmap subtrees. Reword so nobody later pools nodes or skips Close here on the strength of this comment.

…kchain#2029)

The load in setTxMinedStatus is heap-only for a fresh fetch but keeps whatever a
cached block already holds, possibly mmap-backed, so say that instead of
calling it heap-only. The defer comment still described the success-path release
as replaced; it was restored before SetBlockMinedSet. The revert test's doc
comment no longer claims a retry-loop leak: the leak is one cached mmap set.
@codenlighten

Copy link
Copy Markdown
Contributor Author

Thanks @icellan. Pushed 5662ead (comments only, no code or test change) and replaced the PR body.

  • BlockValidation.go:1575. Reworded. A fresh fetch loads on the heap, but a cached block whose subtrees are already loaded keeps them as they are, possibly mmap-backed, and the comment now says not to pool nodes or skip Close on the strength of the heap case. I also fixed the defer comment near :1531, which still said the single defer replaced the explicit success-path release (it was restored before SetBlockMinedSet).
  • PR body. Rewritten. It no longer names TestSetTxMinedStatus_ValidPathReleasesSubtreesOnError or a per-branch defer, and the retry-leak claim is gone: the leak is the one cached mmap set. The four tests are listed under their real names, and the merge order with fix(blockvalidation): an in-flight tagging run no longer overwrites an invalidation's mined_set reset #2018 (whichever lands second updates the SetBlockMinedSet calls and minedSetProbeClient) and the likely text conflict with fix(blockassembly): stop re-marking the whole mempool on every reorg #2065 are noted.
  • set_tx_mined_revert_test.go:264. The retry-loop sentence is removed from the doc comment.
  • update-tx-mined.go:242, nil pre-scan. I kept the join rather than switching. The pre-scan does avoid the wasted SetMinedMulti writes and the partially tagged block, and a block that is partially tagged on that error is the same as before this PR. But with it there are no goroutines running ahead of the missing subtree, so the join test that @oskarszoon and @freemans13 asked for (hold a worker in SetMinedMulti, prove the call has not returned) would have nothing to hold. If you prefer the pre-scan I will switch and replace that test with one that asserts no SetMinedMulti call is made; say so and I will do it.
  • set_tx_mined_revert_test.go:586 / :666. Left as is: the nil ClientI embed in headerFailingClient is only reached on the cached-block path, which calls GetBlockHeader alone, and the comment says so. fail is always true in the only test that uses the client, so it is dead weight; I can drop the field in a follow-up commit if you want.

This branch has not been deployed

No deployments
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.

setTxMinedStatus leaks mmap-backed subtrees on the valid-block path when UpdateTxMinedStatus fails

4 participants