myxce/src/controller/testsuite/test_ha_vote_send.c
Rongsong Shen bd58e5045a feat(election-iomgr-refactor): implement vote_reply_msg_handler
Replace stub with full iomgr-native vote reply decoder:
- Reads RequestVoteReply fields into local scalars BEFORE capn_free (UAF fix)
- Term fencing: atomic update ha->term if peer term > ours
- Error path: decode failure counts peer as denied vote
- Test seam: xce_vote_reply_hook for unit testing without real packets
- 4 new ASAN-clean unit tests: field decode, denied vote, term fencing, no-UAF

The pre-existing vote_request_thread UAF in the old pthread path remains
but is targeted for removal in task 5.3 (old code removal).
2026-05-30 08:45:43 +00:00

573 lines
22 KiB
C

/*
* 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 <errno.h>
#include <stdatomic.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unity/unity.h>
#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");
}
/* ── 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);
}