/* * test_ha_vote_send.c — Unity tests for task 2.2: vote_ep_ctx_t and * on_vote_connected callback (election-iomgr-refactor spec). * * Observable: unit test with mock endpoint verifies xce_request called with * correct opcode and fields after on_vote_connected fires. * * Strategy: XCE_TESTING test seam — xce_vote_request_hook captures the call * to xce_request from on_vote_connected, letting us inspect the xce_request_t * without touching the real iomgr/endpoint_enq path. * * Req coverage: 2.3 (send vote after handshake), 1.2 (vote RPC via iomgr path) */ #define _GNU_SOURCE #include #include #include #include #include #include #include #include "ha.h" #include "netio.h" #include "controller.capnp.h" /* ── Forward declarations for functions under test ───────────────────────── * * vote_ep_ctx_t, on_vote_connected, election_timeout_ctx_t are defined in * ha.c (file-scoped under XCE_TESTING preprocessor for the hook; the structs * and callback are static but the hook pointer is exposed). * * on_vote_connected is static — we call it indirectly by setting ep->on_connected * and invoking it through the endpoint pointer, but since we can't extern a static * function, we test via the hook seam exclusively: set xce_vote_request_hook and * invoke on_vote_connected by address via the function pointer stored in ep->on_connected. * * vote_ep_ctx_t_build: test helper exposed only under XCE_TESTING that allocates * and fills a vote_ep_ctx_t, sets ep->on_connected, ep->data, so the test can * call ep->on_connected(ep) to drive on_vote_connected. */ /* Test seam: pointer is declared in ha.c under XCE_TESTING and exposed here */ extern int (*xce_vote_request_hook)(endpoint_t *ep, xce_request_t *req); /* vote_ep_ctx_build: test helper exposed from ha.c under XCE_TESTING. * Allocates a vote_ep_ctx_t, fills it from the parameters, sets ep->data and * ep->on_connected. Caller owns the returned pointer. */ typedef struct vote_ep_ctx_s vote_ep_ctx_t; /* defined in ha.c — opaque here */ extern endpoint_t *vote_ep_ctx_build_for_test(endpoint_t *ep, ha_state_t *ha, ha_elect_ctx_t *ctx, int peer_idx, uint64_t my_term, uint64_t my_tag, uint64_t my_lsn, const char *my_name); /* ── Minimal ha_config_t for tests ─────────────────────────────────────── */ static ha_config_t g_vote_cfg = { .heartbeat_interval_ms = 3000, .heartbeat_timeout_ms = 10000, .election_timeout_ms = 2000, .probe_connect_timeout_ms = 500, .max_promotion_retries = 5, .election_jitter_max_ms = 0, .election_priority_step_ms = 500, }; /* ── Mock xce_request hook ──────────────────────────────────────────────── */ static int g_hook_call_count = 0; static endpoint_t *g_hook_ep = NULL; static uint32_t g_hook_opcode = 0; static int g_hook_body_which = 0; static uint64_t g_captured_term = 0; static uint64_t g_captured_tag = 0; static uint64_t g_captured_lsn = 0; static char g_captured_name[256] = {0}; static int mock_vote_request_hook(endpoint_t *ep, xce_request_t *req) { g_hook_call_count++; g_hook_ep = ep; g_hook_opcode = req->opcode; g_hook_body_which = req->body_which; if (req->body_which == Request_body_requestvotemsg && req->body.requestvotemsg != NULL) { g_captured_term = req->body.requestvotemsg->term; g_captured_tag = req->body.requestvotemsg->config_tag; g_captured_lsn = req->body.requestvotemsg->last_log_lsn; if (req->body.requestvotemsg->candidate_id) strncpy(g_captured_name, req->body.requestvotemsg->candidate_id, sizeof(g_captured_name) - 1); } return 0; } static void reset_mock(void) { g_hook_call_count = 0; g_hook_ep = NULL; g_hook_opcode = 0; g_hook_body_which = 0; g_captured_term = 0; g_captured_tag = 0; g_captured_lsn = 0; memset(g_captured_name, 0, sizeof(g_captured_name)); xce_vote_request_hook = mock_vote_request_hook; } /* ── Helper: make a minimal zeroed endpoint_t on the stack ─────────────── */ static void make_endpoint(endpoint_t *ep) { memset(ep, 0, sizeof(*ep)); ep->fd = -1; } /* ── Helper: allocate a minimal ha_elect_ctx_t ─────────────────────────── */ static ha_elect_ctx_t *alloc_ctx_for_vote(int n_peers) { ha_elect_ctx_t *ctx = calloc(1, sizeof(*ctx) + (size_t)n_peers * sizeof(ctx->votes[0])); if (!ctx) { fprintf(stderr, "alloc_ctx_for_vote: calloc failed\n"); exit(1); } ctx->n_peers = n_peers; ctx->votes_granted = 1; /* self-vote */ sem_init(&ctx->done, 0, 0); return ctx; } static void free_ctx_for_vote(ha_elect_ctx_t *ctx) { sem_destroy(&ctx->done); free(ctx); } /* ── Test: on_vote_connected calls hook with OPCode_requestVote ─────────── */ static void test_on_vote_connected_calls_hook_with_correct_opcode(void) { reset_mock(); ha_state_t ha; ha_init(&ha, &g_vote_cfg, 0); atomic_store_explicit(&ha.term, 3ULL, memory_order_release); ha_elect_ctx_t *ctx = alloc_ctx_for_vote(2); endpoint_t ep; make_endpoint(&ep); /* vote_ep_ctx_build_for_test sets ep->data and ep->on_connected */ vote_ep_ctx_build_for_test(&ep, &ha, ctx, 0, /* peer_idx */ 3ULL, /* my_term */ 100ULL, /* my_tag */ 50ULL, /* my_lsn */ "ctrl-a"); TEST_ASSERT_NOT_NULL_MESSAGE(ep.on_connected, "ep->on_connected must be set by vote_ep_ctx_build_for_test"); /* Fire on_vote_connected */ ep.on_connected(&ep); /* Verify hook was called exactly once */ TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_hook_call_count, "xce_vote_request_hook must be called exactly once by on_vote_connected"); /* Verify opcode */ TEST_ASSERT_EQUAL_UINT32_MESSAGE(OPCode_requestVote, g_hook_opcode, "xce_request must be called with opcode OPCode_requestVote"); /* Verify body_which */ TEST_ASSERT_EQUAL_INT_MESSAGE(Request_body_requestvotemsg, g_hook_body_which, "body_which must be Request_body_requestvotemsg"); /* Verify endpoint pointer passed through */ TEST_ASSERT_EQUAL_PTR_MESSAGE(&ep, g_hook_ep, "hook must receive the same endpoint pointer that on_vote_connected received"); /* Cleanup: ep.data was allocated by vote_ep_ctx_build_for_test; free it */ free(ep.data); free_ctx_for_vote(ctx); ha_fini(&ha); printf("test_on_vote_connected_calls_hook_with_correct_opcode: PASS\n"); } /* ── Test: on_vote_connected encodes correct term, tag, lsn, name ────────── */ static void test_on_vote_connected_encodes_correct_fields(void) { reset_mock(); ha_state_t ha; ha_init(&ha, &g_vote_cfg, 1); atomic_store_explicit(&ha.term, 7ULL, memory_order_release); ha_elect_ctx_t *ctx = alloc_ctx_for_vote(2); endpoint_t ep; make_endpoint(&ep); vote_ep_ctx_build_for_test(&ep, &ha, ctx, 1, /* peer_idx */ 7ULL, /* my_term */ 200ULL, /* my_tag */ 99ULL, /* my_lsn */ "ctrl-b"); ep.on_connected(&ep); TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_hook_call_count, "hook must be called exactly once"); TEST_ASSERT_EQUAL_UINT64_MESSAGE(7ULL, g_captured_term, "request_vote_req_t.term must match my_term from vote_ep_ctx_t"); TEST_ASSERT_EQUAL_UINT64_MESSAGE(200ULL, g_captured_tag, "request_vote_req_t.config_tag must match my_tag from vote_ep_ctx_t"); TEST_ASSERT_EQUAL_UINT64_MESSAGE(99ULL, g_captured_lsn, "request_vote_req_t.last_log_lsn must match my_lsn from vote_ep_ctx_t"); TEST_ASSERT_EQUAL_STRING_MESSAGE("ctrl-b", g_captured_name, "request_vote_req_t.candidate_id must match my_name from vote_ep_ctx_t"); free(ep.data); free_ctx_for_vote(ctx); ha_fini(&ha); printf("test_on_vote_connected_encodes_correct_fields: PASS\n"); } /* ── Test: on_vote_connected sets ep->msg_handler after sending ─────────── */ static void test_on_vote_connected_sets_msg_handler(void) { reset_mock(); ha_state_t ha; ha_init(&ha, &g_vote_cfg, 0); atomic_store_explicit(&ha.term, 1ULL, memory_order_release); ha_elect_ctx_t *ctx = alloc_ctx_for_vote(2); endpoint_t ep; make_endpoint(&ep); vote_ep_ctx_build_for_test(&ep, &ha, ctx, 0, 1ULL, 100ULL, 50ULL, "ctrl-a"); /* msg_handler starts NULL */ TEST_ASSERT_NULL_MESSAGE(ep.msg_handler, "msg_handler must be NULL before on_vote_connected fires"); ep.on_connected(&ep); /* After on_vote_connected fires, ep->msg_handler must be set to * vote_reply_msg_handler (forward-declared in 2.3; non-NULL here) */ TEST_ASSERT_NOT_NULL_MESSAGE(ep.msg_handler, "on_vote_connected must set ep->msg_handler to vote_reply_msg_handler"); free(ep.data); free_ctx_for_vote(ctx); ha_fini(&ha); printf("test_on_vote_connected_sets_msg_handler: PASS\n"); } /* ── Test: election_timeout_ctx_t cancelled flag prevents action ─────────── */ static void test_election_timeout_ctx_cancelled_flag(void) { /* election_timeout_ctx_t carries _Atomic int cancelled. * When cancelled == 1, the delaymgr callback must be a no-op. * We test this by directly calling the exported helper * election_timeout_fn_for_test (exposed under XCE_TESTING). */ extern int election_timeout_fn_for_test(ha_state_t *ha, ha_elect_ctx_t *ctx, int cancelled_val); ha_state_t ha; ha_init(&ha, &g_vote_cfg, 0); atomic_store_explicit(&ha.term, 1ULL, memory_order_release); ha_elect_ctx_t *ctx = alloc_ctx_for_vote(2); /* Set up so if evaluate fires, we'd know (done posted changes result) */ ctx->votes_granted = 1; /* below quorum — would yield STANDBY */ ctx->n_replied = 2; /* cancelled=1 → no evaluate → done NOT posted */ int rc = election_timeout_fn_for_test(&ha, ctx, /*cancelled=*/1); TEST_ASSERT_EQUAL_INT(0, rc); /* must return 0 (no-op) */ int try_rc = sem_trywait(&ctx->done); TEST_ASSERT_EQUAL_INT_MESSAGE(-1, try_rc, "cancelled election_timeout must NOT post ctx->done"); TEST_ASSERT_EQUAL_INT(EAGAIN, errno); /* cancelled=0 → evaluate runs → done posted */ ctx->done_posted = 0; /* reset guard */ ctx->votes[0].my_tag = 100ULL; ctx->votes[0].my_lsn = 50ULL; rc = election_timeout_fn_for_test(&ha, ctx, /*cancelled=*/0); TEST_ASSERT_EQUAL_INT(0, rc); try_rc = sem_trywait(&ctx->done); TEST_ASSERT_EQUAL_INT_MESSAGE(0, try_rc, "not-cancelled election_timeout must post ctx->done"); free_ctx_for_vote(ctx); ha_fini(&ha); printf("test_election_timeout_ctx_cancelled_flag: PASS\n"); } /* ── Task 2.3: vote_reply_msg_handler tests ───────────────────────────────── * * Test seam: xce_vote_reply_hook captures decoded fields without real iomgr. * vote_reply_build_pkt_for_test encodes a real capnp RequestVoteReply packet. * vote_reply_msg_handler_for_test sets ep->fsm_in.pkt and calls the handler. * * ASAN: handler calls free_packet()+free(pkt) before accessing vr_raw fields, * so there is no heap-use-after-free under AddressSanitizer. */ /* Forward declarations for task 2.3 test seams (defined in ha.c XCE_TESTING) */ extern int (*xce_vote_reply_hook)(ha_state_t *ha, ha_elect_ctx_t *ctx, int peer_idx, uint64_t term, int vote_granted, uint64_t peer_tag, uint64_t peer_lsn); extern io_packet_t *vote_reply_build_pkt_for_test(uint64_t term, int vote_granted, uint64_t peer_tag, uint64_t peer_lsn); extern int vote_reply_msg_handler_for_test(endpoint_t *ep, io_packet_t *pkt); /* ── Mock vote_reply_hook ───────────────────────────────────────────────── */ static int g_reply_hook_calls = 0; static uint64_t g_reply_term = 0; static int g_reply_granted = -1; static uint64_t g_reply_peer_tag = 0; static uint64_t g_reply_peer_lsn = 0; static int g_reply_peer_idx = -1; static int mock_vote_reply_hook(ha_state_t *ha, ha_elect_ctx_t *ctx, int peer_idx, uint64_t term, int vote_granted, uint64_t peer_tag, uint64_t peer_lsn) { (void)ha; (void)ctx; g_reply_hook_calls++; g_reply_peer_idx = peer_idx; g_reply_term = term; g_reply_granted = vote_granted; g_reply_peer_tag = peer_tag; g_reply_peer_lsn = peer_lsn; return 0; } static void reset_reply_mock(void) { g_reply_hook_calls = 0; g_reply_term = 0; g_reply_granted = -1; g_reply_peer_tag = 0; g_reply_peer_lsn = 0; g_reply_peer_idx = -1; xce_vote_reply_hook = mock_vote_reply_hook; } /* ── Helper: build a minimal vote endpoint with reply context ────────────── */ static void make_vote_reply_ep(endpoint_t *ep, ha_state_t *ha, ha_elect_ctx_t *ctx, int peer_idx) { memset(ep, 0, sizeof(*ep)); ep->fd = -1; /* Build and attach a vote_ep_ctx_t via the existing test helper */ vote_ep_ctx_build_for_test(ep, ha, ctx, peer_idx, atomic_load_explicit(&ha->term, memory_order_acquire), 100ULL, 50ULL, "ctrl-a"); } /* ── Test: handler decodes correct term, granted, tag, lsn ─────────────── */ static void test_vote_reply_handler_decodes_correct_fields(void) { reset_reply_mock(); ha_state_t ha; ha_init(&ha, &g_vote_cfg, 0); atomic_store_explicit(&ha.term, 5ULL, memory_order_release); ha_elect_ctx_t *ctx = alloc_ctx_for_vote(2); pthread_mutex_init(&ctx->lock, NULL); endpoint_t ep; make_vote_reply_ep(&ep, &ha, ctx, 0); /* Build a real capnp packet with known fields */ io_packet_t *pkt = vote_reply_build_pkt_for_test( 5ULL, /* term */ 1, /* vote_granted = true */ 200ULL, /* peer_tag */ 99ULL /* peer_lsn */); TEST_ASSERT_NOT_NULL_MESSAGE(pkt, "vote_reply_build_pkt_for_test must succeed"); /* Call handler — it takes ownership of pkt; frees it internally */ int rc = vote_reply_msg_handler_for_test(&ep, pkt); TEST_ASSERT_EQUAL_INT(0, rc); /* Verify hook was called exactly once */ TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_reply_hook_calls, "vote_reply_msg_handler must call hook exactly once"); /* Verify decoded fields */ TEST_ASSERT_EQUAL_INT_MESSAGE(0, g_reply_peer_idx, "peer_idx must match (0)"); TEST_ASSERT_EQUAL_UINT64_MESSAGE(5ULL, g_reply_term, "decoded term must be 5"); TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_reply_granted, "decoded vote_granted must be 1"); TEST_ASSERT_EQUAL_UINT64_MESSAGE(200ULL, g_reply_peer_tag, "decoded peer_tag must be 200"); TEST_ASSERT_EQUAL_UINT64_MESSAGE(99ULL, g_reply_peer_lsn, "decoded peer_lsn must be 99"); /* ep->fsm_in.pkt is NOT freed here — handler already freed it */ free(ep.data); /* vote_ep_ctx_t allocated by build helper */ free_ctx_for_vote(ctx); ha_fini(&ha); printf("test_vote_reply_handler_decodes_correct_fields: PASS\n"); } /* ── Test: vote_granted=false correctly decoded ──────────────────────────── */ static void test_vote_reply_handler_denied_vote(void) { reset_reply_mock(); ha_state_t ha; ha_init(&ha, &g_vote_cfg, 0); atomic_store_explicit(&ha.term, 3ULL, memory_order_release); ha_elect_ctx_t *ctx = alloc_ctx_for_vote(2); pthread_mutex_init(&ctx->lock, NULL); endpoint_t ep; make_vote_reply_ep(&ep, &ha, ctx, 1); io_packet_t *pkt = vote_reply_build_pkt_for_test( 3ULL, /* term */ 0, /* vote_granted = false */ 50ULL, /* peer_tag */ 20ULL /* peer_lsn */); TEST_ASSERT_NOT_NULL_MESSAGE(pkt, "vote_reply_build_pkt_for_test must succeed"); int rc = vote_reply_msg_handler_for_test(&ep, pkt); TEST_ASSERT_EQUAL_INT(0, rc); TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_reply_hook_calls, "hook must be called once even for denied vote"); TEST_ASSERT_EQUAL_INT_MESSAGE(0, g_reply_granted, "vote_granted must be 0 for denied vote"); TEST_ASSERT_EQUAL_UINT64_MESSAGE(3ULL, g_reply_term, "term must be decoded correctly even when denied"); TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_reply_peer_idx, "peer_idx must be 1"); free(ep.data); free_ctx_for_vote(ctx); ha_fini(&ha); printf("test_vote_reply_handler_denied_vote: PASS\n"); } /* ── Test: term fencing — higher peer term updates ha->term atomically ───── */ static void test_vote_reply_handler_term_fencing(void) { reset_reply_mock(); ha_state_t ha; ha_init(&ha, &g_vote_cfg, 0); atomic_store_explicit(&ha.term, 3ULL, memory_order_release); ha_elect_ctx_t *ctx = alloc_ctx_for_vote(2); pthread_mutex_init(&ctx->lock, NULL); endpoint_t ep; make_vote_reply_ep(&ep, &ha, ctx, 0); /* Peer claims term 10 — higher than our term 3 */ io_packet_t *pkt = vote_reply_build_pkt_for_test( 10ULL, /* peer term > our term */ 0, /* denied */ 0ULL, 0ULL); TEST_ASSERT_NOT_NULL_MESSAGE(pkt, "vote_reply_build_pkt_for_test must succeed"); vote_reply_msg_handler_for_test(&ep, pkt); /* ha->term must now be updated to 10 */ uint64_t updated_term = atomic_load_explicit(&ha.term, memory_order_acquire); TEST_ASSERT_EQUAL_UINT64_MESSAGE(10ULL, updated_term, "ha->term must be atomically updated to peer term when peer term > ours"); free(ep.data); free_ctx_for_vote(ctx); ha_fini(&ha); printf("test_vote_reply_handler_term_fencing: PASS\n"); } /* ── Test: no UAF — pkt is freed before fields are used (ASAN observable) ── */ static void test_vote_reply_handler_no_uaf(void) { /* This test proves ASAN-safety: vote_reply_msg_handler reads ALL capnp * scalars into vr_term/vr_granted/vr_tag/vr_lsn BEFORE calling * free_packet()+free(pkt). Under ASAN (enabled in xce.build via * -fsanitize=address), accessing freed memory would fault immediately. * If this test passes under ASAN, there is no heap-use-after-free. */ reset_reply_mock(); ha_state_t ha; ha_init(&ha, &g_vote_cfg, 0); atomic_store_explicit(&ha.term, 2ULL, memory_order_release); ha_elect_ctx_t *ctx = alloc_ctx_for_vote(2); pthread_mutex_init(&ctx->lock, NULL); endpoint_t ep; make_vote_reply_ep(&ep, &ha, ctx, 0); io_packet_t *pkt = vote_reply_build_pkt_for_test( 2ULL, 1, 777ULL, 888ULL); TEST_ASSERT_NOT_NULL_MESSAGE(pkt, "pkt allocation must succeed"); /* Handler frees pkt internally; all field reads happen before the free */ int rc = vote_reply_msg_handler_for_test(&ep, pkt); TEST_ASSERT_EQUAL_INT(0, rc); /* Hook captured the fields — verify they are the values we encoded */ TEST_ASSERT_EQUAL_UINT64_MESSAGE(2ULL, g_reply_term, "term matches"); TEST_ASSERT_EQUAL_INT_MESSAGE(1, g_reply_granted, "granted matches"); TEST_ASSERT_EQUAL_UINT64_MESSAGE(777ULL, g_reply_peer_tag, "peer_tag matches"); TEST_ASSERT_EQUAL_UINT64_MESSAGE(888ULL, g_reply_peer_lsn, "peer_lsn matches"); free(ep.data); free_ctx_for_vote(ctx); ha_fini(&ha); printf("test_vote_reply_handler_no_uaf: PASS\n"); } /* ── bug-1417: middle-index (self_idx=1) real-vote index-mapping regression ── * * raft_cb_send_vote_request (ha.c:142) stores the COMPRESSED local votes_idx * (self-excluded) in vctx->peer_idx: votes_idx = peer_idx < self_idx ? * peer_idx : peer_idx - 1. vote_reply_dispatch (ha.c:5208) then passes that * SAME compressed idx straight to raft_on_vote_reply, which indexes * s->vote_replies[] in ABSOLUTE space (the space raft_start_election's * self-mark and per-peer send loop both use, raft.c:113-153). * * For self_idx=1 (a 3-node cluster's middle node), the peer at absolute * index 2 maps to compressed votes_idx=1 -- which COLLIDES with * vote_replies[self_idx=1], the slot raft_start_election already marked as * counted for the self-vote. raft_on_vote_reply's per-peer dedup guard * (raft.c:477) then treats the genuine reply from peer 2 as a duplicate and * silently drops it -- vote_replies[1] was already 1, votes_granted stays * at 1 (self only), quorum (2) is never reached, and the election times out * even though peer 2 genuinely granted. * * Peer 0 is unaffected (compressed votes_idx=0 == absolute 0, no collision), * which is why sequential/all-peers-alive elections never surfaced this -- * only a 3-node cluster's middle-priority node losing exactly its * higher-priority peer (index 0) and depending on its lower peer's (index 2) * vote exposes the collision. */ static void make_three_node_self1(ha_state_t *ha) { ha->raft.n_peers = 3; ha->raft.self_idx = ha->my_priority; /* my_priority == 1 for this test */ ha->raft.role = RAFT_FOLLOWER; ha->n_peers = 3; } static void test_bug1417_middle_index_real_vote_from_peer2_not_dropped(void) { reset_reply_mock(); xce_vote_reply_hook = NULL; /* drive the REAL raft_on_vote_reply path, not the hook */ ha_state_t ha; TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &g_vote_cfg, /*my_priority=*/1)); make_three_node_self1(&ha); /* Start a real election: term++, self-vote, vote_replies[self_idx=1]=1 * pre-marked (raft.c:122-123), CANDIDATE role, one on_send_vote_request * call per non-self absolute peer (0 and 2) -- c==NULL so each send is a * safe no-op (raft_cb_send_vote_request returns 1 immediately), exactly * the same test-mode pattern test_ha_prevote.c uses for * ha_start_prevote_async(&ha, NULL). */ int rc = raft_start_election(&ha.raft); TEST_ASSERT_EQUAL_INT_MESSAGE(RAFT_OK, rc, "3-node multi-node election must return RAFT_OK (CANDIDATE transition)"); TEST_ASSERT_EQUAL_INT_MESSAGE(RAFT_CANDIDATE, (int)ha.raft.role, "raft role must be CANDIDATE after starting a 3-node election"); TEST_ASSERT_EQUAL_INT_MESSAGE(1, ha.raft.votes_granted, "self-vote must be counted (1) before any peer reply"); TEST_ASSERT_EQUAL_INT_MESSAGE(1, ha.raft.vote_replies[1], "self_idx's own vote_replies slot must be pre-marked"); ha_elect_ctx_t *ctx = alloc_ctx_for_vote(2); /* n_other = n_peers - 1 = 2 */ pthread_mutex_init(&ctx->lock, NULL); uint64_t term = atomic_load_explicit(&ha.term, memory_order_acquire); /* Peer at ABSOLUTE index 2 grants. raft_cb_send_vote_request's mapping * (peer_idx=2 >= self_idx=1) computes votes_idx = 2 - 1 = 1 -- this IS * the value production code stores in vctx->peer_idx for this peer, so * the test reproduces the exact wire-level index the real send path * would have used. */ endpoint_t ep2; make_vote_reply_ep(&ep2, &ha, ctx, /*peer_idx (compressed votes_idx)=*/1); io_packet_t *pkt2 = vote_reply_build_pkt_for_test(term, /*granted=*/1, 0ULL, 0ULL); TEST_ASSERT_NOT_NULL_MESSAGE(pkt2, "vote_reply_build_pkt_for_test must succeed"); vote_reply_msg_handler_for_test(&ep2, pkt2); /* This is the bug: without ha_repl_ack_abs_peer_idx remapping compressed * votes_idx=1 back to absolute peer_idx=2 before calling * raft_on_vote_reply, the reply lands in vote_replies[1] -- the * self-vote's own slot, already 1 -- and is silently discarded as a * duplicate. votes_granted stays at 1 (self only), never reaching the * 2-vote quorum, even though peer 2 genuinely granted. */ TEST_ASSERT_EQUAL_INT_MESSAGE(2, ha.raft.votes_granted, "bug-1417: peer 2's genuine grant must be counted (votes_granted==2), " "not silently dropped into the self-vote's slot"); TEST_ASSERT_EQUAL_INT_MESSAGE(RAFT_LEADER, (int)ha.raft.role, "bug-1417: with self-vote + peer 2's grant, quorum (2) is reached and " "the node must become LEADER"); TEST_ASSERT_EQUAL_INT_MESSAGE(1, ha.raft.election_decided, "election must be decided (won) once quorum is reached"); free(ep2.data); free_ctx_for_vote(ctx); ha_fini(&ha); printf("test_bug1417_middle_index_real_vote_from_peer2_not_dropped: PASS\n"); } /* ── Suite entry point ───────────────────────────────────────────────────── */ void test_ha_vote_send(void) { RUN_TEST(test_on_vote_connected_calls_hook_with_correct_opcode); RUN_TEST(test_on_vote_connected_encodes_correct_fields); RUN_TEST(test_on_vote_connected_sets_msg_handler); RUN_TEST(test_election_timeout_ctx_cancelled_flag); /* Task 2.3: vote_reply_msg_handler */ RUN_TEST(test_vote_reply_handler_decodes_correct_fields); RUN_TEST(test_vote_reply_handler_denied_vote); RUN_TEST(test_vote_reply_handler_term_fencing); RUN_TEST(test_vote_reply_handler_no_uaf); /* bug-1417 */ RUN_TEST(test_bug1417_middle_index_real_vote_from_peer2_not_dropped); }