feat(ha): reference-counted ha_elect_ctx_t lifecycle (ha-elect-ctx-lifecycle)

Replace the deliberate election-carrier leak with an atomic refcount:
a bias reference held by ha->elect_ctx plus one reference per captured
vote endpoint, released via a new endpoint_t.on_fini teardown-completion
hook (fired once, before ep->data is freed). Both real-election and
PreVote capture sites (raft_cb_send_vote_request,
raft_cb_send_pre_vote_request) increment on capture; both publish sites
(ha_start_election_async, ha_start_prevote_async) now atomic_exchange
the outgoing carrier and release it instead of leaking or freeing
outright, so a still-outstanding vote endpoint keeps the superseded
carrier alive until its own teardown completes.

Verified: 758+ controller tests green, isolated LeakSanitizer run shows
zero ha_elect_ctx_t leaks across many election/PreVote rounds, no
premature-free/UAF under supersession, and a live 10-round HA soak
(36 jobs, zero loss, all 3 nodes served as primary).
This commit is contained in:
Rongsong Shen 2026-07-12 04:25:42 +00:00
parent 58e047f533
commit 6529dcf08f
14 changed files with 879 additions and 22 deletions

View file

@ -36,6 +36,7 @@ extern void test_cluster(void);
extern void test_cluster_parse(void);
extern void test_libxce_retry(void);
extern void test_netio_on_connected(void);
extern void test_netio_on_fini(void);
extern void test_netio_arm_failure(void);
extern void test_netio_close_propagation(void);
extern void test_netio_bounded_connect(void);
@ -149,6 +150,7 @@ int main(int argc, char* argv[])
TEST(test_cluster_parse);
TEST(test_libxce_retry);
TEST(test_netio_on_connected);
TEST(test_netio_on_fini);
TEST(test_netio_arm_failure);
TEST(test_netio_close_propagation);
TEST(test_netio_bounded_connect);

View file

@ -0,0 +1,169 @@
/*
* test_netio_on_fini.c — TDD tests for on_fini completion hook in endpoint_t.
*
* Task 1.1: ha-elect-ctx-lifecycle spec
* Boundary: netio.c / netio.h
*
* Mirrors test_netio_on_connected.c's structure for the completion-side
* counterpart hook. Tests verify:
* 1. on_fini field exists in endpoint_t (compile-time check), NULL by default
* 2. endpoint_set_on_fini() stores the callback
* 3. Hook fires exactly once from endpoint_fini(), before ep->data is freed
* 4. ep->data is freed (NULL'd) after endpoint_fini() returns, as before
* 5. No callback set — endpoint_fini() still frees ep->data, no crash
*/
#define _GNU_SOURCE
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unity/unity.h>
#include "netio.h"
/* ── Static state for callback tracking ──────────────────────────── */
static int g_fini_cb_count = 0;
static endpoint_t *g_fini_cb_ep = NULL;
static void *g_fini_cb_data_seen = NULL;
static void on_fini_stub(endpoint_t *ep)
{
g_fini_cb_count++;
g_fini_cb_ep = ep;
/* Capture ep->data as observed INSIDE the hook — must equal the
* original allocation, proving it is still valid (not yet freed) at
* the moment the hook runs. */
g_fini_cb_data_seen = ep->data;
}
static void reset_cb_state(void)
{
g_fini_cb_count = 0;
g_fini_cb_ep = NULL;
g_fini_cb_data_seen = NULL;
}
/* ── Test 1: Field exists and is zero-initialised after endpoint_get ── */
static void test_on_fini_field_exists_and_is_null(void)
{
endpoint_t *ep = endpoint_get();
TEST_ASSERT_NOT_NULL(ep);
TEST_ASSERT_NULL(ep->on_fini);
free(ep);
printf("test_on_fini_field_exists_and_is_null: PASS\n");
}
/* ── Test 2: endpoint_set_on_fini stores the callback ────────────── */
static void test_endpoint_set_on_fini_stores_callback(void)
{
endpoint_t *ep = endpoint_get();
TEST_ASSERT_NOT_NULL(ep);
int rc = endpoint_set_on_fini(ep, on_fini_stub);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_PTR(on_fini_stub, ep->on_fini);
free(ep);
printf("test_endpoint_set_on_fini_stores_callback: PASS\n");
}
/* ── Test 3: endpoint_set_on_fini with NULL ep returns -EINVAL ───── */
static void test_endpoint_set_on_fini_null_ep(void)
{
int rc = endpoint_set_on_fini(NULL, on_fini_stub);
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
printf("test_endpoint_set_on_fini_null_ep: PASS\n");
}
/* ── Test 4: endpoint_set_on_fini accepts NULL callback ──────────── */
static void test_endpoint_set_on_fini_null_cb(void)
{
endpoint_t *ep = endpoint_get();
TEST_ASSERT_NOT_NULL(ep);
int rc = endpoint_set_on_fini(ep, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_NULL(ep->on_fini);
free(ep);
printf("test_endpoint_set_on_fini_null_cb: PASS\n");
}
/*
* ── Test 5: hook fires exactly once from endpoint_fini(), and ep->data
* is still valid (not yet freed) at the moment the hook runs ──────
*/
static void test_on_fini_fires_once_before_data_freed(void)
{
reset_cb_state();
endpoint_t *ep = endpoint_get();
TEST_ASSERT_NOT_NULL(ep);
void *marker = malloc(16);
TEST_ASSERT_NOT_NULL(marker);
ep->data = marker;
endpoint_set_on_fini(ep, on_fini_stub);
TEST_ASSERT_EQUAL_PTR(on_fini_stub, ep->on_fini);
endpoint_fini(ep);
/* Hook fired exactly once, with the correct endpoint. */
TEST_ASSERT_EQUAL_INT(1, g_fini_cb_count);
TEST_ASSERT_EQUAL_PTR(ep, g_fini_cb_ep);
/* ep->data was still the original allocation AT HOOK-CALL TIME —
* proves the hook observes teardown completion before the generic
* FREEUP(ep->data) reclaims it. */
TEST_ASSERT_EQUAL_PTR(marker, g_fini_cb_data_seen);
/* After endpoint_fini() returns, ep->data is freed and NULL'd —
* existing FREEUP(ep->data) behavior is unchanged. */
TEST_ASSERT_NULL(ep->data);
free(ep);
printf("test_on_fini_fires_once_before_data_freed: PASS\n");
}
/* ── Test 6: No callback set — endpoint_fini still frees data, no crash ── */
static void test_no_on_fini_callback_data_still_freed_no_crash(void)
{
endpoint_t *ep = endpoint_get();
TEST_ASSERT_NOT_NULL(ep);
void *marker = malloc(16);
TEST_ASSERT_NOT_NULL(marker);
ep->data = marker;
/* No on_fini set. */
endpoint_fini(ep);
TEST_ASSERT_NULL(ep->data);
free(ep);
printf("test_no_on_fini_callback_data_still_freed_no_crash: PASS\n");
}
/* ── Test suite entry point ───────────────────────────────────────── */
void test_netio_on_fini(void)
{
printf("\n>> netio on_fini completion hook tests\n\n");
RUN_TEST(test_on_fini_field_exists_and_is_null);
RUN_TEST(test_endpoint_set_on_fini_stores_callback);
RUN_TEST(test_endpoint_set_on_fini_null_ep);
RUN_TEST(test_endpoint_set_on_fini_null_cb);
RUN_TEST(test_on_fini_fires_once_before_data_freed);
RUN_TEST(test_no_on_fini_callback_data_still_freed_no_crash);
}