diff --git a/src/controller/cluster.c b/src/controller/cluster.c index 41b24f58..d1512431 100644 --- a/src/controller/cluster.c +++ b/src/controller/cluster.c @@ -377,11 +377,19 @@ static int myreq_handler(vtask_t* task) uint64_t cand_tag = vrq ? vrq->config_tag : 0; uint64_t cand_lsn = vrq ? vrq->last_log_lsn : 0; uint64_t cand_term = vrq ? vrq->term : 0; - uint64_t my_tag = c->myconf->config_version_tag; - uint64_t my_lsn = (uint64_t)atomic_load_explicit( + uint64_t local_tag = c->myconf->config_version_tag; + uint64_t local_lsn = (uint64_t)atomic_load_explicit( &c->ha.applied_lsn, memory_order_acquire); - /* Freshness gate: deny if candidate is not at least as fresh as us. + /* Election-safety fix #2: the freshness floor is the max of our local + * state and our learned-committed cluster state, so we deny candidates + * that are outdated against what the cluster has already committed + * (not merely against our local config file / applied LSN). */ + uint64_t my_tag = 0, my_lsn = 0; + ha_vote_self_freshness(&c->ha, local_tag, local_lsn, &my_tag, &my_lsn); + + /* Freshness gate: deny if candidate is not at least as fresh as the + * effective floor. * (Req 2.4: config_tag must be ≥ ours; if equal, log_lsn must be ≥ ours.) */ int freshness_ok = !(cand_tag < my_tag || (cand_tag == my_tag && cand_lsn < my_lsn)); diff --git a/src/controller/ha.c b/src/controller/ha.c index 2618909b..f272d1e8 100644 --- a/src/controller/ha.c +++ b/src/controller/ha.c @@ -1982,6 +1982,30 @@ static int ha_heartbeat_is_fresh(ha_state_t *ha) return ms < (long)ha->config.heartbeat_timeout_ms; } +/* ── Voter freshness floor (election safety) ─────────────────────────────── + * + * Compute the voter's effective freshness floor as the per-field max of its + * local state and its learned-committed cluster state. Outdated candidates + * (below this floor) are denied so the cluster can never be rolled back below + * what it has already committed. See ha.h for full contract. + */ +void ha_vote_self_freshness(ha_state_t *ha, + uint64_t local_config_tag, + uint64_t local_applied_lsn, + uint64_t *out_tag, + uint64_t *out_lsn) +{ + uint64_t learned_tag = + atomic_load_explicit(&ha->committed_config_tag, memory_order_acquire); + uint64_t learned_lsn = + atomic_load_explicit(&ha->last_known_committed_lsn, memory_order_acquire); + + if (out_tag) + *out_tag = (local_config_tag > learned_tag) ? local_config_tag : learned_tag; + if (out_lsn) + *out_lsn = (local_applied_lsn > learned_lsn) ? local_applied_lsn : learned_lsn; +} + /* ── Vote Handler (Req 2.2–2.6, 6.1, 6.2, 6.4, 9.1) ──────────────────────── * * handle_request_vote: process an incoming OP_requestVote RPC. @@ -2073,15 +2097,20 @@ int handle_request_vote(ha_state_t *ha, return 0; } - /* Req 2.4: freshness gate - * Deny if candidate has a lower config_tag, or equal config_tag with lower LSN. - * Grant if candidate is strictly fresher or equally fresh. - */ - if (config_tag < my_config_tag) { + /* Req 2.4 + election-safety fix #2: freshness gate against the EFFECTIVE + * floor — the max of the voter's local state and its learned-committed + * cluster state. A candidate that is below what the cluster has already + * committed (config_tag or, at equal tag, LSN) would roll the cluster back + * and must be denied even when this voter's local state is colder. + * Deny if candidate has a lower config_tag, or equal config_tag with lower + * LSN; grant if strictly fresher or equally fresh. */ + uint64_t eff_tag = 0, eff_lsn = 0; + ha_vote_self_freshness(ha, my_config_tag, my_last_lsn, &eff_tag, &eff_lsn); + if (config_tag < eff_tag) { pthread_mutex_unlock(&ha->lock); return 0; } - if (config_tag == my_config_tag && last_lsn < my_last_lsn) { + if (config_tag == eff_tag && last_lsn < eff_lsn) { pthread_mutex_unlock(&ha->lock); return 0; } @@ -3423,6 +3452,34 @@ void ha_on_primary_ctrl_fd_rdhup(ha_state_t *ha, cluster_t *c) if (committed > 0) persist_truncate_wal_above(&c->persist, committed); + /* Election-SAFETY guard (ha-config-bootstrap Req 2.8 / ha-repl-hardening + * Req 10.5): a config-less node (config_version_tag == 0) must NEVER start + * an RDHUP re-election while a configured peer exists. Two such nodes would + * grant each other (the freshness gate only denies a LOWER tag; tag0==tag0 + * with equal lsn is granted), forming a majority and electing a config-less + * primary over the node that actually holds the config — which then pushes + * an empty config and breaks replication. + * + * Mirror the proven ha_start_election_async voter-only guard: skip the + * elect_ctx alloc + the election trigger (raft_on_leader_lost) entirely and + * stay a passive voter (CTRL_STANDBY). handle_request_vote still GRANTS + * incoming vote requests to the fresher configured peer regardless of role, + * and this node transitions/fetches config when the configured winner + * connects and pushes config via ctrlhello. + * + * Single-node (candidates_count == 1) is unaffected: n_peers > 1 is false, + * so a lone node still self-elects regardless of tag. */ + uint64_t my_config_tag = (c->myconf) ? c->myconf->config_version_tag : 0; + int n_peers_guard = (c->myconf) ? c->myconf->peers_cfg.candidates_count : 1; + if (my_config_tag == 0 && n_peers_guard > 1) { + INFO("ha_on_primary_ctrl_fd_rdhup: no local config (tag=0, n_peers=%d) — " + "voter only, not starting RDHUP re-election; awaiting configured " + "primary (ha-config-bootstrap Req 2.8)", n_peers_guard); + atomic_store_explicit(&ha->role, (int32_t)CTRL_STANDBY, + memory_order_release); + return; + } + /* Task 6.3 / 6.7 fix: delegate RDHUP election trigger to raft library. * * raft_on_leader_lost: diff --git a/src/controller/ha.h b/src/controller/ha.h index 2d3c0ad9..10e2a824 100644 --- a/src/controller/ha.h +++ b/src/controller/ha.h @@ -657,6 +657,27 @@ int handle_request_vote(ha_state_t *ha, int in_catch_up_flag); #endif +/* + * ha_vote_self_freshness: compute the voter's effective freshness floor. + * + * Election safety: a voter must deny candidates that are outdated against the + * strongest knowledge it has of the cluster's COMMITTED state — not merely its + * own local config file / applied LSN. The floor is the per-field max of the + * voter's local state and its learned-committed state: + * + * *out_tag = max(local_config_tag, atomic committed_config_tag) + * *out_lsn = max(local_applied_lsn, atomic last_known_committed_lsn) + * + * Uses acquire loads of the atomic learned-committed fields; never reads the + * non-atomic last_known_committed_config_tag (data-race safe). Pure aside from + * the atomic reads — no state mutation. + */ +void ha_vote_self_freshness(ha_state_t *ha, + uint64_t local_config_tag, + uint64_t local_applied_lsn, + uint64_t *out_tag, + uint64_t *out_lsn); + /* * ha_elect_evaluate_and_signal: evaluate accumulated ha_elect_ctx_t votes. * Sets ctx->result (1=primary, 0=standby) and posts ctx->done semaphore. diff --git a/src/controller/testsuite/test_ha_failover.c b/src/controller/testsuite/test_ha_failover.c index 8d931d0b..8008fa68 100644 --- a/src/controller/testsuite/test_ha_failover.c +++ b/src/controller/testsuite/test_ha_failover.c @@ -291,6 +291,14 @@ static void test_rdhup_calls_start_election_async(void) myconf_t myconf; memset(&myconf, 0, sizeof(myconf)); myconf.name = "ctrl-a"; + /* Configured node: a real (nonzero) config tag so the no-config voter-only + * RDHUP guard does NOT trip — this test exercises the legitimate "a + * CONFIGURED node re-elects after RDHUP" path (ha-config-bootstrap Req 2.8). + * Without a tag, config_version_tag==0 with candidates_count>1 would (post + * election-safety fix) keep the node a passive voter (CTRL_STANDBY) and the + * election hook would never fire — that was the buggy condition this test + * incidentally relied on. */ + myconf.config_version_tag = 0x1234; myconf.peers_cfg.candidates_count = 2; snprintf(myconf.peers_cfg.candidates[0].name, sizeof(myconf.peers_cfg.candidates[0].name), "ctrl-a"); diff --git a/src/controller/testsuite/test_ha_rdhup_integration.c b/src/controller/testsuite/test_ha_rdhup_integration.c index 0e4d2245..b8b4e634 100644 --- a/src/controller/testsuite/test_ha_rdhup_integration.c +++ b/src/controller/testsuite/test_ha_rdhup_integration.c @@ -59,6 +59,12 @@ static int stub_ctrlhello(void *c) { (void)c; g_ctrlhello_calls++; return 0; * longer call it (production ha_elect_evaluate_and_signal now lands the winner * directly per task 5.1 territory). */ +/* A real (nonzero) config tag, so a node is treated as CONFIGURED and is + * allowed to start an RDHUP re-election. The no-config voter-only guard in + * ha_on_primary_ctrl_fd_rdhup only trips for config_version_tag == 0 with + * candidates_count > 1 (ha-config-bootstrap Req 2.8 / ha-repl-hardening Req 10.5). */ +#define TEST_CONFIG_TAG 0x1234 + /* ── Shared HA config ───────────────────────────────────────────────────── */ static ha_config_t g_cfg = { @@ -144,6 +150,11 @@ static void make_cluster(cluster_t *c, myconf_t *myconf, const char *my_name) memset(c, 0, sizeof(*c)); memset(myconf, 0, sizeof(*myconf)); myconf->name = (char *)my_name; + /* Configured node by default: a real (nonzero) tag so the no-config + * voter-only RDHUP guard does NOT trip — these fixtures exercise the + * legitimate "a CONFIGURED node re-elects after RDHUP" path. The + * config-less guard is exercised by its own dedicated test below. */ + myconf->config_version_tag = TEST_CONFIG_TAG; myconf->peers_cfg.candidates_count = 2; snprintf(myconf->peers_cfg.candidates[0].name, sizeof(myconf->peers_cfg.candidates[0].name), "%s", my_name); @@ -373,6 +384,118 @@ static void test_rdhup_primary_dispatch_gate_opens(void) printf("test_rdhup_primary_dispatch_gate_opens: PASS (gate accepts job ops)\n"); } +/* ═══════════════════════════════════════════════════════════════════════════ + * Test 7: config-less node (tag=0) with peers MUST NOT start an RDHUP re-election + * + * Election-SAFETY (ha-config-bootstrap Req 2.8 / ha-repl-hardening Req 10.5): + * a node with no local config (config_version_tag == 0) and >1 candidates must + * NOT win an election while a configured peer exists. ha_on_primary_ctrl_fd_rdhup + * must mirror the ha_start_election_async voter-only guard: skip the elect_ctx + * alloc + the election trigger entirely and stay a passive voter (CTRL_STANDBY). + * + * Asserted via the ha_rdhup_election_hook seam: the hook must NOT fire (count==0), + * and role must be CTRL_STANDBY (not CTRL_ELECTING / CTRL_PRIMARY). + * Before the fix this FAILS: the hook fires and the election proceeds. + * ═════════════════════════════════════════════════════════════════════════ */ +static int g_guard_hook_calls = 0; + +static void counting_election_hook(ha_state_t *ha, cluster_t *c, + int n_peers, xce_candidate_t **peers, + uint64_t my_config_tag, uint64_t my_lsn, + const char *my_name) +{ + (void)ha; (void)c; (void)n_peers; (void)peers; + (void)my_config_tag; (void)my_lsn; (void)my_name; + g_guard_hook_calls++; +} + +static void test_rdhup_noconfig_node_does_not_elect(void) +{ + install_stubs(); + g_guard_hook_calls = 0; + + cluster_t c; + myconf_t myconf; + make_cluster(&c, &myconf, "ctrl-a"); + /* Config-less node: no local config tag, but part of a >1 node cluster. */ + myconf.config_version_tag = 0; + myconf.peers_cfg.candidates_count = 2; + + TEST_ASSERT_EQUAL_INT(0, ha_init(&c.ha, &g_cfg, 0)); + ha_do_demote(&c.ha); + c.ha.phase2_done = 1; + c.ha.n_peers = 1; + + ha_rdhup_election_hook = counting_election_hook; + + int sv[2]; + TEST_ASSERT_EQUAL_INT(0, socketpair(AF_UNIX, SOCK_STREAM, 0, sv)); + c.ha.primary_ctrl_fd = sv[0]; + + ha_on_primary_ctrl_fd_rdhup(&c.ha, &c); + + clear_stubs(); + close(sv[1]); + + /* The election hook must NOT have fired — no re-election started. */ + TEST_ASSERT_EQUAL_INT_MESSAGE(0, g_guard_hook_calls, + "config-less node (tag=0, peers>1) must NOT start an RDHUP re-election " + "(ha-config-bootstrap Req 2.8)"); + + /* Role must be CTRL_STANDBY — a passive voter awaiting the configured primary. */ + int role = (int)atomic_load_explicit(&c.ha.role, memory_order_acquire); + TEST_ASSERT_EQUAL_INT_MESSAGE((int)CTRL_STANDBY, role, + "config-less RDHUP node must stay CTRL_STANDBY (voter only), not ELECTING/PRIMARY"); + + ha_fini(&c.ha); + printf("test_rdhup_noconfig_node_does_not_elect: PASS " + "(hook_calls=%d, role=%d==CTRL_STANDBY)\n", g_guard_hook_calls, role); +} + +/* ═══════════════════════════════════════════════════════════════════════════ + * Test 8 (contrast / non-regression): a CONFIGURED node (tag!=0) with peers + * MUST still start the RDHUP re-election. + * + * Same shape as Test 7 but with a real config tag — the guard must NOT trip. + * The election hook MUST fire exactly once. + * ═════════════════════════════════════════════════════════════════════════ */ +static void test_rdhup_configured_node_still_elects(void) +{ + install_stubs(); + g_guard_hook_calls = 0; + + cluster_t c; + myconf_t myconf; + make_cluster(&c, &myconf, "ctrl-a"); + /* Configured node: real tag, >1 candidates — guard must NOT trip. */ + myconf.config_version_tag = TEST_CONFIG_TAG; + myconf.peers_cfg.candidates_count = 2; + + TEST_ASSERT_EQUAL_INT(0, ha_init(&c.ha, &g_cfg, 0)); + ha_do_demote(&c.ha); + c.ha.phase2_done = 1; + c.ha.n_peers = 1; + + ha_rdhup_election_hook = counting_election_hook; + + int sv[2]; + TEST_ASSERT_EQUAL_INT(0, socketpair(AF_UNIX, SOCK_STREAM, 0, sv)); + c.ha.primary_ctrl_fd = sv[0]; + + ha_on_primary_ctrl_fd_rdhup(&c.ha, &c); + + clear_stubs(); + close(sv[1]); + + /* Configured node: the election hook MUST fire (re-election proceeds). */ + TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_guard_hook_calls, + "configured node (tag!=0, peers>1) must still start RDHUP re-election"); + + ha_fini(&c.ha); + printf("test_rdhup_configured_node_still_elects: PASS (hook_calls=%d)\n", + g_guard_hook_calls); +} + /* ── Suite entry point ──────────────────────────────────────────────────── */ void test_ha_rdhup_integration(void) @@ -385,4 +508,8 @@ void test_ha_rdhup_integration(void) * ack-counting promotion machinery removed, no shim) */ RUN_TEST(test_rdhup_promotion_fires_replication_hooks); RUN_TEST(test_rdhup_primary_dispatch_gate_opens); + /* Election-safety guard: config-less node must not re-elect; configured + * node still does (ha-config-bootstrap Req 2.8 / ha-repl-hardening Req 10.5). */ + RUN_TEST(test_rdhup_noconfig_node_does_not_elect); + RUN_TEST(test_rdhup_configured_node_still_elects); } diff --git a/src/controller/testsuite/test_ha_vote_gate.c b/src/controller/testsuite/test_ha_vote_gate.c index 281f78cd..d2f66bb6 100644 --- a/src/controller/testsuite/test_ha_vote_gate.c +++ b/src/controller/testsuite/test_ha_vote_gate.c @@ -35,6 +35,15 @@ int handle_request_vote(ha_state_t *ha, uint64_t my_last_lsn, int in_catch_up_flag); +/* Pure helper under test (election-safety fix #2): computes the voter's + * effective freshness floor as the max of its local state and its learned + * COMMITTED cluster state. */ +void ha_vote_self_freshness(ha_state_t *ha, + uint64_t local_config_tag, + uint64_t local_applied_lsn, + uint64_t *out_tag, + uint64_t *out_lsn); + /* ── Shared config for vote gate tests ──────────────────────────────────── */ static ha_config_t g_gate_cfg = { @@ -229,6 +238,199 @@ static void test_electing_role_evaluates_vote(void) printf("test_electing_role_evaluates_vote: PASS\n"); } +/* ══ Election-safety fix #2: deny candidates outdated vs LEARNED committed state ══ + * + * A voter must measure candidate freshness against the strongest knowledge it + * has of the cluster's COMMITTED state — not merely its own local config file / + * applied LSN. ha->committed_config_tag and ha->last_known_committed_lsn carry + * that learned-committed floor (from prior primary heartbeats / quorum ACK / + * post-election safe point). The gate floor is max(local, learned). */ + +/* ── Direct unit test of ha_vote_self_freshness (max logic both fields) ───── */ + +static void test_vote_self_freshness_takes_max_of_local_and_learned(void) +{ + ha_state_t ha; + ha_init(&ha, &g_gate_cfg, 0); + + /* learned-committed > local on tag, local > learned on lsn → expect the + * per-field max of each. */ + atomic_store_explicit(&ha.committed_config_tag, 100ULL, memory_order_release); + atomic_store_explicit(&ha.last_known_committed_lsn, 20ULL, memory_order_release); + + uint64_t out_tag = 0, out_lsn = 0; + ha_vote_self_freshness(&ha, 40ULL /*local tag*/, 70ULL /*local lsn*/, + &out_tag, &out_lsn); + + TEST_ASSERT_EQUAL_UINT64_MESSAGE(100ULL, out_tag, + "freshness floor tag must be max(local=40, learned=100)"); + TEST_ASSERT_EQUAL_UINT64_MESSAGE(70ULL, out_lsn, + "freshness floor lsn must be max(local=70, learned=20)"); + + ha_fini(&ha); + printf("test_vote_self_freshness_takes_max_of_local_and_learned: PASS\n"); +} + +/* ── RED: learned-committed config_tag floor denies an outdated candidate ─── */ + +static void test_learned_committed_tag_denies_outdated_candidate(void) +{ + /* Voter local config is cold (my_config_tag=0) but it has LEARNED that the + * cluster committed config_tag=100. A candidate offering config_tag=50 + * would roll the cluster back below the committed floor → must be DENIED. + * + * Before the fix the gate used the raw local my_config_tag=0, so cand=50 ≥ 0 + * granted (RED). After the fix the floor is max(0,100)=100 → deny (GREEN). */ + ha_state_t ha; + ha_init(&ha, &g_gate_cfg, 0); + atomic_store_explicit(&ha.role, (int32_t)CTRL_STANDBY, memory_order_release); + atomic_store_explicit(&ha.term, 1ULL, memory_order_release); + ha.voted_for_term = 0; + /* No active/fresh primary connection, not in catch-up → role gate opens. */ + ha.primary_ctrl_fd = -1; + set_last_activity_age(&ha, 60000L); /* stale */ + + /* Learned committed floor: config_tag=100. */ + atomic_store_explicit(&ha.committed_config_tag, 100ULL, memory_order_release); + + int granted = handle_request_vote(&ha, + 1ULL, + "ctrl-stale", + 50ULL, /* candidate config_tag — below learned floor */ + 999ULL, /* high lsn — irrelevant once tag is below */ + 0ULL, /* my local config_tag (cold) */ + 0ULL, /* my local applied lsn (cold) */ + 0); + + TEST_ASSERT_EQUAL_INT_MESSAGE(0, granted, + "candidate below learned-committed config_tag floor must be DENIED"); + + ha_fini(&ha); + printf("test_learned_committed_tag_denies_outdated_candidate: PASS\n"); +} + +/* ── RED: equal-tag, lower learned-committed LSN denies ──────────────────── */ + +static void test_learned_committed_lsn_denies_lower_lsn_candidate(void) +{ + /* Voter has learned the cluster committed up to LSN=100 (tag stays 0). + * Candidate matches the effective tag but offers last_lsn=50 < 100 → + * would lose committed entries → must be DENIED. */ + ha_state_t ha; + ha_init(&ha, &g_gate_cfg, 0); + atomic_store_explicit(&ha.role, (int32_t)CTRL_STANDBY, memory_order_release); + atomic_store_explicit(&ha.term, 1ULL, memory_order_release); + ha.voted_for_term = 0; + ha.primary_ctrl_fd = -1; + set_last_activity_age(&ha, 60000L); /* stale */ + + /* Learned committed floor: lsn=100 (tag unchanged at 0). */ + atomic_store_explicit(&ha.last_known_committed_lsn, 100ULL, memory_order_release); + + int granted = handle_request_vote(&ha, + 1ULL, + "ctrl-laggard", + 0ULL, /* cand tag == effective tag (0) */ + 50ULL, /* cand lsn < learned floor (100) */ + 0ULL, /* my local config_tag */ + 0ULL, /* my local applied lsn (cold) */ + 0); + + TEST_ASSERT_EQUAL_INT_MESSAGE(0, granted, + "equal-tag candidate below learned-committed LSN floor must be DENIED"); + + ha_fini(&ha); + printf("test_learned_committed_lsn_denies_lower_lsn_candidate: PASS\n"); +} + +/* ── Liveness: an up-to-date candidate is still GRANTED ──────────────────── */ + +static void test_uptodate_candidate_still_granted_with_learned_floor(void) +{ + /* The freshest node must still be able to win — no liveness regression. + * Candidate meets the learned floor exactly (tag=100, lsn=100). */ + ha_state_t ha; + ha_init(&ha, &g_gate_cfg, 0); + atomic_store_explicit(&ha.role, (int32_t)CTRL_STANDBY, memory_order_release); + atomic_store_explicit(&ha.term, 1ULL, memory_order_release); + ha.voted_for_term = 0; + ha.primary_ctrl_fd = -1; + set_last_activity_age(&ha, 60000L); /* stale */ + + atomic_store_explicit(&ha.committed_config_tag, 100ULL, memory_order_release); + atomic_store_explicit(&ha.last_known_committed_lsn, 100ULL, memory_order_release); + + int granted = handle_request_vote(&ha, + 1ULL, + "ctrl-fresh", + 100ULL, /* meets learned tag floor */ + 100ULL, /* meets learned lsn floor */ + 0ULL, /* local cold */ + 0ULL, + 0); + + TEST_ASSERT_EQUAL_INT_MESSAGE(1, granted, + "candidate meeting the learned-committed floor must still be GRANTED (liveness)"); + + ha_fini(&ha); + printf("test_uptodate_candidate_still_granted_with_learned_floor: PASS\n"); +} + +/* ── Backward-compat: no learned state (cold) falls back to local-only gate ─ */ + +static void test_no_learned_state_falls_back_to_local_gate(void) +{ + /* committed_config_tag=0 and last_known_committed_lsn=0 → floor is the raw + * local state, so behaviour is identical to the original local-only gate. + * Local floor tag=100/lsn=50; candidate is below it → deny (unchanged). */ + ha_state_t ha; + ha_init(&ha, &g_gate_cfg, 0); + atomic_store_explicit(&ha.role, (int32_t)CTRL_STANDBY, memory_order_release); + atomic_store_explicit(&ha.term, 1ULL, memory_order_release); + ha.voted_for_term = 0; + ha.primary_ctrl_fd = -1; + set_last_activity_age(&ha, 60000L); /* stale */ + + /* No learned state. */ + atomic_store_explicit(&ha.committed_config_tag, 0ULL, memory_order_release); + atomic_store_explicit(&ha.last_known_committed_lsn, 0ULL, memory_order_release); + + int denied = handle_request_vote(&ha, + 1ULL, + "ctrl-old", + 99ULL, /* below local tag 100 */ + 999ULL, + 100ULL, /* local tag */ + 50ULL, /* local lsn */ + 0); + TEST_ASSERT_EQUAL_INT_MESSAGE(0, denied, + "with no learned state the gate must behave as the local-only gate (deny lower tag)"); + + /* And an equally-fresh candidate (local-only) still grants. */ + ha_state_t ha2; + ha_init(&ha2, &g_gate_cfg, 0); + atomic_store_explicit(&ha2.role, (int32_t)CTRL_STANDBY, memory_order_release); + atomic_store_explicit(&ha2.term, 1ULL, memory_order_release); + ha2.voted_for_term = 0; + ha2.primary_ctrl_fd = -1; + set_last_activity_age(&ha2, 60000L); + + int granted = handle_request_vote(&ha2, + 1ULL, + "ctrl-eq", + 100ULL, /* == local tag */ + 50ULL, /* == local lsn */ + 100ULL, + 50ULL, + 0); + TEST_ASSERT_EQUAL_INT_MESSAGE(1, granted, + "with no learned state an equally-fresh candidate still grants (local-only)"); + + ha_fini(&ha2); + ha_fini(&ha); + printf("test_no_learned_state_falls_back_to_local_gate: PASS\n"); +} + /* ── Test suite entry point ───────────────────────────────────────────────── */ void test_ha_vote_gate(void) @@ -247,4 +449,11 @@ void test_ha_vote_gate(void) /* Req 6.4 — ELECTING evaluates */ RUN_TEST(test_electing_role_evaluates_vote); + + /* Election-safety fix #2 — learned-committed freshness floor */ + RUN_TEST(test_vote_self_freshness_takes_max_of_local_and_learned); + RUN_TEST(test_learned_committed_tag_denies_outdated_candidate); + RUN_TEST(test_learned_committed_lsn_denies_lower_lsn_candidate); + RUN_TEST(test_uptodate_candidate_still_granted_with_learned_floor); + RUN_TEST(test_no_learned_state_falls_back_to_local_gate); }