test(raft-ha-core-safety): harness infrastructure + payload-loss smoke-RED (task 1.4)

New test_ha_core_safety.c with deterministic gate/probe seams (no sleeps,
drain-before-fini per bug-1224): payload-controllable WAL writer, on-disk
(term,pcrc,len) readback probe, walcompare observer, apply-gap + kill/
election injection for phases 2.3/4.1/5.1. Smoke-RED (Expected 64 Was 0)
proves the payload-blind (term,lsn)-only guard at persist.c:822 drops a
corrective full-payload write at a committed LSN. 5 tests/1 RED/4 seam
PASS, no ASAN errors.
This commit is contained in:
Rongsong Shen 2026-06-27 09:49:51 +00:00
parent 9e55f09274
commit dc50924833
4 changed files with 415 additions and 1 deletions

View file

@ -25,7 +25,7 @@
- _Requirements: 7.1, 1.1_
- _Boundary: Deterministic Chaotic Harness_
- [ ] 1.4 Stand up the unit/integration harness infrastructure
- [x] 1.4 Stand up the unit/integration harness infrastructure
- Create `test_ha_core_safety.c` with the deterministic gate/probe seams (no sleeps; teardown drains pending delaymgr tasks to avoid the bug-1224 teardown-race UAF), the `(term, lsn)`/apply-gap/kill-injection fixtures, and register it in the controller unit-test build; include one smoke-RED case proving the harness can observe a divergence. The per-defect RED bodies are owned by their phases (2.3 ingest, 4.1 reconvergence, 5.1 election) — this task provides the shared seams they build on, not those cases
- Observable completion: `test_ha_core_safety` builds and runs, the injection fixtures and probe seams are usable by later phases, and the smoke-RED case FAILs on the baseline with no ASAN teardown errors
- _Requirements: 7.1, 7.4_

View file

@ -145,6 +145,7 @@ controller_unit_test_src = [
'testsuite/test_commit_admission_pool.c',
'testsuite/test_ha_vep_teardown.c',
'testsuite/test_ha_dispatch_integration.c',
'testsuite/test_ha_core_safety.c',
'testsuite/test_waldump.c',
'testsuite/waldump.c',
'testsuite/test_walcompare.c',

View file

@ -71,6 +71,7 @@ extern void test_slowlane(void);
extern void test_commit_admission_pool(void);
extern void test_ha_vep_teardown(void);
extern void test_ha_dispatch_integration(void);
extern void test_ha_core_safety(void);
void setUp(void) { return; }
@ -211,6 +212,7 @@ int main(int argc, char* argv[])
TEST(test_commit_admission_pool);
TEST(test_ha_vep_teardown);
TEST(test_ha_dispatch_integration);
TEST(test_ha_core_safety);
return UNITY_END();
}

View file

