Skip to content

fix(legacy): prevent shutdown-race panic in QueueInv (send on closed channel)#1009

Merged
freemans13 merged 2 commits into
bsv-blockchain:mainfrom
freemans13:stu/fix-legacy-queueinv-shutdown-panic
Jun 2, 2026
Merged

fix(legacy): prevent shutdown-race panic in QueueInv (send on closed channel)#1009
freemans13 merged 2 commits into
bsv-blockchain:mainfrom
freemans13:stu/fix-legacy-queueinv-shutdown-panic

Conversation

@freemans13

Copy link
Copy Markdown
Collaborator

Problem

Legacy sync crashes the whole process on shutdown with panic: send on closed channel, originating in SyncManager.QueueInv:

panic: send on closed channel
  netsync.(*SyncManager).QueueInv  services/legacy/netsync/manager.go
  legacy.(*serverPeer).OnInv       services/legacy/peer_server.go
  peer.(*Peer).inHandler           services/legacy/peer/peer.go

Inv delivery runs on peer read-loop goroutines (Peer.inHandler → OnInv → QueueInv), but the channels QueueInv sends to are torn down by a different goroutine during shutdown:

  • legacyKafkaInvCh is closed by the kafka async producer's Stop() (util/kafka/kafka_producer_async.go), which fires on context cancellation / SIGTERM;
  • msgChan stops being drained once the block handler exits on quit.

QueueInv guards with an atomic shutdown-flag check, but a flag check and a channel send are not atomic against a concurrent close — so a late inv slips past the check and sends on an already-closed channel. send on closed channel is an unrecoverable panic, so it takes the whole node down. Because peers announce invs continuously while syncing, this fires on nearly every SIGTERM taken mid-sync, turning routine restarts into hard crashes.

Observed downstream impact

The crash happens mid-shutdown, so graceful teardown never completes: in-flight UTXO-store postgres queries are abandoned with their server-side backends still alive holding locks (e.g. on txs_p00). On the next boot, the UTXO store's schema step (ALTER TABLE/CREATE INDEX on txs_p00) blocks on that orphaned lock → the node won't boot until the stuck backend is manually terminated. So a single shutdown-race panic can wedge the node.

Fix

Route QueueInv's sends through sendDuringShutdown, a small generic helper that recovers from the send-on-closed-channel panic and drops the inv. Dropping an inv during shutdown is safe — inv is an advisory announcement, re-sent by the peer (or a later session) on reconnect. The atomic shutdown-flag check is kept as the cheap common-case short-circuit.

Tests

  • TestSendDuringShutdown: helper delivers on an open channel and returns false (no panic) on a closed one.
  • TestQueueInv_NoPanicWhenChannelsClosedDuringShutdown: QueueInv no longer panics when legacyKafkaInvCh / msgChan are closed underneath it (kafka, block, and non-kafka paths).
  • Full services/legacy/netsync suite passes under -race (120 tests); go vet + go build ./... clean.

Note / follow-up

The deeper cause is the kafka producer closing its input channel (a consumer closing a channel that still has senders, kafka_producer_async.go Stop()close(ch)). That lives in shared infra used by many services, so it's left for a separate change; this fix makes the legacy inv path robust regardless.

…channel)

Inv delivery runs on peer read-loop goroutines (Peer.inHandler -> OnInv ->
SyncManager.QueueInv), but the channels QueueInv sends to are torn down by a
different goroutine during shutdown:

- legacyKafkaInvCh is closed by the kafka async producer's Stop()
  (util/kafka/kafka_producer_async.go), which fires on context cancellation /
  SIGTERM; and
- msgChan stops being drained once the block handler exits on quit.

QueueInv guards with an atomic shutdown-flag check, but a flag check and a
channel send are not atomic against a concurrent close, so a late inv could
still hit a closed channel and panic — and "send on closed channel" is an
unrecoverable panic that crashes the whole process. In practice this fired on
nearly every SIGTERM taken while syncing (peers are continuously announcing
invs), turning routine restarts into hard crashes mid-shutdown.

