feat(xce-harness-component): consolidate harness_unit_test binary

Relocate the 13 test files that lived inside controller_unit_test into
src/harness/testsuite/, add a new Unity runner (harness_test.c, modeled
on controller_test.c) carrying their extern/RUN_TEST entries, and
register the new harness_unit_test executable + suite in
src/harness/meson.build with an explicit build-order dependency on
xce_test (two of its tests spawn that binary over a pty).

Finish clearing the controller side: remove test_test_cli.c/
test_xcetest_process_entry.c from controller_unit_test_src and their
extern/RUN_TEST entries from controller_test.c, and remove the
temporary xce_test_controller_link symlink bridge task 1.1 added (no
longer needed now that both binaries build as siblings under harness/).

Verified: harness suite 132 Tests 0 Failures 0 Ignored; controller suite
1199 Tests 0 Failures 0 Ignored. 1199+132=1331 matches the pre-move
controller_unit_test baseline exactly (cross-checked against buglog
bug-1798/bug-1805).

Task 2.1 of xce-harness-component.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RLSNJhNoULvVcUhXEFafQC
This commit is contained in:
Rongsong Shen 2026-09-14 05:49:31 +00:00
parent 79628f7b2b
commit b5873a67aa
No known key found for this signature in database
GPG key ID: 5BCDAAE0123E9FE2
18 changed files with 192 additions and 29 deletions

View file

