btcwbackend: reorg-aware chain notifier forwarding (C0b) - #559
Conversation
There was a problem hiding this comment.
Code Review
This pull request refactors ChainBackend to use the chainntnfs.ChainNotifier interface instead of a concrete type, and updates RegisterConf and RegisterSpend to forward reorg and done lifecycle events to backend-agnostic channels. It also introduces comprehensive unit tests to verify this new forwarding behavior. The feedback highlights a potential data race and code smell in both registration methods where fields of the external event struct are mutated directly to disable select cases; using local channel variables is recommended instead.
| for { | ||
| select { | ||
| case lndConf, ok := <-event.Confirmed: | ||
| if !ok { | ||
| return | ||
| } | ||
|
|
||
| conf := &chainsource.TxConfirmation{ | ||
| BlockHash: lndConf.BlockHash, | ||
| BlockHeight: lndConf.BlockHeight, | ||
| TxIndex: lndConf.TxIndex, | ||
| Tx: lndConf.Tx, | ||
| Block: lndConf.Block, | ||
| } | ||
| conf := &chainsource.TxConfirmation{ | ||
| BlockHash: lndConf.BlockHash, | ||
| BlockHeight: lndConf.BlockHeight, | ||
| TxIndex: lndConf.TxIndex, | ||
| Tx: lndConf.Tx, | ||
| Block: lndConf.Block, | ||
| } | ||
|
|
||
| select { | ||
| case confChan <- conf: | ||
| case <-notifyCtx.Done(): | ||
| return | ||
| } | ||
|
|
||
| case _, ok := <-event.NegativeConf: | ||
| if !ok { | ||
| event.NegativeConf = nil | ||
|
|
||
| continue | ||
| } | ||
|
|
||
| select { | ||
| case reorgChan <- struct{}{}: | ||
| case <-notifyCtx.Done(): | ||
| return | ||
| } | ||
|
|
||
| case _, ok := <-event.Done: | ||
| if !ok { | ||
| event.Done = nil | ||
|
|
||
| continue | ||
| } | ||
|
|
||
| select { | ||
| case doneChan <- struct{}{}: | ||
| case <-notifyCtx.Done(): | ||
| return | ||
| } | ||
|
|
||
| return | ||
|
|
||
| select { | ||
| case confChan <- conf: | ||
| case <-notifyCtx.Done(): | ||
| return | ||
| } | ||
|
|
||
| case <-notifyCtx.Done(): | ||
| return | ||
| } |
There was a problem hiding this comment.
Mutating the fields of the event struct (e.g., event.NegativeConf = nil and event.Done = nil) directly is a code smell and a potential data race. The event struct is returned by an external package (chainntnfs), and mutating its fields concurrently from a background goroutine can race with other goroutines reading those fields (such as the notifier itself, or test assertions/mocks). Instead, use local variables for the channels within the select loop to safely nil them out and disable the select cases.
confirmedChan := event.Confirmed
negativeConfChan := event.NegativeConf
doneEventChan := event.Done
for {
select {
case lndConf, ok := <-confirmedChan:
if !ok {
return
}
conf := &chainsource.TxConfirmation{
BlockHash: lndConf.BlockHash,
BlockHeight: lndConf.BlockHeight,
TxIndex: lndConf.TxIndex,
Tx: lndConf.Tx,
Block: lndConf.Block,
}
select {
case confChan <- conf:
case <-notifyCtx.Done():
return
}
case _, ok := <-negativeConfChan:
if !ok {
negativeConfChan = nil
continue
}
select {
case reorgChan <- struct{}{}:
case <-notifyCtx.Done():
return
}
case _, ok := <-doneEventChan:
if !ok {
doneEventChan = nil
continue
}
select {
case doneChan <- struct{}{}:
case <-notifyCtx.Done():
return
}
return
case <-notifyCtx.Done():
return
}
}| for { | ||
| select { | ||
| case lndSpend, ok := <-event.Spend: | ||
| if !ok { | ||
| return | ||
| } | ||
|
|
||
| spend := &chainsource.SpendDetail{ | ||
| SpentOutPoint: lndSpend.SpentOutPoint, | ||
| SpenderTxHash: lndSpend.SpenderTxHash, | ||
| SpendingTx: lndSpend.SpendingTx, | ||
| SpenderInputIndex: lndSpend.SpenderInputIndex, | ||
| SpendingHeight: lndSpend.SpendingHeight, | ||
| } | ||
| spend := &chainsource.SpendDetail{ | ||
| SpentOutPoint: lndSpend.SpentOutPoint, | ||
| SpenderTxHash: lndSpend.SpenderTxHash, | ||
| SpendingTx: lndSpend.SpendingTx, | ||
| SpenderInputIndex: lndSpend.SpenderInputIndex, | ||
| SpendingHeight: lndSpend.SpendingHeight, | ||
| } | ||
|
|
||
| select { | ||
| case spendChan <- spend: | ||
| case <-notifyCtx.Done(): | ||
| return | ||
| } | ||
|
|
||
| case _, ok := <-event.Reorg: | ||
| if !ok { | ||
| event.Reorg = nil | ||
|
|
||
| continue | ||
| } | ||
|
|
||
| select { | ||
| case reorgChan <- struct{}{}: | ||
| case <-notifyCtx.Done(): | ||
| return | ||
| } | ||
|
|
||
| case _, ok := <-event.Done: | ||
| if !ok { | ||
| event.Done = nil | ||
|
|
||
| continue | ||
| } | ||
|
|
||
| select { | ||
| case doneChan <- struct{}{}: | ||
| case <-notifyCtx.Done(): | ||
| return | ||
| } | ||
|
|
||
| return | ||
|
|
||
| select { | ||
| case spendChan <- spend: | ||
| case <-notifyCtx.Done(): | ||
| return | ||
| } | ||
|
|
||
| case <-notifyCtx.Done(): | ||
| return | ||
| } |
There was a problem hiding this comment.
Mutating the fields of the event struct (e.g., event.Reorg = nil and event.Done = nil) directly is a code smell and a potential data race. The event struct is returned by an external package (chainntnfs), and mutating its fields concurrently from a background goroutine can race with other goroutines reading those fields (such as the notifier itself, or test assertions/mocks). Instead, use local variables for the channels within the select loop to safely nil them out and disable the select cases.
spendEventChan := event.Spend
reorgEventChan := event.Reorg
doneEventChan := event.Done
for {
select {
case lndSpend, ok := <-spendEventChan:
if !ok {
return
}
spend := &chainsource.SpendDetail{
SpentOutPoint: lndSpend.SpentOutPoint,
SpenderTxHash: lndSpend.SpenderTxHash,
SpendingTx: lndSpend.SpendingTx,
SpenderInputIndex: lndSpend.SpenderInputIndex,
SpendingHeight: lndSpend.SpendingHeight,
}
select {
case spendChan <- spend:
case <-notifyCtx.Done():
return
}
case _, ok := <-reorgEventChan:
if !ok {
reorgEventChan = nil
continue
}
select {
case reorgChan <- struct{}{}:
case <-notifyCtx.Done():
return
}
case _, ok := <-doneEventChan:
if !ok {
doneEventChan = nil
continue
}
select {
case doneChan <- struct{}{}:
case <-notifyCtx.Done():
return
}
return
case <-notifyCtx.Done():
return
}
}There was a problem hiding this comment.
Code Review
This pull request updates the ChainBackend to support reorg-aware lifecycles for transaction confirmations and spends by abstracting the notifier to the chainntnfs.ChainNotifier interface and forwarding reorg and done signals. It also adds comprehensive unit tests in chain_backend_reorg_test.go. The feedback identifies potential data races in both RegisterConf and RegisterSpend where fields of the shared event struct are modified concurrently inside goroutines; copying these channels to local variables before the loop is recommended to safely disable the select cases.
| go func() { | ||
| // Defers run in LIFO order. event.Cancel() must run first | ||
| // so the upstream notifier stops writing to its internal | ||
| // channels before we cancel notifyCtx (which any in-flight | ||
| // downstream sends are still using) and finally close the | ||
| // outgoing chans. Reversing this order would race the | ||
| // upstream notifier against closed channels. | ||
| defer close(confChan) | ||
| defer close(reorgChan) | ||
| defer close(doneChan) | ||
| defer cancel() | ||
| defer safeCancel() | ||
|
|
||
| select { | ||
| case lndConf, ok := <-event.Confirmed: | ||
| if !ok { | ||
| return | ||
| } | ||
| for { | ||
| select { | ||
| case lndConf, ok := <-event.Confirmed: | ||
| if !ok { | ||
| return | ||
| } | ||
|
|
||
| conf := &chainsource.TxConfirmation{ | ||
| BlockHash: lndConf.BlockHash, | ||
| BlockHeight: lndConf.BlockHeight, | ||
| TxIndex: lndConf.TxIndex, | ||
| Tx: lndConf.Tx, | ||
| Block: lndConf.Block, | ||
| } | ||
| conf := &chainsource.TxConfirmation{ | ||
| BlockHash: lndConf.BlockHash, | ||
| BlockHeight: lndConf.BlockHeight, | ||
| TxIndex: lndConf.TxIndex, | ||
| Tx: lndConf.Tx, | ||
| Block: lndConf.Block, | ||
| } | ||
|
|
||
| select { | ||
| case confChan <- conf: | ||
| case <-notifyCtx.Done(): | ||
| return | ||
| } | ||
|
|
||
| case _, ok := <-event.NegativeConf: | ||
| if !ok { | ||
| event.NegativeConf = nil | ||
|
|
||
| continue | ||
| } | ||
|
|
||
| select { | ||
| case reorgChan <- struct{}{}: | ||
| case <-notifyCtx.Done(): | ||
| return | ||
| } | ||
|
|
||
| case _, ok := <-event.Done: | ||
| if !ok { | ||
| event.Done = nil | ||
|
|
||
| continue | ||
| } | ||
|
|
||
| select { | ||
| case doneChan <- struct{}{}: | ||
| case <-notifyCtx.Done(): | ||
| return | ||
| } | ||
|
|
||
| return | ||
|
|
||
| select { | ||
| case confChan <- conf: | ||
| case <-notifyCtx.Done(): | ||
| return | ||
| } | ||
|
|
||
| case <-notifyCtx.Done(): | ||
| return | ||
| } | ||
| }() |
There was a problem hiding this comment.
Modifying the fields of the shared event struct (event.NegativeConf = nil and event.Done = nil) inside the concurrent forwarding goroutine can lead to data races if the struct is accessed or inspected elsewhere.
Instead, copy the channels to local variables before the loop and nil those out to safely disable the select cases.
go func() {
// Defers run in LIFO order. event.Cancel() must run first
// so the upstream notifier stops writing to its internal
// channels before we cancel notifyCtx (which any in-flight
// downstream sends are still using) and finally close the
// outgoing chans. Reversing this order would race the
// upstream notifier against closed channels.
defer close(confChan)
defer close(reorgChan)
defer close(doneChan)
defer cancel()
defer safeCancel()
upstreamConfirmed := event.Confirmed
upstreamNegConf := event.NegativeConf
upstreamDone := event.Done
for {
select {
case lndConf, ok := <-upstreamConfirmed:
if !ok {
return
}
conf := &chainsource.TxConfirmation{
BlockHash: lndConf.BlockHash,
BlockHeight: lndConf.BlockHeight,
TxIndex: lndConf.TxIndex,
Tx: lndConf.Tx,
Block: lndConf.Block,
}
select {
case confChan <- conf:
case <-notifyCtx.Done():
return
}
case _, ok := <-upstreamNegConf:
if !ok {
upstreamNegConf = nil
continue
}
select {
case reorgChan <- struct{}{}:
case <-notifyCtx.Done():
return
}
case _, ok := <-upstreamDone:
if !ok {
upstreamDone = nil
continue
}
select {
case doneChan <- struct{}{}:
case <-notifyCtx.Done():
return
}
return
case <-notifyCtx.Done():
return
}
}
}()| go func() { | ||
| // LIFO defer: event.Cancel() first so the upstream notifier | ||
| // stops writing, then cancel notifyCtx so in-flight downstream | ||
| // sends unblock, and finally close the outgoing chans. | ||
| defer close(spendChan) | ||
| defer close(reorgChan) | ||
| defer close(doneChan) | ||
| defer cancel() | ||
| defer safeCancel() | ||
|
|
||
| select { | ||
| case lndSpend, ok := <-event.Spend: | ||
| if !ok { | ||
| return | ||
| } | ||
| for { | ||
| select { | ||
| case lndSpend, ok := <-event.Spend: | ||
| if !ok { | ||
| return | ||
| } | ||
|
|
||
| spend := &chainsource.SpendDetail{ | ||
| SpentOutPoint: lndSpend.SpentOutPoint, | ||
| SpenderTxHash: lndSpend.SpenderTxHash, | ||
| SpendingTx: lndSpend.SpendingTx, | ||
| SpenderInputIndex: lndSpend.SpenderInputIndex, | ||
| SpendingHeight: lndSpend.SpendingHeight, | ||
| } | ||
| spend := &chainsource.SpendDetail{ | ||
| SpentOutPoint: lndSpend.SpentOutPoint, | ||
| SpenderTxHash: lndSpend.SpenderTxHash, | ||
| SpendingTx: lndSpend.SpendingTx, | ||
| SpenderInputIndex: lndSpend.SpenderInputIndex, | ||
| SpendingHeight: lndSpend.SpendingHeight, | ||
| } | ||
|
|
||
| select { | ||
| case spendChan <- spend: | ||
| case <-notifyCtx.Done(): | ||
| return | ||
| } | ||
|
|
||
| case _, ok := <-event.Reorg: | ||
| if !ok { | ||
| event.Reorg = nil | ||
|
|
||
| continue | ||
| } | ||
|
|
||
| select { | ||
| case reorgChan <- struct{}{}: | ||
| case <-notifyCtx.Done(): | ||
| return | ||
| } | ||
|
|
||
| case _, ok := <-event.Done: | ||
| if !ok { | ||
| event.Done = nil | ||
|
|
||
| continue | ||
| } | ||
|
|
||
| select { | ||
| case doneChan <- struct{}{}: | ||
| case <-notifyCtx.Done(): | ||
| return | ||
| } | ||
|
|
||
| return | ||
|
|
||
| select { | ||
| case spendChan <- spend: | ||
| case <-notifyCtx.Done(): | ||
| return | ||
| } | ||
|
|
||
| case <-notifyCtx.Done(): | ||
| return | ||
| } | ||
| }() |
There was a problem hiding this comment.
Modifying the fields of the shared event struct (event.Reorg = nil and event.Done = nil) inside the concurrent forwarding goroutine can lead to data races if the struct is accessed or inspected elsewhere.
Instead, copy the channels to local variables before the loop and nil those out to safely disable the select cases.
go func() {
// LIFO defer: event.Cancel() first so the upstream notifier
// stops writing, then cancel notifyCtx so in-flight downstream
// sends unblock, and finally close the outgoing chans.
defer close(spendChan)
defer close(reorgChan)
defer close(doneChan)
defer cancel()
defer safeCancel()
upstreamSpend := event.Spend
upstreamReorg := event.Reorg
upstreamDone := event.Done
for {
select {
case lndSpend, ok := <-upstreamSpend:
if !ok {
return
}
spend := &chainsource.SpendDetail{
SpentOutPoint: lndSpend.SpentOutPoint,
SpenderTxHash: lndSpend.SpenderTxHash,
SpendingTx: lndSpend.SpendingTx,
SpenderInputIndex: lndSpend.SpenderInputIndex,
SpendingHeight: lndSpend.SpendingHeight,
}
select {
case spendChan <- spend:
case <-notifyCtx.Done():
return
}
case _, ok := <-upstreamReorg:
if !ok {
upstreamReorg = nil
continue
}
select {
case reorgChan <- struct{}{}:
case <-notifyCtx.Done():
return
}
case _, ok := <-upstreamDone:
if !ok {
upstreamDone = nil
continue
}
select {
case doneChan <- struct{}{}:
case <-notifyCtx.Done():
return
}
return
case <-notifyCtx.Done():
return
}
}
}()ca29548 to
a9ee5e8
Compare
c88a093 to
71bb642
Compare
c86210e to
1ab3019
Compare
71bb642 to
eda2540
Compare
3eb6f61 to
449b6cf
Compare
eda2540 to
791c6ae
Compare
32871de to
4a1f9b1
Compare
Squashed for the btcd v2 port; forwards Neutrino chain-notifier reorg signals into the reorg-aware chainsource lifecycle.
791c6ae to
5309ed8
Compare
Task 1b for the reorg-safety epic (darepo#454) — Neutrino-backed sibling of
#461 (lwwallet/Esplora).
neutrino's chainntnfs adapter already drives the full
Confirmed / NegativeConf / Done(andSpend / Reorg / Done) lifecyclenatively from its compact-block-filter scanner, but btcwbackend's
RegisterConfandRegisterSpendpreviously only read the firstConfirmed/Spendevent and let the rest fall on the floor. The result:any downstream consumer subscribed to a
chainsource.ConfRegistration.Reorgedor
.Donechannel silently saw nothing on the neutrino backend, defeatingthe whole reorg-safety substrate (#422).
This PR mirrors what
chainbackends/lnd.goalready does for the LNDbackend:
Confirmed/Spend,NegativeConf/Reorg, andDoneto the matching chainsource channel.nil'd out so the select doesn't busy-loop afterone upstream sub-channel tears down.
event.Cancel→notifyCtxcancel → closedownstream chans, so the upstream notifier stops writing before the
outgoing channels close.
*neutrinonotify.NeutrinoNotifierto thechainntnfs.ChainNotifierinterface so a stub notifier can drive thelifecycle in tests without standing up a real neutrino chain service.
The reorg depth carried on
NegativeConfis intentionally dropped — samechoice the LND backend makes — so both backends present the same wire
shape to chainsource consumers.
Stack
#422 (chainsource substrate) → this PR. Sibling of #461 (lwwallet).
Test plan
go vet ./...make unit pkg=btcwbackend(
TestRegisterConfForwardsReorgAndDone,TestRegisterSpendForwardsReorgAndDone,plus pre-existing
TestSubmitPackage*)