Route QueueInv's sends through sendDuringShutdown, a tiny generic helper that
recovers from the send-on-closed-channel panic and drops the inv. Dropping an
inv during shutdown is safe: inv is an advisory announcement, re-sent by the
peer (or a later session) on reconnect. The shutdown-flag check is kept as the
cheap common-case short-circuit.

Adds tests: the helper returns false (no panic) on a closed channel, and
QueueInv no longer panics when legacyKafkaInvCh / msgChan are closed underneath
it (kafka, block, and non-kafka paths).

Note: the deeper cause is the kafka producer closing its *input* channel (a
consumer closing a channel that still has senders); that lives in shared infra
and is left for a separate change. This fix makes the legacy inv path robust to
it regardless.
@github-actions

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

🤖 Claude Code Review

Status: Complete


Current Review:

This PR fixes a critical shutdown-race panic in QueueInv where concurrent channel sends and closes caused "send on closed channel" panics. The fix is sound and well-tested, but the same vulnerability exists in other Queue* functions.

Issues found:

  1. [Major] Same race condition in QueueBlock, QueueTx, and QueueHeaders - see inline comment
  2. [Minor] Unused return value from sendDuringShutdown - see inline comment

Positive observations:

  • Excellent test coverage with three scenarios covering all code paths
  • Clear documentation explaining the race and why dropping invs is safe
  • Helper function sendDuringShutdown is well-designed with panic recovery
  • PR description thoroughly documents the problem, impact, and fix

Recommendation: Address the other Queue* functions before merge to prevent the same crash through different code paths.

@freemans13 freemans13 self-assigned this Jun 1, 2026
@github-actions

github-actions Bot commented Jun 1, 2026

Copy link
Copy Markdown
Contributor

Benchmark Comparison Report

Baseline: main (unknown)

