fix(controller,lib): decide each PreVote round once and promote only with this election's votes (bug-1915)

A late PreVote grant after WON re-ran the WON path and started a second
real election that replaced elect_ctx, so the term-2 leader promoted no
vote endpoints. Rounds now carry an id and endpoints a round/kind; stale
or wrong-kind replies, timeouts and errors are dropped; raft ignores
replies with no round in progress. raft_cb_become_leader promotes only
with its own stamped election record (own term, majority), else steps
down and re-arms. raft_on_leader_lost keeps voted_for (one vote per
term) and the delayed election starts only as a follower in the lost
term. The failover stagger hands the PreVote start to a vtask so the
delaymgr stays trigger-only.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rongsong Shen 2026-10-04 09:14:38 +00:00
parent acf78f9a94
commit 88a8bf8a47
Signed by untrusted user: rshen
GPG key ID: 80533536B0EB651C
7 changed files with 2463 additions and 201 deletions

File diff suppressed because it is too large Load diff

View file

@ -125,6 +125,14 @@ typedef struct {
* for the full rationale (a pure per-endpoint count would allow a * for the full rationale (a pure per-endpoint count would allow a
* spurious free before the round is superseded). */ * spurious free before the round is superseded). */
_Atomic int refcount; _Atomic int refcount;
/* bug-1915 follow-up: which round this carrier belongs to, checked by
* raft_cb_become_leader before it trusts votes[]. election_term is the
* term ha_start_election_async stamped (raft.term + 1, under raft_lock,
* immediately before raft_start_election); 0 = not stamped (synthetic
* test carriers). is_pre_vote = 1 for a PreVote round carrier
* (ha_start_prevote_async), which never holds real votes. */
uint64_t election_term;
int is_pre_vote;
ha_vote_entry_t votes[]; /* C99 flexible array: n_peers-1 entries */ ha_vote_entry_t votes[]; /* C99 flexible array: n_peers-1 entries */
} ha_elect_ctx_t; } ha_elect_ctx_t;
@ -245,6 +253,13 @@ typedef struct {
* -> real election, within one call chain) — that nesting must not * -> real election, within one call chain) — that nesting must not
* self-deadlock, while a genuinely different thread still fully blocks. */ * self-deadlock, while a genuinely different thread still fully blocks. */
pthread_mutex_t raft_lock; pthread_mutex_t raft_lock;
/* bug-1915 follow-up: PreVote round id. Incremented under raft_lock by
* ha_start_prevote_async for every round it starts and captured by that
* round's PreVote endpoints and round timeout, so a reply, endpoint error
* or timeout from an older round is dropped instead of being tallied in
* the current one (PreVote replies carry no round id on the wire).
* Read and written only under raft_lock. */
uint64_t pre_vote_round;
/* ── New fields added by raft-library spec (Task 6.2) ──────── */ /* ── New fields added by raft-library spec (Task 6.2) ──────── */
cluster_t *raft_c; /* cluster_t* set by ha_start_election_async; used by raft callbacks */ cluster_t *raft_c; /* cluster_t* set by ha_start_election_async; used by raft callbacks */
void *raft_election_timeout_ctx; /* election_timeout_ctx_t* for cancel (task 6.2) */ void *raft_election_timeout_ctx; /* election_timeout_ctx_t* for cancel (task 6.2) */

File diff suppressed because it is too large Load diff

View file

