myxce/src/controller/testsuite/test_ha_vep_teardown.c
Rongsong Shen a81f2a249c fix(controller-tests): make Unity test-file attribution and counters consistent suite-wide
Only 21 of 86 test_*.c group functions called their own nested
UNITY_BEGIN()/UNITY_END(). Each nested call reset Unity's global
NumberOfTests/TestFailures counters, so controller_test.c's final
`return UNITY_END();` -- the value meson test actually gates on --
only ever reflected whichever small window of tests ran since the
LAST nested reset, silently discarding pass/fail evidence for
everything before it (measured: 16 of 972 total tests in one run).
The same nested-BEGIN calls also caused PASS/FAIL banners to show
the wrong source file for tests in files that don't reset it
themselves (Unity.TestFile just carries over from whichever group
last called UNITY_BEGIN()).

Fix: replace every nested UNITY_BEGIN() with UnitySetTestFile(__FILE__)
(sets file attribution only, no counter reset) and drop the matching
UNITY_END(); add the same UnitySetTestFile(__FILE__) as the first
statement to the other group functions that had neither. The 2
genuinely standalone binaries (test_job_array_sched_push.c,
test_job_array_apiserver_push.c -- each has its own int main(), not
registered via controller_test.c's TEST() macro) are untouched.

Verified: single consolidated summary now reads
"972 Tests 0 Failures 0 Ignored OK", exactly matching the
ground-truth RUN_TEST call-site count across all 83 registered
group files (976 total minus 4 belonging to the 2 standalone
binaries). Every PASS/FAIL banner now names its own true source
file. Full xce-build.sh all regression pass clean (Ok: 9, Fail: 0).

No production code touched -- test-harness plumbing only.
2026-07-17 14:49:12 +00:00

637 lines
24 KiB
C

/*
* test_ha_vep_teardown.c — bug-1197: deferred vote-ep teardown.
*
* When g_drive_async_peerctrl_dispatch_enabled is ON, raft_cb_become_follower
* runs on a vtask pool worker and must endpoint_fini() every loser vote-ep while
* another pool worker may be mid-vtask (or have a queued vtask) on one of those
* same eps reading ep->data → use-after-free. The fix defers each vote-ep
* teardown onto the SAME per-endpoint FIFO the pool already enforces (so the
* free runs strictly after the last live use) and gates late dispatch on a
* tearing_down flag.
*
* Tests (all run with ASAN; the runner uses detect_leaks=0 for the pre-existing
* suite-wide leak, but these tests are use-after-free / double-free clean):
* (a) ORDERING — teardown runs strictly after an in-flight same-ep vtask.
* (b) LATE-DROP — a late same-batch event on a tearing-down ep is dropped,
* its packet freed, no new task enqueued.
* (c) DISARM — a teardown vtask run through the real pool is ASAN-clean
* (iod->ep = NULL prevents run_task touching the freed lock).
* (d) OFF-PATH — with the flag OFF the loser loop uses plain endpoint_fini
* and never touches the pool (behavior unchanged).
*/
#define _GNU_SOURCE
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include <pthread.h>
#include <unity/unity.h>
#include "ha.h"
#include "netio.h"
#include "vtaskmgr.h"
/* ── XCE_TESTING seams declared in ha.c ──────────────────────────────────── */
extern int peer_ctrl_msg_handler_for_test(endpoint_t *ep, io_packet_t *pkt);
extern io_packet_t *repl_catchup_done_pkt_for_test(void);
extern void raft_cb_become_follower_for_test(ha_state_t *ha, uint64_t term);
/* ── Fixtures ────────────────────────────────────────────────────────────── */
static ha_config_t g_cfg = {
.heartbeat_interval_ms = 3000,
.heartbeat_timeout_ms = 6000,
.election_timeout_ms = 500,
.probe_connect_timeout_ms = 50,
.max_promotion_retries = 3,
.election_jitter_max_ms = 0,
.election_priority_step_ms = 500,
};
/* Sequence oracle: the in-flight vtask body and the teardown probe each grab a
* monotonically increasing ticket; teardown's ticket must be the larger. */
static _Atomic int g_seq = 0;
static _Atomic int g_inflight_seq = -1;
static _Atomic int g_teardown_seq = -1;
static _Atomic int g_teardown_done = 0;
static _Atomic int g_body_calls = 0;
static _Atomic int g_body_seq = -1;
static _Atomic int g_body_ran = 0;
/* Gate the in-flight vtask body parks on (so the test controls when it ends). */
static struct {
pthread_mutex_t mu;
pthread_cond_t entered_cv;
pthread_cond_t release_cv;
int open;
_Atomic int entered;
} g_gate;
static void gate_init(void)
{
pthread_mutex_init(&g_gate.mu, NULL);
pthread_cond_init(&g_gate.entered_cv, NULL);
pthread_cond_init(&g_gate.release_cv, NULL);
g_gate.open = 0;
atomic_store(&g_gate.entered, 0);
}
static void gate_destroy(void)
{
pthread_mutex_destroy(&g_gate.mu);
pthread_cond_destroy(&g_gate.entered_cv);
pthread_cond_destroy(&g_gate.release_cv);
}
static void gate_open(void)
{
pthread_mutex_lock(&g_gate.mu);
g_gate.open = 1;
pthread_cond_broadcast(&g_gate.release_cv);
pthread_mutex_unlock(&g_gate.mu);
}
static int gate_wait_entered(int timeout_ms)
{
struct timespec d;
clock_gettime(CLOCK_REALTIME, &d);
d.tv_sec += timeout_ms / 1000;
d.tv_nsec += (long)(timeout_ms % 1000) * 1000000L;
if (d.tv_nsec >= 1000000000L) { d.tv_sec++; d.tv_nsec -= 1000000000L; }
pthread_mutex_lock(&g_gate.mu);
int rc = 0;
while (atomic_load(&g_gate.entered) < 1) {
rc = pthread_cond_timedwait(&g_gate.entered_cv, &g_gate.mu, &d);
if (rc != 0) break;
}
int entered = atomic_load(&g_gate.entered);
pthread_mutex_unlock(&g_gate.mu);
return entered >= 1 ? 0 : -1;
}
/* In-flight peer-control vtask body: record a ticket, signal entry, park. */
static int inflight_vtask_body(vtask_t *task)
{
(void)task;
atomic_store(&g_inflight_seq, atomic_fetch_add(&g_seq, 1));
pthread_mutex_lock(&g_gate.mu);
atomic_fetch_add(&g_gate.entered, 1);
pthread_cond_broadcast(&g_gate.entered_cv);
while (!g_gate.open)
pthread_cond_wait(&g_gate.release_cv, &g_gate.mu);
pthread_mutex_unlock(&g_gate.mu);
return 0;
}
/* Teardown probe (fires at the top of the teardown vtask body). */
static void teardown_probe(endpoint_t *ep)
{
(void)ep;
atomic_store(&g_teardown_seq, atomic_fetch_add(&g_seq, 1));
atomic_store(&g_teardown_done, 1);
}
/* Body probe (fires at the top of the inline routing body). */
static void body_probe(endpoint_t *ep)
{
(void)ep;
atomic_fetch_add(&g_body_calls, 1);
}
/* Body probe variant that also records a sequence ticket (drain test). */
static void body_seq_probe(endpoint_t *ep)
{
(void)ep;
atomic_store(&g_body_seq, atomic_fetch_add(&g_seq, 1));
atomic_store(&g_body_ran, 1);
atomic_fetch_add(&g_body_calls, 1);
}
/* Predispatch probe (drain test): fires INSIDE the dispatch_inflight bracket,
* after the tearing_down check passed but before the pool enqueue. Signals
* entry, then parks on the gate so the dispatch stays in-flight (counter == 1,
* not yet enqueued) while the test drives endpoint_mark_tearing_down's drain. */
static void predispatch_park_probe(endpoint_t *ep)
{
(void)ep;
pthread_mutex_lock(&g_gate.mu);
atomic_fetch_add(&g_gate.entered, 1);
pthread_cond_broadcast(&g_gate.entered_cv);
while (!g_gate.open)
pthread_cond_wait(&g_gate.release_cv, &g_gate.mu);
pthread_mutex_unlock(&g_gate.mu);
}
/* Heap ep with a heap ep->data (so endpoint_fini's FREEUP has something to free,
* matching the production vote_ep_ctx_t allocation). */
static endpoint_t *make_ep(iomgr_t *iomgr)
{
endpoint_t *ep = endpoint_get();
TEST_ASSERT_NOT_NULL(ep);
ep->iomgr = iomgr;
ep->fd = -1;
ep->data = calloc(1, 128); /* stand-in for vote_ep_ctx_t */
TEST_ASSERT_NOT_NULL(ep->data);
return ep;
}
static void reset_oracles(void)
{
atomic_store(&g_seq, 0);
atomic_store(&g_inflight_seq, -1);
atomic_store(&g_teardown_seq, -1);
atomic_store(&g_teardown_done, 0);
atomic_store(&g_body_calls, 0);
atomic_store(&g_body_seq, -1);
atomic_store(&g_body_ran, 0);
}
/* Enqueue an arbitrary VTASK_IO body bound to ep (drives per-ep serialization). */
static void enqueue_io_vtask(vtaskmgr_t *pool, endpoint_t *ep, vtask_func_t fn)
{
vtask_data_t *data = calloc(1, sizeof(*data));
vtask_t *task = calloc(1, sizeof(*task));
TEST_ASSERT_NOT_NULL(data);
TEST_ASSERT_NOT_NULL(task);
data->vtype = VTASK_IO;
data->u.iodata.ep = ep;
data->u.iodata.pkt = NULL;
vtask_init(task, fn, data);
TEST_ASSERT_EQUAL_INT(0, vtask_enq_classified(pool, task, VTASK_LIVENESS));
}
static int poll_until(_Atomic int *flag, int want, int timeout_ms)
{
for (int i = 0; i < timeout_ms / 5; i++) {
if (atomic_load(flag) == want)
return 0;
struct timespec ts = { .tv_sec = 0, .tv_nsec = 5 * 1000000L };
nanosleep(&ts, NULL);
}
return atomic_load(flag) == want ? 0 : -1;
}
/* ── Test (a): ORDERING ──────────────────────────────────────────────────── */
static void test_vep_teardown_runs_after_inflight(void)
{
reset_oracles();
gate_init();
ha_state_t ha;
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &g_cfg, 1));
vtaskmgr_t pool;
TEST_ASSERT_EQUAL_INT(0, vtaskmgr_init(&pool, 2));
TEST_ASSERT_EQUAL_INT(0, vtaskmgr_start(&pool));
ha.pool = &pool;
iomgr_t iomgr;
TEST_ASSERT_EQUAL_INT(0, iomgr_init(&iomgr));
endpoint_t *ep = make_ep(&iomgr);
ha_set_peerctrl_dispatch_for_test(1);
ha_vep_teardown_probe = teardown_probe;
/* Park an in-flight peer-control vtask on this ep (busy=1 while gated). */
enqueue_io_vtask(&pool, ep, (vtask_func_t)inflight_vtask_body);
TEST_ASSERT_EQUAL_INT_MESSAGE(0, gate_wait_entered(2000),
"in-flight vtask must run and park (busy=1)");
/* Now tear the SAME ep down: marks tearing_down + enqueues the teardown
* vtask, which is busy-blocked behind the parked in-flight vtask. */
ha_vote_ep_teardown_for_test(&ha, ep);
/* While the in-flight vtask is still parked, teardown must NOT have run. */
TEST_ASSERT_TRUE_MESSAGE(endpoint_is_tearing_down(ep),
"ep must be marked tearing_down once teardown is initiated");
struct timespec ts = { .tv_sec = 0, .tv_nsec = 80 * 1000000L };
nanosleep(&ts, NULL);
TEST_ASSERT_EQUAL_INT_MESSAGE(0, atomic_load(&g_teardown_done),
"teardown must NOT run while an in-flight same-ep vtask is parked");
/* Release the in-flight vtask; teardown becomes eligible and runs. */
gate_open();
TEST_ASSERT_EQUAL_INT_MESSAGE(0, poll_until(&g_teardown_done, 1, 2000),
"teardown must run once the in-flight vtask completes");
TEST_ASSERT_GREATER_THAN_INT_MESSAGE(atomic_load(&g_inflight_seq),
atomic_load(&g_teardown_seq),
"teardown_seq must be strictly after inflight_seq");
/* Join the pool (workers idle; ep already freed by the teardown body). */
vtaskmgr_fini(&pool);
iomgr_fini(&iomgr);
ha_vep_teardown_probe = NULL;
ha_set_peerctrl_dispatch_for_test(0);
ha.pool = NULL;
ha_fini(&ha);
gate_destroy();
printf("test_vep_teardown_runs_after_inflight: PASS\n");
}
/* ── Test (b): LATE-DROP ─────────────────────────────────────────────────── */
static void test_vep_late_event_dropped(void)
{
reset_oracles();
ha_state_t ha;
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &g_cfg, 1));
vtaskmgr_t pool;
TEST_ASSERT_EQUAL_INT(0, vtaskmgr_init(&pool, 2));
TEST_ASSERT_EQUAL_INT(0, vtaskmgr_start(&pool));
ha.pool = &pool;
iomgr_t iomgr;
TEST_ASSERT_EQUAL_INT(0, iomgr_init(&iomgr));
endpoint_t *ep = make_ep(&iomgr);
ha_set_peerctrl_dispatch_for_test(1);
ha_peerctrl_body_probe = body_probe;
/* mark + unmonitor (the teardown caller's first two steps) — but do NOT
* enqueue a teardown vtask, so the ep stays alive for this call. */
endpoint_mark_tearing_down(ep);
iomgr_unmonitor(ep->iomgr, ep);
io_packet_t *pkt = repl_catchup_done_pkt_for_test();
TEST_ASSERT_NOT_NULL(pkt);
int rc = peer_ctrl_msg_handler_for_test(ep, pkt); /* takes ownership of pkt */
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc,
"late event on a tearing-down ep must return 0 (dropped)");
/* Give any (erroneously) enqueued task time to run the body. */
struct timespec ts = { .tv_sec = 0, .tv_nsec = 100 * 1000000L };
nanosleep(&ts, NULL);
TEST_ASSERT_EQUAL_INT_MESSAGE(0, atomic_load(&g_body_calls),
"a dropped late event must NOT enqueue/run the routing body");
ha_peerctrl_body_probe = NULL;
ha_set_peerctrl_dispatch_for_test(0);
/* ep was not torn down by a vtask here (late event dropped, refcnt==1) —
* drop the owner ref ourselves; endpoint_unref fini's + frees it (pkt was
* freed by the drop path). bug-1298: use unref, not raw fini+free. */
endpoint_unref(ep);
vtaskmgr_fini(&pool);
iomgr_fini(&iomgr);
ha.pool = NULL;
ha_fini(&ha);
printf("test_vep_late_event_dropped: PASS\n");
}
/* ── Test (c): DISARM ────────────────────────────────────────────────────── */
static void test_vep_teardown_vtask_disarms_unblock(void)
{
reset_oracles();
ha_state_t ha;
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &g_cfg, 1));
vtaskmgr_t pool;
TEST_ASSERT_EQUAL_INT(0, vtaskmgr_init(&pool, 2));
TEST_ASSERT_EQUAL_INT(0, vtaskmgr_start(&pool));
ha.pool = &pool;
iomgr_t iomgr;
TEST_ASSERT_EQUAL_INT(0, iomgr_init(&iomgr));
endpoint_t *ep = make_ep(&iomgr);
ha_set_peerctrl_dispatch_for_test(1);
ha_vep_teardown_probe = teardown_probe;
/* Run a teardown vtask straight through the pool (no in-flight task). If the
* iod->ep = NULL disarm were missing, run_task would call
* endpoint_unblock_tasks on the freed ep->lock — ASAN would flag a UAF. */
ha_vote_ep_teardown_for_test(&ha, ep);
TEST_ASSERT_EQUAL_INT_MESSAGE(0, poll_until(&g_teardown_done, 1, 2000),
"teardown vtask must run through the pool");
vtaskmgr_fini(&pool);
iomgr_fini(&iomgr);
ha_vep_teardown_probe = NULL;
ha_set_peerctrl_dispatch_for_test(0);
ha.pool = NULL;
ha_fini(&ha);
printf("test_vep_teardown_vtask_disarms_unblock: PASS\n");
}
/* ── Test (d): OFF-PATH ──────────────────────────────────────────────────── */
static void test_vep_off_path_uses_plain_fini(void)
{
reset_oracles();
ha_state_t ha;
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &g_cfg, 1));
ha.pool = NULL; /* off path must not need the pool */
ha_set_peerctrl_dispatch_for_test(0); /* flag OFF */
ha_vep_teardown_probe = teardown_probe;
endpoint_t *ep = endpoint_get();
TEST_ASSERT_NOT_NULL(ep);
ep->iomgr = NULL; /* OFF path: endpoint_fini only, no unmonitor */
ep->fd = -1;
ep->data = calloc(1, 128);
TEST_ASSERT_NOT_NULL(ep->data);
/* One-peer election context with this ep as the loser vote-ep. */
ha_elect_ctx_t *ectx = calloc(1, sizeof(*ectx) + sizeof(ha_vote_entry_t));
TEST_ASSERT_NOT_NULL(ectx);
ectx->n_peers = 1;
ectx->votes[0].ep = ep;
atomic_store_explicit(&ha.elect_ctx, ectx, memory_order_release);
raft_cb_become_follower_for_test(&ha, 5);
TEST_ASSERT_EQUAL_INT_MESSAGE(0, atomic_load(&g_teardown_done),
"OFF path must NOT run the async teardown vtask");
TEST_ASSERT_NULL_MESSAGE(ectx->votes[0].ep,
"loser loop must clear votes[i].ep after endpoint_unref");
/* bug-1298: the OFF path now drops the owner ref via endpoint_unref — with no
* pool vtask referencing the ep (refcnt==1) that fini's AND frees it here,
* fixing the legacy per-lost-election struct leak. So the test must NOT
* free(ep) again (that was the old leak-cleanup; it is now a double-free). */
atomic_store_explicit(&ha.elect_ctx, NULL, memory_order_release);
free(ectx);
/* become_follower armed the heartbeat watchdog (c=NULL) — release its ctx. */
ha_disarm_heartbeat_timeout_vtask(&ha);
ha_vep_teardown_probe = NULL;
ha_fini(&ha);
printf("test_vep_off_path_uses_plain_fini: PASS\n");
}
/* ── Test (e): DRAIN — mark blocks until an in-flight dispatch finishes ───────
*
* Closes the TOCTOU window: a dispatch that read tearing_down as false but is
* preempted before its enqueue must NOT be able to enqueue after the teardown
* vtask. endpoint_mark_tearing_down drains ep->dispatch_inflight to 0 (after
* setting the flag) before returning, so the teardown caller enqueues the
* teardown vtask only after every such in-flight dispatch has finished enqueuing
* → the peer-control vtask is strictly FIFO-before the teardown vtask.
*
* Deterministic: a predispatch probe parks thread A inside the dispatch_inflight
* bracket (counter == 1, not yet enqueued). Thread B runs the teardown seam; we
* assert its mark/drain does NOT return while A is parked, then release A and
* assert mark returns and the peer-control body ran before the teardown probe. */
typedef struct { endpoint_t *ep; _Atomic int done; } dispatch_arg_t;
typedef struct { ha_state_t *ha; endpoint_t *ep; _Atomic int done; } teardown_arg_t;
/* Layout-compatible stand-in for vote_ep_ctx_t: the inline routing body reads
* only the first member (ha) before the default-drop path frees the packet. */
struct mini_vctx { ha_state_t *ha; char pad[160]; };
static void *drain_dispatch_thread(void *p)
{
dispatch_arg_t *a = (dispatch_arg_t *)p;
io_packet_t *pkt = repl_catchup_done_pkt_for_test();
peer_ctrl_msg_handler_for_test(a->ep, pkt); /* parks in predispatch probe */
atomic_store(&a->done, 1);
return NULL;
}
static void *drain_teardown_thread(void *p)
{
teardown_arg_t *b = (teardown_arg_t *)p;
ha_vote_ep_teardown_for_test(b->ha, b->ep); /* mark drains, then enqueues */
atomic_store(&b->done, 1);
return NULL;
}
static void test_vep_mark_drains_inflight_dispatch(void)
{
reset_oracles();
gate_init();
ha_state_t ha;
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &g_cfg, 1));
/* CTRL_INIT → the inline body hits the default case (drop + free pkt) so it
* never decodes, keeping the stand-in vote_ep_ctx_t sufficient. */
atomic_store_explicit(&ha.role, (int32_t)CTRL_INIT, memory_order_release);
vtaskmgr_t pool;
TEST_ASSERT_EQUAL_INT(0, vtaskmgr_init(&pool, 2));
TEST_ASSERT_EQUAL_INT(0, vtaskmgr_start(&pool));
ha.pool = &pool;
iomgr_t iomgr;
TEST_ASSERT_EQUAL_INT(0, iomgr_init(&iomgr));
endpoint_t *ep = endpoint_get();
TEST_ASSERT_NOT_NULL(ep);
ep->iomgr = &iomgr;
ep->fd = -1;
struct mini_vctx *v = calloc(1, sizeof(*v));
TEST_ASSERT_NOT_NULL(v);
v->ha = &ha;
ep->data = v;
ha_set_peerctrl_dispatch_for_test(1);
ha_peerctrl_predispatch_probe = predispatch_park_probe;
ha_peerctrl_body_probe = body_seq_probe;
ha_vep_teardown_probe = teardown_probe;
/* Thread A: dispatch parks in the drained window (inflight == 1). */
dispatch_arg_t aarg = { .ep = ep };
atomic_store(&aarg.done, 0);
pthread_t ta;
TEST_ASSERT_EQUAL_INT(0, pthread_create(&ta, NULL, drain_dispatch_thread, &aarg));
TEST_ASSERT_EQUAL_INT_MESSAGE(0, gate_wait_entered(2000),
"dispatch must reach the predispatch window and park (inflight=1)");
/* Thread B: teardown mark must BLOCK draining on A. */
teardown_arg_t barg = { .ha = &ha, .ep = ep };
atomic_store(&barg.done, 0);
pthread_t tb;
TEST_ASSERT_EQUAL_INT(0, pthread_create(&tb, NULL, drain_teardown_thread, &barg));
TEST_ASSERT_EQUAL_INT_MESSAGE(-1, poll_until(&barg.done, 1, 150),
"endpoint_mark_tearing_down must block while a dispatch is in-flight");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, atomic_load(&g_teardown_done),
"teardown vtask must not be enqueued/run while mark is still draining");
/* Release A → it completes its enqueue + decrements → B's drain ends. */
gate_open();
TEST_ASSERT_EQUAL_INT_MESSAGE(0, poll_until(&barg.done, 1, 2000),
"mark must return once the in-flight dispatch finishes its enqueue");
pthread_join(ta, NULL);
pthread_join(tb, NULL);
/* Both vtasks run on the pool; peer-control is FIFO-before teardown. */
TEST_ASSERT_EQUAL_INT_MESSAGE(0, poll_until(&g_teardown_done, 1, 2000),
"teardown vtask must run");
TEST_ASSERT_EQUAL_INT_MESSAGE(1, atomic_load(&g_body_ran),
"the in-flight peer-control body must have run");
TEST_ASSERT_GREATER_THAN_INT_MESSAGE(atomic_load(&g_body_seq),
atomic_load(&g_teardown_seq),
"peer-control vtask must be FIFO-before the teardown vtask");
vtaskmgr_fini(&pool);
iomgr_fini(&iomgr);
ha_peerctrl_predispatch_probe = NULL;
ha_peerctrl_body_probe = NULL;
ha_vep_teardown_probe = NULL;
ha_set_peerctrl_dispatch_for_test(0);
ha.pool = NULL;
ha_fini(&ha);
gate_destroy();
printf("test_vep_mark_drains_inflight_dispatch: PASS\n");
}
/* ── Test (f): bug-1376 SELF-TEARDOWN — must not self-deadlock ─────────────
*
* A node's OWN in-flight peer-control dispatch can, via its routing body,
* decide to tear down the VERY SAME ep the dispatch is currently processing
* (the live signature: a node's own vote-request ep receives the reply that
* makes it become follower, and the loser loop closes that same ep). Before
* the bug-1376 fix, peer_ctrl_msg_handler held ep->dispatch_inflight across
* the ENTIRE inline body call; vote_ep_teardown_async's endpoint_mark_
* tearing_down then spun forever waiting for that same count — held by this
* same thread, unable to decrement until the spin itself returns — a
* permanent self-deadlock. Reproduced with a bounded-timeout join: the
* dispatch runs on a helper thread; the test FAILS (rather than hanging the
* whole suite) if it does not return within the timeout.
*/
static void *self_teardown_dispatch_thread(void *p)
{
dispatch_arg_t *a = (dispatch_arg_t *)p;
io_packet_t *pkt = repl_catchup_done_pkt_for_test();
peer_ctrl_msg_handler_for_test(a->ep, pkt);
atomic_store(&a->done, 1);
return NULL;
}
static ha_state_t *g_self_teardown_ha = NULL;
static endpoint_t *g_self_teardown_ep = NULL;
/* Fires at the top of the inline routing body (before role dispatch) — tears
* down the SAME ep this dispatch is currently processing, the self-referential
* case the fix must resolve. */
static void self_teardown_body_probe(endpoint_t *ep)
{
(void)ep;
ha_vote_ep_teardown_for_test(g_self_teardown_ha, g_self_teardown_ep);
}
static void test_vep_self_teardown_does_not_deadlock(void)
{
reset_oracles();
ha_state_t ha;
TEST_ASSERT_EQUAL_INT(0, ha_init(&ha, &g_cfg, 1));
/* CTRL_INIT → the inline body hits the default case (drop + free pkt)
* after the probe runs, so the stand-in vote_ep_ctx_t is sufficient. */
atomic_store_explicit(&ha.role, (int32_t)CTRL_INIT, memory_order_release);
ha.pool = NULL; /* matches the live cold-start repro (bug-1376) */
endpoint_t *ep = endpoint_get();
TEST_ASSERT_NOT_NULL(ep);
ep->iomgr = NULL;
ep->fd = -1;
struct mini_vctx *v = calloc(1, sizeof(*v));
TEST_ASSERT_NOT_NULL(v);
v->ha = &ha;
ep->data = v;
ha_set_peerctrl_dispatch_for_test(1);
g_self_teardown_ha = &ha;
g_self_teardown_ep = ep;
ha_peerctrl_body_probe = self_teardown_body_probe;
dispatch_arg_t darg = { .ep = ep };
atomic_store(&darg.done, 0);
pthread_t t;
TEST_ASSERT_EQUAL_INT(0,
pthread_create(&t, NULL, self_teardown_dispatch_thread, &darg));
struct timespec deadline;
clock_gettime(CLOCK_REALTIME, &deadline);
deadline.tv_sec += 5;
int jrc = pthread_timedjoin_np(t, NULL, &deadline);
TEST_ASSERT_EQUAL_INT_MESSAGE(0, jrc,
"bug-1376: self-teardown (an ep's own dispatch tearing down the same "
"ep) must not deadlock — dispatch_inflight must be released before "
"the inline body runs, not held across it");
TEST_ASSERT_EQUAL_INT_MESSAGE(1, atomic_load(&darg.done),
"the dispatch thread must have completed");
ha_peerctrl_body_probe = NULL;
ha_set_peerctrl_dispatch_for_test(0);
g_self_teardown_ha = NULL;
g_self_teardown_ep = NULL;
ha_fini(&ha);
printf("test_vep_self_teardown_does_not_deadlock: PASS\n");
}
/* ── Entry point ─────────────────────────────────────────────────────────── */
void test_ha_vep_teardown(void)
{
UnitySetTestFile(__FILE__);
RUN_TEST(test_vep_teardown_runs_after_inflight);
RUN_TEST(test_vep_late_event_dropped);
RUN_TEST(test_vep_teardown_vtask_disarms_unblock);
RUN_TEST(test_vep_off_path_uses_plain_fini);
RUN_TEST(test_vep_mark_drains_inflight_dispatch);
RUN_TEST(test_vep_self_teardown_does_not_deadlock);
}