Current: PR-1009 (d742cb2)

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.635µ 1.713µ ~ 0.400
SplitSyncedParentMap_SetIfNotExists/256_buckets-4 71.19n 71.05n ~ 0.700
SplitSyncedParentMap_SetIfNotExists/16_buckets-4 71.31n 71.07n ~ 1.000
SplitSyncedParentMap_SetIfNotExists/1_bucket-4 71.04n 71.13n ~ 0.500
SplitSyncedParentMap_ConcurrentSetIfNotExists/256_buckets... 32.85n 32.91n ~ 1.000
SplitSyncedParentMap_ConcurrentSetIfNotExists/16_buckets_... 55.38n 55.14n ~ 0.400
SplitSyncedParentMap_ConcurrentSetIfNotExists/1_bucket_pa... 130.3n 130.4n ~ 1.000
MiningCandidate_Stringify_Short-4 218.2n 220.2n ~ 0.400
MiningCandidate_Stringify_Long-4 1.641µ 1.644µ ~ 0.500
MiningSolution_Stringify-4 852.9n 845.1n ~ 0.100
BlockInfo_MarshalJSON-4 1.737µ 1.737µ ~ 0.800
NewFromBytes-4 131.2n 144.7n ~ 0.700
AddTxBatchColumnar_Validation-4 2.467µ 2.465µ ~ 1.000
OffsetValidationLoop-4 636.8n 638.4n ~ 0.300
Mine_EasyDifficulty-4 67.15µ 67.22µ ~ 1.000
Mine_WithAddress-4 7.271µ 7.092µ ~ 0.300
DirectSubtreeAdd/4_per_subtree-4 58.73n 57.76n ~ 1.000
DirectSubtreeAdd/64_per_subtree-4 29.70n 29.87n ~ 0.400
DirectSubtreeAdd/256_per_subtree-4 28.88n 28.89n ~ 1.000
DirectSubtreeAdd/1024_per_subtree-4 27.79n 27.83n ~ 0.700
DirectSubtreeAdd/2048_per_subtree-4 27.39n 27.46n ~ 0.200
SubtreeProcessorAdd/4_per_subtree-4 281.4n 279.1n ~ 1.000
SubtreeProcessorAdd/64_per_subtree-4 280.3n 275.6n ~ 0.700
SubtreeProcessorAdd/256_per_subtree-4 280.3n 277.7n ~ 0.400
SubtreeProcessorAdd/1024_per_subtree-4 269.3n 268.1n ~ 0.100
SubtreeProcessorAdd/2048_per_subtree-4 270.3n 268.1n ~ 0.100
SubtreeProcessorRotate/4_per_subtree-4 274.2n 272.3n ~ 0.100
SubtreeProcessorRotate/64_per_subtree-4 275.7n 272.8n ~ 0.200
SubtreeProcessorRotate/256_per_subtree-4 273.1n 271.0n ~ 0.700
SubtreeProcessorRotate/1024_per_subtree-4 269.9n 274.2n ~ 0.200
SubtreeNodeAddOnly/4_per_subtree-4 53.94n 54.05n ~ 1.000
SubtreeNodeAddOnly/64_per_subtree-4 34.16n 34.20n ~ 0.700
SubtreeNodeAddOnly/256_per_subtree-4 33.14n 33.19n ~ 0.400
SubtreeNodeAddOnly/1024_per_subtree-4 32.49n 32.74n ~ 0.100
SubtreeCreationOnly/4_per_subtree-4 113.5n 112.5n ~ 0.700
SubtreeCreationOnly/64_per_subtree-4 395.5n 392.1n ~ 0.700
SubtreeCreationOnly/256_per_subtree-4 1.330µ 1.337µ ~ 0.300
SubtreeCreationOnly/1024_per_subtree-4 4.388µ 4.437µ ~ 0.300
SubtreeCreationOnly/2048_per_subtree-4 7.955µ 8.179µ ~ 0.100
SubtreeProcessorOverheadBreakdown/64_per_subtree-4 270.5n 271.3n ~ 0.400
SubtreeProcessorOverheadBreakdown/1024_per_subtree-4 270.5n 273.4n ~ 0.700
ParallelGetAndSetIfNotExists/1k_nodes-4 2.175m 2.181m ~ 0.700
ParallelGetAndSetIfNotExists/10k_nodes-4 5.259m 5.241m ~ 0.200
ParallelGetAndSetIfNotExists/50k_nodes-4 7.028m 7.023m ~ 0.700
ParallelGetAndSetIfNotExists/100k_nodes-4 9.550m 9.712m ~ 0.700
SequentialGetAndSetIfNotExists/1k_nodes-4 1.953m 1.934m ~ 0.100
SequentialGetAndSetIfNotExists/10k_nodes-4 4.476m 4.294m ~ 0.100
SequentialGetAndSetIfNotExists/50k_nodes-4 13.22m 12.00m ~ 0.100
SequentialGetAndSetIfNotExists/100k_nodes-4 23.39m 21.73m ~ 0.100
ProcessOwnBlockSubtreeNodesParallel/1k_nodes-4 2.224m 2.226m ~ 0.700
ProcessOwnBlockSubtreeNodesParallel/10k_nodes-4 8.099m 8.090m ~ 1.000
ProcessOwnBlockSubtreeNodesParallel/100k_nodes-4 12.75m 12.72m ~ 0.100
ProcessOwnBlockSubtreeNodesSequential/1k_nodes-4 1.962m 1.959m ~ 1.000
ProcessOwnBlockSubtreeNodesSequential/10k_nodes-4 7.389m 7.397m ~ 0.700
ProcessOwnBlockSubtreeNodesSequential/100k_nodes-4 40.98m 39.01m ~ 0.200
DiskTxMap_SetIfNotExists-4 3.459µ 3.472µ ~ 0.700
DiskTxMap_SetIfNotExists_Parallel-4 3.350µ 3.388µ ~ 0.400
DiskTxMap_ExistenceOnly-4 306.1n 313.8n ~ 0.200
Queue-4 191.4n 191.3n ~ 1.000
AtomicPointer-4 4.722n 4.590n ~ 0.500
ReorgOptimizations/DedupFilterPipeline/Old/10K-4 912.9µ 863.1µ ~ 0.100
ReorgOptimizations/DedupFilterPipeline/New/10K-4 816.0µ 790.3µ ~ 0.100
ReorgOptimizations/AllMarkFalse/Old/10K-4 108.5µ 104.1µ ~ 0.200
ReorgOptimizations/AllMarkFalse/New/10K-4 62.97µ 62.59µ ~ 0.400
ReorgOptimizations/HashSlicePool/Old/10K-4 56.14µ 61.99µ ~ 0.400
ReorgOptimizations/HashSlicePool/New/10K-4 12.65µ 11.97µ ~ 0.100
ReorgOptimizations/NodeFlags/Old/10K-4 4.945µ 4.646µ ~ 0.100
ReorgOptimizations/NodeFlags/New/10K-4 1.670µ 1.584µ ~ 0.100
ReorgOptimizations/DedupFilterPipeline/Old/100K-4 9.484m 9.345m ~ 0.200
ReorgOptimizations/DedupFilterPipeline/New/100K-4 10.514m 9.980m ~ 0.100
ReorgOptimizations/AllMarkFalse/Old/100K-4 1.200m 1.080m ~ 0.100
ReorgOptimizations/AllMarkFalse/New/100K-4 689.0µ 684.7µ ~ 0.200
ReorgOptimizations/HashSlicePool/Old/100K-4 708.0µ 597.8µ ~ 0.100
ReorgOptimizations/HashSlicePool/New/100K-4 321.4µ 315.2µ ~ 0.700
ReorgOptimizations/NodeFlags/Old/100K-4 49.62µ 53.03µ ~ 0.200
ReorgOptimizations/NodeFlags/New/100K-4 17.90µ 18.46µ ~ 0.100
TxMapSetIfNotExists-4 52.56n 52.23n ~ 0.100
TxMapSetIfNotExistsDuplicate-4 41.01n 40.24n ~ 0.300
ChannelSendReceive-4 640.0n 594.3n ~ 0.100
BlockAssembler_AddTx-4 0.02741n 0.02669n ~ 0.700
AddNode-4 11.65 10.89 ~ 0.200
AddNodeWithMap-4 11.42 11.42 ~ 0.700
CalcBlockWork-4 476.9n 488.8n ~ 0.700
CalculateWork-4 645.0n 660.6n ~ 0.100
BuildBlockLocatorString_Helpers/Size_10-4 1.410µ 1.399µ ~ 1.000
BuildBlockLocatorString_Helpers/Size_100-4 14.91µ 14.62µ ~ 1.000
BuildBlockLocatorString_Helpers/Size_1000-4 126.8µ 126.7µ ~ 0.700
CatchupWithHeaderCache-4 104.8m 104.4m ~ 0.200
_BufferPoolAllocation/16KB-4 4.351µ 4.105µ ~ 0.400
_BufferPoolAllocation/32KB-4 9.008µ 10.020µ ~ 0.700
_BufferPoolAllocation/64KB-4 22.06µ 17.79µ ~ 0.200
_BufferPoolAllocation/128KB-4 37.31µ 36.32µ ~ 0.400
_BufferPoolAllocation/512KB-4 132.0µ 139.4µ ~ 0.100
_BufferPoolConcurrent/32KB-4 24.89µ 20.75µ ~ 0.200
_BufferPoolConcurrent/64KB-4 31.25µ 31.57µ ~ 1.000
_BufferPoolConcurrent/512KB-4 158.0µ 157.0µ ~ 0.700
_SubtreeDeserializationWithBufferSizes/16KB-4 640.9µ 655.9µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/32KB-4 638.4µ 660.8µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/64KB-4 634.8µ 646.0µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/128KB-4 631.5µ 660.5µ ~ 0.100
_SubtreeDeserializationWithBufferSizes/512KB-4 624.7µ 662.2µ ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/16KB-4 36.81m 37.46m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/32KB-4 36.79m 37.32m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/64KB-4 36.55m 37.47m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/128KB-4 36.82m 37.21m ~ 0.100
_SubtreeDataDeserializationWithBufferSizes/512KB-4 36.44m 37.36m ~ 0.100
_PooledVsNonPooled/Pooled-4 650.8n 747.4n ~ 0.100
_PooledVsNonPooled/NonPooled-4 8.701µ 9.244µ ~ 0.100
_MemoryFootprint/Current_512KB_32concurrent-4 6.656µ 7.312µ ~ 0.100
_MemoryFootprint/Proposed_32KB_32concurrent-4 9.631µ 10.985µ ~ 0.100
_MemoryFootprint/Alternative_64KB_32concurrent-4 9.166µ 11.019µ ~ 0.100
SubtreeSizes/10k_tx_4_per_subtree-4 1.364m 1.369m ~ 1.000
SubtreeSizes/10k_tx_16_per_subtree-4 324.0µ 324.8µ ~ 1.000
SubtreeSizes/10k_tx_64_per_subtree-4 78.03µ 77.89µ ~ 1.000
SubtreeSizes/10k_tx_256_per_subtree-4 19.33µ 19.23µ ~ 0.400
SubtreeSizes/10k_tx_512_per_subtree-4 9.603µ 9.569µ ~ 0.400
SubtreeSizes/10k_tx_1024_per_subtree-4 4.841µ 4.743µ ~ 0.100
SubtreeSizes/10k_tx_2k_per_subtree-4 2.375µ 2.386µ ~ 0.100
BlockSizeScaling/10k_tx_64_per_subtree-4 75.69µ 75.73µ ~ 1.000
BlockSizeScaling/10k_tx_256_per_subtree-4 19.49µ 19.02µ ~ 0.100
BlockSizeScaling/10k_tx_1024_per_subtree-4 4.872µ 4.736µ ~ 0.700
BlockSizeScaling/50k_tx_64_per_subtree-4 401.8µ 397.4µ ~ 0.400
BlockSizeScaling/50k_tx_256_per_subtree-4 96.05µ 96.79µ ~ 0.700
BlockSizeScaling/50k_tx_1024_per_subtree-4 23.65µ 23.16µ ~ 0.100
SubtreeAllocations/small_subtrees_exists_check-4 160.3µ 160.5µ ~ 0.700
SubtreeAllocations/small_subtrees_data_fetch-4 168.5µ 167.2µ ~ 0.400
SubtreeAllocations/small_subtrees_full_validation-4 331.4µ 329.9µ ~ 1.000
SubtreeAllocations/medium_subtrees_exists_check-4 9.577µ 9.431µ ~ 0.400
SubtreeAllocations/medium_subtrees_data_fetch-4 9.923µ 9.811µ ~ 0.400
SubtreeAllocations/medium_subtrees_full_validation-4 19.35µ 19.27µ ~ 1.000
SubtreeAllocations/large_subtrees_exists_check-4 2.271µ 2.265µ ~ 0.700
SubtreeAllocations/large_subtrees_data_fetch-4 2.442µ 2.389µ ~ 0.100
SubtreeAllocations/large_subtrees_full_validation-4 4.819µ 4.776µ ~ 0.100
_prepareTxsPerLevel-4 308.8m 315.9m ~ 0.400
_prepareTxsPerLevelOrdered-4 2.993m 3.068m ~ 0.400
_prepareTxsPerLevel_Comparison/Original-4 310.0m 310.1m ~ 1.000
_prepareTxsPerLevel_Comparison/Optimized-4 3.172m 3.013m ~ 0.100
StoreBlock_Sequential/BelowCSVHeight-4 341.8µ 335.6µ ~ 0.200
StoreBlock_Sequential/AboveCSVHeight-4 337.4µ 341.8µ ~ 0.700
GetUtxoHashes-4 262.8n 266.7n ~ 1.000
GetUtxoHashes_ManyOutputs-4 44.20µ 44.21µ ~ 0.700
_NewMetaDataFromBytes-4 230.6n 231.0n ~ 0.400
_Bytes-4 397.5n 410.0n ~ 0.400
_MetaBytes-4 139.9n 139.6n ~ 0.600

