fix(raft): vote-reply per-peer dedup + stale-term drop (bug-1095/1096)

raft_on_vote_reply double-counted duplicated vote-responses (counter ignored
from_idx) and failed to drop stale-term replies, allowing two leaders at the
same term (bug-851 split-brain family). Mirror the existing pre-vote per-peer
dedup pattern (new vote_replies[] set) and add DropStaleResponse (term <
currentTerm). Surfaced by the raft-sim-conformance oracle (task 4.2).

47 raft / 264 overall lib tests pass.
This commit is contained in:
Rongsong Shen 2026-06-24 13:56:24 +00:00
parent e15bbf8190
commit b5b7e01b92
3 changed files with 135 additions and 2 deletions

View file

@ -70,6 +70,10 @@ int raft_init(raft_state_t *s, const raft_config_t *cfg)
s->self_idx = cfg->self_idx;
s->votes_granted = 0;
s->replies_received = 0;
/* Defensive: a node must never carry stale per-peer vote flags into its
* first election (mirrors pre_vote_replies). raft_start_election re-clears
* them, but zero here so a never-elected node has a clean array. */
memset(s->vote_replies, 0, sizeof(s->vote_replies));
s->election_decided = 0;
s->election_timeout_ms = cfg->election_timeout_ms ? cfg->election_timeout_ms : 2000;
s->my_priority = cfg->my_priority;
@ -110,6 +114,13 @@ int raft_start_election(raft_state_t *s)
s->voted_for_term = s->term;
s->votes_granted = 1; /* self-vote */
s->replies_received = 0;
/* Fresh per-peer vote-reply tally for this election (mirrors
* pre_vote_replies reset). Mark self as already-counted so a stray
* self-addressed reply cannot double-count the self-vote that
* votes_granted already includes. */
memset(s->vote_replies, 0, sizeof(s->vote_replies));
if (s->self_idx >= 0 && s->self_idx < RAFT_MAX_PEERS)
s->vote_replies[s->self_idx] = 1;
s->election_decided = 0;
if (s->cbs->on_trace)
@ -421,8 +432,6 @@ int raft_on_pre_vote_reply(raft_state_t *s, int peer_idx, int granted)
int raft_on_vote_reply(raft_state_t *s, int from_idx, int granted, uint64_t term)
{
(void)from_idx;
if (s->cbs->on_trace)
s->cbs->on_trace(s->ctx, "vote_reply_in", NULL);
@ -438,6 +447,15 @@ int raft_on_vote_reply(raft_state_t *s, int from_idx, int granted, uint64_t term
return RAFT_BECAME_FOLLOWER;
}
/* DropStaleResponse (raft.tla DropStaleResponse, mterm < currentTerm):
* a reply from an older term is discarded, NOT counted toward this
* election — and it is NOT a higher term, so it must not step us down. */
if (term < s->term) {
if (s->cbs->on_trace)
s->cbs->on_trace(s->ctx, "vote_reply_stale_drop", NULL);
return RAFT_PENDING;
}
/* Step 2: no-op guard — election already decided */
if (s->election_decided) {
if (s->cbs->on_trace)
@ -445,6 +463,24 @@ int raft_on_vote_reply(raft_state_t *s, int from_idx, int granted, uint64_t term
return RAFT_PENDING;
}
/* Bounds guard: ignore a reply from an out-of-range peer (mirrors the
* pre-vote per-peer dedup bound). */
if (from_idx < 0 || from_idx >= s->n_peers || from_idx >= RAFT_MAX_PEERS) {
if (s->cbs->on_trace)
s->cbs->on_trace(s->ctx, "vote_reply_out_of_range", NULL);
return RAFT_PENDING;
}
/* Per-peer idempotency (raft.tla votesGranted is a SET): a duplicate reply
* from a peer we already counted is ignored — a legal DuplicateMessage must
* not double-count toward quorum (Election Safety). */
if (s->vote_replies[from_idx]) {
if (s->cbs->on_trace)
s->cbs->on_trace(s->ctx, "vote_reply_dup", NULL);
return RAFT_PENDING;
}
s->vote_replies[from_idx] = 1;
/* Step 3: count this reply */
s->replies_received++;

View file

@ -34,6 +34,7 @@ typedef struct {
int self_idx; /* this node's index in [0, n_peers) */
int votes_granted; /* votes collected in current election */
int replies_received; /* total peer replies received (for all-replied-no-quorum, Req 4.4) */
int vote_replies[RAFT_MAX_PEERS]; /* per-peer replied flag; avoids double-count (mirrors pre_vote_replies) */
int election_decided; /* 1 once on_become_leader/follower fired */
uint32_t election_timeout_ms;
int my_priority; /* priority index for jitter (0 = highest) */