@ -146,8 +146,6 @@ controller_unit_test_src = [
'../harness/walcompare.c',
'../harness/snapverify.c',
'../harness/snapdiff.c',
'testsuite/test_xcetest_process_entry.c',
'testsuite/test_test_cli.c',
'testsuite/test_persist_snapshot_entities.c',
'testsuite/test_apply_seam.c',
'testsuite/test_apply_committed_entry.c',
@ -221,31 +219,9 @@ controller_unit_test = executable('controller_unit_test',
c_args: ['-DXCE_TESTING'],
install: false)
# TEMPORARY (xce-harness-component task 1.1): test_test_cli.c and
# test_xcetest_process_entry.c stay registered in controller_unit_test_src
# above until task 2.1 relocates them into src/harness/testsuite/. Both
# spawn the built xce_test binary as a subprocess via resolve_xce_test_path()
# (test_xcetest_process_entry.c), which finds it by looking in the SAME
# build directory as the running test binary (dirname(/proc/self/exe) +
# "/xce_test") -- an assumption that held when xce_test built into this
# same controller/ build subdir, and breaks now that xce_test builds into
# harness/ instead (task 1.1's whole point). This symlink keeps that
# assumption satisfied without touching either test file, both of which
# are out of this task's boundary -- remove this custom_target and the
# test()'s `depends:` below once task 2.1 physically relocates both files,
# at which point they become genuine build-dir siblings of xce_test again
# (mirroring harness_unit_test's own explicit dependency on the xce_test
# target, per the design's System Flows).
xce_test_controller_link = custom_target('xce_test_controller_link',
output: 'xce_test',
input: xce_test,
command: ['ln', '-sf', '../harness/xce_test', '@OUTPUT0@'],
)
test('controller test', controller_unit_test,
suite: 'controller',
timeout: 300,
depends: [xce_test_controller_link],
env: ['ASAN_OPTIONS=detect_leaks=0'])
# job-array-single-wal-record task 5.1: sched.c's push logic (sched_job_new_fun /

View file

@ -65,8 +65,6 @@ extern void test_ctrlstatus(void);
extern void test_ha_heartbeat_committed(void);
extern void test_ha_vote_floor_heartbeat(void);
extern void test_term_safety_adapter(void);
extern void test_xcetest_process_entry(void);
extern void test_test_cli(void);
extern void test_drive_async_config(void);
extern void test_slowlane(void);
extern void test_commit_admission_pool(void);
@ -241,8 +239,6 @@ int main(int argc, char* argv[])
TEST(test_ha_heartbeat_committed);
TEST(test_ha_vote_floor_heartbeat);
TEST(test_term_safety_adapter);
TEST(test_xcetest_process_entry);
TEST(test_test_cli);
TEST(test_drive_async_config);
TEST(test_slowlane);
TEST(test_commit_admission_pool);

View file

@ -1,217 +0,0 @@
#define _GNU_SOURCE
#include <errno.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <unity/unity.h>
#include "configcompare.h"
static char g_dir[256];
static void fresh_dir(void)
{
strcpy(g_dir, "/tmp/xce_configcompare_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(g_dir));
}
static void cleanup_dir(void)
{
char cmd[512];
snprintf(cmd, sizeof(cmd), "rm -rf %s", g_dir);
system(cmd);
}
/* Creates <root>/<name>/conf/ (a member's `workdir/name` directory, mirroring
* conf_dir = "%s/%s", myconf->workdir, myconf->name in persist.c /
* config_repl.c) and writes the resulting path into out_path. Does NOT touch
* config_repl.c or persist.c -- these are hand-written fixture files so the
* test exercises configcompare.c's OWN resolve-then-read logic, not a
* different code path's. */
static void make_member_dir(const char* root, const char* name,
char out_path[512])
{
snprintf(out_path, 512, "%s/%s", root, name);
TEST_ASSERT_EQUAL_INT(0, mkdir(out_path, 0700));
char conf_dir[560];
snprintf(conf_dir, sizeof(conf_dir), "%s/conf", out_path);
TEST_ASSERT_EQUAL_INT(0, mkdir(conf_dir, 0700));
}
static void write_conf_current(const char* member_path, const char* hexsuffix)
{
char ptr_path[600];
snprintf(ptr_path, sizeof(ptr_path), "%s/conf/current", member_path);
FILE* f = fopen(ptr_path, "w");
TEST_ASSERT_NOT_NULL(f);
fprintf(f, "cluster.bin.%s\n", hexsuffix);
fclose(f);
}
static void write_conf_blob(const char* member_path, const char* hexsuffix,
const uint8_t* bytes, size_t len)
{
char blob_path[600];
snprintf(blob_path, sizeof(blob_path), "%s/conf/cluster.bin.%s",
member_path, hexsuffix);
FILE* f = fopen(blob_path, "wb");
TEST_ASSERT_NOT_NULL(f);
if (len > 0)
TEST_ASSERT_EQUAL_INT((int)len, (int)fwrite(bytes, 1, len, f));
fclose(f);
}
/* Full happy-path member: dir + conf/current + the versioned blob it
* points at. */
static void setup_member_ok(const char* root, const char* name,
const char* hexsuffix, const uint8_t* bytes,
size_t len, char out_path[512])
{
make_member_dir(root, name, out_path);
write_conf_current(out_path, hexsuffix);
write_conf_blob(out_path, hexsuffix, bytes, len);
}
static void test_two_members_identical_config_reports_identical(void)
{
fresh_dir();
const uint8_t data[] = "identical config bytes for both members";
char m0[512], m1[512];
setup_member_ok(g_dir, "m0", "aaaaaaaaaaaa", data, sizeof(data) - 1, m0);
setup_member_ok(g_dir, "m1", "bbbbbbbbbbbb", data, sizeof(data) - 1, m1);
const char* paths[2] = { m0, m1 };
configcompare_report_t rep;
int rc = configcompare_paths(paths, 2, stdout, &rep);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(1, rep.all_present);
TEST_ASSERT_EQUAL_INT(1, rep.all_identical);
TEST_ASSERT_EQUAL_INT(-1, rep.first_divergent_index);
cleanup_dir();
}
static void test_two_members_differing_config_reports_divergent(void)
{
fresh_dir();
const uint8_t data0[] = "config bytes version A";
const uint8_t data1[] = "config bytes version B, quite different";
char m0[512], m1[512];
setup_member_ok(g_dir, "m0", "aaaaaaaaaaaa", data0, sizeof(data0) - 1, m0);
setup_member_ok(g_dir, "m1", "bbbbbbbbbbbb", data1, sizeof(data1) - 1, m1);
const char* paths[2] = { m0, m1 };
configcompare_report_t rep;
int rc = configcompare_paths(paths, 2, stdout, &rep);
TEST_ASSERT_EQUAL_INT(1, rc);
TEST_ASSERT_EQUAL_INT(1, rep.all_present);
TEST_ASSERT_EQUAL_INT(0, rep.all_identical);
TEST_ASSERT_EQUAL_INT(1, rep.first_divergent_index);
cleanup_dir();
}
/* A member with no conf/current pointer file at all must be reported as
* unreadable (read_rc != 0), never silently folded into "identical" or
* "divergent" against the two members that DO agree. */
static void test_missing_conf_current_reported_unreadable_not_divergent(void)
{
fresh_dir();
const uint8_t data[] = "shared config bytes across the healthy members";
char m0[512], m1[512], m2[512];
setup_member_ok(g_dir, "m0", "aaaaaaaaaaaa", data, sizeof(data) - 1, m0);
/* m1: conf/ dir exists but conf/current was never written. */
make_member_dir(g_dir, "m1", m1);
setup_member_ok(g_dir, "m2", "cccccccccccc", data, sizeof(data) - 1, m2);
const char* paths[3] = { m0, m1, m2 };
configcompare_report_t rep;
int rc = configcompare_paths(paths, 3, stdout, &rep);
TEST_ASSERT_LESS_THAN_INT(0, rc);
TEST_ASSERT_EQUAL_INT(0, rep.read_rc[0]);
TEST_ASSERT_NOT_EQUAL(0, rep.read_rc[1]);
TEST_ASSERT_EQUAL_INT(0, rep.all_present);
cleanup_dir();
}
/* A member whose conf/current points at a versioned file that does not
* exist on disk gets the same "unreadable" treatment as a missing pointer
* file -- neither is evidence of matching or mismatching configuration. */
static void test_current_points_to_missing_versioned_file_reported_unreadable(void)
{
fresh_dir();
const uint8_t data[] = "shared config bytes, dangling pointer case";
char m0[512], m1[512];
setup_member_ok(g_dir, "m0", "aaaaaaaaaaaa", data, sizeof(data) - 1, m0);
/* m1: conf/current points at cluster.bin.dddddddddddd, but that
* versioned file was never written. */
make_member_dir(g_dir, "m1", m1);
write_conf_current(m1, "dddddddddddd");
const char* paths[2] = { m0, m1 };
configcompare_report_t rep;
int rc = configcompare_paths(paths, 2, stdout, &rep);
TEST_ASSERT_LESS_THAN_INT(0, rc);
TEST_ASSERT_EQUAL_INT(0, rep.read_rc[0]);
TEST_ASSERT_NOT_EQUAL(0, rep.read_rc[1]);
cleanup_dir();
}
/* Basic determinism sanity check: comparing the same two byte-identical
* files twice in a row yields the same hash both times. */
static void test_repeated_comparison_of_identical_files_is_deterministic(void)
{
fresh_dir();
const uint8_t data[] = "deterministic hash check bytes, fixed content";
char m0[512], m1[512];
setup_member_ok(g_dir, "m0", "aaaaaaaaaaaa", data, sizeof(data) - 1, m0);
setup_member_ok(g_dir, "m1", "bbbbbbbbbbbb", data, sizeof(data) - 1, m1);
const char* paths[2] = { m0, m1 };
configcompare_report_t rep1, rep2;
int rc1 = configcompare_paths(paths, 2, NULL, &rep1);
int rc2 = configcompare_paths(paths, 2, NULL, &rep2);
TEST_ASSERT_EQUAL_INT(0, rc1);
TEST_ASSERT_EQUAL_INT(0, rc2);
TEST_ASSERT_EQUAL_INT(0, memcmp(rep1.sha256[0], rep2.sha256[0], 32));
TEST_ASSERT_EQUAL_INT(0, memcmp(rep1.sha256[1], rep2.sha256[1], 32));
cleanup_dir();
}
/* Fewer than two members means there is nothing to compare -- a negative
* errno, not a degenerate "identical" verdict. */
static void test_n_less_than_2_returns_negative_errno(void)
{
const char* one_path[1] = { "/tmp/xce_configcompare_irrelevant" };
configcompare_report_t rep;
int rc0 = configcompare_paths(NULL, 0, NULL, &rep);
TEST_ASSERT_LESS_THAN_INT(0, rc0);
int rc1 = configcompare_paths(one_path, 1, NULL, &rep);
TEST_ASSERT_LESS_THAN_INT(0, rc1);
}
void test_configcompare(void)
{
UnitySetTestFile(__FILE__);
RUN_TEST(test_two_members_identical_config_reports_identical);
RUN_TEST(test_two_members_differing_config_reports_divergent);
RUN_TEST(test_missing_conf_current_reported_unreadable_not_divergent);
RUN_TEST(test_current_points_to_missing_versioned_file_reported_unreadable);
RUN_TEST(test_repeated_comparison_of_identical_files_is_deterministic);
RUN_TEST(test_n_less_than_2_returns_negative_errno);
}

View file

@ -1,353 +0,0 @@
/*
* test_confirm.c -- proves confirm.c's destructive-command confirmation
* gate (xce-test-cli-ux spec, task 2.1; Requirements 9.1-9.4, 9.6-9.9):
*
* 1. A non-interactive session (xcetest_is_interactive() false) makes
* confirm_destructive() proceed immediately WITHOUT ever reading
* stdin (9.4).
* 2. A declining answer ("n", or anything not affirmative, or EOF)
* makes it return "not run" (0), not "failed" (9.2).
* 3. A REAL SIGINT delivered while confirm_destructive() is blocked in
* its prompt's read makes it return "not run" (0), identically to a
* decline -- not a crash, not a hang, not a distinct outcome (9.9).
* 4. The confirm_set_skip()/confirm_is_skip_set() toggle genuinely
* switches whether the read ever happens: with the toggle ON,
* confirm_destructive() returns "proceed" (1) even when stdin holds
* a decline answer that would otherwise cause "not run" (9.6/9.7).
*
* xcetest_is_interactive() (declared in confirm.h) is normally implemented
* in test.c, over test.c's own private g_interactive flag -- but test.c
* itself is never linked into controller_unit_test (it pulls in libedit/
* cJSON/libxce/Cap'n Proto/OpenSSL and defines its own main(); see
* xcetest_interrupt.h's header comment for the identical reasoning
* already applied to xcetest_interrupted()). So this file provides its
* OWN local fake definition of xcetest_is_interactive(), over its own
* local flag -- the same "local fake standing in for a real dependency
* this binary never links" pattern test_interruptcall.c already uses for
* xcetest_interrupted().
*
* STDIN REDIRECTION TECHNIQUE (scenarios 1, 2, 4 -- all in-process, not
* forked): rather than dup2()-ing a new fd onto the persistent
* STDIN_FILENO while reusing the single process-wide `stdin` FILE*
* object (which risks stale buffered bytes surviving from a previous
* redirection), each test opens its fixture as a fresh fd and swaps the
* global `stdin` pointer itself to a brand-new fdopen()'d FILE* -- glibc
* declares `stdin` as a plain `extern FILE *stdin;`, so this reassignment
* is well-defined. A fresh fdopen() guarantees a clean buffer with no
* carry-over from any earlier test in this same combined Unity binary.
* Each test restores the original `stdin` pointer before returning.
*
* Scenario 3 (the real-SIGINT case) is instead a fork()-based test, NOT
* an in-process one -- mirroring test_xcetest_interrupt.c's and
* test_interruptcall.c's existing convention in this exact directory.
* Forking (rather than juggling stdin/alarms in-process) means a
* regression that makes the prompt's read silently retry on EINTR (the
* exact bug Requirement 9.9 exists to prevent) only hangs a short-lived
* CHILD process, which a 2-second alarm() safety net self-terminates --
* it can never hang this whole Unity binary or the rest of the suite.
*/
#include <errno.h>
#include <fcntl.h>
#include <pthread.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
#include <unity/unity.h>
#include "confirm.h"
#include "xcetest_interrupt.h"
/* --- local fake for xcetest_is_interactive() (see file header) --- */
static volatile int g_test_interactive = 1;
int xcetest_is_interactive(void) { return g_test_interactive; }
/* ---- test-local temp dir + fixture-file helpers (mirrors
* test_rcfile.c's/test_configcompare.c's fresh_dir()/cleanup_dir()
* convention) ---- */
static char g_dir[256];
static void fresh_dir(void)
{
strcpy(g_dir, "/tmp/xce_confirm_test_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(g_dir));
}
static void cleanup_dir(void)
{
char cmd[512];
snprintf(cmd, sizeof(cmd), "rm -rf %s", g_dir);
system(cmd);
}
static void write_file(const char* path, const char* content)
{
FILE* f = fopen(path, "wb");
size_t len = strlen(content);
TEST_ASSERT_NOT_NULL(f);
if (len > 0)
TEST_ASSERT_EQUAL_INT((int)len, (int)fwrite(content, 1, len, f));
fclose(f);
}
/* ---- in-process stdin redirection (scenarios 1, 2, 4) ---- */
static FILE* g_saved_stdin = NULL;
static void redirect_stdin_from_fd(int fd)
{
TEST_ASSERT_NULL(g_saved_stdin); /* catch a leaked prior redirection */
g_saved_stdin = stdin;
stdin = fdopen(fd, "r"); /* fresh FILE*, fresh buffer, owns fd */
TEST_ASSERT_NOT_NULL(stdin);
setvbuf(stdin, NULL, _IONBF, 0); /* belt-and-suspenders: no buffering
* to ever carry residue at all */
}
static void redirect_stdin_from_file(const char* path)
{
int fd = open(path, O_RDONLY);
TEST_ASSERT_TRUE(fd >= 0);
redirect_stdin_from_fd(fd);
}
static void restore_stdin(void)
{
TEST_ASSERT_NOT_NULL(g_saved_stdin);
fclose(stdin); /* closes the fixture fd */
stdin = g_saved_stdin;
g_saved_stdin = NULL;
}
/* ---- Scenario 1: non-interactive session (Requirement 9.4) ---- */
/* The fixture answer is "n\n" -- a DECLINE -- specifically so that if
* confirm_destructive() incorrectly attempted to read it despite being
* told the session isn't interactive, the test would observe a 0
* (not-run) instead of the expected 1 (proceed), catching the bug via
* the WRONG return value rather than merely "didn't crash". */
static void test_non_interactive_session_proceeds_without_reading_stdin(void)
{
fresh_dir();
char path[300];
snprintf(path, sizeof(path), "%s/answer", g_dir);
write_file(path, "n\n");
g_test_interactive = 0;
confirm_set_skip(0);
redirect_stdin_from_file(path);
int rc = confirm_destructive("do something destructive");
restore_stdin();
TEST_ASSERT_EQUAL_INT(1, rc);
g_test_interactive = 1; /* restore default for later tests */
cleanup_dir();
}
/* ---- Scenario 2: decline / EOF (Requirement 9.2) ---- */
static void test_decline_answer_returns_not_run(void)
{
fresh_dir();
char path[300];
snprintf(path, sizeof(path), "%s/answer", g_dir);
write_file(path, "n\n");
g_test_interactive = 1;
confirm_set_skip(0);
redirect_stdin_from_file(path);
int rc = confirm_destructive("do something destructive");
restore_stdin();
TEST_ASSERT_EQUAL_INT(0, rc);
cleanup_dir();
}
static void test_garbage_answer_returns_not_run(void)
{
fresh_dir();
char path[300];
snprintf(path, sizeof(path), "%s/answer", g_dir);
write_file(path, "maybe later\n");
g_test_interactive = 1;
confirm_set_skip(0);
redirect_stdin_from_file(path);
int rc = confirm_destructive("do something destructive");
restore_stdin();
TEST_ASSERT_EQUAL_INT(0, rc);
cleanup_dir();
}
/* EOF (an empty fixture, no bytes at all) is distinguished internally by
* confirm.c from a genuine decline (fgets() returns NULL with errno NOT
* EINTR, vs. returning a real "n\n" string) but converges on the exact
* same two-valued contract confirm_destructive() documents: 0/not-run.
* design.md's Service Interface defines no third "error" outcome for
* this function, so EOF is treated as the safer of the two available
* answers rather than inventing a new return value. */
static void test_eof_on_prompt_returns_not_run(void)
{
fresh_dir();
char path[300];
snprintf(path, sizeof(path), "%s/answer", g_dir);
write_file(path, "");
g_test_interactive = 1;
confirm_set_skip(0);
redirect_stdin_from_file(path);
int rc = confirm_destructive("do something destructive");
restore_stdin();
TEST_ASSERT_EQUAL_INT(0, rc);
cleanup_dir();
}
/* Affirmative spelling coverage: "y"/"yes", case-insensitive, tolerant of
* incidental surrounding whitespace -- the exact accepted spellings
* confirm.h documents. */
static void test_affirmative_answers_return_proceed(void)
{
static const char* answers[] = { "y\n", "Y\n", "yes\n", "YES\n",
" yes \n", "Yes" };
for (size_t i = 0; i < sizeof(answers) / sizeof(answers[0]); i++) {
fresh_dir();
char path[300];
snprintf(path, sizeof(path), "%s/answer", g_dir);
write_file(path, answers[i]);
g_test_interactive = 1;
confirm_set_skip(0);
redirect_stdin_from_file(path);
int rc = confirm_destructive("do something destructive");
restore_stdin();
TEST_ASSERT_EQUAL_INT_MESSAGE(1, rc, answers[i]);
cleanup_dir();
}
}
/* ---- Scenario 4: the session-scoped skip toggle (Requirements 9.6/9.7) ---- */
/* Proves the toggle is a genuine short-circuit, not just a return-value
* override: with the SAME declining fixture content, toggling skip ON
* proceeds (never consults the decline) and toggling it back OFF makes
* the identical content decline again. */
static void test_skip_toggle_bypasses_and_restores_prompting(void)
{
fresh_dir();
char path[300];
snprintf(path, sizeof(path), "%s/answer", g_dir);
write_file(path, "n\n"); /* would decline if actually read */
g_test_interactive = 1;
TEST_ASSERT_EQUAL_INT(0, confirm_is_skip_set());
confirm_set_skip(1);
TEST_ASSERT_EQUAL_INT(1, confirm_is_skip_set());
redirect_stdin_from_file(path);
int rc = confirm_destructive("do something destructive");
restore_stdin();
TEST_ASSERT_EQUAL_INT(1, rc); /* proceeded despite the decline fixture */
confirm_set_skip(0);
TEST_ASSERT_EQUAL_INT(0, confirm_is_skip_set());
redirect_stdin_from_file(path); /* same fixture content, fresh fd */
rc = confirm_destructive("do something destructive");
restore_stdin();
TEST_ASSERT_EQUAL_INT(0, rc); /* toggle off: prompting is back */
cleanup_dir();
}
/* ---- Scenario 3: a real SIGINT during the blocking prompt read
* (Requirement 9.9) -- fork()-based, see file header. ---- */
#define CHILD_EXIT_NOT_RUN 4 /* confirm_destructive() returned 0 -- expected */
#define CHILD_EXIT_PROCEED 5 /* returned 1 -- wrong: SIGINT was ignored */
#define CHILD_EXIT_TIMED_OUT 6 /* alarm fired first -- EINTR was silently
* retried instead of propagating, exactly
* the bug Requirement 9.9 exists to catch */
static void child_alarm_handler(int signo)
{
(void)signo;
_exit(CHILD_EXIT_TIMED_OUT);
}
/* Stands in for test.c's sigbreak(): any real, non-default SIGINT handler
* -- only its existence (not SIG_DFL/SIG_IGN) matters for whether the
* blocked read() restarts or returns EINTR once xcetest_install_sigint()
* (task 1.3) has cleared SA_RESTART. */
static void child_sigint_noop_handler(int signo) { (void)signo; }
static void test_sigint_during_prompt_returns_not_run_like_decline(void)
{
int pipefd[2];
TEST_ASSERT_EQUAL_INT(0, pipe(pipefd));
pid_t pid = fork();
TEST_ASSERT_TRUE(pid >= 0);
if (pid == 0) {
/* child: never writes to the pipe, so confirm_destructive()'s
* fgets() can only unblock via the SIGINT the parent sends below,
* or this alarm safety net if that fix regresses. */
close(pipefd[1]);
signal(SIGALRM, child_alarm_handler);
alarm(2);
xcetest_install_sigint(child_sigint_noop_handler); /* task 1.3's
* installer --
* the exact
* one test.c's
* real main()
* uses */
g_test_interactive = 1;
confirm_set_skip(0);
redirect_stdin_from_fd(pipefd[0]);
int rc = confirm_destructive("do something destructive");
_exit(rc == 0 ? CHILD_EXIT_NOT_RUN : CHILD_EXIT_PROCEED);
}
/* parent */
close(pipefd[0]);
usleep(200000); /* give the child time to install its handler and
* reach the blocking fgets() before we signal it */
TEST_ASSERT_EQUAL_INT(0, kill(pid, SIGINT));
int status = 0;
TEST_ASSERT_EQUAL_INT(pid, waitpid(pid, &status, 0));
close(pipefd[1]);
TEST_ASSERT_TRUE(WIFEXITED(status));
TEST_ASSERT_EQUAL_INT(CHILD_EXIT_NOT_RUN, WEXITSTATUS(status));
}
void test_confirm(void)
{
UnitySetTestFile(__FILE__);
RUN_TEST(test_non_interactive_session_proceeds_without_reading_stdin);
RUN_TEST(test_decline_answer_returns_not_run);
RUN_TEST(test_garbage_answer_returns_not_run);
RUN_TEST(test_eof_on_prompt_returns_not_run);
RUN_TEST(test_affirmative_answers_return_proceed);
RUN_TEST(test_skip_toggle_bypasses_and_restores_prompting);
RUN_TEST(test_sigint_during_prompt_returns_not_run_like_decline);
}

View file

@ -1,508 +0,0 @@
#define _GNU_SOURCE
#include <errno.h>
#include <getopt.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <unity/unity.h>
#include "flagspec.h"
/* ---- fixture: a made-up two-flag test command -------------------------
* "testcmd -h <host> [-c <cmdline>]" -- one required flag with a short
* and long form, one optional flag with a short and long form. Used by
* every test below so the same descriptor table exercises both
* flagspec_build_opts() and flagspec_reject(), and so a rejection's usage
* summary is provably the SAME string no matter which of the two flags is
* the malformed one (the task's stated observable-completion criterion).
* ------------------------------------------------------------------- */
static const flagspec_t g_testcmd_spec[2] = {
{ 'h', "host", "host", required_argument, 1, NULL },
{ 'c', "command", "command-line", no_argument, 0, "off" },
};
static const char g_testcmd_usage[] = "testcmd -h <host> [-c]";
/* ---- stream-capture helpers --------------------------------------------
* Mirrors test_rcfile.c's dup/dup2/freopen technique for proving what a
* function wrote to stderr, extended to capture stdout AND stderr at the
* same time so a test can prove Requirement 3.1: a rejection message
* lands on stderr only, never stdout. Uses a fresh temp dir per test
* (mkdtemp, like test_rcfile.c/test_configcompare.c's fresh_dir()) so
* capture files never collide with anything else in this shared /tmp. */
static char g_dir[256];
static void fresh_dir(void)
{
strcpy(g_dir, "/tmp/xce_flagspec_test_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(g_dir));
}
static void cleanup_dir(void)
{
char cmd[512];
snprintf(cmd, sizeof(cmd), "rm -rf %s", g_dir);
system(cmd);
}
typedef struct {
int saved_stdout_fd;
int saved_stderr_fd;
char stdout_path[300];
char stderr_path[300];
} capture_t;
static void capture_begin(capture_t* cap)
{
fflush(stdout);
fflush(stderr);
cap->saved_stdout_fd = dup(fileno(stdout));
cap->saved_stderr_fd = dup(fileno(stderr));
TEST_ASSERT_TRUE(cap->saved_stdout_fd >= 0);
TEST_ASSERT_TRUE(cap->saved_stderr_fd >= 0);
snprintf(cap->stdout_path, sizeof(cap->stdout_path), "%s/out", g_dir);
snprintf(cap->stderr_path, sizeof(cap->stderr_path), "%s/err", g_dir);
TEST_ASSERT_NOT_NULL(freopen(cap->stdout_path, "w", stdout));
TEST_ASSERT_NOT_NULL(freopen(cap->stderr_path, "w", stderr));
}
/* Restores the real stdout/stderr fds and reads both captured files back
* into caller-provided, NUL-terminated buffers. */
static void capture_end(capture_t* cap, char* out_stdout,
size_t out_stdout_cap, char* out_stderr,
size_t out_stderr_cap)
{
fflush(stdout);
fflush(stderr);
TEST_ASSERT_TRUE(dup2(cap->saved_stdout_fd, fileno(stdout)) >= 0);
TEST_ASSERT_TRUE(dup2(cap->saved_stderr_fd, fileno(stderr)) >= 0);
close(cap->saved_stdout_fd);
close(cap->saved_stderr_fd);
clearerr(stdout);
clearerr(stderr);
FILE* f;
size_t n;
f = fopen(cap->stdout_path, "r");
TEST_ASSERT_NOT_NULL(f);
n = fread(out_stdout, 1, out_stdout_cap - 1, f);
out_stdout[n] = '\0';
fclose(f);
f = fopen(cap->stderr_path, "r");
TEST_ASSERT_NOT_NULL(f);
n = fread(out_stderr, 1, out_stderr_cap - 1, f);
out_stderr[n] = '\0';
fclose(f);
}
/* ---- flagspec_build_opts() tests --------------------------------------- */
static void test_build_opts_produces_correct_table_for_two_flag_command(void)
{
struct option opts[3];
char optstring[8];
int rc = flagspec_build_opts(g_testcmd_spec, 2, opts, optstring,
sizeof(optstring));
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_STRING("host", opts[0].name);
TEST_ASSERT_EQUAL_INT(required_argument, opts[0].has_arg);
TEST_ASSERT_NULL(opts[0].flag);
TEST_ASSERT_EQUAL_INT('h', opts[0].val);
TEST_ASSERT_EQUAL_STRING("command", opts[1].name);
TEST_ASSERT_EQUAL_INT(no_argument, opts[1].has_arg);
TEST_ASSERT_NULL(opts[1].flag);
TEST_ASSERT_EQUAL_INT('c', opts[1].val);
/* trailing all-zero sentinel getopt_long() requires */
TEST_ASSERT_NULL(opts[2].name);
TEST_ASSERT_EQUAL_INT(0, opts[2].has_arg);
TEST_ASSERT_NULL(opts[2].flag);
TEST_ASSERT_EQUAL_INT(0, opts[2].val);
TEST_ASSERT_EQUAL_STRING("h:c", optstring);
}
static void test_build_opts_assigns_synthetic_val_to_long_only_flag(void)
{
flagspec_t spec[3];
memcpy(spec, g_testcmd_spec, sizeof(g_testcmd_spec));
spec[2] = (flagspec_t){ 0, "json", "output-format", no_argument, 0,
"table" };
struct option opts[4];
char optstring[8];
int rc = flagspec_build_opts(spec, 3, opts, optstring,
sizeof(optstring));
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_STRING("json", opts[2].name);
TEST_ASSERT_EQUAL_INT(no_argument, opts[2].has_arg);
TEST_ASSERT_EQUAL_INT(FLAGSPEC_LONG_ONLY_BASE + 2, opts[2].val);
/* a long-only flag contributes nothing to the short optstring */
TEST_ASSERT_EQUAL_STRING("h:c", optstring);
/* still terminated correctly at the new, later index */
TEST_ASSERT_NULL(opts[3].name);
TEST_ASSERT_EQUAL_INT(0, opts[3].val);
}
static void test_build_opts_rejects_null_spec(void)
{
struct option opts[3];
char optstring[8];
TEST_ASSERT_EQUAL_INT(-EINVAL,
flagspec_build_opts(NULL, 2, opts, optstring,
sizeof(optstring)));
}
static void test_build_opts_rejects_zero_count(void)
{
struct option opts[3];
char optstring[8];
TEST_ASSERT_EQUAL_INT(
-EINVAL, flagspec_build_opts(g_testcmd_spec, 0, opts, optstring,
sizeof(optstring)));
}
static void test_build_opts_rejects_entry_with_null_long_opt(void)
{
flagspec_t bad_spec[1] = {
{ 'x', NULL, "x", no_argument, 0, NULL },
};
struct option opts[2];
char optstring[8];
TEST_ASSERT_EQUAL_INT(
-EINVAL,
flagspec_build_opts(bad_spec, 1, opts, optstring, sizeof(optstring)));
}
static void test_build_opts_rejects_too_small_optstring_buffer(void)
{
struct option opts[3];
/* g_testcmd_spec builds "h:c" (h requires a value, c does not) --
* exactly 4 bytes including the terminating NUL. A 3-byte buffer is
* one short. */
char optstring[3];
TEST_ASSERT_EQUAL_INT(-ENOSPC,
flagspec_build_opts(g_testcmd_spec, 2, opts,
optstring, sizeof(optstring)));
}
/* ---- flagspec_reject() tests: real getopt_long() pipeline -------------
* Each case builds the real option table via flagspec_build_opts(), then
* drives an actual getopt_long() call over a fabricated argv so the
* bad_flag handed to flagspec_reject() is exactly what a real cmd_*
* caller would see from its own `case '?':` arm -- not a hand-picked
* string. */
static int run_getopt_long_expect_error(int argc, char* argv[],
const struct option* opts,
const char* optstring)
{
optind = 1;
opterr = 0;
int opt = getopt_long(argc, argv, optstring, opts, NULL);
TEST_ASSERT_EQUAL_INT('?', opt);
return optind;
}
static void test_reject_rejects_unrecognized_long_flag(void)
{
struct option opts[3];
char optstring[8];
TEST_ASSERT_EQUAL_INT(0, flagspec_build_opts(g_testcmd_spec, 2, opts,
optstring,
sizeof(optstring)));
char* argv[] = { "testcmd", "--bogus", NULL };
int after = run_getopt_long_expect_error(2, argv, opts, optstring);
const char* bad_flag = argv[after - 1];
TEST_ASSERT_EQUAL_STRING("--bogus", bad_flag);
fresh_dir();
capture_t cap;
capture_begin(&cap);
int rc = flagspec_reject("testcmd", g_testcmd_spec, 2, bad_flag,
g_testcmd_usage);
char out_buf[512], err_buf[512];
capture_end(&cap, out_buf, sizeof(out_buf), err_buf, sizeof(err_buf));
TEST_ASSERT_TRUE(rc < 0);
TEST_ASSERT_EQUAL_STRING("", out_buf);
TEST_ASSERT_NOT_NULL(strstr(err_buf, "unrecognized flag '--bogus'"));
TEST_ASSERT_NOT_NULL(strstr(err_buf, "usage: testcmd -h <host> [-c]"));
cleanup_dir();
}
static void test_reject_rejects_unrecognized_short_flag(void)
{
struct option opts[3];
char optstring[8];
TEST_ASSERT_EQUAL_INT(0, flagspec_build_opts(g_testcmd_spec, 2, opts,
optstring,
sizeof(optstring)));
char* argv[] = { "testcmd", "-z", NULL };
int after = run_getopt_long_expect_error(2, argv, opts, optstring);
const char* bad_flag = argv[after - 1];
TEST_ASSERT_EQUAL_STRING("-z", bad_flag);
fresh_dir();
capture_t cap;
capture_begin(&cap);
int rc = flagspec_reject("testcmd", g_testcmd_spec, 2, bad_flag,
g_testcmd_usage);
char out_buf[512], err_buf[512];
capture_end(&cap, out_buf, sizeof(out_buf), err_buf, sizeof(err_buf));
TEST_ASSERT_TRUE(rc < 0);
TEST_ASSERT_EQUAL_STRING("", out_buf);
TEST_ASSERT_NOT_NULL(strstr(err_buf, "unrecognized flag '-z'"));
TEST_ASSERT_NOT_NULL(strstr(err_buf, "usage: testcmd -h <host> [-c]"));
cleanup_dir();
}
static void test_reject_rejects_missing_value_long_flag(void)
{
struct option opts[3];
char optstring[8];
TEST_ASSERT_EQUAL_INT(0, flagspec_build_opts(g_testcmd_spec, 2, opts,
optstring,
sizeof(optstring)));
/* "--host" is the last argv element -- getopt_long() has nothing to
* consume as its required value. */
char* argv[] = { "testcmd", "--host", NULL };
int after = run_getopt_long_expect_error(2, argv, opts, optstring);
const char* bad_flag = argv[after - 1];
TEST_ASSERT_EQUAL_STRING("--host", bad_flag);
fresh_dir();
capture_t cap;
capture_begin(&cap);
int rc = flagspec_reject("testcmd", g_testcmd_spec, 2, bad_flag,
g_testcmd_usage);
char out_buf[512], err_buf[512];
capture_end(&cap, out_buf, sizeof(out_buf), err_buf, sizeof(err_buf));
TEST_ASSERT_TRUE(rc < 0);
TEST_ASSERT_EQUAL_STRING("", out_buf);
TEST_ASSERT_NOT_NULL(strstr(err_buf, "flag '--host' requires a value"));
TEST_ASSERT_NOT_NULL(strstr(err_buf, "usage: testcmd -h <host> [-c]"));
cleanup_dir();
}
static void test_reject_rejects_missing_value_short_flag(void)
{
struct option opts[3];
char optstring[8];
TEST_ASSERT_EQUAL_INT(0, flagspec_build_opts(g_testcmd_spec, 2, opts,
optstring,
sizeof(optstring)));
char* argv[] = { "testcmd", "-h", NULL };
int after = run_getopt_long_expect_error(2, argv, opts, optstring);
const char* bad_flag = argv[after - 1];
TEST_ASSERT_EQUAL_STRING("-h", bad_flag);
fresh_dir();
capture_t cap;
capture_begin(&cap);
int rc = flagspec_reject("testcmd", g_testcmd_spec, 2, bad_flag,
g_testcmd_usage);
char out_buf[512], err_buf[512];
capture_end(&cap, out_buf, sizeof(out_buf), err_buf, sizeof(err_buf));
TEST_ASSERT_TRUE(rc < 0);
TEST_ASSERT_EQUAL_STRING("", out_buf);
TEST_ASSERT_NOT_NULL(strstr(err_buf, "flag '-h' requires a value"));
TEST_ASSERT_NOT_NULL(strstr(err_buf, "usage: testcmd -h <host> [-c]"));
cleanup_dir();
}
/* Direct-call (non-getopt_long) tests: exercises flagspec_reject()'s own
* argument handling in isolation, including NULL inputs it must survive
* without crashing. */
static void test_reject_handles_null_bad_flag_and_null_usage_gracefully(void)
{
fresh_dir();
capture_t cap;
capture_begin(&cap);
int rc = flagspec_reject("testcmd", g_testcmd_spec, 2, NULL, NULL);
char out_buf[512], err_buf[512];
capture_end(&cap, out_buf, sizeof(out_buf), err_buf, sizeof(err_buf));
TEST_ASSERT_TRUE(rc < 0);
TEST_ASSERT_EQUAL_STRING("", out_buf);
TEST_ASSERT_NOT_NULL(strstr(err_buf, "unrecognized flag"));
cleanup_dir();
}
static void test_reject_no_argument_flag_given_a_value_reports_distinctly(void)
{
/* "-c" never requires a value; a caller that somehow reaches
* flagspec_reject() with the bare "-c" token (matches an entry, but
* that entry takes no_argument) must be told a different thing than
* either the unrecognized-flag or missing-value message. */
fresh_dir();
capture_t cap;
capture_begin(&cap);
int rc = flagspec_reject("testcmd", g_testcmd_spec, 2, "-c",
g_testcmd_usage);
char out_buf[512], err_buf[512];
capture_end(&cap, out_buf, sizeof(out_buf), err_buf, sizeof(err_buf));
TEST_ASSERT_TRUE(rc < 0);
TEST_ASSERT_EQUAL_STRING("", out_buf);
TEST_ASSERT_NOT_NULL(strstr(err_buf, "flag '-c' does not take a value"));
TEST_ASSERT_NOT_NULL(strstr(err_buf, "usage: testcmd -h <host> [-c]"));
cleanup_dir();
}
/* ---- flagspec_reject() typo-suggestion tests (Requirement 10) ---------
* A second fixture, g_ambiguous_spec, is purpose-built for the
* ambiguous-match case below so its edit distances are deliberate rather
* than incidental to g_testcmd_spec's two very dissimilar flag names. */
static const flagspec_t g_ambiguous_spec[2] = {
{ 0, "targeta", "target-a", no_argument, 0, NULL },
{ 0, "targetb", "target-b", no_argument, 0, NULL },
};
static void test_reject_suggests_single_close_match_flag_typo(void)
{
/* "--hst" is "--host" with the 'o' deleted -- one edit, well inside
* the closeness threshold for a 6-character long-form spelling -- and
* is nowhere near "--command". Requirement 10.1: name the
* likely-intended flag in the rejection message. */
fresh_dir();
capture_t cap;
capture_begin(&cap);
int rc = flagspec_reject("testcmd", g_testcmd_spec, 2, "--hst",
g_testcmd_usage);
char out_buf[512], err_buf[512];
capture_end(&cap, out_buf, sizeof(out_buf), err_buf, sizeof(err_buf));
TEST_ASSERT_TRUE(rc < 0);
TEST_ASSERT_EQUAL_STRING("", out_buf);
TEST_ASSERT_NOT_NULL(strstr(err_buf, "unrecognized flag '--hst'"));
TEST_ASSERT_NOT_NULL(strstr(err_buf, "did you mean"));
TEST_ASSERT_NOT_NULL(strstr(err_buf, "--host"));
cleanup_dir();
}
static void test_reject_gives_no_suggestion_for_a_distant_typo(void)
{
/* "--wombat" does not closely resemble "--host" or "--command" by any
* reasonable edit-distance threshold. Requirement 10.2: report
* unrecognized without guessing. */
fresh_dir();
capture_t cap;
capture_begin(&cap);
int rc = flagspec_reject("testcmd", g_testcmd_spec, 2, "--wombat",
g_testcmd_usage);
char out_buf[512], err_buf[512];
capture_end(&cap, out_buf, sizeof(out_buf), err_buf, sizeof(err_buf));
TEST_ASSERT_TRUE(rc < 0);
TEST_ASSERT_EQUAL_STRING("", out_buf);
TEST_ASSERT_NOT_NULL(strstr(err_buf, "unrecognized flag '--wombat'"));
TEST_ASSERT_NULL(strstr(err_buf, "did you mean"));
cleanup_dir();
}
static void test_reject_gives_no_suggestion_when_typo_is_ambiguous(void)
{
/* "--targetc" is exactly one substitution away from BOTH "--targeta"
* and "--targetb". Requirement 10.1 only promises a guess when
* exactly one flag qualifies -- two equally-plausible candidates must
* produce no suggestion at all, the same as a typo that matches
* nothing. */
fresh_dir();
capture_t cap;
capture_begin(&cap);
int rc = flagspec_reject("testcmd", g_ambiguous_spec, 2, "--targetc",
"testcmd --targeta|--targetb");
char out_buf[512], err_buf[512];
capture_end(&cap, out_buf, sizeof(out_buf), err_buf, sizeof(err_buf));
TEST_ASSERT_TRUE(rc < 0);
TEST_ASSERT_EQUAL_STRING("", out_buf);
TEST_ASSERT_NOT_NULL(strstr(err_buf, "unrecognized flag '--targetc'"));
TEST_ASSERT_NULL(strstr(err_buf, "did you mean"));
cleanup_dir();
}
/* ---- flagspec_render_help() tests (Requirement 8.2) -------------------- */
static void test_render_help_formats_multi_flag_table(void)
{
flagspec_t spec[3];
memcpy(spec, g_testcmd_spec, sizeof(g_testcmd_spec));
spec[2] = (flagspec_t){ 0, "json", "output-format", no_argument, 0,
"table" };
char* help = flagspec_render_help(spec, 3);
TEST_ASSERT_NOT_NULL(help);
/* required flag: short + long form, marked required */
TEST_ASSERT_NOT_NULL(strstr(help, "-h"));
TEST_ASSERT_NOT_NULL(strstr(help, "--host"));
TEST_ASSERT_NOT_NULL(strstr(help, "required"));
/* optional flag: short + long form, marked optional, default shown */
TEST_ASSERT_NOT_NULL(strstr(help, "-c"));
TEST_ASSERT_NOT_NULL(strstr(help, "--command"));
TEST_ASSERT_NOT_NULL(strstr(help, "optional"));
TEST_ASSERT_NOT_NULL(strstr(help, "off"));
/* long-only flag: no short form, still shows long form + default */
TEST_ASSERT_NOT_NULL(strstr(help, "--json"));
TEST_ASSERT_NOT_NULL(strstr(help, "table"));
free(help);
}
static void test_render_help_rejects_null_and_empty_inputs(void)
{
TEST_ASSERT_NULL(flagspec_render_help(NULL, 2));
TEST_ASSERT_NULL(flagspec_render_help(g_testcmd_spec, 0));
}
void test_flagspec(void)
{
UnitySetTestFile(__FILE__);
RUN_TEST(test_build_opts_produces_correct_table_for_two_flag_command);
RUN_TEST(test_build_opts_assigns_synthetic_val_to_long_only_flag);
RUN_TEST(test_build_opts_rejects_null_spec);
RUN_TEST(test_build_opts_rejects_zero_count);
RUN_TEST(test_build_opts_rejects_entry_with_null_long_opt);
RUN_TEST(test_build_opts_rejects_too_small_optstring_buffer);
RUN_TEST(test_reject_rejects_unrecognized_long_flag);
RUN_TEST(test_reject_rejects_unrecognized_short_flag);
RUN_TEST(test_reject_rejects_missing_value_long_flag);
RUN_TEST(test_reject_rejects_missing_value_short_flag);
RUN_TEST(test_reject_handles_null_bad_flag_and_null_usage_gracefully);
RUN_TEST(test_reject_no_argument_flag_given_a_value_reports_distinctly);
RUN_TEST(test_reject_suggests_single_close_match_flag_typo);
RUN_TEST(test_reject_gives_no_suggestion_for_a_distant_typo);
RUN_TEST(test_reject_gives_no_suggestion_when_typo_is_ambiguous);
RUN_TEST(test_render_help_formats_multi_flag_table);
RUN_TEST(test_render_help_rejects_null_and_empty_inputs);
}

View file

@ -1,582 +0,0 @@
/*
* test_interruptcall.c -- proves xcetest_run_interruptible()'s (task 1.4,
* Requirements 1.1, 1.2, 1.3, 1.4) heap-block CAS ownership handoff: a
* normal/fast call returns its real result and frees cleanly, and a
* forced-abandonment call returns -EINTR immediately with no double-free
* and no leak -- the abandoned worker thread finishes in the background
* and frees the block itself, proven via a side channel rather than
* assumed from a fixed sleep.
*
* ALSO proves (task 1.5, Requirements 1.1, 1.5) the per-connection lock
* registry added on top of that same call: two calls sharing a
* connection identity (set via xcetest_set_active_connection()) never
* run their fn bodies concurrently -- including a call issued right
* after a prior one on that same connection was abandoned, which must
* wait (interruptibly) for the abandoned worker to finish and release
* the lock -- while two calls against DIFFERENT connection identities
* run independently/concurrently. A third scenario proves that a call
* interrupted WHILE waiting for a lock still held by a prior abandoned
* call returns -EINTR without ever acquiring the lock or spawning a
* worker for itself.
*
* xcetest_interrupted() (declared in xcetest_interrupt.h) is normally
* defined in test.c, over test.c's own private break_out flag -- but
* test.c itself is never linked into controller_unit_test (see
* xcetest_interrupt.h's own header comment: test.c pulls in libedit/
* cJSON/libxce/Cap'n Proto/OpenSSL and defines its own main(), both of
* which make it impractical to link into a small Unity binary). So this
* file provides its OWN local fake definition of xcetest_interrupted(),
* over its own local flag -- the same "local fake standing in for a
* real dependency this binary never links" pattern already used
* elsewhere in this directory (e.g. test_commit_barrier_reply.c's/
* test_worker_join.c's own fakes for on_scheduler_join()/notify()/
* collect_confvars(), per src/controller/meson.build's comment on
* sched_push_unit_test_src). interruptcall.c only ever calls
* xcetest_interrupted() through its documented pure-read contract, so a
* fake reading a test-local flag exercises interruptcall.c's real CAS
* state machine exactly as production would.
*
* The forced-abandonment test triggers a REAL, OS-delivered SIGINT (via
* kill(getpid(), SIGINT), sent from a small helper thread once the
* worker has had time to start) rather than flipping the fake flag
* directly by hand, and installs its handler via xcetest_install_sigint()
* (task 1.3) -- the exact installer test.c's own main() uses -- so this
* test also doubles as a real, signal-level exercise of the same
* interrupt path a live xce_test process would take, not a purely
* hand-simulated one. This is choice (ii) from the task brief (a real
* SIGINT), preferred over choice (i) (a direct break_out setter) because
* no such setter exists on the REAL break_out anyway (xcetest_interrupt.h
* deliberately exposes none) -- and a real signal exercises the actual
* delivery path end-to-end, the same thing test_xcetest_interrupt.c
* already does for xcetest_install_sigint() itself.
*/
#include <errno.h>
#include <pthread.h>
#include <signal.h>
#include <stdatomic.h>
#include <time.h>
#include <unistd.h>
#include <unity/unity.h>
#include "interruptcall.h"
#include "xcetest_interrupt.h"
/* --- local fake for xcetest_interrupted() (see file header) --- */
static volatile sig_atomic_t g_test_break_out = 0;
int xcetest_interrupted(void) { return g_test_break_out; }
static void test_sigint_handler(int signo)
{
(void)signo;
g_test_break_out = 1;
}
/* ---- Scenario (a): normal, uninterrupted completion ---- */
#define NORMAL_FN_RC 7
static int fast_fn(void* ctx)
{
(void)ctx;
return NORMAL_FN_RC;
}
static void test_normal_completion_returns_real_result(void)
{
static int conn_normal; /* private fake connection key for this test */
g_test_break_out = 0;
xcetest_set_active_connection(&conn_normal);
int rc = xcetest_run_interruptible(fast_fn, NULL);
TEST_ASSERT_EQUAL_INT(NORMAL_FN_RC, rc);
/* No side channel to poll here: on this path the CALLING thread's own
* poll loop reads block->rc and frees the block synchronously, before
* xcetest_run_interruptible() ever returns -- so by the time the
* TEST_ASSERT_EQUAL_INT above runs, cleanup has unconditionally
* already happened. The remaining proof is this whole suite running
* clean under ASAN (no leak, no double-free) in the validating
* build+test run. */
}
/* ---- Scenario (b): forced abandonment via a real SIGINT ---- */
#define SLOW_FN_RC 99
#define SLOW_FN_SLEEP_MS 900
#define SIGNAL_DELAY_MS 150
#define WAIT_FOR_FREE_MS 3000
#define POLL_STEP_MS 20
static int slow_fn(void* ctx)
{
(void)ctx;
struct timespec ts;
ts.tv_sec = 0;
ts.tv_nsec = (long)SLOW_FN_SLEEP_MS * 1000L * 1000L;
nanosleep(&ts, NULL);
return SLOW_FN_RC;
}
/* Runs on its own helper thread so it can deliver SIGINT to the process
* WHILE the main test thread is blocked inside
* xcetest_run_interruptible()'s poll loop below. */
static void* send_sigint_after_delay(void* arg)
{
(void)arg;
struct timespec ts;
ts.tv_sec = 0;
ts.tv_nsec = (long)SIGNAL_DELAY_MS * 1000L * 1000L;
nanosleep(&ts, NULL);
kill(getpid(), SIGINT);
return NULL;
}
/* Polls the XCE_TESTING-only xcetest_test_abandoned_free_count side
* channel (NOT part of the production API) until it reaches `expected`
* or a generous timeout elapses, then asserts it landed exactly on
* `expected` -- so a test proves an abandoned worker's asynchronous
* cleanup (free + lock release, see interruptcall.c's abandoned path)
* actually completed, rather than merely hoping enough wall-clock time
* passed before the process moves on. Shared by every scenario below
* that produces an abandoned-but-still-running worker. */
static void wait_for_abandoned_free_count(int expected)
{
int waited_ms = 0;
while (atomic_load(&xcetest_test_abandoned_free_count) < expected
&& waited_ms < WAIT_FOR_FREE_MS) {
struct timespec poll;
poll.tv_sec = 0;
poll.tv_nsec = (long)POLL_STEP_MS * 1000L * 1000L;
nanosleep(&poll, NULL);
waited_ms += POLL_STEP_MS;
}
TEST_ASSERT_EQUAL_INT(expected, atomic_load(&xcetest_test_abandoned_free_count));
}
static void test_forced_abandonment_returns_eintr_and_worker_cleans_up(void)
{
static int conn_abandon; /* private fake connection key for this test */
g_test_break_out = 0;
atomic_store(&xcetest_test_abandoned_free_count, 0);
xcetest_install_sigint(test_sigint_handler);
xcetest_set_active_connection(&conn_abandon);
pthread_t signaler;
TEST_ASSERT_EQUAL_INT(
0, pthread_create(&signaler, NULL, send_sigint_after_delay, NULL));
/* SLOW_FN_SLEEP_MS (900ms) comfortably exceeds SIGNAL_DELAY_MS (150ms)
* plus several 50ms poll intervals, so the interrupt is reliably
* noticed well before slow_fn() itself would have returned -- this
* call is expected to return -EINTR, not SLOW_FN_RC. */
int rc = xcetest_run_interruptible(slow_fn, NULL);
TEST_ASSERT_EQUAL_INT(-EINTR, rc);
TEST_ASSERT_EQUAL_INT(0, pthread_join(signaler, NULL));
/* The abandoned worker is still running slow_fn() in the background
* at this point; wait_for_abandoned_free_count() proves its
* asynchronous cleanup actually happened -- confirming no leak --
* rather than merely hoping enough wall-clock time passed before the
* process exits. */
wait_for_abandoned_free_count(1);
}
/* ---- Scenario (c): per-connection lock registry (task 1.5) ----
*
* Requirements 1.1, 1.5. Three scenarios, per the task's Observable
* Completion line:
* (i) two overlapping calls sharing the SAME fake connection key
* (one abandoned, one issued immediately after) never run their
* fn bodies concurrently -- the second waits for the first's
* still-running abandoned worker to release the lock;
* (ii) two calls against DIFFERENT fake connection keys run
* independently -- one's abandonment does not block the other;
* (iii) a call interrupted WHILE waiting for a lock still held by a
* prior abandoned call returns -EINTR without ever acquiring
* the lock or spawning a worker for itself.
*
* All three reuse the same real-SIGINT-delivery pattern as scenario (b)
* above (xcetest_install_sigint() + a helper thread's kill(getpid(),
* SIGINT)) rather than flipping g_test_break_out by hand, for the same
* reason given in this file's header comment. g_test_break_out is
* intentionally reset to 0 between two calls in the same scenario
* whenever the SECOND call is expected to complete normally (not be
* interrupted itself) -- xcetest_interrupted() is a pure, non-clearing
* read (see xcetest_interrupt.h's CONTRACT), so a stale "interrupted"
* flag left set by an earlier call in the same test would otherwise be
* observed by the second call's own lock-wait or completion-wait poll
* and spuriously abandon it too.
*/
/* Shared probe: counts how many fn instances are concurrently "inside"
* their body right now, and how many times an instance started while
* that count was already nonzero. Used two ways below: scenario (i)
* asserts overlap_count stays 0 (same key must serialize); scenario
* (ii) asserts it becomes nonzero (different keys must NOT serialize). */
typedef struct {
_Atomic int running;
_Atomic int overlap_count;
_Atomic int ran;
} overlap_probe_t;
static int probe_enter(overlap_probe_t* p)
{
int prev = atomic_fetch_add(&p->running, 1);
if (prev != 0)
atomic_fetch_add(&p->overlap_count, 1);
atomic_store(&p->ran, 1);
return prev;
}
static void probe_leave(overlap_probe_t* p)
{
atomic_fetch_sub(&p->running, 1);
}
static void sleep_ms(long ms)
{
struct timespec ts;
ts.tv_sec = ms / 1000;
ts.tv_nsec = (ms % 1000) * 1000L * 1000L;
nanosleep(&ts, NULL);
}
static long elapsed_ms_since(const struct timespec* start)
{
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
return (now.tv_sec - start->tv_sec) * 1000L
+ (now.tv_nsec - start->tv_nsec) / 1000000L;
}
/* -- (i) same connection key serializes -- */
#define SAME_KEY_SLOW_SLEEP_MS 900
#define SAME_KEY_FAST_SLEEP_MS 50
#define SAME_KEY_FAST_RC 42
#define SAME_KEY_MIN_WAIT_MS 300 /* well below the ~700ms this call
* should actually wait for the
* first call's abandoned worker,
* comfortably above scheduling
* noise if the lock were NOT
* enforced (that failure mode
* returns in tens of ms). */
static int same_key_slow_fn(void* arg)
{
overlap_probe_t* p = arg;
probe_enter(p);
sleep_ms(SAME_KEY_SLOW_SLEEP_MS);
probe_leave(p);
return SLOW_FN_RC;
}
static int same_key_fast_fn(void* arg)
{
overlap_probe_t* p = arg;
probe_enter(p);
sleep_ms(SAME_KEY_FAST_SLEEP_MS);
probe_leave(p);
return SAME_KEY_FAST_RC;
}
static void test_same_connection_key_serializes_overlapping_calls(void)
{
static int conn_a; /* address used purely as an opaque fake key */
overlap_probe_t probe = {0};
pthread_t signaler;
struct timespec wait_start;
g_test_break_out = 0;
atomic_store(&xcetest_test_abandoned_free_count, 0);
xcetest_install_sigint(test_sigint_handler);
xcetest_set_active_connection(&conn_a);
TEST_ASSERT_EQUAL_INT(
0, pthread_create(&signaler, NULL, send_sigint_after_delay, NULL));
int rc1 = xcetest_run_interruptible(same_key_slow_fn, &probe);
TEST_ASSERT_EQUAL_INT(-EINTR, rc1);
TEST_ASSERT_EQUAL_INT(0, pthread_join(signaler, NULL));
TEST_ASSERT_TRUE_MESSAGE(atomic_load(&probe.ran),
"the slow call must have started running "
"before it was abandoned");
/* conn_a's lock is now held by the still-running abandoned worker.
* Reset the fake interrupt flag -- this second call must NOT be
* treated as interrupted itself; it is expected to wait for, then
* acquire, the same lock once the first worker releases it. */
g_test_break_out = 0;
clock_gettime(CLOCK_MONOTONIC, &wait_start);
int rc2 = xcetest_run_interruptible(same_key_fast_fn, &probe);
long waited_ms = elapsed_ms_since(&wait_start);
TEST_ASSERT_EQUAL_INT(SAME_KEY_FAST_RC, rc2);
TEST_ASSERT_EQUAL_INT(0, atomic_load(&probe.overlap_count));
TEST_ASSERT_TRUE_MESSAGE(
waited_ms >= SAME_KEY_MIN_WAIT_MS,
"second call on the same connection returned too quickly -- the "
"per-connection lock did not make it wait for the first call's "
"abandoned worker to finish and release it");
/* By the time rc2's call could acquire the lock, the abandoned
* worker's cleanup (free + counter increment, THEN lock release --
* see interruptcall.c's abandoned path) must already have happened. */
TEST_ASSERT_EQUAL_INT(1, atomic_load(&xcetest_test_abandoned_free_count));
}
/* -- (ii) different connection keys run independently -- */
#define DIFF_KEY_SLOW_SLEEP_MS 900
#define DIFF_KEY_FAST_SLEEP_MS 20
#define DIFF_KEY_FAST_RC 77
static int diff_key_slow_fn(void* arg)
{
overlap_probe_t* p = arg;
probe_enter(p);
sleep_ms(DIFF_KEY_SLOW_SLEEP_MS);
probe_leave(p);
return SLOW_FN_RC;
}
static int diff_key_fast_fn(void* arg)
{
overlap_probe_t* p = arg;
probe_enter(p);
sleep_ms(DIFF_KEY_FAST_SLEEP_MS);
probe_leave(p);
return DIFF_KEY_FAST_RC;
}
static void test_different_connection_keys_run_independently(void)
{
static int conn_b, conn_c; /* two distinct opaque fake keys */
overlap_probe_t probe = {0};
pthread_t signaler;
g_test_break_out = 0;
atomic_store(&xcetest_test_abandoned_free_count, 0);
xcetest_install_sigint(test_sigint_handler);
xcetest_set_active_connection(&conn_b);
TEST_ASSERT_EQUAL_INT(
0, pthread_create(&signaler, NULL, send_sigint_after_delay, NULL));
int rc1 = xcetest_run_interruptible(diff_key_slow_fn, &probe);
TEST_ASSERT_EQUAL_INT(-EINTR, rc1);
TEST_ASSERT_EQUAL_INT(0, pthread_join(signaler, NULL));
/* Reset the fake flag before the second call, same reasoning as
* scenario (i) -- this call is on a DIFFERENT connection and must
* complete normally. */
g_test_break_out = 0;
xcetest_set_active_connection(&conn_c);
int rc2 = xcetest_run_interruptible(diff_key_fast_fn, &probe);
TEST_ASSERT_EQUAL_INT(DIFF_KEY_FAST_RC, rc2);
TEST_ASSERT_TRUE_MESSAGE(
atomic_load(&probe.overlap_count) >= 1,
"different connection keys must not serialize against each "
"other -- expected the second call to run concurrently with the "
"first call's still-running abandoned worker");
wait_for_abandoned_free_count(1);
}
/* -- (iii) interrupted while waiting for a held lock: no worker spawned -- */
#define WAIT_KEY_SLOW_SLEEP_MS 900
static int wait_key_slow_fn(void* arg)
{
(void)arg;
sleep_ms(WAIT_KEY_SLOW_SLEEP_MS);
return SLOW_FN_RC;
}
static _Atomic int g_wait_key_fn_invoked = 0;
static int wait_key_never_should_run_fn(void* arg)
{
(void)arg;
atomic_store(&g_wait_key_fn_invoked, 1);
return 0;
}
static void test_interrupted_while_waiting_for_lock_never_spawns_worker(void)
{
static int conn_d;
pthread_t signaler1, signaler2;
struct timespec t0;
g_test_break_out = 0;
atomic_store(&xcetest_test_abandoned_free_count, 0);
atomic_store(&g_wait_key_fn_invoked, 0);
xcetest_install_sigint(test_sigint_handler);
xcetest_set_active_connection(&conn_d);
TEST_ASSERT_EQUAL_INT(
0, pthread_create(&signaler1, NULL, send_sigint_after_delay, NULL));
int rc1 = xcetest_run_interruptible(wait_key_slow_fn, NULL);
TEST_ASSERT_EQUAL_INT(-EINTR, rc1);
TEST_ASSERT_EQUAL_INT(0, pthread_join(signaler1, NULL));
/* conn_d's lock is now held by the still-running abandoned worker
* (up to ~750ms left of its 900ms sleep). Reset the fake flag, then
* arm a SECOND delayed SIGINT that fires while THIS next call is
* still waiting to acquire that same lock -- well before the
* abandoned worker could ever release it. */
g_test_break_out = 0;
TEST_ASSERT_EQUAL_INT(
0, pthread_create(&signaler2, NULL, send_sigint_after_delay, NULL));
clock_gettime(CLOCK_MONOTONIC, &t0);
int rc2 = xcetest_run_interruptible(wait_key_never_should_run_fn, NULL);
long elapsed_ms = elapsed_ms_since(&t0);
TEST_ASSERT_EQUAL_INT(0, pthread_join(signaler2, NULL));
TEST_ASSERT_EQUAL_INT(-EINTR, rc2);
TEST_ASSERT_EQUAL_INT_MESSAGE(
0, atomic_load(&g_wait_key_fn_invoked),
"a call interrupted while waiting for a held connection lock "
"must never spawn a worker / invoke fn for itself");
TEST_ASSERT_TRUE_MESSAGE(
elapsed_ms < (WAIT_KEY_SLOW_SLEEP_MS - SIGNAL_DELAY_MS),
"the interrupted lock-wait should return promptly, well before "
"the held lock would ever naturally free");
/* The first call's abandoned worker is still running in the
* background; wait for it to finish so this test leaves no live
* thread behind when it returns. */
wait_for_abandoned_free_count(1);
}
/* -- (iv) xcetest_wait_connection_idle() (task 3.2 remediation round 1,
* bug-1795) -- deterministic proof of the exact mechanism leave_ctx() now
* relies on to close the connection-teardown use-after-free.
*
* test_test_cli.c's black-box test_interrupt_then_immediate_teardown_no_uaf
* exercises the SAME fix end-to-end through a real xce_test subprocess, but
* the actual race window there (between an abandoned worker being woken by
* xcecli_wakeup() and leave_ctx()'s teardown proceeding) is sub-millisecond
* and timing-dependent -- it did not reliably reproduce as a failing test
* even with the fix disabled in this environment (matching the original
* round-1 review's own observation that a clean ASAN run is evidence the
* race didn't fire on THAT run's timing, not evidence the gap doesn't
* exist). This test instead proves xcetest_wait_connection_idle() itself,
* in isolation, with a WIDE, deterministic timing margin (a 700ms abandoned
* worker, not a sub-millisecond one) -- a direct, reliable RED (this
* function returning early would fail it) / GREEN (the shipped
* implementation passes it) demonstration of the invariant leave_ctx()
* depends on. */
#define WCI_SLOW_SLEEP_MS 700
#define WCI_MIN_WAIT_MS 400 /* comfortably below the ~700ms - \
* SIGNAL_DELAY_MS remaining sleep once the \
* call is abandoned, comfortably above \
* scheduling noise if \
* xcetest_wait_connection_idle() did NOT \
* actually wait (that failure mode returns \
* in ~0ms). */
static int wci_slow_fn(void* arg)
{
(void)arg;
sleep_ms(WCI_SLOW_SLEEP_MS);
return SLOW_FN_RC;
}
static void test_wait_connection_idle_blocks_until_abandoned_worker_finishes(
void)
{
static int conn_e; /* private fake connection key for this test */
pthread_t signaler;
struct timespec wait_start;
long waited_ms;
int rc2;
g_test_break_out = 0;
atomic_store(&xcetest_test_abandoned_free_count, 0);
xcetest_install_sigint(test_sigint_handler);
xcetest_set_active_connection(&conn_e);
TEST_ASSERT_EQUAL_INT(
0, pthread_create(&signaler, NULL, send_sigint_after_delay, NULL));
int rc = xcetest_run_interruptible(wci_slow_fn, NULL);
TEST_ASSERT_EQUAL_INT(-EINTR, rc);
TEST_ASSERT_EQUAL_INT(0, pthread_join(signaler, NULL));
/* At this instant the abandoned worker is still running wci_slow_fn()
* -- it has NOT yet freed its call block or released conn_e's lock
* (interruptcall.c's abandoned path). This is exactly the production
* race leave_ctx() closes: xcetest_wait_connection_idle() below must
* genuinely block until that worker finishes, not return immediately. */
TEST_ASSERT_EQUAL_INT_MESSAGE(
0, atomic_load(&xcetest_test_abandoned_free_count),
"the abandoned worker must still be running at this point for "
"this test to actually exercise the wait -- if this fires, the "
"worker finished implausibly fast and the timing margin above "
"needs to grow, not shrink");
clock_gettime(CLOCK_MONOTONIC, &wait_start);
xcetest_wait_connection_idle(&conn_e);
waited_ms = elapsed_ms_since(&wait_start);
TEST_ASSERT_TRUE_MESSAGE(
waited_ms >= WCI_MIN_WAIT_MS,
"xcetest_wait_connection_idle() returned too quickly -- it must "
"genuinely block until the abandoned worker finishes, not return "
"immediately (this is precisely the bug-1795 use-after-free gap: "
"a caller proceeding to tear down the connection before this "
"point would race the still-running worker)");
TEST_ASSERT_EQUAL_INT_MESSAGE(
1, atomic_load(&xcetest_test_abandoned_free_count),
"the abandoned worker's cleanup must have genuinely completed by "
"the time xcetest_wait_connection_idle() returns");
/* The semaphore must be left available (posted back), not held --
* proven by a normal subsequent call on the SAME connection key
* completing without waiting at all. Reset the fake interrupt flag
* first -- xcetest_interrupted() is a pure, non-clearing read (see
* xcetest_interrupt.h's CONTRACT), so the flag the earlier SIGINT set
* is still "on" here and would otherwise spuriously abandon this call
* too, same reasoning as every other multi-call scenario in this
* file. */
g_test_break_out = 0;
clock_gettime(CLOCK_MONOTONIC, &wait_start);
rc2 = xcetest_run_interruptible(fast_fn, NULL);
waited_ms = elapsed_ms_since(&wait_start);
TEST_ASSERT_EQUAL_INT(NORMAL_FN_RC, rc2);
TEST_ASSERT_TRUE_MESSAGE(
waited_ms < WCI_MIN_WAIT_MS,
"xcetest_wait_connection_idle() must release the semaphore back "
"immediately after proving idleness, not hold it -- a subsequent "
"call on the same connection should not itself have to wait");
}
void test_interruptcall(void)
{
UnitySetTestFile(__FILE__);
RUN_TEST(test_normal_completion_returns_real_result);
RUN_TEST(test_forced_abandonment_returns_eintr_and_worker_cleans_up);
RUN_TEST(test_same_connection_key_serializes_overlapping_calls);
RUN_TEST(test_different_connection_keys_run_independently);
RUN_TEST(test_interrupted_while_waiting_for_lock_never_spawns_worker);
RUN_TEST(test_wait_connection_idle_blocks_until_abandoned_worker_finishes);
}

View file

@ -1,342 +0,0 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <unity/unity.h>
#include "listfmt_json.h"
/* ---- stream-capture helpers --------------------------------------------
* Mirrors test_flagspec.c's dup/dup2/freopen stdout+stderr capture
* technique, so this test can prove Requirement 3's routing: JSON output
* is command-RESULT data and must land on stdout only, never stderr. Uses
* a fresh temp dir per test (mkdtemp) so capture files never collide with
* anything else in this shared /tmp. */
static char g_dir[256];
static void fresh_dir(void)
{
strcpy(g_dir, "/tmp/xce_listfmt_json_test_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(g_dir));
}
static void cleanup_dir(void)
{
char cmd[512];
snprintf(cmd, sizeof(cmd), "rm -rf %s", g_dir);
system(cmd);
}
typedef struct {
int saved_stdout_fd;
int saved_stderr_fd;
char stdout_path[300];
char stderr_path[300];
} capture_t;
static void capture_begin(capture_t* cap)
{
fflush(stdout);
fflush(stderr);
cap->saved_stdout_fd = dup(fileno(stdout));
cap->saved_stderr_fd = dup(fileno(stderr));
TEST_ASSERT_TRUE(cap->saved_stdout_fd >= 0);
TEST_ASSERT_TRUE(cap->saved_stderr_fd >= 0);
snprintf(cap->stdout_path, sizeof(cap->stdout_path), "%s/out", g_dir);
snprintf(cap->stderr_path, sizeof(cap->stderr_path), "%s/err", g_dir);
TEST_ASSERT_NOT_NULL(freopen(cap->stdout_path, "w", stdout));
TEST_ASSERT_NOT_NULL(freopen(cap->stderr_path, "w", stderr));
}
/* Restores the real stdout/stderr fds and reads both captured files back
* into caller-provided, NUL-terminated, heap-allocated buffers (the long-
* string test needs more than a fixed small stack buffer can safely
* hold). Caller must free() both *out_stdout and *out_stderr. */
static void capture_end(capture_t* cap, char** out_stdout, char** out_stderr)
{
fflush(stdout);
fflush(stderr);
TEST_ASSERT_TRUE(dup2(cap->saved_stdout_fd, fileno(stdout)) >= 0);
TEST_ASSERT_TRUE(dup2(cap->saved_stderr_fd, fileno(stderr)) >= 0);
close(cap->saved_stdout_fd);
close(cap->saved_stderr_fd);
clearerr(stdout);
clearerr(stderr);
FILE* f;
long len;
f = fopen(cap->stdout_path, "r");
TEST_ASSERT_NOT_NULL(f);
fseek(f, 0, SEEK_END);
len = ftell(f);
fseek(f, 0, SEEK_SET);
*out_stdout = malloc((size_t)len + 1);
TEST_ASSERT_NOT_NULL(*out_stdout);
size_t n = fread(*out_stdout, 1, (size_t)len, f);
(*out_stdout)[n] = '\0';
fclose(f);
f = fopen(cap->stderr_path, "r");
TEST_ASSERT_NOT_NULL(f);
fseek(f, 0, SEEK_END);
len = ftell(f);
fseek(f, 0, SEEK_SET);
*out_stderr = malloc((size_t)len + 1);
TEST_ASSERT_NOT_NULL(*out_stderr);
n = fread(*out_stderr, 1, (size_t)len, f);
(*out_stderr)[n] = '\0';
fclose(f);
}
/* ---- lifecycle tests: array/object plumbing, no printing involved ----
* These build a cJSON tree directly with the module under test, inspect
* it with cJSON's own accessors, and clean up with a plain cJSON_Delete
* (NOT listfmt_json_finish(), which also prints -- these tests are about
* the tree shape, not stdout). */
static void test_begin_returns_empty_array(void)
{
cJSON* array = listfmt_json_begin();
TEST_ASSERT_NOT_NULL(array);
TEST_ASSERT_TRUE(cJSON_IsArray(array));
TEST_ASSERT_EQUAL_INT(0, cJSON_GetArraySize(array));
cJSON_Delete(array);
}
static void test_new_row_appends_object_and_returns_it(void)
{
cJSON* array = listfmt_json_begin();
TEST_ASSERT_NOT_NULL(array);
cJSON* row1 = listfmt_json_new_row(array);
TEST_ASSERT_NOT_NULL(row1);
TEST_ASSERT_TRUE(cJSON_IsObject(row1));
TEST_ASSERT_EQUAL_INT(1, cJSON_GetArraySize(array));
cJSON* row2 = listfmt_json_new_row(array);
TEST_ASSERT_NOT_NULL(row2);
TEST_ASSERT_TRUE(row2 != row1);
TEST_ASSERT_EQUAL_INT(2, cJSON_GetArraySize(array));
TEST_ASSERT_TRUE(cJSON_GetArrayItem(array, 0) == row1);
TEST_ASSERT_TRUE(cJSON_GetArrayItem(array, 1) == row2);
cJSON_Delete(array);
}
static void test_new_row_returns_null_on_null_array(void)
{
TEST_ASSERT_NULL(listfmt_json_new_row(NULL));
}
static void test_add_string_and_number_populate_row_fields(void)
{
cJSON* array = listfmt_json_begin();
cJSON* row = listfmt_json_new_row(array);
listfmt_json_add_string(row, "host", "worker-07");
listfmt_json_add_number(row, "ntasks", 4);
cJSON* host = cJSON_GetObjectItem(row, "host");
TEST_ASSERT_NOT_NULL(host);
TEST_ASSERT_TRUE(cJSON_IsString(host));
TEST_ASSERT_EQUAL_STRING("worker-07", cJSON_GetStringValue(host));
cJSON* ntasks = cJSON_GetObjectItem(row, "ntasks");
TEST_ASSERT_NOT_NULL(ntasks);
TEST_ASSERT_TRUE(cJSON_IsNumber(ntasks));
/* Unity is built without UNITY_INCLUDE_DOUBLE (this codebase's
* convention, see test_host_runusage.c/test_e2e_capnp.c) -- compare
* via TEST_ASSERT_EQUAL_FLOAT instead of the disabled DOUBLE variant. */
TEST_ASSERT_EQUAL_FLOAT(4.0f, (float)cJSON_GetNumberValue(ntasks));
cJSON_Delete(array);
}
static void test_add_string_and_number_are_noop_on_null_row(void)
{
/* Must not crash. */
listfmt_json_add_string(NULL, "key", "val");
listfmt_json_add_number(NULL, "key", 1.0);
}
static void test_add_string_null_value_stored_as_json_null(void)
{
cJSON* array = listfmt_json_begin();
cJSON* row = listfmt_json_new_row(array);
listfmt_json_add_string(row, "note", NULL);
cJSON* note = cJSON_GetObjectItem(row, "note");
TEST_ASSERT_NOT_NULL(note);
TEST_ASSERT_TRUE(cJSON_IsNull(note));
cJSON_Delete(array);
}
/* ---- listfmt_json_finish() tests: valid JSON, stdout-only, no
* truncation (task 2.2's stated Observable Completion criterion) ------- */
static void test_finish_noop_on_null_array(void)
{
fresh_dir();
capture_t cap;
capture_begin(&cap);
listfmt_json_finish(NULL);
char *out, *err;
capture_end(&cap, &out, &err);
TEST_ASSERT_EQUAL_STRING("", out);
TEST_ASSERT_EQUAL_STRING("", err);
free(out);
free(err);
cleanup_dir();
}
static void test_finish_prints_valid_multirow_multifield_json_to_stdout_only(void)
{
cJSON* array = listfmt_json_begin();
TEST_ASSERT_NOT_NULL(array);
/* Row 0: worker-like fields. */
cJSON* row0 = listfmt_json_new_row(array);
listfmt_json_add_string(row0, "host", "worker-01");
listfmt_json_add_string(row0, "status", "UP");
listfmt_json_add_number(row0, "maxmem", 67108864);
/* Row 1: a different field mix, to prove per-row field independence. */
cJSON* row1 = listfmt_json_new_row(array);
listfmt_json_add_string(row1, "host", "worker-02");
listfmt_json_add_string(row1, "status", "DOWN");
listfmt_json_add_number(row1, "maxmem", 134217728);
/* Row 2: a third row, with a numeric jobid-shaped field too. */
cJSON* row2 = listfmt_json_new_row(array);
listfmt_json_add_string(row2, "host", "worker-03");
listfmt_json_add_string(row2, "status", "UP");
listfmt_json_add_number(row2, "maxmem", 268435456);
listfmt_json_add_number(row2, "jobcount", 12);
fresh_dir();
capture_t cap;
capture_begin(&cap);
listfmt_json_finish(array);
char *out, *err;
capture_end(&cap, &out, &err);
/* Requirement 3: result data on stdout only, never stderr. */
TEST_ASSERT_EQUAL_STRING("", err);
TEST_ASSERT_TRUE(strlen(out) > 0);
/* Genuinely valid, parseable JSON -- don't just eyeball the string,
* parse it back with cJSON's own parser and confirm the round-trip. */
cJSON* parsed = cJSON_Parse(out);
TEST_ASSERT_NOT_NULL(parsed);
TEST_ASSERT_TRUE(cJSON_IsArray(parsed));
TEST_ASSERT_EQUAL_INT(3, cJSON_GetArraySize(parsed));
/* Unity is built without UNITY_INCLUDE_DOUBLE (this codebase's
* convention, see test_host_runusage.c/test_e2e_capnp.c) -- compare
* numeric fields via TEST_ASSERT_EQUAL_FLOAT instead of the disabled
* DOUBLE variant. Every value here is an exact power of two (or a
* small integer), so the float cast loses no precision. */
cJSON* p0 = cJSON_GetArrayItem(parsed, 0);
TEST_ASSERT_EQUAL_STRING(
"worker-01", cJSON_GetStringValue(cJSON_GetObjectItem(p0, "host")));
TEST_ASSERT_EQUAL_STRING(
"UP", cJSON_GetStringValue(cJSON_GetObjectItem(p0, "status")));
TEST_ASSERT_EQUAL_FLOAT(
67108864.0f,
(float)cJSON_GetNumberValue(cJSON_GetObjectItem(p0, "maxmem")));
cJSON* p1 = cJSON_GetArrayItem(parsed, 1);
TEST_ASSERT_EQUAL_STRING(
"worker-02", cJSON_GetStringValue(cJSON_GetObjectItem(p1, "host")));
TEST_ASSERT_EQUAL_STRING(
"DOWN", cJSON_GetStringValue(cJSON_GetObjectItem(p1, "status")));
TEST_ASSERT_EQUAL_FLOAT(
134217728.0f,
(float)cJSON_GetNumberValue(cJSON_GetObjectItem(p1, "maxmem")));
cJSON* p2 = cJSON_GetArrayItem(parsed, 2);
TEST_ASSERT_EQUAL_STRING(
"worker-03", cJSON_GetStringValue(cJSON_GetObjectItem(p2, "host")));
TEST_ASSERT_EQUAL_STRING(
"UP", cJSON_GetStringValue(cJSON_GetObjectItem(p2, "status")));
TEST_ASSERT_EQUAL_FLOAT(
268435456.0f,
(float)cJSON_GetNumberValue(cJSON_GetObjectItem(p2, "maxmem")));
TEST_ASSERT_EQUAL_FLOAT(
12.0f, (float)cJSON_GetNumberValue(cJSON_GetObjectItem(p2, "jobcount")));
cJSON_Delete(parsed);
free(out);
free(err);
cleanup_dir();
}
static void test_finish_does_not_truncate_long_string_field(void)
{
/* A deliberately long value -- several hundred characters, built from
* a repeating, easily-verified pattern -- to prove Requirement 5.3:
* no field is truncated or misaligned regardless of value length. */
enum { LONG_LEN = 733 };
char long_val[LONG_LEN + 1];
for (int i = 0; i < LONG_LEN; i++)
long_val[i] = (char)('a' + (i % 26));
long_val[LONG_LEN] = '\0';
cJSON* array = listfmt_json_begin();
cJSON* row = listfmt_json_new_row(array);
listfmt_json_add_string(row, "description", long_val);
listfmt_json_add_string(row, "host", "worker-09");
fresh_dir();
capture_t cap;
capture_begin(&cap);
listfmt_json_finish(array);
char *out, *err;
capture_end(&cap, &out, &err);
TEST_ASSERT_EQUAL_STRING("", err);
cJSON* parsed = cJSON_Parse(out);
TEST_ASSERT_NOT_NULL(parsed);
TEST_ASSERT_EQUAL_INT(1, cJSON_GetArraySize(parsed));
cJSON* p0 = cJSON_GetArrayItem(parsed, 0);
const char* desc =
cJSON_GetStringValue(cJSON_GetObjectItem(p0, "description"));
TEST_ASSERT_NOT_NULL(desc);
TEST_ASSERT_EQUAL_INT(LONG_LEN, (int)strlen(desc));
TEST_ASSERT_EQUAL_STRING(long_val, desc);
/* A field appearing after the long one in the same row must not have
* been clipped/misaligned by it either. */
TEST_ASSERT_EQUAL_STRING(
"worker-09", cJSON_GetStringValue(cJSON_GetObjectItem(p0, "host")));
cJSON_Delete(parsed);
free(out);
free(err);
cleanup_dir();
}
void test_listfmt_json(void)
{
UnitySetTestFile(__FILE__);
RUN_TEST(test_begin_returns_empty_array);
RUN_TEST(test_new_row_appends_object_and_returns_it);
RUN_TEST(test_new_row_returns_null_on_null_array);
RUN_TEST(test_add_string_and_number_populate_row_fields);
RUN_TEST(test_add_string_and_number_are_noop_on_null_row);
RUN_TEST(test_add_string_null_value_stored_as_json_null);
RUN_TEST(test_finish_noop_on_null_array);
RUN_TEST(test_finish_prints_valid_multirow_multifield_json_to_stdout_only);
RUN_TEST(test_finish_does_not_truncate_long_string_field);
}

View file

@ -1,419 +0,0 @@
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include <unity/unity.h>
#include "rcfile.h"
/* Saved prior state of one environment variable, captured before a test
* case mutates it. Distinguishes "was unset" (getenv() returned NULL) from
* "was set to X" (including X == "") so restoration is exact -- required
* because controller_unit_test links 80+ test_*.c files into ONE process
* running sequentially, and a leaked HOME/XDG_CONFIG_HOME value would
* silently affect every test that runs after this file. */
typedef struct {
int was_set; /* 1 if getenv() returned non-NULL before mutation */
char* value; /* heap copy of the prior value; valid only if was_set */
} rcfile_envsave_t;
static rcfile_envsave_t env_save(const char* name)
{
rcfile_envsave_t s;
const char* cur = getenv(name);
if (cur) {
s.was_set = 1;
s.value = strdup(cur);
TEST_ASSERT_NOT_NULL(s.value);
} else {
s.was_set = 0;
s.value = NULL;
}
return s;
}
/* Restores `name` to exactly the state `s` captured, then frees `s`'s heap
* copy. Safe to call even if the test already unset/reset the variable
* itself -- always drives it back to the captured prior state. */
static void env_restore(const char* name, rcfile_envsave_t* s)
{
if (s->was_set) {
TEST_ASSERT_EQUAL_INT(0, setenv(name, s->value, 1));
free(s->value);
} else {
TEST_ASSERT_EQUAL_INT(0, unsetenv(name));
}
s->value = NULL;
}
static void test_resolve_uses_xdg_config_home_when_set_and_nonempty(void)
{
rcfile_envsave_t save_xdg = env_save("XDG_CONFIG_HOME");
rcfile_envsave_t save_home = env_save("HOME");
TEST_ASSERT_EQUAL_INT(
0, setenv("XDG_CONFIG_HOME", "/tmp/xce_rcfile_test_xdg", 1));
/* HOME is deliberately set to something recognizably different, so a
* pass here proves HOME was NOT consulted, not just that the result
* happens to match by coincidence. */
TEST_ASSERT_EQUAL_INT(
0, setenv("HOME", "/tmp/xce_rcfile_test_home_unused", 1));
char* path = rcfile_resolve_path();
TEST_ASSERT_NOT_NULL(path);
TEST_ASSERT_EQUAL_STRING("/tmp/xce_rcfile_test_xdg/xce_test/rc", path);
free(path);
env_restore("HOME", &save_home);
env_restore("XDG_CONFIG_HOME", &save_xdg);
}
static void test_resolve_falls_back_to_home_when_xdg_unset(void)
{
rcfile_envsave_t save_xdg = env_save("XDG_CONFIG_HOME");
rcfile_envsave_t save_home = env_save("HOME");
TEST_ASSERT_EQUAL_INT(0, unsetenv("XDG_CONFIG_HOME"));
TEST_ASSERT_EQUAL_INT(0, setenv("HOME", "/tmp/xce_rcfile_test_home", 1));
char* path = rcfile_resolve_path();
TEST_ASSERT_NOT_NULL(path);
TEST_ASSERT_EQUAL_STRING("/tmp/xce_rcfile_test_home/.config/xce_test/rc",
path);
free(path);
env_restore("HOME", &save_home);
env_restore("XDG_CONFIG_HOME", &save_xdg);
}
static void test_resolve_falls_back_to_home_when_xdg_empty(void)
{
rcfile_envsave_t save_xdg = env_save("XDG_CONFIG_HOME");
rcfile_envsave_t save_home = env_save("HOME");
TEST_ASSERT_EQUAL_INT(0, setenv("XDG_CONFIG_HOME", "", 1));
TEST_ASSERT_EQUAL_INT(
0, setenv("HOME", "/tmp/xce_rcfile_test_home2", 1));
char* path = rcfile_resolve_path();
TEST_ASSERT_NOT_NULL(path);
TEST_ASSERT_EQUAL_STRING("/tmp/xce_rcfile_test_home2/.config/xce_test/rc",
path);
free(path);
env_restore("HOME", &save_home);
env_restore("XDG_CONFIG_HOME", &save_xdg);
}
static void test_resolve_returns_null_when_neither_var_usable_both_unset(void)
{
rcfile_envsave_t save_xdg = env_save("XDG_CONFIG_HOME");
rcfile_envsave_t save_home = env_save("HOME");
TEST_ASSERT_EQUAL_INT(0, unsetenv("XDG_CONFIG_HOME"));
TEST_ASSERT_EQUAL_INT(0, unsetenv("HOME"));
char* path = rcfile_resolve_path();
TEST_ASSERT_NULL(path);
env_restore("HOME", &save_home);
env_restore("XDG_CONFIG_HOME", &save_xdg);
}
static void test_resolve_returns_null_when_neither_var_usable_both_empty(void)
{
rcfile_envsave_t save_xdg = env_save("XDG_CONFIG_HOME");
rcfile_envsave_t save_home = env_save("HOME");
TEST_ASSERT_EQUAL_INT(0, setenv("XDG_CONFIG_HOME", "", 1));
TEST_ASSERT_EQUAL_INT(0, setenv("HOME", "", 1));
char* path = rcfile_resolve_path();
TEST_ASSERT_NULL(path);
env_restore("HOME", &save_home);
env_restore("XDG_CONFIG_HOME", &save_xdg);
}
/* Proves the save/restore mechanism itself round-trips exactly, for all
* three prior-state shapes (unset / empty / a real value), regardless of
* whatever ambient state this test happened to start with -- this is what
* guarantees a test run AFTER this file never observes state leaked by it,
* since there is no later test in this same file to leak into. */
static void test_env_save_restore_roundtrips_all_three_prior_states(void)
{
const char* name = "XDG_CONFIG_HOME";
rcfile_envsave_t real_prior = env_save(name);
/* Case 1: prior state was unset. */
TEST_ASSERT_EQUAL_INT(0, unsetenv(name));
rcfile_envsave_t s1 = env_save(name);
TEST_ASSERT_EQUAL_INT(0, s1.was_set);
TEST_ASSERT_EQUAL_INT(0, setenv(name, "/tmp/mutated_during_case1", 1));
env_restore(name, &s1);
TEST_ASSERT_NULL(getenv(name));
/* Case 2: prior state was set to the empty string. */
TEST_ASSERT_EQUAL_INT(0, setenv(name, "", 1));
rcfile_envsave_t s2 = env_save(name);
TEST_ASSERT_EQUAL_INT(1, s2.was_set);
TEST_ASSERT_EQUAL_STRING("", s2.value);
TEST_ASSERT_EQUAL_INT(0, setenv(name, "/tmp/mutated_during_case2", 1));
env_restore(name, &s2);
TEST_ASSERT_NOT_NULL(getenv(name));
TEST_ASSERT_EQUAL_STRING("", getenv(name));
/* Case 3: prior state was set to a real value. */
TEST_ASSERT_EQUAL_INT(0, setenv(name, "/tmp/xce_rcfile_prior_value", 1));
rcfile_envsave_t s3 = env_save(name);
TEST_ASSERT_EQUAL_INT(1, s3.was_set);
TEST_ASSERT_EQUAL_STRING("/tmp/xce_rcfile_prior_value", s3.value);
TEST_ASSERT_EQUAL_INT(0, unsetenv(name));
env_restore(name, &s3);
TEST_ASSERT_NOT_NULL(getenv(name));
TEST_ASSERT_EQUAL_STRING("/tmp/xce_rcfile_prior_value", getenv(name));
env_restore(name, &real_prior);
}
/* ---- rcfile_load() tests (task 1.2) ---------------------------------- */
/* Test-local temp dir helper, mirroring test_configcompare.c's
* fresh_dir()/cleanup_dir() pattern -- keeps rc-file fixtures out of the
* shared /tmp leak this sandbox already tracks (bug-924) by cleaning up
* after itself instead of leaving an mkdtemp() dir behind forever. */
static char g_dir[256];
static void fresh_dir(void)
{
strcpy(g_dir, "/tmp/xce_rcfile_test_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(g_dir));
}
static void cleanup_dir(void)
{
char cmd[512];
snprintf(cmd, sizeof(cmd), "rm -rf %s", g_dir);
system(cmd);
}
static void write_file(const char* path, const char* content, size_t len)
{
FILE* f = fopen(path, "wb");
TEST_ASSERT_NOT_NULL(f);
if (len > 0)
TEST_ASSERT_EQUAL_INT((int)len, (int)fwrite(content, 1, len, f));
fclose(f);
}
/* Fake runner: records every line it is called with (heap copies, in call
* order) and, once scripted via fail_at_call, returns fail_rc on that call
* (1-based) -- lets a test assert both the exact propagated return value
* and the exact point iteration stopped. */
#define RCFILE_TEST_MAX_LINES 8
typedef struct {
char* lines[RCFILE_TEST_MAX_LINES];
int count;
int fail_at_call; /* 1-based; 0 = never fail */
int fail_rc;
} rcfile_fake_runner_state_t;
static rcfile_fake_runner_state_t g_fake;
static void fake_runner_reset(void)
{
for (int i = 0; i < g_fake.count; i++) {
free(g_fake.lines[i]);
g_fake.lines[i] = NULL;
}
g_fake.count = 0;
g_fake.fail_at_call = 0;
g_fake.fail_rc = 0;
}
static int fake_runner(char* line)
{
TEST_ASSERT_LESS_THAN_INT(RCFILE_TEST_MAX_LINES, g_fake.count);
g_fake.lines[g_fake.count] = strdup(line);
TEST_ASSERT_NOT_NULL(g_fake.lines[g_fake.count]);
g_fake.count++;
if (g_fake.fail_at_call != 0 && g_fake.count == g_fake.fail_at_call)
return g_fake.fail_rc;
return 0;
}
static void test_rcfile_load_returns_0_and_never_calls_runner_when_path_missing(void)
{
fresh_dir();
fake_runner_reset();
char missing_path[300];
snprintf(missing_path, sizeof(missing_path), "%s/does_not_exist", g_dir);
int rc = rcfile_load(missing_path, fake_runner);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(0, g_fake.count);
cleanup_dir();
}
static void test_rcfile_load_calls_runner_once_per_line_in_order_newline_stripped(void)
{
fresh_dir();
fake_runner_reset();
char path[300];
snprintf(path, sizeof(path), "%s/rc", g_dir);
/* Last line deliberately has no trailing newline -- getline() must
* still hand it to the runner intact, not require a final \n. */
const char* content =
"set masters host1:1,host2:2\nworker\njoin\nquit";
write_file(path, content, strlen(content));
int rc = rcfile_load(path, fake_runner);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(4, g_fake.count);
TEST_ASSERT_EQUAL_STRING("set masters host1:1,host2:2", g_fake.lines[0]);
TEST_ASSERT_EQUAL_STRING("worker", g_fake.lines[1]);
TEST_ASSERT_EQUAL_STRING("join", g_fake.lines[2]);
TEST_ASSERT_EQUAL_STRING("quit", g_fake.lines[3]);
cleanup_dir();
}
static void test_rcfile_load_stops_after_nonzero_runner_and_propagates_value(void)
{
fresh_dir();
fake_runner_reset();
g_fake.fail_at_call = 2;
g_fake.fail_rc = 42;
char path[300];
snprintf(path, sizeof(path), "%s/rc", g_dir);
const char* content = "line1\nline2\nline3\nline4\n";
write_file(path, content, strlen(content));
int rc = rcfile_load(path, fake_runner);
TEST_ASSERT_EQUAL_INT(42, rc);
TEST_ASSERT_EQUAL_INT(2, g_fake.count);
TEST_ASSERT_EQUAL_STRING("line1", g_fake.lines[0]);
TEST_ASSERT_EQUAL_STRING("line2", g_fake.lines[1]);
cleanup_dir();
}
/* A present-but-unreadable-as-a-regular-file path (a directory) must warn
* to stderr (non-fatal) and return 0 without ever calling the runner --
* proven here by actually capturing stderr via dup/dup2 (not just
* asserting the return value), then restoring the original fd exactly so
* later test files in this same combined binary see stderr unaffected. */
static void test_rcfile_load_directory_path_warns_and_returns_0(void)
{
fresh_dir();
fake_runner_reset();
fflush(stderr);
int saved_stderr_fd = dup(fileno(stderr));
TEST_ASSERT_TRUE(saved_stderr_fd >= 0);
char capture_path[300];
snprintf(capture_path, sizeof(capture_path), "%s/stderr_capture", g_dir);
FILE* redirected = freopen(capture_path, "w", stderr);
TEST_ASSERT_NOT_NULL(redirected);
int rc = rcfile_load(g_dir, fake_runner); /* g_dir is a directory */
fflush(stderr);
TEST_ASSERT_TRUE(dup2(saved_stderr_fd, fileno(stderr)) >= 0);
close(saved_stderr_fd);
clearerr(stderr);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(0, g_fake.count);
FILE* f = fopen(capture_path, "r");
TEST_ASSERT_NOT_NULL(f);
char buf[512];
size_t n = fread(buf, 1, sizeof(buf) - 1, f);
buf[n] = '\0';
fclose(f);
TEST_ASSERT_TRUE(n > 0);
TEST_ASSERT_NOT_NULL(strstr(buf, g_dir));
cleanup_dir();
}
/* Proves the getline()-not-fgets() design decision actually holds: a
* single line well beyond any typical small fixed-size buffer (e.g. the
* historic 256/1024-byte stack buffers this design note calls out) must
* reach the runner completely intact and unsplit -- a real content/length
* equality check, not just "didn't crash". */
static void test_rcfile_load_long_line_reaches_runner_intact_and_unsplit(void)
{
fresh_dir();
fake_runner_reset();
char path[300];
snprintf(path, sizeof(path), "%s/rc", g_dir);
/* Build a >4KB single line shaped like a real long `set masters` rc
* command: many comma-separated host:port pairs on one line. */
size_t cap = 8192;
char* long_line = (char*)malloc(cap);
TEST_ASSERT_NOT_NULL(long_line);
size_t off = (size_t)snprintf(long_line, cap, "set masters ");
int pair_no = 0;
while (off < 4096 + 200) {
int written = snprintf(long_line + off, cap - off,
"%shost%d:1000%d", pair_no ? "," : "",
pair_no, pair_no % 10);
TEST_ASSERT_TRUE(written > 0);
off += (size_t)written;
pair_no++;
}
TEST_ASSERT_TRUE(off > 4096);
/* File content: the long line, then a short trailer line, so a
* truncation/splitting bug would also show up as an extra/garbled
* runner call rather than only a length mismatch. */
size_t file_len = off + 1 /* \n */ + strlen("quit") + 1 /* \n */;
char* file_content = (char*)malloc(file_len + 1);
TEST_ASSERT_NOT_NULL(file_content);
int fc_len = snprintf(file_content, file_len + 1, "%s\nquit\n", long_line);
TEST_ASSERT_EQUAL_INT((int)file_len, fc_len);
write_file(path, file_content, file_len);
int rc = rcfile_load(path, fake_runner);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(2, g_fake.count);
TEST_ASSERT_TRUE(strlen(g_fake.lines[0]) > 4096);
TEST_ASSERT_EQUAL_INT((int)off, (int)strlen(g_fake.lines[0]));
TEST_ASSERT_EQUAL_STRING(long_line, g_fake.lines[0]);
TEST_ASSERT_EQUAL_STRING("quit", g_fake.lines[1]);
free(long_line);
free(file_content);
cleanup_dir();
}
void test_rcfile(void)
{
UnitySetTestFile(__FILE__);
RUN_TEST(test_resolve_uses_xdg_config_home_when_set_and_nonempty);
RUN_TEST(test_resolve_falls_back_to_home_when_xdg_unset);
RUN_TEST(test_resolve_falls_back_to_home_when_xdg_empty);
RUN_TEST(test_resolve_returns_null_when_neither_var_usable_both_unset);
RUN_TEST(test_resolve_returns_null_when_neither_var_usable_both_empty);
RUN_TEST(test_env_save_restore_roundtrips_all_three_prior_states);
RUN_TEST(test_rcfile_load_returns_0_and_never_calls_runner_when_path_missing);
RUN_TEST(test_rcfile_load_calls_runner_once_per_line_in_order_newline_stripped);
RUN_TEST(test_rcfile_load_stops_after_nonzero_runner_and_propagates_value);
RUN_TEST(test_rcfile_load_directory_path_warns_and_returns_0);
RUN_TEST(test_rcfile_load_long_line_reaches_runner_intact_and_unsplit);
}

View file

@ -1,133 +0,0 @@
#define _GNU_SOURCE
#include <errno.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <unity/unity.h>
#include "snapdiff.h"
static char g_dir[256];
static void fresh_dir(void)
{
strcpy(g_dir, "/tmp/xce_snapdiff_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(g_dir));
}
static void cleanup_dir(void)
{
char cmd[512];
snprintf(cmd, sizeof(cmd), "rm -rf %s", g_dir);
system(cmd);
}
static void write_file(const char* path, size_t size, uint8_t fill,
size_t flip_at, uint8_t flip_to)
{
uint8_t* buf = malloc(size);
memset(buf, fill, size);
if (flip_at < size)
buf[flip_at] = flip_to;
FILE* fp = fopen(path, "wb");
TEST_ASSERT_NOT_NULL(fp);
fwrite(buf, 1, size, fp);
fclose(fp);
free(buf);
}
static void test_identical_files(void)
{
fresh_dir();
char p1[512], p2[512];
snprintf(p1, sizeof(p1), "%s/a.bin", g_dir);
snprintf(p2, sizeof(p2), "%s/b.bin", g_dir);
write_file(p1, 1000, 0x42, 2000 /* no flip */, 0);
write_file(p2, 1000, 0x42, 2000, 0);
snapdiff_report_t rep;
int rc = snapdiff_paths(p1, p2, stdout, &rep);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(1, rep.identical);
TEST_ASSERT_EQUAL_INT(-1, (int)rep.diff_offset);
cleanup_dir();
}
static void test_size_mismatch(void)
{
fresh_dir();
char p1[512], p2[512];
snprintf(p1, sizeof(p1), "%s/a.bin", g_dir);
snprintf(p2, sizeof(p2), "%s/b.bin", g_dir);
write_file(p1, 1000, 0x42, 2000, 0);
write_file(p2, 999, 0x42, 2000, 0);
snapdiff_report_t rep;
int rc = snapdiff_paths(p1, p2, stdout, &rep);
TEST_ASSERT_EQUAL_INT(1, rc);
TEST_ASSERT_EQUAL_INT(1, rep.size_mismatch);
TEST_ASSERT_EQUAL_INT(0, rep.identical);
cleanup_dir();
}
/* bug-1389 regression shape: two SNAPDIFF_CHUNK_SIZE-sized chunks, content
* diverges starting at the exact first byte of chunk index 1 — the OPCode_
* snapshotTransfer chunk-boundary corruption signature. */
static void test_diff_at_chunk_boundary_is_flagged(void)
{
fresh_dir();
char p1[512], p2[512];
snprintf(p1, sizeof(p1), "%s/a.bin", g_dir);
snprintf(p2, sizeof(p2), "%s/b.bin", g_dir);
size_t size = SNAPDIFF_CHUNK_SIZE * 2;
write_file(p1, size, 0x11, size, 0);
write_file(p2, size, 0x11, SNAPDIFF_CHUNK_SIZE /* flip first byte of chunk 1 */,
0x22);
snapdiff_report_t rep;
int rc = snapdiff_paths(p1, p2, stdout, &rep);
TEST_ASSERT_EQUAL_INT(1, rc);
TEST_ASSERT_EQUAL_INT(0, rep.identical);
TEST_ASSERT_EQUAL_INT(SNAPDIFF_CHUNK_SIZE, (int)rep.diff_offset);
TEST_ASSERT_EQUAL_INT(1, rep.at_chunk_boundary);
TEST_ASSERT_EQUAL_INT(1, (int)rep.chunk_index);
cleanup_dir();
}
static void test_diff_mid_chunk_is_not_flagged_as_boundary(void)
{
fresh_dir();
char p1[512], p2[512];
snprintf(p1, sizeof(p1), "%s/a.bin", g_dir);
snprintf(p2, sizeof(p2), "%s/b.bin", g_dir);
write_file(p1, 1000, 0x11, 2000, 0);
write_file(p2, 1000, 0x11, 500 /* mid-file, not a chunk boundary */, 0x22);
snapdiff_report_t rep;
int rc = snapdiff_paths(p1, p2, stdout, &rep);
TEST_ASSERT_EQUAL_INT(1, rc);
TEST_ASSERT_EQUAL_INT(500, (int)rep.diff_offset);
TEST_ASSERT_EQUAL_INT(0, rep.at_chunk_boundary);
cleanup_dir();
}
static void test_missing_path_errors(void)
{
snapdiff_report_t rep;
int rc = snapdiff_paths("/tmp/xce_snapdiff_missing_a_zzz",
"/tmp/xce_snapdiff_missing_b_zzz", stdout, &rep);
TEST_ASSERT_LESS_THAN_INT(0, rc);
}
void test_snapdiff(void)
{
UnitySetTestFile(__FILE__);
RUN_TEST(test_identical_files);
RUN_TEST(test_size_mismatch);
RUN_TEST(test_diff_at_chunk_boundary_is_flagged);
RUN_TEST(test_diff_mid_chunk_is_not_flagged_as_boundary);
RUN_TEST(test_missing_path_errors);
}

View file

@ -1,470 +0,0 @@
#define _GNU_SOURCE
#include <errno.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <unity/unity.h>
#include "snapverify.h"
static char g_dir[256];
static void fresh_dir(void)
{
strcpy(g_dir, "/tmp/xce_snapverify_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(g_dir));
}
static void cleanup_dir(void)
{
char cmd[512];
snprintf(cmd, sizeof(cmd), "rm -rf %s", g_dir);
system(cmd);
}
/* Writes a checkpoint file matching persist.c write_snapshot()'s exact
* on-disk layout for SNAPSHOT_FORMAT_VERSION=2 (magic+lsn+cfgref(41) + fmt +
* job_count + per-job[payload_len+buf] + queue_count + per-queue
* [payload_len+opaque_bytes] + host_count + per-host[payload_len+buf] +
* cfg_bytes_len + cfg_bytes).
*
* Queue/host frame CONTENT is irrelevant here -- snapverify validates
* structure only (length-prefix walk), never capnp-decodes a payload -- so
* queue frames are written as an opaque [payload_len][name_len+name+status]
* blob (convenient filler reusing the queue_names/queue_status test
* parameters, NOT a real capnp QueueAdminState encoding) and host frames as
* an opaque [payload_len][0xCD-filled bytes] blob.
*
* job_lens/job_count, queue_names/queue_status/queue_count, and
* host_lens/host_count are all caller-controlled so tests can inject a
* specific defect or size at a specific section. cfg_bytes_len controls the
* trailing config-bytes section's declared length (content is 0xEF filler);
* pass 0 for "section present but empty" -- the section's 4-byte length
* field is ALWAYS written by this helper (mirrors persist.c writing it on
* every checkpoint, even when there are no local config bytes yet). Tests
* that need to inject a real header cfgref, or a file with NO config-bytes
* length field at all, use write_snap_full()/a hand-written file instead
* (see below).
*
* `truncate_last_bytes` drops that many bytes off the END of the fully-
* written file (0 = write it whole). */
static void write_snap_full(const char* path, uint64_t lsn,
int cfgref_present, uint64_t cfgref_tag,
const uint8_t* cfgref_sha /* 32 bytes, or NULL
* for all-zero */,
uint32_t fmt,
const uint32_t* job_lens, uint32_t job_count,
const char** queue_names,
const uint32_t* queue_status, uint32_t queue_count,
const uint32_t* host_lens, uint32_t host_count,
uint32_t cfg_bytes_len, int truncate_last_bytes)
{
FILE* fp = fopen(path, "wb");
TEST_ASSERT_NOT_NULL(fp);
char header[16 + 1 + 8 + 32];
memset(header, 0, sizeof(header));
memcpy(header, "XCESNAP\0", 8);
memcpy(header + 8, &lsn, 8);
header[16] = (char)(cfgref_present ? 1 : 0);
memcpy(header + 17, &cfgref_tag, 8);
if (cfgref_sha)
memcpy(header + 25, cfgref_sha, 32);
fwrite(header, 1, sizeof(header), fp);
fwrite(&fmt, sizeof(fmt), 1, fp);
fwrite(&job_count, sizeof(job_count), 1, fp);
for (uint32_t i = 0; i < job_count; i++) {
fwrite(&job_lens[i], sizeof(job_lens[i]), 1, fp);
uint8_t* buf = malloc(job_lens[i]);
memset(buf, 0xAB, job_lens[i]);
fwrite(buf, 1, job_lens[i], fp);
free(buf);
}
fwrite(&queue_count, sizeof(queue_count), 1, fp);
for (uint32_t i = 0; i < queue_count; i++) {
uint32_t nlen = (uint32_t)strlen(queue_names[i]);
uint32_t payload_len = (uint32_t)(sizeof(nlen) + nlen +
sizeof(queue_status[i]));
fwrite(&payload_len, sizeof(payload_len), 1, fp);
fwrite(&nlen, sizeof(nlen), 1, fp);
fwrite(queue_names[i], 1, nlen, fp);
fwrite(&queue_status[i], sizeof(queue_status[i]), 1, fp);
}
fwrite(&host_count, sizeof(host_count), 1, fp);
for (uint32_t i = 0; i < host_count; i++) {
fwrite(&host_lens[i], sizeof(host_lens[i]), 1, fp);
uint8_t* buf = malloc(host_lens[i]);
memset(buf, 0xCD, host_lens[i]);
fwrite(buf, 1, host_lens[i], fp);
free(buf);
}
fwrite(&cfg_bytes_len, sizeof(cfg_bytes_len), 1, fp);
if (cfg_bytes_len > 0) {
uint8_t* buf = malloc(cfg_bytes_len);
memset(buf, 0xEF, cfg_bytes_len);
fwrite(buf, 1, cfg_bytes_len, fp);
free(buf);
}
fclose(fp);
if (truncate_last_bytes > 0) {
long sz = 0;
FILE* f = fopen(path, "rb");
fseek(f, 0, SEEK_END);
sz = ftell(f);
fclose(f);
TEST_ASSERT_TRUE(truncate(path, sz - truncate_last_bytes) == 0);
}
}
/* Convenience wrapper for the (common) case of no header cfgref -- mirrors
* every pre-task-8.4 call site's shape, just with the new host_lens/
* host_count/cfg_bytes_len parameters appended. */
static void write_snap(const char* path, uint64_t lsn, uint32_t fmt,
const uint32_t* job_lens, uint32_t job_count,
const char** queue_names, const uint32_t* queue_status,
uint32_t queue_count,
const uint32_t* host_lens, uint32_t host_count,
uint32_t cfg_bytes_len, int truncate_last_bytes)
{
write_snap_full(path, lsn, 0, 0, NULL, fmt, job_lens, job_count,
queue_names, queue_status, queue_count, host_lens,
host_count, cfg_bytes_len, truncate_last_bytes);
}
static void test_legacy_position_only_file_is_ok(void)
{
fresh_dir();
char path[512];
snprintf(path, sizeof(path), "%s/snap-000000000000000a.capnp", g_dir);
char header[16];
memcpy(header, "XCESNAP\0", 8);
uint64_t lsn = 10;
memcpy(header + 8, &lsn, 8);
FILE* fp = fopen(path, "wb");
TEST_ASSERT_NOT_NULL(fp);
fwrite(header, 1, sizeof(header), fp);
fclose(fp);
snapverify_report_t rep;
int rc = snapverify_path(path, stdout, &rep);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(1, rep.ok);
TEST_ASSERT_EQUAL_INT(0, rep.has_entity_section);
TEST_ASSERT_EQUAL_UINT64(10, rep.checkpoint_lsn);
cleanup_dir();
}
static void test_entity_bearing_well_formed_file_is_ok(void)
{
fresh_dir();
char path[512];
snprintf(path, sizeof(path), "%s/snap-0000000000000064.capnp", g_dir);
uint32_t job_lens[3] = { 100, 200, 50 };
const char* qnames[2] = { "gpu", "cpu" };
uint32_t qstatus[2] = { 1, 0 };
/* fmt=2 (SNAPSHOT_FORMAT_VERSION), no hosts, cfg-bytes section present
* but empty -- must still be fully consumed to EOF by the fmt-2-aware
* parser (task 8.4 fix: this file shape is what a real checkpoint
* written by the current codebase looks like). */
write_snap(path, 100, 2, job_lens, 3, qnames, qstatus, 2, NULL, 0, 0, 0);
snapverify_report_t rep;
int rc = snapverify_path(path, stdout, &rep);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(1, rep.ok);
TEST_ASSERT_EQUAL_INT(1, rep.has_entity_section);
TEST_ASSERT_EQUAL_UINT32(2, rep.format_version);
TEST_ASSERT_EQUAL_UINT32(3, rep.job_count);
TEST_ASSERT_EQUAL_UINT32(3, rep.jobs_ok);
TEST_ASSERT_EQUAL_UINT32(2, rep.queue_count);
TEST_ASSERT_EQUAL_UINT32(2, rep.queues_ok);
TEST_ASSERT_EQUAL_UINT32(0, rep.host_count);
TEST_ASSERT_EQUAL_UINT32(0, rep.hosts_ok);
TEST_ASSERT_EQUAL_INT(1, rep.has_config_bytes_section);
TEST_ASSERT_EQUAL_UINT32(0, rep.config_bytes_len);
TEST_ASSERT_EQUAL_INT(-1, (int)rep.fail_offset);
cleanup_dir();
}
static void test_bad_magic_is_einval(void)
{
fresh_dir();
char path[512];
snprintf(path, sizeof(path), "%s/snap-bad.capnp", g_dir);
FILE* fp = fopen(path, "wb");
TEST_ASSERT_NOT_NULL(fp);
fwrite("NOTASNAP", 1, 8, fp);
char zero[8] = {0};
fwrite(zero, 1, 8, fp);
fclose(fp);
snapverify_report_t rep;
int rc = snapverify_path(path, stdout, &rep);
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
TEST_ASSERT_EQUAL_INT(0, rep.ok);
cleanup_dir();
}
static void test_truncated_header_is_eio(void)
{
fresh_dir();
char path[512];
snprintf(path, sizeof(path), "%s/snap-short.capnp", g_dir);
FILE* fp = fopen(path, "wb");
TEST_ASSERT_NOT_NULL(fp);
fwrite("XCE", 1, 3, fp); /* far short of the 16-byte minimum */
fclose(fp);
snapverify_report_t rep;
int rc = snapverify_path(path, stdout, &rep);
TEST_ASSERT_EQUAL_INT(-EIO, rc);
TEST_ASSERT_EQUAL_INT(0, rep.ok);
cleanup_dir();
}
static void test_unrecognized_format_version_is_einval(void)
{
fresh_dir();
char path[512];
snprintf(path, sizeof(path), "%s/snap-0000000000000001.capnp", g_dir);
uint32_t job_lens[1] = { 10 };
write_snap(path, 1, 99 /* bogus version */, job_lens, 1, NULL, NULL, 0,
NULL, 0, 0, 0);
snapverify_report_t rep;
int rc = snapverify_path(path, stdout, &rep);
TEST_ASSERT_EQUAL_INT(-EINVAL, rc);
TEST_ASSERT_EQUAL_INT(0, rep.ok);
TEST_ASSERT_EQUAL_UINT32(99, rep.format_version);
cleanup_dir();
}
/* bug-1389 regression shape: a job frame's length field decodes to garbage
* (e.g. transport corrupted a chunk) mid-stream. snapverify must stop at
* exactly the bad job, report how many parsed cleanly before it, and pinpoint
* the byte offset of the bad length field — the diagnostic this tool exists
* to produce instead of a bare "persist_load_snapshot failed rc=-5". */
static void test_corrupt_job_payload_len_reports_exact_failure_point(void)
{
fresh_dir();
char path[512];
snprintf(path, sizeof(path), "%s/snap-0000000000000002.capnp", g_dir);
uint32_t job_lens[4] = { 50, 60, 70, 80 };
write_snap(path, 2, 2, job_lens, 4, NULL, NULL, 0, NULL, 0, 0, 0);
/* Corrupt job 2's length field (3rd job, after header(57)+fmt(4)+
* job_count(4)+job0(4+50)+job1(4+60) = 57+4+4+54+64 = 183) to a value
* that cannot possibly fit in the remaining file. */
FILE* fp = fopen(path, "r+b");
TEST_ASSERT_NOT_NULL(fp);
long job2_len_off = 57 + 4 + 4 + (4 + 50) + (4 + 60);
fseek(fp, job2_len_off, SEEK_SET);
uint32_t garbage = 0x7FFFFFFF;
fwrite(&garbage, sizeof(garbage), 1, fp);
fclose(fp);
snapverify_report_t rep;
int rc = snapverify_path(path, stdout, &rep);
TEST_ASSERT_EQUAL_INT(-EIO, rc);
TEST_ASSERT_EQUAL_INT(0, rep.ok);
TEST_ASSERT_EQUAL_UINT32(2, rep.jobs_ok); /* job 0, job 1 parsed fine */
TEST_ASSERT_EQUAL_INT(job2_len_off, (int)rep.fail_offset);
cleanup_dir();
}
static void test_missing_path_errors(void)
{
snapverify_report_t rep;
int rc = snapverify_path("/tmp/xce_snapverify_does_not_exist_zzz", stdout,
&rep);
TEST_ASSERT_LESS_THAN_INT(0, rc);
}
/* task 8.4: the header's cfgref tag/sha must be DECODED (not merely
* reported present) -- a genuine round-trip assertion against concrete
* injected values, not just "non-zero". */
static void test_cfgref_tag_and_sha_decode_roundtrip(void)
{
fresh_dir();
char path[512];
snprintf(path, sizeof(path), "%s/snap-00000000000000c8.capnp", g_dir);
uint64_t tag = 0x1122334455667788ULL;
uint8_t sha[32];
memset(sha, 0xAA, 16);
memset(sha + 16, 0xBB, 16);
write_snap_full(path, 200, 1 /* cfgref_present */, tag, sha, 2, NULL, 0,
NULL, NULL, 0, NULL, 0, 0, 0);
snapverify_report_t rep;
int rc = snapverify_path(path, stdout, &rep);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(1, rep.ok);
TEST_ASSERT_EQUAL_INT(1, rep.has_cfgref);
TEST_ASSERT_EQUAL_UINT64(tag, rep.cfgref_tag);
TEST_ASSERT_EQUAL_MEMORY(sha, rep.cfgref_sha, 32);
cleanup_dir();
}
/* task 8.4: host frames get their own structural walk + counts, same shape
* as the job/queue loops. */
static void test_host_frames_are_walked_and_counted(void)
{
fresh_dir();
char path[512];
snprintf(path, sizeof(path), "%s/snap-00000000000000c9.capnp", g_dir);
uint32_t host_lens[3] = { 30, 45, 12 };
write_snap(path, 201, 2, NULL, 0, NULL, NULL, 0, host_lens, 3, 0, 0);
snapverify_report_t rep;
int rc = snapverify_path(path, stdout, &rep);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(1, rep.ok);
TEST_ASSERT_EQUAL_UINT32(3, rep.host_count);
TEST_ASSERT_EQUAL_UINT32(3, rep.hosts_ok);
cleanup_dir();
}
/* task 8.4: a non-empty trailing config-bytes section is reported with its
* concrete declared length. */
static void test_nonempty_config_bytes_section_is_reported(void)
{
fresh_dir();
char path[512];
snprintf(path, sizeof(path), "%s/snap-00000000000000ca.capnp", g_dir);
write_snap(path, 202, 2, NULL, 0, NULL, NULL, 0, NULL, 0, 42, 0);
snapverify_report_t rep;
int rc = snapverify_path(path, stdout, &rep);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(1, rep.ok);
TEST_ASSERT_EQUAL_INT(1, rep.has_config_bytes_section);
TEST_ASSERT_EQUAL_UINT32(42, rep.config_bytes_len);
cleanup_dir();
}
/* task 8.4: an EXPLICIT zero-length config-bytes section (the 4-byte length
* field IS present and reads 0, immediately followed by EOF) must be
* distinguishable from a file that has no config-bytes section at all --
* both are ok==1, but only the explicit-zero case has
* has_config_bytes_section==1. */
static void test_explicit_zero_length_config_bytes_section_is_ok(void)
{
fresh_dir();
char path[512];
snprintf(path, sizeof(path), "%s/snap-00000000000000cb.capnp", g_dir);
write_snap(path, 203, 2, NULL, 0, NULL, NULL, 0, NULL, 0, 0, 0);
snapverify_report_t rep;
int rc = snapverify_path(path, stdout, &rep);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(1, rep.ok);
TEST_ASSERT_EQUAL_INT(1, rep.has_config_bytes_section);
TEST_ASSERT_EQUAL_UINT32(0, rep.config_bytes_len);
cleanup_dir();
}
/* task 8.4: a file that simply ENDS right after the host section (no
* config_bytes_len field at all -- a slightly-older-but-still-fmt-2-
* consistent shape) is still ok==1, but has_config_bytes_section==0 --
* the counterpart to the explicit-zero-length case above, proving the two
* are genuinely distinguishable rather than collapsed into one "empty"
* bucket. Hand-written (not via write_snap_full, which always writes the
* length field) to produce exactly this shape. */
static void test_no_config_bytes_section_at_all_is_still_ok(void)
{
fresh_dir();
char path[512];
snprintf(path, sizeof(path), "%s/snap-00000000000000cc.capnp", g_dir);
FILE* fp = fopen(path, "wb");
TEST_ASSERT_NOT_NULL(fp);
char header[16 + 1 + 8 + 32];
memset(header, 0, sizeof(header));
memcpy(header, "XCESNAP\0", 8);
uint64_t lsn = 204;
memcpy(header + 8, &lsn, 8);
fwrite(header, 1, sizeof(header), fp);
uint32_t fmt = 2;
uint32_t zero = 0;
fwrite(&fmt, sizeof(fmt), 1, fp); /* format_version */
fwrite(&zero, sizeof(zero), 1, fp); /* job_count = 0 */
fwrite(&zero, sizeof(zero), 1, fp); /* queue_count = 0 */
fwrite(&zero, sizeof(zero), 1, fp); /* host_count = 0 */
/* No config_bytes_len field at all -- file ends right here. */
fclose(fp);
snapverify_report_t rep;
int rc = snapverify_path(path, stdout, &rep);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_INT(1, rep.ok);
TEST_ASSERT_EQUAL_INT(0, rep.has_config_bytes_section);
cleanup_dir();
}
/* task 8.4: same corrupt-length-field diagnostic as
* test_corrupt_job_payload_len_reports_exact_failure_point, in the host
* section instead of the job section. */
static void test_corrupt_host_payload_len_reports_exact_failure_point(void)
{
fresh_dir();
char path[512];
snprintf(path, sizeof(path), "%s/snap-00000000000000cd.capnp", g_dir);
uint32_t host_lens[3] = { 20, 30, 40 };
write_snap(path, 205, 2, NULL, 0, NULL, NULL, 0, host_lens, 3, 0, 0);
/* header(57) + fmt(4) + job_count(4) + [0 jobs] + queue_count(4) +
* [0 queues] + host_count(4) + host0(4+20) + host1(4+30) = offset of
* host2's payload_len field. */
long host2_len_off = 57 + 4 + 4 + 4 + 4 + (4 + 20) + (4 + 30);
FILE* fp = fopen(path, "r+b");
TEST_ASSERT_NOT_NULL(fp);
fseek(fp, host2_len_off, SEEK_SET);
uint32_t garbage = 0x7FFFFFFF;
fwrite(&garbage, sizeof(garbage), 1, fp);
fclose(fp);
snapverify_report_t rep;
int rc = snapverify_path(path, stdout, &rep);
TEST_ASSERT_EQUAL_INT(-EIO, rc);
TEST_ASSERT_EQUAL_INT(0, rep.ok);
TEST_ASSERT_EQUAL_UINT32(2, rep.hosts_ok); /* host 0, host 1 parsed fine */
TEST_ASSERT_EQUAL_INT(host2_len_off, (int)rep.fail_offset);
cleanup_dir();
}
void test_snapverify(void)
{
UnitySetTestFile(__FILE__);
RUN_TEST(test_legacy_position_only_file_is_ok);
RUN_TEST(test_entity_bearing_well_formed_file_is_ok);
RUN_TEST(test_bad_magic_is_einval);
RUN_TEST(test_truncated_header_is_eio);
RUN_TEST(test_unrecognized_format_version_is_einval);
RUN_TEST(test_corrupt_job_payload_len_reports_exact_failure_point);
RUN_TEST(test_missing_path_errors);
RUN_TEST(test_cfgref_tag_and_sha_decode_roundtrip);
RUN_TEST(test_host_frames_are_walked_and_counted);
RUN_TEST(test_nonempty_config_bytes_section_is_reported);
RUN_TEST(test_explicit_zero_length_config_bytes_section_is_ok);
RUN_TEST(test_no_config_bytes_section_at_all_is_still_ok);
RUN_TEST(test_corrupt_host_payload_len_reports_exact_failure_point);
}

File diff suppressed because it is too large Load diff

View file

@ -1,650 +0,0 @@
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
#include <zlib.h>
#include <unity/unity.h>
#include "config.h" /* WAL_HDR_CRC_SIZE, WAL_CRC_NONE */
#include "persist.h" /* WAL_LEGACY_TERM */
#include "walcompare.h"
#include "wal_types.h"
/* ------------------------------------------------------------------ *
* Harness: hand-write raw WAL segments (LSN, term, payload all under
* test control) into per-member temp dirs, then drive walcompare over
* them. Mirrors test_waldump's writer so the on-disk format is pinned
* independently of persist.c — and exercises the REAL waldump decoder
* that walcompare reuses (no second parser).
* ------------------------------------------------------------------ */
/* Append one entry to `fp`, chaining prev_crc from *last_crc. CRC32 over
* WAL_HDR_CRC_SIZE header bytes + payload (identical to persist.c append). */
static void put_entry(FILE* fp, uint64_t term, uint64_t lsn, uint32_t type,
const void* payload, uint32_t plen, uint32_t* last_crc,
int first_in_seg)
{
wal_entry_hdr_t hdr;
memset(&hdr, 0, sizeof(hdr));
hdr.term = term;
hdr.lsn = lsn;
hdr.timestamp = 0;
hdr.type = type;
hdr.payload_len = plen;
hdr.prev_crc = first_in_seg ? WAL_CRC_NONE : *last_crc;
uint32_t c = crc32(0L, (const Bytef*)&hdr, WAL_HDR_CRC_SIZE);
if (plen > 0)
c = crc32(c, (const Bytef*)payload, plen);
hdr.crc = c;
*last_crc = c;
fwrite(&hdr, sizeof(hdr), 1, fp);
if (plen > 0)
fwrite(payload, plen, 1, fp);
}
/* A single hand-built WAL entry spec for a member. */
typedef struct {
uint64_t term;
uint32_t type;
const char* payload; /* NUL-terminated; length is strlen (0 => no payload) */
} wal_spec_t;
/* Mint a fresh temp dir and write a single seg-0001.wal holding `specs`
* (LSNs 1..n contiguous). Returns the dir path in `dir_out`. */
static void build_member(char* dir_out, size_t dirsz, const wal_spec_t* specs,
int n)
{
snprintf(dir_out, dirsz, "/tmp/xce_walcmp_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(dir_out));
char path[512];
snprintf(path, sizeof(path), "%s/seg-%016lx.wal", dir_out, 1UL);
FILE* fp = fopen(path, "wb");
TEST_ASSERT_NOT_NULL(fp);
uint32_t last = WAL_CRC_NONE;
for (int i = 0; i < n; i++) {
uint32_t plen =
specs[i].payload ? (uint32_t)strlen(specs[i].payload) : 0;
put_entry(fp, specs[i].term, (uint64_t)(i + 1), specs[i].type,
specs[i].payload, plen, &last, i == 0);
}
fclose(fp);
}
/* ------------------------------------------------------------------ */
/* Identical member WALs → success (exit 0). */
/* ------------------------------------------------------------------ */
static void test_identical_members_ok(void)
{
wal_spec_t specs[] = {
{ 1, WAL_NOOP, NULL },
{ 1, WAL_JOB_SUBMIT, "jobA" },
{ 2, WAL_JOB_SUBMIT, "jobB" },
};
char a[256], b[256];
build_member(a, sizeof(a), specs, 3);
build_member(b, sizeof(b), specs, 3);
walcompare_member_t m[2] = {
{ a, 0 },
{ b, 0 },
};
walcompare_divergence_t div;
int rc = walcompare(m, 2, stdout, &div);
TEST_ASSERT_EQUAL_INT(WALCMP_OK, rc);
}
/* ------------------------------------------------------------------ */
/* Hand-divergent pair: SAME LSN, DIFFERENT term within the committed */
/* prefix → FAIL with the diverging LSN (R6.2/R6.3). */
/* ------------------------------------------------------------------ */
static void test_same_lsn_diff_term_divergent(void)
{
wal_spec_t a_specs[] = {
{ 1, WAL_NOOP, NULL },
{ 1, WAL_JOB_SUBMIT, "jobA" },
{ 2, WAL_JOB_SUBMIT, "jobB" }, /* LSN 3, term 2 */
};
wal_spec_t b_specs[] = {
{ 1, WAL_NOOP, NULL },
{ 1, WAL_JOB_SUBMIT, "jobA" },
{ 3, WAL_JOB_SUBMIT, "jobB" }, /* LSN 3, term 3 — divergent */
};
char a[256], b[256];
build_member(a, sizeof(a), a_specs, 3);
build_member(b, sizeof(b), b_specs, 3);
walcompare_member_t m[2] = { { a, 0 }, { b, 0 } };
walcompare_divergence_t div;
int rc = walcompare(m, 2, stdout, &div);
TEST_ASSERT_EQUAL_INT(WALCMP_DIVERGENT, rc);
TEST_ASSERT_EQUAL_UINT64(3, div.lsn); /* the diverging LSN */
TEST_ASSERT_EQUAL_UINT64(2, div.member_term[0]);
TEST_ASSERT_EQUAL_UINT64(3, div.member_term[1]);
}
/* ------------------------------------------------------------------ */
/* SAME LSN, SAME term, DIFFERENT payload (pcrc) → FAIL (R6.2: a content */
/* divergence is not masked by equal term + LSN range). */
/* ------------------------------------------------------------------ */
static void test_same_lsn_diff_payload_divergent(void)
{
wal_spec_t a_specs[] = {
{ 1, WAL_NOOP, NULL },
{ 1, WAL_JOB_SUBMIT, "jobA" }, /* LSN 2 */
};
wal_spec_t b_specs[] = {
{ 1, WAL_NOOP, NULL },
{ 1, WAL_JOB_SUBMIT, "jobZ" }, /* LSN 2, same term, different bytes */
};
char a[256], b[256];
build_member(a, sizeof(a), a_specs, 2);
build_member(b, sizeof(b), b_specs, 2);
walcompare_member_t m[2] = { { a, 0 }, { b, 0 } };
walcompare_divergence_t div;
int rc = walcompare(m, 2, stdout, &div);
TEST_ASSERT_EQUAL_INT(WALCMP_DIVERGENT, rc);
TEST_ASSERT_EQUAL_UINT64(2, div.lsn);
TEST_ASSERT_EQUAL_UINT64(1, div.member_term[0]);
TEST_ASSERT_EQUAL_UINT64(1, div.member_term[1]);
TEST_ASSERT_NOT_EQUAL(div.member_pcrc[0], div.member_pcrc[1]);
}
/* ------------------------------------------------------------------ */
/* A member that is AHEAD (longer log) with a DIFFERENT uncommitted tail */
/* is NOT a divergence — the shared committed prefix matches (§6.1). */
/* ------------------------------------------------------------------ */
static void test_uncommitted_tail_not_flagged(void)
{
wal_spec_t a_specs[] = {
{ 1, WAL_NOOP, NULL },
{ 1, WAL_JOB_SUBMIT, "jobA" }, /* shared committed prefix: LSN 1..2 */
};
wal_spec_t b_specs[] = {
{ 1, WAL_NOOP, NULL },
{ 1, WAL_JOB_SUBMIT, "jobA" },
{ 2, WAL_JOB_SUBMIT, "tailX" }, /* uncommitted tail — beyond shared tip */
};
char a[256], b[256];
build_member(a, sizeof(a), a_specs, 2); /* tip 2 */
build_member(b, sizeof(b), b_specs, 3); /* tip 3 */
/* Shared boundary = min recovered tip = 2; the LSN-3 tail is not compared. */
walcompare_member_t m[2] = { { a, 0 }, { b, 0 } };
walcompare_divergence_t div;
int rc = walcompare(m, 2, stdout, &div);
TEST_ASSERT_EQUAL_INT(WALCMP_OK, rc);
}
/* ------------------------------------------------------------------ */
/* An explicit committed boundary SHORTER than a member's log: a */
/* divergence ABOVE the boundary must NOT be flagged (§6.1 committed */
/* prefix only). */
/* ------------------------------------------------------------------ */
static void test_divergence_above_committed_boundary_not_flagged(void)
{
wal_spec_t a_specs[] = {
{ 1, WAL_NOOP, NULL },
{ 1, WAL_JOB_SUBMIT, "jobA" }, /* LSN 2 committed */
{ 2, WAL_JOB_SUBMIT, "uncA" }, /* LSN 3 uncommitted, divergent */
};
wal_spec_t b_specs[] = {
{ 1, WAL_NOOP, NULL },
{ 1, WAL_JOB_SUBMIT, "jobA" },
{ 9, WAL_JOB_SUBMIT, "uncB" }, /* LSN 3, different term+bytes */
};
char a[256], b[256];
build_member(a, sizeof(a), a_specs, 3);
build_member(b, sizeof(b), b_specs, 3);
/* committed boundary pinned at 2 → the LSN-3 divergence is below scope. */
walcompare_member_t m[2] = { { a, 2 }, { b, 2 } };
walcompare_divergence_t div;
int rc = walcompare(m, 2, stdout, &div);
TEST_ASSERT_EQUAL_INT(WALCMP_OK, rc);
/* Same data, but raise the boundary to 3 → now it IS a divergence. */
walcompare_member_t m3[2] = { { a, 3 }, { b, 3 } };
rc = walcompare(m3, 2, stdout, &div);
TEST_ASSERT_EQUAL_INT(WALCMP_DIVERGENT, rc);
TEST_ASSERT_EQUAL_UINT64(3, div.lsn);
}
/* ------------------------------------------------------------------ *
* Harness for the per-member checkpoint-floor tests (raft-ha-core-safety
* task 5.10a / ha-entity-snapshots task 7.1): builds `<dir_out>/wal/` (real
* segs, LSNs starting at `start_lsn`, contiguous CRC chain) and, when
* `floor_lsn > 0`, an empty `<dir_out>/snap/snap-<floor_lsn:016x>.capnp`
* marker (walcompare only parses the FILENAME's embedded LSN, never the
* content) — the sibling `wal/`+`snap/` layout `member_snapshot_floor`'s
* "Candidate 1" path expects, matching the real deployed layout
* (`<data>/wal/`, `<data>/snap/`).
* ------------------------------------------------------------------ */
static void build_member_floored(char* dir_out, size_t dirsz,
const wal_spec_t* specs, int n,
uint64_t start_lsn, uint64_t floor_lsn)
{
snprintf(dir_out, dirsz, "/tmp/xce_walcmp_fl_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(dir_out));
char waldir[288];
snprintf(waldir, sizeof(waldir), "%s/wal", dir_out);
TEST_ASSERT_EQUAL_INT(0, mkdir(waldir, 0755));
char path[512];
snprintf(path, sizeof(path), "%s/seg-%016lx.wal", waldir,
(unsigned long)start_lsn);
FILE* fp = fopen(path, "wb");
TEST_ASSERT_NOT_NULL(fp);
uint32_t last = WAL_CRC_NONE;
for (int i = 0; i < n; i++) {
uint32_t plen =
specs[i].payload ? (uint32_t)strlen(specs[i].payload) : 0;
put_entry(fp, specs[i].term, start_lsn + (uint64_t)i, specs[i].type,
specs[i].payload, plen, &last, i == 0);
}
fclose(fp);
if (floor_lsn > 0) {
char snapdir[288];
snprintf(snapdir, sizeof(snapdir), "%s/snap", dir_out);
TEST_ASSERT_EQUAL_INT(0, mkdir(snapdir, 0755));
char snapfile[512];
snprintf(snapfile, sizeof(snapfile), "%s/snap-%016lx.capnp", snapdir,
(unsigned long)floor_lsn);
FILE* sf = fopen(snapfile, "wb");
TEST_ASSERT_NOT_NULL(sf);
fclose(sf); /* content is irrelevant — only the filename is parsed */
}
}
/* ------------------------------------------------------------------ */
/* A member that caught up via a full-checkpoint snapshot legitimately */
/* holds NO raw WAL below its own installed checkpoint LSN — that range */
/* must be excluded from comparison, never flagged MISSING/divergent */
/* (raft-ha-core-safety task 5.10a / ha-entity-snapshots task 7.1). */
/* ------------------------------------------------------------------ */
static void test_checkpoint_floor_excludes_compacted_range(void)
{
/* Member A: full history, LSNs 1..5, uniform term 1. */
wal_spec_t a_specs[] = {
{ 1, WAL_NOOP, NULL },
{ 1, WAL_JOB_SUBMIT, "jobA" },
{ 1, WAL_JOB_SUBMIT, "jobB" },
{ 1, WAL_JOB_SUBMIT, "jobC" },
{ 1, WAL_JOB_SUBMIT, "jobD" },
};
/* Member B: caught up via a checkpoint at LSN 3 — its raw WAL only
* physically holds LSNs 4..5 (matching A's LSN 4..5 exactly), and it
* carries a snap-...0003.capnp marker declaring that floor. */
wal_spec_t b_specs[] = {
{ 1, WAL_JOB_SUBMIT, "jobC" }, /* LSN 4 */
{ 1, WAL_JOB_SUBMIT, "jobD" }, /* LSN 5 */
};
char a[256], b[256];
build_member(a, sizeof(a), a_specs, 5);
build_member_floored(b, sizeof(b), b_specs, 2, /*start_lsn=*/4,
/*floor_lsn=*/3);
walcompare_member_t m[2] = { { a, 5 }, { b, 5 } };
walcompare_divergence_t div;
int rc = walcompare(m, 2, stdout, &div);
TEST_ASSERT_EQUAL_INT_MESSAGE(WALCMP_OK, rc,
"a member's own checkpoint floor must suppress the compacted range "
"from comparison, not flag it MISSING");
}
/* ------------------------------------------------------------------ */
/* The checkpoint floor must NOT mask a genuine divergence within the */
/* range the compacted member DOES claim to hold (past its own floor). */
/* ------------------------------------------------------------------ */
static void test_checkpoint_floor_does_not_mask_real_divergence(void)
{
wal_spec_t a_specs[] = {
{ 1, WAL_NOOP, NULL },
{ 1, WAL_JOB_SUBMIT, "jobA" },
{ 1, WAL_JOB_SUBMIT, "jobB" },
{ 1, WAL_JOB_SUBMIT, "jobC" },
{ 1, WAL_JOB_SUBMIT, "jobD" },
};
/* Member B's post-floor LSN 4 entry DISAGREES with A's (different
* term+payload) — this is a real divergence and must still be caught. */
wal_spec_t b_specs[] = {
{ 9, WAL_JOB_SUBMIT, "DIFFERENT" }, /* LSN 4 — diverges from A */
{ 1, WAL_JOB_SUBMIT, "jobD" }, /* LSN 5 */
};
char a[256], b[256];
build_member(a, sizeof(a), a_specs, 5);
build_member_floored(b, sizeof(b), b_specs, 2, /*start_lsn=*/4,
/*floor_lsn=*/3);
walcompare_member_t m[2] = { { a, 5 }, { b, 5 } };
walcompare_divergence_t div;
int rc = walcompare(m, 2, stdout, &div);
TEST_ASSERT_EQUAL_INT_MESSAGE(WALCMP_DIVERGENT, rc,
"a checkpoint floor excludes the compacted range only -- a real "
"divergence within the member's post-floor range must still be caught");
TEST_ASSERT_EQUAL_UINT64(4, div.lsn);
}
/* ------------------------------------------------------------------ */
/* A member MISSING a committed entry (a hole inside the committed */
/* prefix the boundary covers) is a divergence (R4.2/R6.4). */
/* ------------------------------------------------------------------ */
static void test_missing_committed_entry_divergent(void)
{
/* Member A holds 1..3; member B holds only 1..2 but is told its
* committed boundary is 3 — it is MISSING the committed entry at LSN 3. */
wal_spec_t a_specs[] = {
{ 1, WAL_NOOP, NULL },
{ 1, WAL_JOB_SUBMIT, "jobA" },
{ 2, WAL_JOB_SUBMIT, "jobB" },
};
wal_spec_t b_specs[] = {
{ 1, WAL_NOOP, NULL },
{ 1, WAL_JOB_SUBMIT, "jobA" },
};
char a[256], b[256];
build_member(a, sizeof(a), a_specs, 3);
build_member(b, sizeof(b), b_specs, 2);
walcompare_member_t m[2] = { { a, 3 }, { b, 3 } };
walcompare_divergence_t div;
int rc = walcompare(m, 2, stdout, &div);
TEST_ASSERT_EQUAL_INT(WALCMP_DIVERGENT, rc);
TEST_ASSERT_EQUAL_UINT64(3, div.lsn);
TEST_ASSERT_EQUAL_INT(1, div.member_present[0]); /* A holds it */
TEST_ASSERT_EQUAL_INT(0, div.member_present[1]); /* B is missing it */
}
/* ------------------------------------------------------------------ */
/* Two legacy-0-term entries at the same LSN are NOT divergent: legacy 0 */
/* maps to WAL_LEGACY_TERM for both (R1.8). */
/* ------------------------------------------------------------------ */
static void test_legacy_zero_term_not_divergent(void)
{
wal_spec_t specs[] = {
{ 0, WAL_NOOP, NULL }, /* legacy-0 term */
{ 0, WAL_JOB_SUBMIT, "jobA" }, /* legacy-0 term, same payload */
};
char a[256], b[256];
build_member(a, sizeof(a), specs, 2);
build_member(b, sizeof(b), specs, 2);
walcompare_member_t m[2] = { { a, 0 }, { b, 0 } };
walcompare_divergence_t div;
int rc = walcompare(m, 2, stdout, &div);
TEST_ASSERT_EQUAL_INT(WALCMP_OK, rc);
}
/* ------------------------------------------------------------------ */
/* Three members, all identical → success (the live regression shape). */
/* ------------------------------------------------------------------ */
static void test_three_members_identical_ok(void)
{
wal_spec_t specs[] = {
{ 1, WAL_NOOP, NULL },
{ 1, WAL_JOB_SUBMIT, "j1" },
{ 1, WAL_CONFIG_MARKER, "cfgref" }, /* a config marker in the log */
{ 2, WAL_JOB_SUBMIT, "j2" },
};
char a[256], b[256], c[256];
build_member(a, sizeof(a), specs, 4);
build_member(b, sizeof(b), specs, 4);
build_member(c, sizeof(c), specs, 4);
walcompare_member_t m[3] = { { a, 0 }, { b, 0 }, { c, 0 } };
walcompare_divergence_t div;
int rc = walcompare(m, 3, stdout, &div);
TEST_ASSERT_EQUAL_INT(WALCMP_OK, rc);
}
/* ------------------------------------------------------------------ */
/* Three members, one with a divergent committed config marker → FAIL */
/* (R6.6: config markers reconcile by (term,lsn) like job entries). */
/* ------------------------------------------------------------------ */
static void test_three_members_divergent_config_marker(void)
{
wal_spec_t ok_specs[] = {
{ 1, WAL_NOOP, NULL },
{ 1, WAL_JOB_SUBMIT, "j1" },
{ 1, WAL_CONFIG_MARKER, "cfgX" }, /* LSN 3 config ref */
};
wal_spec_t bad_specs[] = {
{ 1, WAL_NOOP, NULL },
{ 1, WAL_JOB_SUBMIT, "j1" },
{ 1, WAL_CONFIG_MARKER, "cfgY" }, /* LSN 3, different config ref */
};
char a[256], b[256], c[256];
build_member(a, sizeof(a), ok_specs, 3);
build_member(b, sizeof(b), ok_specs, 3);
build_member(c, sizeof(c), bad_specs, 3);
walcompare_member_t m[3] = { { a, 0 }, { b, 0 }, { c, 0 } };
walcompare_divergence_t div;
int rc = walcompare(m, 3, stdout, &div);
TEST_ASSERT_EQUAL_INT(WALCMP_DIVERGENT, rc);
TEST_ASSERT_EQUAL_UINT64(3, div.lsn);
/* A and B agree; C diverges. */
TEST_ASSERT_EQUAL_UINT32(div.member_pcrc[0], div.member_pcrc[1]);
TEST_ASSERT_NOT_EQUAL(div.member_pcrc[0], div.member_pcrc[2]);
}
/* ------------------------------------------------------------------ */
/* A missing WAL source is a hard (negative) error, not a false OK. */
/* ------------------------------------------------------------------ */
static void test_missing_member_errors(void)
{
wal_spec_t specs[] = {
{ 1, WAL_NOOP, NULL },
{ 1, WAL_JOB_SUBMIT, "jobA" },
};
char a[256];
build_member(a, sizeof(a), specs, 2);
walcompare_member_t m[2] = {
{ a, 0 },
{ "/tmp/xce_walcmp_does_not_exist_zzz", 0 },
};
walcompare_divergence_t div;
int rc = walcompare(m, 2, stdout, &div);
TEST_ASSERT_LESS_THAN_INT(0, rc); /* negative errno, not OK/DIVERGENT */
}
/* ------------------------------------------------------------------ */
/* Fewer than two members is a usage error. */
/* ------------------------------------------------------------------ */
static void test_too_few_members_errors(void)
{
wal_spec_t specs[] = { { 1, WAL_NOOP, NULL } };
char a[256];
build_member(a, sizeof(a), specs, 1);
walcompare_member_t m[1] = { { a, 0 } };
int rc = walcompare(m, 1, stdout, NULL);
TEST_ASSERT_LESS_THAN_INT(0, rc);
}
/* ------------------------------------------------------------------ */
/* Machine-checkable report: a committed-prefix divergence must emit a */
/* parseable per-member line set and a committed-vs-uncommitted */
/* classification (task 1.1, R7.1/R7.5). Captured via open_memstream so */
/* the exact greppable tokens are asserted, not just the verdict. */
/* ------------------------------------------------------------------ */
static void test_machine_report_committed_divergence(void)
{
wal_spec_t a_specs[] = {
{ 1, WAL_NOOP, NULL },
{ 1, WAL_JOB_SUBMIT, "jobA" },
{ 2, WAL_JOB_SUBMIT, "jobB" }, /* LSN 3, term 2 */
};
wal_spec_t b_specs[] = {
{ 1, WAL_NOOP, NULL },
{ 1, WAL_JOB_SUBMIT, "jobA" },
{ 3, WAL_JOB_SUBMIT, "jobB" }, /* LSN 3, term 3 — divergent */
};
char a[256], b[256];
build_member(a, sizeof(a), a_specs, 3);
build_member(b, sizeof(b), b_specs, 3);
char* buf = NULL;
size_t len = 0;
FILE* out = open_memstream(&buf, &len);
TEST_ASSERT_NOT_NULL(out);
walcompare_member_t m[2] = { { a, 0 }, { b, 0 } };
walcompare_divergence_t div;
int rc = walcompare(m, 2, out, &div);
fclose(out);
TEST_ASSERT_EQUAL_INT(WALCMP_DIVERGENT, rc);
/* committed-vs-uncommitted classification line. */
TEST_ASSERT_NOT_NULL(strstr(buf, "WALCMP_DIVERGENCE"));
TEST_ASSERT_NOT_NULL(strstr(buf, "class=committed"));
/* parseable per-member lines with fixed field order. */
TEST_ASSERT_NOT_NULL(strstr(buf, "WALCMP member=0 lsn=3"));
TEST_ASSERT_NOT_NULL(strstr(buf, "WALCMP member=1 lsn=3"));
TEST_ASSERT_NOT_NULL(strstr(buf, "status=present"));
/* divergence struct carries the committed classification flag. */
TEST_ASSERT_EQUAL_INT(1, div.committed);
free(buf);
}
/* ------------------------------------------------------------------ */
/* walcompare_report emits a full per-member, per-committed-LSN table */
/* (lsn, term, pcrc, status) for identical members and a clean verdict. */
/* ------------------------------------------------------------------ */
static void test_report_full_member_table(void)
{
wal_spec_t specs[] = {
{ 1, WAL_NOOP, NULL },
{ 1, WAL_JOB_SUBMIT, "jobA" },
{ 2, WAL_JOB_SUBMIT, "jobB" },
};
char a[256], b[256], c[256];
build_member(a, sizeof(a), specs, 3);
build_member(b, sizeof(b), specs, 3);
build_member(c, sizeof(c), specs, 3);
char* buf = NULL;
size_t len = 0;
FILE* out = open_memstream(&buf, &len);
TEST_ASSERT_NOT_NULL(out);
walcompare_member_t m[3] = { { a, 0 }, { b, 0 }, { c, 0 } };
walcompare_divergence_t div;
int rc = walcompare_report(m, 3, out, &div);
fclose(out);
TEST_ASSERT_EQUAL_INT(WALCMP_OK, rc);
/* every member appears at every committed LSN with committed=1. */
TEST_ASSERT_NOT_NULL(strstr(buf, "WALCMP member=0 lsn=1"));
TEST_ASSERT_NOT_NULL(strstr(buf, "WALCMP member=2 lsn=3"));
TEST_ASSERT_NOT_NULL(strstr(buf, "committed=1"));
TEST_ASSERT_NOT_NULL(strstr(buf, "status=present"));
free(buf);
}
/* ------------------------------------------------------------------ */
/* A MISSING committed entry is tagged status=MISSING in the report, */
/* and classified class=committed (a committed-prefix hole, R4.2/R6.4). */
/* ------------------------------------------------------------------ */
static void test_report_missing_tagged(void)
{
wal_spec_t a_specs[] = {
{ 1, WAL_NOOP, NULL },
{ 1, WAL_JOB_SUBMIT, "jobA" },
{ 2, WAL_JOB_SUBMIT, "jobB" },
};
wal_spec_t b_specs[] = {
{ 1, WAL_NOOP, NULL },
{ 1, WAL_JOB_SUBMIT, "jobA" },
};
char a[256], b[256];
build_member(a, sizeof(a), a_specs, 3);
build_member(b, sizeof(b), b_specs, 2);
char* buf = NULL;
size_t len = 0;
FILE* out = open_memstream(&buf, &len);
TEST_ASSERT_NOT_NULL(out);
walcompare_member_t m[2] = { { a, 3 }, { b, 3 } };
walcompare_divergence_t div;
int rc = walcompare_report(m, 2, out, &div);
fclose(out);
TEST_ASSERT_EQUAL_INT(WALCMP_DIVERGENT, rc);
TEST_ASSERT_NOT_NULL(strstr(buf, "status=MISSING"));
TEST_ASSERT_NOT_NULL(strstr(buf, "class=committed"));
TEST_ASSERT_EQUAL_INT(1, div.committed);
free(buf);
}
/* ------------------------------------------------------------------ */
/* A divergence in the UNCOMMITTED tail (above the committed boundary) is */
/* reported class=uncommitted and is NON-fatal: the committed verdict is */
/* still WALCMP_OK (task 1.1 classification; §6.1 committed-prefix only). */
/* ------------------------------------------------------------------ */
static void test_report_uncommitted_tail_classified(void)
{
wal_spec_t a_specs[] = {
{ 1, WAL_NOOP, NULL },
{ 1, WAL_JOB_SUBMIT, "jobA" }, /* committed LSN 2 */
{ 2, WAL_JOB_SUBMIT, "uncA" }, /* LSN 3 uncommitted, divergent */
};
wal_spec_t b_specs[] = {
{ 1, WAL_NOOP, NULL },
{ 1, WAL_JOB_SUBMIT, "jobA" },
{ 9, WAL_JOB_SUBMIT, "uncB" }, /* LSN 3, different term+bytes */
};
char a[256], b[256];
build_member(a, sizeof(a), a_specs, 3);
build_member(b, sizeof(b), b_specs, 3);
char* buf = NULL;
size_t len = 0;
FILE* out = open_memstream(&buf, &len);
TEST_ASSERT_NOT_NULL(out);
/* committed boundary pinned at 2 → LSN-3 divergence is the tail. */
walcompare_member_t m[2] = { { a, 2 }, { b, 2 } };
walcompare_divergence_t div;
int rc = walcompare_report(m, 2, out, &div);
fclose(out);
TEST_ASSERT_EQUAL_INT(WALCMP_OK, rc); /* tail divergence is non-fatal */
TEST_ASSERT_NOT_NULL(strstr(buf, "class=uncommitted"));
TEST_ASSERT_NOT_NULL(strstr(buf, "committed=0"));
free(buf);
}
void test_walcompare(void)
{
UnitySetTestFile(__FILE__);
RUN_TEST(test_identical_members_ok);
RUN_TEST(test_same_lsn_diff_term_divergent);
RUN_TEST(test_same_lsn_diff_payload_divergent);
RUN_TEST(test_uncommitted_tail_not_flagged);
RUN_TEST(test_divergence_above_committed_boundary_not_flagged);
RUN_TEST(test_checkpoint_floor_excludes_compacted_range);
RUN_TEST(test_checkpoint_floor_does_not_mask_real_divergence);
RUN_TEST(test_missing_committed_entry_divergent);
RUN_TEST(test_legacy_zero_term_not_divergent);
RUN_TEST(test_three_members_identical_ok);
RUN_TEST(test_three_members_divergent_config_marker);
RUN_TEST(test_missing_member_errors);
RUN_TEST(test_too_few_members_errors);
RUN_TEST(test_machine_report_committed_divergence);
RUN_TEST(test_report_full_member_table);
RUN_TEST(test_report_missing_tagged);
RUN_TEST(test_report_uncommitted_tail_classified);
}

View file

@ -1,430 +0,0 @@
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <unistd.h>
#include <zlib.h>
#include <unity/unity.h>
#include "config.h" /* WAL_HDR_CRC_SIZE, WAL_CRC_NONE */
#include "controller.capnp.h" /* Job encode for the JOB_SUBMIT decode test */
#include "wal_types.h"
#include "waldump.h"
/* ------------------------------------------------------------------ *
* Test harness: hand-write raw WAL segments so the test fully controls
* LSNs (to inject gaps), the CRC chain (to inject a break), and a real
* Cap'n Proto Job payload (to exercise the submit jobid decode). This
* also pins the on-disk WAL format independently of persist.c.
* ------------------------------------------------------------------ */
static char g_dir[256];
/* Append one entry to `fp`, chaining prev_crc from *last_crc and updating it.
* Mirrors persist.c's append: CRC32 over WAL_HDR_CRC_SIZE header bytes + payload. */
static void put_entry(FILE* fp, uint64_t term, uint64_t lsn, uint32_t type,
const void* payload, uint32_t plen, uint32_t* last_crc,
int first_in_seg)
{
wal_entry_hdr_t hdr;
memset(&hdr, 0, sizeof(hdr));
hdr.term = term;
hdr.lsn = lsn;
hdr.timestamp = 0;
hdr.type = type;
hdr.payload_len = plen;
hdr.prev_crc = first_in_seg ? WAL_CRC_NONE : *last_crc;
uint32_t c = crc32(0L, (const Bytef*)&hdr, WAL_HDR_CRC_SIZE);
if (plen > 0)
c = crc32(c, (const Bytef*)payload, plen);
hdr.crc = c;
*last_crc = c;
fwrite(&hdr, sizeof(hdr), 1, fp);
if (plen > 0)
fwrite(payload, plen, 1, fp);
}
/* Encode a minimal Cap'n Proto Job carrying `jobid`+`subtime` (what a real
* WAL_JOB_SUBMIT payload looks like). Returns malloc'd bytes; caller frees.
* subtime lets a test make two payloads byte-identical (same subtime) or
* distinct (different subtime) to exercise bug-845 dup detection. */
static uint8_t* encode_job(uint64_t jobid, uint64_t subtime, size_t* out_len)
{
struct capn c;
capn_init_malloc(&c);
capn_ptr root = capn_root(&c);
Job_ptr jp = new_Job(root.seg);
struct Job cj;
memset(&cj, 0, sizeof(cj));
cj.jobid = jobid;
cj.subtime = subtime;
cj.state = 1;
write_Job(&cj, jp);
capn_setp(root, 0, jp.p);
size_t sz = capn_size(&c) + 64;
uint8_t* buf = malloc(sz);
int64_t n = capn_write_mem(&c, buf, sz, 0);
capn_free(&c);
*out_len = (size_t)n;
return buf;
}
static const char* seg_path(uint64_t start_lsn, char* out, size_t n)
{
snprintf(out, n, "%s/seg-%016lx.wal", g_dir, (unsigned long)start_lsn);
return out;
}
/* setUp/tearDown are owned by the shared controller_test runner, so each
* test mints its own fresh temp WAL dir here instead. */
static void fresh_dir(void)
{
strcpy(g_dir, "/tmp/xce_waldump_XXXXXX");
TEST_ASSERT_NOT_NULL(mkdtemp(g_dir));
}
/* ------------------------------------------------------------------ */
/* A healthy, contiguous log from LSN 1: no gaps, tip == last entry. */
/* ------------------------------------------------------------------ */
static void test_contiguous_log_has_no_gaps(void)
{
fresh_dir();
char path[512];
FILE* fp = fopen(seg_path(1, path, sizeof(path)), "wb");
TEST_ASSERT_NOT_NULL(fp);
uint32_t last = WAL_CRC_NONE;
put_entry(fp, 0, 1, WAL_NOOP, NULL, 0, &last, 1);
size_t p2, p3;
uint8_t* j2 = encode_job(1001, 100, &p2); /* distinct jobs → no dup */
uint8_t* j3 = encode_job(1002, 100, &p3);
put_entry(fp, 0, 2, WAL_JOB_SUBMIT, j2, (uint32_t)p2, &last, 0);
put_entry(fp, 0, 3, WAL_JOB_SUBMIT, j3, (uint32_t)p3, &last, 0);
free(j2);
free(j3);
fclose(fp);
waldump_stats_t st;
int rc = waldump_path(g_dir, stdout, &st);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_UINT(3, st.n_entries);
TEST_ASSERT_EQUAL_UINT(2, st.n_submits);
TEST_ASSERT_EQUAL_UINT(0, st.n_gaps);
TEST_ASSERT_EQUAL_UINT(0, st.n_dup_submits); /* distinct jobids */
TEST_ASSERT_EQUAL_UINT64(1, st.first_lsn);
TEST_ASSERT_EQUAL_UINT64(3, st.last_lsn);
TEST_ASSERT_EQUAL_UINT64(3, st.recovered_tip);
TEST_ASSERT_EQUAL_INT(0, st.crc_broken);
}
/* ------------------------------------------------------------------ */
/* The failover bug signature: log starts at a high LSN (empty-rejoin */
/* caught up from the watermark) so the committed prefix is missing. */
/* ------------------------------------------------------------------ */
static void test_missing_prefix_flagged_as_gap(void)
{
fresh_dir();
char path[512];
/* seg file named from its first LSN, 13 (like ctrl-3 in the live repro) */
FILE* fp = fopen(seg_path(13, path, sizeof(path)), "wb");
TEST_ASSERT_NOT_NULL(fp);
uint32_t last = WAL_CRC_NONE;
put_entry(fp, 0, 13, WAL_NOOP, NULL, 0, &last, 1);
size_t p14, p15;
uint8_t* j14 = encode_job(2001, 100, &p14);
uint8_t* j15 = encode_job(2002, 100, &p15);
put_entry(fp, 0, 14, WAL_JOB_SUBMIT, j14, (uint32_t)p14, &last, 0);
put_entry(fp, 0, 15, WAL_JOB_SUBMIT, j15, (uint32_t)p15, &last, 0);
free(j14);
free(j15);
fclose(fp);
waldump_stats_t st;
int rc = waldump_path(g_dir, stdout, &st);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_UINT(3, st.n_entries);
TEST_ASSERT_EQUAL_UINT64(13, st.first_lsn);
/* prefix [1..12] absent → exactly one gap */
TEST_ASSERT_EQUAL_UINT(1, st.n_gaps);
/* g_dir has no sibling data/snap/ at all → nothing can explain it */
TEST_ASSERT_EQUAL_INT(0, st.leading_gap_explained);
}
/* ------------------------------------------------------------------ */
/* WAL_CONFIG_MARKER must decode as its own named type, not fall into */
/* the generic UNKNOWN bucket (a real, defined type — wal_types.h). */
/* ------------------------------------------------------------------ */
static void test_config_marker_type_decoded(void)
{
fresh_dir();
char path[512];
FILE* fp = fopen(seg_path(1, path, sizeof(path)), "wb");
TEST_ASSERT_NOT_NULL(fp);
uint32_t last = WAL_CRC_NONE;
put_entry(fp, 0, 1, WAL_NOOP, NULL, 0, &last, 1);
uint8_t marker[WAL_CONFIG_MARKER_PAYLOAD_LEN];
uint64_t tag = 0x1122334455667788ULL;
for (int i = 0; i < 8; i++)
marker[i] = (uint8_t)(tag >> (56 - i * 8));
memset(marker + 8, 0xab, 32);
put_entry(fp, 3, 2, WAL_CONFIG_MARKER, marker, sizeof(marker), &last, 0);
fclose(fp);
char* buf = NULL;
size_t bufsz = 0;
FILE* mem = open_memstream(&buf, &bufsz);
TEST_ASSERT_NOT_NULL(mem);
waldump_stats_t st;
int rc = waldump_path(g_dir, mem, &st);
fclose(mem);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_NOT_NULL(strstr(buf, "CONFIG_MARKER"));
TEST_ASSERT_NULL(strstr(buf, "UNKNOWN"));
TEST_ASSERT_NOT_NULL(strstr(buf, "tag=1122334455667788"));
free(buf);
}
/* ------------------------------------------------------------------ */
/* A leading gap covered by a sibling data/snap/ snapshot is normal, */
/* checkpoint-triggered WAL trim — waldump must say so, not alarm. */
/* ------------------------------------------------------------------ */
static void test_leading_gap_explained_by_covering_snapshot(void)
{
fresh_dir();
char wal_dir[300], snap_dir[300];
snprintf(wal_dir, sizeof(wal_dir), "%s/data/wal", g_dir);
snprintf(snap_dir, sizeof(snap_dir), "%s/data/snap", g_dir);
char data_dir[300];
snprintf(data_dir, sizeof(data_dir), "%s/data", g_dir);
TEST_ASSERT_EQUAL_INT(0, mkdir(data_dir, 0755));
TEST_ASSERT_EQUAL_INT(0, mkdir(wal_dir, 0755));
TEST_ASSERT_EQUAL_INT(0, mkdir(snap_dir, 0755));
/* Snapshot at LSN 12 covers the trimmed prefix [1..12] below. */
char snap_file[400];
snprintf(snap_file, sizeof(snap_file), "%s/snap-%016x.capnp", snap_dir,
0xc);
FILE* sf = fopen(snap_file, "wb");
TEST_ASSERT_NOT_NULL(sf);
fclose(sf);
char segp[400];
snprintf(segp, sizeof(segp), "%s/seg-%016lx.wal", wal_dir,
(unsigned long)13);
FILE* fp = fopen(segp, "wb");
TEST_ASSERT_NOT_NULL(fp);
uint32_t last = WAL_CRC_NONE;
put_entry(fp, 0, 13, WAL_NOOP, NULL, 0, &last, 1);
fclose(fp);
char* buf = NULL;
size_t bufsz = 0;
FILE* mem = open_memstream(&buf, &bufsz);
TEST_ASSERT_NOT_NULL(mem);
waldump_stats_t st;
int rc = waldump_path(g_dir, mem, &st);
fclose(mem);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_UINT(1, st.n_gaps);
TEST_ASSERT_NOT_EQUAL(0, st.leading_gap_explained);
TEST_ASSERT_NOT_NULL(strstr(buf, "covered by snapshot"));
TEST_ASSERT_NULL(strstr(buf, "committed prefix missing!"));
free(buf);
}
/* ------------------------------------------------------------------ */
/* A snapshot that does NOT cover the full gap must NOT explain it — */
/* the alarming wording stays, since the gap could still be real loss. */
/* ------------------------------------------------------------------ */
static void test_leading_gap_not_explained_by_insufficient_snapshot(void)
{
fresh_dir();
char wal_dir[300], snap_dir[300], data_dir[300];
snprintf(data_dir, sizeof(data_dir), "%s/data", g_dir);
snprintf(wal_dir, sizeof(wal_dir), "%s/data/wal", g_dir);
snprintf(snap_dir, sizeof(snap_dir), "%s/data/snap", g_dir);
TEST_ASSERT_EQUAL_INT(0, mkdir(data_dir, 0755));
TEST_ASSERT_EQUAL_INT(0, mkdir(wal_dir, 0755));
TEST_ASSERT_EQUAL_INT(0, mkdir(snap_dir, 0755));
/* Snapshot at LSN 5 does NOT cover the trimmed prefix [1..12] below. */
char snap_file[400];
snprintf(snap_file, sizeof(snap_file), "%s/snap-%016x.capnp", snap_dir,
0x5);
FILE* sf = fopen(snap_file, "wb");
TEST_ASSERT_NOT_NULL(sf);
fclose(sf);
char segp[400];
snprintf(segp, sizeof(segp), "%s/seg-%016lx.wal", wal_dir,
(unsigned long)13);
FILE* fp = fopen(segp, "wb");
TEST_ASSERT_NOT_NULL(fp);
uint32_t last = WAL_CRC_NONE;
put_entry(fp, 0, 13, WAL_NOOP, NULL, 0, &last, 1);
fclose(fp);
waldump_stats_t st;
int rc = waldump_path(g_dir, stdout, &st);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_UINT(1, st.n_gaps);
TEST_ASSERT_EQUAL_INT(0, st.leading_gap_explained);
}
/* ------------------------------------------------------------------ */
/* bug-845: each job double-appended (same jobid at adjacent LSN). The */
/* identical-payload case = a literal byte-for-byte double-write. */
/* ------------------------------------------------------------------ */
static void test_duplicate_submit_detection(void)
{
fresh_dir();
char path[512];
FILE* fp = fopen(seg_path(1, path, sizeof(path)), "wb");
TEST_ASSERT_NOT_NULL(fp);
uint32_t last = WAL_CRC_NONE;
put_entry(fp, 0, 1, WAL_NOOP, NULL, 0, &last, 1);
/* Same jobid + same subtime → byte-identical payload (literal dup). */
size_t pa, pb;
uint8_t* ja = encode_job(7001, 555, &pa);
uint8_t* jb = encode_job(7001, 555, &pb);
put_entry(fp, 0, 2, WAL_JOB_SUBMIT, ja, (uint32_t)pa, &last, 0);
put_entry(fp, 0, 3, WAL_JOB_SUBMIT, jb, (uint32_t)pb, &last, 0);
/* A second job, also doubled but with a DIFFERING subtime per copy. */
size_t pc, pd;
uint8_t* jc = encode_job(7002, 600, &pc);
uint8_t* jd = encode_job(7002, 601, &pd);
put_entry(fp, 0, 4, WAL_JOB_SUBMIT, jc, (uint32_t)pc, &last, 0);
put_entry(fp, 0, 5, WAL_JOB_SUBMIT, jd, (uint32_t)pd, &last, 0);
free(ja); free(jb); free(jc); free(jd);
fclose(fp);
waldump_stats_t st;
int rc = waldump_path(g_dir, stdout, &st);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_UINT(4, st.n_submits);
TEST_ASSERT_EQUAL_UINT(2, st.n_dup_submits); /* both pairs flagged */
TEST_ASSERT_EQUAL_UINT(1, st.n_dup_identical); /* only the first is byte-identical */
TEST_ASSERT_EQUAL_UINT(0, st.n_gaps);
}
/* ------------------------------------------------------------------ */
/* An interior hole (a skipped LSN between two present entries). */
/* ------------------------------------------------------------------ */
static void test_interior_gap_flagged(void)
{
fresh_dir();
/* Two segments: [1..2] then [5..6]; 3,4 are missing. */
char p1[512], p2[512];
FILE* f1 = fopen(seg_path(1, p1, sizeof(p1)), "wb");
uint32_t last = WAL_CRC_NONE;
put_entry(f1, 0, 1, WAL_NOOP, NULL, 0, &last, 1);
put_entry(f1, 0, 2, WAL_JOB_UPDATE, "x", 1, &last, 0);
fclose(f1);
FILE* f2 = fopen(seg_path(5, p2, sizeof(p2)), "wb");
uint32_t last2 = WAL_CRC_NONE;
put_entry(f2, 0, 5, WAL_JOB_UPDATE, "y", 1, &last2, 1);
put_entry(f2, 0, 6, WAL_JOB_UPDATE, "z", 1, &last2, 0);
fclose(f2);
waldump_stats_t st;
int rc = waldump_path(g_dir, stdout, &st);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_UINT(4, st.n_entries);
TEST_ASSERT_EQUAL_UINT(2, st.n_segments);
TEST_ASSERT_EQUAL_UINT64(1, st.first_lsn);
TEST_ASSERT_EQUAL_UINT64(6, st.last_lsn);
TEST_ASSERT_EQUAL_UINT(1, st.n_gaps); /* the 3..4 hole */
}
/* ------------------------------------------------------------------ */
/* A CRC break truncates the recovered tip below the last physical LSN. */
/* ------------------------------------------------------------------ */
static void test_crc_break_sets_recovered_tip(void)
{
fresh_dir();
char path[512];
FILE* fp = fopen(seg_path(1, path, sizeof(path)), "wb");
uint32_t last = WAL_CRC_NONE;
put_entry(fp, 0, 1, WAL_NOOP, NULL, 0, &last, 1);
put_entry(fp, 0, 2, WAL_JOB_UPDATE, "ok", 2, &last, 0);
fclose(fp);
/* Corrupt the second entry's stored CRC in place. */
fp = fopen(path, "r+b");
wal_entry_hdr_t h;
long off2 = sizeof(wal_entry_hdr_t) + 0; /* entry 1 has 0 payload */
fseek(fp, off2, SEEK_SET);
fread(&h, sizeof(h), 1, fp);
h.crc ^= 0xdeadbeefu; /* break it */
fseek(fp, off2, SEEK_SET);
fwrite(&h, sizeof(h), 1, fp);
fclose(fp);
waldump_stats_t st;
int rc = waldump_path(g_dir, stdout, &st);
TEST_ASSERT_EQUAL_INT(0, rc);
TEST_ASSERT_EQUAL_UINT64(1, st.recovered_tip); /* stops before the bad LSN 2 */
TEST_ASSERT_NOT_EQUAL(0, st.crc_broken);
}
/* ------------------------------------------------------------------ */
/* Missing path is a hard error. */
/* ------------------------------------------------------------------ */
static void test_missing_path_errors(void)
{
waldump_stats_t st;
int rc = waldump_path("/tmp/xce_waldump_does_not_exist_zzz", stdout, &st);
TEST_ASSERT_LESS_THAN_INT(0, rc);
}
/* ------------------------------------------------------------------ */
/* A non-WAL file (e.g. a config blob) must ERROR, not report 0 entries.*/
/* ------------------------------------------------------------------ */
static void test_non_wal_file_errors(void)
{
fresh_dir();
char path[512];
snprintf(path, sizeof(path), "%s/cluster.bin", g_dir);
FILE* fp = fopen(path, "wb");
TEST_ASSERT_NOT_NULL(fp);
const char junk[] = "not a wal segment — a config blob";
fwrite(junk, sizeof(junk), 1, fp);
fclose(fp);
/* Pointing at a non-seg-*.wal file must be a hard error (-EINVAL), not a
* silent "0 entries" scan. */
waldump_stats_t st;
int rc = waldump_path(path, stdout, &st);
TEST_ASSERT_LESS_THAN_INT(0, rc);
}
void test_waldump(void)
{
UnitySetTestFile(__FILE__);
RUN_TEST(test_contiguous_log_has_no_gaps);
RUN_TEST(test_missing_prefix_flagged_as_gap);
RUN_TEST(test_config_marker_type_decoded);
RUN_TEST(test_leading_gap_explained_by_covering_snapshot);
RUN_TEST(test_leading_gap_not_explained_by_insufficient_snapshot);
RUN_TEST(test_duplicate_submit_detection);
RUN_TEST(test_interior_gap_flagged);
RUN_TEST(test_crc_break_sets_recovered_tip);
RUN_TEST(test_missing_path_errors);
RUN_TEST(test_non_wal_file_errors);
}

View file

@ -1,152 +0,0 @@
/*
* test_xcetest_interrupt.c -- proves, via a REAL, OS-delivered SIGINT
* hitting a genuinely blocked read(), that xcetest_install_sigint()
* (xcetest_interrupt.h, xce-test-cli-ux task 1.3, Requirement 9.9) fixes
* the restart-vs-EINTR bug the bare `signal(SIGINT, sigbreak)` call in
* test.c's main() used to have.
*
* Why a fork()-based process-level test rather than a plain unit test:
* this is inherently about kernel/libc syscall-restart semantics, which
* only a REAL signal delivered to a REAL process blocked in a REAL slow
* syscall can demonstrate -- calling sigbreak() directly as a plain
* function call would prove nothing about SA_RESTART. Each test below
* forks a short-lived child that installs a SIGINT handler via the given
* installer function, blocks in read() on a pipe that is never written
* to (so it can only unblock via a signal or its own 1-second alarm
* safety net), and reports which happened via its exit code -- the exact
* fork/report pattern already established in this directory (see
* test_ha_role_lifecycle.c's noop_classifies_retain()).
*/
#include <errno.h>
#include <signal.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
#include <unity/unity.h>
#include "xcetest_interrupt.h"
/* Child-side exit codes, decoded by the parent via WEXITSTATUS(). */
#define CHILD_EXIT_EINTR_OBSERVED 0 /* read() returned -1/EINTR promptly */
#define CHILD_EXIT_NO_EINTR 1 /* read() returned, but not via EINTR */
#define CHILD_EXIT_TIMED_OUT 2 /* read() never returned before the
* 1-second alarm fired -- i.e. the
* interrupted syscall was silently
* restarted instead */
/* 1-second safety net: if the installation under test lets the blocked
* read() silently restart, this makes the child self-terminate quickly
* (reporting CHILD_EXIT_TIMED_OUT) instead of hanging the whole suite.
* Uses plain signal() -- fine here, since the handler calls _exit()
* directly and never returns, so SIGALRM's own restart-vs-EINTR
* disposition never comes into play. */
static void child_alarm_handler(int signo)
{
(void)signo;
_exit(CHILD_EXIT_TIMED_OUT);
}
/* Stands in for test.c's sigbreak(): a real, non-default SIGINT handler.
* Only its mere existence (vs SIG_DFL/SIG_IGN) matters for read()'s
* restart-vs-EINTR decision -- this test is about the *installation*,
* not what the handler itself does. */
static void child_sigint_noop_handler(int signo)
{
(void)signo;
}
static void install_via_plain_signal(void (*handler)(int))
{
signal(SIGINT, handler);
}
/* Forks a child that installs SIGINT via `installer`, then blocks in
* read() on an always-empty pipe. The parent sends a real SIGINT to the
* child shortly after fork (giving it time to install the handler and
* enter the blocking read()) -- genuine OS signal delivery, not a direct
* call to any handler function. Returns the child's CHILD_EXIT_* exit
* code. */
static int run_forked_blocked_read(void (*installer)(void (*)(int)))
{
int pipefd[2];
pid_t pid;
pid_t waited;
int status = 0;
TEST_ASSERT_EQUAL_INT(0, pipe(pipefd));
pid = fork();
TEST_ASSERT_TRUE(pid >= 0);
if (pid == 0) {
/* child */
char buf[1];
ssize_t n;
close(pipefd[1]); /* only the PARENT's copy of the write end must
* stay open -- that's what keeps this pipe
* "empty but not at EOF" for the duration of
* the child's read() below. */
signal(SIGALRM, child_alarm_handler);
alarm(1);
installer(child_sigint_noop_handler);
errno = 0;
n = read(pipefd[0], buf, sizeof(buf));
if (n == -1 && errno == EINTR) {
_exit(CHILD_EXIT_EINTR_OBSERVED);
}
_exit(CHILD_EXIT_NO_EINTR);
}
/* parent */
close(pipefd[0]);
usleep(200000); /* give the child time to install its handler and
* reach the blocking read() before we signal it */
TEST_ASSERT_EQUAL_INT(0, kill(pid, SIGINT));
waited = waitpid(pid, &status, 0);
close(pipefd[1]);
TEST_ASSERT_EQUAL_INT(pid, waited);
TEST_ASSERT_TRUE(WIFEXITED(status));
return WEXITSTATUS(status);
}
/* RED-phase documentation, kept as a permanent regression check: proves
* the ROOT CAUSE this task fixes is real in this environment, not
* assumed. glibc's signal() installs with SA_RESTART set by default, so
* a single SIGINT delivered while a process is blocked in read() does
* NOT make that read() return -- the kernel transparently restarts it,
* and the read() keeps blocking (here, until this test's own 1-second
* alarm safety net fires). */
static void test_plain_signal_install_lets_sigint_restart_blocked_read(void)
{
int rc = run_forked_blocked_read(install_via_plain_signal);
TEST_ASSERT_EQUAL_INT(CHILD_EXIT_TIMED_OUT, rc);
}
/* GREEN: the fix under test. test.c's main() now installs SIGINT via
* this exact function instead of the bare signal(SIGINT, sigbreak) call
* it used before this task (Requirement 9.9). A real SIGINT delivered
* while blocked in read() must make that read() return immediately with
* errno == EINTR, not silently retry. */
static void test_install_sigint_clears_sa_restart_so_blocked_read_returns_eintr(void)
{
int rc = run_forked_blocked_read(xcetest_install_sigint);
TEST_ASSERT_EQUAL_INT(CHILD_EXIT_EINTR_OBSERVED, rc);
}
void test_xcetest_interrupt(void)
{
UnitySetTestFile(__FILE__);
RUN_TEST(test_plain_signal_install_lets_sigint_restart_blocked_read);
RUN_TEST(test_install_sigint_clears_sa_restart_so_blocked_read_returns_eintr);
}

View file

@ -1,249 +0,0 @@
/*
* test_xcetest_process_entry.c -- xce-test-cli-ux task 1.6, Requirements
* 4.1, 4.2, 4.3 (Process-Level Help and Version Discovery).
*
* These are black-box, subprocess tests: they exec the REAL, built
* xce_test binary (a sibling of this test binary in the same build
* directory -- see resolve_xce_test_path()) with a process-level flag and
* inspect its exit code and captured stdout. This is deliberately NOT a
* plain unit test that calls argp_parse()/parse_opt() in-process: what's
* under test is main()'s own process-level argv handling, including
* whether it reaches the interactive/scripted REPL loop at all, which can
* only be observed by actually running the process to completion.
*
* This is a small, self-contained subprocess harness scoped to this one
* task's needs (process-level -h/--help/--version) -- NOT the reusable
* pty-driven black-box CLI harness that task 1.7 builds separately (that
* one will live in test_test_cli.c and is needed for interactive-only
* surfaces like tab completion that a plain pipe can't exercise).
*
* resolve_xce_test_path() below is deliberately non-static (task 1.7):
* test_test_cli.c's pty-driven harness needs the exact same "find the real
* built xce_test binary next to this test binary" logic and reuses this
* copy via an `extern` declaration rather than duplicating it -- see this
* project's own consolidation-over-copy-paste convention.
*/
#define _GNU_SOURCE
#include <fcntl.h>
#include <libgen.h>
#include <limits.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/wait.h>
#include <unistd.h>
#include <unity/unity.h>
#define CAPTURE_BUF_SIZE 16384
#define CHILD_TIMEOUT_TENTHS 50 /* 5 seconds, polled every 100ms */
/* xce_test is always built as a sibling of this test binary in the same
* meson build directory (src/controller/meson.build builds both `xce_test`
* and `controller_unit_test` into the same target dir) -- resolving via
* /proc/self/exe's directory avoids depending on the caller's cwd or an
* env var that could go stale. */
int resolve_xce_test_path(char* out, size_t outsz)
{
char self[PATH_MAX];
ssize_t n;
char* dir;
n = readlink("/proc/self/exe", self, sizeof(self) - 1);
if (n <= 0) {
return -1;
}
self[n] = '\0';
dir = dirname(self); /* dirname() may modify its argument in place;
* `self` is a disposable local copy, so that's
* fine here. */
if (snprintf(out, outsz, "%s/xce_test", dir) >= (int)outsz) {
return -1;
}
return 0;
}
/* Runs xce_test_path with argv (NULL-terminated; argv[0] conventionally
* the program name) with stdin attached to /dev/null -- so that if a
* regression makes it fall through into the interactive REPL instead of
* exiting, readline() sees immediate EOF rather than hanging -- and
* captures its stdout into out_buf. Kills and fails the test if the child
* does not exit within CHILD_TIMEOUT_TENTHS/10 seconds, so a genuine hang
* can't wedge the whole suite. */
static void run_and_capture(const char* xce_test_path, char* const argv[],
char* out_buf, size_t out_buf_size,
int* exit_code)
{
int outpipe[2];
pid_t pid;
int status = 0;
int elapsed = 0;
pid_t waited;
TEST_ASSERT_EQUAL_INT(0, pipe(outpipe));
pid = fork();
TEST_ASSERT_TRUE(pid >= 0);
if (pid == 0) {
int devnull = open("/dev/null", O_RDONLY);
if (devnull >= 0) {
dup2(devnull, STDIN_FILENO);
close(devnull);
}
dup2(outpipe[1], STDOUT_FILENO);
close(outpipe[0]);
close(outpipe[1]);
execv(xce_test_path, argv);
_exit(127); /* only reached if execv() itself failed */
}
close(outpipe[1]);
for (;;) {
waited = waitpid(pid, &status, WNOHANG);
if (waited == pid) {
break;
}
if (elapsed >= CHILD_TIMEOUT_TENTHS) {
kill(pid, SIGKILL);
waitpid(pid, &status, 0);
close(outpipe[0]);
TEST_FAIL_MESSAGE(
"xce_test did not exit within timeout -- it likely fell "
"through into the REPL instead of exiting after a "
"process-level help/version flag");
}
usleep(100000);
elapsed++;
}
{
ssize_t total = 0;
ssize_t r;
while (total < (ssize_t)out_buf_size - 1) {
r = read(outpipe[0], out_buf + total,
out_buf_size - 1 - (size_t)total);
if (r <= 0) {
break;
}
total += r;
}
out_buf[total] = '\0';
}
close(outpipe[0]);
TEST_ASSERT_TRUE(WIFEXITED(status));
*exit_code = WEXITSTATUS(status);
}
/* Word-wrapped help text can break a multi-word phrase across lines at any
* space -- argp wraps the doc string to the terminal width. Folding
* newlines to spaces before substring-matching makes assertions robust to
* exactly where those wraps land. */
static void flatten_newlines(char* s)
{
for (; *s; s++) {
if (*s == '\n') {
*s = ' ';
}
}
}
static void test_help_flag_prints_usage_and_exits_without_repl(void)
{
char xce_test_path[PATH_MAX];
char out[CAPTURE_BUF_SIZE];
int exit_code;
char* const argv[] = { "xce_test", "--help", NULL };
TEST_ASSERT_EQUAL_INT(
0, resolve_xce_test_path(xce_test_path, sizeof(xce_test_path)));
run_and_capture(xce_test_path, argv, out, sizeof(out), &exit_code);
TEST_ASSERT_EQUAL_INT(0, exit_code);
TEST_ASSERT_NOT_NULL(strstr(out, "Usage:"));
/* Never reached the interactive session's top-level prompt. */
TEST_ASSERT_NULL(strstr(out, "xce>"));
}
/* Requirement 4.3: the process-level help output must describe the
* in-session `help` command as the way to list/learn available commands
* -- not just document its own --help/-?/-h flags. */
static void test_help_flag_describes_insession_help_command(void)
{
char xce_test_path[PATH_MAX];
char out[CAPTURE_BUF_SIZE];
int exit_code;
char* const argv[] = { "xce_test", "--help", NULL };
TEST_ASSERT_EQUAL_INT(
0, resolve_xce_test_path(xce_test_path, sizeof(xce_test_path)));
run_and_capture(xce_test_path, argv, out, sizeof(out), &exit_code);
TEST_ASSERT_EQUAL_INT(0, exit_code);
flatten_newlines(out);
TEST_ASSERT_NOT_NULL_MESSAGE(
strstr(out, "type `help' at the prompt to list"),
"xce_test --help must describe the in-session `help' command as "
"the way to list/learn available commands (Requirement 4.3)");
}
/* Requirement 4.1 explicitly calls out `-h` alongside `--help`. GNU argp's
* OWN built-in short help alias is `-?`, not `-h` -- so unlike --help/-?,
* this is not automatic and must be verified (and, if missing, added). */
static void test_dash_h_flag_prints_usage_and_exits_without_repl(void)
{
char xce_test_path[PATH_MAX];
char out[CAPTURE_BUF_SIZE];
int exit_code;
char* const argv[] = { "xce_test", "-h", NULL };
TEST_ASSERT_EQUAL_INT(
0, resolve_xce_test_path(xce_test_path, sizeof(xce_test_path)));
run_and_capture(xce_test_path, argv, out, sizeof(out), &exit_code);
TEST_ASSERT_EQUAL_INT(0, exit_code);
TEST_ASSERT_NOT_NULL(strstr(out, "Usage:"));
TEST_ASSERT_NULL(strstr(out, "xce>"));
}
static void test_version_flag_prints_version_and_exits_without_repl(void)
{
char xce_test_path[PATH_MAX];
char out[CAPTURE_BUF_SIZE];
int exit_code;
char* const argv[] = { "xce_test", "--version", NULL };
TEST_ASSERT_EQUAL_INT(
0, resolve_xce_test_path(xce_test_path, sizeof(xce_test_path)));
run_and_capture(xce_test_path, argv, out, sizeof(out), &exit_code);
TEST_ASSERT_EQUAL_INT(0, exit_code);
TEST_ASSERT_NOT_NULL(strstr(out, "xce"));
/* xce_compose_version_ex()'s output includes a "built <timestamp>"
* segment -- distinguishes real version info from a coincidental
* "xce" substring elsewhere. */
TEST_ASSERT_NOT_NULL(strstr(out, "built"));
TEST_ASSERT_NULL(strstr(out, "Usage:"));
TEST_ASSERT_NULL(strstr(out, "xce>"));
}
void test_xcetest_process_entry(void)
{
UnitySetTestFile(__FILE__);
RUN_TEST(test_help_flag_prints_usage_and_exits_without_repl);
RUN_TEST(test_help_flag_describes_insession_help_command);
RUN_TEST(test_dash_h_flag_prints_usage_and_exits_without_repl);
RUN_TEST(test_version_flag_prints_version_and_exits_without_repl);
}