@ -0,0 +1,411 @@
/*
* test_ha_core_safety.c — raft-ha-core-safety Phase 1, Task 1.4:
* the DETERMINISTIC CHAOTIC HARNESS infrastructure (shared seams only).
*
* This file stands up the reusable gate/probe/injection seams the later
* RED phases build their per-defect cases on. It does NOT implement those
* defect cases — each is owned by its phase:
*
* - 2.3 cross-term catch-up streamer payload-integrity → uses the WAL
* construction + readback-probe seams here.
* - 4.1 standby reconvergence (apply-gap) → uses the apply-gap
* injection seam here.
* - 5.1 election liveness / Election Safety → uses the
* kill/election injection seam here.
*
* Retargeted root cause (task 1.3, verified — see research.md §"Task 1.3"):
* the live committed divergence is CROSS-TERM CATCH-UP PAYLOAD LOSS — a
* non-empty entry persisted with PLEN=0 at a committed LSN whose (term, lsn)
* are CORRECT — NOT the refuted term-blind bypass. So the seams here are
* built to construct and observe a payload-divergent entry at an existing
* (term, lsn): a payload-controllable WAL writer over the real persist guard
* (the very (term,lsn)-only idempotency guard the spec hardens in 2.2), an
* on-disk (term, pcrc, len) readback probe, and the walcompare (term,lsn,pcrc)
* divergence observer.
*
* Determinism: every seam is synchronous over a real persist_t WAL — NO
* sleeps on the correctness path. Teardown DRAINS the persist background
* worker (persist_stop joins the fsync thread) BEFORE persist_fini, so no
* just-woken body races the teardown (the bug-1224 teardown-race UAF class).
* The seams here post NO delaymgr/pool work (ha pool is left NULL); a later
* phase that dispatches onto delaymgr/pool must add the same drain-before-fini
* discipline used by test_ha_dispatch_integration.c.
*
* Smoke-RED (the ONE case that FAILs on the baseline): the payload-blind
* persistence guard silently no-ops a corrective full-payload write over a
* previously-stored empty (PLEN=0) entry at the same committed LSN — so the
* WAL keeps the empty body. This is the minimal observable form of the live
* payload-loss signature; it goes GREEN when task 2.2 makes the guard
* (term, lsn, payload-crc)-aware.
*
* Requirements: 7.1 (force divergence RED on baseline), 7.4 (unit harness).
*/
#define _GNU_SOURCE
#include <stdatomic.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unity/unity.h>
#include "ha.h"
#include "persist.h"
#include "wal_types.h"
#include "walcompare.h"
#include "waldump.h"
/* ── Fixtures ──────────────────────────────────────────────────────────────
* A member is a real persist_t WAL on its own temp dir. This is the exact
* receiver-side persistence surface the spec hardens, so a divergence
* constructed through it is the live divergence, not a synthetic stand-in. */
typedef struct {
persist_t* p;
char root[256]; /* mkdtemp root */
char wal_dir[320]; /* <root>/ctrl/data/wal — what waldump reads */
} hcs_member_t;
/* Bring one member up. max_wal_records is pinned high so no checkpoint is
* scheduled (keeps the harness off the delaymgr path — drain-clean teardown). */
static int hcs_member_up(hcs_member_t* m, const char* suffix)
{
memset(m, 0, sizeof(*m));
m->p = calloc(1, sizeof(persist_t));
if (!m->p)
return -1;
persist_config_t cfg = persist_config_defaults();
cfg.wal_sync_ms = 5000;
cfg.sync_timeout_ms = 50;
cfg.max_wal_records = 1u << 30; /* never auto-checkpoint during a unit test */
snprintf(m->root, sizeof(m->root), "/tmp/xce_hcs_%s_XXXXXX", suffix);
if (!mkdtemp(m->root)) {
free(m->p);
m->p = NULL;
return -1;
}
snprintf(m->wal_dir, sizeof(m->wal_dir), "%s/ctrl/data/wal", m->root);
persist_set_workdir(m->p, m->root, "ctrl");
persist_init(m->p, &cfg, NULL);
persist_start(m->p);
return 0;
}
/* Drain + tear down: persist_stop joins the fsync worker BEFORE persist_fini,
* so no background body races the free (bug-1224 teardown-race discipline). */
static void hcs_member_down(hcs_member_t* m)
{
if (!m->p)
return;
persist_stop(m->p); /* joins the background fsync thread (the drain) */
persist_fini(m->p);
free(m->p);
m->p = NULL;
}
/* ── Seam 1: payload-controllable WAL entry construction ────────────────────
* Write an entry with a caller-chosen (term, lsn, type, payload, len) through
* the real replicated-write guard (persist.c:802). term is stamped via
* persist_set_term (what write_replicated copies into hdr.term). A len==0 /
* payload==NULL write is the cross-term catch-up PLEN=0 signature 2.3 injects.
* Returns the guard's rc verbatim (0 = persisted or idempotent-skipped). */
static int hcs_wal_put(hcs_member_t* m, uint64_t lsn, uint64_t term,
uint32_t type, const void* payload, uint32_t len)
{
persist_set_term(m->p, term);
return persist_wal_write_replicated(m->p, lsn, type, payload, len);
}
/* ── Seam 2: persisted (term, pcrc, len) readback probe ─────────────────────
* Reads back what is PHYSICALLY on disk at an LSN — the same (term, pcrc)
* fingerprint walcompare compares across members, plus the payload length that
* disambiguates an empty (PLEN=0) body from a full one. On-disk truth, not
* in-memory state: this is how a phase asserts "the corrective write actually
* landed". Returns 0 on a successful scan (set out->present), <0 on a hard
* scan error. */
typedef struct {
int present;
uint64_t term; /* raw on-disk term (legacy 0 kept verbatim) */
uint32_t pcrc; /* payload-only CRC32 (0 for an empty body) */
uint32_t len; /* payload byte count (0 for an empty body) */
} hcs_probe_t;
typedef struct {
uint64_t want_lsn;
hcs_probe_t* out;
} hcs_probe_ctx_t;
static int hcs_probe_visit(void* ctx, const waldump_entry_t* e)
{
hcs_probe_ctx_t* pc = ctx;
if (e->lsn != pc->want_lsn)
return 0; /* keep scanning */
pc->out->present = 1;
pc->out->term = e->term;
pc->out->pcrc = e->pcrc;
pc->out->len = e->payload_len;
return 1; /* found — stop the scan */
}
static int hcs_wal_probe(hcs_member_t* m, uint64_t lsn, hcs_probe_t* out)
{
memset(out, 0, sizeof(*out));
hcs_probe_ctx_t pc = { .want_lsn = lsn, .out = out };
FILE* sink = fopen("/dev/null", "w");
if (!sink)
return -1;
int rc = waldump_path_visit(m->wal_dir, sink, NULL, hcs_probe_visit, &pc);
fclose(sink);
return rc;
}
/* ── Seam 3: cross-member divergence observation (walcompare) ───────────────
* Run the (term, lsn, pcrc) committed-prefix detector over N members' WAL
* dirs and surface the verdict + first divergent identity. This is how a
* phase asserts a constructed payload divergence is OBSERVABLE as a committed
* safety violation. */
#define HCS_MAX_MEMBERS 8
static int hcs_walcompare(hcs_member_t** members, int n, uint64_t committed_lsn,
walcompare_divergence_t* div)
{
walcompare_member_t wm[HCS_MAX_MEMBERS];
for (int i = 0; i < n; i++) {
wm[i].path = members[i]->wal_dir;
wm[i].committed_lsn = committed_lsn;
}
return walcompare(wm, n, NULL, div);
}
/* ── Seam 4: apply-gap injection (for 4.1 reconvergence) ────────────────────
* Park a member with committed > applied so a phase can assert it must NOT be
* reported converged until applied catches the committed boundary. Both are
* test-only atomic pokes of the canonical fields (ha->applied_lsn,
* persist->committed_lsn). */
static void hcs_inject_apply_gap(ha_state_t* ha, persist_t* p,
uint64_t applied_lsn, uint64_t committed_lsn)
{
atomic_store_explicit(&p->committed_lsn, committed_lsn, memory_order_release);
atomic_store_explicit(&ha->applied_lsn, applied_lsn, memory_order_release);
}
/* Committed-minus-applied gap (0 == fully applied / converged-eligible). */
static uint64_t hcs_apply_gap(ha_state_t* ha, persist_t* p)
{
uint64_t committed = atomic_load_explicit(&p->committed_lsn,
memory_order_acquire);
uint64_t applied = atomic_load_explicit(&ha->applied_lsn,
memory_order_acquire);
return committed > applied ? committed - applied : 0;
}
/* ── Seam 5: kill/election injection (for 5.1 election liveness/safety) ──────
* Deterministically advance a member's (term, role) the way a completed
* election round would, WITHOUT spinning real election threads — so a phase
* can script rapid rounds and assert per-term primary counts (Election Safety)
* and a higher term each round (liveness) without sleeps. */
static uint64_t hcs_inject_election(ha_state_t* ha, uint64_t new_term,
int32_t new_role)
{
atomic_store_explicit(&ha->term, new_term, memory_order_release);
atomic_store_explicit(&ha->role, new_role, memory_order_release);
return atomic_load_explicit(&ha->term, memory_order_acquire);
}
/* A 64-byte non-empty body whose payload CRC is non-zero — stands in for a
* real JOB_SUBMIT body (the live divergence dropped a 3560-byte one). */
#define HCS_FULL_LEN 64u
static void hcs_fill_body(uint8_t* buf)
{
for (uint32_t i = 0; i < HCS_FULL_LEN; i++)
buf[i] = (uint8_t)(0xA0 ^ i);
}
/* ═══════════════════════════════════════════════════════════════════════════
* SMOKE-RED — payload-blind persistence guard (FAILs on the baseline)
*
* Construct the live payload-loss signature at a single committed LSN: an
* empty (PLEN=0) cross-term catch-up body lands first, then the CORRECT full
* body is streamed for the SAME (term, lsn). The baseline guard is
* LSN-only-idempotent (persist.c:822 `lsn+1 <= current_lsn → return 0`), so it
* silently no-ops the corrective write and the WAL keeps the empty body.
*
* The harness OBSERVES the divergence via the readback probe: the entry is
* present, term is correct, but the payload is gone (len==0, pcrc==0). This
* FAILs now and goes GREEN when task 2.2 makes the guard
* (term, lsn, payload-crc)-aware (a differing payload-crc at an existing LSN
* is a conflict to repair, not an idempotent skip).
* ═══════════════════════════════════════════════════════════════════════════ */
static void test_smoke_red_payload_blind_guard_drops_corrective_write(void)
{
hcs_member_t m;
TEST_ASSERT_EQUAL_INT(0, hcs_member_up(&m, "smoke"));
uint8_t full[HCS_FULL_LEN];
hcs_fill_body(full);
/* (1) cross-term catch-up ships an empty body for committed LSN 1. */
int rc_empty = hcs_wal_put(&m, /*lsn=*/1, /*term=*/3, WAL_JOB_SUBMIT,
/*payload=*/NULL, /*len=*/0);
TEST_ASSERT_EQUAL_INT_MESSAGE(0, rc_empty,
"the empty replicated write must persist (needs a real fs for the "
"inline fdatasync; if this is -EIO you are on overlayfs — bug-924)");
/* (2) the CORRECT full body is later streamed for the same (term, lsn). */
int rc_full = hcs_wal_put(&m, /*lsn=*/1, /*term=*/3, WAL_JOB_SUBMIT,
full, HCS_FULL_LEN);
TEST_ASSERT_TRUE_MESSAGE(rc_full == 0,
"the corrective full-payload write returns 0 (baseline: an idempotent "
"no-op; fixed: an accepted repair) — either way rc==0");
/* (3) OBSERVE: on disk the entry must end with the FULL payload. */
hcs_probe_t pr;
TEST_ASSERT_EQUAL_INT(0, hcs_wal_probe(&m, 1, &pr));
TEST_ASSERT_TRUE_MESSAGE(pr.present,
"committed LSN 1 must be present after the corrective write");
TEST_ASSERT_EQUAL_UINT32_MESSAGE(HCS_FULL_LEN, pr.len,
"RED: the payload-blind (term,lsn)-only guard silently no-ops the "
"corrective full-payload write — the WAL keeps the empty (PLEN=0) "
"body at a committed LSN (cross-term catch-up payload loss)");
TEST_ASSERT_NOT_EQUAL_MESSAGE(0u, pr.pcrc,
"RED: the committed entry must hold the real payload CRC, not 0");
hcs_member_down(&m);
printf("test_smoke_red_payload_blind_guard_drops_corrective_write: done\n");
}
/* ═══════════════════════════════════════════════════════════════════════════
* SCAFFOLDING (PASS) — prove each shared seam is usable by later phases.
* ═══════════════════════════════════════════════════════════════════════════ */
/* Seam 1+2: a payload-controllable write is read back faithfully on disk. */
static void test_seam_wal_construct_and_probe(void)
{
hcs_member_t m;
TEST_ASSERT_EQUAL_INT(0, hcs_member_up(&m, "probe"));
uint8_t full[HCS_FULL_LEN];
hcs_fill_body(full);
TEST_ASSERT_EQUAL_INT(0,
hcs_wal_put(&m, 1, /*term=*/7, WAL_JOB_SUBMIT, full, HCS_FULL_LEN));
hcs_probe_t pr;
TEST_ASSERT_EQUAL_INT(0, hcs_wal_probe(&m, 1, &pr));
TEST_ASSERT_TRUE(pr.present);
TEST_ASSERT_EQUAL_UINT64(7, pr.term);
TEST_ASSERT_EQUAL_UINT32(HCS_FULL_LEN, pr.len);
TEST_ASSERT_NOT_EQUAL(0u, pr.pcrc);
/* An absent LSN reads back not-present (scan succeeds). */
hcs_probe_t pr2;
TEST_ASSERT_EQUAL_INT(0, hcs_wal_probe(&m, 2, &pr2));
TEST_ASSERT_FALSE(pr2.present);
hcs_member_down(&m);
printf("test_seam_wal_construct_and_probe: PASS\n");
}
/* Seam 3: a constructed payload divergence at an identical (term, lsn) is
* OBSERVABLE as a committed-prefix safety violation by walcompare. */
static void test_seam_divergence_is_observable(void)
{
hcs_member_t maj, min_;
TEST_ASSERT_EQUAL_INT(0, hcs_member_up(&maj, "maj"));
TEST_ASSERT_EQUAL_INT(0, hcs_member_up(&min_, "min"));
uint8_t full[HCS_FULL_LEN];
hcs_fill_body(full);
/* Same (term, lsn) on both, but the minority holds an empty body — the
* exact live signature (term identical, only payload-CRC diverges). */
TEST_ASSERT_EQUAL_INT(0,
hcs_wal_put(&maj, 1, /*term=*/3, WAL_JOB_SUBMIT, full, HCS_FULL_LEN));
TEST_ASSERT_EQUAL_INT(0,
hcs_wal_put(&min_, 1, /*term=*/3, WAL_JOB_SUBMIT, NULL, 0));
hcs_member_t* ms[2] = { &maj, &min_ };
walcompare_divergence_t div;
int verdict = hcs_walcompare(ms, 2, /*committed_lsn=*/1, &div);
TEST_ASSERT_EQUAL_INT_MESSAGE(WALCMP_DIVERGENT, verdict,
"the divergence-observation seam must flag the payload divergence");
TEST_ASSERT_EQUAL_UINT64(1, div.lsn);
TEST_ASSERT_EQUAL_INT(1, div.committed);
TEST_ASSERT_NOT_EQUAL_MESSAGE(div.member_pcrc[0], div.member_pcrc[1],
"the two members must disagree on payload CRC at the same committed LSN");
hcs_member_down(&maj);
hcs_member_down(&min_);
printf("test_seam_divergence_is_observable: PASS\n");
}
/* Shared ha_config for the ha-state seams (no pool → no delaymgr work). */
static ha_config_t g_hcs_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,
};
/* Seam 4: the apply-gap injection seam parks committed > applied and the gap
* query reflects it (used by 4.1 to gate "converged"). */
static void test_seam_apply_gap_injection(void)
{
hcs_member_t m;
TEST_ASSERT_EQUAL_INT(0, hcs_member_up(&m, "gap"));
ha_state_t ha;
ha_init(&ha, &g_hcs_cfg, 0);
hcs_inject_apply_gap(&ha, m.p, /*applied=*/5, /*committed=*/9);
TEST_ASSERT_EQUAL_UINT64_MESSAGE(4, hcs_apply_gap(&ha, m.p),
"an injected apply gap must be observable (committed 9 - applied 5)");
/* Closing the gap makes the member converged-eligible (gap == 0). */
hcs_inject_apply_gap(&ha, m.p, /*applied=*/9, /*committed=*/9);
TEST_ASSERT_EQUAL_UINT64(0, hcs_apply_gap(&ha, m.p));
ha_fini(&ha);
hcs_member_down(&m);
printf("test_seam_apply_gap_injection: PASS\n");
}
/* Seam 5: the kill/election injection seam advances (term, role)
* deterministically (used by 5.1 to script rapid rounds, no sleeps). */
static void test_seam_election_injection(void)
{
ha_state_t ha;
ha_init(&ha, &g_hcs_cfg, 0);
uint64_t prev = atomic_load_explicit(&ha.term, memory_order_acquire);
for (int round = 1; round <= 3; round++) {
uint64_t t = hcs_inject_election(&ha, prev + 1, (int32_t)CTRL_PRIMARY);
TEST_ASSERT_EQUAL_UINT64_MESSAGE(prev + 1, t,
"each scripted round must yield a strictly higher term (liveness)");
TEST_ASSERT_EQUAL_INT32(CTRL_PRIMARY,
atomic_load_explicit(&ha.role, memory_order_acquire));
prev = t;
}
ha_fini(&ha);
printf("test_seam_election_injection: PASS\n");
}
/* ── Suite entry point ─────────────────────────────────────────────────────*/
void test_ha_core_safety(void)
{
UNITY_BEGIN();
/* The ONE smoke-RED: payload-blind guard drops the corrective write. */
RUN_TEST(test_smoke_red_payload_blind_guard_drops_corrective_write);
/* Scaffolding: each shared seam is usable by 2.3 / 4.1 / 5.1. */
RUN_TEST(test_seam_wal_construct_and_probe);
RUN_TEST(test_seam_divergence_is_observable);
RUN_TEST(test_seam_apply_gap_injection);
RUN_TEST(test_seam_election_injection);
UNITY_END();
}