@ -737,6 +737,13 @@ static void test_post_connect_vote_failure_routes_through_production_tally(void)
extern int pre_vote_ep_err_handler_for_test(endpoint_t *ep); extern int pre_vote_ep_err_handler_for_test(endpoint_t *ep);
/* bug-1915 follow-up: a production PreVote endpoint is stamped with its kind
* and the round it belongs to (raft_cb_send_pre_vote_request); an endpoint
* built with vote_ep_ctx_build_for_test is stamped the same way here, for the
* round currently in progress. */
extern void vote_ep_ctx_mark_pre_vote_for_test(endpoint_t *ep, uint64_t round);
extern uint64_t ha_pre_vote_round_for_test(ha_state_t *ha);
extern void pre_vote_reply_dispatch_for_test(ha_state_t *ha, cluster_t *c, extern void pre_vote_reply_dispatch_for_test(ha_state_t *ha, cluster_t *c,
int peer_idx, int granted); int peer_idx, int granted);
@ -797,6 +804,7 @@ static void test_post_connect_prevote_failure_routes_through_production_tally(vo
* registered err_handler (pre_vote_ep_err_handler) on this same ep. */ * registered err_handler (pre_vote_ep_err_handler) on this same ep. */
vote_ep_ctx_build_for_test(&ep, &ha, ctx, /*peer_idx=*/1, vote_ep_ctx_build_for_test(&ep, &ha, ctx, /*peer_idx=*/1,
4ULL, 100ULL, 50ULL, "ctrl-a"); 4ULL, 100ULL, 50ULL, "ctrl-a");
vote_ep_ctx_mark_pre_vote_for_test(&ep, ha_pre_vote_round_for_test(&ha));
int rc = pre_vote_ep_err_handler_for_test(&ep); int rc = pre_vote_ep_err_handler_for_test(&ep);
TEST_ASSERT_EQUAL_INT(0, rc); TEST_ASSERT_EQUAL_INT(0, rc);
@ -850,6 +858,7 @@ static void test_prevote_round_reaches_quorum_decision_via_fast_tally(void)
make_endpoint(&ep1); make_endpoint(&ep1);
vote_ep_ctx_build_for_test(&ep1, &ha, ctx, /*peer_idx=*/1, vote_ep_ctx_build_for_test(&ep1, &ha, ctx, /*peer_idx=*/1,
4ULL, 100ULL, 50ULL, "ctrl-a"); 4ULL, 100ULL, 50ULL, "ctrl-a");
vote_ep_ctx_mark_pre_vote_for_test(&ep1, ha_pre_vote_round_for_test(&ha));
TEST_ASSERT_EQUAL_INT(0, pre_vote_ep_err_handler_for_test(&ep1)); TEST_ASSERT_EQUAL_INT(0, pre_vote_ep_err_handler_for_test(&ep1));
TEST_ASSERT_EQUAL_INT_MESSAGE(1, ha.raft.pre_vote_in_progress, TEST_ASSERT_EQUAL_INT_MESSAGE(1, ha.raft.pre_vote_in_progress,

View file

@ -77,6 +77,9 @@ int raft_init(raft_state_t *s, const raft_config_t *cfg)
s->election_decided = 0; s->election_decided = 0;
s->election_timeout_ms = cfg->election_timeout_ms ? cfg->election_timeout_ms : 2000; s->election_timeout_ms = cfg->election_timeout_ms ? cfg->election_timeout_ms : 2000;
s->my_priority = cfg->my_priority; s->my_priority = cfg->my_priority;
/* No leader lost yet: the restored term (raft_check_and_start_election is
* only meaningful after raft_on_leader_lost, which overwrites this). */
s->leader_lost_term = cfg->initial_term;
/* Log length restored from the adapter's recovered WAL length; the library /* Log length restored from the adapter's recovered WAL length; the library
* never reads storage (same discipline as initial_term/voted_for). 0 on * never reads storage (same discipline as initial_term/voted_for). 0 on
* first boot. (raft-log-replication Req 1.1; design "log_len restored at * first boot. (raft-log-replication Req 1.1; design "log_len restored at
@ -374,6 +377,17 @@ int raft_on_pre_vote_reply(raft_state_t *s, int peer_idx, int granted)
* controller (task 4.1) acts on. There is no term in a pre-vote reply, so * controller (task 4.1) acts on. There is no term in a pre-vote reply, so
* no term adoption is even possible here. */ * no term adoption is even possible here. */
/* No-op guard — the round is already decided, or none was started
* (mirrors raft_on_vote_reply's election_decided check). WON and LOST
* both clear pre_vote_in_progress, and pre_votes_granted never decreases,
* so tallying a peer that had not replied yet would read a WON round as
* WON again on its late GRANT or DENY (bug-1915). */
if (!s->pre_vote_in_progress) {
if (s->cbs->on_trace)
s->cbs->on_trace(s->ctx, "pre_vote_reply_noop", NULL);
return RAFT_PRE_VOTE_PENDING;
}
/* Bounds guard: ignore replies from an out-of-range peer (mirrors the /* Bounds guard: ignore replies from an out-of-range peer (mirrors the
* per-peer dedup array bound). A self reply (peer_idx == self_idx) is * per-peer dedup array bound). A self reply (peer_idx == self_idx) is
* never expected — self is granted by raft_begin_pre_vote — and would also * never expected — self is granted by raft_begin_pre_vote — and would also
@ -537,7 +551,11 @@ void raft_on_election_timeout(raft_state_t *s)
void raft_on_leader_lost(raft_state_t *s) void raft_on_leader_lost(raft_state_t *s)
{ {
s->voted_for = -1; /* bug-1915 final review: do NOT clear voted_for. The term does not change
* here, so a vote already cast in it still binds — clearing it let a
* delayed RequestVote(term) from ANOTHER candidate be granted (a second
* vote in one term). The delayed-election guard keys on the term instead. */
s->leader_lost_term = s->term;
s->role = RAFT_FOLLOWER; s->role = RAFT_FOLLOWER;
s->cbs->on_start_election_after_delay(s->ctx, s->cbs->on_start_election_after_delay(s->ctx,
(uint32_t)s->my_priority * s->election_timeout_ms); (uint32_t)s->my_priority * s->election_timeout_ms);
@ -547,13 +565,18 @@ void raft_on_leader_lost(raft_state_t *s)
int raft_check_and_start_election(raft_state_t *s) int raft_check_and_start_election(raft_state_t *s)
{ {
if (s->voted_for != -1 && s->voted_for != s->self_idx) { /* Skip unless still a FOLLOWER in the leader-lost term: a term change
* during the delay (a grant to another candidate's higher term, a new
* leader) or a role change means another node moved the cluster on.
* voted_for is no longer a usable signal — raft_on_leader_lost keeps the
* vote cast in this term (often for the lost leader itself). */
if (s->term != s->leader_lost_term || s->role != RAFT_FOLLOWER) {
if (s->cbs->on_trace) if (s->cbs->on_trace)
s->cbs->on_trace(s->ctx, "jitter_skip", NULL); s->cbs->on_trace(s->ctx, "jitter_skip", NULL);
return RAFT_SKIP_ELECTION; return RAFT_SKIP_ELECTION;
} }
/* Precondition: raft_on_leader_lost sets role=FOLLOWER, so -EALREADY is unreachable here */ /* Precondition: role == FOLLOWER (checked above), so -EALREADY is unreachable here */
int rc = raft_start_election(s); int rc = raft_start_election(s);
(void)rc; (void)rc;
return RAFT_ELECTION_STARTED; return RAFT_ELECTION_STARTED;

View file

@ -38,6 +38,12 @@ typedef struct {
int election_decided; /* 1 once on_become_leader/follower fired */ int election_decided; /* 1 once on_become_leader/follower fired */
uint32_t election_timeout_ms; uint32_t election_timeout_ms;
int my_priority; /* priority index for jitter (0 = highest) */ int my_priority; /* priority index for jitter (0 = highest) */
/* Term in which raft_on_leader_lost last ran (volatile, never persisted).
* raft_check_and_start_election starts the delayed election only while the
* node is still a FOLLOWER in this term: a term change during the delay
* means another candidate (or a new leader) moved the cluster on.
* raft_on_leader_lost no longer clears voted_for (bug-1915 final review). */
uint64_t leader_lost_term;
/* ---- PreVote round tally (Req 1.2) ---- /* ---- PreVote round tally (Req 1.2) ----
* Self-contained pre-vote bookkeeping. A pre-vote round mutates NONE of * Self-contained pre-vote bookkeeping. A pre-vote round mutates NONE of
* the persistent Raft state above (term, voted_for, role); these fields * the persistent Raft state above (term, voted_for, role); these fields
@ -284,14 +290,20 @@ void raft_on_election_timeout(raft_state_t *s);
/* /*
* raft_on_leader_lost: primary control channel dropped (RDHUP). * raft_on_leader_lost: primary control channel dropped (RDHUP).
* Clears voted_for, transitions to FOLLOWER, fires on_start_election_after_delay. * Records leader_lost_term = term, transitions to FOLLOWER, fires
* on_start_election_after_delay. Does NOT clear voted_for: the term is
* unchanged, so the vote cast in it still binds (one vote per term — clearing
* it let a delayed RequestVote of the same term from another candidate be
* granted; bug-1915 final review).
*/ */
void raft_on_leader_lost(raft_state_t *s); void raft_on_leader_lost(raft_state_t *s);
/* /*
* raft_check_and_start_election: call after the delay from raft_on_leader_lost. * raft_check_and_start_election: call after the delay from raft_on_leader_lost.
* Starts election only if we have not already voted for a non-self candidate * Starts the election only if the node is still a FOLLOWER in the term in which
* in the new term (i.e., another node won during the jitter window). * the leader was lost (term == leader_lost_term). Any term change during the
* delay (a vote granted to another candidate's higher term, a new leader's
* heartbeat) or a role change means the cluster moved on: skip ("jitter_skip").
* Returns RAFT_SKIP_ELECTION or RAFT_ELECTION_STARTED. * Returns RAFT_SKIP_ELECTION or RAFT_ELECTION_STARTED.
*/ */
int raft_check_and_start_election(raft_state_t *s); int raft_check_and_start_election(raft_state_t *s);
@ -337,7 +349,10 @@ int raft_on_pre_vote_request(raft_state_t *s, int from_idx, uint64_t cand_term,
* pre_vote_replies), mirroring raft_on_vote_reply but WITHOUT firing * pre_vote_replies), mirroring raft_on_vote_reply but WITHOUT firing
* on_become_leader/on_become_follower or making any role/term change. * on_become_leader/on_become_follower or making any role/term change.
* Returns RAFT_PRE_VOTE_WON at quorum, RAFT_PRE_VOTE_LOST when all peers * Returns RAFT_PRE_VOTE_WON at quorum, RAFT_PRE_VOTE_LOST when all peers
* replied without quorum, else RAFT_PRE_VOTE_PENDING. * replied without quorum, else RAFT_PRE_VOTE_PENDING. A reply while no round
* is in progress (already decided WON/LOST, or never started) is a no-op that
* returns RAFT_PRE_VOTE_PENDING and leaves the tally untouched, so a round is
* decided at most once.
*/ */
int raft_on_pre_vote_reply(raft_state_t *s, int peer_idx, int granted); int raft_on_pre_vote_reply(raft_state_t *s, int peer_idx, int granted);

View file

@ -3133,15 +3133,18 @@ static void test_election_timeout_already_decided(void)
/* ---------- raft_on_leader_lost tests (task 3.5) ---------- */ /* ---------- raft_on_leader_lost tests (task 3.5) ---------- */
/* /*
* test_leader_lost_clears_voted_for * test_leader_lost_keeps_voted_for (was test_leader_lost_clears_voted_for)
* *
* Node had voted_for=1. raft_on_leader_lost must: * Node had voted_for=1 in its current term. raft_on_leader_lost must:
* - set voted_for=-1 * - KEEP voted_for (bug-1915 final review: clearing it without a term change
* let a delayed RequestVote of the SAME term from another candidate be
* granted — a second vote in one term, an Election Safety violation)
* - record leader_lost_term = term (raft_check_and_start_election's guard)
* - set role=FOLLOWER * - set role=FOLLOWER
* - fire on_start_election_after_delay with delay = my_priority * election_timeout_ms * - fire on_start_election_after_delay with delay = my_priority * election_timeout_ms
* - trace "leader_lost" * - trace "leader_lost"
*/ */
static void test_leader_lost_clears_voted_for(void) static void test_leader_lost_keeps_voted_for(void)
{ {
raft_state_t s; raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs(); raft_callbacks_t cbs = make_mock_cbs();
@ -3159,12 +3162,18 @@ static void test_leader_lost_clears_voted_for(void)
cfg.cbs = &cbs; cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg)); TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
s.term = 5;
s.voted_for = 1; s.voted_for = 1;
s.voted_for_term = 5;
s.role = RAFT_FOLLOWER; s.role = RAFT_FOLLOWER;
raft_on_leader_lost(&s); raft_on_leader_lost(&s);
TEST_ASSERT_EQUAL_INT(-1, s.voted_for); TEST_ASSERT_EQUAL_INT_MESSAGE(1, s.voted_for,
"leader-lost must not clear the vote cast in the current term");
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
TEST_ASSERT_EQUAL_UINT64(5, s.term);
TEST_ASSERT_EQUAL_UINT64(5, s.leader_lost_term);
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role); TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
/* on_start_election_after_delay fired once with delay = priority * timeout */ /* on_start_election_after_delay fired once with delay = priority * timeout */
@ -3176,12 +3185,59 @@ static void test_leader_lost_clears_voted_for(void)
TEST_ASSERT_EQUAL_STRING("leader_lost", g_mock.last_trace_event); TEST_ASSERT_EQUAL_STRING("leader_lost", g_mock.last_trace_event);
} }
/*
* test_leader_lost_same_term_vote_from_other_denied (bug-1915 final review)
*
* Node voted for candidate 1 in term 5, then lost its leader. A delayed
* RequestVote(term 5) from ANOTHER candidate (2) must be DENIED: at most one
* vote per node per term, whatever happened to the leader in between.
*/
static void test_leader_lost_same_term_vote_from_other_denied(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
cbs.on_trace = mock_trace;
reset_mock();
raft_config_t cfg;
memset(&cfg, 0, sizeof(cfg));
cfg.n_peers = 3;
cfg.self_idx = 0;
cfg.election_timeout_ms = 500;
cfg.my_priority = 1;
cfg.initial_term = 5;
cfg.initial_voted_for = 1; /* voted for candidate 1 in term 5 */
cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
TEST_ASSERT_EQUAL_UINT64(5, s.voted_for_term);
raft_on_leader_lost(&s);
int persist_before = g_mock.persist_count;
int rc = raft_on_vote_request(&s, 2, 5, 0, 0, &reply_term);
TEST_ASSERT_EQUAL_INT_MESSAGE(RAFT_DENY, rc,
"a second vote in term 5 (for candidate 2) must be denied after "
"leader-lost");
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
TEST_ASSERT_EQUAL_UINT64(5, s.term);
TEST_ASSERT_EQUAL_INT_MESSAGE(persist_before, g_mock.persist_count,
"a denied vote must not persist");
/* The same candidate's retry is still an idempotent re-grant. */
rc = raft_on_vote_request(&s, 1, 5, 0, 0, &reply_term);
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
TEST_ASSERT_EQUAL_INT(1, s.voted_for);
}
/* ---------- raft_check_and_start_election tests (task 3.5) ---------- */ /* ---------- raft_check_and_start_election tests (task 3.5) ---------- */
/* /*
* test_check_and_start_voted_for_other * test_check_and_start_voted_for_other
* *
* voted_for=1, self_idx=0 (voted for a different candidate). * Leader lost in term 3; during the delay this node granted another candidate
* (peer 1) a vote in its higher term 4 (term moved on, voted_for=1).
* Must return RAFT_SKIP_ELECTION, role stays FOLLOWER, no election started. * Must return RAFT_SKIP_ELECTION, role stays FOLLOWER, no election started.
* Trace "jitter_skip" fires. * Trace "jitter_skip" fires.
*/ */
@ -3189,13 +3245,18 @@ static void test_check_and_start_voted_for_other(void)
{ {
raft_state_t s; raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs(); raft_callbacks_t cbs = make_mock_cbs();
uint64_t reply_term = 0;
cbs.on_trace = mock_trace; cbs.on_trace = mock_trace;
reset_mock(); reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0)); TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
s.voted_for = 1; /* voted for peer 1, not self (0) */ s.term = 3;
s.role = RAFT_FOLLOWER; raft_on_leader_lost(&s);
TEST_ASSERT_EQUAL_INT(RAFT_GRANT,
raft_on_vote_request(&s, 1, 4, 0, 0, &reply_term));
TEST_ASSERT_EQUAL_INT(1, s.voted_for); /* voted for peer 1, not self (0) */
TEST_ASSERT_EQUAL_INT(RAFT_FOLLOWER, s.role);
int rc = raft_check_and_start_election(&s); int rc = raft_check_and_start_election(&s);
@ -3210,6 +3271,54 @@ static void test_check_and_start_voted_for_other(void)
TEST_ASSERT_EQUAL_STRING("jitter_skip", g_mock.last_trace_event); TEST_ASSERT_EQUAL_STRING("jitter_skip", g_mock.last_trace_event);
} }
/*
* test_check_and_start_after_leader_lost_same_term_starts (bug-1915 final review)
*
* The vote raft_on_leader_lost now keeps (here: for the lost leader, peer 1,
* in term 5) must not block the delayed election: the term has not moved, so
* the node starts its election in term 6.
*/
static void test_check_and_start_after_leader_lost_same_term_starts(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
s.term = 5;
s.voted_for = 1;
s.voted_for_term = 5;
raft_on_leader_lost(&s);
TEST_ASSERT_EQUAL_INT(RAFT_ELECTION_STARTED,
raft_check_and_start_election(&s));
TEST_ASSERT_EQUAL_INT(RAFT_CANDIDATE, s.role);
TEST_ASSERT_EQUAL_UINT64(6, s.term);
}
/*
* test_check_and_start_skips_when_not_follower (bug-1915 final review)
*
* Leader lost in term 5, then the node left FOLLOWER (e.g. another trigger
* already started its election) — the delayed start must skip.
*/
static void test_check_and_start_skips_when_not_follower(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
cbs.on_trace = mock_trace;
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_node(&s, &cbs, 3, 0));
s.term = 5;
raft_on_leader_lost(&s);
s.role = RAFT_CANDIDATE; /* same term, but no longer a follower */
TEST_ASSERT_EQUAL_INT(RAFT_SKIP_ELECTION, raft_check_and_start_election(&s));
TEST_ASSERT_EQUAL_UINT64(5, s.term);
TEST_ASSERT_EQUAL_STRING("jitter_skip", g_mock.last_trace_event);
}
/* /*
* test_check_and_start_not_voted * test_check_and_start_not_voted
* *
@ -6461,11 +6570,11 @@ static void test_proto_F1_leader_lost_priority_delay(void)
/* /*
* test_proto_F2_voted_during_delay_skip * test_proto_F2_voted_during_delay_skip
* *
* Node calls raft_on_leader_lost (voted_for=-1). * Node calls raft_on_leader_lost (voted_for stays -1).
* During the jitter delay, a vote request arrives from node 0 at term=1. * During the jitter delay, a vote request arrives from node 0 at term=1.
* Node grants the vote (voted_for=0). * Node grants the vote (voted_for=0).
* Then raft_check_and_start_election is called. * Then raft_check_and_start_election is called.
* Since voted_for==0 != self_idx==1, must return RAFT_SKIP_ELECTION. * Since the term moved past leader_lost_term, must return RAFT_SKIP_ELECTION.
*/ */
static void test_proto_F2_voted_during_delay_skip(void) static void test_proto_F2_voted_during_delay_skip(void)
{ {
@ -6484,7 +6593,7 @@ static void test_proto_F2_voted_during_delay_skip(void)
cfg.cbs = &cbs; cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg)); TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
/* Leader lost: clears voted_for to -1, schedules delayed election */ /* Leader lost: records leader_lost_term (voted_for kept), schedules delayed election */
raft_on_leader_lost(&s); raft_on_leader_lost(&s);
TEST_ASSERT_EQUAL_INT(-1, s.voted_for); TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
@ -6493,7 +6602,7 @@ static void test_proto_F2_voted_during_delay_skip(void)
TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc); TEST_ASSERT_EQUAL_INT(RAFT_GRANT, rc);
TEST_ASSERT_EQUAL_INT(0, s.voted_for); TEST_ASSERT_EQUAL_INT(0, s.voted_for);
/* Timer fires: check-and-start sees voted_for=0 != self_idx=1 → skip */ /* Timer fires: check-and-start sees term 1 != leader_lost_term 0 → skip */
int check_rc = raft_check_and_start_election(&s); int check_rc = raft_check_and_start_election(&s);
TEST_ASSERT_EQUAL_INT(RAFT_SKIP_ELECTION, check_rc); TEST_ASSERT_EQUAL_INT(RAFT_SKIP_ELECTION, check_rc);
@ -6524,7 +6633,7 @@ static void test_proto_F3_no_vote_during_delay_starts_election(void)
cfg.cbs = &cbs; cfg.cbs = &cbs;
TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg)); TEST_ASSERT_EQUAL_INT(0, raft_init(&s, &cfg));
/* Leader lost: voted_for cleared to -1 */ /* Leader lost (voted_for kept; it was -1) */
raft_on_leader_lost(&s); raft_on_leader_lost(&s);
TEST_ASSERT_EQUAL_INT(-1, s.voted_for); TEST_ASSERT_EQUAL_INT(-1, s.voted_for);
@ -6539,11 +6648,11 @@ static void test_proto_F3_no_vote_during_delay_starts_election(void)
* test_proto_F4_simultaneous_rdhup_one_leader * test_proto_F4_simultaneous_rdhup_one_leader
* *
* Nodes 1 and 2 both receive RDHUP (leader/node 0 lost). * Nodes 1 and 2 both receive RDHUP (leader/node 0 lost).
* Both call raft_on_leader_lost → voted_for=-1 for both. * Both call raft_on_leader_lost (voted_for kept; -1 for both here).
* Node 1 (lower idx, lower priority) calls check_and_start first → ELECTION_STARTED. * Node 1 (lower idx, lower priority) calls check_and_start first → ELECTION_STARTED.
* Node 1 sends vote requests; deliver them to node 2. * Node 1 sends vote requests; deliver them to node 2.
* Node 2 grants node 1 (voted_for=1). * Node 2 grants node 1 (voted_for=1).
* Node 2 then calls check_and_start → RAFT_SKIP_ELECTION (voted_for=1 != self_idx=2). * Node 2 then calls check_and_start → RAFT_SKIP_ELECTION (term moved past leader_lost_term).
* Result: leader_count==1. * Result: leader_count==1.
*/ */
static void test_proto_F4_simultaneous_rdhup_one_leader(void) static void test_proto_F4_simultaneous_rdhup_one_leader(void)
@ -6574,7 +6683,7 @@ static void test_proto_F4_simultaneous_rdhup_one_leader(void)
/* Node 2 received node 1's vote request and granted it */ /* Node 2 received node 1's vote request and granted it */
TEST_ASSERT_EQUAL_INT(1, c.nodes[2].state.voted_for); TEST_ASSERT_EQUAL_INT(1, c.nodes[2].state.voted_for);
/* Node 2's timer fires: check_and_start sees voted_for=1 != self_idx=2 → skip */ /* Node 2's timer fires: its term moved (granted node 1's term) → skip */
int rc2 = raft_check_and_start_election(&c.nodes[2].state); int rc2 = raft_check_and_start_election(&c.nodes[2].state);
TEST_ASSERT_EQUAL_INT(RAFT_SKIP_ELECTION, rc2); TEST_ASSERT_EQUAL_INT(RAFT_SKIP_ELECTION, rc2);
@ -7538,6 +7647,63 @@ static void test_pre_vote_reply_5node_won_at_three(void)
TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count); TEST_ASSERT_EQUAL_INT(0, g_mock.become_follower_count);
} }
/*
* bug-1915 follow-up (defence in depth): a PreVote round is decided exactly
* once. After WON (or LOST) cleared pre_vote_in_progress, a reply from a peer
* that has not been counted yet must be a no-op — PENDING, tally untouched —
* mirroring raft_on_vote_reply's election_decided guard. Before the fix the
* late reply was tallied and, since pre_votes_granted never decreases, a late
* GRANT or DENY to a WON 3-node round read as WON a second time.
*/
static void test_pre_vote_reply_late_grant_after_won_is_noop(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, arm_pre_vote_round(&s, &cbs, 3, 0, 5));
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_WON, raft_on_pre_vote_reply(&s, 1, 1));
TEST_ASSERT_EQUAL_INT_MESSAGE(RAFT_PRE_VOTE_PENDING,
raft_on_pre_vote_reply(&s, 2, 1 /* late grant */),
"a reply to an already-decided PreVote round must not decide it again");
TEST_ASSERT_EQUAL_INT(2, s.pre_votes_granted); /* not tallied */
TEST_ASSERT_EQUAL_INT(0, s.pre_vote_replies[2]); /* not marked */
TEST_ASSERT_EQUAL_INT(0, s.pre_vote_in_progress);
}
static void test_pre_vote_reply_late_deny_after_won_is_noop(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, arm_pre_vote_round(&s, &cbs, 3, 0, 5));
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_WON, raft_on_pre_vote_reply(&s, 1, 1));
TEST_ASSERT_EQUAL_INT_MESSAGE(RAFT_PRE_VOTE_PENDING,
raft_on_pre_vote_reply(&s, 2, 0 /* late deny */),
"a late DENY must not re-read the old grant count as WON");
TEST_ASSERT_EQUAL_INT(2, s.pre_votes_granted);
TEST_ASSERT_EQUAL_INT(0, s.pre_vote_replies[2]);
}
/* No round ever started (fresh state): a stray reply is ignored, not tallied. */
static void test_pre_vote_reply_without_round_is_noop(void)
{
raft_state_t s;
raft_callbacks_t cbs = make_mock_cbs();
reset_mock();
TEST_ASSERT_EQUAL_INT(0, init_vote_node(&s, &cbs, 3, 0, 5));
TEST_ASSERT_EQUAL_INT(0, s.pre_vote_in_progress);
TEST_ASSERT_EQUAL_INT(RAFT_PRE_VOTE_PENDING,
raft_on_pre_vote_reply(&s, 1, 1));
TEST_ASSERT_EQUAL_INT(0, s.pre_vote_replies[1]);
TEST_ASSERT_EQUAL_INT(0, s.pre_vote_in_progress);
}
/* ====================================================================== * /* ====================================================================== *
* Task 6.1 — Raft safety-invariant assertions for the PreVote API * Task 6.1 — Raft safety-invariant assertions for the PreVote API
* *
@ -9210,8 +9376,11 @@ void test_raft(void)
/* raft_on_election_timeout, raft_on_leader_lost, raft_check_and_start_election (task 3.5) */ /* raft_on_election_timeout, raft_on_leader_lost, raft_check_and_start_election (task 3.5) */
RUN_TEST(test_election_timeout_undecided); RUN_TEST(test_election_timeout_undecided);
RUN_TEST(test_election_timeout_already_decided); RUN_TEST(test_election_timeout_already_decided);
RUN_TEST(test_leader_lost_clears_voted_for); RUN_TEST(test_leader_lost_keeps_voted_for);
RUN_TEST(test_leader_lost_same_term_vote_from_other_denied);
RUN_TEST(test_check_and_start_voted_for_other); RUN_TEST(test_check_and_start_voted_for_other);
RUN_TEST(test_check_and_start_after_leader_lost_same_term_starts);
RUN_TEST(test_check_and_start_skips_when_not_follower);
RUN_TEST(test_check_and_start_not_voted); RUN_TEST(test_check_and_start_not_voted);
RUN_TEST(test_check_and_start_voted_for_self); RUN_TEST(test_check_and_start_voted_for_self);
@ -9240,6 +9409,9 @@ void test_raft(void)
RUN_TEST(test_pre_vote_reply_lost_purity_state_unchanged); RUN_TEST(test_pre_vote_reply_lost_purity_state_unchanged);
RUN_TEST(test_pre_vote_reply_out_of_range_peer_ignored); RUN_TEST(test_pre_vote_reply_out_of_range_peer_ignored);
RUN_TEST(test_pre_vote_reply_5node_won_at_three); RUN_TEST(test_pre_vote_reply_5node_won_at_three);
RUN_TEST(test_pre_vote_reply_late_grant_after_won_is_noop);
RUN_TEST(test_pre_vote_reply_late_deny_after_won_is_noop);
RUN_TEST(test_pre_vote_reply_without_round_is_noop);
/* Raft safety-invariant assertions for the PreVote API (task 6.1, RN-1) */ /* Raft safety-invariant assertions for the PreVote API (task 6.1, RN-1) */
RUN_TEST(test_invariant_prevote_sequence_pure_no_persist); RUN_TEST(test_invariant_prevote_sequence_pure_no_persist);