Merge branch 'working' into job_peek_lchen

job_peek_lchen: Add fetchfile feature (job output/error peek proxy)
working: HA/Raft infrastructure (consensus, replication, config mgmt)

Conflict resolution summary:
- controller.capnp: fetchfile @41, fetchfilereply @18, fetchfilemsg @30
- cluster.c: bifurcated OPCode_reply for fetchfile + config-push ACK
- job.c: kept full fetchfile pending-request subsystem (~183 lines)
- request.c: kept both xce_fetchfile_req() and XCE_RETRY_DEFAULTS
- helper.c: added missing preVoteRequest/preVoteReply mappings
- All 15 new test registrations (test_cbuf.c + 14 HA tests)
This commit is contained in:
Lei Chen 2026-07-22 13:57:25 +08:00
commit 3a2094a461
529 changed files with 345174 additions and 5048 deletions

View file

@ -0,0 +1,107 @@
/*
* failover_harness.h — reusable, test-only fixtures for the libxce-failover
* spec (test_libxce_retry.c and the later component tasks 2.1/2.2/2.3/3.1/3.2,
* plus the concurrency tasks 5.1/6.1).
*
* This header is HARNESS ONLY. It references NO not-yet-existing failover
* production symbols (e.g. xce_failover_classify, xcecli_retry_next,
* xce_failover_rotate) — those arrive in later tasks. Everything here compiles
* and links against the current libxce surface so the lib test suite stays
* GREEN between this task (1.2) and the component tasks.
*
* What it provides:
* 1. A fake multi-master client config (3 masters) + a master-list cursor
* that the rotation logic will later consume (advance/wrap).
* 2. A controller-status taxonomy table mapping every status the failover
* classifier must handle (REDIRECT with/without hint, NOMASTER, NOT_READY,
* STANDBY, DUP_JOB/EEXIST) and the transport errors (ECONNREFUSED,
* ETIMEDOUT, ECONNRESET) to a stable description + expected failover
* action category — so later tasks can table-drive the classifier RED/GREEN.
* 3. A generic N-pthread concurrency harness so 5.1/6.1 can spawn N threads
* against a shared ctx without re-rolling pthread plumbing.
*
* Requirements laid down for: 2.5, 4.1-4.5, 5.2, 8.3, 8.4 (taxonomy table);
* 2.1, 2.4, 8.4 (master cursor); 6.3 / concurrency reuse (thread harness).
*/
#ifndef XCE_FAILOVER_HARNESS_H
#define XCE_FAILOVER_HARNESS_H
#define _GNU_SOURCE
#include <errno.h>
#include <pthread.h>
#include <stdbool.h>
#include <stddef.h>
#include "xce/cluster.h" /* xce_cluster_config_t, xce_candidate_t, MAX_HA_CANDIDATES */
#include "xce/errnox.h" /* E_XCE_REDIRECT / NOMASTER / NOT_READY / STANDBY / DUP_JOB */
/* ── 1. Fake multi-master config + cursor ─────────────────────────────── */
/* Number of fake masters the rotation fixtures stand up. Kept <= MAX_HA_CANDIDATES. */
#define FO_FAKE_MASTERS 3
/* A master-list cursor: mirrors the "advance to next master / wrap" behaviour
* the real EndpointRotator (task 2.2) will drive over api_ctx->conf. Pure value
* type — no I/O, no production dependency. */
typedef struct {
const xce_cluster_config_t *cfg;
int cur; /* index into cfg->candidates[] */
} fo_master_cursor_t;
/* Populate `cfg` with FO_FAKE_MASTERS distinct host:port candidates. Returns the
* candidate count written. */
int fo_make_fake_masters(xce_cluster_config_t *cfg);
/* Initialise a cursor at master 0. */
void fo_cursor_init(fo_master_cursor_t *cur, const xce_cluster_config_t *cfg);
/* Advance the cursor to the next master, wrapping at the end. Returns the new
* index. */
int fo_cursor_advance(fo_master_cursor_t *cur);
/* The candidate the cursor currently points at (NULL if cfg empty). */
const xce_candidate_t *fo_cursor_current(const fo_master_cursor_t *cur);
/* ── 2. Controller-status taxonomy table ──────────────────────────────── */
/* Expected failover action for each status — mirrors design.md StatusClassifier
* (XCE_FO_SUCCESS / RETRY_NOW / RETRY_BACKOFF / TERMINAL). This is the HARNESS'
* expectation table; the real xce_fo_action_t enum is introduced by task 2.1,
* and its tests will assert the real classifier matches these expectations. */
typedef enum {
FO_ACT_SUCCESS = 0, /* status 0, or idempotent DUP replay on a re-sent write */
FO_ACT_RETRY_NOW, /* REDIRECT / STANDBY / transport error — rotate, no backoff */
FO_ACT_RETRY_BACKOFF,/* NOMASTER / NOT_READY — rotate after jittered backoff */
FO_ACT_TERMINAL /* non-retriable — surface to caller */
} fo_expected_action_t;
typedef struct {
const char *name; /* human label, e.g. "E_XCE_REDIRECT+hint" */
int status; /* status/rc as the classifier sees it */
bool has_hint; /* redirect carried an address hint? */
bool is_transport;/* negative -errno transport error? */
fo_expected_action_t expect; /* expected first-send classification */
const char *desc; /* one-line description */
} fo_status_row_t;
/* The complete taxonomy table and its length. Defined in test_libxce_retry.c.
* Covers: REDIRECT (with + without hint), NOMASTER, NOT_READY, STANDBY,
* DUP_JOB, -EEXIST, and the three transport errors. */
extern const fo_status_row_t FO_STATUS_TABLE[];
extern const size_t FO_STATUS_TABLE_LEN;
/* Look up a row by its `status` field (first match), or NULL. */
const fo_status_row_t *fo_status_lookup(int status);
/* ── 3. Generic N-thread concurrency harness ──────────────────────────── */
/* A worker fn run on each spawned thread. `ctx` is a shared context the caller
* owns; `idx` is the thread's 0-based index. */
typedef void (*fo_thread_fn)(void *ctx, int idx);
/* Spawn `n` pthreads, each invoking `fn(ctx, idx)`, and join them all. Returns 0
* if every thread was created and joined, -errno on the first failure. Generic
* so 5.1 (shared-ctx safety) and 6.1 (50-client jitter) reuse it unchanged. */
int fo_run_threads(int n, fo_thread_fn fn, void *ctx);
#endif /* XCE_FAILOVER_HARNESS_H */

View file

@ -30,6 +30,19 @@ extern void test_qobj(void);
extern void test_wobj(void);
extern void test_request(void);
extern void test_cbuf(void);
extern void test_zarray_cow(void);
extern void test_kvmgr_capture(void);
extern void test_hooks(void);
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_arm_failure(void);
extern void test_netio_close_propagation(void);
extern void test_request_send_propagation(void);
extern void test_raft(void);
extern void test_wal_wire(void);
extern void test_ctrlstatus(void);
void setUp(void) { return; }
@ -129,6 +142,19 @@ int main(int argc, char* argv[])
TEST(test_qobj);
TEST(test_request);
TEST(test_cbuf);
TEST(test_zarray_cow);
TEST(test_kvmgr_capture);
TEST(test_hooks);
TEST(test_cluster);
TEST(test_cluster_parse);
TEST(test_libxce_retry);
TEST(test_netio_on_connected);
TEST(test_netio_arm_failure);
TEST(test_netio_close_propagation);
TEST(test_request_send_propagation);
TEST(test_raft);
TEST(test_wal_wire);
TEST(test_ctrlstatus);
return UNITY_END();
}

View file

@ -0,0 +1,181 @@
#include <errno.h>
#include <stdio.h>
#include <string.h>
#include <unity/unity.h>
#include "xce/cluster.h"
/* ── Test seam: mock connect function ───────────────────────────── */
static int s_connect_call_count = 0;
static int s_connect_fail_n = 0; /* fail first N attempts, then succeed */
static char s_last_host[256] = "";
static int s_last_port = 0;
static void* mock_connect(const char* host, int port, const char* ca_cert)
{
(void)ca_cert;
strncpy(s_last_host, host, sizeof(s_last_host) - 1);
s_last_port = port;
s_connect_call_count++;
if (s_connect_call_count <= s_connect_fail_n)
return NULL;
return (void*)0xDEADBEEF; /* non-NULL = success */
}
static xce_cluster_config_t make_valid_config(void)
{
xce_cluster_config_t cfg = {0};
strncpy(cfg.candidates[0].hostname, "10.0.1.1",
XCE_MAXHOSTNAME_LEN - 1);
cfg.candidates[0].port = 7000;
strncpy(cfg.candidates[1].hostname, "10.0.1.2",
XCE_MAXHOSTNAME_LEN - 1);
cfg.candidates[1].port = 7001;
cfg.candidates_count = 2;
strncpy(cfg.ca_cert_path, "/etc/xce/ca.pem",
sizeof(cfg.ca_cert_path) - 1);
strncpy(cfg.identity, "worker-01", sizeof(cfg.identity) - 1);
return cfg;
}
/* ── Test cases ─────────────────────────────────────────────────── */
/* Test 1: validate rejects empty config */
static void test_validate_rejects_zero_candidates(void)
{
xce_cluster_config_t cfg = {0};
cfg.candidates_count = 0;
TEST_ASSERT_NOT_EQUAL(0, xce_cluster_config_validate(&cfg));
printf("test_validate_rejects_zero_candidates: PASS\n");
}
/* Test 2: validate rejects too many candidates */
static void test_validate_rejects_over_max_candidates(void)
{
xce_cluster_config_t cfg = {0};
cfg.candidates_count = MAX_HA_CANDIDATES + 1;
TEST_ASSERT_NOT_EQUAL(0, xce_cluster_config_validate(&cfg));
printf("test_validate_rejects_over_max_candidates: PASS\n");
}
/* Test 3: validate rejects missing identity */
static void test_validate_rejects_empty_identity(void)
{
xce_cluster_config_t cfg = make_valid_config();
cfg.identity[0] = '\0';
TEST_ASSERT_NOT_EQUAL(0, xce_cluster_config_validate(&cfg));
printf("test_validate_rejects_empty_identity: PASS\n");
}
/* Test 4: validate accepts valid config */
static void test_validate_accepts_valid_config(void)
{
xce_cluster_config_t cfg = make_valid_config();
TEST_ASSERT_EQUAL_INT(0, xce_cluster_config_validate(&cfg));
printf("test_validate_accepts_valid_config: PASS\n");
}
/* Test 5: connect succeeds on first candidate */
static void test_connect_succeeds_first_candidate(void)
{
xce_cluster_config_t cfg = make_valid_config();
s_connect_call_count = 0;
s_connect_fail_n = 0;
xce_cluster_set_connect_fn(mock_connect);
void* ep = xce_connect_cluster(&cfg);
TEST_ASSERT_NOT_NULL(ep);
TEST_ASSERT_EQUAL_INT(1, s_connect_call_count);
TEST_ASSERT_EQUAL_STRING("10.0.1.1", s_last_host);
TEST_ASSERT_EQUAL_INT(7000, s_last_port);
xce_cluster_set_connect_fn(NULL);
printf("test_connect_succeeds_first_candidate: PASS\n");
}
/* Test 6: connect falls back to second candidate when first fails */
static void test_connect_fallback_to_second_candidate(void)
{
xce_cluster_config_t cfg = make_valid_config();
s_connect_call_count = 0;
s_connect_fail_n = 1; /* first attempt fails */
xce_cluster_set_connect_fn(mock_connect);
void* ep = xce_connect_cluster(&cfg);
TEST_ASSERT_NOT_NULL(ep);
TEST_ASSERT_EQUAL_INT(2, s_connect_call_count);
TEST_ASSERT_EQUAL_STRING("10.0.1.2", s_last_host);
xce_cluster_set_connect_fn(NULL);
printf("test_connect_fallback_to_second_candidate: PASS\n");
}
/* Test 7: connect returns NULL when all candidates fail */
static void test_connect_returns_null_all_fail(void)
{
xce_cluster_config_t cfg = make_valid_config();
s_connect_call_count = 0;
s_connect_fail_n = 999; /* all fail */
xce_cluster_set_connect_fn(mock_connect);
void* ep = xce_connect_cluster(&cfg);
TEST_ASSERT_NULL(ep);
xce_cluster_set_connect_fn(NULL);
printf("test_connect_returns_null_all_fail: PASS\n");
}
/* Test 8: reconnect state machine — IDLE → CONNECTING → CONNECTED */
static void test_reconnect_step_reaches_connected(void)
{
xce_cluster_config_t cfg = make_valid_config();
xce_reconnect_ctx_t rctx;
s_connect_call_count = 0;
s_connect_fail_n = 0;
xce_cluster_set_connect_fn(mock_connect);
xce_reconnect_init(&rctx, &cfg);
TEST_ASSERT_EQUAL_INT(XCE_RECONNECT_IDLE, (int)rctx.state);
int steps = 0;
while (rctx.state != XCE_RECONNECT_CONNECTED &&
rctx.state != XCE_RECONNECT_FAILED && steps < 100) {
xce_reconnect_step(&rctx);
steps++;
}
TEST_ASSERT_EQUAL_INT(XCE_RECONNECT_CONNECTED, (int)rctx.state);
TEST_ASSERT_NOT_NULL(rctx.endpoint);
xce_reconnect_fini(&rctx);
xce_cluster_set_connect_fn(NULL);
printf("test_reconnect_step_reaches_connected: PASS\n");
}
/* Test 9: reconnect state machine backs off on failure */
static void test_reconnect_step_backs_off_on_failure(void)
{
xce_cluster_config_t cfg = make_valid_config();
cfg.candidates_count = 1; /* only one candidate so it hits backoff */
xce_reconnect_ctx_t rctx;
s_connect_call_count = 0;
s_connect_fail_n = 999; /* always fail */
xce_cluster_set_connect_fn(mock_connect);
xce_reconnect_init(&rctx, &cfg);
xce_reconnect_step(&rctx); /* IDLE → CONNECTING (attempt connect) */
xce_reconnect_step(&rctx); /* CONNECTING → BACKOFF (all candidates exhausted) */
/* When mock is set, sleep is skipped — state must be BACKOFF */
TEST_ASSERT_EQUAL_INT(XCE_RECONNECT_BACKOFF, (int)rctx.state);
TEST_ASSERT_TRUE(rctx.backoff_ms > 0);
xce_reconnect_fini(&rctx);
xce_cluster_set_connect_fn(NULL);
printf("test_reconnect_step_backs_off_on_failure: PASS\n");
}
/* ── Test group entry point ─────────────────────────────────────── */
void test_cluster(void)
{
RUN_TEST(test_validate_rejects_zero_candidates);
RUN_TEST(test_validate_rejects_over_max_candidates);
RUN_TEST(test_validate_rejects_empty_identity);
RUN_TEST(test_validate_accepts_valid_config);
RUN_TEST(test_connect_succeeds_first_candidate);
RUN_TEST(test_connect_fallback_to_second_candidate);
RUN_TEST(test_connect_returns_null_all_fail);
RUN_TEST(test_reconnect_step_reaches_connected);
RUN_TEST(test_reconnect_step_backs_off_on_failure);
}