Threshold: >10% with p < 0.05 | Generated: 2026-06-02 09:05 UTC

@@ -2097,6 +2097,28 @@ func (sm *SyncManager) QueueBlock(block *bsvutil.Block, peer *peerpkg.Peer, done
sm.msgChan <- &blockMsg{block: block, peer: peer, reply: done}

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.

[Major] The same shutdown race exists in QueueBlock (line 2097), QueueTx (line 2084), and QueueHeaders (line 2186).

All three functions follow the identical pattern:

  1. Check atomic.LoadInt32(&sm.shutdown)
  2. Perform channel send to sm.msgChan

Like QueueInv, these are called from peer read-loop goroutines while msgChan is closed/drained on shutdown by a different goroutine. The atomic check and channel send are not atomic against concurrent close.

Current code at manager.go:2097:

sm.msgChan <- &blockMsg{block: block, peer: peer, reply: done}

Should be:

sendDuringShutdown[interface{}](sm.msgChan, &blockMsg{block: block, peer: peer, reply: done})

Same fix needed for QueueTx:2084 and QueueHeaders:2186.

// write to Kafka
sm.logger.Debugf("writing INV message to Kafka from peer %s, length: %d", peer.String(), len(value))
sm.legacyKafkaInvCh <- &kafka.Message{
sendDuringShutdown(sm.legacyKafkaInvCh, &kafka.Message{

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.

[Minor] The return value of sendDuringShutdown is not used. While dropping invs during shutdown is safe (as documented), the function returns bool to indicate whether the send succeeded, which might be useful for logging or metrics.

Consider either:

  1. Using the return value for observability:
if !sendDuringShutdown(sm.legacyKafkaInvCh, &kafka.Message{Value: value}) {
    sm.logger.Debugf("dropped inv to closed kafka channel during shutdown")
}
  1. Or changing the signature to not return a value if it will never be used.

@sonarqubecloud

sonarqubecloud Bot commented Jun 2, 2026

Copy link
Copy Markdown

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

Prevents a legacy sync shutdown race from crashing the process with panic: send on closed channel when SyncManager.QueueInv sends to channels that may be closed concurrently during teardown (notably the Kafka async producer input channel).

Changes:

  • Added a small sendDuringShutdown helper that recovers from send on closed channel and reports whether the send succeeded.
  • Routed QueueInv’s sends (to msgChan and legacyKafkaInvCh) through the helper to avoid process-killing panics.
  • Added tests covering helper behavior and ensuring QueueInv does not panic when target channels are closed.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
services/legacy/netsync/manager.go Introduces sendDuringShutdown and uses it in QueueInv to prevent shutdown-time panics when channels are closed concurrently.
services/legacy/netsync/manager_test.go Adds unit tests to validate the helper and regression tests to ensure QueueInv doesn’t panic when channels are closed.

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

Comment on lines +2110 to +2114
func sendDuringShutdown[T any](ch chan T, v T) (sent bool) {
defer func() {
if recover() != nil {
sent = false
}

@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. Solid, well-reasoned fix for a real process-wide crash, with an excellent write-up of root cause and downstream impact.

Verified the core claim: KafkaAsyncProducer.Stop() does close(ch) on the same channel passed to Start() (i.e. legacyKafkaInvCh), so that path is a genuine send-on-closed panic source and sendDuringShutdown correctly neutralizes it. The recover() is appropriately narrow in practice — a channel send can only panic for send-on-closed, so it won't mask unrelated bugs.

One non-blocking observation: msgChan is never close()d in production (only in the tests) — the block handler stops draining it but doesn't close it. So on the msgChan paths the send blocks once the buffer fills rather than panicking, and recover() never fires there. The wrapping is harmless belt-and-suspenders, but the doc comment slightly overstates "the block handler stops draining msgChan" as a panic source on par with the kafka close — the only real panic vector is legacyKafkaInvCh. Worth tightening the comment (and noting the msgChan-closed tests exercise helper behaviour rather than a faithful prod scenario). Not a blocker.

@freemans13 freemans13 merged commit 01f1a1b into bsv-blockchain:main Jun 2, 2026
34 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