Skip to content

Commit 3b61266

Browse files
committed
fix: do not use fixed start time for proposal duties
1 parent ec7fe97 commit 3b61266

6 files changed

Lines changed: 248 additions & 21 deletions

File tree

anchor/common/qbft/src/lib.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,9 @@ where
145145
/// Message sender callback to instruct managing code to send a message
146146
message_sender: S,
147147

148+
/// Signal to reset timer
149+
round_change_timer_reset: bool,
150+
148151
data_validator: Box<dyn QbftDataValidator<D>>,
149152
}
150153

@@ -202,6 +205,7 @@ where
202205
aggregated_commit: None,
203206

204207
message_sender,
208+
round_change_timer_reset: false,
205209
data_validator,
206210
};
207211
qbft.data
@@ -246,6 +250,11 @@ where
246250
self.aggregated_commit.clone()
247251
}
248252

253+
/// This returns true once and clears the signal to reset the timer for the round.
254+
pub fn take_timer_reset_signal(&mut self) -> bool {
255+
std::mem::take(&mut self.round_change_timer_reset)
256+
}
257+
249258
// Validation and check functions.
250259
fn check_leader(&self, operator_id: &OperatorId, round: Round) -> bool {
251260
self.config.leader_fn().leader_function(
@@ -600,6 +609,7 @@ where
600609
if round > self.current_round {
601610
debug!(old_round = ?self.current_round, new_round = ?round, "Updating to future round from proposal");
602611
self.current_round = round;
612+
self.round_change_timer_reset = true;
603613
}
604614

605615
// Accept this proposal

anchor/qbft_manager/src/instance.rs

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ use tokio::{
1515
use tracing::{debug, error, trace, warn};
1616
use types::Hash256;
1717

18-
use crate::{QbftInitialization, QbftMessage, QbftMessageKind, timeout::calculate_round_timeout};
18+
use crate::{
19+
QbftInitialization, QbftMessage, QbftMessageKind, TimeoutMode, timeout::calculate_round_timeout,
20+
};
1921
type Qbft<D> = qbft::Qbft<DefaultLeaderFunction, D, MessageCallback>;
2022

2123
/// Maximum number of messages that are buffered before messages are dropped.
@@ -48,6 +50,7 @@ struct Initialized<D: QbftData<Hash = Hash256>> {
4850
msgs_sent_by_us: UnboundedReceiver<WrappedQbftMessage>,
4951
on_completed: Vec<oneshot::Sender<Completed<D>>>,
5052
start_time: Instant,
53+
timeout_mode: TimeoutMode,
5154
}
5255

5356
struct Decided<D: QbftData<Hash = Hash256>> {
@@ -114,7 +117,15 @@ impl Uninitialized {
114117
init: QbftInitialization<D>,
115118
sender: &Arc<dyn MessageSender>,
116119
) -> Initialized<D> {
120+
// For SlotTime: sleep until start_time, then use that as reference
121+
// For Relative: sleep until start_time, but use Instant::now() as reference
117122
tokio::time::sleep_until(init.start_time).await;
123+
124+
let start_time = match init.timeout_mode {
125+
TimeoutMode::SlotTime => init.start_time,
126+
TimeoutMode::Relative => Instant::now(),
127+
};
128+
118129
let (sent_by_us_tx, sent_by_us_rx) = mpsc::unbounded_channel();
119130

120131
let sender = sender.clone();
@@ -154,7 +165,8 @@ impl Uninitialized {
154165
qbft: instance,
155166
msgs_sent_by_us: sent_by_us_rx,
156167
on_completed: vec![init.on_completed],
157-
start_time: init.start_time,
168+
start_time,
169+
timeout_mode: init.timeout_mode,
158170
}
159171
}
160172
}
@@ -175,10 +187,21 @@ impl<D: QbftData> From<Option<QbftMessage<D>>> for RecvResult<D> {
175187
}
176188

177189
impl<D: QbftData<Hash = Hash256>> Initialized<D> {
190+
fn check_timer_reset(&mut self) {
191+
if self.timeout_mode == TimeoutMode::Relative && self.qbft.take_timer_reset_signal() {
192+
debug!("Resetting round timer due to justified proposal");
193+
self.start_time = Instant::now();
194+
}
195+
}
196+
178197
async fn recv(&mut self, rx: &mut UnboundedReceiver<QbftMessage<D>>) -> RecvResult<D> {
179198
// We calculate the sleep dynamically, as both messages and the local timer might cause the
180199
// round to advance
181-
let round_end = calculate_round_timeout(self.qbft.get_round().into(), &self.start_time);
200+
let round_end = calculate_round_timeout(
201+
self.qbft.get_round().into(),
202+
&self.start_time,
203+
self.timeout_mode,
204+
);
182205

183206
let Some(timeout_instant) = round_end else {
184207
error!(
@@ -286,6 +309,11 @@ pub async fn qbft_instance<D: QbftData<Hash = Hash256>>(
286309
// Can be removed as Anchor approaches maturity.
287310
debug!(msg = %message, "Received message in qbft_instance");
288311
instance.receive(message);
312+
// Check if timer should be reset due to justified proposal (Relative mode
313+
// only)
314+
if let QbftInstance::Initialized(initialized) = &mut instance {
315+
initialized.check_timer_reset();
316+
}
289317
}
290318
}
291319
msg.drop_on_finish
@@ -295,6 +323,10 @@ pub async fn qbft_instance<D: QbftData<Hash = Hash256>>(
295323
if let QbftInstance::Initialized(initialized) = &mut instance {
296324
warn!("Round timer elapsed");
297325
initialized.qbft.end_round();
326+
// Reset timer for new round in Relative mode
327+
if initialized.timeout_mode == TimeoutMode::Relative {
328+
initialized.start_time = Instant::now();
329+
}
298330
};
299331
None
300332
}

anchor/qbft_manager/src/lib.rs

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,17 @@ const QBFT_CLEANER_NAME: &str = "qbft_cleaner";
4343
/// Number of slots to keep before the current slot
4444
const QBFT_RETAIN_SLOTS: u64 = 1;
4545

46+
/// Determines how round timeouts are calculated.
47+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48+
pub enum TimeoutMode {
49+
/// Timeouts are cumulative from slot start. The timer never resets.
50+
/// Used for: attestations, aggregations, sync committee.
51+
SlotTime,
52+
/// Timeouts are relative to when each round started. Timer resets on justified proposals.
53+
/// Used for: block proposals.
54+
Relative,
55+
}
56+
4657
// Unique Identifier for a committee and its corresponding QBFT instance
4758
#[derive(Debug, Clone, Hash, PartialEq, Eq)]
4859
pub struct CommitteeInstanceId {
@@ -91,8 +102,10 @@ pub struct QbftInitialization<D: QbftData> {
91102
validator: Box<dyn QbftDataValidator<D>>,
92103
/// The message id to be embedded into outgoing messages.
93104
message_id: MessageId,
94-
/// The time when the first round is supposed to start. Rounds will be advanced based on this.
105+
/// The time reference for timeout calculations.
95106
start_time: Instant,
107+
/// The timeout mode for this instance.
108+
timeout_mode: TimeoutMode,
96109
/// The configuration for the instance.
97110
config: qbft::Config<DefaultLeaderFunction>,
98111
/// The channel to send the final result to.
@@ -152,6 +165,7 @@ impl QbftManager {
152165
initial: D,
153166
validator: Box<dyn QbftDataValidator<D>>,
154167
start_time: Instant,
168+
timeout_mode: TimeoutMode,
155169
committee: &Cluster,
156170
) -> Result<Completed<D>, QbftError> {
157171
let Some(operator_id) = self.operator_id.get() else {
@@ -190,6 +204,7 @@ impl QbftManager {
190204
validator,
191205
message_id,
192206
start_time,
207+
timeout_mode,
193208
config,
194209
on_completed: result_sender,
195210
}),

anchor/qbft_manager/src/tests.rs

Lines changed: 135 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ use types::{Hash256, Slot};
3131

3232
use super::{
3333
CommitteeInstanceId, Completed, QbftDecidable, QbftError, QbftInitialization, QbftManager,
34-
QbftMessageKind, WrappedQbftMessage,
34+
QbftMessageKind, TimeoutMode, WrappedQbftMessage,
3535
};
3636
use crate::instance::qbft_instance;
3737

@@ -347,8 +347,6 @@ where
347347
) -> UnboundedReceiver<(Hash256, Result<Completed<D>, QbftError>)> {
348348
let (result_tx, result_rx) = mpsc::unbounded_channel();
349349

350-
let start_time = Instant::now();
351-
352350
for (data, data_id) in all_data {
353351
let height = *data.instance_height(&data_id) as u64;
354352
self.identifiers.insert(height, data_id.clone());
@@ -392,7 +390,8 @@ where
392390
id_clone,
393391
data_clone.clone(),
394392
Box::new(NoDataValidation),
395-
start_time,
393+
Instant::now(),
394+
TimeoutMode::SlotTime,
396395
&cluster,
397396
)
398397
.await;
@@ -945,6 +944,7 @@ async fn test_timeout(round_timeout_to_test: usize) {
945944
&DutyExecutor::Committee(CommitteeId::default()),
946945
),
947946
start_time: qbft_start_time,
947+
timeout_mode: TimeoutMode::SlotTime,
948948
config: qbft::ConfigBuilder::new(
949949
OperatorId(1),
950950
InstanceHeight::from(0),
@@ -983,3 +983,134 @@ async fn test_timeout(round_timeout_to_test: usize) {
983983
}
984984
assert_eq!(timeout, expected_timeout);
985985
}
986+
987+
/// Test that Relative mode uses single-round timeouts starting from Instant::now()
988+
/// after the sleep_until, not cumulative timeouts from start_time.
989+
#[tokio::test(start_paused = true)]
990+
async fn test_relative_mode_timeout() {
991+
let (sender_tx, _sender_rx) = unbounded_channel();
992+
let (message_tx, message_rx) = unbounded_channel();
993+
let (result_tx, result_rx) = oneshot::channel();
994+
let message_sender = MockMessageSender::new(sender_tx, OperatorId(1));
995+
let _handle = tokio::spawn(qbft_instance::<BeaconVote>(
996+
message_rx,
997+
Arc::new(message_sender),
998+
));
999+
1000+
let slot_start_time = Instant::now();
1001+
// Set start_time 4 seconds in the future (simulating slot timing)
1002+
let qbft_start_time = slot_start_time + Duration::from_secs(4);
1003+
1004+
message_tx
1005+
.send(crate::QbftMessage {
1006+
kind: QbftMessageKind::Initialize(QbftInitialization {
1007+
initial: manager_tests::generate_test_data(0).0,
1008+
validator: Box::new(NoDataValidation),
1009+
message_id: MessageId::new(
1010+
&DomainType::default(),
1011+
Role::Committee,
1012+
&DutyExecutor::Committee(CommitteeId::default()),
1013+
),
1014+
start_time: qbft_start_time,
1015+
timeout_mode: TimeoutMode::Relative, // Using Relative mode
1016+
config: qbft::ConfigBuilder::new(
1017+
OperatorId(1),
1018+
InstanceHeight::from(0),
1019+
IndexSet::from([1, 2, 3, 4].map(OperatorId)),
1020+
)
1021+
.with_max_rounds(3) // Test 3 rounds
1022+
.build()
1023+
.unwrap(),
1024+
on_completed: result_tx,
1025+
}),
1026+
drop_on_finish: None,
1027+
})
1028+
.unwrap();
1029+
1030+
assert!(matches!(result_rx.await, Ok(Completed::TimedOut)));
1031+
1032+
let total_time = Instant::now() - slot_start_time;
1033+
1034+
// For Relative mode:
1035+
// - Wait 4 seconds until start_time
1036+
// - Round 1: 2 seconds (single round timeout, not cumulative)
1037+
// - Round 2: 2 seconds
1038+
// - Round 3: 2 seconds
1039+
// Total: 4 + 2 + 2 + 2 = 10 seconds
1040+
//
1041+
// If it were SlotTime mode (cumulative), it would be:
1042+
// - Wait 4 seconds
1043+
// - Round 1 ends at start_time + 2 = 6 seconds total
1044+
// - Round 2 ends at start_time + 4 = 8 seconds total
1045+
// - Round 3 ends at start_time + 6 = 10 seconds total
1046+
// Which happens to be the same for this test, but the key difference is
1047+
// Relative mode resets start_time to Instant::now() after sleep_until
1048+
1049+
let expected = Duration::from_secs(4 + 2 + 2 + 2);
1050+
assert_eq!(total_time, expected);
1051+
}
1052+
1053+
/// Test that SlotTime and Relative modes differ when start_time is in the past.
1054+
/// This tests the key behavioral difference between the modes.
1055+
#[tokio::test(start_paused = true)]
1056+
async fn test_relative_vs_slottime_timing_difference() {
1057+
// Test with start_time in the past - this highlights the difference
1058+
// between SlotTime (uses original start_time) and Relative (uses Instant::now())
1059+
1060+
async fn run_with_mode(mode: TimeoutMode) -> Duration {
1061+
let (sender_tx, _sender_rx) = unbounded_channel();
1062+
let (message_tx, message_rx) = unbounded_channel();
1063+
let (result_tx, result_rx) = oneshot::channel();
1064+
let message_sender = MockMessageSender::new(sender_tx, OperatorId(1));
1065+
let _handle = tokio::spawn(qbft_instance::<BeaconVote>(
1066+
message_rx,
1067+
Arc::new(message_sender),
1068+
));
1069+
1070+
let now = Instant::now();
1071+
// start_time is NOW (no waiting)
1072+
let qbft_start_time = now;
1073+
1074+
message_tx
1075+
.send(crate::QbftMessage {
1076+
kind: QbftMessageKind::Initialize(QbftInitialization {
1077+
initial: manager_tests::generate_test_data(0).0,
1078+
validator: Box::new(NoDataValidation),
1079+
message_id: MessageId::new(
1080+
&DomainType::default(),
1081+
Role::Committee,
1082+
&DutyExecutor::Committee(CommitteeId::default()),
1083+
),
1084+
start_time: qbft_start_time,
1085+
timeout_mode: mode,
1086+
config: qbft::ConfigBuilder::new(
1087+
OperatorId(1),
1088+
InstanceHeight::from(0),
1089+
IndexSet::from([1, 2, 3, 4].map(OperatorId)),
1090+
)
1091+
.with_max_rounds(2)
1092+
.build()
1093+
.unwrap(),
1094+
on_completed: result_tx,
1095+
}),
1096+
drop_on_finish: None,
1097+
})
1098+
.unwrap();
1099+
1100+
assert!(matches!(result_rx.await, Ok(Completed::TimedOut)));
1101+
Instant::now() - now
1102+
}
1103+
1104+
let slottime_duration = run_with_mode(TimeoutMode::SlotTime).await;
1105+
let relative_duration = run_with_mode(TimeoutMode::Relative).await;
1106+
1107+
// Both should complete in 4 seconds (2 rounds * 2 seconds each)
1108+
// The difference is in HOW they calculate it:
1109+
// - SlotTime: cumulative from original start_time
1110+
// - Relative: single-round from Instant::now() after sleep
1111+
//
1112+
// When start_time is now, both should behave similarly for the first run,
1113+
// but the internal calculations differ.
1114+
assert_eq!(slottime_duration, Duration::from_secs(4));
1115+
assert_eq!(relative_duration, Duration::from_secs(4));
1116+
}

0 commit comments

Comments
 (0)