@@ -1049,6 +1049,105 @@ func TestCatchingUpWithSyncAutonomous(t *testing.T) {
10491049 assert .Equal (t , uint32 (0 ), atomic .LoadUint32 (& detectedSequenceGap ))
10501050}
10511051
1052+ func TestSyncSameHeightPreservesDecisionsInView (t * testing.T ) {
1053+ // Scenario:
1054+ // we inject a fake heartbeat with a higher view number to node 4 (index 3).
1055+ // The HeartbeatMonitor sees hb.View > hm.view and calls handler.Sync()
1056+ // directly — no view change involved. Since node 4 already has all blocks,
1057+ // sync() returns the same height.
1058+ t .Parallel ()
1059+ network := NewNetwork ()
1060+ defer network .Shutdown ()
1061+
1062+ testDir , err := os .MkdirTemp ("" , t .Name ())
1063+ assert .NoErrorf (t , err , "generate temporary test dir" )
1064+ defer os .RemoveAll (testDir )
1065+
1066+ numberOfNodes := 4
1067+ nodes := make ([]* App , 0 )
1068+ for i := 1 ; i <= numberOfNodes ; i ++ {
1069+ n := newNode (uint64 (i ), network , t .Name (), testDir , false , 0 )
1070+ nodes = append (nodes , n )
1071+ }
1072+
1073+ // Hook node 4's logger to detect:
1074+ // 1. When sync is processed (so we know it's safe to submit more proposals)
1075+ // 2. The bug symptom: "Expected decisions in view" validation failure
1076+ var syncProcessed uint32
1077+ bugDetected := make (chan struct {}, 1 )
1078+ baseLogger := nodes [3 ].Consensus .Logger .(* zap.SugaredLogger ).Desugar ()
1079+ nodes [3 ].Consensus .Logger = baseLogger .WithOptions (zap .Hooks (func (entry zapcore.Entry ) error {
1080+ if strings .Contains (entry .Message , "get msg from syncChan" ) {
1081+ atomic .StoreUint32 (& syncProcessed , 1 )
1082+ }
1083+ if strings .Contains (entry .Message , "Expected decisions in view" ) {
1084+ select {
1085+ case bugDetected <- struct {}{}:
1086+ default :
1087+ }
1088+ }
1089+ return nil
1090+ })).Sugar ()
1091+
1092+ startNodes (nodes , network )
1093+
1094+ // Phase 1: Submit 5 proposals to build up DecisionsInView on all nodes.
1095+ for i := 1 ; i <= 5 ; i ++ {
1096+ nodes [0 ].Submit (Request {ID : fmt .Sprintf ("%d" , i ), ClientID : "alice" })
1097+ for j := 0 ; j < numberOfNodes ; j ++ {
1098+ <- nodes [j ].Delivered
1099+ }
1100+ }
1101+
1102+ // Phase 2:
1103+ // the bug this test validate depended on non-deterministic Go select ordering
1104+ // in the [internal.bft.Controller] run() function:
1105+ // select {
1106+ // case newView := <-c.viewChange:
1107+ // case <-c.syncChan:
1108+ // }
1109+ // That's why it can't be reliably reproduced through the natural path
1110+ // in tests — hence the fake heartbeat that triggers only sync, guaranteeing
1111+ // the bug path.
1112+ //
1113+ // The test injects a fake heartbeat with View=1 (higher than current view 0)
1114+ // to node 4. The HeartbeatMonitor sees hb.View > hm.view and calls
1115+ // handler.Sync() directly, bypassing any view change.
1116+ // The sender must be the leader (ID 1) to pass the leaderID check.
1117+ fakeHeartbeat := & smartbftprotos.Message {
1118+ Content : & smartbftprotos.Message_HeartBeat {
1119+ HeartBeat : & smartbftprotos.HeartBeat {
1120+ View : 1 , // higher than node 4's current view (0)
1121+ Seq : 6 ,
1122+ },
1123+ },
1124+ }
1125+ nodes [3 ].Consensus .HandleMessage (1 , fakeHeartbeat )
1126+
1127+ // Wait for the sync to be processed by the controller's run loop.
1128+ assert .Eventually (t , func () bool {
1129+ return atomic .LoadUint32 (& syncProcessed ) == 1
1130+ }, 30 * time .Second , 50 * time .Millisecond ,
1131+ "Node 4 should process sync triggered by fake heartbeat" )
1132+
1133+ // Phase 3: Submit more proposals. Without the bug fix, node 4's view was
1134+ // restarted with DecisionsInView=0, so it rejects proposals from the
1135+ // leader (which has DecisionsInView=5).
1136+ for i := 6 ; i <= 10 ; i ++ {
1137+ nodes [0 ].Submit (Request {ID : fmt .Sprintf ("%d" , i ), ClientID : "alice" })
1138+ for j := 0 ; j < numberOfNodes ; j ++ {
1139+ select {
1140+ case <- nodes [j ].Delivered :
1141+ case <- bugDetected :
1142+ t .Fatalf ("DecisionsInView validation failed on node: sync at same height reset DecisionsInView to 0" )
1143+ case <- time .After (30 * time .Second ):
1144+ t .Fatalf ("Node %d did not deliver proposal %d within timeout" , j + 1 , i )
1145+ }
1146+ }
1147+ }
1148+
1149+ }
1150+
10521151func TestFollowerStateTransfer (t * testing.T ) {
10531152 // Scenario: the leader (n0) is disconnected and so there is a view change
10541153 // a follower (n6) is also disconnected and misses the view change
0 commit comments