diff --git a/src/controller/ha.c b/src/controller/ha.c index 3771d9c3..61937f0b 100644 --- a/src/controller/ha.c +++ b/src/controller/ha.c @@ -8856,6 +8856,17 @@ int primary_heartbeat_fn_for_test(cluster_t *c) { return primary_heartbeat_fn(c); } + +/* + * liveness_ctx_get_for_test: expose the current s_liveness_ctx pointer (as an + * opaque void*, no type leaked to callers) so a test can assert whether a + * ctx is published as "active" without a live delaymgr task actually + * servicing it (bug-1483 regression lock). Only compiled when XCE_TESTING. + */ +void *liveness_ctx_get_for_test(void) +{ + return (void *)atomic_load_explicit(&s_liveness_ctx, memory_order_acquire); +} #endif void ha_arm_heartbeat_timeout_vtask(ha_state_t *ha, cluster_t *c) @@ -8898,8 +8909,24 @@ void ha_arm_heartbeat_timeout_vtask(ha_state_t *ha, cluster_t *c) if (c && c->delaymgr.running) { int rc = delay_task_ms(&c->delaymgr, (int)poll_ms, 1, (delay_task_entry_fn_t)liveness_poll_fn, ctx); - if (rc < 0) + if (rc < 0) { WARN("HA: ha_arm_heartbeat_timeout_vtask: delay_task_ms failed: %d", rc); + /* Scheduling failed -- no delaymgr task holds ctx, so + * liveness_poll_fn's cancelled-check free will never run for it. + * Free it now instead of leaving it published as "active" + * (bug-1483). */ + atomic_store_explicit(&s_liveness_ctx, NULL, memory_order_release); + free(ctx); + } + } else { + /* No live delaymgr to poll on (c==NULL, e.g. unit-test mode driving + * this path directly via ha_prevote_rearm_liveness with no live + * cluster_t; or delaymgr not running). liveness_poll_fn's own + * cancelled-check free is the ONLY place a scheduled ctx ever gets + * freed, so an unscheduled ctx would otherwise leak permanently + * (bug-1483). Free it here instead. */ + atomic_store_explicit(&s_liveness_ctx, NULL, memory_order_release); + free(ctx); } } diff --git a/src/controller/testsuite/test_ha_prevote.c b/src/controller/testsuite/test_ha_prevote.c index 65814e17..b0caca5d 100644 --- a/src/controller/testsuite/test_ha_prevote.c +++ b/src/controller/testsuite/test_ha_prevote.c @@ -63,6 +63,12 @@ extern void ha_prevote_round_timeout_fire_for_test(ha_state_t *ha, cluster_t *c) * LOST outcome without a live delaymgr. */ extern int (*ha_prevote_rearm_liveness_hook)(ha_state_t *ha, cluster_t *c); +/* + * liveness_ctx_get_for_test: opaque accessor for ha.c's static s_liveness_ctx + * (bug-1483 regression lock — see test below). + */ +extern void *liveness_ctx_get_for_test(void); + /* ── Task 5.1 transport-route seams ──────────────────────────────────────── */ /* @@ -280,6 +286,44 @@ static void test_prevote_round_timeout_is_loss(void) printf("test_prevote_round_timeout_is_loss: PASS\n"); } +/* ── Test 4b: LOST round with no test hook must not leak the liveness ctx + * (bug-1483) ──────────────────────────────────────────────────────────── */ + +/* + * Every other LOST-path test in this file stubs ha_prevote_rearm_liveness_hook + * to avoid driving the real ha_arm_heartbeat_timeout_vtask(ha, c) with c==NULL + * — that unstubbed shape is exactly what bug-1483 found: the real function + * callocs a liveness_poll_ctx_t and publishes it to the static s_liveness_ctx, + * but with c==NULL there is no delaymgr to schedule liveness_poll_fn on, so + * nothing ever frees it — a permanent leak/dangling-publish on every such + * call. This test deliberately does NOT stub the hook, driving the real path, + * and asserts the ctx is never left published once arming without a + * scheduler is impossible. + */ +static void test_bug1483_lost_round_without_hook_does_not_leak_liveness_ctx(void) +{ + ha_state_t ha; + TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &g_cfg, 0)); + ha_do_demote(&ha); + make_three_node(&ha); + + /* Deliberately leave ha_prevote_rearm_liveness_hook == NULL so the LOST + * branch calls the real ha_arm_heartbeat_timeout_vtask(&ha, NULL). */ + TEST_ASSERT_NULL_MESSAGE(ha_prevote_rearm_liveness_hook, + "precondition: hook must be unstubbed to drive the real path"); + + ha_start_prevote_async(&ha, NULL); + ha_prevote_round_timeout_fire_for_test(&ha, NULL); + + TEST_ASSERT_NULL_MESSAGE(liveness_ctx_get_for_test(), + "bug-1483: arming the liveness watchdog with c==NULL (no delaymgr " + "to poll on) must not leave an allocated ctx published as the " + "\"active\" s_liveness_ctx -- nothing would ever free it"); + + ha_fini(&ha); + printf("test_bug1483_lost_round_without_hook_does_not_leak_liveness_ctx: PASS\n"); +} + /* ── Test 5: a subsequent genuine failure still triggers a new round ─────── */ /* @@ -876,6 +920,7 @@ void test_ha_prevote(void) RUN_TEST(test_prevote_majority_grant_starts_real_election); RUN_TEST(test_prevote_no_majority_stays_standby_and_rearms); RUN_TEST(test_prevote_round_timeout_is_loss); + RUN_TEST(test_bug1483_lost_round_without_hook_does_not_leak_liveness_ctx); RUN_TEST(test_prevote_reusable_after_loss); RUN_TEST(test_prevote_pending_is_noop); RUN_TEST(test_prevote_reply_over_real_transport_reaches_tally);