Repository navigation
[blocksprovider] Fix BFT censorship monitor stop race and flaky deliverer test - #228
liran-funaro wants to merge 6 commits into
Conversation
a174d93 to
30edf3d
Compare
30edf3d to
b322817
Compare
…erer test #### Type of change - Bug fix - Test update #### Description - `BFTCensorshipMonitor.launchHeaderReceivers()` does nothing once the monitor is stopped, so `Monitor()` can no longer start header receivers that are never stopped. - The BFT deliverer test fake now ends each deliver stream only when its own connection context is cancelled, like gRPC. Previously, all streams shared one fake whose `CloseSend` pushed an error onto the shared response channel, so a late-closing old stream could fail the stream that replaced it. The `CloseSendStub = nil` workarounds in `TestBFTDeliverer_BlockReception` are no longer needed and are removed. #### Additional details (Optional) Ran both tests 150 times each with `-race -cpu 1,2,4` and no failures. Before the fix, the same run reproduced the hyperledger#155 panic; hyperledger#128 did not reproduce locally, so its fix rests on the CI log. #### Related issues - resolves hyperledger#128 - resolves hyperledger#155 Signed-off-by: Liran Funaro <liran.funaro@gmail.com>
b322817 to
5ebfdbd
Compare
tock-ibm
left a comment
There was a problem hiding this comment.
Automated review (high effort). Inline findings below, plus three that sit on lines outside the diff:
-
common/deliverclient/blocksprovider/bft_censorship_monitor.go:365: header receivers leak their stream context and gRPC connection.launchHeaderReceiversthrows away theclientCloserreturned byConnect(// TODO use the clientCloser function), andBFTHeaderReceiver.Stop()only callsCloseSend(). With real gRPC,CloseSenddoes not unblockRecv(this PR's new fake now models that correctly). So afterBFTCensorshipMonitor.Stop(), which runs on every block-source switch, eachDeliverHeadersgoroutine stays blocked inRecv, and itsctxandClientConnare never released. This repeats for every monitor generation. Suggest keeping the closer in the tracker or receiver and calling it fromStop(). -
common/deliverclient/blocksprovider/bft_censorship_monitor.go:172: afterStop(), theselectinMonitor()picks at random between the closedstopChand a timer that may also have fired. When the timer wins,detectBlockCensorship()runs after the stop and can emitErrCensorshipinstead ofErrStopping, and the loop goes around again intolaunchHeaderReceivers. The new guard depends on this loop eventually choosingstopCh. A non-blocking check ofstopChat the top of the loop (or beforedetectBlockCensorship) would make shutdown deterministic. -
common/deliverclient/blocksprovider/deliverer_test.go:113(altitude): the CFT deliverer suite still has the same pattern this PR removes from the BFT suite: one sharedfakeDeliverClientwhoseCloseSendStubpushes onto a sharedrecvStep(and again at about line 332). An old stream's teardown can still wake the stream that replaced it. MovingperStreamDeliverStreamerandperStreamDeliverClientinto a shared test helper and using it in both suites would fix the root cause once.
| return r, nil | ||
| case <-c.ctx.Done(): | ||
| return nil, c.ctx.Err() | ||
| case <-c.s.deliverClientDoneC: |
There was a problem hiding this comment.
Stop race in BFTDeliverer is still hidden by deliverClientDoneC. Every stream now unblocks when its own ctx is cancelled, so this deliverClientDoneC case is only needed when a BlockReceiver is never stopped. That can happen in BFTDeliverer.FetchBlocks. DoneC is checked only at the top of the loop, so if Stop() runs after Connect (bft_deliverer.go:348) but before d.blockReceiver = blockRcv (line 368), Stop() stops the previous receiver. ProcessIncoming on the new one then waits on stopC, recvC and Refreshed indefinitely. With real gRPC, that goroutine and its connection stay open until the orderer sends something. In this test, s.stop() closing deliverClientDoneC is what makes Recv return (nil, nil) and lets it out. Suggest re-checking stopFlag/DoneC under d.mutex when assigning d.blockReceiver and calling cancel() if already stopped.
There was a problem hiding this comment.
Fixed in bb3c7f1. FetchBlocks now re-checks stopFlag under d.mutex before assigning d.blockReceiver; if Stop() already ran, it calls cancel() and reports ErrStopping. With that, the deliverClientDoneC case is no longer needed, so I removed it from the fake and from stop(). Every stream now ends only through its own context.
| // Stop() may have been called since the last iteration; receivers launched now would never be stopped. | ||
| if m.stopFlag { | ||
| return nil | ||
| } |
There was a problem hiding this comment.
Shutdown still depends on a random select. Returning nil here sends Monitor() back into its select, where a closed stopCh and an already-fired time.After are both ready. Go picks one at random, so after Stop() the loop can still call detectBlockCensorship() and send ErrCensorship on errorCh (buffered, size 1) instead of ErrStopping. It then returns without ever reporting the stop. This guard prevents the leaked receivers from #155, but stopping is still not deterministic. Checking stopCh non-blockingly in the Monitor() loop, before launching and before detecting, would cover both.
There was a problem hiding this comment.
Fixed in bb3c7f1. Monitor() now waits on the select, then checks stopCh non-blockingly before calling detectBlockCensorship(), so a stop always reports ErrStopping. The guard in launchHeaderReceivers() stays for the pass that is already running when Stop() is called.
There was a problem hiding this comment.
Correction to my previous reply: the random-select premise doesn't hold. time.After(...) creates a new timer on every pass, so it is never ready at the moment select starts. If stopCh is already closed, that case always wins. (I checked: against a 10ms timer, the closed channel won 100,000/100,000 times.) The only real window is the timer firing first, then Stop() running before detectBlockCensorship(). That is an ordering race, and a second select only narrows it. In c9d1ed4 I restored the original select and made detectBlockCensorship() return false when stopFlag is set. It already holds m.mutex, so the check is race-free, and the next pass then reports ErrStopping.
| } | ||
|
|
||
| // Stop() may have been called since the last iteration; receivers launched now would never be stopped. | ||
| if m.stopFlag { |
There was a problem hiding this comment.
Simplification and ordering. This guard comes after the no endpoints check, so a stopped monitor whose endpoints are empty still returns an error, and Monitor() then emits ErrFatal after Stop(). In practice fetchSources never changes, so this is minor. A cleaner fix is to put the stop check first, or to move it into the Monitor() loop as a select { case <-m.stopCh: ...; default: } before calling launchHeaderReceivers. That keeps the stop handling in one place and avoids a nil return that callers can't tell apart from a successful launch.
There was a problem hiding this comment.
Kept the order on purpose: I tried putting the stop check first, and it breaks TestBFTCensorshipMonitor_NoSources, which expects no endpoints even though the test calls Stop() right away. The nil return now has a comment saying Monitor() reports the stop itself, and the new stopCh check in Monitor() (previous thread) is what reports it.
There was a problem hiding this comment.
Done in 1e9f8ad, following this suggestion: Monitor() now owns the stop checks, using a new isStopped() backed by an atomic stopFlag. It checks before launchHeaderReceivers() (reporting ErrStopping) and before detectBlockCensorship(). I kept one check inside launchHeaderReceivers(), under the mutex: a check in Monitor() alone leaves a window where Stop() runs between the check and the launch, which would bring back the #155 leak. The check in detectBlockCensorship() is removed. TestBFTCensorshipMonitor_NoSources no longer calls Stop(), because with the top-of-loop check that call raced with the no endpoints failure the test asserts.
| return errors.New("no endpoints") | ||
| } | ||
|
|
||
| // Stop() may have been called since the last iteration; receivers launched now would never be stopped. |
There was a problem hiding this comment.
No test covers this regression. The only coverage for the fix is the existing TestBFTCensorshipMonitor_FaultySourceRecovery, which caught #155 only by chance. Please add a deterministic monitor test: call Stop(), then run another launchHeaderReceivers pass (for example through an export_test hook, or by checking that ConnectCallCount does not increase after Stop()). That would stop this guard from being dropped later without anyone noticing.
There was a problem hiding this comment.
Added TestBFTCensorshipMonitor_NoLaunchAfterStop in bb3c7f1. It calls Stop(), runs one launch pass through a test-only LaunchHeaderReceivers hook in export_test.go, and checks that ConnectCallCount() stays 0. I confirmed it fails with the guard removed.
There was a problem hiding this comment.
Update: in a9b8442 I replaced the hook-based test with a black-box one, TestBFTCensorshipMonitor_StopBeforeMonitor, and deleted export_test.go. It calls Stop() before Monitor(), which is also a real ordering because the deliverer starts go Monitor() and can stop it before the goroutine runs. It then checks that the monitor reports received a stop signal and that ConnectCallCount() is 0. With the guard removed it fails with 3 connect calls.
| select { | ||
| case r := <-c.s.recvStepC: | ||
| if r == nil { | ||
| return nil, errors.New("fake-recv-step-error") |
There was a problem hiding this comment.
Conventions. The repo-root CLAUDE.md says: "github.com/pkg/errors is banned by the depguard linter for new code (legacy ported files still use it)." This new line uses errors.New from github.com/pkg/errors (the file's existing import) and replaces the previous fmt.Errorf("fake-recv-step-error"). depguard won't flag it because the import already exists. Keeping fmt.Errorf, or using github.com/cockroachdb/errors, avoids adding new pkg/errors usage.
| } | ||
|
|
||
| func (c *perStreamDeliverClient) Recv() (*orderer.DeliverResponse, error) { | ||
| _, _ = c.AtomicBroadcast_DeliverClient.Recv() // record the call |
There was a problem hiding this comment.
The fake's Recv results are silently ignored. The wrapper calls the inner fake.DeliverClient.Recv() only to record the call and then throws away what it returns. Any test that later sets setup.fakeDeliverClient.RecvReturns(...), RecvReturnsOnCall or RecvStub will see them have no effect, with no error. It is also easy to configure a RecvStub that blocks, which would deadlock here before the select. Consider recording calls with your own counter or a stub-free fake, or adding a comment on bftDelivererTestSetup.fakeDeliverClient that Recv behaviour must be driven through recvStepC.
There was a problem hiding this comment.
Documented in bb3c7f1: the fakeDeliverClient field comment says it only records calls and Recv() must be driven through recvStepC, and perStreamDeliverClient.Recv says it ignores the fake stubs and return values. No test configures Recv stubs on it today.
…cks stop race - `BFTCensorshipMonitor.Monitor()` checks `stopCh` before running censorship detection, so a stop always reports `ErrStopping`. - `BFTDeliverer.FetchBlocks` cancels a just-connected stream if `Stop()` ran after the `DoneC` check. - Test: add `TestBFTCensorshipMonitor_NoLaunchAfterStop`; drop the `deliverClientDoneC` escape hatch from the BFT deliverer fake; keep `fmt.Errorf` instead of adding `pkg/errors` usage. Signed-off-by: Liran Funaro <liran.funaro@gmail.com>
|
Thanks for the review. On the three points outside the diff:
|
Signed-off-by: Liran Funaro <liran.funaro@gmail.com>
e85686a to
4e4162d
Compare
… of a second select Signed-off-by: Liran Funaro <liran.funaro@gmail.com>
…box stop test Signed-off-by: Liran Funaro <liran.funaro@gmail.com>
…atomic stop flag Signed-off-by: Liran Funaro <liran.funaro@gmail.com>
|
Closing without merging. Nothing in the Fabric-X repos uses The bugs found here are still in the orderer's copy, which does run; they are filed as hyperledger/fabric-x-orderer#1372, which refers back to this PR's fixes. |
Type of change
Description
BFTCensorshipMonitor.Monitor()checks a newisStopped()(backed by an atomicstopFlag) before launching header receivers and before censorship detection, so it can no longer start header receivers that are never stopped, or reportErrCensorshipafterStop().launchHeaderReceivers()repeats the check under the mutexStop()takes, which closes the window whereStop()runs between the check inMonitor()and the launch.BFTDeliverer.FetchBlockscancels a just-connected stream whenStop()ran after itsDoneCcheck, instead of leaving a block receiver nothing will stop.CloseSendpushed an error onto the shared response channel, so a late-closing old stream could fail the stream that replaced it. TheCloseSendStub = nilworkarounds and thedeliverClientDoneCescape hatch are removed.TestBFTCensorshipMonitor_StopBeforeMonitorcovers the [blocksprovider] BFTCensorshipMonitor launches header receivers after Stop() #155 regression deterministically: it callsStop()beforeMonitor()and checks that no source is connected.TestBFTCensorshipMonitor_NoSourcesno longer callsStop(), which raced with the failure it asserts.Additional details (Optional)
Ran all
TestBFTDeliverer_*andTestBFTCensorshipMonitor_*tests 100 times each with-race -cpu 1,2,4and no failures. Before the fix, a similar run reproduced the #155 panic; #128 did not reproduce locally, so its fix rests on the CI log.Related issues