View file

@ -0,0 +1,160 @@
/* test_cluster_parse.c — unit tests for the parse_peers() helper used by
* worker and scheduler main.c (P2.5-5 / P2.5-6).
*
* The parser is a static function in the respective main.c files. To keep
* the test self-contained we duplicate a simplified reference implementation
* here and test the same contract. Integration with the real binaries is
* covered by the build (both must compile).
*/
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unity/unity.h>
#include "xce/cluster.h"
/* ── Reference implementation of parse_peers (mirrors worker/main.c) ─── */
static int parse_peers_local(const char* peers_str, xce_cluster_config_t* cfg)
{
cfg->candidates_count = 0;
if (!peers_str || peers_str[0] == '\0')
return -1;
char buf[2048];
strncpy(buf, peers_str, sizeof(buf) - 1);
buf[sizeof(buf) - 1] = '\0';
char* saveptr;
char* tok = strtok_r(buf, ",", &saveptr);
while (tok && cfg->candidates_count < MAX_HA_CANDIDATES) {
/* Format: name=host:port OR host:port */
char* eq = strchr(tok, '=');
char* addr = eq ? eq + 1 : tok;
char* colon = strrchr(addr, ':');
if (!colon)
return -1;
*colon = '\0';
strncpy(cfg->candidates[cfg->candidates_count].hostname, addr,
XCE_MAXHOSTNAME_LEN - 1);
cfg->candidates[cfg->candidates_count].hostname[XCE_MAXHOSTNAME_LEN - 1]
= '\0';
cfg->candidates[cfg->candidates_count].port = atoi(colon + 1);
cfg->candidates_count++;
tok = strtok_r(NULL, ",", &saveptr);
}
return cfg->candidates_count > 0 ? 0 : -1;
}
/* ── Test cases ─────────────────────────────────────────────────────── */
static void test_parse_peers_single(void)
{
xce_cluster_config_t cfg = {0};
int rc = parse_peers_local("ctrl-1=10.0.1.1:7000", &cfg);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(1, cfg.candidates_count);
TEST_ASSERT_EQUAL_STRING("10.0.1.1", cfg.candidates[0].hostname);
TEST_ASSERT_EQUAL_INT(7000, cfg.candidates[0].port);
printf("test_parse_peers_single: PASS\n");
}
static void test_parse_peers_multiple(void)
{
xce_cluster_config_t cfg = {0};
int rc = parse_peers_local(
"ctrl-1=10.0.1.1:7000,ctrl-2=10.0.1.2:7001,ctrl-3=10.0.1.3:7002",
&cfg);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(3, cfg.candidates_count);
TEST_ASSERT_EQUAL_STRING("10.0.1.2", cfg.candidates[1].hostname);
TEST_ASSERT_EQUAL_INT(7001, cfg.candidates[1].port);
TEST_ASSERT_EQUAL_STRING("10.0.1.3", cfg.candidates[2].hostname);
TEST_ASSERT_EQUAL_INT(7002, cfg.candidates[2].port);
printf("test_parse_peers_multiple: PASS\n");
}
static void test_parse_peers_no_name(void)
{
xce_cluster_config_t cfg = {0};
int rc = parse_peers_local("10.0.1.1:7000,10.0.1.2:7001", &cfg);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(2, cfg.candidates_count);
TEST_ASSERT_EQUAL_STRING("10.0.1.1", cfg.candidates[0].hostname);
TEST_ASSERT_EQUAL_INT(7000, cfg.candidates[0].port);
TEST_ASSERT_EQUAL_STRING("10.0.1.2", cfg.candidates[1].hostname);
TEST_ASSERT_EQUAL_INT(7001, cfg.candidates[1].port);
printf("test_parse_peers_no_name: PASS\n");
}
static void test_parse_peers_empty_returns_error(void)
{
xce_cluster_config_t cfg = {0};
int rc = parse_peers_local("", &cfg);
TEST_ASSERT_NOT_EQUAL(0, rc);
printf("test_parse_peers_empty_returns_error: PASS\n");
}
static void test_parse_peers_null_returns_error(void)
{
xce_cluster_config_t cfg = {0};
int rc = parse_peers_local(NULL, &cfg);
TEST_ASSERT_NOT_EQUAL(0, rc);
printf("test_parse_peers_null_returns_error: PASS\n");
}
static void test_parse_peers_missing_port_returns_error(void)
{
xce_cluster_config_t cfg = {0};
int rc = parse_peers_local("ctrl-1=10.0.1.1", &cfg); /* no colon:port */
TEST_ASSERT_NOT_EQUAL(0, rc);
printf("test_parse_peers_missing_port_returns_error: PASS\n");
}
static void test_parse_peers_first_entry_parsed_correctly(void)
{
/* Verify the name= prefix is stripped and only addr:port is stored */
xce_cluster_config_t cfg = {0};
int rc = parse_peers_local("primary=192.168.10.1:9000", &cfg);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_STRING("192.168.10.1", cfg.candidates[0].hostname);
TEST_ASSERT_EQUAL_INT(9000, cfg.candidates[0].port);
printf("test_parse_peers_first_entry_parsed_correctly: PASS\n");
}
static void test_parse_peers_respects_max_candidates(void)
{
/* Build a string with MAX_HA_CANDIDATES + 2 entries; only MAX accepted */
char big[2048] = "";
char entry[128] = "";
int n = MAX_HA_CANDIDATES + 2;
for (int i = 0; i < n; i++) {
snprintf(entry, sizeof(entry), "10.0.1.%d:%d", i + 1, 7000 + i);
if (i > 0)
strncat(big, ",", sizeof(big) - strlen(big) - 1);
strncat(big, entry, sizeof(big) - strlen(big) - 1);
}
xce_cluster_config_t cfg = {0};
int rc = parse_peers_local(big, &cfg);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(MAX_HA_CANDIDATES, cfg.candidates_count);
printf("test_parse_peers_respects_max_candidates: PASS\n");
}
/* ── Test group entry point ─────────────────────────────────────────── */
void test_cluster_parse(void)
{
RUN_TEST(test_parse_peers_single);
RUN_TEST(test_parse_peers_multiple);
RUN_TEST(test_parse_peers_no_name);
RUN_TEST(test_parse_peers_empty_returns_error);
RUN_TEST(test_parse_peers_null_returns_error);
RUN_TEST(test_parse_peers_missing_port_returns_error);
RUN_TEST(test_parse_peers_first_entry_parsed_correctly);
RUN_TEST(test_parse_peers_respects_max_candidates);
}

View file

@ -27,6 +27,27 @@ static void test_array(void)
TEST_ASSERT_TRUE(MAX_ARRAY_IDX == 32767);
}
static void test_persistence_constants(void)
{
TEST_ASSERT_TRUE(MAX_STANDBYS == 3);
TEST_ASSERT_TRUE(MAX_HA_CANDIDATES == 4);
TEST_ASSERT_TRUE(WAL_FORMAT_VERSION == 1);
TEST_ASSERT_TRUE(WAL_ENTRY_TYPE_MAX == 100);
TEST_ASSERT_TRUE(MAX_WAL_PAYLOAD_BYTES == (1 << 20));
TEST_ASSERT_TRUE(MAX_SNAPSHOT_BYTES == (256 * 1024 * 1024));
TEST_ASSERT_TRUE(DEFAULT_WAL_SYNC_MS == 10);
TEST_ASSERT_TRUE(DEFAULT_SNAP_INTERVAL_S == 60);
TEST_ASSERT_TRUE(DEFAULT_SNAP_WAL_SIZE_MB == 64);
TEST_ASSERT_TRUE(DEFAULT_SNAP_KEEP_N == 2);
TEST_ASSERT_TRUE(DEFAULT_FINISHED_RETAIN_S == 3600);
TEST_ASSERT_TRUE(DEFAULT_WORKER_RETAIN_S == 86400);
TEST_ASSERT_TRUE(DEFAULT_SYNC_TIMEOUT_MS == 500);
TEST_ASSERT_TRUE(DEFAULT_HB_INTERVAL_MS == 3000);
TEST_ASSERT_TRUE(DEFAULT_TRANSIENT_JOB_TIMEOUT_S == 300);
TEST_ASSERT_TRUE(WAL_HDR_CRC_SIZE == 32);
TEST_ASSERT_TRUE(WAL_CRC_NONE == 0xFFFFFFFFu);
}
void test_config(void)
{
printf("\n>> config test\n\n");
@ -36,4 +57,5 @@ void test_config(void)
RUN_TEST(test_timestamp);
RUN_TEST(test_tag);
RUN_TEST(test_array);
RUN_TEST(test_persistence_constants);
}

View file

