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).
This commit is contained in:
Rongsong Shen 2026-05-30 08:45:43 +00:00
parent 18c280429e
commit bd58e5045a
2 changed files with 464 additions and 6 deletions

View file

@ -318,6 +318,245 @@ static void test_election_timeout_ctx_cancelled_flag(void)
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)
@ -326,4 +565,9 @@ void test_ha_vote_send(void)
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);
}