myxce/src/controller/testsuite/test_ha_dispatch_integration.c
Rongsong Shen d2830ce514 test(raft-drive-async): receiver-side dispatch liveness integration tests (task 3.4b)
With the dispatch flags flipped ON in-test, prove the three receiver-side
liveness properties (production defaults stay OFF):

- P1: while a snapshot (and config-push) transfer is parked in flight on the
  slow lane, another endpoint's peer-control message is still processed
  promptly on the vtask pool (different thread), with the transfer still
  parked — the offloaded transfer does not block the drive path.
- P2: a peer-control ack (classified VTASK_LIVENESS) runs promptly even while
  the pool is saturated to the LivenessAdmission reservation boundary with
  REQ_BLOCK tasks (one more REQ_BLOCK fast-rejects with -E_XCE_NOT_READY,
  proving real saturation). Narrowed to the liveness-dispatch-under-saturation
  assertion; commit-advance itself is covered by the commit-engine tests.
- P3: while one endpoint's peer-control body is parked (holding a worker + that
  ep busy), another endpoint's body runs concurrently on a different worker —
  per-endpoint FIFO does not serialize across endpoints.

All deterministic (cond-var gates, bounded polls). Fixed a flaky teardown-race
hang (bug-1224): the tests released a parked vtask body then immediately called
the cancel-based vtaskmgr_fini while the just-released body was still unwinding
(P2's REQ_BLOCK bodies racing blk_lock teardown the worst). Fix adds end-of-body
completion counters polled to the full expected count before fini, so every
pool body has fully returned (worker idle) before the cancel. slowlane_fini is
graceful (join) so the lane side was already safe.

Verified: 5/5 deterministic isolated runs; full controller suite EXIT=0;
ASan-clean. Completes task 3.4 (3.4a UAF fix + 3.4b tests); the production
flag-flip remains deferred to after the 4.1 partition soak + 4.2 regression
sweep.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S6fDCtXkkEgupu7rUah1y2
2026-06-27 00:36:22 +00:00

606 lines
26 KiB
C
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* test_ha_dispatch_integration.c — raft-drive-async Task 3.4b:
* receiver-side liveness integration tests for the new dispatch flags.
*
* With the dispatch flags ON, prove the three receiver-side liveness
* properties the spec requires:
*
* (P1) TRANSFER-DOES-NOT-BLOCK-CROSS-ENDPOINT — while a snapshot (or
* config-push) transfer is parked in flight on the SLOW LANE, the
* controller still processes ANOTHER peer's peer-control message on the
* vtask POOL promptly: the transfer (lane) and the drive path (pool) are
* separate executors, so the offloaded transfer never blocks peer
* control.
*
* (P2) ACK-ADVANCES-UNDER-SATURATION — a peer-control ack is classified
* VTASK_LIVENESS (peer_ctrl_classify returns VTASK_LIVENESS for ALL
* peer-control: heartbeat/vote/ack) and the LivenessAdmission
* reservation keeps it from being starved by *_BLOCK tasks: with the
* pool saturated to the reservation boundary (cap = n_vcpus −
* VTASK_LIVENESS_RESERVED REQ_BLOCK tasks parked, one more fast-rejected)
* the LIVENESS ack still dispatches and runs promptly.
*
* (P3) CROSS-ENDPOINT-NOT-SERIALIZED — per-endpoint FIFO serialization does
* NOT serialize across endpoints: while ep_A's peer-control body is
* parked (holding one pool worker + ep_A busy), ep_B's peer-control body
* still runs concurrently on a DIFFERENT pool worker. (Complement of
* test_peerctrl_preserves_per_endpoint_order, which proves SAME-ep
* ordering.)
*
* All tests are DETERMINISTIC (gates / cond-vars / bounded polls, never sleeps
* on the correctness path). Every feature flag is flipped ON in-test via the
* ha_set_*_for_test setters and restored to 0 (production default OFF) at the
* end of each test. No production feature-flag default is changed; the only
* seams used are the pre-existing XCE_TESTING test-entry points and hooks.
*
* Requirements: raft-drive-async Req 1.1, 1.4, 1.5, 3.1, 3.2, 4.1, 4.2, 8.1, 8.2.
*/
#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 "slowlane.h"
#include "vtaskmgr.h"
#include "xce/cluster_def.h"
#include "xce/errnox.h"
/* ── XCE_TESTING seams (those NOT already declared in ha.h / vtaskmgr.h) ───── */
extern endpoint_t *standby_ep_ctx_build_for_test(endpoint_t *ep, ha_state_t *ha,
cluster_t *c, int peer_idx);
extern int peer_ctrl_msg_handler_for_test(endpoint_t *ep, io_packet_t *pkt);
extern io_packet_t *vote_reply_build_pkt_for_test(uint64_t term, int vote_granted,
uint64_t peer_tag,
uint64_t peer_lsn);
/* config-push offload seam (Task 3.3): a void(void) probe + a direct router. */
extern void (*ha_configpush_probe)(void);
extern int ha_config_push_offloadable_for_test(cluster_t *c, const uint8_t *bytes,
size_t blen, const uint8_t sha256[32],
uint64_t new_tag);
/* ha.h already declares: ha_set_peerctrl_dispatch_for_test,
* ha_set_snapshot_offload_for_test, ha_set_configpush_offload_for_test,
* ha_peerctrl_body_probe, ha_dispatch_repl_action_for_standby,
* ha_repl_send_snapshot_hook. */
/* ── Fixture config ──────────────────────────────────────────────────────── */
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,
};
/* ── Generic park gate (cond-var; deterministic, no sleeps) ──────────────── */
typedef struct {
pthread_mutex_t mu;
pthread_cond_t entered_cv; /* a body broadcasts when it has parked */
pthread_cond_t release_cv; /* test broadcasts to release parked bodies */
int open;
_Atomic int entered; /* # of bodies that reached the gate */
} gate_t;
static void gate_init(gate_t *g)
{
pthread_mutex_init(&g->mu, NULL);
pthread_cond_init(&g->entered_cv, NULL);
pthread_cond_init(&g->release_cv, NULL);
g->open = 0;
atomic_store(&g->entered, 0);
}
static void gate_destroy(gate_t *g)
{
pthread_mutex_destroy(&g->mu);
pthread_cond_destroy(&g->entered_cv);
pthread_cond_destroy(&g->release_cv);
}
static void gate_open(gate_t *g)
{
pthread_mutex_lock(&g->mu);
g->open = 1;
pthread_cond_broadcast(&g->release_cv);
pthread_mutex_unlock(&g->mu);
}
/* Body-side: record entry, broadcast, then park until the test opens the gate. */
static void gate_park(gate_t *g)
{
pthread_mutex_lock(&g->mu);
atomic_fetch_add(&g->entered, 1);
pthread_cond_broadcast(&g->entered_cv);
while (!g->open)
pthread_cond_wait(&g->release_cv, &g->mu);
pthread_mutex_unlock(&g->mu);
}
/* Bounded wait until >= n bodies have parked; 0 on success, -1 on timeout. */
static int gate_wait_entered(gate_t *g, int n, 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->mu);
int rc = 0;
while (atomic_load(&g->entered) < n) {
rc = pthread_cond_timedwait(&g->entered_cv, &g->mu, &d);
if (rc != 0) break;
}
int entered = atomic_load(&g->entered);
pthread_mutex_unlock(&g->mu);
return entered >= n ? 0 : -1;
}
/* Bounded poll until *a >= want; returns the observed value. */
static int poll_atomic_ge(_Atomic int *a, int want, int timeout_ms)
{
for (int i = 0; i < timeout_ms; i++) {
if (atomic_load(a) >= want) break;
struct timespec ts = { .tv_sec = 0, .tv_nsec = 1000000L }; /* 1 ms */
nanosleep(&ts, NULL);
}
return atomic_load(a);
}
/* ── Peer-control body probe (selective park keyed on ep pointer) ────────── */
static _Atomic int g_pc_runs; /* # peer-control bodies that entered */
static _Atomic int g_pc_completed; /* # NON-parked bodies that returned */
static _Atomic pthread_t g_pc_other_thread;/* thread a non-parked body ran on */
static _Atomic pthread_t g_pc_park_thread; /* thread the parked body ran on */
static _Atomic(endpoint_t *) g_pc_park_ep; /* if body's ep == this → park */
static gate_t g_pc_gate;
static void pc_probe(endpoint_t *ep)
{
atomic_fetch_add(&g_pc_runs, 1);
endpoint_t *park = atomic_load(&g_pc_park_ep);
if (park != NULL && ep == park) {
atomic_store_explicit(&g_pc_park_thread, pthread_self(),
memory_order_relaxed);
gate_park(&g_pc_gate);
/* Signal completion on the park path too, so teardown can wait for the
* released body to FULLY return before vtaskmgr_fini (which cancels
* workers) — otherwise fini races the just-woken body → flaky hang. */
atomic_fetch_add(&g_pc_completed, 1);
return;
}
atomic_store_explicit(&g_pc_other_thread, pthread_self(),
memory_order_relaxed);
atomic_fetch_add(&g_pc_completed, 1);
}
static void pc_probe_reset(void)
{
atomic_store(&g_pc_runs, 0);
atomic_store(&g_pc_completed, 0);
atomic_store(&g_pc_park_ep, NULL);
gate_init(&g_pc_gate);
}
/* ── Snapshot-send lane probe (parks on a gate, Task 3.2 hook) ───────────── */
static _Atomic int g_snap_runs;
static gate_t g_snap_gate;
static int g_snap_park; /* 1 → body parks at the gate */
static int snap_probe(int peer_idx)
{
(void)peer_idx;
if (g_snap_park)
gate_park(&g_snap_gate);
atomic_fetch_add(&g_snap_runs, 1);
return 0;
}
/* ── Config-push lane probe (parks on a gate, Task 3.3 hook) ─────────────── */
static _Atomic int g_cp_runs;
static gate_t g_cp_gate;
static int g_cp_park; /* 1 → body parks at the gate */
static void cp_probe(void)
{
if (g_cp_park)
gate_park(&g_cp_gate);
atomic_fetch_add(&g_cp_runs, 1);
}
/* Drive one SEND_SNAPSHOT decision (mirrors test_ha_liveness snap_dispatch_once):
* standby tag current but lsn behind the oldest WAL window → the action is
* HA_REPL_SEND_SNAPSHOT, which the offload path submits to the slow lane. */
static int snap_dispatch_once(cluster_t *c, endpoint_t *ep, int peer_idx)
{
c->ha.standby_reported_tag[peer_idx] = 5;
c->ha.standby_reported_lsn[peer_idx] = 10; /* < oldest_wal_lsn below */
return ha_dispatch_repl_action_for_standby(
&c->ha, c, ep, peer_idx,
/*committed_tag=*/5, /*committed_lsn=*/100, /*oldest_wal_lsn=*/50);
}
/* ═══════════════════════════════════════════════════════════════════════════
* P1 — TRANSFER DOES NOT BLOCK CROSS-ENDPOINT
*
* Flags ON (peerctrl + snapshot OR peerctrl + configpush). Park a transfer in
* flight on the SLOW LANE; while it is parked, dispatch a peer-control message
* for a DIFFERENT endpoint and assert its body runs on a POOL worker (a
* different thread, within a bounded poll) WHILE the transfer is still parked.
* This proves the offloaded transfer (lane) does not block the drive path
* (pool).
* ═══════════════════════════════════════════════════════════════════════════ */
static void p1_run(int use_configpush, const char *label)
{
vtaskmgr_t pool;
TEST_ASSERT_EQUAL_INT(0, vtaskmgr_init(&pool, 4)); /* >= 2 vcpus */
TEST_ASSERT_EQUAL_INT(0, vtaskmgr_start(&pool));
cluster_t c;
memset(&c, 0, sizeof(c));
ha_init(&c.ha, &g_cfg, 0);
c.ha.pool = &pool;
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_INIT, memory_order_release);
TEST_ASSERT_EQUAL_INT(0, slowlane_init(&c.slowlane, 2, 4));
/* peer-control endpoint B (a DIFFERENT peer than the transfer target). */
endpoint_t ep_b;
memset(&ep_b, 0, sizeof(ep_b));
standby_ep_ctx_build_for_test(&ep_b, &c.ha, &c, 1);
/* Probes: peer-control body just records thread + runs (never parks). */
pc_probe_reset();
ha_peerctrl_body_probe = pc_probe;
ha_set_peerctrl_dispatch_for_test(1);
/* Endpoint used by the transfer decision (borrowed pointer only). */
endpoint_t ep_a;
memset(&ep_a, 0, sizeof(ep_a));
pthread_t caller = pthread_self();
if (!use_configpush) {
/* ── snapshot variant ── */
atomic_store(&g_snap_runs, 0);
gate_init(&g_snap_gate);
g_snap_park = 1;
ha_repl_send_snapshot_hook = snap_probe;
ha_set_snapshot_offload_for_test(1);
/* Submit the snapshot to the lane; its body parks in flight. */
TEST_ASSERT_EQUAL_INT(0, snap_dispatch_once(&c, &ep_a, 0));
TEST_ASSERT_EQUAL_INT_MESSAGE(0, gate_wait_entered(&g_snap_gate, 1, 2000),
"P1: the snapshot transfer must reach the lane and park in flight");
/* While the transfer is parked, dispatch peer-control on ep_B. */
io_packet_t *pkt = vote_reply_build_pkt_for_test(1, 1, 0, 0);
TEST_ASSERT_NOT_NULL(pkt);
TEST_ASSERT_EQUAL_INT(0, peer_ctrl_msg_handler_for_test(&ep_b, pkt));
int runs = poll_atomic_ge(&g_pc_runs, 1, 2000);
TEST_ASSERT_EQUAL_INT_MESSAGE(1, runs,
"P1: ep_B's peer-control body must run on the pool while the "
"snapshot transfer is parked on the lane");
/* The transfer is STILL parked — concurrency, not serialization. */
TEST_ASSERT_EQUAL_INT_MESSAGE(0, atomic_load(&g_snap_runs),
"P1: the snapshot transfer must still be in flight (parked) while "
"ep_B's peer control is processed");
TEST_ASSERT_FALSE_MESSAGE(
pthread_equal(atomic_load_explicit(&g_pc_other_thread,
memory_order_relaxed), caller),
"P1: ep_B's peer-control body must run on a POOL worker, not the "
"caller (io_worker/drive) thread");
gate_open(&g_snap_gate);
TEST_ASSERT_EQUAL_INT_MESSAGE(1, poll_atomic_ge(&g_snap_runs, 1, 2000),
"P1: the released snapshot transfer must then complete on the lane");
ha_set_snapshot_offload_for_test(0); /* restore production default OFF */
ha_repl_send_snapshot_hook = NULL;
gate_destroy(&g_snap_gate);
} else {
/* ── config-push variant ── */
atomic_store(&g_cp_runs, 0);
gate_init(&g_cp_gate);
g_cp_park = 1;
ha_configpush_probe = cp_probe;
ha_set_configpush_offload_for_test(1);
/* Submit the config push to the lane; its body parks in flight. */
TEST_ASSERT_EQUAL_INT(0,
ha_config_push_offloadable_for_test(&c, NULL, 0, NULL, 7));
TEST_ASSERT_EQUAL_INT_MESSAGE(0, gate_wait_entered(&g_cp_gate, 1, 2000),
"P1: the config-push transfer must reach the lane and park in flight");
io_packet_t *pkt = vote_reply_build_pkt_for_test(1, 1, 0, 0);
TEST_ASSERT_NOT_NULL(pkt);
TEST_ASSERT_EQUAL_INT(0, peer_ctrl_msg_handler_for_test(&ep_b, pkt));
int runs = poll_atomic_ge(&g_pc_runs, 1, 2000);
TEST_ASSERT_EQUAL_INT_MESSAGE(1, runs,
"P1: ep_B's peer-control body must run on the pool while the "
"config-push transfer is parked on the lane");
TEST_ASSERT_EQUAL_INT_MESSAGE(0, atomic_load(&g_cp_runs),
"P1: the config-push transfer must still be in flight (parked) while "
"ep_B's peer control is processed");
TEST_ASSERT_FALSE_MESSAGE(
pthread_equal(atomic_load_explicit(&g_pc_other_thread,
memory_order_relaxed), caller),
"P1: ep_B's peer-control body must run on a POOL worker, not the "
"caller (io_worker/drive) thread");
gate_open(&g_cp_gate);
TEST_ASSERT_EQUAL_INT_MESSAGE(1, poll_atomic_ge(&g_cp_runs, 1, 2000),
"P1: the released config-push transfer must then complete on the lane");
ha_set_configpush_offload_for_test(0); /* restore production default OFF */
ha_configpush_probe = NULL;
gate_destroy(&g_cp_gate);
}
/* Teardown: ensure ep_B's pool body has FULLY returned (g_pc_completed, its
* last step) before vtaskmgr_fini cancels workers; then drain lane + pool.
* slowlane_fini is graceful (joins workers), so the (already-released) lane
* body is safe; vtaskmgr_fini cancels, so the pool body must be done. */
poll_atomic_ge(&g_pc_completed, 1, 2000);
ha_set_peerctrl_dispatch_for_test(0); /* restore production default OFF */
ha_peerctrl_body_probe = NULL;
slowlane_fini(&c.slowlane);
vtaskmgr_fini(&pool);
gate_destroy(&g_pc_gate);
free(ep_b.data);
ha_fini(&c.ha);
printf("%s: PASS\n", label);
}
static void test_p1_snapshot_transfer_does_not_block_cross_endpoint(void)
{
p1_run(0, "test_p1_snapshot_transfer_does_not_block_cross_endpoint");
}
static void test_p1_configpush_transfer_does_not_block_cross_endpoint(void)
{
p1_run(1, "test_p1_configpush_transfer_does_not_block_cross_endpoint");
}
/* ═══════════════════════════════════════════════════════════════════════════
* P2 — ACK ADVANCES UNDER POOL SATURATION
*
* Assertion used: LIVENESS-DISPATCH-UNDER-SATURATION (the narrowed assertion
* the task authorizes). We do NOT wire a full commit-engine repl_on_ack →
* commit-advance here — that is too heavy for the unit harness and commit-
* advance itself is already covered by the commit-engine tests. Instead we
* assert the load-bearing liveness property: a peer-control ack — classified
* VTASK_LIVENESS by peer_ctrl_classify (which returns VTASK_LIVENESS for ALL
* peer-control, including wal-acks) — is dispatched and RUNS promptly even
* while (n_vcpus − VTASK_LIVENESS_RESERVED) REQ_BLOCK tasks occupy the pool
* (the reservation boundary), with one more REQ_BLOCK fast-rejected to prove
* the pool really is saturated at that boundary.
* ═══════════════════════════════════════════════════════════════════════════ */
static gate_t g_block_gate;
static _Atomic int g_block_started;
static _Atomic int g_block_done; /* incremented as the LAST body step */
static int block_task_entry(void *p)
{
(void)p;
atomic_fetch_add(&g_block_started, 1);
/* Park holding this vcpu until the test releases the gate. */
pthread_mutex_lock(&g_block_gate.mu);
while (!g_block_gate.open)
pthread_cond_wait(&g_block_gate.release_cv, &g_block_gate.mu);
pthread_mutex_unlock(&g_block_gate.mu);
/* Signal full completion so teardown can wait for every released blocker
* to return BEFORE vtaskmgr_fini (which cancels workers) — otherwise fini
* races a body still in vtaskmgr_blocking_release → flaky hang. */
atomic_fetch_add(&g_block_done, 1);
return 0;
}
static vtask_t *make_block_task(void)
{
vtask_data_t *d = calloc(1, sizeof(*d));
vtask_t *t = vtask_alloc();
TEST_ASSERT_NOT_NULL(d);
TEST_ASSERT_NOT_NULL(t);
d->vtype = VTASK_GENERIC;
d->u.generic = NULL;
vtask_init(t, block_task_entry, d);
return t;
}
static void test_p2_ack_runs_under_pool_saturation(void)
{
const int NVCPUS = 4;
const int RESERVED = 2; /* cap = n_vcpus - reserved = 2 */
const int CAP = NVCPUS - RESERVED;
vtaskmgr_t pool;
TEST_ASSERT_EQUAL_INT(0, vtaskmgr_init(&pool, NVCPUS));
vtaskmgr_set_liveness_reserved(&pool, RESERVED);
TEST_ASSERT_EQUAL_INT(0, vtaskmgr_start(&pool));
cluster_t c;
memset(&c, 0, sizeof(c));
ha_init(&c.ha, &g_cfg, 0);
c.ha.pool = &pool;
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_INIT, memory_order_release);
/* Saturate the REQ_BLOCK admission cap: CAP blocking tasks, all parked,
* occupying CAP pool workers. The reservation keeps RESERVED workers free
* for LIVENESS — that is the property under test. */
gate_init(&g_block_gate);
atomic_store(&g_block_started, 0);
atomic_store(&g_block_done, 0);
for (int i = 0; i < CAP; i++) {
vtask_t *t = make_block_task();
TEST_ASSERT_EQUAL_INT(0, vtask_enq_classified(&pool, t, VTASK_REQ_BLOCK));
}
TEST_ASSERT_EQUAL_INT_MESSAGE(CAP,
poll_atomic_ge(&g_block_started, CAP, 2000),
"P2: all REQ_BLOCK saturators must reach their body (occupy a vcpu)");
/* One more REQ_BLOCK over the cap fast-rejects — proves the pool is
* saturated exactly at the reservation boundary (not merely idle). */
vtask_t *over = make_block_task();
int over_rc = vtask_enq_classified(&pool, over, VTASK_REQ_BLOCK);
TEST_ASSERT_EQUAL_INT_MESSAGE(-E_XCE_NOT_READY, over_rc,
"P2: a REQ_BLOCK over the reservation cap must fast-reject "
"(pool saturated at the boundary)");
vtask_fini(over); /* rejected → caller still owns it */
free(over);
/* Dispatch the ack as a peer-control message: peer_ctrl_classify makes it
* VTASK_LIVENESS, so admission never gates it. It must run promptly on one
* of the RESERVED workers despite the *_BLOCK saturation. */
endpoint_t ep_ack;
memset(&ep_ack, 0, sizeof(ep_ack));
standby_ep_ctx_build_for_test(&ep_ack, &c.ha, &c, 0);
pc_probe_reset(); /* g_pc_park_ep = NULL → never parks */
ha_peerctrl_body_probe = pc_probe;
ha_set_peerctrl_dispatch_for_test(1);
io_packet_t *ack = vote_reply_build_pkt_for_test(1, 1, 0, 0);
TEST_ASSERT_NOT_NULL(ack);
TEST_ASSERT_EQUAL_INT(0, peer_ctrl_msg_handler_for_test(&ep_ack, ack));
TEST_ASSERT_EQUAL_INT_MESSAGE(1, poll_atomic_ge(&g_pc_runs, 1, 2000),
"P2: the VTASK_LIVENESS ack must run promptly even while the pool is "
"saturated to the reservation boundary with *_BLOCK tasks "
"(never starved by the blocking admission)");
/* Release the parked blockers AND wait for every one to FULLY return
* before fini — vtaskmgr_fini cancels workers, so finishing here while a
* blocker is still in vtaskmgr_blocking_release races blk_lock teardown. */
gate_open(&g_block_gate);
TEST_ASSERT_EQUAL_INT_MESSAGE(CAP, poll_atomic_ge(&g_block_done, CAP, 2000),
"P2: all released blockers must fully return before teardown");
/* Also wait for the LIVENESS ack body to fully return (it increments
* g_pc_completed as its last step). */
poll_atomic_ge(&g_pc_completed, 1, 2000);
ha_set_peerctrl_dispatch_for_test(0); /* restore production default OFF */
ha_peerctrl_body_probe = NULL;
vtaskmgr_fini(&pool);
gate_destroy(&g_block_gate);
gate_destroy(&g_pc_gate); /* allocated by pc_probe_reset */
free(ep_ack.data);
ha_fini(&c.ha);
printf("test_p2_ack_runs_under_pool_saturation: PASS\n");
}
/* ═══════════════════════════════════════════════════════════════════════════
* P3 — CROSS-ENDPOINT NOT SERIALIZED
*
* Flags ON (peerctrl). Park ep_A's peer-control body at the gate (holding one
* pool worker + ep_A busy via bind_vtask's per-endpoint serialization). While
* it is parked, dispatch a peer-control message on ep_B and assert ep_B's body
* runs to completion on a DIFFERENT pool worker WHILE ep_A's is still parked —
* proving per-endpoint FIFO does NOT serialize across endpoints. (Pool has
* >= 2 vcpus.)
* ═══════════════════════════════════════════════════════════════════════════ */
static void test_p3_cross_endpoint_not_serialized(void)
{
vtaskmgr_t pool;
TEST_ASSERT_EQUAL_INT(0, vtaskmgr_init(&pool, 4)); /* >= 2 vcpus */
TEST_ASSERT_EQUAL_INT(0, vtaskmgr_start(&pool));
cluster_t c;
memset(&c, 0, sizeof(c));
ha_init(&c.ha, &g_cfg, 0);
c.ha.pool = &pool;
atomic_store_explicit(&c.ha.role, (int32_t)CTRL_INIT, memory_order_release);
endpoint_t ep_a, ep_b;
memset(&ep_a, 0, sizeof(ep_a));
memset(&ep_b, 0, sizeof(ep_b));
standby_ep_ctx_build_for_test(&ep_a, &c.ha, &c, 0);
standby_ep_ctx_build_for_test(&ep_b, &c.ha, &c, 1);
pc_probe_reset();
atomic_store(&g_pc_park_ep, &ep_a); /* only ep_A's body parks */
ha_peerctrl_body_probe = pc_probe;
ha_set_peerctrl_dispatch_for_test(1);
pthread_t caller = pthread_self();
/* Dispatch on ep_A — its body enters and parks (holds a pool worker). */
io_packet_t *pkt_a = vote_reply_build_pkt_for_test(1, 1, 0, 0);
TEST_ASSERT_NOT_NULL(pkt_a);
TEST_ASSERT_EQUAL_INT(0, peer_ctrl_msg_handler_for_test(&ep_a, pkt_a));
TEST_ASSERT_EQUAL_INT_MESSAGE(0, gate_wait_entered(&g_pc_gate, 1, 2000),
"P3: ep_A's peer-control body must reach the gate and park on a worker");
/* Dispatch on ep_B while ep_A is parked — must run concurrently. */
io_packet_t *pkt_b = vote_reply_build_pkt_for_test(1, 1, 0, 0);
TEST_ASSERT_NOT_NULL(pkt_b);
TEST_ASSERT_EQUAL_INT(0, peer_ctrl_msg_handler_for_test(&ep_b, pkt_b));
/* ep_B's body must ENTER and COMPLETE while ep_A's is still parked. */
TEST_ASSERT_EQUAL_INT_MESSAGE(1, poll_atomic_ge(&g_pc_completed, 1, 2000),
"P3: ep_B's peer-control body must run to completion while ep_A's body "
"is parked — per-endpoint FIFO must NOT serialize across endpoints");
TEST_ASSERT_EQUAL_INT_MESSAGE(2, atomic_load(&g_pc_runs),
"P3: BOTH bodies must have entered concurrently (ep_A parked, ep_B ran)");
/* The two bodies ran on distinct pool workers, neither on the caller. */
pthread_t tb = atomic_load_explicit(&g_pc_other_thread, memory_order_relaxed);
pthread_t ta = atomic_load_explicit(&g_pc_park_thread, memory_order_relaxed);
TEST_ASSERT_FALSE_MESSAGE(pthread_equal(tb, caller),
"P3: ep_B's body must run on a POOL worker, not the caller's thread");
TEST_ASSERT_FALSE_MESSAGE(pthread_equal(tb, ta),
"P3: ep_A and ep_B bodies must run on DIFFERENT pool workers "
"(concurrent, not serialized)");
/* Release ep_A, then wait for BOTH bodies to FULLY return (g_pc_completed,
* incremented as the last body step) before fini — vtaskmgr_fini cancels
* workers, so finishing while the just-woken ep_A body is still unwinding
* races the cancel → flaky hang. */
gate_open(&g_pc_gate);
TEST_ASSERT_EQUAL_INT_MESSAGE(2, poll_atomic_ge(&g_pc_completed, 2, 2000),
"P3: both bodies must fully complete after release before teardown");
ha_set_peerctrl_dispatch_for_test(0); /* restore production default OFF */
ha_peerctrl_body_probe = NULL;
vtaskmgr_fini(&pool);
gate_destroy(&g_pc_gate);
free(ep_a.data);
free(ep_b.data);
ha_fini(&c.ha);
printf("test_p3_cross_endpoint_not_serialized: PASS\n");
}
/* ── Suite entry point ──────────────────────────────────────────────────── */
void test_ha_dispatch_integration(void)
{
UNITY_BEGIN();
/* P1 — transfer (lane) does not block cross-endpoint peer control (pool) */
RUN_TEST(test_p1_snapshot_transfer_does_not_block_cross_endpoint);
RUN_TEST(test_p1_configpush_transfer_does_not_block_cross_endpoint);
/* P2 — VTASK_LIVENESS ack runs under *_BLOCK pool saturation */
RUN_TEST(test_p2_ack_runs_under_pool_saturation);
/* P3 — cross-endpoint peer control is not serialized */
RUN_TEST(test_p3_cross_endpoint_not_serialized);
UNITY_END();
}