@ -0,0 +1,405 @@
/* test_ctrlstatus.c — Unity tests for the controller-status (ctrlstatus)
* libxce client reply path (controller-status task 3.1).
*
* Proves the reply path that xcecli_ctrlstatus relies on:
* 1. A true ENCODE→DECODE round-trip of xce_ctrlstatus_t through the
* generated Cap'n Proto codec (encode_CtrlStatus/write_CtrlStatus →
* bytes → read_CtrlStatus/decode_CtrlStatus): every status field,
* including the peers list and the configChecksum hex Text, survives
* the wire (Req 1.1 — the client decodes every field a handler emits).
* 2. xcecli_ctrlstatus(NULL, ...) returns a typed negative rather than a
* partial/empty success (Req 7.3 — no reachable controller ⇒ typed
* failure).
*
* A full live round-trip (client → running controller → handler reply)
* needs a controller and is the E2E task; here we prove the encode/decode
* round-trip + the no-context typed failure only.
*/
#include <errno.h>
#include <stdlib.h>
#include <string.h>
#include <unity/unity.h>
#include "client.h"
#include "config.h" /* XCE_MAXHOSTNAME_LEN */
#include "request.h"
#include "xce/types.h"
#include "xcectx.h" /* xce_ctx_t, xce_handle_reply (bug-1077 regression) */
/* generated Cap'n Proto codec for CtrlStatus / CtrlPeer */
#include "controller.capnp.h"
/* ── encode→decode round-trip of xce_ctrlstatus_t ───────────────────────── */
static void test_ctrlstatus_roundtrip_all_fields(void)
{
/* Arrange: a fully-populated status with two configured peers */
xce_ctrlpeer_t p0 = {
.name = "ctrl-a", .host = "10.0.0.1", .port = 9991
};
xce_ctrlpeer_t p1 = {
.name = "ctrl-b", .host = "10.0.0.2", .port = 9992
};
xce_ctrlpeer_t* peerlist[2] = { &p0, &p1 };
xce_ctrlstatus_t in;
memset(&in, 0, sizeof(in));
in.clusterName = "lab-cluster";
in.primaryName = "ctrl-a";
in.responderName = "ctrl-b";
in.responderRole = 3; /* STANDBY */
in.term = 42;
in.configVersion = 0x1122334455667788ULL;
in.configChecksum = "1122334455667788aabbccddeeff0011"
"2233445566778899aabbccddeeff0011";
in.committedConfigVersion = 0x1122334455667788ULL;
in.committedLsn = 5000;
in.appliedLsn = 4999;
in.fenced = 1;
in.peers.count = 2;
in.peers.list = peerlist;
in.peerCount = 2;
/* Act: encode into a capnp segment, serialize to bytes, then decode
* a fresh struct back out — mirrors xce_encode/xce_decode. */
struct capn c;
struct capn_segment* cs;
struct CtrlStatus cap_in;
CtrlStatus_ptr sp;
capn_init_malloc(&c);
cs = capn_root(&c).seg;
memset(&cap_in, 0, sizeof(cap_in));
encode_CtrlStatus(cs, &cap_in, &in);
sp = new_CtrlStatus(cs);
write_CtrlStatus(&cap_in, sp);
capn_setp(capn_root(&c), 0, sp.p);
size_t sz = capn_size(&c);
uint8_t* buf = (uint8_t*)malloc(sz);
TEST_ASSERT_NOT_NULL(buf);
TEST_ASSERT_EQUAL_size_t(sz, capn_write_mem(&c, buf, sz, 0));
capn_free(&c);
/* decode side */
struct capn dc;
struct CtrlStatus cap_out;
CtrlStatus_ptr dp;
xce_ctrlstatus_t out;
capn_init_mem(&dc, buf, sz, 0);
dp.p = capn_getp(capn_root(&dc), 0, 1);
read_CtrlStatus(&cap_out, dp);
memset(&out, 0, sizeof(out));
decode_CtrlStatus(&out, &cap_out);
/* Assert: every scalar/text field survived */
TEST_ASSERT_NOT_NULL(out.clusterName);
TEST_ASSERT_EQUAL_STRING("lab-cluster", out.clusterName);
TEST_ASSERT_NOT_NULL(out.primaryName);
TEST_ASSERT_EQUAL_STRING("ctrl-a", out.primaryName);
TEST_ASSERT_NOT_NULL(out.responderName);
TEST_ASSERT_EQUAL_STRING("ctrl-b", out.responderName);
TEST_ASSERT_EQUAL_UINT8(3, out.responderRole);
TEST_ASSERT_EQUAL_UINT64(42, out.term);
TEST_ASSERT_EQUAL_UINT64(0x1122334455667788ULL, out.configVersion);
TEST_ASSERT_NOT_NULL(out.configChecksum);
TEST_ASSERT_EQUAL_STRING(in.configChecksum, out.configChecksum);
TEST_ASSERT_EQUAL_UINT64(0x1122334455667788ULL, out.committedConfigVersion);
TEST_ASSERT_EQUAL_UINT64(5000, out.committedLsn);
TEST_ASSERT_EQUAL_UINT64(4999, out.appliedLsn);
TEST_ASSERT_EQUAL_UINT32(1, out.fenced);
TEST_ASSERT_EQUAL_UINT32(2, out.peerCount);
/* Assert: the peers list survived with both entries */
TEST_ASSERT_EQUAL_INT(2, out.peers.count);
TEST_ASSERT_NOT_NULL(out.peers.list);
TEST_ASSERT_NOT_NULL(out.peers.list[0]);
TEST_ASSERT_EQUAL_STRING("ctrl-a", out.peers.list[0]->name);
TEST_ASSERT_EQUAL_STRING("10.0.0.1", out.peers.list[0]->host);
TEST_ASSERT_EQUAL_UINT32(9991, out.peers.list[0]->port);
TEST_ASSERT_NOT_NULL(out.peers.list[1]);
TEST_ASSERT_EQUAL_STRING("ctrl-b", out.peers.list[1]->name);
TEST_ASSERT_EQUAL_STRING("10.0.0.2", out.peers.list[1]->host);
TEST_ASSERT_EQUAL_UINT32(9992, out.peers.list[1]->port);
free_CtrlStatus(&out);
capn_free(&dc);
free(buf);
printf("test_ctrlstatus_roundtrip_all_fields: PASS\n");
}
/* An "unknown" primary (empty primaryName) must round-trip as empty/NULL,
* never garbage — the client renders it as "unknown" (Req 1.3, 2.2). */
static void test_ctrlstatus_roundtrip_unknown_primary(void)
{
xce_ctrlstatus_t in;
memset(&in, 0, sizeof(in));
in.clusterName = "lab-cluster";
in.primaryName = ""; /* unknown */
in.responderName = "ctrl-a";
in.responderRole = 1; /* ELECTING */
in.peers.count = 0;
in.peers.list = NULL;
in.peerCount = 0;
struct capn c;
struct capn_segment* cs;
struct CtrlStatus cap_in;
CtrlStatus_ptr sp;
capn_init_malloc(&c);
cs = capn_root(&c).seg;
memset(&cap_in, 0, sizeof(cap_in));
encode_CtrlStatus(cs, &cap_in, &in);
sp = new_CtrlStatus(cs);
write_CtrlStatus(&cap_in, sp);
capn_setp(capn_root(&c), 0, sp.p);
size_t sz = capn_size(&c);
uint8_t* buf = (uint8_t*)malloc(sz);
TEST_ASSERT_NOT_NULL(buf);
capn_write_mem(&c, buf, sz, 0);
capn_free(&c);
struct capn dc;
struct CtrlStatus cap_out;
CtrlStatus_ptr dp;
xce_ctrlstatus_t out;
capn_init_mem(&dc, buf, sz, 0);
dp.p = capn_getp(capn_root(&dc), 0, 1);
read_CtrlStatus(&cap_out, dp);
memset(&out, 0, sizeof(out));
decode_CtrlStatus(&out, &cap_out);
TEST_ASSERT_EQUAL_STRING("ctrl-a", out.responderName);
TEST_ASSERT_EQUAL_UINT8(1, out.responderRole);
/* empty primaryName decodes to NULL or "" — not a stale/guessed value */
if (out.primaryName != NULL) {
TEST_ASSERT_EQUAL_STRING("", out.primaryName);
}
TEST_ASSERT_EQUAL_INT(0, out.peers.count);
TEST_ASSERT_EQUAL_UINT32(0, out.peerCount);
free_CtrlStatus(&out);
capn_free(&dc);
free(buf);
printf("test_ctrlstatus_roundtrip_unknown_primary: PASS\n");
}
/* ── no reachable controller ⇒ typed failure (Req 7.3) ──────────────────── */
static void test_xcecli_ctrlstatus_null_ctx_typed_failure(void)
{
xce_ctrlstatus_t* status = (xce_ctrlstatus_t*)0xdeadbeef;
int rc = xcecli_ctrlstatus(NULL, &status);
/* typed negative, not a partial/empty success */
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
printf("test_xcecli_ctrlstatus_null_ctx_typed_failure: PASS\n");
}
/* ── authoritative (primary-only) read: task 3.2 ─────────────────────────── */
/* The authoritative call must reject a NULL/unconnected ctx with the same
* typed negative as the any-node read — it must NEVER hand back a hint reply
* as the authoritative result (Req 9.2). The `status` out-param is left
* untouched on failure (it still holds the poisoned sentinel below). */
static void test_xcecli_ctrlstatus_primary_null_ctx_typed_failure(void)
{
xce_ctrlstatus_t* status = (xce_ctrlstatus_t*)0xdeadbeef;
int rc = xcecli_ctrlstatus_primary(NULL, &status);
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
/* never returned a hint reply as the authoritative result */
TEST_ASSERT_EQUAL_PTR((xce_ctrlstatus_t*)0xdeadbeef, status);
printf("test_xcecli_ctrlstatus_primary_null_ctx_typed_failure: PASS\n");
}
/* NULL status out-param is also a typed failure (boundary validation). */
static void test_xcecli_ctrlstatus_primary_null_out_typed_failure(void)
{
int rc = xcecli_ctrlstatus_primary(NULL, NULL);
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
printf("test_xcecli_ctrlstatus_primary_null_out_typed_failure: PASS\n");
}
/* The pure authoritative-decision helper: a reply is authoritative iff the
* responder's role is PRIMARY (ctrl_role_t PRIMARY == 0). A standby (1),
* init (2), or electing (3) responder is a non-authoritative HINT — the call
* must keep following the redirect (Req 9.1, 2.3, 3.2). */
static void test_ctrlstatus_is_authoritative_decision(void)
{
xce_ctrlstatus_t reply;
memset(&reply, 0, sizeof(reply));
reply.responderRole = 0; /* PRIMARY */
TEST_ASSERT_TRUE(xcecli_ctrlstatus_is_authoritative(&reply));
reply.responderRole = 1; /* STANDBY */
TEST_ASSERT_FALSE(xcecli_ctrlstatus_is_authoritative(&reply));
reply.responderRole = 2; /* INIT */
TEST_ASSERT_FALSE(xcecli_ctrlstatus_is_authoritative(&reply));
reply.responderRole = 3; /* ELECTING */
TEST_ASSERT_FALSE(xcecli_ctrlstatus_is_authoritative(&reply));
/* NULL reply is never authoritative (defensive). */
TEST_ASSERT_FALSE(xcecli_ctrlstatus_is_authoritative(NULL));
printf("test_ctrlstatus_is_authoritative_decision: PASS\n");
}
/* The pure next-target helper: given a standby's hint reply, map the reported
* primaryName to its host:port via the reply's peers list so the next query
* can target the primary directly (the redirect step of Req 9.1). */
static void test_ctrlstatus_primary_addr_from_peers(void)
{
xce_ctrlpeer_t p0 = { .name = "ctrl-a", .host = "10.0.0.1", .port = 9991 };
xce_ctrlpeer_t p1 = { .name = "ctrl-b", .host = "10.0.0.2", .port = 9992 };
xce_ctrlpeer_t* peerlist[2] = { &p0, &p1 };
xce_ctrlstatus_t hint;
memset(&hint, 0, sizeof(hint));
hint.responderName = "ctrl-a";
hint.responderRole = 1; /* STANDBY */
hint.primaryName = "ctrl-b"; /* the standby knows ctrl-b is primary */
hint.peers.count = 2;
hint.peers.list = peerlist;
hint.peerCount = 2;
char host[XCE_MAXHOSTNAME_LEN];
uint16_t port = 0;
int rc = xcecli_ctrlstatus_primary_addr(&hint, host, sizeof(host), &port);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_STRING("10.0.0.2", host);
TEST_ASSERT_EQUAL_UINT16(9992, port);
printf("test_ctrlstatus_primary_addr_from_peers: PASS\n");
}
/* When the standby reports no known primary (empty primaryName) or the named
* primary is not in the peers list, the address lookup fails (-ENOENT) so the
* caller falls back to plain master rotation rather than targeting a bogus
* address (Req 2.3 unknown-primary, redirect best-effort). */
static void test_ctrlstatus_primary_addr_unknown_or_missing(void)
{
xce_ctrlpeer_t p0 = { .name = "ctrl-a", .host = "10.0.0.1", .port = 9991 };
xce_ctrlpeer_t* peerlist[1] = { &p0 };
xce_ctrlstatus_t hint;
memset(&hint, 0, sizeof(hint));
hint.responderName = "ctrl-a";
hint.responderRole = 3; /* ELECTING */
hint.peers.count = 1;
hint.peers.list = peerlist;
hint.peerCount = 1;
char host[XCE_MAXHOSTNAME_LEN];
uint16_t port = 7;
/* unknown primary (empty) */
hint.primaryName = "";
TEST_ASSERT_EQUAL_INT(-ENOENT, xcecli_ctrlstatus_primary_addr(
&hint, host, sizeof(host), &port));
/* NULL primary */
hint.primaryName = NULL;
TEST_ASSERT_EQUAL_INT(-ENOENT, xcecli_ctrlstatus_primary_addr(
&hint, host, sizeof(host), &port));
/* named primary not present in peers */
hint.primaryName = "ctrl-z";
TEST_ASSERT_EQUAL_INT(-ENOENT, xcecli_ctrlstatus_primary_addr(
&hint, host, sizeof(host), &port));
/* NULL/zero args are rejected */
hint.primaryName = "ctrl-a";
TEST_ASSERT_EQUAL_INT(-EINVAL, xcecli_ctrlstatus_primary_addr(
NULL, host, sizeof(host), &port));
TEST_ASSERT_EQUAL_INT(-EINVAL, xcecli_ctrlstatus_primary_addr(
&hint, NULL, sizeof(host), &port));
TEST_ASSERT_EQUAL_INT(
-EINVAL, xcecli_ctrlstatus_primary_addr(&hint, host, 0, &port));
TEST_ASSERT_EQUAL_INT(
-EINVAL,
xcecli_ctrlstatus_primary_addr(&hint, host, sizeof(host), NULL));
printf("test_ctrlstatus_primary_addr_unknown_or_missing: PASS\n");
}
/* ── reply-union type-confusion regression (bug-1077) ───────────────────────
*
* ctx->reply.u is a single untagged union shared by every opcode (debugmsg /
* get / jobid / clusterconf / ctrlstatus all alias the same 8 bytes). A
* failover-aborted reslist/joblist leaves ctx->reply.u.get pointing at the
* caller's (now-dead) stack `getreply`. When the next ctrlstatus reply lands on
* a worker thread, handle_reply must NOT treat that foreign, non-NULL slot as a
* CtrlStatus* and free it — doing so dereferenced a dead stack address inside
* free_CtrlStatus and crashed (ASan stack-use-after-return at
* controller.capnp.c:11638).
*
* This drives xce_handle_reply directly with a poisoned union slot and proves
* the foreign occupant is left untouched (no free, no deref). Pre-fix, the
* ctrlstatus branch frees the sentinel and the explicit free() below is a
* double-free that ASan aborts on. */
static void test_handle_reply_ctrlstatus_does_not_free_foreign_slot(void)
{
xce_ctx_t ctx;
memset(&ctx, 0, sizeof(ctx));
/* A prior get/reslist leaked a non-CtrlStatus pointer into the shared slot.
* Sized as a zeroed xce_ctrlstatus_t so that, even on the buggy path,
* free_CtrlStatus(sentinel) only walks all-NULL fields (no wild deref) and
* the regression surfaces as a clean double-free rather than a hard crash. */
xce_ctrlstatus_t* sentinel = (xce_ctrlstatus_t*)calloc(1, sizeof(*sentinel));
TEST_ASSERT_NOT_NULL(sentinel);
ctx.reply.u.get = (dataset_t*)sentinel; /* same 8 bytes as u.ctrlstatus */
/* A genuine ctrlstatus reply arrives for this ctx. */
xce_ctrlstatus_t* cs = (xce_ctrlstatus_t*)calloc(1, sizeof(*cs));
TEST_ASSERT_NOT_NULL(cs);
cs->clusterName = strdup("eda1");
xce_reply_t reply;
memset(&reply, 0, sizeof(reply));
reply.opcode = OPCode_ctrlstatus;
reply.status = 0;
reply.data_which = RequestReply_data_ctrlstatus;
reply.data.ctrlstatus = cs;
int rc = xce_handle_reply(&ctx, &reply);
/* The genuine reply is now owned by the ctx (MOVE_PTR nulls the source) … */
TEST_ASSERT_EQUAL_INT(1, rc);
TEST_ASSERT_EQUAL_PTR(cs, ctx.reply.u.ctrlstatus);
TEST_ASSERT_NULL(reply.data.ctrlstatus);
/* … and the foreign sentinel was NOT freed by the reply handler. Freeing it
* here is the sole owner's free; on the buggy code it is the second free. */
free(sentinel);
free_CtrlStatus(cs);
free(cs);
printf("test_handle_reply_ctrlstatus_does_not_free_foreign_slot: PASS\n");
}
/* ── Suite entry point ──────────────────────────────────────────────────── */
void test_ctrlstatus(void)
{
RUN_TEST(test_handle_reply_ctrlstatus_does_not_free_foreign_slot);
RUN_TEST(test_ctrlstatus_roundtrip_all_fields);
RUN_TEST(test_ctrlstatus_roundtrip_unknown_primary);
RUN_TEST(test_xcecli_ctrlstatus_null_ctx_typed_failure);
RUN_TEST(test_xcecli_ctrlstatus_primary_null_ctx_typed_failure);
RUN_TEST(test_xcecli_ctrlstatus_primary_null_out_typed_failure);
RUN_TEST(test_ctrlstatus_is_authoritative_decision);
RUN_TEST(test_ctrlstatus_primary_addr_from_peers);
RUN_TEST(test_ctrlstatus_primary_addr_unknown_or_missing);
}

View file

@ -1,6 +1,7 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <unistd.h>
#include "delay.h"
@ -26,7 +27,7 @@ static void test_delay1()
int delay_period = 2;
time_t t_start = time(NULL);
delaymgr_init(&mgr, 1, NULL);
delaymgr_init(&mgr, 1000 /* precision_ms */, NULL);
delaymgr_start(&mgr);
delay_task(&mgr, delay_period, runcount, (delay_task_entry_fn_t)delay_work,
&count);
@ -48,9 +49,101 @@ static void test_delay1()
return;
}
static void test_delaymgr_precision_ms_field(void)
{
delaymgr_t mgr;
memset(&mgr, 0, sizeof(mgr));
/* precision_ms field must exist and be stored correctly */
int rc = delaymgr_init(&mgr, 200 /* precision_ms */, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(200, mgr.precision_ms);
delaymgr_fini(&mgr);
printf("test_delaymgr_precision_ms_field: PASS\n");
}
/* ---------- RED: test for delay_task_ms (millisecond-granularity) ---------- */
static int ms_work_count = 0;
static int ms_work(void* arg)
{
(void)arg;
ms_work_count++;
return 0;
}
static void test_delay_task_ms_fires_within_tolerance(void)
{
delaymgr_t mgr;
struct timespec t_start, t_end;
long elapsed_ms;
const int delay_ms = 500;
const int tolerance_ms = 200;
const int precision_ms = 50;
ms_work_count = 0;
delaymgr_init(&mgr, precision_ms, NULL);
delaymgr_start(&mgr);
clock_gettime(CLOCK_MONOTONIC, &t_start);
delay_task_ms(&mgr, delay_ms, 1, (delay_task_entry_fn_t)ms_work, NULL);
/* wait long enough: delay + tolerance + one extra precision tick */
{
struct timespec wait = {
.tv_sec = 0,
.tv_nsec = (long)(delay_ms + tolerance_ms + precision_ms) * 1000000L,
};
nanosleep(&wait, NULL);
}
clock_gettime(CLOCK_MONOTONIC, &t_end);
elapsed_ms = (t_end.tv_sec - t_start.tv_sec) * 1000L
+ (t_end.tv_nsec - t_start.tv_nsec) / 1000000L;
TEST_ASSERT_EQUAL_INT_MESSAGE(1, ms_work_count,
"ms task must have fired exactly once");
TEST_ASSERT_TRUE_MESSAGE(elapsed_ms >= (delay_ms - tolerance_ms),
"task fired too early");
TEST_ASSERT_TRUE_MESSAGE(elapsed_ms <= (delay_ms + tolerance_ms + precision_ms + 50),
"task fired too late");
delaymgr_fini(&mgr);
printf("test_delay_task_ms_fires_within_tolerance: PASS\n");
}
static void test_delay_task_ms_invalid_args(void)
{
delaymgr_t mgr;
int rc;
delaymgr_init(&mgr, 50, NULL);
/* NULL manager */
rc = delay_task_ms(NULL, 500, 1, (delay_task_entry_fn_t)ms_work, NULL);
TEST_ASSERT_TRUE(rc < 0);
/* zero delay */
rc = delay_task_ms(&mgr, 0, 1, (delay_task_entry_fn_t)ms_work, NULL);
TEST_ASSERT_TRUE(rc < 0);
/* NULL entry */
rc = delay_task_ms(&mgr, 500, 1, NULL, NULL);
TEST_ASSERT_TRUE(rc < 0);
delaymgr_fini(&mgr);
printf("test_delay_task_ms_invalid_args: PASS\n");
}
/* ---------- existing tests still exercise backward compat ---------- */
void test_delay(void)
{
printf("\n>> delay test\n\n");
RUN_TEST(test_delay1);
RUN_TEST(test_delaymgr_precision_ms_field);
RUN_TEST(test_delay_task_ms_fires_within_tolerance);
RUN_TEST(test_delay_task_ms_invalid_args);
}

View file

@ -35,11 +35,54 @@ static void test_strerror_r(void) {
}
}
#endif
/* Every XCE error code in [XCE_ERRNO_BASE, E_XCE_MAX) must map to a real string
* via xce_strerror_r — no NULL deref, no out-of-bounds read of xce_err_str[].
* Regression for the global-buffer-overflow at utils.c:335: the HA codes
* E_XCE_NOMASTER/REDIRECT/NOT_READY/STANDBY (10011-10014) were added to the
* enum but not to xce_err_str[], so formatting them (e.g. ctrlstatus on a
* failover-exhausted cluster returning E_XCE_NOT_READY) ran off the array.
* Under ASAN this crashes pre-fix. */
static void test_strerror_all_xce_codes(void)
{
char buf[128];
int code;
for (code = XCE_ERRNO_BASE; code < E_XCE_MAX; code++) {
buf[0] = '\0';
int rc = xce_strerror_r(code, buf, sizeof(buf));
TEST_ASSERT_EQUAL_INT(0, rc); /* every in-range code resolves */
TEST_ASSERT_TRUE(buf[0] != '\0'); /* to a non-empty string */
TEST_ASSERT_TRUE(strcmp(buf, "unknown error") != 0); /* a SPECIFIC one */
}
/* the specific HA codes that triggered the crash */
xce_strerror_r(E_XCE_NOT_READY, buf, sizeof(buf));
TEST_ASSERT_TRUE(buf[0] != '\0' && strcmp(buf, "unknown error") != 0);
xce_strerror_r(E_XCE_NOMASTER, buf, sizeof(buf));
TEST_ASSERT_TRUE(buf[0] != '\0' && strcmp(buf, "unknown error") != 0);
}
/* Out-of-range XCE codes (>= E_XCE_MAX) must fall back to "unknown error",
* never index past xce_err_str[] (defense in depth against future enum drift). */
static void test_strerror_out_of_range(void)
{
char buf[128];
TEST_ASSERT_EQUAL_INT(-1, xce_strerror_r(E_XCE_MAX, buf, sizeof(buf)));
TEST_ASSERT_TRUE(strcmp(buf, "unknown error") == 0);
TEST_ASSERT_EQUAL_INT(-1, xce_strerror_r(E_XCE_MAX + 50, buf, sizeof(buf)));
TEST_ASSERT_TRUE(strcmp(buf, "unknown error") == 0);
}
void test_error(void)
{
printf("\n>> error test\n\n");
RUN_TEST(test_error_setget);
RUN_TEST(test_strerror_all_xce_codes);
RUN_TEST(test_strerror_out_of_range);
#if 0
RUN_TEST(test_strerror_r);
#endif

View file

@ -0,0 +1,167 @@
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include "hook.h"
#include <unity/unity.h>
/* Shared call counter for hook tests */
static int s_call_count = 0;
static int counting_fn(int type, int sit, int det, void* data, va_list ap)
{
(void)type;
(void)sit;
(void)det;
(void)ap;
(*(int*)data)++;
return 0;
}
/* --- test cases --- */
static void test_disable_hook_stops_invocation(void)
{
hooksys_t hs;
int rc;
s_call_count = 0;
rc = xce_hook_init(&hs, 2 /* maxtypes */);
TEST_ASSERT_EQUAL_INT(0, rc);
rc = xce_add_hook(&hs, HOOK_AFTER_EVENT, counting_fn, &s_call_count);
TEST_ASSERT_EQUAL_INT(0, rc);
/* Before disable: hook fires once */
xce_run_hooks(&hs, HOOK_AFTER_EVENT, 0, 0);
TEST_ASSERT_EQUAL_INT(1, s_call_count);
/* Disable the hook */
rc = xce_disable_hook(&hs, HOOK_AFTER_EVENT, counting_fn);
TEST_ASSERT_EQUAL_INT(0, rc);
/* After disable: hook must not fire */
xce_run_hooks(&hs, HOOK_AFTER_EVENT, 0, 0);
TEST_ASSERT_EQUAL_INT(1, s_call_count); /* unchanged */
xce_hook_fini(&hs);
printf("test_disable_hook_stops_invocation: PASS\n");
}
static void test_disable_hook_returns_enoent_if_not_found(void)
{
hooksys_t hs;
int rc;
rc = xce_hook_init(&hs, 2);
TEST_ASSERT_EQUAL_INT(0, rc);
/* Disabling a hook never added must return non-zero (ENOENT) */
rc = xce_disable_hook(&hs, HOOK_AFTER_EVENT, counting_fn);
TEST_ASSERT_NOT_EQUAL(0, rc);
xce_hook_fini(&hs);
printf("test_disable_hook_returns_enoent_if_not_found: PASS\n");
}
static void test_add_hook_reuses_null_slot(void)
{
hooksys_t hs;
int rc;
int count_before_reuse;
s_call_count = 0;
rc = xce_hook_init(&hs, 2);
TEST_ASSERT_EQUAL_INT(0, rc);
rc = xce_add_hook(&hs, HOOK_AFTER_EVENT, counting_fn, &s_call_count);
TEST_ASSERT_EQUAL_INT(0, rc);
/* Record list length after first add */
count_before_reuse = hs.hooks[HOOK_AFTER_EVENT].count;
TEST_ASSERT_EQUAL_INT(1, count_before_reuse);
/* Disable the slot — fn becomes NULL atomically */
rc = xce_disable_hook(&hs, HOOK_AFTER_EVENT, counting_fn);
TEST_ASSERT_EQUAL_INT(0, rc);
/* Re-add the same fn — must reuse the NULL slot, not grow the list */
rc = xce_add_hook(&hs, HOOK_AFTER_EVENT, counting_fn, &s_call_count);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(count_before_reuse, hs.hooks[HOOK_AFTER_EVENT].count);
/* Fire: must get exactly 1 call (not 2 if slot was duplicated) */
xce_run_hooks(&hs, HOOK_AFTER_EVENT, 0, 0);
TEST_ASSERT_EQUAL_INT(1, s_call_count);
xce_hook_fini(&hs);
printf("test_add_hook_reuses_null_slot: PASS\n");
}
/* bug-845: re-registering the SAME (fn,data) on a type must be idempotent.
* xce_disable_hook removes only ONE matching slot and xce_run_hooks fires ALL
* slots, so a duplicate (fn,data) registration would (a) fire twice and (b)
* survive a single disable. This is exactly how the controller double-appended
* every WAL_JOB_SUBMIT: persist_start() and ha_register_persist_hooks() both
* register persist_job_hook_fun with the same persist_t closure. */
static void test_add_hook_dedups_same_fn_and_data(void)
{
hooksys_t hs;
int a = 0;
TEST_ASSERT_EQUAL_INT(0, xce_hook_init(&hs, 2));
/* First add registers; the second add with the SAME fn AND data must be a
* no-op (still rc=0, still exactly one slot). */
TEST_ASSERT_EQUAL_INT(0, xce_add_hook(&hs, HOOK_AFTER_EVENT, counting_fn, &a));
TEST_ASSERT_EQUAL_INT(0, xce_add_hook(&hs, HOOK_AFTER_EVENT, counting_fn, &a));
TEST_ASSERT_EQUAL_INT(1, hs.hooks[HOOK_AFTER_EVENT].count);
/* Fires ONCE per event, never twice. */
xce_run_hooks(&hs, HOOK_AFTER_EVENT, 0, 0);
TEST_ASSERT_EQUAL_INT(1, a);
/* A single disable fully removes it — no duplicate slot lingers. */
TEST_ASSERT_EQUAL_INT(0, xce_disable_hook(&hs, HOOK_AFTER_EVENT, counting_fn));
xce_run_hooks(&hs, HOOK_AFTER_EVENT, 0, 0);
TEST_ASSERT_EQUAL_INT(1, a); /* unchanged */
xce_hook_fini(&hs);
printf("test_add_hook_dedups_same_fn_and_data: PASS\n");
}
/* The dedup is keyed on (fn,data): the SAME fn with DISTINCT closures is a
* legitimate pair of registrations and BOTH must fire. */
static void test_add_hook_allows_same_fn_distinct_data(void)
{
hooksys_t hs;
int a = 0, b = 0;
TEST_ASSERT_EQUAL_INT(0, xce_hook_init(&hs, 2));
TEST_ASSERT_EQUAL_INT(0, xce_add_hook(&hs, HOOK_AFTER_EVENT, counting_fn, &a));
TEST_ASSERT_EQUAL_INT(0, xce_add_hook(&hs, HOOK_AFTER_EVENT, counting_fn, &b));
TEST_ASSERT_EQUAL_INT(2, hs.hooks[HOOK_AFTER_EVENT].count);
xce_run_hooks(&hs, HOOK_AFTER_EVENT, 0, 0);
TEST_ASSERT_EQUAL_INT(1, a);
TEST_ASSERT_EQUAL_INT(1, b);
xce_hook_fini(&hs);
printf("test_add_hook_allows_same_fn_distinct_data: PASS\n");
}
/* --- suite entry point --- */
void test_hooks(void)
{
printf("\n>> hook system tests\n\n");
RUN_TEST(test_disable_hook_stops_invocation);
RUN_TEST(test_disable_hook_returns_enoent_if_not_found);
RUN_TEST(test_add_hook_reuses_null_slot);
RUN_TEST(test_add_hook_dedups_same_fn_and_data);
RUN_TEST(test_add_hook_allows_same_fn_distinct_data);
}

View file

@ -0,0 +1,244 @@
/*
* Test suite for kvmgr_capture_keys() — P0-14a.
*
* Tests follow TDD: written BEFORE implementation.
* All tests exercise the public API declared in kvmgr.h.
*/
#include <rtti.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unity/unity.h>
#include "kv.h"
#include "kvmgr.h"
#include "persist.h"
#include "utils.h"
/* ------------------------------------------------------------------ */
/* helpers */
/* ------------------------------------------------------------------ */
static int find_key_in_captured(slot_key_t* keys, size_t n,
const char* target_key, int* index_out)
{
size_t i;
for (i = 0; i < n; i++) {
if (strcmp(keys[i].key, target_key) == 0) {
if (index_out != NULL) {
*index_out = keys[i].index;
}
return 1;
}
}
return 0;
}
/* ------------------------------------------------------------------ */
/* test 1: capture from a string-keyed kvmgr with 3 entries */
/* ------------------------------------------------------------------ */
static void test_capture_keys_string_kvmgr(void)
{
kvmgr_params_t params = { .key_type = KVMGR_KEY_STRING,
.nslots = 8,
.delta = 4,
.datakiller = NULL };
var kvmgr = makeInstance(&xkvmgr_type, &params);
TEST_ASSERT_NOT_NULL(kvmgr);
/* Add 3 string-keyed entries whose values are arbitrary pointers;
the index returned by key_add is the slot index we capture. */
int idx_a = key_add(kvmgr, (void*)"alpha", (void*)"data_alpha");
int idx_b = key_add(kvmgr, (void*)"beta", (void*)"data_beta");
int idx_c = key_add(kvmgr, (void*)"gamma", (void*)"data_gamma");
TEST_ASSERT_TRUE(idx_a >= 0);
TEST_ASSERT_TRUE(idx_b >= 0);
TEST_ASSERT_TRUE(idx_c >= 0);
slot_key_t* keys = NULL;
size_t n_keys = 0;
int rc = kvmgr_capture_keys(kvmgr, &keys, &n_keys);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_size_t(3, n_keys);
TEST_ASSERT_NOT_NULL(keys);
/* Every inserted key must appear exactly once with the correct index */
int found_idx = -1;
TEST_ASSERT_TRUE(find_key_in_captured(keys, n_keys, "alpha", &found_idx));
TEST_ASSERT_EQUAL_INT(idx_a, found_idx);
TEST_ASSERT_TRUE(find_key_in_captured(keys, n_keys, "beta", &found_idx));
TEST_ASSERT_EQUAL_INT(idx_b, found_idx);
TEST_ASSERT_TRUE(find_key_in_captured(keys, n_keys, "gamma", &found_idx));
TEST_ASSERT_EQUAL_INT(idx_c, found_idx);
/* key_len must equal strlen(key)+1 */
size_t i;
for (i = 0; i < n_keys; i++) {
TEST_ASSERT_EQUAL_size_t(strlen(keys[i].key) + 1, keys[i].key_len);
}
free(keys);
destroy(kvmgr);
}
/* ------------------------------------------------------------------ */
/* test 2: empty kvmgr returns 0 entries and NULL keys_out */
/* ------------------------------------------------------------------ */
static void test_capture_keys_empty_kvmgr(void)
{
kvmgr_params_t params = { .key_type = KVMGR_KEY_STRING,
.nslots = 4,
.delta = 4,
.datakiller = NULL };
var kvmgr = makeInstance(&xkvmgr_type, &params);
TEST_ASSERT_NOT_NULL(kvmgr);
slot_key_t* keys = (slot_key_t*)(uintptr_t)0xdeadbeef; /* sentinel */
size_t n_keys = 99;
int rc = kvmgr_capture_keys(kvmgr, &keys, &n_keys);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_size_t(0, n_keys);
TEST_ASSERT_NULL(keys);
destroy(kvmgr);
}
/* ------------------------------------------------------------------ */
/* test 3: captured keys are deep copies — safe after kvmgr destroyed */
/* ------------------------------------------------------------------ */
static void test_capture_keys_are_deep_copies(void)
{
kvmgr_params_t params = { .key_type = KVMGR_KEY_STRING,
.nslots = 4,
.delta = 4,
.datakiller = NULL };
var kvmgr = makeInstance(&xkvmgr_type, &params);
TEST_ASSERT_NOT_NULL(kvmgr);
key_add(kvmgr, (void*)"keyA", (void*)"valA");
key_add(kvmgr, (void*)"keyB", (void*)"valB");
slot_key_t* keys = NULL;
size_t n_keys = 0;
int rc = kvmgr_capture_keys(kvmgr, &keys, &n_keys);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_size_t(2, n_keys);
/* Stash manual copies of the captured key strings */
char copy_a[256], copy_b[256];
int found_a = 0, found_b = 0;
size_t i;
for (i = 0; i < n_keys; i++) {
if (strcmp(keys[i].key, "keyA") == 0) {
strncpy(copy_a, keys[i].key, sizeof(copy_a) - 1);
copy_a[sizeof(copy_a) - 1] = '\0';
found_a = 1;
} else if (strcmp(keys[i].key, "keyB") == 0) {
strncpy(copy_b, keys[i].key, sizeof(copy_b) - 1);
copy_b[sizeof(copy_b) - 1] = '\0';
found_b = 1;
}
}
TEST_ASSERT_TRUE(found_a);
TEST_ASSERT_TRUE(found_b);
/* Destroy the kvmgr — this frees all kvtab entries including the
original key strings. Captured keys[] must remain valid. */
destroy(kvmgr);
TEST_ASSERT_EQUAL_STRING("keyA", copy_a);
TEST_ASSERT_EQUAL_STRING("keyB", copy_b);
/* Directly verify the captured keys array is still readable */
int still_a = 0, still_b = 0;
for (i = 0; i < n_keys; i++) {
if (strcmp(keys[i].key, "keyA") == 0) still_a = 1;
if (strcmp(keys[i].key, "keyB") == 0) still_b = 1;
}
TEST_ASSERT_TRUE(still_a);
TEST_ASSERT_TRUE(still_b);
free(keys);
}
/* ------------------------------------------------------------------ */
/* test 4: capture from an integer-keyed kvmgr with 3 entries */
/* ------------------------------------------------------------------ */
static void test_capture_keys_int_kvmgr(void)
{
kvmgr_params_t params = { .key_type = KVMGR_KEY_INT,
.nslots = 8,
.delta = 4,
.datakiller = NULL };
var kvmgr = makeInstance(&xkvmgr_type, &params);
TEST_ASSERT_NOT_NULL(kvmgr);
/* Add 3 integer-keyed entries; the integer keys are jobids.
For integer-keyed kvmgrs, key_add expects a (void*)(long)intval. */
int64_t jobid_1001 = 1001;
int64_t jobid_1002 = 1002;
int64_t jobid_1003 = 1003;
int idx_1001 =
key_add(kvmgr, (void*)(long)jobid_1001, (void*)"data_1001");
int idx_1002 =
key_add(kvmgr, (void*)(long)jobid_1002, (void*)"data_1002");
int idx_1003 =
key_add(kvmgr, (void*)(long)jobid_1003, (void*)"data_1003");
TEST_ASSERT_TRUE(idx_1001 >= 0);
TEST_ASSERT_TRUE(idx_1002 >= 0);
TEST_ASSERT_TRUE(idx_1003 >= 0);
slot_key_t* keys = NULL;
size_t n_keys = 0;
int rc = kvmgr_capture_keys(kvmgr, &keys, &n_keys);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_size_t(3, n_keys);
TEST_ASSERT_NOT_NULL(keys);
/* Every inserted integer key must appear as a decimal string with the
correct index */
int found_idx = -1;
TEST_ASSERT_TRUE(find_key_in_captured(keys, n_keys, "1001", &found_idx));
TEST_ASSERT_EQUAL_INT(idx_1001, found_idx);
TEST_ASSERT_TRUE(find_key_in_captured(keys, n_keys, "1002", &found_idx));
TEST_ASSERT_EQUAL_INT(idx_1002, found_idx);
TEST_ASSERT_TRUE(find_key_in_captured(keys, n_keys, "1003", &found_idx));
TEST_ASSERT_EQUAL_INT(idx_1003, found_idx);
/* key_len must equal strlen(key)+1 */
size_t i;
for (i = 0; i < n_keys; i++) {
TEST_ASSERT_EQUAL_size_t(strlen(keys[i].key) + 1, keys[i].key_len);
}
free(keys);
destroy(kvmgr);
}
/* ------------------------------------------------------------------ */
/* entry point called from test.c */
/* ------------------------------------------------------------------ */
void test_kvmgr_capture(void)
{
printf("\n>> kvmgr_capture_keys test\n");
RUN_TEST(test_capture_keys_string_kvmgr);
RUN_TEST(test_capture_keys_empty_kvmgr);
RUN_TEST(test_capture_keys_are_deep_copies);
RUN_TEST(test_capture_keys_int_kvmgr);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,95 @@
/*
* test_netio_arm_failure.c — libxce-failover: a poll-arm failure must fail LOUD.
*
* Root cause (bug-646): endpoint_update_epoll() swallowed iomgr_update_poll()
* (EPOLL_CTL_MOD) failures — it logged "rc1=-1 < 0" but returned 0 and left the
* poll interest unarmed. On the synchronous client send path (endpoint_enq),
* a failed arm meant the request bytes were never flushed, no reply ever
* arrived, and the caller blocked forever in cbuf_get() while a perfectly
* healthy io_worker slept in epoll_wait(). (EPOLL_CTL_MOD fails with ENOENT
* when the fd is no longer registered — e.g. after a failed reconnect.)
*
* Contract under test: when the poll interest cannot be armed, endpoint_enq()
* must (1) return a negative, RETRIABLE error so the caller propagates it
* (request.c senders return it; the client failover loop rotates — bug-650),
* and (2) must NOT synchronously invoke the endpoint's err_handler. endpoint_enq
* runs on the caller's thread, which may already hold a lock the err_handler
* needs (the controller's primary_heartbeat_fn holds ha->lock and
* vote_ep_err_handler re-acquires it → self-deadlock, bug-652). The real
* teardown stays on the io_worker thread, off the next epoll error event.
*/
#define _GNU_SOURCE
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#include <unity/unity.h>
#include "netio.h"
/* ── err_handler invocation tracker ──────────────────────────────── */
static int g_err_count = 0;
static int err_handler_stub(void *ep)
{
(void)ep;
g_err_count++;
return 0;
}
/*
* Arming EPOLL_CTL_MOD on an fd that was never EPOLL_CTL_ADD'd fails with
* ENOENT — the exact post-failed-reconnect condition. endpoint_enq() must
* surface it (negative rc) WITHOUT calling the err_handler (no re-entrancy).
*/
static void test_enq_arm_failure_is_loud_no_errhandler(void)
{
iomgr_t iomgr;
memset(&iomgr, 0, sizeof(iomgr));
TEST_ASSERT_EQUAL_INT(0, iomgr_init(&iomgr));
endpoint_t *ep = endpoint_get();
TEST_ASSERT_NOT_NULL(ep);
/* A real socket fd that is NOT registered in iomgr's epoll: EPOLL_CTL_MOD
* on it fails with ENOENT, mirroring a send after a failed reconnect. */
ep->fd = socket(AF_INET, SOCK_STREAM, 0);
TEST_ASSERT_TRUE(ep->fd >= 0);
ep->iomgr = &iomgr;
ep->ssl_handshake_done = 1; /* past handshake: calc_flags arms EPOLLIN */
ep->flags = 0; /* force flags != calc_flags → iomgr_update_poll */
endpoint_set_err_handler(ep, (err_handler_t)err_handler_stub);
g_err_count = 0;
/* One packet on the outq so there is real outbound work to strand. The
* endpoint owns it after enq; endpoint_fini drains it via outq_item_killer. */
io_packet_t *pkt = (io_packet_t *)calloc(1, sizeof(*pkt));
TEST_ASSERT_NOT_NULL(pkt);
int rc = endpoint_enq(ep, &pkt->link);
/* Loud failure (negative, retriable) so the caller propagates and rotates —
* but the err_handler must NOT be invoked from this synchronous send path
* (re-entrancy would self-deadlock the controller, bug-652). */
TEST_ASSERT_TRUE(rc < 0);
TEST_ASSERT_EQUAL_INT(0, g_err_count);
endpoint_fini(ep);
free(ep);
close(iomgr.epoll_fd);
printf("test_enq_arm_failure_is_loud_no_errhandler: PASS\n");
}
/* ── suite entry point ───────────────────────────────────────────── */
void test_netio_arm_failure(void)
{
printf("\n>> netio poll-arm failure tests\n\n");
RUN_TEST(test_enq_arm_failure_is_loud_no_errhandler);
}

View file

@ -0,0 +1,77 @@
/*
* test_netio_close_propagation.c — bug-648: a peer close/EOF must propagate as a
* negative error, never as rc==0.
*
* io_fsm_do() moved the read FSM to FSM_STATE_ERROR on a peer close but returned
* rc = -errno, which on a clean EOF (errno==0) is 0. handle_input() then read
* rc>=0, never called handle_error, and a blocked cbuf_get() reply-waiter was
* never woken. io_classify_io_error() is the extracted, pure decision; it must
* return <0 for every close/error case and 0 only for retriable would-block.
*/
#define _GNU_SOURCE
#include <errno.h>
#include <stdio.h>
#include <openssl/ssl.h>
#include <unity/unity.h>
/* Pure classifier defined (non-static) in netio.c, linked into this binary. */
extern int io_classify_io_error(int ssl_err, int ecode, int io_rc);
/* WANT_READ / WANT_WRITE are retriable would-block → rc==0, FSM stays put. */
static void test_wantread_writeready_is_retriable_zero(void)
{
TEST_ASSERT_EQUAL_INT(0, io_classify_io_error(SSL_ERROR_WANT_READ, 0, -1));
TEST_ASSERT_EQUAL_INT(0, io_classify_io_error(SSL_ERROR_WANT_WRITE, 0, -1));
printf("test_wantread_writeready_is_retriable_zero: PASS\n");
}
/* Non-blocking EAGAIN/EWOULDBLOCK syscall (io_rc<0) is retriable → rc==0. */
static void test_syscall_eagain_is_retriable_zero(void)
{
TEST_ASSERT_EQUAL_INT(0, io_classify_io_error(SSL_ERROR_SYSCALL, EAGAIN, -1));
TEST_ASSERT_EQUAL_INT(0,
io_classify_io_error(SSL_ERROR_SYSCALL, EWOULDBLOCK, -1));
printf("test_syscall_eagain_is_retriable_zero: PASS\n");
}
/*
* THE BUG: a clean EOF — SSL_read returns 0 with errno==0 — must yield a
* NEGATIVE error (not 0) so the close propagates and the waiter is woken.
*/
static void test_clean_eof_is_negative_not_zero(void)
{
int rc = io_classify_io_error(SSL_ERROR_SYSCALL, 0 /*errno*/, 0 /*io_rc EOF*/);
TEST_ASSERT_TRUE(rc < 0);
TEST_ASSERT_EQUAL_INT(-ECONNRESET, rc);
printf("test_clean_eof_is_negative_not_zero: PASS\n");
}
/* Clean TLS close_notify (ZERO_RETURN) with errno==0 must also be negative. */
static void test_zero_return_is_negative_not_zero(void)
{
int rc = io_classify_io_error(SSL_ERROR_ZERO_RETURN, 0, 0);
TEST_ASSERT_TRUE(rc < 0);
TEST_ASSERT_EQUAL_INT(-ECONNRESET, rc);
printf("test_zero_return_is_negative_not_zero: PASS\n");
}
/* A hard syscall error preserves its real errno (still negative). */
static void test_hard_syscall_error_preserves_errno(void)
{
TEST_ASSERT_EQUAL_INT(-ECONNRESET,
io_classify_io_error(SSL_ERROR_SYSCALL, ECONNRESET, -1));
printf("test_hard_syscall_error_preserves_errno: PASS\n");
}
void test_netio_close_propagation(void)
{
printf("\n>> netio peer-close propagation tests\n\n");
RUN_TEST(test_wantread_writeready_is_retriable_zero);
RUN_TEST(test_syscall_eagain_is_retriable_zero);
RUN_TEST(test_clean_eof_is_negative_not_zero);
RUN_TEST(test_zero_return_is_negative_not_zero);
RUN_TEST(test_hard_syscall_error_preserves_errno);
}

View file

@ -0,0 +1,182 @@
/*
* test_netio_on_connected.c — TDD tests for on_connected callback in endpoint_t.
*
* Task 1.2: election-iomgr-refactor spec
* Boundary: netio.c / netio.h
*
* Tests verify:
* 1. on_connected field exists in endpoint_t (compile-time check)
* 2. endpoint_set_on_connected() stores the callback
* 3. Callback fires exactly once when ssl_handshake_done is set to 1
* 4. Callback pointer is nulled after firing (fire-once semantics)
* 5. handle_ssl_handshake path: invokes on_connected via io_handler mock
*/
#define _GNU_SOURCE
#include <errno.h>
#include <stdatomic.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_cb_count = 0;
static endpoint_t *g_cb_ep = NULL;
static void on_connected_stub(endpoint_t *ep)
{
g_cb_count++;
g_cb_ep = ep;
}
/* Reset per-test globals */
static void reset_cb_state(void)
{
g_cb_count = 0;
g_cb_ep = NULL;
}
/* ── Test 1: Field exists and is zero-initialised after endpoint_get ── */
static void test_on_connected_field_exists_and_is_null(void)
{
endpoint_t *ep = endpoint_get();
TEST_ASSERT_NOT_NULL(ep);
/* Field must be accessible and NULL after fresh allocation */
TEST_ASSERT_NULL(ep->on_connected);
free(ep);
printf("test_on_connected_field_exists_and_is_null: PASS\n");
}
/* ── Test 2: endpoint_set_on_connected stores the callback ──────── */
static void test_endpoint_set_on_connected_stores_callback(void)
{
endpoint_t *ep = endpoint_get();
TEST_ASSERT_NOT_NULL(ep);
int rc = endpoint_set_on_connected(ep, on_connected_stub);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_PTR(on_connected_stub, ep->on_connected);
free(ep);
printf("test_endpoint_set_on_connected_stores_callback: PASS\n");
}
/* ── Test 3: endpoint_set_on_connected with NULL ep returns -EINVAL */
static void test_endpoint_set_on_connected_null_ep(void)
{
int rc = endpoint_set_on_connected(NULL, on_connected_stub);
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
printf("test_endpoint_set_on_connected_null_ep: PASS\n");
}
/* ── Test 4: endpoint_set_on_connected accepts NULL callback ────── */
static void test_endpoint_set_on_connected_null_cb(void)
{
endpoint_t *ep = endpoint_get();
TEST_ASSERT_NOT_NULL(ep);
/* Setting to NULL is valid — it clears a prior callback */
int rc = endpoint_set_on_connected(ep, NULL);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_NULL(ep->on_connected);
free(ep);
printf("test_endpoint_set_on_connected_null_cb: PASS\n");
}
/*
* ── Test 5: Direct simulation of ssl_handshake_done path ─────────
*
* Because io_handler requires a live SSL+epoll context, we simulate
* the exact code path specified by the design:
*
* ep->ssl_handshake_done = 1;
* if (ep->on_connected) { ep->on_connected(ep); ep->on_connected = NULL; }
*
* This tests that the fire-once contract holds when the hook is invoked
* immediately after ssl_handshake_done is set.
*/
static void test_on_connected_fires_once_after_handshake_done(void)
{
reset_cb_state();
endpoint_t *ep = endpoint_get();
TEST_ASSERT_NOT_NULL(ep);
endpoint_set_on_connected(ep, on_connected_stub);
TEST_ASSERT_EQUAL_PTR(on_connected_stub, ep->on_connected);
/* Simulate the moment handle_ssl_handshake sets ssl_handshake_done = 1
* and the subsequent on_connected invocation in io_handler.
* This mirrors the exact logic added to netio.c.
*/
ep->ssl_handshake_done = 1;
if (ep->on_connected) {
ep->on_connected(ep);
ep->on_connected = NULL;
}
/* Callback must have fired exactly once */
TEST_ASSERT_EQUAL_INT(1, g_cb_count);
/* Callback must receive the correct endpoint pointer */
TEST_ASSERT_EQUAL_PTR(ep, g_cb_ep);
/* Pointer must be nulled (fire-once) */
TEST_ASSERT_NULL(ep->on_connected);
/* Simulate a second epoll event — must NOT fire again */
if (ep->on_connected) {
ep->on_connected(ep);
ep->on_connected = NULL;
}
TEST_ASSERT_EQUAL_INT(1, g_cb_count); /* still exactly 1 */
free(ep);
printf("test_on_connected_fires_once_after_handshake_done: PASS\n");
}
/* ── Test 6: No callback set — no crash when handshake completes ── */
static void test_no_callback_handshake_done_no_crash(void)
{
endpoint_t *ep = endpoint_get();
TEST_ASSERT_NOT_NULL(ep);
/* No on_connected set */
ep->ssl_handshake_done = 1;
if (ep->on_connected) {
ep->on_connected(ep);
ep->on_connected = NULL;
}
/* Must reach here without crash */
TEST_ASSERT_NULL(ep->on_connected);
free(ep);
printf("test_no_callback_handshake_done_no_crash: PASS\n");
}
/* ── Test suite entry point ───────────────────────────────────────── */
void test_netio_on_connected(void)
{
printf("\n>> netio on_connected callback tests\n\n");
RUN_TEST(test_on_connected_field_exists_and_is_null);
RUN_TEST(test_endpoint_set_on_connected_stores_callback);
RUN_TEST(test_endpoint_set_on_connected_null_ep);
RUN_TEST(test_endpoint_set_on_connected_null_cb);
RUN_TEST(test_on_connected_fires_once_after_handshake_done);
RUN_TEST(test_no_callback_handshake_done_no_crash);
}

File diff suppressed because it is too large Load diff

View file

@ -16,6 +16,89 @@ static void test_hostctrl_req_null_ep(void)
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
}
/* ── HA heartbeat sender (xce_ha_heartbeat_req) ───────────────────────────
* The HA heartbeat is an OP_nop request carrying a HeartbeatMsg body. The
* sender builds that request and hands it to xce_request(); since unit tests
* have no live endpoint, these tests round-trip the same request shape the
* sender builds through xce_encode()/xce_decode() and assert the three
* committed-position fields survive the wire. A companion guard confirms the
* generic xce_nop_req still produces a body-less nop. */
/* Build the heartbeat request exactly as xce_ha_heartbeat_req does and encode
* it into a heap io_packet_t. xce_decode() takes ownership of the packet
* (free_packet + FREEUP), so the caller must NOT free the returned pkt — it is
* consumed by the subsequent xce_decode(). Mirrors the test_wal_wire pattern. */
static io_packet_t *encode_heartbeat(uint64_t term, uint64_t committed_lsn,
uint64_t committed_config_tag)
{
heartbeat_msg_t hb = {
.sender_priority = 0,
.term = term,
.committed_lsn = committed_lsn,
.committed_config_tag = committed_config_tag,
};
xce_request_t request = {
.opcode = OPCode_nop,
.body_which = Request_body_heartbeatmsg,
.body = { .heartbeatmsg = &hb },
};
io_packet_t *pkt = (io_packet_t *)calloc(1, sizeof(*pkt));
TEST_ASSERT_NOT_NULL(pkt);
/* xce_encode returns 1 on success (mirrors xce_request's encode path). */
TEST_ASSERT_EQUAL_INT(1, xce_encode(&request, pkt));
return pkt;
}
static void test_ha_heartbeat_req_null_ep(void)
{
/* Mirror the nop sender's NULL-endpoint guard. */
int rc = xce_ha_heartbeat_req(NULL, 1, 1, 1);
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
}
static void test_ha_heartbeat_req_roundtrip_fields(void)
{
/* Arrange/Act: encode a heartbeat carrying a committed position, decode. */
io_packet_t *pkt = encode_heartbeat(7, 9, 4);
xce_request_t req;
memset(&req, 0, sizeof(req));
TEST_ASSERT_EQUAL_INT(0, xce_decode(pkt, &req)); /* consumes/frees pkt */
/* Assert: OP_nop + heartbeatmsg arm with the three fields intact. */
TEST_ASSERT_EQUAL_INT(OPCode_nop, req.opcode);
TEST_ASSERT_EQUAL_INT(Request_body_heartbeatmsg, req.body_which);
TEST_ASSERT_NOT_NULL(req.body.heartbeatmsg);
TEST_ASSERT_EQUAL_UINT64(7, req.body.heartbeatmsg->term);
TEST_ASSERT_EQUAL_UINT64(9, req.body.heartbeatmsg->committed_lsn);
TEST_ASSERT_EQUAL_UINT64(4, req.body.heartbeatmsg->committed_config_tag);
xce_free_request(&req);
printf("test_ha_heartbeat_req_roundtrip_fields: PASS\n");
}
/* Guard: the generic nop sender still produces a body-less nop request. */
static void test_nop_req_remains_bodyless(void)
{
xce_request_t request
= { .opcode = OPCode_nop, .body_which = Request_body_nomsg };
io_packet_t *pkt = (io_packet_t *)calloc(1, sizeof(*pkt));
TEST_ASSERT_NOT_NULL(pkt);
TEST_ASSERT_EQUAL_INT(1, xce_encode(&request, pkt));
xce_request_t req;
memset(&req, 0, sizeof(req));
TEST_ASSERT_EQUAL_INT(0, xce_decode(pkt, &req)); /* consumes/frees pkt */
TEST_ASSERT_EQUAL_INT(OPCode_nop, req.opcode);
TEST_ASSERT_EQUAL_INT(Request_body_nomsg, req.body_which);
xce_free_request(&req);
printf("test_nop_req_remains_bodyless: PASS\n");
}
static void test_hostctrl_req_null_ctrl(void)
{
/* non-NULL dummy ep is enough; the NULL ctrl check fires first */
@ -65,6 +148,34 @@ static void test_hostctrl_req_reject_negative_action(void)
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
}
/* init_reply_by_req must fully zero the reply so the generated
* encode_RequestReply never reads a stale `redirect_host` pointer (and the
* other common trailer fields) off an uninitialized stack reply. Regression
* guard for the heap-use-after-free in encode_RequestReply / strlen seen on a
* NOP/state-report reply whose redirect_host was stack garbage. */
static void test_init_reply_by_req_zeroes_reply(void)
{
/* Poison the whole reply with non-zero bytes — mirrors an uninitialized
* stack reply whose redirect_host happens to be a (dangling) non-NULL. */
xce_reply_t reply;
xce_request_t req = { .seq = 42, .opcode = OPCode_nop };
memset(&reply, 0xFF, sizeof(reply));
init_reply_by_req(&reply, &req);
/* seq/opcode carried from the request */
TEST_ASSERT_EQUAL_UINT64(42, reply.seq);
TEST_ASSERT_EQUAL_INT(OPCode_nop, reply.opcode);
/* Trailer/common fields the encoder always reads must be cleared so a
* handler that only sets data_which/data cannot leak garbage onto the wire
* (or dereference a dangling redirect_host). */
TEST_ASSERT_NULL(reply.redirect_host);
TEST_ASSERT_EQUAL_UINT(0, reply.redirect_port);
TEST_ASSERT_EQUAL_INT(0, reply.status);
TEST_ASSERT_EQUAL_INT(0, reply.data_which);
}
void test_request(void)
{
printf("\n>> host control request validation tests\n\n");
@ -76,4 +187,10 @@ void test_request(void)
RUN_TEST(test_hostctrl_req_reject_tag);
RUN_TEST(test_hostctrl_req_reject_untag);
RUN_TEST(test_hostctrl_req_reject_negative_action);
RUN_TEST(test_init_reply_by_req_zeroes_reply);
printf("\n>> HA heartbeat sender round-trip tests\n\n");
RUN_TEST(test_ha_heartbeat_req_null_ep);
RUN_TEST(test_ha_heartbeat_req_roundtrip_fields);
RUN_TEST(test_nop_req_remains_bodyless);
}

View file

@ -0,0 +1,72 @@
/*
* test_request_send_propagation.c — bug-650: xce_request() must PROPAGATE an
* endpoint_enq() send failure, not swallow it.
*
* xce_request() (and the other request.c senders) ignored endpoint_enq()'s
* return and always returned 0. So when the transport was dead — endpoint_enq()
* returns <0 and marks the endpoint dead (bug-646) — xce_request() still
* reported success. xce_jobsubmit() then fell through to a BLOCKING cbuf_get()
* and hung forever waiting for a reply to a request that was never transmitted
* (server idle, client fd unconnected). xce_request() must return the negative
* enq error so the failover loop rotates instead of blocking.
*/
#define _GNU_SOURCE
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#include <unity/unity.h>
#include "netio.h"
#include "request.h"
/*
* A request that ENCODES successfully but whose send fails because the endpoint
* is dead (EPOLL_CTL_MOD on an fd that is not registered → ENOENT) must make
* xce_request() return a negative error, not 0.
*/
static void test_xce_request_propagates_send_failure(void)
{
iomgr_t iomgr;
memset(&iomgr, 0, sizeof(iomgr));
TEST_ASSERT_EQUAL_INT(0, iomgr_init(&iomgr));
endpoint_t *ep = endpoint_get();
TEST_ASSERT_NOT_NULL(ep);
ep->fd = socket(AF_INET, SOCK_STREAM, 0); /* valid fd, NOT in the epoll */
TEST_ASSERT_TRUE(ep->fd >= 0);
ep->iomgr = &iomgr;
ep->ssl_handshake_done = 1;
ep->flags = 0; /* force iomgr_update_poll MOD on the unregistered fd */
char nm[] = "tester";
char ky[] = "key";
xce_peerinfo_t peer = { .name = nm, .key = ky };
ep->peer = &peer;
xce_request_t req;
memset(&req, 0, sizeof(req));
req.opcode = OPCode_nop;
req.seq = 42;
int rc = xce_request(ep, &req);
/* Old code: always 0 (swallowed). Fixed: the negative enq error propagates. */
TEST_ASSERT_TRUE(rc < 0);
endpoint_fini(ep);
free(ep);
close(iomgr.epoll_fd);
printf("test_xce_request_propagates_send_failure: PASS\n");
}
void test_request_send_propagation(void)
{
printf("\n>> request send-failure propagation tests\n\n");
RUN_TEST(test_xce_request_propagates_send_failure);
}

View file

@ -726,12 +726,12 @@ static void
test_gc_sweep_unreachable_freed(void)
{
/* After a sweep with no live jobs, every node in the table is
* unreachable. Re-interning the same arguments must return a freshly
* allocated pointer — the old node was freed and removed from the table.
* unreachable. Re-interning the same arguments must return a fresh
* node — the old node was freed and removed from the table.
*
* The pointer-inequality assertion captures the contract; it becomes
* meaningful once Phase 4f wires the mark-and-sweep to the intern table.
*/
* We track node_id rather than pointer equality: calloc may lawfully
* reuse the freed address, so pointer-inequality is not a reliable
* indicator that GC swept correctly. */
sel_node_table_init();
sel_optimize_set(1);
@ -741,16 +741,18 @@ test_gc_sweep_unreachable_freed(void)
xce_select_node_t *node1 = makeSelectNode(SEL_NUM, bits, 0);
TEST_ASSERT_NOT_NULL(node1);
uint32_t node1_id = node1->node_id;
/* No live jobs → all nodes are unreachable */
gc_clear_marks();
gc_sweep_select_nodes(NULL, 0);
/* Re-intern: node1 was freed; a new node must be allocated */
/* Re-intern: node1 was freed; a new node must be allocated.
* node_id is monotonically assigned — a fresh intern produces a
* different id even if the heap address happens to be reused. */
xce_select_node_t *node2 = makeSelectNode(SEL_NUM, bits, 0);
TEST_ASSERT_NOT_NULL(node2);
/* Phase 4f: node2 must be a distinct allocation from the freed node1 */
TEST_ASSERT_TRUE(node2 != node1);
TEST_ASSERT_NOT_EQUAL(node1_id, node2->node_id);
sel_optimize_test_reset();
}
@ -2230,6 +2232,7 @@ test_gc_clear_marks_exported(void)
xce_select_node_t* n2 = makeSelectNode(SEL_NUM, b2, 0);
TEST_ASSERT_NOT_NULL(n1);
TEST_ASSERT_NOT_NULL(n2);
uint32_t n1_id = n1->node_id;
/* manually mark n1 */
n1->gc_mark = 1;
@ -2246,10 +2249,12 @@ test_gc_clear_marks_exported(void)
gc_sweep_select_nodes(NULL, 0);
TEST_ASSERT_EQUAL_INT(1, n2->gc_mark);
/* verify n1 was swept — re-interning gives a fresh allocation */
/* n1 was swept — re-interning must produce a fresh node_id.
* Pointer inequality would be unreliable because calloc may
* lawfully reuse the freed address. */
xce_select_node_t* n1_again = makeSelectNode(SEL_NUM, b1, 0);
TEST_ASSERT_NOT_NULL(n1_again);
TEST_ASSERT_NOT_EQUAL(n1, n1_again); /* different pointer = n1 was freed */
TEST_ASSERT_NOT_EQUAL(n1_id, n1_again->node_id);
sel_node_table_fini();
sel_optimize_test_reset();

View file

@ -0,0 +1,290 @@
/* test_wal_wire.c — Unity tests for the raw payload wire format
* (ha-repl-hardening task 7.1).
*
* Verifies true encode→decode round-trips for:
* - OPCode_walCatchupEntry batches (xce_wal_batch_encode_raw):
* per-sub-entry lsn/entry_type and raw_payload bytes survive the wire
* - OPCode_snapshotTransfer chunks (xce_snapshot_chunk_encode_raw):
* chunk envelope fields and raw_payload bytes survive the wire
*
* These messages bypass the generated encode path because their
* payload :Data fields cannot be carried through xce_data_t (same
* limitation as replConfigPush); xce_decode() pre-extracts the bytes
* into raw_payload/raw_payload_len before capn_free().
*/
#include <errno.h>
#include <stdlib.h>
#include <string.h>
#include <unity/unity.h>
#include "request.h"
#include "xce/types.h"
/* ── Helpers ────────────────────────────────────────────────────────────── */
static xce_peerinfo_t test_peer = { 0 };
/* encode a batch into a heap io_packet_t (xce_decode frees it) */
static io_packet_t *encode_batch(wal_catchup_entry_t **entries, int n)
{
io_packet_t *pkt = (io_packet_t *)calloc(1, sizeof(*pkt));
TEST_ASSERT_NOT_NULL(pkt);
int rc = xce_wal_batch_encode_raw(entries, n, &test_peer, 42, pkt);
TEST_ASSERT_EQUAL_INT(0, rc);
return pkt;
}
/* ── WAL batch round-trip ───────────────────────────────────────────────── */
static void test_wal_batch_roundtrip_payload_bytes(void)
{
/* Arrange: 2 entries with distinct payloads */
uint8_t p0[] = { 0xDE, 0xAD, 0xBE, 0xEF };
uint8_t p1[] = "capnp job blob \x00 with embedded NUL";
wal_catchup_entry_t e0, e1;
memset(&e0, 0, sizeof(e0));
memset(&e1, 0, sizeof(e1));
e0.lsn = 101;
e0.entry_type = 1; /* WAL_JOB_SUBMIT */
e0.raw_payload = p0;
e0.raw_payload_len = sizeof(p0);
e1.lsn = 102;
e1.entry_type = 2;
e1.raw_payload = p1;
e1.raw_payload_len = sizeof(p1);
wal_catchup_entry_t *entries[2] = { &e0, &e1 };
/* Act: encode → decode */
io_packet_t *pkt = encode_batch(entries, 2);
xce_request_t req;
memset(&req, 0, sizeof(req));
TEST_ASSERT_EQUAL_INT(0, xce_decode(pkt, &req));
/* Assert: opcode, body arm, batch shape */
TEST_ASSERT_EQUAL_INT(OPCode_walCatchupEntry, req.opcode);
TEST_ASSERT_EQUAL_INT(Request_body_walcatchupentrymsg, req.body_which);
TEST_ASSERT_NOT_NULL(req.body.walcatchupentrymsg);
wal_catchup_entry_t *batch = req.body.walcatchupentrymsg;
TEST_ASSERT_EQUAL_INT(2, batch->n_entries);
TEST_ASSERT_NOT_NULL(batch->entries);
/* Entry 0: header + payload bytes intact */
TEST_ASSERT_NOT_NULL(batch->entries[0]);
TEST_ASSERT_EQUAL_UINT64(101, batch->entries[0]->lsn);
TEST_ASSERT_EQUAL_UINT8(1, batch->entries[0]->entry_type);
TEST_ASSERT_EQUAL_size_t(sizeof(p0), batch->entries[0]->raw_payload_len);
TEST_ASSERT_NOT_NULL(batch->entries[0]->raw_payload);
TEST_ASSERT_EQUAL_MEMORY(p0, batch->entries[0]->raw_payload, sizeof(p0));
/* Entry 1: embedded NUL survives (binary-safe) */
TEST_ASSERT_NOT_NULL(batch->entries[1]);
TEST_ASSERT_EQUAL_UINT64(102, batch->entries[1]->lsn);
TEST_ASSERT_EQUAL_UINT8(2, batch->entries[1]->entry_type);
TEST_ASSERT_EQUAL_size_t(sizeof(p1), batch->entries[1]->raw_payload_len);
TEST_ASSERT_EQUAL_MEMORY(p1, batch->entries[1]->raw_payload, sizeof(p1));
xce_free_request(&req);
printf("test_wal_batch_roundtrip_payload_bytes: PASS\n");
}
/* Production-scale batch: many entries each carrying a full-size (872-byte)
* capnp-Job-sized payload — mirrors the live catch-up stream where ctrl-1
* rejoined and received header-only entries (PLEN=0). The small 2-entry
* round-trip passes; this reproduces whatever the larger/multi-segment batch
* does on the wire (bug-847). */
static void test_wal_batch_roundtrip_large_payloads(void)
{
/* KNOWN-FAILING REPRODUCER for bug-847: a production-scale batch that spans
* more than one capnp segment loses its per-entry payload Data pointers on
* the wire (decode returns raw_payload_len=0; it also leaks the orphaned
* decode allocations under ASAN). IGNORE'd so the suite stays green; REMOVE
* this TEST_IGNORE line when bug-847 is fixed and assert the payloads
* survive. Decisive evidence: this fails "Expected 872 Was 0" while the
* 2-entry round-trip above passes. */
TEST_IGNORE_MESSAGE(
"bug-847: catch-up batch drops payloads across capnp segments — "
"un-ignore when fixed");
enum { N = 14, PSZ = 872 };
wal_catchup_entry_t e[N];
wal_catchup_entry_t *entries[N];
uint8_t* payloads[N];
for (int i = 0; i < N; i++) {
payloads[i] = malloc(PSZ);
TEST_ASSERT_NOT_NULL(payloads[i]);
/* distinct, non-zero content per entry so a dropped/zeroed payload is
* unambiguous */
for (int b = 0; b < PSZ; b++)
payloads[i][b] = (uint8_t)((i * 31 + b) & 0xff);
memset(&e[i], 0, sizeof(e[i]));
e[i].lsn = (uint64_t)(i + 1);
e[i].entry_type = 1; /* WAL_JOB_SUBMIT */
e[i].raw_payload = payloads[i];
e[i].raw_payload_len = PSZ;
entries[i] = &e[i];
}
io_packet_t* pkt = encode_batch(entries, N);
xce_request_t req;
memset(&req, 0, sizeof(req));
TEST_ASSERT_EQUAL_INT(0, xce_decode(pkt, &req));
wal_catchup_entry_t* batch = req.body.walcatchupentrymsg;
TEST_ASSERT_NOT_NULL(batch);
TEST_ASSERT_EQUAL_INT(N, batch->n_entries);
for (int i = 0; i < N; i++) {
TEST_ASSERT_NOT_NULL(batch->entries[i]);
TEST_ASSERT_EQUAL_UINT64((uint64_t)(i + 1), batch->entries[i]->lsn);
/* the bug: these come back NULL / len 0 on the live path */
TEST_ASSERT_EQUAL_size_t(PSZ, batch->entries[i]->raw_payload_len);
TEST_ASSERT_NOT_NULL(batch->entries[i]->raw_payload);
TEST_ASSERT_EQUAL_MEMORY(payloads[i], batch->entries[i]->raw_payload,
PSZ);
}
xce_free_request(&req);
for (int i = 0; i < N; i++)
free(payloads[i]);
printf("test_wal_batch_roundtrip_large_payloads: PASS\n");
}
static void test_wal_batch_entry_without_payload(void)
{
/* An entry with no payload (e.g. header-only types) must decode with
* raw_payload == NULL and raw_payload_len == 0 — not garbage. */
wal_catchup_entry_t e;
memset(&e, 0, sizeof(e));
e.lsn = 7;
e.entry_type = 3;
wal_catchup_entry_t *entries[1] = { &e };
io_packet_t *pkt = encode_batch(entries, 1);
xce_request_t req;
memset(&req, 0, sizeof(req));
TEST_ASSERT_EQUAL_INT(0, xce_decode(pkt, &req));
wal_catchup_entry_t *batch = req.body.walcatchupentrymsg;
TEST_ASSERT_NOT_NULL(batch);
TEST_ASSERT_EQUAL_INT(1, batch->n_entries);
TEST_ASSERT_EQUAL_UINT64(7, batch->entries[0]->lsn);
TEST_ASSERT_NULL(batch->entries[0]->raw_payload);
TEST_ASSERT_EQUAL_size_t(0, batch->entries[0]->raw_payload_len);
xce_free_request(&req);
printf("test_wal_batch_entry_without_payload: PASS\n");
}
static void test_wal_batch_encode_rejects_invalid_args(void)
{
io_packet_t pkt;
memset(&pkt, 0, sizeof(pkt));
wal_catchup_entry_t e;
memset(&e, 0, sizeof(e));
wal_catchup_entry_t *entries[1] = { &e };
TEST_ASSERT_EQUAL_INT(-EINVAL,
xce_wal_batch_encode_raw(NULL, 1, &test_peer, 0, &pkt));
/* n_entries == 0 is a valid heartbeat (ha-term-safety task 3.2): a 0-entry AE
* carries the AppendEntries metadata (term, prev tuple, leader_commit) on the
* top-level container to drive UpdateTerm/logOk. Only n_entries < 0 is invalid. */
TEST_ASSERT_EQUAL_INT(-EINVAL,
xce_wal_batch_encode_raw(entries, -1, &test_peer, 0, &pkt));
TEST_ASSERT_EQUAL_INT(-EINVAL,
xce_wal_batch_encode_raw(entries, 1, &test_peer, 0, NULL));
printf("test_wal_batch_encode_rejects_invalid_args: PASS\n");
}
/* ── Snapshot chunk round-trip ──────────────────────────────────────────── */
static void test_snapshot_chunk_roundtrip(void)
{
/* Arrange: a mid-transfer chunk with binary payload */
uint8_t payload[300];
for (size_t i = 0; i < sizeof(payload); i++)
payload[i] = (uint8_t)(i * 7);
snapshot_transfer_t chunk;
memset(&chunk, 0, sizeof(chunk));
chunk.snapshot_lsn = 5000;
chunk.chunk_index = 3;
chunk.chunk_offset = 3 * 65536;
chunk.total_size = 4 * 65536 + 300;
chunk.is_last = 1;
io_packet_t *pkt = (io_packet_t *)calloc(1, sizeof(*pkt));
TEST_ASSERT_NOT_NULL(pkt);
TEST_ASSERT_EQUAL_INT(0,
xce_snapshot_chunk_encode_raw(&chunk, payload, sizeof(payload),
&test_peer, 9, pkt));
/* Act */
xce_request_t req;
memset(&req, 0, sizeof(req));
TEST_ASSERT_EQUAL_INT(0, xce_decode(pkt, &req));
/* Assert: envelope + payload bytes */
TEST_ASSERT_EQUAL_INT(OPCode_snapshotTransfer, req.opcode);
TEST_ASSERT_EQUAL_INT(Request_body_snapshottransfermsg, req.body_which);
snapshot_transfer_t *rx = req.body.snapshottransfermsg;
TEST_ASSERT_NOT_NULL(rx);
TEST_ASSERT_EQUAL_UINT64(5000, rx->snapshot_lsn);
TEST_ASSERT_EQUAL_UINT32(3, rx->chunk_index);
TEST_ASSERT_EQUAL_UINT64(3 * 65536, rx->chunk_offset);
TEST_ASSERT_EQUAL_UINT64(4 * 65536 + 300, rx->total_size);
TEST_ASSERT_TRUE(rx->is_last);
TEST_ASSERT_EQUAL_size_t(sizeof(payload), rx->raw_payload_len);
TEST_ASSERT_NOT_NULL(rx->raw_payload);
TEST_ASSERT_EQUAL_MEMORY(payload, rx->raw_payload, sizeof(payload));
xce_free_request(&req);
printf("test_snapshot_chunk_roundtrip: PASS\n");
}
static void test_snapshot_chunk_empty_payload(void)
{
/* Zero-length payload (defensive): envelope round-trips, raw NULL/0 */
snapshot_transfer_t chunk;
memset(&chunk, 0, sizeof(chunk));
chunk.snapshot_lsn = 1;
chunk.total_size = 0;
chunk.is_last = 1;
io_packet_t *pkt = (io_packet_t *)calloc(1, sizeof(*pkt));
TEST_ASSERT_NOT_NULL(pkt);
TEST_ASSERT_EQUAL_INT(0,
xce_snapshot_chunk_encode_raw(&chunk, NULL, 0, &test_peer, 0, pkt));
xce_request_t req;
memset(&req, 0, sizeof(req));
TEST_ASSERT_EQUAL_INT(0, xce_decode(pkt, &req));
snapshot_transfer_t *rx = req.body.snapshottransfermsg;
TEST_ASSERT_NOT_NULL(rx);
TEST_ASSERT_EQUAL_UINT64(1, rx->snapshot_lsn);
TEST_ASSERT_TRUE(rx->is_last);
TEST_ASSERT_NULL(rx->raw_payload);
TEST_ASSERT_EQUAL_size_t(0, rx->raw_payload_len);
xce_free_request(&req);
printf("test_snapshot_chunk_empty_payload: PASS\n");
}
/* ── Suite entry point ──────────────────────────────────────────────────── */
void test_wal_wire(void)
{
RUN_TEST(test_wal_batch_roundtrip_payload_bytes);
RUN_TEST(test_wal_batch_roundtrip_large_payloads);
RUN_TEST(test_wal_batch_entry_without_payload);
RUN_TEST(test_wal_batch_encode_rejects_invalid_args);
RUN_TEST(test_snapshot_chunk_roundtrip);
RUN_TEST(test_snapshot_chunk_empty_payload);
}

View file

@ -0,0 +1,136 @@
#include <assert.h>
#include <rtti.h>
#include <stdio.h>
#include <unity/unity.h>
#include "zarray.h"
/* xzarray_type is defined in zarray.c */
extern typeinfo_t xzarray_type;
/* Helper: create an xzarray instance */
static meta_obj_t* make_test_xzarray(void)
{
xzarray_params_t params = { .delta = 4, .datakiller = NULL };
return (meta_obj_t*)makeInstance(&xzarray_type, &params);
}
static void test_freeze_thaw_slots_exist(void)
{
/* After P0-2, vfreeze and vthaw must be wired (non-NULL). */
const intf_varray_t* iface
= (const intf_varray_t*)xzarray_type.interfaces.varray;
TEST_ASSERT_NOT_NULL(iface);
/* Access slots without crash — verifies struct layout has them */
(void)iface->vfreeze;
(void)iface->vthaw;
/* Slots must be non-NULL after P0-2 wires the real implementations */
TEST_ASSERT_NOT_NULL(iface->vfreeze);
TEST_ASSERT_NOT_NULL(iface->vthaw);
printf("test_freeze_thaw_slots_exist: PASS\n");
}
static void test_freeze_creates_shadow(void)
{
meta_obj_t* arr = make_test_xzarray();
TEST_ASSERT_NOT_NULL(arr);
/* Store 3 items */
char* items[3] = { "alpha", "beta", "gamma" };
for (int i = 0; i < 3; i++) {
int idx = vstore(arr, items[i]);
TEST_ASSERT_TRUE(idx >= 0);
}
/* Freeze should succeed */
TEST_ASSERT_EQUAL_INT(0, vfreeze(arr));
/* After freeze, vget must still return stored values (COW mode active) */
void* out = NULL;
TEST_ASSERT_EQUAL_INT(0, vget(arr, 0, &out));
TEST_ASSERT_NOT_NULL(out);
/* Thaw to clean up */
vthaw(arr, NULL, NULL);
destroy(arr);
}
static void test_freeze_thaw_preserves_all_entries(void)
{
meta_obj_t* arr = make_test_xzarray();
TEST_ASSERT_NOT_NULL(arr);
int N = 5;
char* items[5] = { "a", "b", "c", "d", "e" };
int idxs[5];
for (int i = 0; i < N; i++) {
idxs[i] = vstore(arr, items[i]);
TEST_ASSERT_TRUE(idxs[i] >= 0);
}
TEST_ASSERT_EQUAL_INT(0, vfreeze(arr));
TEST_ASSERT_EQUAL_INT(0, vthaw(arr, NULL, NULL));
/* All 5 entries must be reachable after thaw */
for (int i = 0; i < N; i++) {
void* out = NULL;
TEST_ASSERT_EQUAL_INT(0, vget(arr, idxs[i], &out));
TEST_ASSERT_EQUAL_PTR(items[i], out);
}
destroy(arr);
}
static void test_mutation_during_cow_visible_after_thaw(void)
{
meta_obj_t* arr = make_test_xzarray();
TEST_ASSERT_NOT_NULL(arr);
char* original = "original";
char* updated = "updated";
int idx = vstore(arr, original);
TEST_ASSERT_TRUE(idx >= 0);
TEST_ASSERT_EQUAL_INT(0, vfreeze(arr));
/* Overwrite index during COW — write to new active array */
int put_idxs[1] = { idx };
void* put_data[1] = { updated };
vput_vec(arr, 1, put_idxs, put_data);
TEST_ASSERT_EQUAL_INT(0, vthaw(arr, NULL, NULL));
/* After thaw, the updated value must win over the shadow value */
void* out = NULL;
TEST_ASSERT_EQUAL_INT(0, vget(arr, idx, &out));
TEST_ASSERT_EQUAL_PTR(updated, out);
destroy(arr);
}
static void test_double_freeze_fails(void)
{
meta_obj_t* arr = make_test_xzarray();
TEST_ASSERT_NOT_NULL(arr);
TEST_ASSERT_EQUAL_INT(0, vfreeze(arr));
/* Second freeze must fail (already in COW mode) */
TEST_ASSERT_TRUE(vfreeze(arr) != 0);
vthaw(arr, NULL, NULL);
destroy(arr);
}
void test_zarray_cow(void)
{
printf("\n>> zarray COW interface slot test\n\n");
RUN_TEST(test_freeze_thaw_slots_exist);
RUN_TEST(test_freeze_creates_shadow);
RUN_TEST(test_freeze_thaw_preserves_all_entries);
RUN_TEST(test_mutation_during_cow_visible_after_thaw);
RUN_TEST(test_double_freeze_fails);
}