feat(xce-harness-component): relocate xce_test into standalone harness component
Move xce_test's 15 production sources + headers from src/controller/testsuite/ into a new src/harness/ component with its own meson.build, and strip every now-dangling controller-side reference (11 controller_unit_test_src entries, 4 standalone test-binary blocks, 11 controller_test.c extern/RUN_TEST lines) so the controller build is never left broken. Fixes a broken relative-symlink bridge (xce_test_controller_link) added to keep two controller-embedded tests (test_test_cli.c, test_xcetest_process_entry.c) finding the relocated xce_test binary until task 2.1 moves them too. Discovered and documented in design.md: four controller-domain regression tests call functions directly from four of xce_test's own files, requiring controller_unit_test to permanently recompile them from src/harness/ — a real dependency neither the original discovery nor design audit caught. controller_unit_test: 1228 Tests 0 Failures 0 Ignored (verified via the Unity binary's own printed summary line, not just meson's aggregate count). Task 1.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:
parent
b8ef82c1b2
commit
cf55d691de
36 changed files with 550 additions and 180 deletions
219
src/harness/configcompare.c
Normal file
219
src/harness/configcompare.c
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
#define _GNU_SOURCE
|
||||
#include "configcompare.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <inttypes.h>
|
||||
#include <openssl/sha.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
/* Bound on a member's config bytes this tool will read -- mirrors
|
||||
* persist.c's PERSIST_CONFIG_BYTES_MAX_SIZE / config_repl.h's CONF_MAX_SIZE
|
||||
* (1 MiB) exactly. Duplicated as its own local constant rather than
|
||||
* including either header, for the same reason the resolve-then-read logic
|
||||
* below is duplicated -- see read_member_config_sha256()'s doc comment. */
|
||||
#define CONFIGCOMPARE_CONFIG_MAX_SIZE (1u * 1024u * 1024u)
|
||||
|
||||
/* Resolve `<conf_dir>/conf/current` -> `<conf_dir>/conf/<versioned file>`,
|
||||
* read its raw bytes, and hash them into `out_sha`.
|
||||
*
|
||||
* DELIBERATE DUPLICATION -- this is the FOURTH independent copy of this
|
||||
* exact resolve-then-read-then-hash logic in this codebase:
|
||||
* 1. config_repl.c's conf_read_current_filename() + conf_read_raw()
|
||||
* (the production implementation -- config replication).
|
||||
* 2. config_repl.c's local_config_sha256() (activation-fence hash check;
|
||||
* an inline copy for the same "no cross-file call needed here"
|
||||
* reason as #4 below).
|
||||
* 3. persist.c's read_active_config_bytes() (checkpoint config-bytes
|
||||
* embedding; see its own doc comment for the full "why duplicate,
|
||||
* not call" rationale -- avoiding a persist.c -> config_repl.c
|
||||
* layering dependency).
|
||||
* 4. This function. configcompare.c is compiled into the standalone
|
||||
* xce_test CLI (meson.build's xce_test_src), which links ONLY
|
||||
* libxce_dep / librtti_dep / struct2json_dep / readline_dep /
|
||||
* history_dep / ncurses_dep / tinylog_dep / zlib_dep /
|
||||
* c_capnproto_dep -- deliberately NOT config_repl.c, persist.c,
|
||||
* cluster.c, or any other heavy controller object file. This is a
|
||||
* lightweight, dependency-light offline CLI tool family
|
||||
* (walcompare/snapdiff/snapverify all share the same constraint; no
|
||||
* live RPC to any cluster member, consistent with every other tool
|
||||
* in this family -- design.md "Regression Tooling"). Adding a
|
||||
* config_repl.c dependency to link in one function would violate
|
||||
* that boundary, so the ~25-line resolve+read is duplicated inline
|
||||
* here instead, exactly as persist.c's read_active_config_bytes()
|
||||
* already chose to do for the identical reason.
|
||||
*
|
||||
* Returns 0 with `out_sha` filled on success. Returns a negative errno on
|
||||
* any failure to resolve or read: -ENOENT (no conf/current pointer, or it
|
||||
* names a versioned file that is not present -- benign "not provisioned
|
||||
* yet" as well as genuine absence, both surfaced identically to the
|
||||
* caller as "unreadable"), -EINVAL (malformed pointer file: empty,
|
||||
* embedded '/', wrong prefix, wrong hex length), -EIO (short
|
||||
* read/zero-byte versioned file), -EFBIG (versioned file exceeds
|
||||
* CONFIGCOMPARE_CONFIG_MAX_SIZE), or -ENOMEM. This is the caller's
|
||||
* "unreadable/absent" signal -- distinct from and never conflated with a
|
||||
* mismatched hash. */
|
||||
static int read_member_config_sha256(const char* conf_dir, uint8_t out_sha[32])
|
||||
{
|
||||
if (!conf_dir || !*conf_dir)
|
||||
return -EINVAL;
|
||||
|
||||
char head_path[4160];
|
||||
snprintf(head_path, sizeof(head_path), "%s/conf/current", conf_dir);
|
||||
|
||||
FILE* hf = fopen(head_path, "r");
|
||||
if (hf == NULL)
|
||||
return -ENOENT; /* no config pushed to this member yet */
|
||||
|
||||
struct stat hst;
|
||||
if (fstat(fileno(hf), &hst) == 0 && hst.st_size == 0) {
|
||||
fclose(hf);
|
||||
return -ENOENT; /* interrupted-write pointer file -- treat as absent */
|
||||
}
|
||||
|
||||
char fname[256];
|
||||
if (fgets(fname, (int)sizeof(fname), hf) == NULL) {
|
||||
fclose(hf);
|
||||
return -EIO;
|
||||
}
|
||||
fclose(hf);
|
||||
fname[strcspn(fname, "\n")] = '\0';
|
||||
|
||||
if (strchr(fname, '/') != NULL)
|
||||
return -EINVAL; /* corrupted pointer file -- embedded path separator */
|
||||
if (strncmp(fname, "cluster.bin.", 12) != 0)
|
||||
return -EINVAL;
|
||||
const char* hex = fname + 12;
|
||||
if (strlen(hex) != 12 || strspn(hex, "0123456789abcdef") != 12)
|
||||
return -EINVAL;
|
||||
|
||||
char cfg_path[4416];
|
||||
snprintf(cfg_path, sizeof(cfg_path), "%s/conf/%s", conf_dir, fname);
|
||||
|
||||
FILE* cf = fopen(cfg_path, "rb");
|
||||
if (cf == NULL)
|
||||
return -ENOENT; /* pointer references a file that is not there */
|
||||
|
||||
if (fseek(cf, 0, SEEK_END) != 0) { fclose(cf); return -EIO; }
|
||||
long sz = ftell(cf);
|
||||
if (sz < 0) { fclose(cf); return -EIO; }
|
||||
if (fseek(cf, 0, SEEK_SET) != 0) { fclose(cf); return -EIO; }
|
||||
|
||||
if (sz == 0) {
|
||||
fclose(cf);
|
||||
return -EIO; /* zero-byte versioned config file: genuine corruption */
|
||||
}
|
||||
if ((size_t)sz > CONFIGCOMPARE_CONFIG_MAX_SIZE) {
|
||||
fclose(cf);
|
||||
return -EFBIG;
|
||||
}
|
||||
|
||||
uint8_t* buf = (uint8_t*)malloc((size_t)sz);
|
||||
if (buf == NULL) {
|
||||
fclose(cf);
|
||||
return -ENOMEM;
|
||||
}
|
||||
if (fread(buf, 1, (size_t)sz, cf) != (size_t)sz) {
|
||||
free(buf);
|
||||
fclose(cf);
|
||||
return -EIO;
|
||||
}
|
||||
fclose(cf);
|
||||
|
||||
SHA256(buf, (size_t)sz, out_sha);
|
||||
free(buf);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static const char* sha256_hex(const uint8_t sha[32], char out[65])
|
||||
{
|
||||
static const char digits[] = "0123456789abcdef";
|
||||
for (int i = 0; i < 32; i++) {
|
||||
out[i * 2] = digits[(sha[i] >> 4) & 0xF];
|
||||
out[i * 2 + 1] = digits[sha[i] & 0xF];
|
||||
}
|
||||
out[64] = '\0';
|
||||
return out;
|
||||
}
|
||||
|
||||
int configcompare_paths(const char* const* conf_paths, int n, FILE* out,
|
||||
configcompare_report_t* rep)
|
||||
{
|
||||
configcompare_report_t local;
|
||||
memset(&local, 0, sizeof(local));
|
||||
local.first_divergent_index = -1;
|
||||
|
||||
if (!conf_paths || n < 2) {
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out,
|
||||
"configcompare: nothing to compare (need >= 2 members, "
|
||||
"got %d)\n", n);
|
||||
return -EINVAL;
|
||||
}
|
||||
if (n > CONFIGCOMPARE_MAX_MEMBERS) {
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out, "configcompare: too many members (%d > max %d)\n", n,
|
||||
CONFIGCOMPARE_MAX_MEMBERS);
|
||||
return -EINVAL;
|
||||
}
|
||||
|
||||
local.n = n;
|
||||
int first_ok = -1;
|
||||
char hexbuf[65];
|
||||
|
||||
for (int i = 0; i < n; i++) {
|
||||
int rc = read_member_config_sha256(conf_paths[i], local.sha256[i]);
|
||||
local.read_rc[i] = rc;
|
||||
|
||||
if (rc != 0) {
|
||||
if (out)
|
||||
fprintf(out,
|
||||
"configcompare: member %d (%s): UNREADABLE: %s\n", i,
|
||||
conf_paths[i] ? conf_paths[i] : "(null)",
|
||||
strerror(-rc));
|
||||
if (rep)
|
||||
*rep = local;
|
||||
/* Short-circuit on the first unreadable member, mirroring
|
||||
* snapdiff_paths's early-return-on-open-failure style: a
|
||||
* member we could not read is not evidence of matching OR
|
||||
* mismatching configuration, so there is no point continuing
|
||||
* to accumulate a verdict that will be discarded anyway. */
|
||||
return rc;
|
||||
}
|
||||
|
||||
if (out)
|
||||
fprintf(out, "configcompare: member %d (%s): sha256=%s\n", i,
|
||||
conf_paths[i], sha256_hex(local.sha256[i], hexbuf));
|
||||
|
||||
if (first_ok < 0) {
|
||||
first_ok = i;
|
||||
} else if (local.first_divergent_index < 0 &&
|
||||
memcmp(local.sha256[i], local.sha256[first_ok], 32) != 0) {
|
||||
local.first_divergent_index = i;
|
||||
}
|
||||
}
|
||||
|
||||
local.all_present = 1;
|
||||
local.all_identical = (local.first_divergent_index < 0);
|
||||
|
||||
if (rep)
|
||||
*rep = local;
|
||||
|
||||
if (!local.all_identical) {
|
||||
if (out)
|
||||
fprintf(out,
|
||||
"configcompare: DIVERGENT -- member %d differs from "
|
||||
"member %d\n",
|
||||
local.first_divergent_index, first_ok);
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (out)
|
||||
fprintf(out, "configcompare: IDENTICAL across %d members\n", n);
|
||||
return 0;
|
||||
}
|
||||
105
src/harness/confirm.c
Normal file
105
src/harness/confirm.c
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
/*
|
||||
* confirm.c -- destructive-command confirmation gate. See confirm.h for
|
||||
* the full contract, return-value semantics, and design rationale
|
||||
* (xce-test-cli-ux spec, task 2.1; Requirements 9.1-9.4, 9.6-9.9).
|
||||
*
|
||||
* IMPLEMENTATION NOTES
|
||||
*
|
||||
* - confirm_destructive() checks confirm_is_skip_set() and
|
||||
* xcetest_is_interactive() BEFORE touching stdin or stderr at all --
|
||||
* both are pure, side-effect-free reads, so ordering between them
|
||||
* doesn't matter, but checking them first is what makes the "proceed
|
||||
* without printing or reading anything" postcondition (9.4, 9.7) hold.
|
||||
*
|
||||
* - The prompt read is exactly one fgets() call on stdin, not a loop.
|
||||
* This is deliberate and is the crux of Requirement 9.9: glibc's
|
||||
* buffered stdio does NOT internally retry a read() that returns
|
||||
* -1/EINTR (it propagates the failure up as fgets() returning NULL
|
||||
* with errno left at EINTR) -- so as long as this function itself
|
||||
* never loops back to retry after seeing EINTR, a real Ctrl-C during
|
||||
* the prompt reliably falls straight through to the decline path. A
|
||||
* naive wrapper that did `while (fgets(...) == NULL) { if (errno ==
|
||||
* EINTR) continue; ... }` would defeat this entirely; no such loop
|
||||
* exists here. (This assumes test.c's main() installs SIGINT via
|
||||
* xcetest_install_sigint() -- SA_RESTART cleared -- per task 1.3; see
|
||||
* confirm.h's precondition note.)
|
||||
*
|
||||
* - errno is reset to 0 immediately before the fgets() call. fgets()
|
||||
* does not promise to clear a stale errno on success, and NULL is the
|
||||
* only signal we get to even look at errno, so a stale nonzero errno
|
||||
* from some earlier, unrelated libc call could otherwise be
|
||||
* misread as this call's own EINTR.
|
||||
*/
|
||||
#include <ctype.h>
|
||||
#include <errno.h>
|
||||
#include <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "confirm.h"
|
||||
|
||||
static int g_confirm_skip = 0; /* 0 == confirmation required (default at
|
||||
* process start); mirrors
|
||||
* g_continue_on_error's exact style
|
||||
* (test.c). */
|
||||
|
||||
void confirm_set_skip(int skip) { g_confirm_skip = skip ? 1 : 0; }
|
||||
int confirm_is_skip_set(void) { return g_confirm_skip; }
|
||||
|
||||
/* Accepted spellings: "y" or "yes", case-insensitive, tolerant of
|
||||
* incidental leading/trailing whitespace (including the trailing
|
||||
* newline fgets() leaves in place). Anything else -- "n", "no", empty,
|
||||
* garbage -- is NOT affirmative; confirm_destructive() maps every
|
||||
* non-affirmative case to the same "not run" outcome (see confirm.h's
|
||||
* two-valued return contract). */
|
||||
static int is_affirmative(const char* raw)
|
||||
{
|
||||
char norm[8];
|
||||
size_t i = 0, n = 0;
|
||||
|
||||
while (raw[i] != '\0' && isspace((unsigned char)raw[i]))
|
||||
i++;
|
||||
|
||||
while (raw[i] != '\0' && !isspace((unsigned char)raw[i]) &&
|
||||
n < sizeof(norm) - 1) {
|
||||
norm[n++] = (char)tolower((unsigned char)raw[i]);
|
||||
i++;
|
||||
}
|
||||
norm[n] = '\0';
|
||||
|
||||
return (strcmp(norm, "y") == 0) || (strcmp(norm, "yes") == 0);
|
||||
}
|
||||
|
||||
int confirm_destructive(const char* action_description)
|
||||
{
|
||||
char buf[64];
|
||||
char* line;
|
||||
|
||||
/* Requirement 9.6/9.7: the session-scoped toggle bypasses the prompt
|
||||
* entirely, regardless of TTY state. Requirement 9.4: a non-interactive
|
||||
* session bypasses it too. Neither path touches stdin/stderr. */
|
||||
if (confirm_is_skip_set() || !xcetest_is_interactive())
|
||||
return 1;
|
||||
|
||||
/* Requirement 9.8: the prompt goes to stderr, never stdout -- a
|
||||
* destructive command's successful result data (if any) must stay
|
||||
* uncontaminated by this diagnostic text. Deliberately NOT routed
|
||||
* through libedit's readline() (see confirm.h). */
|
||||
fprintf(stderr, "About to %s. Continue? [y/N] ", action_description);
|
||||
fflush(stderr);
|
||||
|
||||
errno = 0;
|
||||
line = fgets(buf, sizeof(buf), stdin);
|
||||
|
||||
if (line == NULL) {
|
||||
/* Requirement 9.9: a real Ctrl-C during the prompt's blocking
|
||||
* read surfaces as EINTR here (never retried -- see the file
|
||||
* header) and is treated exactly like a decline, not a distinct
|
||||
* "error" outcome. EOF (errno left at 0, or set to something
|
||||
* other than EINTR by the underlying read) is likewise mapped
|
||||
* to decline -- see confirm.h's return-contract note on why no
|
||||
* third outcome exists. */
|
||||
return 0;
|
||||
}
|
||||
|
||||
return is_affirmative(buf) ? 1 : 0;
|
||||
}
|
||||
298
src/harness/flagspec.c
Normal file
298
src/harness/flagspec.c
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
#define _GNU_SOURCE
|
||||
#include "flagspec.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/* True iff `token` (a raw command-line argument, e.g. "-h", "--host", or
|
||||
* -- defensively -- a dash-free bare name) identifies the flag described
|
||||
* by `fs`. A "--name" token is matched against fs->long_opt; a "-X" token
|
||||
* is matched against fs->short_opt (single character only, so "-xy" never
|
||||
* falsely matches a single-letter short_opt). */
|
||||
static int flag_token_matches(const char* token, const flagspec_t* fs)
|
||||
{
|
||||
if (!token || !fs)
|
||||
return 0;
|
||||
|
||||
if (token[0] == '-' && token[1] == '-')
|
||||
return fs->long_opt != NULL && strcmp(token + 2, fs->long_opt) == 0;
|
||||
|
||||
if (token[0] == '-' && token[1] != '\0')
|
||||
return fs->short_opt != 0 && token[1] == fs->short_opt &&
|
||||
token[2] == '\0';
|
||||
|
||||
/* No leading dash at all -- not how a real getopt_long() failure
|
||||
* token looks, but handled so this helper never mis-signals on
|
||||
* unexpected input from a caller that skips the usual
|
||||
* argv[optind - 1] convention. */
|
||||
if (fs->long_opt != NULL && strcmp(token, fs->long_opt) == 0)
|
||||
return 1;
|
||||
return fs->short_opt != 0 && token[0] == fs->short_opt &&
|
||||
token[1] == '\0';
|
||||
}
|
||||
|
||||
int flagspec_build_opts(const flagspec_t* spec, size_t n,
|
||||
struct option* out_opts, char* out_optstring,
|
||||
size_t out_optstring_cap)
|
||||
{
|
||||
if (!spec || !out_opts || !out_optstring || n == 0 ||
|
||||
out_optstring_cap == 0)
|
||||
return -EINVAL;
|
||||
|
||||
size_t str_idx = 0;
|
||||
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
const flagspec_t* fs = &spec[i];
|
||||
|
||||
if (!fs->long_opt || !*fs->long_opt)
|
||||
return -EINVAL;
|
||||
if (fs->has_arg != no_argument && fs->has_arg != required_argument)
|
||||
return -EINVAL;
|
||||
|
||||
out_opts[i].name = fs->long_opt;
|
||||
out_opts[i].has_arg = fs->has_arg;
|
||||
out_opts[i].flag = NULL;
|
||||
out_opts[i].val = fs->short_opt
|
||||
? (int)fs->short_opt
|
||||
: (int)(FLAGSPEC_LONG_ONLY_BASE + i);
|
||||
|
||||
if (fs->short_opt != 0) {
|
||||
size_t need = (fs->has_arg == required_argument) ? 2 : 1;
|
||||
/* Leave room for the final NUL written after the loop. */
|
||||
if (str_idx + need >= out_optstring_cap)
|
||||
return -ENOSPC;
|
||||
out_optstring[str_idx++] = fs->short_opt;
|
||||
if (fs->has_arg == required_argument)
|
||||
out_optstring[str_idx++] = ':';
|
||||
}
|
||||
}
|
||||
|
||||
out_optstring[str_idx] = '\0';
|
||||
|
||||
/* Trailing all-zero sentinel getopt_long() requires to terminate the
|
||||
* table; out_opts must have room for n+1 entries (precondition). */
|
||||
out_opts[n].name = NULL;
|
||||
out_opts[n].has_arg = 0;
|
||||
out_opts[n].flag = NULL;
|
||||
out_opts[n].val = 0;
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Levenshtein edit distance between two NUL-terminated strings -- the
|
||||
* minimum number of single-character insertions/deletions/substitutions
|
||||
* that turns `a` into `b`. Small and self-contained: research.md confirms
|
||||
* no edit-distance utility exists anywhere in the codebase yet. Standard
|
||||
* two-row rolling DP, O(len(a)*len(b)) time, O(min(len(a),len(b))+1)
|
||||
* space -- more than fast enough for flag-name-sized strings.
|
||||
*
|
||||
* Returns (size_t)-1 on allocation failure, which callers must treat as
|
||||
* "not a close match" rather than a false zero-distance/exact-match
|
||||
* signal. */
|
||||
static size_t levenshtein(const char* a, const char* b)
|
||||
{
|
||||
size_t la = strlen(a), lb = strlen(b);
|
||||
|
||||
/* Keep the rolling row sized to the SHORTER string. */
|
||||
if (lb > la) {
|
||||
const char* tmp = a;
|
||||
a = b;
|
||||
b = tmp;
|
||||
size_t t = la;
|
||||
la = lb;
|
||||
lb = t;
|
||||
}
|
||||
|
||||
size_t* row = calloc(lb + 1, sizeof(size_t));
|
||||
if (!row)
|
||||
return (size_t)-1;
|
||||
|
||||
for (size_t j = 0; j <= lb; j++)
|
||||
row[j] = j;
|
||||
|
||||
for (size_t i = 1; i <= la; i++) {
|
||||
size_t prev_diag = row[0];
|
||||
row[0] = i;
|
||||
for (size_t j = 1; j <= lb; j++) {
|
||||
size_t saved = row[j];
|
||||
size_t cost = (a[i - 1] == b[j - 1]) ? 0 : 1;
|
||||
size_t del = row[j] + 1;
|
||||
size_t ins = row[j - 1] + 1;
|
||||
size_t sub = prev_diag + cost;
|
||||
size_t best = del < ins ? del : ins;
|
||||
if (sub < best)
|
||||
best = sub;
|
||||
row[j] = best;
|
||||
prev_diag = saved;
|
||||
}
|
||||
}
|
||||
|
||||
size_t result = row[lb];
|
||||
free(row);
|
||||
return result;
|
||||
}
|
||||
|
||||
/* True iff `candidate` (one flag's literal spelling, e.g. "-h" or
|
||||
* "--host") "closely matches" `bad_flag` for typo-suggestion purposes
|
||||
* (Requirement 10.1).
|
||||
*
|
||||
* Threshold, a deliberate design choice (design.md/research.md do not
|
||||
* mandate a specific algorithm or cutoff): floor(len(candidate)/4),
|
||||
* clamped to [1,3]. A fixed absolute threshold would be wrong at both
|
||||
* ends -- distance 1 from a 2-character short flag ("-h" vs "-z") is a
|
||||
* near-total rewrite, while distance 1 from an 8+ character long flag is
|
||||
* a single typo -- so the tolerance scales with the spelling's own
|
||||
* length instead. Exact matches (distance 0) are never "close" here:
|
||||
* flagspec_reject() only calls this from the already-unrecognized branch,
|
||||
* where an exact-spelling candidate would be a contradiction, and this
|
||||
* keeps the helper correct standalone too. */
|
||||
static int is_close_match(const char* bad_flag, const char* candidate)
|
||||
{
|
||||
size_t dist = levenshtein(bad_flag, candidate);
|
||||
if (dist == 0 || dist == (size_t)-1)
|
||||
return 0;
|
||||
|
||||
size_t len = strlen(candidate);
|
||||
size_t threshold = len / 4;
|
||||
if (threshold < 1)
|
||||
threshold = 1;
|
||||
if (threshold > 3)
|
||||
threshold = 3;
|
||||
|
||||
return dist <= threshold;
|
||||
}
|
||||
|
||||
/* Writes `fs`'s display label ("-h/--host" or, for a long-only flag,
|
||||
* "--json") into `buf`. */
|
||||
static void format_flag_label(char* buf, size_t cap, const flagspec_t* fs)
|
||||
{
|
||||
if (fs->short_opt)
|
||||
snprintf(buf, cap, "-%c/--%s", fs->short_opt, fs->long_opt);
|
||||
else
|
||||
snprintf(buf, cap, "--%s", fs->long_opt);
|
||||
}
|
||||
|
||||
/* Returns the single flagspec_t in `spec` whose short or long spelling
|
||||
* closely matches `bad_flag`, or NULL when zero or more than one entry
|
||||
* qualifies -- Requirement 10.1 only promises a guess when exactly one
|
||||
* flag is a plausible intended target; an ambiguous typo (two or more
|
||||
* equally-plausible candidates) gets no guess at all, the same as a typo
|
||||
* that matches nothing (Requirement 10.2). */
|
||||
static const flagspec_t* find_typo_suggestion(const flagspec_t* spec, size_t n,
|
||||
const char* bad_flag)
|
||||
{
|
||||
if (!spec || !bad_flag || !*bad_flag)
|
||||
return NULL;
|
||||
|
||||
const flagspec_t* found = NULL;
|
||||
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
const flagspec_t* fs = &spec[i];
|
||||
int is_close = 0;
|
||||
|
||||
if (fs->short_opt) {
|
||||
char short_form[3] = { '-', fs->short_opt, '\0' };
|
||||
if (is_close_match(bad_flag, short_form))
|
||||
is_close = 1;
|
||||
}
|
||||
if (!is_close && fs->long_opt && *fs->long_opt) {
|
||||
char long_form[128];
|
||||
snprintf(long_form, sizeof(long_form), "--%s", fs->long_opt);
|
||||
if (is_close_match(bad_flag, long_form))
|
||||
is_close = 1;
|
||||
}
|
||||
|
||||
if (is_close) {
|
||||
if (found)
|
||||
return NULL; /* ambiguous -- more than one candidate */
|
||||
found = fs;
|
||||
}
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
int flagspec_reject(const char* cmd_name, const flagspec_t* spec, size_t n,
|
||||
const char* bad_flag, const char* usage_text)
|
||||
{
|
||||
const char* display = bad_flag ? bad_flag : "?";
|
||||
const char* who = cmd_name ? cmd_name : "?";
|
||||
const flagspec_t* match = NULL;
|
||||
|
||||
if (spec) {
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
if (flag_token_matches(bad_flag, &spec[i])) {
|
||||
match = &spec[i];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (match && match->has_arg == required_argument) {
|
||||
fprintf(stderr, "xce_test: %s: flag '%s' requires a value\n", who,
|
||||
display);
|
||||
} else if (match) {
|
||||
fprintf(stderr, "xce_test: %s: flag '%s' does not take a value\n",
|
||||
who, display);
|
||||
} else {
|
||||
const flagspec_t* suggestion = find_typo_suggestion(spec, n, bad_flag);
|
||||
if (suggestion) {
|
||||
char label[144];
|
||||
format_flag_label(label, sizeof(label), suggestion);
|
||||
fprintf(stderr,
|
||||
"xce_test: %s: unrecognized flag '%s' (did you mean "
|
||||
"'%s'?)\n",
|
||||
who, display, label);
|
||||
} else {
|
||||
fprintf(stderr, "xce_test: %s: unrecognized flag '%s'\n", who,
|
||||
display);
|
||||
}
|
||||
}
|
||||
|
||||
if (usage_text && *usage_text) {
|
||||
size_t len = strlen(usage_text);
|
||||
if (len > 0 && usage_text[len - 1] == '\n')
|
||||
fprintf(stderr, "usage: %s", usage_text);
|
||||
else
|
||||
fprintf(stderr, "usage: %s\n", usage_text);
|
||||
}
|
||||
|
||||
return -EINVAL;
|
||||
}
|
||||
|
||||
char* flagspec_render_help(const flagspec_t* spec, size_t n)
|
||||
{
|
||||
if (!spec || n == 0)
|
||||
return NULL;
|
||||
|
||||
char* buf = NULL;
|
||||
size_t size = 0;
|
||||
FILE* f = open_memstream(&buf, &size);
|
||||
if (!f)
|
||||
return NULL;
|
||||
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
const flagspec_t* fs = &spec[i];
|
||||
|
||||
if (fs->short_opt)
|
||||
fprintf(f, " -%c, --%s", fs->short_opt, fs->long_opt);
|
||||
else
|
||||
fprintf(f, " --%s", fs->long_opt);
|
||||
|
||||
fprintf(f, " %s", fs->required ? "required" : "optional");
|
||||
|
||||
if (fs->default_desc && *fs->default_desc)
|
||||
fprintf(f, " (default: %s)", fs->default_desc);
|
||||
|
||||
fprintf(f, "\n");
|
||||
}
|
||||
|
||||
if (fclose(f) != 0) {
|
||||
free(buf);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
return buf;
|
||||
}
|
||||
74
src/harness/include/configcompare.h
Normal file
74
src/harness/include/configcompare.h
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
#pragma once
|
||||
/*
|
||||
* configcompare.h — cross-member active-configuration comparator
|
||||
* (config-upgrade-snapshot task 8.4, Requirement 4.2).
|
||||
*
|
||||
* Compares each cluster member's locally-held ACTIVE configuration content
|
||||
* hash, recomputed FRESH from that member's own on-disk configuration file
|
||||
* (never trusted from a cached/embedded tag), and reports whether every
|
||||
* member agrees.
|
||||
*
|
||||
* A new sibling tool to walcompare, NOT an extension of it: walcompare's
|
||||
* internal model is WAL-entry-indexed (lsn -> term, pcrc) -- an awkward fit
|
||||
* for a single (tag, sha256) value per member with no LSN dimension
|
||||
* (design.md "Regression Tooling"). Mirrors snapdiff_paths's exact
|
||||
* signature/reporting shape instead.
|
||||
*
|
||||
* Offline/file-based: no live RPC to any cluster member, consistent with
|
||||
* every other tool in this family (walcompare, snapdiff, snapverify).
|
||||
*
|
||||
* Strictly read-only: opens files O_RDONLY, mutates nothing.
|
||||
*/
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
|
||||
/* Bound on the number of members a single call can compare -- generous for
|
||||
* any real cluster size this codebase targets; a caller with more members
|
||||
* than this is almost certainly passing a malformed argument list. */
|
||||
#define CONFIGCOMPARE_MAX_MEMBERS 32
|
||||
|
||||
typedef struct {
|
||||
int n; /* member count passed in */
|
||||
int read_rc[CONFIGCOMPARE_MAX_MEMBERS]; /* 0 = resolved+hashed ok,
|
||||
* else -errno; entries at
|
||||
* or past a short-circuit
|
||||
* failure are unset (0)
|
||||
* and must be ignored --
|
||||
* see all_present */
|
||||
uint8_t sha256[CONFIGCOMPARE_MAX_MEMBERS][32]; /* valid only where
|
||||
* read_rc[i] == 0 */
|
||||
int all_present; /* 1 iff every member (0..n-1) was resolved and
|
||||
* hashed successfully */
|
||||
int all_identical; /* 1 iff all_present && every member's sha256
|
||||
* equals the first member's */
|
||||
int first_divergent_index; /* -1 if all_identical or !all_present;
|
||||
* else the first index (after the
|
||||
* reference, member 0) whose hash
|
||||
* differs from member 0's */
|
||||
} configcompare_report_t;
|
||||
|
||||
/* Compare the active-configuration content hash of `n` members. Each
|
||||
* `conf_paths[i]` is a member's `workdir/name` directory -- the SAME
|
||||
* directory persist.c's `read_active_config_bytes()` and config_repl.c's
|
||||
* `local_config_sha256()` both resolve `conf/current` (and the versioned
|
||||
* config file it points at) under, matching where this spec's snapshot-embed
|
||||
* step also reads from. NOT the raw `conf/current` file itself -- this
|
||||
* function resolves `<path>/conf/current` and `<path>/conf/<versioned-file>`
|
||||
* internally, exactly like those two functions do.
|
||||
*
|
||||
* Prints a human-readable report to `out` (may be NULL) and fills `*rep`
|
||||
* (may be NULL). Returns:
|
||||
* 0 if every member's config was readable and all hashes are identical
|
||||
* 1 if every member's config was readable but at least one diverges
|
||||
* (see `*rep->first_divergent_index` for which)
|
||||
* <0 (negative errno) if `n < 2` (nothing to compare), `n` exceeds
|
||||
* CONFIGCOMPARE_MAX_MEMBERS, or any member's config could not be
|
||||
* resolved/read -- mirrors snapdiff_paths's early-return-on-open-
|
||||
* failure style: the first unreadable member short-circuits the scan,
|
||||
* and `*rep` is still filled with whatever was learned about the
|
||||
* members checked before the failure, so the report can say which
|
||||
* member and why. A member that could not be read is NOT evidence of
|
||||
* matching OR mismatching configuration -- it is a distinct third
|
||||
* outcome from "identical" and "divergent". */
|
||||
int configcompare_paths(const char* const* conf_paths, int n, FILE* out,
|
||||
configcompare_report_t* rep);
|
||||
101
src/harness/include/confirm.h
Normal file
101
src/harness/include/confirm.h
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
#pragma once
|
||||
/*
|
||||
* confirm.h -- destructive-command confirmation gate: prompts the operator
|
||||
* on stderr before a destructive command runs, offers a session-scoped
|
||||
* toggle to skip that prompt, and treats a Ctrl-C during the prompt the
|
||||
* same as declining it (xce-test-cli-ux spec, task 2.1; Requirements
|
||||
* 9.1-9.4, 9.6-9.9. Requirement 9.5 -- the five-command enumeration and
|
||||
* each cmd_*'s own `--yes` bypass flag -- is task 3.3, NOT this module;
|
||||
* this module is the generic, command-agnostic gate mechanism only).
|
||||
*
|
||||
* THE MECHANISM (see design.md's "Confirmation Gate" component and its
|
||||
* "Destructive Command Confirmation Gate" flowchart):
|
||||
* - confirm_destructive() is the gate itself. It internally checks, in
|
||||
* order: the session-scoped skip toggle (confirm_is_skip_set()), then
|
||||
* whether the top-level session is even interactive
|
||||
* (xcetest_is_interactive(), see below). If either says "don't
|
||||
* prompt", it returns 1 (proceed) immediately WITHOUT touching stdin
|
||||
* or printing anything. Otherwise it prints a yes/no prompt to
|
||||
* stderr and performs one blocking read of the answer from stdin.
|
||||
* - confirm_destructive() deliberately has NO parameter for a
|
||||
* per-command `--yes` bypass flag -- that flag is per-INVOCATION, not
|
||||
* session state, so it cannot be folded into confirm_is_skip_set()'s
|
||||
* persistent toggle without leaking into the next command. Task 3.3's
|
||||
* five call sites (cmd_jobkill, cmd_shutdown, cmd_qctrl, cmd_hostctrl,
|
||||
* cmd_upgradeconfig) are responsible for short-circuiting around
|
||||
* confirm_destructive() entirely when their OWN --yes flag was given,
|
||||
* e.g.:
|
||||
* if (!opts.yes && !confirm_destructive("kill job 1234"))
|
||||
* return NOT_RUN;
|
||||
* (design.md's Implementation Notes describes this same three-way
|
||||
* bypass -- toggle, TTY, --yes -- as one boolean expression each call
|
||||
* site evaluates; confirm_destructive() itself owns the first two,
|
||||
* the call site owns the third because only it knows its own flag.)
|
||||
*
|
||||
* RETURN CONTRACT (confirm_destructive): exactly two outcomes, matching
|
||||
* design.md's Service Interface postcondition -- no third "error" case is
|
||||
* defined for this function.
|
||||
* 1 -- proceed: skip-toggle set, OR non-interactive session, OR the
|
||||
* operator answered affirmatively ("y"/"yes", case-insensitive,
|
||||
* surrounding whitespace ignored).
|
||||
* 0 -- not run: the operator declined (anything else, including a bare
|
||||
* Enter or unrecognized text), OR stdin hit EOF, OR the prompt's
|
||||
* blocking read was interrupted by a real Ctrl-C/SIGINT (EINTR;
|
||||
* Requirement 9.9 -- treated identically to a decline, never
|
||||
* retried). The caller must report this as "not run", distinct
|
||||
* from "failed" (Requirement 9.2/9.9).
|
||||
*
|
||||
* WHY THE PROMPT READ IS A PLAIN fgets(), NOT libedit's readline(): a
|
||||
* y/n answer routed through readline() would pollute the session's
|
||||
* command history and behave inconsistently when stdin isn't a TTY
|
||||
* (design.md's Implementation Notes / research.md). It also would not
|
||||
* reliably surface EINTR the way a bare blocking libc read does.
|
||||
*
|
||||
* WHY EINTR IS RELIABLE HERE: task 1.3 (xcetest_interrupt.h,
|
||||
* xcetest_install_sigint()) replaced test.c's old `signal(SIGINT,
|
||||
* sigbreak)` installation with an explicit sigaction() call with
|
||||
* SA_RESTART cleared, once, at process startup. That is a PRECONDITION
|
||||
* of this module, not something confirm_destructive() re-checks on every
|
||||
* call -- see design.md's Service Interface preconditions.
|
||||
*/
|
||||
|
||||
/* Prompts the operator (on stderr) to confirm a destructive action
|
||||
* described by `action_description` (a short phrase completing a
|
||||
* sentence like "About to <action_description>. Continue? [y/N] " --
|
||||
* e.g. "kill job 1234"), unless the skip toggle is set or the session
|
||||
* isn't interactive, in which case it returns 1 without printing
|
||||
* anything or reading stdin. See the return contract above. */
|
||||
int confirm_destructive(const char* action_description);
|
||||
|
||||
/* Sets/gets the session-scoped confirmation-skip toggle (Requirement
|
||||
* 9.6/9.7), in the exact same style as test.c's existing g_continue_on_error
|
||||
* `-e`/`+e` toggle: a plain `static int`, defaulting to 0 (confirmation
|
||||
* required -- NOT skipped) at process start, flipped by `cmd_set` (task
|
||||
* 3.3 wires the `set` command's new branch; this module only owns the
|
||||
* flag itself). While set, confirm_destructive() proceeds without
|
||||
* prompting regardless of TTY state, so a `source`d script (Requirement
|
||||
* 13) can opt in to unattended destructive commands explicitly. */
|
||||
void confirm_set_skip(int skip);
|
||||
int confirm_is_skip_set(void);
|
||||
|
||||
/* Read-only accessor over test.c's private `g_interactive` flag (set once
|
||||
* in main() from isatty(STDIN_FILENO), never changed after that) --
|
||||
* mirrors xcetest_interrupted()'s accessor pattern over test.c's private
|
||||
* `break_out` flag (xcetest_interrupt.h, task 1.3). Declared here, not in
|
||||
* xcetest_interrupt.h, because confirm.c is its only consumer; g_interactive
|
||||
* itself is unrelated to that header's SIGINT/EINTR concern.
|
||||
*
|
||||
* Per design.md's Confirmation Gate invariant, this is deliberately the
|
||||
* TOP-LEVEL session's own stdin, not "is this line currently being read
|
||||
* from a sourced file" -- Requirement 13.7 (a later task) depends on that
|
||||
* distinction: a destructive command inside a `source`d file in an
|
||||
* otherwise-interactive session must still prompt unless the skip toggle
|
||||
* is set.
|
||||
*
|
||||
* Implemented in test.c. 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 same reasoning applied to xcetest_interrupted()), so
|
||||
* test_confirm.c supplies its own local fake definition, exactly as
|
||||
* test_interruptcall.c already does for xcetest_interrupted(). */
|
||||
int xcetest_is_interactive(void);
|
||||
154
src/harness/include/flagspec.h
Normal file
154
src/harness/include/flagspec.h
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
#pragma once
|
||||
/*
|
||||
* flagspec.h -- flag-descriptor table format, getopt_long-based parsing
|
||||
* core, typo suggestion, and help-flag-block rendering for xce_test's
|
||||
* command-line surface (xce-test-cli-ux spec, tasks 1.1/1.2; Requirements
|
||||
* 2.1-2.4, 3.1, 6.1-6.4, 8.2, 10.1-10.3; the flagspec_t shape is a stable
|
||||
* contract also relied on by task 3.1 (migrating every existing cmd_*
|
||||
* command onto this table)).
|
||||
*
|
||||
* bug-1711's root cause was that xce_test's flag parsing had no shared
|
||||
* helper at all: a plain getopt() loop per command, each with its own
|
||||
* hand-written switch/default, drifted independently from its
|
||||
* hand-authored USAGE_* string. This module gives every flag-accepting
|
||||
* command ONE descriptor table to build its getopt_long() option table
|
||||
* from, ONE shared rejection path (with typo suggestion) to call on any
|
||||
* parse failure, and ONE renderer for the flag-listing portion of its
|
||||
* help text, so the table is the single source of truth for all three.
|
||||
*
|
||||
* Task 1.1 declared/implemented flagspec_build_opts() and
|
||||
* flagspec_reject() (without typo suggestion). THIS TASK (1.2) adds
|
||||
* flagspec_render_help() and extends flagspec_reject()'s rejection
|
||||
* message with a "did you mean" suggestion (Requirement 10) when exactly
|
||||
* one flag in `spec` closely matches the unrecognized token.
|
||||
*/
|
||||
|
||||
#include <getopt.h>
|
||||
#include <stddef.h>
|
||||
|
||||
/* One flag's full description: its short/long spellings, the real-world
|
||||
* concept it represents (for cross-command naming consistency, see
|
||||
* design.md Requirement 6.2), whether getopt_long() expects a value, and
|
||||
* documentation state consumed by later tasks (help rendering).
|
||||
*
|
||||
* Any two flagspec_t[] tables (i.e. any two commands) that describe the
|
||||
* "same concept" (e.g. a target host, a job id) MUST use the identical
|
||||
* long_opt string for it -- enforced by table review, not at runtime. */
|
||||
typedef struct {
|
||||
char short_opt; /* 0 if long-only */
|
||||
const char* long_opt; /* stable, cross-command-consistent name;
|
||||
* spelled WITHOUT leading dashes, e.g.
|
||||
* "host", not "--host" */
|
||||
const char* concept; /* e.g. "host", "jobid", "command-line";
|
||||
* consumed by help rendering (task 1.2),
|
||||
* not by flagspec_build_opts()/
|
||||
* flagspec_reject() */
|
||||
int has_arg; /* no_argument | required_argument, per
|
||||
* <getopt.h>; this design uses no
|
||||
* optional_argument flags anywhere */
|
||||
int required; /* 1 if the command fails without it;
|
||||
* enforcement is each command's own
|
||||
* post-parse-loop check (see design.md's
|
||||
* bug-1704/cmd_qctrl note), not this
|
||||
* module's job */
|
||||
const char* default_desc; /* human text describing the default when
|
||||
* the flag is omitted, NULL if none;
|
||||
* consumed by help rendering (task 1.2) */
|
||||
} flagspec_t;
|
||||
|
||||
/* Synthetic getopt_long() `val` assigned to a long-only flag (short_opt
|
||||
* == 0) at spec index i: FLAGSPEC_LONG_ONLY_BASE + i. Chosen safely above
|
||||
* the full unsigned-char range (0-255) so it can never collide with a
|
||||
* real short_opt value, with getopt_long()'s own -1 end-of-options
|
||||
* sentinel, or with '?' (parse error). Every flagspec_t entry -- long-only
|
||||
* or not -- therefore returns a value from getopt_long() that a caller's
|
||||
* switch() can distinguish. */
|
||||
#define FLAGSPEC_LONG_ONLY_BASE 0x100
|
||||
|
||||
/* Builds a getopt_long()-compatible option table and optstring from a
|
||||
* flagspec_t descriptor table.
|
||||
*
|
||||
* `out_opts` must have room for at least n+1 entries -- this function
|
||||
* appends the trailing all-zero sentinel entry getopt_long() requires to
|
||||
* terminate the table at index n. `out_optstring` is filled with the
|
||||
* short-option spec getopt_long()'s `optstring` argument expects: each
|
||||
* flag with a nonzero short_opt contributes that character, followed by
|
||||
* ':' when has_arg == required_argument; a long-only flag (short_opt ==
|
||||
* 0) contributes nothing. The result is NUL-terminated.
|
||||
*
|
||||
* Invariant: the built optstring never begins with ':' -- this module
|
||||
* deliberately does not use getopt_long()'s own leading-':' convention
|
||||
* for distinguishing "missing argument" from "unrecognized option";
|
||||
* flagspec_reject() makes that distinction itself by looking the
|
||||
* offending token up in `spec`. The caller must set `opterr = 0` before
|
||||
* its getopt_long() loop so libc's own diagnostic never doubles up with
|
||||
* flagspec_reject()'s.
|
||||
*
|
||||
* Returns 0 on success. Returns a negative errno on failure:
|
||||
* -EINVAL spec/out_opts/out_optstring is NULL, n == 0,
|
||||
* out_optstring_cap == 0, or a spec entry is malformed
|
||||
* (long_opt is NULL/empty, or has_arg is neither no_argument
|
||||
* nor required_argument)
|
||||
* -ENOSPC out_optstring_cap is too small to hold the built optstring
|
||||
* (including its terminating NUL)
|
||||
*
|
||||
* On any failure, the contents of out_opts/out_optstring are unspecified
|
||||
* -- the caller must not use them. */
|
||||
int flagspec_build_opts(const flagspec_t* spec, size_t n,
|
||||
struct option* out_opts, char* out_optstring,
|
||||
size_t out_optstring_cap);
|
||||
|
||||
/* Shared rejection path for an unrecognized or malformed flag, called
|
||||
* from a command's getopt_long() loop's `case '?':` arm (or right after
|
||||
* the loop, for a bad_flag captured some other way).
|
||||
*
|
||||
* `bad_flag` is the literal command-line token that failed -- e.g. "-z"
|
||||
* or "--bogus" -- ordinarily read as `argv[optind - 1]` right after
|
||||
* getopt_long() returns '?'. flagspec_reject() looks `bad_flag` up
|
||||
* against `spec` (matching a "-X" token by short_opt, a "--name" token by
|
||||
* long_opt) to decide which case applies:
|
||||
* - `bad_flag` matches no entry in `spec` ->
|
||||
* "unrecognized flag" (Requirement 2.1)
|
||||
* - `bad_flag` matches an entry with has_arg == required_argument ->
|
||||
* "flag ... requires a value" (Requirement 2.2)
|
||||
* - `bad_flag` matches an entry that takes no argument (getopt_long()
|
||||
* still returned '?', e.g. "--flag=value" given for a no_argument
|
||||
* long flag) -> "flag ... does not take a value"
|
||||
* Either way, it prints the offending flag and `usage_text` (Requirement
|
||||
* 2.4) to stderr only (Requirement 3.1 -- never stdout) and returns a
|
||||
* negative value; it never partially executes the command.
|
||||
*
|
||||
* Typo suggestion (Requirement 10): when `bad_flag` matches no entry in
|
||||
* `spec` (the "unrecognized flag" case above) and exactly one entry's
|
||||
* short or long spelling closely matches it by edit distance (see
|
||||
* flagspec.c's is_close_match()), the rejection message names that flag
|
||||
* as the likely intended one (Requirement 10.1). When zero or more than
|
||||
* one entry qualifies as a close match, no guess is made (Requirement
|
||||
* 10.2) -- an ambiguous typo is treated the same as a typo that matches
|
||||
* nothing at all. Suggestion is never attempted for the "requires a
|
||||
* value"/"does not take a value" cases, since those already identify the
|
||||
* exact intended flag.
|
||||
*
|
||||
* `usage_text` is expected to be the command's one-line usage summary
|
||||
* (e.g. "jobkill -j <jobid> [-s <signo>]"), not its full help block; it
|
||||
* may be NULL to omit the usage line entirely (defensive only -- every
|
||||
* real call site always has one).
|
||||
*
|
||||
* Returns -EINVAL (a fixed, nonzero, negative-errno result) in every
|
||||
* case. */
|
||||
int flagspec_reject(const char* cmd_name, const flagspec_t* spec, size_t n,
|
||||
const char* bad_flag, const char* usage_text);
|
||||
|
||||
/* Renders the flag-listing portion of a command's help text (Requirement
|
||||
* 8.2) directly from `spec`: one line per flag, each stating its short
|
||||
* form (when it has one), its long form, whether it is required or
|
||||
* optional, and its default-value description (when `default_desc` is
|
||||
* non-NULL). This is the flag-block ONLY -- the surrounding description,
|
||||
* example, and related-command prose (Requirement 8.1/8.3/8.4) stay
|
||||
* hand-authored, per design.md's "Flag-descriptor table as the single
|
||||
* source of truth" decision.
|
||||
*
|
||||
* Returns a heap-allocated, NUL-terminated string the caller must free().
|
||||
* Returns NULL if `spec` is NULL or n == 0, or if the underlying
|
||||
* allocation fails; it never returns a partially-written buffer. */
|
||||
char* flagspec_render_help(const flagspec_t* spec, size_t n);
|
||||
326
src/harness/include/interruptcall.h
Normal file
326
src/harness/include/interruptcall.h
Normal file
|
|
@ -0,0 +1,326 @@
|
|||
#pragma once
|
||||
/*
|
||||
* interruptcall.h -- xcetest_run_interruptible(): a detached-worker-thread
|
||||
* wrapper that lets a command make a blocking xce_* RPC call cancellably
|
||||
* from the operator's Ctrl-C, without ever touching libxce's own blocking
|
||||
* primitive (xce-test-cli-ux spec, task 1.4; Requirements 1.1, 1.2, 1.3,
|
||||
* 1.4).
|
||||
*
|
||||
* ROOT CAUSE this module addresses: every blocking xce_* call
|
||||
* (src/lib/xceapi.c) waits on pthread_cond_timedwait() with a fixed
|
||||
* 10-second deadline (cbuf_deadline_10s/cbuf_timedget, src/lib/mylock.c) --
|
||||
* a wait a signal-setting flag alone cannot interrupt (glibc's condvar
|
||||
* wait retries internally). Modifying that shared libxce primitive is out
|
||||
* of this spec's boundary (research.md's Architecture Pattern Evaluation:
|
||||
* "Modify libxce's cbuf_timedget to poll a cancellation token" -- Rejected,
|
||||
* out of boundary). The fix is entirely client-side: run the existing
|
||||
* blocking call, UNMODIFIED, on a detached worker thread; have the calling
|
||||
* thread poll xcetest_interrupted() (xcetest_interrupt.h, task 1.3) on a
|
||||
* short interval instead of blocking on the call itself; on interrupt,
|
||||
* abandon the wait and return immediately, leaving the worker thread to
|
||||
* finish (or hit its own 10s deadline) on its own -- satisfying "leave the
|
||||
* connection intact" (Requirement 1.5) and the design's "cancelling a
|
||||
* client-side wait does not need to cancel the server-side request" note.
|
||||
*
|
||||
* THE HARD PART this module exists to get right: the worker thread and the
|
||||
* calling thread both have a legitimate reason to touch the heap-allocated
|
||||
* call state backing one call (the worker to run fn()/store its result;
|
||||
* the caller to read that result) -- but at most ONE of them may free it,
|
||||
* and it must be resolved without a lock the caller would have to block
|
||||
* on (that would defeat the whole point of "abandon immediately"). See
|
||||
* design.md's "Heap-Block Ownership Handoff" state diagram and
|
||||
* research.md's "Decision: Interruptible RPC via detached worker thread
|
||||
* with CAS-based ownership handoff" for the full rationale, including why
|
||||
* pthread_cancel() was rejected (cancellation-safety of
|
||||
* pthread_cond_timedwait()'s internal state is not established).
|
||||
*
|
||||
* PER-CONNECTION LOCK REGISTRY (task 1.5, Requirements 1.1, 1.5): an
|
||||
* abandoned worker thread from task 1.4's mechanism above keeps running
|
||||
* against whatever connection state `ctx` closes over. If the operator's
|
||||
* very next command reuses that SAME connection, its own call would race
|
||||
* the still-running abandoned worker for that connection's single-
|
||||
* outstanding-reply state -- not a rare edge case, but the direct, likely
|
||||
* consequence of Ctrl-C's own purpose (cancel, then immediately try
|
||||
* something else). xcetest_run_interruptible() closes this by acquiring
|
||||
* a per-connection lock -- looked up or lazily created in a small,
|
||||
* process-lifetime registry keyed by whatever `xcetest_set_active_
|
||||
* connection()` was last called with -- BEFORE spawning the worker, and
|
||||
* holding it for the call's full lifetime, including through an
|
||||
* abandonment (ownership of "release the lock" passes to the worker in
|
||||
* that case, exactly mirroring the heap-block CAS handoff above). Lock
|
||||
* acquisition is itself interruptible (the same break_out poll loop), so
|
||||
* a second call waiting on a connection a prior abandoned call still
|
||||
* holds can itself be Ctrl-C'd out of that wait, before ever acquiring
|
||||
* the lock or spawning a worker of its own. See design.md's "Interruptible
|
||||
* RPC Call and Ctrl-C Abandonment" sequence diagram and research.md's
|
||||
* "Decision: Per-connection lock serializes overlapping calls on the
|
||||
* Interruptible Call Adapter" for the full rationale.
|
||||
*
|
||||
* WHY THE LOCK IS A POSIX SEMAPHORE (sem_t), NOT A MUTEX: the handoff
|
||||
* above is a genuine CROSS-THREAD release -- the REPL/caller thread
|
||||
* acquires the lock before spawning the worker, but on the abandonment
|
||||
* path it is the still-running WORKER thread that later releases it,
|
||||
* once fn() finishes. A pthread_mutex_t (of ANY type, including the
|
||||
* default PTHREAD_MUTEX_NORMAL) is the wrong primitive for this: POSIX
|
||||
* defines pthread_mutex_t in terms of a single owning thread, and
|
||||
* classifies pthread_mutex_unlock() from a non-owning thread as
|
||||
* undefined behavior for every mutex type -- PTHREAD_MUTEX_ERRORCHECK/
|
||||
* _RECURSIVE are simply *required* to detect and report that UB (hence
|
||||
* the EPERM an earlier version of this code hit immediately), while
|
||||
* NORMAL/DEFAULT are merely *permitted* not to detect it. POSIX does NOT
|
||||
* separately declare cross-thread unlock well-defined for NORMAL; it
|
||||
* just does not mandate an error return for it. A pthread_mutex_t-based
|
||||
* fix that swaps ERRORCHECK for the default type therefore only trades a
|
||||
* loud crash for a silent reliance on non-portable, POSIX-classified-UB
|
||||
* behavior -- something that happens to hold on this project's actual
|
||||
* target (glibc/NPTL's PTHREAD_MUTEX_NORMAL has had no ownership
|
||||
* tracking for decades, and XCE's Nix-hermetic build is glibc/Linux-only)
|
||||
* but is the wrong thing to build on when a fully-defined alternative
|
||||
* exists at the same code complexity.
|
||||
*
|
||||
* sem_t has NO ownership concept at all: sem_wait()/sem_trywait()/
|
||||
* sem_post() may be called from any thread regardless of which thread
|
||||
* called which, with fully-defined POSIX semantics for exactly this
|
||||
* cross-thread signal pattern. Each registry entry is therefore a binary
|
||||
* semaphore (sem_init(&sem, 0, 1)) used as a mutual-exclusion lock:
|
||||
* sem_trywait() takes the place of pthread_mutex_trylock() in the
|
||||
* interruptible poll loop below (EAGAIN where trylock would have
|
||||
* returned EBUSY), and sem_post() takes the place of
|
||||
* pthread_mutex_unlock() at both release sites -- with no UB caveat on
|
||||
* either.
|
||||
*
|
||||
* WHY THE REGISTRY IS A SINGLY-LINKED LIST, NOT A GROWABLE ARRAY: a
|
||||
* sem_t* handed out by this registry may be cached inside a call block
|
||||
* that a still-running abandoned worker thread holds onto indefinitely
|
||||
* (up to ~10s, per the heap-block ownership handoff above). A growable
|
||||
* array's realloc() can MOVE every previously-returned element the
|
||||
* moment a later, unrelated connection is first seen -- silently
|
||||
* invalidating that worker's pointer into stale/reused memory. A linked
|
||||
* list's nodes are individually allocated and, per this registry's own
|
||||
* "entries are never removed" contract, never freed or moved for the
|
||||
* rest of the process's life, so a pointer into it stays valid forever
|
||||
* once handed out.
|
||||
*
|
||||
* CONCURRENCY OF THE REGISTRY ITSELF: per design.md's Implementation
|
||||
* Notes, "the registry's lookup/insert step needs no synchronization of
|
||||
* its own -- a new registry entry is only ever created from the single
|
||||
* REPL thread that issues commands" (detached worker threads never
|
||||
* initiate a new top-level call; they only run the `fn` they were given
|
||||
* and release the lock/free the block they were handed). The SEMAPHORE
|
||||
* each entry owns, once handed out, is of course safe for concurrent
|
||||
* wait/post from different threads -- that safety is sem_t's own job,
|
||||
* not the registry's.
|
||||
*/
|
||||
|
||||
#include <stdatomic.h>
|
||||
|
||||
/* A blocking call this module runs on a detached worker thread. `ctx` is
|
||||
* whatever heap-owned state `fn` needs -- see xcetest_run_interruptible()'s
|
||||
* preconditions below for the one hard rule about what `fn` may touch. */
|
||||
typedef int (*xcetest_blocking_fn)(void* ctx);
|
||||
|
||||
/* The heap-allocated call block's lifecycle, resolved by exactly one
|
||||
* atomic compare-and-swap regardless of which side "wins":
|
||||
* RUNNING -- fn() has not yet returned, and no one has decided
|
||||
* to abandon the call yet. Initial state.
|
||||
* DONE_UNCLAIMED -- fn() returned and the worker's CAS from RUNNING to
|
||||
* this state succeeded (i.e. the caller had not yet
|
||||
* abandoned it). The CALLER is left owning the
|
||||
* block: it reads rc and frees it.
|
||||
* ABANDONED -- the caller's CAS from RUNNING to this state
|
||||
* succeeded (i.e. it observed an interrupt before
|
||||
* fn() finished). The WORKER is left owning the
|
||||
* block: once fn() eventually returns, its own CAS
|
||||
* attempt (RUNNING -> DONE_UNCLAIMED) fails against
|
||||
* this state, and it frees the block itself instead.
|
||||
* See design.md's "Heap-Block Ownership Handoff" stateDiagram-v2 -- this
|
||||
* enum's three values are exactly its three states. */
|
||||
typedef enum {
|
||||
XCT_RUNNING,
|
||||
XCT_DONE_UNCLAIMED,
|
||||
XCT_ABANDONED
|
||||
} xcetest_call_state_t;
|
||||
|
||||
/*
|
||||
* Sets the connection-identity key that xcetest_run_interruptible() below
|
||||
* reads INTERNALLY when a new call starts, to look up (or lazily create)
|
||||
* that connection's entry in the per-connection lock registry (task 1.5,
|
||||
* see the file header above). This is a simple settable-elsewhere,
|
||||
* read-internally piece of process state -- deliberately mirroring how
|
||||
* break_out/xcetest_interrupted() already works -- NOT a parameter added
|
||||
* to xcetest_run_interruptible()'s own signature, which stays exactly
|
||||
* `(fn, ctx)` per this task's design constraint.
|
||||
*
|
||||
* SEAM FOR A FUTURE CALLER (task 3.2): production code (test.c's cmd_*
|
||||
* functions) does not exist inside this module and has no notion of
|
||||
* "the currently active command_ctx_t's connection pointer" that this
|
||||
* header can reach into directly -- test.c itself is never linked into
|
||||
* this binary's unit tests (see this file's own header and
|
||||
* xcetest_interrupt.h's). The intended real wiring is: before invoking
|
||||
* xcetest_run_interruptible() for a blocking RPC, a cmd_* function (or a
|
||||
* shared dispatch point above all of them) calls
|
||||
* xcetest_set_active_connection(<that connection's own stable pointer,
|
||||
* e.g. the command_ctx_t's connection field>). Any stable, comparable-by-
|
||||
* identity pointer works as `conn_key` -- this module never dereferences
|
||||
* it, only compares it by pointer equality against the registry's keys.
|
||||
* Until task 3.2 wires that in, xce_test's real binary calls this
|
||||
* exactly once, from wherever main()/the dispatcher sets up each
|
||||
* command's active connection context.
|
||||
*
|
||||
* Concurrency: like break_out, callable only from the single REPL thread
|
||||
* that issues top-level commands -- see the file header's "CONCURRENCY
|
||||
* OF THE REGISTRY ITSELF" note. Passing the same key across unrelated
|
||||
* connections would incorrectly serialize them against each other;
|
||||
* passing a fresh/different key per connection is what keeps unrelated
|
||||
* connections independent (Requirement 1.5, "leave ... its underlying
|
||||
* connection intact").
|
||||
*/
|
||||
void xcetest_set_active_connection(void* conn_key);
|
||||
|
||||
/*
|
||||
* Runs `fn(ctx)` on a detached worker thread while THIS (the calling)
|
||||
* thread polls xcetest_interrupted() every ~50ms (design.md's Interruptible
|
||||
* Call Adapter component), so a Ctrl-C observed during the wait abandons
|
||||
* it immediately instead of blocking for fn()'s own, possibly much
|
||||
* longer, duration.
|
||||
*
|
||||
* Before spawning that worker, this function first acquires -- also
|
||||
* interruptibly, via the same ~50ms xcetest_interrupted() poll, but
|
||||
* using sem_trywait() instead of a blocking sem_wait() -- the
|
||||
* per-connection lock for whichever connection key was last passed to
|
||||
* xcetest_set_active_connection() (task 1.5; see the file header's "PER-
|
||||
* CONNECTION LOCK REGISTRY" section above). This serializes this call
|
||||
* against any other in-flight or abandoned-but-still-running call on the
|
||||
* same connection, so that connection's single-outstanding-reply state
|
||||
* is never touched by two threads at once.
|
||||
*
|
||||
* Preconditions:
|
||||
* - `fn` is non-NULL.
|
||||
* - `fn` touches only what `ctx` itself heap-owns -- NEVER memory on
|
||||
* this function's caller's own stack frame. This is the precondition
|
||||
* that makes the -EINTR postcondition below safe: once ownership of
|
||||
* `ctx` has passed to the still-running worker, the caller's own
|
||||
* stack frame may have long since unwound.
|
||||
*
|
||||
* Postconditions:
|
||||
* - Returns fn()'s own return value when fn() finishes before this
|
||||
* thread's abandon-CAS wins the race (whether or not
|
||||
* xcetest_interrupted() ever became true during the wait) -- see the
|
||||
* header comment's "near-simultaneous" case below. On this path, the
|
||||
* per-connection lock is released by this (the caller's) thread,
|
||||
* after reading rc and freeing the block.
|
||||
* - Returns -EINTR when this thread observes an interrupt WHILE
|
||||
* WAITING FOR THE PER-CONNECTION LOCK -- i.e. before ever acquiring
|
||||
* it. No worker is spawned for this call at all in this case, and
|
||||
* nothing needs releasing, since this call never held the lock to
|
||||
* begin with.
|
||||
* - Returns -EINTR when this thread observes an interrupt AFTER
|
||||
* acquiring the lock and spawning the worker, and its own CAS
|
||||
* (RUNNING -> ABANDONED) wins the race. On this return, the caller
|
||||
* MUST NOT touch `ctx` again -- ownership of both the call block AND
|
||||
* the per-connection lock has passed to the still-running worker
|
||||
* thread, which will finish fn(), free the call block, and release
|
||||
* the lock on its own, asynchronously, with no further signal back
|
||||
* to this caller.
|
||||
* - Returns -EINVAL if `fn` is NULL (fn is never invoked in this case;
|
||||
* no lock is acquired).
|
||||
* - Returns a negative errno from the per-connection lock registry
|
||||
* lookup (e.g. -ENOMEM), from sem_trywait() itself for an
|
||||
* unexpected error other than EAGAIN, or from pthread_attr_init()/
|
||||
* pthread_create() if the worker thread could not be set up or
|
||||
* spawned at all; in every one of these cases fn() is never invoked,
|
||||
* any call block already allocated is freed before returning, and
|
||||
* the per-connection lock -- if this call had already acquired it --
|
||||
* is released before returning.
|
||||
*
|
||||
* The near-simultaneous race (this thread notices the interrupt in the
|
||||
* same window the worker finishes fn()) is resolved by the SAME single
|
||||
* CAS deciding both sides at once: whichever of {this thread's
|
||||
* RUNNING->ABANDONED attempt, the worker's RUNNING->DONE_UNCLAIMED
|
||||
* attempt} executes first wins, and the loser observes that the state is
|
||||
* no longer RUNNING and falls back to the OTHER side's outcome -- a
|
||||
* caller who loses the abandon-CAS falls through to the normal
|
||||
* completion path (reads rc, frees the block, releases the lock, returns
|
||||
* rc) instead of returning -EINTR; a worker who loses the done-CAS frees
|
||||
* the block and releases the lock itself instead of leaving them for the
|
||||
* caller. Exactly one free() of the block, and exactly one release of
|
||||
* the lock, are therefore reachable on every possible interleaving --
|
||||
* never zero, never two.
|
||||
*/
|
||||
int xcetest_run_interruptible(xcetest_blocking_fn fn, void* ctx);
|
||||
|
||||
/*
|
||||
* xcetest_wait_connection_idle (xce-test-cli-ux task 3.2 remediation round
|
||||
* 1, bug-1795) -- blocks the calling thread until no worker thread
|
||||
* (running or abandoned-but-still-running) is active on `conn_key`, per
|
||||
* this module's own "at most one worker thread per connection" invariant,
|
||||
* then returns immediately.
|
||||
*
|
||||
* WHY THIS EXISTS: xcetest_run_interruptible()'s per-connection lock
|
||||
* registry above enforces that invariant BETWEEN calls on the same
|
||||
* connection -- a second call on a connection an abandoned worker still
|
||||
* holds waits (interruptibly) for that worker to finish. It does NOT by
|
||||
* itself protect connection TEARDOWN: test.c's leave_ctx() (called by
|
||||
* `up`/`quit`) tears down the connection object out from under an
|
||||
* abandoned-but-still-running worker with no wait/join of its own,
|
||||
* because leave_ctx() never calls xcetest_run_interruptible() -- it is
|
||||
* connection teardown, not a new RPC. xcecli_wakeup() (called by
|
||||
* leave_ctx() just before this function, best-effort) nudges that worker
|
||||
* to unwind faster than its own ~10s deadline, but nothing forced
|
||||
* leave_ctx() to actually wait for it -- a genuine, exploitable
|
||||
* use-after-free window (any abandoned worker still reading/writing the
|
||||
* connection object while leave_ctx()'s xcecli_stop()/xcecli_ctx_fini()
|
||||
* frees it). This function closes that gap by reusing the SAME
|
||||
* per-connection semaphore registry xcetest_run_interruptible() already
|
||||
* builds and maintains, rather than inventing a second mechanism.
|
||||
*
|
||||
* CONTRACT: looks up (or lazily creates, via the exact same internal
|
||||
* lookup xcetest_run_interruptible() itself uses) the sem_t for
|
||||
* `conn_key`, does a PLAIN BLOCKING sem_wait() on it (deliberately NOT
|
||||
* the interruptible sem_trywait()-poll variant used inside
|
||||
* xcetest_run_interruptible() -- this is a narrow, safety-critical wait
|
||||
* where correctness matters more than Ctrl-C responsiveness; it should
|
||||
* resolve quickly in the ordinary case since the caller's own
|
||||
* xcecli_wakeup() call already nudges the worker to finish sooner), then
|
||||
* immediately sem_post()s it back before returning. It does NOT hold the
|
||||
* semaphore locked on return -- the semantic this function provides is
|
||||
* "prove that, at this instant, no worker is active on this connection,"
|
||||
* not "hold this connection locked for some ongoing operation of the
|
||||
* caller's own." A caller that needs the latter must acquire and hold the
|
||||
* lock itself (which only xcetest_run_interruptible() currently does).
|
||||
*
|
||||
* A transient EINTR from sem_wait() itself (a signal delivered while
|
||||
* blocked, distinct from `errno`-based break_out signal state) is
|
||||
* retried internally rather than surfaced -- this function's whole
|
||||
* purpose is to make the wait unconditional, not skippable.
|
||||
*
|
||||
* Safe to call with any `conn_key` previously passed to
|
||||
* xcetest_set_active_connection() (or, for that matter, any key never
|
||||
* seen before -- the lazy-create path simply creates a fresh, immediately
|
||||
* uncontended semaphore for it and returns right away). Must be called
|
||||
* from the same single REPL thread this module's other entry points are
|
||||
* documented to run on (see this header's "CONCURRENCY OF THE REGISTRY
|
||||
* ITSELF" note) -- it is not itself safe to call concurrently from
|
||||
* multiple threads, though the semaphore it waits on is of course safe to
|
||||
* be concurrently sem_post()'d by a worker thread while this function
|
||||
* waits on it.
|
||||
*/
|
||||
void xcetest_wait_connection_idle(void* conn_key);
|
||||
|
||||
#ifdef XCE_TESTING
|
||||
/*
|
||||
* Test-only instrumentation, NOT part of the production API and never
|
||||
* even declared unless XCE_TESTING is defined (i.e. never visible to the
|
||||
* real xce_test binary): incremented (via atomic_fetch_add) by the worker
|
||||
* thread immediately after it frees an ABANDONED call block.
|
||||
*
|
||||
* Exists solely so test_interruptcall.c's forced-abandonment test can
|
||||
* confirm the abandoned worker's asynchronous cleanup genuinely completed
|
||||
* before the test process exits, rather than merely hoping a fixed sleep
|
||||
* was long enough -- a still-running detached worker racing a leak
|
||||
* detector's exit-time check could otherwise either mask a real bug or
|
||||
* (more likely, if the implementation above is correct) flag a false
|
||||
* leak on an allocation that was always going to be freed, just not yet.
|
||||
*/
|
||||
extern _Atomic int xcetest_test_abandoned_free_count;
|
||||
#endif
|
||||
81
src/harness/include/joblist_display.h
Normal file
81
src/harness/include/joblist_display.h
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
#ifndef JOBLIST_DISPLAY_H
|
||||
#define JOBLIST_DISPLAY_H
|
||||
|
||||
/* joblist_display.h -- xce-test-cli-ux task 2.5 remediation (bug-1711 fixes)
|
||||
*
|
||||
* Extracted from test.c's worker_status_str()/job_state_str()/
|
||||
* format_job_time(): pure, dependency-light display-formatting helpers, with
|
||||
* NO networking, REPL, or global-session-state dependency (job_state_str's
|
||||
* only state is a function-local `static` scratch buffer, used solely to
|
||||
* render its own return value -- no cross-call or cross-session state).
|
||||
* This isolation is what makes them unit-testable outside of test.c, which
|
||||
* cannot be white-box #include'd into a Unity test (thousands of lines, its
|
||||
* own main(), heavy libedit/network dependencies) -- same rationale as
|
||||
* resval_typeresolve.c's extraction from set_resval() (resource-value-
|
||||
* verification task 1.1) and misc.c's update_host_runusage()/
|
||||
* aggregate_job_runusage() (bug-1735).
|
||||
*
|
||||
* test.c calls these directly at their existing call sites (cmd_workerlist,
|
||||
* joblist_query, print_jobinfo) -- no behavior change, extraction only.
|
||||
*/
|
||||
|
||||
#include <stddef.h> /* size_t */
|
||||
#include <stdint.h> /* uint32_t */
|
||||
#include <time.h> /* time_t */
|
||||
|
||||
/* Render a worker's status bitmask (xce_worker_t.status, XCE_WORKER_* bits
|
||||
* in xce/types.h) as a short display string for cmd_workerlist's Status
|
||||
* column.
|
||||
*
|
||||
* Priority order (first match wins, unchanged from the pre-extraction
|
||||
* logic): CLOSED > UNAVAIL > LOCKEDU > LOCKEDW > BUSY > (status == 0) OK.
|
||||
* Admin-set CLOSED intentionally takes priority over the dynamic UNAVAIL
|
||||
* bit: a host closed via hostctrl that later disconnects would otherwise
|
||||
* print only "UNAVAIL", masking the admin bit this check exists to verify
|
||||
* (config-upgrade-snapshot soak, task 8.6).
|
||||
*
|
||||
* Any other nonzero bit combination not covered above returns "UNKNOWN"
|
||||
* (Requirement 11.1 -- bug-1711's fix; previously misspelled "UNKOWN").
|
||||
*
|
||||
* Returns a string literal -- never a caller-owned buffer, safe to use
|
||||
* across calls with no ownership/lifetime concerns. */
|
||||
char* worker_status_str(uint32_t status);
|
||||
|
||||
/* Render a job's state bitmask (xce_job_t.state, JOB_STAT_* bits in
|
||||
* xce/types.h) as a short display string for cmd_joblist's State column
|
||||
* (joblist_query) and print_jobinfo's "state:" line.
|
||||
*
|
||||
* Priority order (first match wins, unchanged from the pre-extraction
|
||||
* logic): PEND (+PSUSP) > RUN (+USUSP/+SSUSP/+EXIT) > EXIT > DONE > ERROR >
|
||||
* UNKNOWN.
|
||||
*
|
||||
* Requirement 11.7 (bug-1711): a job that is simultaneously RUN and EXIT
|
||||
* (e.g. a worker-reported FAIL received without a prior FINISH;
|
||||
* apply_job_fail_transition ORs in EXIT|PERR without clearing RUN) is
|
||||
* reported via a raw-state fallback "RUN(0x%x)", consistent with the
|
||||
* fallback print_jobinfo already applies for that same ambiguity via its
|
||||
* separate state_raw line -- checked AFTER USUSP/SSUSP in the same
|
||||
* else-if chain, so it never masks either of those.
|
||||
*
|
||||
* Returns a string literal for every branch except the RUN+EXIT fallback,
|
||||
* which is rendered into a function-local `static` scratch buffer valid
|
||||
* until the next call (the same lifetime contract test.c relied on before
|
||||
* extraction). */
|
||||
char* job_state_str(int state);
|
||||
|
||||
/* Format a job time_t field (epoch seconds) using ctrlstatus_fmt.h's
|
||||
* ctrlstatus_format_time_ms human-readable formatter (which takes epoch
|
||||
* milliseconds) into `buf` (at least 24 bytes -- see
|
||||
* ctrlstatus_format_time_ms's own size contract).
|
||||
*
|
||||
* A job time of 0 means the corresponding event has not happened yet (e.g.
|
||||
* an unstarted job's start_time/finish_time) -- returns "-" rather than a
|
||||
* misleading 1970-01-01 epoch date (Requirement 11.5). Also returns "-" if
|
||||
* the underlying formatter itself fails (e.g. `buf`/`bufsize` too small).
|
||||
*
|
||||
* Returns `buf` on success (nonzero `t`), or a string literal ("-") for the
|
||||
* zero/failure cases -- callers must not assume the return value is always
|
||||
* `buf`. */
|
||||
const char* format_job_time(time_t t, char* buf, size_t bufsize);
|
||||
|
||||
#endif /* JOBLIST_DISPLAY_H */
|
||||
68
src/harness/include/join_worker_args.h
Normal file
68
src/harness/include/join_worker_args.h
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
#ifndef JOIN_WORKER_ARGS_H
|
||||
#define JOIN_WORKER_ARGS_H
|
||||
|
||||
/* join_worker_args.h -- xce-test-cli-ux task 2.6 remediation (bug-1557 fix)
|
||||
*
|
||||
* Extracted from test.c's cmd_join_worker(): the pure argument-parsing/
|
||||
* defaulting block that populates a hostattrs_t (xce/types.h) from argc/argv,
|
||||
* including the bug-1557 maxmem default fix. This block runs entirely before
|
||||
* cmd_join_worker's only network call (xcecli_join()) and touches no
|
||||
* active_ctx, REPL state, or any global beyond the standard `optind` reset
|
||||
* every other cmd_ handler in this file already performs -- the same
|
||||
* isolation rationale as joblist_display.c's extraction (task 2.5) and
|
||||
* resval_typeresolve.c's (resource-value-verification task 1.1).
|
||||
*
|
||||
* test.c's cmd_join_worker() calls this directly in place of its old inline
|
||||
* parsing block, then proceeds to xcecli_join() exactly as before -- no
|
||||
* behavior change to the network-call phase, extraction only.
|
||||
*/
|
||||
|
||||
#include <stddef.h> /* size_t */
|
||||
|
||||
#include "flagspec.h" /* flagspec_t */
|
||||
#include "xce/types.h" /* hostattrs_t */
|
||||
|
||||
/* join's flagspec_t table, exposed so test.c's `join` command_t entry
|
||||
* (worker context, cmd_join_worker) can wire it into the Help Renderer's
|
||||
* generated flag block (xce-test-cli-ux task 3.5, Requirement 8.2, 12.2) --
|
||||
* the same table join_worker_parse_attrs() itself parses against, so the
|
||||
* rendered help and the actual parsing/defaulting behavior can never drift
|
||||
* apart.
|
||||
*
|
||||
* JOIN_FLAGS_COUNT is a compile-time literal (not a `const size_t`
|
||||
* variable) specifically so test.c's file-scope command_t table
|
||||
* initializers -- which the C standard requires to be constant
|
||||
* expressions -- can use it directly for a `.flagspec_count` field; an
|
||||
* extern variable's value is only known at link time and is not a
|
||||
* constant expression. Declaring `join_flags[]` here with this explicit
|
||||
* bound, then defining it in join_worker_args.c with a plain `[]` and a
|
||||
* 6-entry initializer, gets a free safety net: the two declarations of
|
||||
* the same array must agree on size, so a future edit that adds/removes a
|
||||
* flag without updating JOIN_FLAGS_COUNT fails the build immediately
|
||||
* ("conflicting types") instead of silently reporting a wrong count.
|
||||
* `JOIN_NFLAGS` (join_worker_args.c) stays an internal alias of this same
|
||||
* constant, used to size join_worker_parse_attrs()'s own getopt_long()
|
||||
* option table -- unchanged behavior, just no longer computed via
|
||||
* sizeof(). */
|
||||
#define JOIN_FLAGS_COUNT 6
|
||||
extern const flagspec_t join_flags[JOIN_FLAGS_COUNT];
|
||||
|
||||
/* Parse join's CLI flags ("-f cpufactor -c maxcpus -m maxmem -s maxswap
|
||||
* -t maxtmp -d ndisks") into `*attrs`, first defaulting every field
|
||||
* (including the bug-1557-corrected maxmem default of 64 * 1024 * 1024
|
||||
* bytes -- see the in-body comment in join_worker_args.c for the full
|
||||
* bytes-vs-MB history) and then overriding any field whose flag was given.
|
||||
*
|
||||
* Resets `optind` to 1 before parsing, matching this file's own convention
|
||||
* for repeated in-process command invocations (see cmd_jobfinish's comment
|
||||
* on the same convention).
|
||||
*
|
||||
* Returns 0 on success. Returns -EINVAL on an unrecognized flag or a NULL
|
||||
* `attrs`, leaving `*attrs` fully defaulted (and possibly partially
|
||||
* overridden by whichever flags were already parsed before the
|
||||
* unrecognized one) -- callers must not use `*attrs` after a nonzero
|
||||
* return. On a parse error, this function itself prints the same usage
|
||||
* string cmd_join_worker printed before extraction. */
|
||||
int join_worker_parse_attrs(int argc, char* argv[], hostattrs_t* attrs);
|
||||
|
||||
#endif /* JOIN_WORKER_ARGS_H */
|
||||
54
src/harness/include/listfmt_json.h
Normal file
54
src/harness/include/listfmt_json.h
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
#pragma once
|
||||
/*
|
||||
* listfmt_json.h -- shared cJSON array/object plumbing for xce_test's
|
||||
* listing commands' JSON output mode (xce-test-cli-ux spec, task 2.2;
|
||||
* Requirements 5.1, 5.3).
|
||||
*
|
||||
* This module owns ONLY the JSON array/object lifecycle: start an array,
|
||||
* append a new row object to it, add string/number fields to a row, then
|
||||
* print + free the whole array. It does NOT know about workerlist's/
|
||||
* joblist's/reslist's own field sets -- each command's existing row
|
||||
* struct already exists and is stable (design.md's Simplification lens),
|
||||
* so this library introduces no generic "record"/"row" abstraction beyond
|
||||
* the bare cJSON object handle itself. Wiring this into cmd_workerlist/
|
||||
* cmd_joblist/cmd_reslist (their field mapping, their --json flag) is
|
||||
* task 3.4, not this one.
|
||||
*
|
||||
* cJSON is already linked into xce_test (struct2json_dep, per
|
||||
* research.md) and already used for job exdata blobs (test.c's
|
||||
* build_job_envs()) -- this module reuses that exact same cJSON API
|
||||
* surface, not a different one.
|
||||
*/
|
||||
|
||||
#include <cjson/cJSON.h>
|
||||
|
||||
/* Creates a new, empty JSON array meant to hold one object per output
|
||||
* row. The caller passes the returned array to listfmt_json_new_row()
|
||||
* once per row, then to listfmt_json_finish() exactly once when done.
|
||||
* Returns NULL on allocation failure. */
|
||||
cJSON* listfmt_json_begin(void);
|
||||
|
||||
/* Adds a string field `key`: `val` to `row`. `val` is copied in full,
|
||||
* regardless of its length -- no field is ever truncated or dropped for
|
||||
* being "too long" (Requirement 5.3). A NULL `val` is stored as an
|
||||
* explicit JSON null rather than silently omitted or crashing. No-op if
|
||||
* `row` or `key` is NULL. */
|
||||
void listfmt_json_add_string(cJSON* row, const char* key, const char* val);
|
||||
|
||||
/* Adds a numeric field `key`: `val` to `row`. No-op if `row` or `key` is
|
||||
* NULL. */
|
||||
void listfmt_json_add_number(cJSON* row, const char* key, double val);
|
||||
|
||||
/* Creates a new, empty JSON object, appends it to `array`, and returns
|
||||
* that object so the caller can then call listfmt_json_add_string()/
|
||||
* listfmt_json_add_number() on it for each of this row's fields. Returns
|
||||
* NULL (leaving `array` unmodified) if `array` is NULL or the new object
|
||||
* could not be allocated. */
|
||||
cJSON* listfmt_json_new_row(cJSON* array);
|
||||
|
||||
/* Prints `array` to stdout as JSON -- command-RESULT data, per
|
||||
* Requirement 3's stdout/stderr discipline, so it is written to stdout
|
||||
* only, never stderr -- and then frees the entire cJSON structure
|
||||
* (`array` and every row/field it holds). `array` must not be
|
||||
* referenced again after this call. No-op if `array` is NULL. */
|
||||
void listfmt_json_finish(cJSON* array);
|
||||
113
src/harness/include/listrow_json.h
Normal file
113
src/harness/include/listrow_json.h
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
#pragma once
|
||||
/*
|
||||
* listrow_json.h -- pure per-command JSON-row field-mapping helpers for
|
||||
* cmd_reslist/cmd_workerlist/joblist_query's new --json output mode
|
||||
* (xce-test-cli-ux task 3.4, Requirements 5.1, 5.2, 5.3).
|
||||
*
|
||||
* Each function here takes one already-fetched record's fields --
|
||||
* already extracted as plain scalars by the caller, no xce/types.h struct
|
||||
* dependency, no RPC, no networking, no global session state -- and
|
||||
* populates one JSON row object (from listfmt_json_new_row(), task 2.2)
|
||||
* via listfmt_json.h's add_string()/add_number() primitives, mapping
|
||||
* EVERY column the corresponding command's human-readable table prints
|
||||
* (Requirement 5.1) with no truncation regardless of value length
|
||||
* (Requirement 5.3).
|
||||
*
|
||||
* Extracted out of test.c (rather than inlined in cmd_reslist/
|
||||
* cmd_workerlist/joblist_query alongside their RPC calls) specifically so
|
||||
* this field-mapping logic is unit-testable in isolation with plain
|
||||
* literal inputs -- test.c itself cannot be white-box #include'd into a
|
||||
* Unity test (thousands of lines, its own main(), heavy libedit/network
|
||||
* dependencies), and cmd_reslist/joblist_query's fetch+print bodies were
|
||||
* already found "genuinely untestable without a live cluster" for the
|
||||
* SAME reason during task 2.5's review. This mirrors joblist_display.c's
|
||||
* extraction precedent (task 2.5) for worker_status_str()/job_state_str()/
|
||||
* format_job_time() -- pure, dependency-light display formatting pulled
|
||||
* out specifically to get real automated coverage.
|
||||
*
|
||||
* The caller (test.c) remains responsible for: calling xcecli_reslist()/
|
||||
* xcecli_workerlist()/xcecli_joblist(), iterating the result, calling
|
||||
* listfmt_json_begin()/listfmt_json_new_row() around each call here, and
|
||||
* listfmt_json_finish() exactly once when done -- this module never
|
||||
* touches the array lifecycle itself (listfmt_json.h already owns that,
|
||||
* task 2.2, and is not modified by this task).
|
||||
*/
|
||||
|
||||
#include <stdint.h>
|
||||
#include <time.h>
|
||||
|
||||
#include <cjson/cJSON.h>
|
||||
|
||||
/* Maps cmd_reslist's table columns -- Name, Flags, Interval, Description,
|
||||
* see test.c's printf("%-10s%10s%10d %-64s\n", ...) row -- onto `row`.
|
||||
*
|
||||
* `flags` is emitted as a JSON NUMBER under the key "flags" (the raw
|
||||
* xce_resource_t.flags value), not the table's "0x%x"-formatted hex
|
||||
* string (Requirement 11.4's hex prefix is a display-only convention for
|
||||
* the human-readable table; the underlying integer value is identical
|
||||
* either way) -- a number already carries the same information the
|
||||
* table's Flags column shows, so nothing is lost relative to the table
|
||||
* (Requirement 5.1), and a script consuming the JSON output gets a
|
||||
* directly-usable integer instead of having to re-parse a "0x..." string.
|
||||
*
|
||||
* No-op if `row` is NULL (mirrors listfmt_json_add_string/number's own
|
||||
* NULL-row no-op contract). */
|
||||
void reslist_json_row(cJSON* row, const char* name, int flags, int interval,
|
||||
const char* desc);
|
||||
|
||||
/* Maps cmd_workerlist's table columns -- HostName, Status, HostType,
|
||||
* HostModel, CPUfactor, MaxCPU, MaxMem, MaxSwap, MaxTmp, NDisks -- onto
|
||||
* `row`.
|
||||
*
|
||||
* Unlike the table (which humanizes MaxMem/MaxSwap/MaxTmp via
|
||||
* format_bytes_human() for display, e.g. "64.0 MB"), this emits their RAW
|
||||
* numeric byte values (`maxmem`/`maxswap`/`maxtmp`, matching hostattrs_t's
|
||||
* own `double` field types in xce/types.h) as JSON numbers under keys
|
||||
* "maxmem"/"maxswap"/"maxtmp" -- the same underlying value the table's
|
||||
* humanized string represents, but directly script-consumable without
|
||||
* re-parsing a unit suffix.
|
||||
*
|
||||
* `status_str` is the caller's worker_status_str(workers[i]->status)
|
||||
* result (joblist_display.h) -- passed in rather than recomputed here, so
|
||||
* this module has no dependency on xce/types.h's XCE_WORKER_* bit
|
||||
* definitions.
|
||||
*
|
||||
* `cpufactor` is `double` (not truncated to float/int) so a genuine
|
||||
* fractional value -- not just a power-of-two -- round-trips exactly
|
||||
* through the JSON text (see test_listrow_json.c's fractional round-trip
|
||||
* test, closing the gap task 2.2's Implementation Notes flagged: its own
|
||||
* tests only exercised power-of-two doubles).
|
||||
*
|
||||
* No-op if `row` is NULL. */
|
||||
void workerlist_json_row(cJSON* row, const char* name, const char* status_str,
|
||||
const char* hosttype, const char* hostmodel,
|
||||
double cpufactor, int maxcpus, double maxmem,
|
||||
double maxswap, double maxtmp, int ndisks);
|
||||
|
||||
/* Maps joblist_query's table columns -- JobId, User, Queue, State,
|
||||
* SubmitTime, StartTime, FinishTime, MinTasks, MaxTasks, Command -- onto
|
||||
* `row`.
|
||||
*
|
||||
* `jobid_str` is the caller's fmt_jobid(...) result and `state_str` the
|
||||
* caller's job_state_str(jobs[i]->state) result (joblist_display.h) --
|
||||
* both passed in rather than recomputed here, keeping this module free of
|
||||
* test.c's own fmt_jobid()/RPC dependencies. `state_str` is stored
|
||||
* verbatim (including job_state_str()'s Requirement-11.7 raw-state
|
||||
* fallback text for an ambiguous RUN+EXIT state, e.g. "RUN(0x38)"), so no
|
||||
* information the table's State column carries is lost.
|
||||
*
|
||||
* Unlike the table (which formats SubmitTime/StartTime/FinishTime as
|
||||
* human-readable text via format_job_time(), Requirement 11.5), this
|
||||
* emits their RAW epoch-second values as JSON numbers under keys
|
||||
* "submit_time"/"start_time"/"finish_time" -- 0 meaning "has not happened
|
||||
* yet", the same convention format_job_time() itself uses for its "-"
|
||||
* display -- the same underlying value the table's formatted string
|
||||
* represents, directly script-consumable without re-parsing a date
|
||||
* string.
|
||||
*
|
||||
* No-op if `row` is NULL. */
|
||||
void joblist_json_row(cJSON* row, const char* jobid_str, const char* user,
|
||||
const char* queue, const char* state_str,
|
||||
time_t submit_time, time_t start_time,
|
||||
time_t finish_time, uint32_t mintasks,
|
||||
uint32_t maxtasks, const char* cmdline);
|
||||
39
src/harness/include/rcfile.h
Normal file
39
src/harness/include/rcfile.h
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
#pragma once
|
||||
/*
|
||||
* rcfile.h -- xce_test startup rc-file path resolution and line-loading
|
||||
* (xce-test-rc-file spec, tasks 1.1/1.2).
|
||||
*
|
||||
* A standalone, dependency-light module following the same pattern as
|
||||
* configcompare.c/h -- no dependency on test.c internals, linked into both
|
||||
* the xce_test executable and controller_unit_test.
|
||||
*
|
||||
* Task 1.1 implemented path resolution (rcfile_resolve_path()). Task 1.2
|
||||
* adds line-loading (rcfile_load()) with a pluggable runner callback.
|
||||
* Wiring rcfile_load() into xce_test's own startup/main() is a later task
|
||||
* in the same spec (2.2) and is out of scope here -- this module has no
|
||||
* knowledge of xce_test commands, contexts, or client state.
|
||||
*/
|
||||
|
||||
/* Resolves the rc-file path per the XDG Base Directory Specification:
|
||||
* "$XDG_CONFIG_HOME/xce_test/rc" when XDG_CONFIG_HOME is set and non-empty,
|
||||
* else "$HOME/.config/xce_test/rc" when HOME is set and non-empty. Returns
|
||||
* a heap string the caller must free(), or NULL if neither variable yields
|
||||
* a usable base directory. */
|
||||
char* rcfile_resolve_path(void);
|
||||
|
||||
/* Opens `path` and calls `runner(line)` once per line (trailing newline
|
||||
* stripped), stopping early if `runner` returns nonzero. Lines are read via
|
||||
* getline() with no length cap -- a line is never truncated or split
|
||||
* regardless of length.
|
||||
*
|
||||
* Returns:
|
||||
* 0 - path is NULL, the file does not exist (ENOENT),
|
||||
* the file could not be opened for another reason
|
||||
* (a warning is printed to stderr; non-fatal), or
|
||||
* every line ran and every runner call returned 0.
|
||||
* nonzero (== runner's) - the runner requested an abort; no further lines
|
||||
* are read.
|
||||
*
|
||||
* Preconditions: `runner` is non-NULL when `path` is non-NULL.
|
||||
* Postconditions: the file handle is always closed before returning. */
|
||||
int rcfile_load(const char* path, int (*runner)(char* line));
|
||||
53
src/harness/include/resval_typeresolve.h
Normal file
53
src/harness/include/resval_typeresolve.h
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
#ifndef RESVAL_TYPERESOLVE_H
|
||||
#define RESVAL_TYPERESOLVE_H
|
||||
|
||||
/* resval_typeresolve.h -- resource-value-verification task 1.1
|
||||
*
|
||||
* Extracted from test.c's set_resval(): pure resource-name -> wire-type
|
||||
* resolution logic, with NO networking, REPL, or global-state dependency
|
||||
* (unlike set_resval() itself, which also allocates xce_val_t and tags
|
||||
* .host via get_setting("myname") -- that stays in test.c since it needs
|
||||
* REPL state). This isolation is what makes the logic unit-testable outside
|
||||
* of test.c, which cannot be white-box #include'd into a Unity test (4298
|
||||
* lines, its own main(), heavy libedit/network dependencies).
|
||||
*
|
||||
* The resolved type is a Value_u_which tag (controller.capnp.h) -- e.g.
|
||||
* Value_u_fvalue for a numeric resource, Value_u_svalue for a string one.
|
||||
*/
|
||||
|
||||
#include "controller.capnp.h" /* enum Value_u_which */
|
||||
|
||||
/* Sentinel: not a valid Value_u_which value. Used both as:
|
||||
* - the RETURN value when `name` is not present in the known-resource
|
||||
* table and no override applies;
|
||||
* - the `type_override` INPUT value meaning "no override requested".
|
||||
*/
|
||||
#define RESVAL_TYPE_UNKNOWN (-1)
|
||||
|
||||
/* Resolve `name` to its Value_u_which wire type.
|
||||
*
|
||||
* `type_override`: pass a valid Value_u_which value (Value_u_fvalue,
|
||||
* Value_u_svalue, ...) to request that type for a name not in the known-
|
||||
* resource table, or RESVAL_TYPE_UNKNOWN for no override.
|
||||
*
|
||||
* Returns:
|
||||
* - the table's type, for any of the 14 currently-known resource names
|
||||
* (r15s, r1m, slots, ut, mem, maxcpus, ndisks, cpuf, maxswap, maxtmp,
|
||||
* type, model, verilog, dyna) -- `type_override` is ignored for a known
|
||||
* name, exactly as set_resval() behaves today.
|
||||
* - `type_override`, for any name NOT in the table, when `type_override`
|
||||
* is not RESVAL_TYPE_UNKNOWN (Requirement 1.1's `-t` escape hatch).
|
||||
* - Value_u_fvalue (numeric), for any name NOT in the table when no
|
||||
* override was requested (Requirement 1.1's default fallback).
|
||||
* - RESVAL_TYPE_UNKNOWN, only for `name` == NULL.
|
||||
*
|
||||
* *** TASK 2.1 STATUS (fallback implemented) ***
|
||||
* Task 1.1 was a byte-for-byte extraction of set_resval()'s existing
|
||||
* resinfo[] table lookup, with the unrecognized-name fallback left
|
||||
* unimplemented (RED tests in test_resval_typeresolve.c). Task 2.1 adds the
|
||||
* numeric-by-default fallback and `type_override` handling described above
|
||||
* -- behavior for all 14 known names remains byte-for-byte unchanged.
|
||||
*/
|
||||
int resval_resolve_type(const char* name, int type_override);
|
||||
|
||||
#endif /* RESVAL_TYPERESOLVE_H */
|
||||
43
src/harness/include/snapdiff.h
Normal file
43
src/harness/include/snapdiff.h
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
#pragma once
|
||||
/*
|
||||
* snapdiff.h — byte-level comparator for two checkpoint files
|
||||
* (bug-1389 triage: OPCode_snapshotTransfer chunk-content corruption).
|
||||
*
|
||||
* Compares a locally-generated source checkpoint (snap-*.capnp) against a
|
||||
* received one (snap-*.bin, ha_handle_snapshot_chunk) byte-for-byte, and
|
||||
* reports the first divergent offset plus whether it lands exactly on an
|
||||
* OPCode_snapshotTransfer chunk boundary (SNAPSHOT_CHUNK_SIZE, config_repl.c)
|
||||
* — the decisive signal separating "chunk transport corrupted this specific
|
||||
* chunk" from "the two files were never going to match" (different content
|
||||
* entirely, unrelated to chunking).
|
||||
*
|
||||
* Strictly read-only: opens both files O_RDONLY, mutates nothing.
|
||||
*/
|
||||
#include <stdio.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
/* Mirrors config_repl.c's SNAPSHOT_CHUNK_SIZE (the OPCode_snapshotTransfer
|
||||
* wire chunk size) — kept in sync manually; both are compile-time constants
|
||||
* for the same wire protocol, not expected to change independently. */
|
||||
#define SNAPDIFF_CHUNK_SIZE 65536
|
||||
|
||||
typedef struct {
|
||||
off_t size1;
|
||||
off_t size2;
|
||||
int size_mismatch; /* 1 if size1 != size2 (diff_offset unset in that case) */
|
||||
int identical; /* 1 if same size and byte-for-byte identical */
|
||||
off_t diff_offset; /* -1 if identical or size_mismatch; else first
|
||||
* differing byte offset */
|
||||
int at_chunk_boundary; /* 1 if diff_offset is the first byte of a
|
||||
* SNAPDIFF_CHUNK_SIZE-aligned chunk, i.e.
|
||||
* diff_offset % SNAPDIFF_CHUNK_SIZE == 0 */
|
||||
off_t chunk_index; /* diff_offset / SNAPDIFF_CHUNK_SIZE, valid only
|
||||
* when diff_offset >= 0 */
|
||||
} snapdiff_report_t;
|
||||
|
||||
/* Compare `path1` (source of truth) against `path2` (candidate, e.g. a
|
||||
* received copy). Prints a human-readable report to `out` (may be NULL) and
|
||||
* fills `*rep` (may be NULL). Returns 0 if identical, 1 if they differ (see
|
||||
* `*rep` for detail), or a negative errno on open/fstat failure. */
|
||||
int snapdiff_paths(const char* path1, const char* path2, FILE* out,
|
||||
snapdiff_report_t* rep);
|
||||
78
src/harness/include/snapverify.h
Normal file
78
src/harness/include/snapverify.h
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
#pragma once
|
||||
/*
|
||||
* snapverify.h — read-only checkpoint-file structural inspector/validator
|
||||
* (bug-1389 triage: OPCode_snapshotTransfer chunk-content corruption;
|
||||
* extended by config-upgrade-snapshot task 8.4, Requirement 4.2, for the
|
||||
* SNAPSHOT_FORMAT_VERSION=2 host + config-bytes sections and to decode --
|
||||
* not merely report the presence of -- the header's cfgref tag/hash).
|
||||
*
|
||||
* Parses the on-disk checkpoint layout persist.c's load_snapshot() /
|
||||
* load_snapshot_entities() use (design.md "Checkpoint File Layout") WITHOUT
|
||||
* restoring any job/queue/host into a live cluster_t — a pure structural
|
||||
* walk, so it needs no jobmgr/qmgr/workermgr context and is safe to run
|
||||
* standalone against a snap-*.capnp (locally generated) or snap-*.bin
|
||||
* (received via ha_handle_snapshot_chunk, ha.c) file on disk. This tool
|
||||
* validates STRUCTURE only, never content: job/queue/host frame payloads
|
||||
* and the config-bytes section are walked past via their length prefix,
|
||||
* never capnp-decoded or interpreted.
|
||||
*
|
||||
* Strictly read-only: opens O_RDONLY, mutates nothing.
|
||||
*/
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <sys/types.h>
|
||||
|
||||
typedef struct {
|
||||
uint64_t checkpoint_lsn;
|
||||
int has_cfgref; /* config-ref tail present in the header
|
||||
* (header[16] != 0) -- semantic presence,
|
||||
* distinct from cfgref_tag/cfgref_sha below
|
||||
* being decoded */
|
||||
uint64_t cfgref_tag; /* header cfgref tag, decoded whenever the
|
||||
* header extends to SNAP_HEADER_MAX bytes,
|
||||
* REGARDLESS of has_cfgref -- meaningful
|
||||
* only when has_cfgref is also 1 */
|
||||
uint8_t cfgref_sha[32]; /* header cfgref sha256, same decode-
|
||||
* unconditionally-but-gate-meaning-on-
|
||||
* has_cfgref discipline as cfgref_tag */
|
||||
int has_entity_section; /* fmt + job_count present past the header;
|
||||
* 0 = legacy position-only checkpoint */
|
||||
uint32_t format_version;
|
||||
uint32_t job_count; /* as declared in the file (0 if legacy) */
|
||||
uint32_t jobs_ok; /* job frames structurally walked before any failure */
|
||||
uint32_t queue_count; /* as declared in the file (0 if legacy, or if the
|
||||
* job-frame walk failed before reaching it) */
|
||||
uint32_t queues_ok; /* queue frames structurally walked before any failure */
|
||||
uint32_t host_count; /* as declared in the file (0 if legacy, or if an
|
||||
* earlier section's walk failed before reaching
|
||||
* it) */
|
||||
uint32_t hosts_ok; /* host frames structurally walked before any failure */
|
||||
int has_config_bytes_section; /* 1 iff the trailing config-bytes
|
||||
* section's 4-byte length field
|
||||
* itself was present and readable
|
||||
* (i.e. the file extends past the
|
||||
* host section at all) -- TRUE even
|
||||
* when that length decodes to 0, and
|
||||
* FALSE (not a failure) when the
|
||||
* file simply ends right after the
|
||||
* host section */
|
||||
uint32_t config_bytes_len; /* decoded length of the config-bytes
|
||||
* section; valid only when
|
||||
* has_config_bytes_section is 1 */
|
||||
off_t file_size;
|
||||
off_t consumed; /* bytes structurally accounted for */
|
||||
int ok; /* 1 = well-formed, consumed == file_size */
|
||||
off_t fail_offset; /* -1 if ok; else the byte offset of the field that
|
||||
* failed the structural check */
|
||||
char fail_reason[160];
|
||||
} snapverify_report_t;
|
||||
|
||||
/* Parse+validate `path` (a snap-*.capnp or snap-*.bin checkpoint file).
|
||||
* Prints a human-readable report to `out` (may be NULL to suppress) and
|
||||
* fills `*rep` (may be NULL). Returns 0 if the file is structurally
|
||||
* well-formed to EOF, or the same rc class persist_load_snapshot() would
|
||||
* return for the equivalent defect (-EINVAL bad magic / unrecognized
|
||||
* format_version, -EIO short read or a length field that doesn't fit the
|
||||
* remaining bytes, -errno on open/fstat failure) — so a "persist_load_snapshot
|
||||
* failed rc=..." log line correlates directly against this tool's verdict. */
|
||||
int snapverify_path(const char* path, FILE* out, snapverify_report_t* rep);
|
||||
105
src/harness/include/walcompare.h
Normal file
105
src/harness/include/walcompare.h
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
#pragma once
|
||||
/*
|
||||
* walcompare.h — cross-member WAL committed-prefix divergence detector
|
||||
* (ha-term-safety R6.1/R6.2/R6.3).
|
||||
*
|
||||
* Reuses the canonical waldump.c WAL decoder (via waldump_path_visit) to index
|
||||
* each member's log by `(lsn -> term, pcrc)`, then asserts that every member's
|
||||
* COMMITTED prefix is identical by `(term, pcrc)` — not merely by LSN range.
|
||||
*
|
||||
* The decisive signal for bug-851: two members can report the same committed
|
||||
* LSN over a contiguous, CRC-intact WAL yet hold DIFFERENT entries at the same
|
||||
* LSN (same lsn, different term and/or payload). Comparing LSN ranges alone
|
||||
* masks that; comparing `(term, pcrc)` per LSN surfaces it (R6.2).
|
||||
*
|
||||
* Strictly read-only — opens member WAL dirs O_RDONLY, mutates nothing. Safe to
|
||||
* run against a live cluster's data dirs.
|
||||
*/
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
|
||||
/* A single member's WAL source and its committed boundary. */
|
||||
typedef struct {
|
||||
const char* path; /* WAL dir / data dir / seg-*.wal (waldump rules) */
|
||||
uint64_t committed_lsn; /* highest committed LSN for this member; 0 =
|
||||
* "derive" (use the shared boundary, see below) */
|
||||
} walcompare_member_t;
|
||||
|
||||
/* Identity of the first divergent entry, filled on a WALCMP_DIVERGENT result. */
|
||||
typedef struct {
|
||||
uint64_t lsn; /* the diverging LSN within the committed prefix */
|
||||
int n_members; /* members compared at this LSN */
|
||||
uint64_t member_term[16]; /* per-member matched term at lsn */
|
||||
uint32_t member_pcrc[16]; /* per-member payload CRC at lsn */
|
||||
int member_present[16]; /* 1 if the member holds this LSN, else 0 */
|
||||
int committed; /* 1 = divergence within the committed prefix
|
||||
* (lsn <= committed boundary, a safety
|
||||
* violation); 0 = uncommitted tail divergence */
|
||||
} walcompare_divergence_t;
|
||||
|
||||
/* Result codes. */
|
||||
enum {
|
||||
WALCMP_OK = 0, /* all members' committed prefixes are identical */
|
||||
WALCMP_DIVERGENT = 1, /* a committed-prefix divergence was found */
|
||||
/* negative values are -errno (a load/usage error: missing WAL, bad args) */
|
||||
};
|
||||
|
||||
/* Compare `n` members' committed WAL prefixes by `(lsn -> term, pcrc)`.
|
||||
*
|
||||
* Committed boundary: each member with `committed_lsn == 0` adopts the SHARED
|
||||
* boundary = the minimum recovered tip (highest CRC-intact LSN) across all
|
||||
* members, i.e. the largest prefix every member can be expected to hold. A
|
||||
* member with an explicit `committed_lsn` uses that. An LSN in `[1..boundary]`
|
||||
* is compared only against the members whose own boundary covers it; a member
|
||||
* that is legitimately AHEAD (longer log) or behind the shared boundary is not
|
||||
* flagged for the uncommitted tail it doesn't share (R6.1 is the committed
|
||||
* prefix, not the full log).
|
||||
*
|
||||
* Per-member checkpoint floor (raft-ha-core-safety task 5.10a / ha-entity-
|
||||
* snapshots task 7.1): a member that caught up via a full-checkpoint snapshot
|
||||
* legitimately holds no raw WAL entries at or below its own installed
|
||||
* checkpoint LSN — that range was restored via entity content, not WAL
|
||||
* replay. Each member's floor is read independently from its own `snap/`
|
||||
* sibling directory (0 if none installed, the unchanged pre-existing
|
||||
* behavior); an LSN below a member's own floor is excluded from that
|
||||
* member's comparison the same way an LSN above its committed_lsn already
|
||||
* is — never flagged MISSING/divergent. This is a PER-MEMBER floor, never a
|
||||
* shared/uniform value (unlike committed_lsn, which the caller may force to
|
||||
* one shared value across all members).
|
||||
*
|
||||
* Legacy-zero term (R1.8) is mapped to WAL_LEGACY_TERM for matching, so two
|
||||
* pre-feature entries are not spuriously divergent.
|
||||
*
|
||||
* On the FIRST divergent LSN, fills `*div` (when non-NULL) and prints a report
|
||||
* to `out`, then returns WALCMP_DIVERGENT. Returns WALCMP_OK on identical
|
||||
* committed prefixes, or a negative errno on a load/usage error. `out` may be
|
||||
* NULL to suppress the report.
|
||||
*/
|
||||
int walcompare(const walcompare_member_t* members, int n, FILE* out,
|
||||
walcompare_divergence_t* div);
|
||||
|
||||
/* Same comparison as walcompare(), but ALSO emits a machine-checkable,
|
||||
* per-member report over the full log: one
|
||||
*
|
||||
* WALCMP member=<i> lsn=<L> term=<T> pcrc=<8hex> status=<present|MISSING|beyond> committed=<0|1>
|
||||
*
|
||||
* line per (member, LSN) for every LSN in [1..max-recovered-tip], where
|
||||
* `committed=1` iff the LSN is within the committed boundary. Each divergence
|
||||
* additionally emits a
|
||||
*
|
||||
* WALCMP_DIVERGENCE lsn=<L> class=<committed|uncommitted> compared=<c> present=<p>
|
||||
*
|
||||
* classification line. A `committed` divergence is fatal (verdict
|
||||
* WALCMP_DIVERGENT, `div->committed = 1`); an `uncommitted`-tail divergence
|
||||
* (above the committed boundary) is reported but NON-fatal — the verdict stays
|
||||
* WALCMP_OK when the committed prefix is identical. Field order is fixed and
|
||||
* the tokens are stable so the harness can grep them. `out` must be non-NULL.
|
||||
*
|
||||
* Backward-compatible: walcompare() keeps its original concise output (the
|
||||
* `== walcompare ==` header, the human divergence block, and the final
|
||||
* `walcompare: OK` verdict line) and additionally emits the machine
|
||||
* classification at a committed divergence; only walcompare_report() prints the
|
||||
* full per-LSN table and scans the uncommitted tail.
|
||||
*/
|
||||
int walcompare_report(const walcompare_member_t* members, int n, FILE* out,
|
||||
walcompare_divergence_t* div);
|
||||
76
src/harness/include/waldump.h
Normal file
76
src/harness/include/waldump.h
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
#pragma once
|
||||
/*
|
||||
* waldump.h — read-only WAL segment inspector for failover/durability triage.
|
||||
*
|
||||
* Decodes the on-disk WAL (`wal_entry_hdr_t` + payload, see wal_types.h),
|
||||
* lists every physical entry, decodes JOB_SUBMIT jobids via the Cap'n Proto
|
||||
* Job schema, and — the decisive signal for the committed-job-loss bug —
|
||||
* flags LSN gaps (a missing prefix, or any non-contiguous hole) and reports
|
||||
* the CRC-chain "recovered tip" a real recovery would stop at.
|
||||
*
|
||||
* Strictly read-only: opens segments O_RDONLY, applies no entries, mutates no
|
||||
* persist_t state. Safe to run against a live node's data dir.
|
||||
*/
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
|
||||
/* Aggregate result of scanning one path (a seg file, a wal/ dir, or a node
|
||||
* data dir). All counts are over the physically-present, CRC-valid entries. */
|
||||
typedef struct {
|
||||
uint64_t first_lsn; /* lowest LSN seen (0 = no entries) */
|
||||
uint64_t last_lsn; /* highest LSN seen */
|
||||
uint64_t recovered_tip; /* highest LSN reachable via an intact CRC chain */
|
||||
uint32_t n_entries; /* total entries scanned */
|
||||
uint32_t n_submits; /* WAL_JOB_SUBMIT entries */
|
||||
uint32_t n_gaps; /* LSN discontinuities (missing prefix counts as 1) */
|
||||
uint32_t n_segments; /* segment files scanned */
|
||||
uint32_t n_dup_submits; /* JOB_SUBMITs with same jobid as the immediately-
|
||||
* preceding entry at an adjacent LSN (bug-845) */
|
||||
uint32_t n_dup_identical; /* of n_dup_submits, those whose payload is
|
||||
* byte-identical to the prior entry */
|
||||
int crc_broken; /* nonzero if a CRC/chain break truncated a segment */
|
||||
int leading_gap_explained; /* nonzero if the leading (prefix) gap's
|
||||
* upper LSN is covered by a sibling
|
||||
* data/snap/snap-<hex>.capnp file — i.e.
|
||||
* normal checkpoint-triggered WAL trim
|
||||
* (persist.c trim_wal_segments), not
|
||||
* genuine loss. 0 if there was no leading
|
||||
* gap, or no covering snapshot was found. */
|
||||
} waldump_stats_t;
|
||||
|
||||
/* One physically-present, CRC-valid WAL entry, surfaced to a visitor so a
|
||||
* caller (e.g. walcompare) can index the log by `(lsn -> term, pcrc)` without
|
||||
* re-implementing the segment/CRC parse. `pcrc` is the payload-only CRC32 (0
|
||||
* for an empty payload) — the same fingerprint waldump prints, isolating the
|
||||
* blob from the header so a same-LSN content divergence is detectable. */
|
||||
typedef struct {
|
||||
uint64_t lsn;
|
||||
uint64_t term; /* raw on-disk term (legacy 0 kept verbatim) */
|
||||
uint32_t type; /* WAL entry type (wal_types.h) */
|
||||
uint32_t payload_len; /* payload byte count */
|
||||
uint32_t pcrc; /* CRC32 of payload only (0 if payload_len == 0) */
|
||||
} waldump_entry_t;
|
||||
|
||||
/* Per-entry visitor. Invoked once per CRC-valid entry in ascending scan order
|
||||
* (before the gap line is printed). Return 0 to continue, non-zero to stop the
|
||||
* scan early. `ctx` is the caller's opaque pointer. */
|
||||
typedef int (*waldump_visit_fn)(void* ctx, const waldump_entry_t* e);
|
||||
|
||||
/* Dump every WAL entry under `path` to `out` (human-readable) and fill
|
||||
* `*stats` when non-NULL. `path` may be:
|
||||
* - a single seg-*.wal file,
|
||||
* - a directory holding seg-*.wal files (a wal/ dir), or
|
||||
* - a node/data dir whose wal/ subdir (data/wal or wal) holds the segments.
|
||||
* Returns 0 on success, or a negative errno on a hard I/O error (e.g. the
|
||||
* path does not exist / no segments found). Gaps and CRC breaks are reported
|
||||
* through `out`/`stats`, NOT via the return code. */
|
||||
int waldump_path(const char* path, FILE* out, waldump_stats_t* stats);
|
||||
|
||||
/* Same as waldump_path, but also invokes `visit(ctx, &entry)` for every
|
||||
* CRC-valid entry so a caller can collect the `(lsn -> term, pcrc)` index.
|
||||
* `visit` may be NULL (then identical to waldump_path). `out` may be a sink
|
||||
* the caller does not care about (e.g. a /dev/null FILE) — the human dump is
|
||||
* still produced. The single canonical WAL decoder is reused; no second
|
||||
* segment parser. */
|
||||
int waldump_path_visit(const char* path, FILE* out, waldump_stats_t* stats,
|
||||
waldump_visit_fn visit, void* ctx);
|
||||
84
src/harness/include/xcetest_interrupt.h
Normal file
84
src/harness/include/xcetest_interrupt.h
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
#pragma once
|
||||
/*
|
||||
* xcetest_interrupt.h -- tiny, storage-detail-hiding surface over xce_test's
|
||||
* process-wide SIGINT handling (xce-test-cli-ux spec, task 1.3;
|
||||
* Requirement 9.9).
|
||||
*
|
||||
* ROOT CAUSE this task fixes: test.c's main() used to install its SIGINT
|
||||
* handler via the bare libc call `signal(SIGINT, sigbreak);`. glibc's
|
||||
* signal() uses BSD semantics, which install the handler with SA_RESTART
|
||||
* SET by default -- a slow syscall (read(), etc.) that is interrupted by
|
||||
* the signal is transparently RESTARTED by the kernel instead of
|
||||
* returning EINTR to the caller. That silently defeats Requirement 9.9:
|
||||
* a later task's confirmation-prompt read (confirm.c) needs a real Ctrl-C
|
||||
* to make its blocking read() return immediately with EINTR, not keep
|
||||
* waiting for input as if nothing happened. See research.md's "Decision:
|
||||
* Confirmation prompt's Ctrl-C-as-decline requires SA_RESTART cleared on
|
||||
* SIGINT" for the full rationale and alternatives considered.
|
||||
*
|
||||
* THE FIX: xcetest_install_sigint() below installs the given handler via
|
||||
* sigaction() with sa_flags = 0 (i.e. explicitly WITHOUT SA_RESTART) and
|
||||
* an empty sa_mask. test.c's main() now calls
|
||||
* `xcetest_install_sigint(sigbreak);` instead of the old signal() call.
|
||||
*
|
||||
* WHY THIS LIVES IN A HEADER, AS `static inline`, RATHER THAN AS A
|
||||
* NORMAL EXPORTED FUNCTION DEFINED IN test.c: test.c pulls in a large
|
||||
* dependency graph (libedit, cJSON, libxce, Cap'n Proto, OpenSSL) that
|
||||
* this project's Unity-based `controller_unit_test` binary does not
|
||||
* link, and test.c itself defines main() -- both make it impractical to
|
||||
* link test.c into a small, fast, signal-level unit test. Defining the
|
||||
* installer here means test.c's main() and this task's test
|
||||
* (test_xcetest_interrupt.c) call the EXACT SAME code, not two
|
||||
* hand-copies that could silently drift apart -- the test genuinely
|
||||
* proves what main() actually installs.
|
||||
*
|
||||
* xcetest_interrupted() is a small read-only accessor over test.c's
|
||||
* PRIVATE `static int break_out` flag (still declared/defined in test.c,
|
||||
* unmodified in shape or storage -- only this accessor is new), so code
|
||||
* outside test.c -- the Interruptible Call Adapter, `interruptcall.c`,
|
||||
* task 1.4 -- can poll whether a SIGINT has been observed without
|
||||
* depending on break_out's storage details.
|
||||
*
|
||||
* CONTRACT (task 1.4's implementer: read this before use):
|
||||
* - Returns nonzero once sigbreak() has run (i.e. a SIGINT was
|
||||
* delivered) since break_out was last 0; returns 0 otherwise.
|
||||
* - This is a PURE READ -- calling it does NOT clear break_out as a
|
||||
* side effect. It is safe to call any number of times from a polling
|
||||
* loop (e.g. "poll break_out every fifty milliseconds", per
|
||||
* design.md's Interruptible Call Adapter) without consuming the
|
||||
* interrupt.
|
||||
* - No reset/clear accessor is exposed yet. test.c itself already
|
||||
* resets break_out to 0 at one existing call site (cmd_hello's
|
||||
* repeat loop, immediately before that loop begins) by writing the
|
||||
* static directly, since it has that access. If task 1.4's design
|
||||
* needs an interruptible operation to start from a guaranteed-clear
|
||||
* flag and it runs from OUTSIDE test.c, it will need a new
|
||||
* xcetest_clear_interrupted()-style accessor -- none exists yet
|
||||
* because no task through 1.3 needed one; do not assume one is
|
||||
* silently available.
|
||||
*/
|
||||
|
||||
#include <signal.h>
|
||||
#include <string.h>
|
||||
|
||||
/* Installs `handler` as the process's SIGINT disposition via sigaction(),
|
||||
* with sa_flags = 0 (i.e. explicitly WITHOUT SA_RESTART) and an empty
|
||||
* sa_mask (no other signal is additionally blocked while `handler`
|
||||
* runs). This is the one and only installation path for xce_test's
|
||||
* SIGINT handling -- see the file header above for why a blocking slow
|
||||
* syscall must observe EINTR rather than a silent restart. */
|
||||
static inline void xcetest_install_sigint(void (*handler)(int))
|
||||
{
|
||||
struct sigaction sa;
|
||||
|
||||
memset(&sa, 0, sizeof(sa));
|
||||
sa.sa_handler = handler;
|
||||
sa.sa_flags = 0; /* explicitly WITHOUT SA_RESTART */
|
||||
sigemptyset(&sa.sa_mask);
|
||||
|
||||
sigaction(SIGINT, &sa, NULL);
|
||||
}
|
||||
|
||||
/* See "CONTRACT" in the file header above. Implemented in test.c, over
|
||||
* its private `static int break_out`. */
|
||||
int xcetest_interrupted(void);
|
||||
354
src/harness/interruptcall.c
Normal file
354
src/harness/interruptcall.c
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
#include "interruptcall.h"
|
||||
|
||||
#include <assert.h>
|
||||
#include <errno.h>
|
||||
#include <pthread.h>
|
||||
#include <semaphore.h>
|
||||
#include <stdlib.h>
|
||||
#include <time.h>
|
||||
|
||||
#include "xcetest_interrupt.h"
|
||||
|
||||
/* design.md's Interruptible Call Adapter component: poll interval is a
|
||||
* fixed 50ms, per the component's own Implementation Notes ("poll
|
||||
* break_out every fifty milliseconds"). Fast enough that an operator's
|
||||
* Ctrl-C feels immediate; slow enough not to spin the calling thread. */
|
||||
#define XCETEST_POLL_INTERVAL_NS (50L * 1000L * 1000L) /* 50ms */
|
||||
|
||||
/* The heap-allocated call block backing one xcetest_run_interruptible()
|
||||
* call. `state` is the single atomic word whose CAS transitions decide
|
||||
* who frees this block -- see interruptcall.h's xcetest_call_state_t
|
||||
* comment and xcetest_run_interruptible()'s own header comment for the
|
||||
* full protocol. `lock` is the per-connection lock (task 1.5) this call
|
||||
* acquired before spawning its worker; whichever side -- caller or
|
||||
* worker -- ends up owning the block also ends up owning the job of
|
||||
* releasing `lock`, per the same protocol. It is a sem_t*, not a
|
||||
* pthread_mutex_t* -- see interruptcall.h's "WHY THE LOCK IS A POSIX
|
||||
* SEMAPHORE" section for why: the release below is a genuine cross-
|
||||
* thread handoff, which sem_t (unlike pthread_mutex_t, of any type)
|
||||
* defines cleanly. */
|
||||
typedef struct {
|
||||
xcetest_blocking_fn fn;
|
||||
void* ctx;
|
||||
int rc;
|
||||
sem_t* lock;
|
||||
_Atomic xcetest_call_state_t state;
|
||||
} xcetest_call_block_t;
|
||||
|
||||
#ifdef XCE_TESTING
|
||||
_Atomic int xcetest_test_abandoned_free_count = 0;
|
||||
#endif
|
||||
|
||||
/* --- Per-connection lock registry (task 1.5) ---
|
||||
*
|
||||
* See interruptcall.h's "PER-CONNECTION LOCK REGISTRY" and "WHY THE LOCK
|
||||
* IS A POSIX SEMAPHORE" file-header sections for the full rationale,
|
||||
* including why this is a singly-linked list (pointer stability for a
|
||||
* sem_t* a still-running abandoned worker may hold onto indefinitely)
|
||||
* rather than a growable array, and why each entry is a sem_t rather
|
||||
* than a pthread_mutex_t.
|
||||
*/
|
||||
typedef struct xcetest_conn_lock_node {
|
||||
void* key;
|
||||
sem_t sem;
|
||||
struct xcetest_conn_lock_node* next;
|
||||
} xcetest_conn_lock_node_t;
|
||||
|
||||
/* Only ever read/written from the single REPL thread that issues
|
||||
* top-level commands -- see interruptcall.h's "CONCURRENCY OF THE
|
||||
* REGISTRY ITSELF" note. Neither variable needs its own lock. */
|
||||
static xcetest_conn_lock_node_t* g_conn_lock_registry = NULL;
|
||||
static void* g_active_connection = NULL;
|
||||
|
||||
void xcetest_set_active_connection(void* conn_key)
|
||||
{
|
||||
g_active_connection = conn_key;
|
||||
}
|
||||
|
||||
/* Looks up (by pointer equality) or lazily creates the lock entry for
|
||||
* `key` in the process-lifetime registry. Returns NULL only if the new
|
||||
* node's allocation fails. Nodes are prepended to the list and NEVER
|
||||
* freed or moved for the rest of the process's life, so a pointer this
|
||||
* function has already returned to a caller stays valid forever,
|
||||
* regardless of how many later, unrelated connections are subsequently
|
||||
* registered. */
|
||||
static sem_t* xcetest_conn_lock_for(void* key)
|
||||
{
|
||||
xcetest_conn_lock_node_t* node;
|
||||
|
||||
for (node = g_conn_lock_registry; node; node = node->next) {
|
||||
if (node->key == key)
|
||||
return &node->sem;
|
||||
}
|
||||
|
||||
node = malloc(sizeof(*node));
|
||||
if (!node)
|
||||
return NULL;
|
||||
|
||||
node->key = key;
|
||||
/* A binary semaphore (initial value 1), used as a mutual-exclusion
|
||||
* lock, NOT a pthread_mutex_t: this design's own ownership-handoff
|
||||
* protocol (see xcetest_run_interruptible()'s and
|
||||
* xcetest_worker_main()'s comments) REQUIRES a cross-thread release
|
||||
* on the abandonment path -- the calling thread acquires the lock,
|
||||
* but the still-running WORKER thread is the one that releases it
|
||||
* once fn() finishes, since ownership of "release the lock" passed
|
||||
* to the worker at the same moment ownership of the call block did.
|
||||
* A pthread_mutex_t (ERRORCHECK/_RECURSIVE, or even the DEFAULT/
|
||||
* NORMAL type) is the wrong primitive for this: POSIX classifies an
|
||||
* unlock from a non-owning thread as undefined behavior for every
|
||||
* mutex type -- ERRORCHECK/_RECURSIVE are simply *required* to
|
||||
* detect and report it (which is why an earlier version of this code
|
||||
* hit an immediate EPERM here), while NORMAL/DEFAULT are merely
|
||||
* *permitted* not to detect it, not declared well-defined for it.
|
||||
* sem_t has no ownership concept at all -- sem_wait()/sem_trywait()/
|
||||
* sem_post() are fully defined regardless of which thread calls
|
||||
* which -- so it is the textbook-correct, portable primitive for
|
||||
* this exact cross-thread signal, with no UB reliance whatsoever.
|
||||
* pshared=0 since this registry is intra-process only. */
|
||||
sem_init(&node->sem, 0, 1);
|
||||
|
||||
node->next = g_conn_lock_registry;
|
||||
g_conn_lock_registry = node;
|
||||
return &node->sem;
|
||||
}
|
||||
|
||||
/* Runs on the detached worker thread. Invokes fn(ctx), stores its result,
|
||||
* then resolves ownership of `block` via the RUNNING -> DONE_UNCLAIMED
|
||||
* CAS: on success, the caller (still polling, or about to read the result
|
||||
* of its own successful poll) owns the block from here and this function
|
||||
* must not touch it again. On failure, the caller has already forced
|
||||
* RUNNING -> ABANDONED and returned -EINTR without touching the block
|
||||
* again -- this thread now owns it and frees it. */
|
||||
static void* xcetest_worker_main(void* arg)
|
||||
{
|
||||
xcetest_call_block_t* block = (xcetest_call_block_t*)arg;
|
||||
xcetest_call_state_t expected;
|
||||
sem_t* lock = block->lock;
|
||||
|
||||
block->rc = block->fn(block->ctx);
|
||||
|
||||
expected = XCT_RUNNING;
|
||||
if (atomic_compare_exchange_strong(&block->state, &expected,
|
||||
XCT_DONE_UNCLAIMED)) {
|
||||
/* Won the CAS: the caller still owns the block AND the
|
||||
* per-connection lock. Do not touch either again from this
|
||||
* thread. */
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* CAS failed. The caller is the only other party that ever writes
|
||||
* block->state, and the only value it ever writes is XCT_ABANDONED --
|
||||
* so `expected` (and the block's actual state) can only be
|
||||
* XCT_ABANDONED here. The caller returned -EINTR without touching
|
||||
* the block or the lock again; this thread now owns both. Free the
|
||||
* block (and record the test-only counter) BEFORE releasing the
|
||||
* lock, so that any other thread waiting on this same connection's
|
||||
* lock only ever observes it become available once this cleanup has
|
||||
* already fully happened. */
|
||||
free(block);
|
||||
#ifdef XCE_TESTING
|
||||
atomic_fetch_add(&xcetest_test_abandoned_free_count, 1);
|
||||
#endif
|
||||
{
|
||||
/* sem_post(), unlike pthread_mutex_unlock(), is well-defined from
|
||||
* any thread regardless of which thread called sem_wait()/
|
||||
* sem_trywait() -- see interruptcall.h's "WHY THE LOCK IS A
|
||||
* POSIX SEMAPHORE" section. This cross-thread release is exactly
|
||||
* the case that primitive was chosen for. */
|
||||
int post_rc = sem_post(lock);
|
||||
(void)post_rc; /* checked via assert() below, never inside it --
|
||||
* an assert() argument's side effects vanish
|
||||
* under NDEBUG, which must never apply to the
|
||||
* post call itself. NOTE: this project's
|
||||
* `release` build profile compiles with -DNDEBUG
|
||||
* (unlike asan/debug), so this assert() -- like
|
||||
* every other one in this file -- compiles out
|
||||
* entirely there; it is a debug/asan-only safety
|
||||
* net, not a guarantee in a release-profile
|
||||
* xce_test binary. */
|
||||
assert(post_rc == 0);
|
||||
}
|
||||
return NULL;
|
||||
}
|
||||
|
||||
/* Sleeps one poll interval. A short nanosleep() legitimately interrupted
|
||||
* early by a delivered signal (e.g. SIGINT, once xcetest_interrupt.h's
|
||||
* SA_RESTART-cleared installation is in effect) just means the next
|
||||
* poll-loop iteration happens slightly sooner than 50ms -- harmless, so
|
||||
* the return value is deliberately ignored. */
|
||||
static void xcetest_sleep_poll_interval(void)
|
||||
{
|
||||
struct timespec ts;
|
||||
ts.tv_sec = 0;
|
||||
ts.tv_nsec = XCETEST_POLL_INTERVAL_NS;
|
||||
nanosleep(&ts, NULL);
|
||||
}
|
||||
|
||||
int xcetest_run_interruptible(xcetest_blocking_fn fn, void* ctx)
|
||||
{
|
||||
xcetest_call_block_t* block;
|
||||
sem_t* lock;
|
||||
pthread_attr_t attr;
|
||||
pthread_t worker;
|
||||
int rc;
|
||||
|
||||
if (!fn)
|
||||
return -EINVAL;
|
||||
|
||||
lock = xcetest_conn_lock_for(g_active_connection);
|
||||
if (!lock)
|
||||
return -ENOMEM;
|
||||
|
||||
/* Interruptible lock acquisition (task 1.5): poll sem_trywait() on
|
||||
* the same interval used for the completion-wait poll below,
|
||||
* checking xcetest_interrupted() between attempts. A plain
|
||||
* sem_wait() would block uninterruptibly, defeating the whole point
|
||||
* of letting a second call on this same connection be Ctrl-C'd out
|
||||
* of waiting for a prior abandoned call to release it.
|
||||
* sem_trywait() returns -1/EAGAIN when the semaphore is currently
|
||||
* at 0 (held) -- the direct equivalent of pthread_mutex_trylock()'s
|
||||
* EBUSY for this poll loop's purposes. If interrupted here, this
|
||||
* call never acquires the lock and never spawns a worker -- there
|
||||
* is nothing to release. */
|
||||
for (;;) {
|
||||
if (sem_trywait(lock) == 0)
|
||||
break; /* acquired */
|
||||
if (errno != EAGAIN)
|
||||
return -errno; /* unexpected sem_trywait() error; nothing held */
|
||||
|
||||
if (xcetest_interrupted())
|
||||
return -EINTR; /* never acquired; nothing to release */
|
||||
|
||||
xcetest_sleep_poll_interval();
|
||||
}
|
||||
|
||||
block = malloc(sizeof(*block));
|
||||
if (!block) {
|
||||
rc = sem_post(lock);
|
||||
(void)rc;
|
||||
assert(rc == 0);
|
||||
return -ENOMEM;
|
||||
}
|
||||
|
||||
block->fn = fn;
|
||||
block->ctx = ctx;
|
||||
block->rc = 0;
|
||||
block->lock = lock;
|
||||
atomic_init(&block->state, XCT_RUNNING);
|
||||
|
||||
rc = pthread_attr_init(&attr);
|
||||
if (rc != 0) {
|
||||
free(block);
|
||||
{
|
||||
/* NOTE: this assert(), like every other one in this file,
|
||||
* compiles out under -DNDEBUG (this project's `release`
|
||||
* build profile) -- a documented debug/asan-only safety net,
|
||||
* not a release-build guarantee. */
|
||||
int post_rc = sem_post(lock);
|
||||
(void)post_rc;
|
||||
assert(post_rc == 0);
|
||||
}
|
||||
return -rc;
|
||||
}
|
||||
/* Detached: no one ever joins this thread. That is precisely why
|
||||
* ownership of `block` (and of `lock`) must be resolved by the CAS
|
||||
* protocol below rather than by a join -- an abandoned caller
|
||||
* returns without waiting for this thread at all. */
|
||||
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
|
||||
|
||||
rc = pthread_create(&worker, &attr, xcetest_worker_main, block);
|
||||
pthread_attr_destroy(&attr);
|
||||
if (rc != 0) {
|
||||
/* The worker never started, so fn() was never invoked and this
|
||||
* thread unambiguously still owns the block, and the lock, by
|
||||
* itself. */
|
||||
free(block);
|
||||
{
|
||||
/* NDEBUG caveat: see the pthread_attr_init() failure branch
|
||||
* above. */
|
||||
int post_rc = sem_post(lock);
|
||||
(void)post_rc;
|
||||
assert(post_rc == 0);
|
||||
}
|
||||
return -rc;
|
||||
}
|
||||
|
||||
for (;;) {
|
||||
xcetest_call_state_t state = atomic_load(&block->state);
|
||||
|
||||
if (state == XCT_DONE_UNCLAIMED) {
|
||||
int result = block->rc;
|
||||
free(block);
|
||||
{
|
||||
/* NDEBUG caveat: see the pthread_attr_init() failure
|
||||
* branch above. */
|
||||
int post_rc = sem_post(lock);
|
||||
(void)post_rc;
|
||||
assert(post_rc == 0);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (xcetest_interrupted()) {
|
||||
xcetest_call_state_t expected = XCT_RUNNING;
|
||||
if (atomic_compare_exchange_strong(&block->state, &expected,
|
||||
XCT_ABANDONED)) {
|
||||
/* Won the CAS: the worker (still running fn()) will see
|
||||
* ABANDONED via its own failed CAS once fn() returns,
|
||||
* free the block, and release `lock` itself. Must not
|
||||
* touch `block` or `lock` again from here. */
|
||||
return -EINTR;
|
||||
}
|
||||
/* CAS failed: the worker is the only other party that ever
|
||||
* writes block->state, and the only value it ever writes is
|
||||
* XCT_DONE_UNCLAIMED -- so the block's actual state can only
|
||||
* be XCT_DONE_UNCLAIMED here. The worker won the race right
|
||||
* as the interrupt was noticed; fall through (via the loop's
|
||||
* next iteration) to the normal completion path above
|
||||
* instead of returning -EINTR, since the call genuinely
|
||||
* finished. */
|
||||
continue;
|
||||
}
|
||||
|
||||
xcetest_sleep_poll_interval();
|
||||
}
|
||||
}
|
||||
|
||||
/* See interruptcall.h for the full contract. Reuses xcetest_conn_lock_for()
|
||||
* -- the SAME lazy-lookup/create used by xcetest_run_interruptible() above
|
||||
* -- rather than a parallel registry, so this genuinely observes the same
|
||||
* per-connection semaphore any in-flight or abandoned worker on `conn_key`
|
||||
* is holding. */
|
||||
void xcetest_wait_connection_idle(void* conn_key)
|
||||
{
|
||||
sem_t* lock;
|
||||
int rc;
|
||||
|
||||
lock = xcetest_conn_lock_for(conn_key);
|
||||
if (!lock) {
|
||||
/* Allocation failure in the lazy-create path: this key has never
|
||||
* been registered, so by definition no worker could be holding a
|
||||
* lock for it via this registry. Nothing to wait for. */
|
||||
return;
|
||||
}
|
||||
|
||||
/* Plain BLOCKING wait, deliberately not the interruptible
|
||||
* sem_trywait()-poll loop used above -- see interruptcall.h's
|
||||
* "CONTRACT" section on this function. A signal delivered while
|
||||
* blocked here can legitimately interrupt sem_wait() with EINTR even
|
||||
* though nothing else ever posts spuriously; retry rather than treat
|
||||
* that as "acquired" or bail out early, since either would defeat
|
||||
* this function's entire purpose (proving the wait genuinely
|
||||
* completed before the caller proceeds to tear the connection down). */
|
||||
do {
|
||||
rc = sem_wait(lock);
|
||||
} while ((rc != 0) && (errno == EINTR));
|
||||
assert(rc == 0); /* NDEBUG caveat: see xcetest_worker_main()'s comment. */
|
||||
|
||||
/* Immediately release: the semantic is "prove idle right now," not
|
||||
* "hold the connection locked." */
|
||||
rc = sem_post(lock);
|
||||
(void)rc;
|
||||
assert(rc == 0);
|
||||
}
|
||||
99
src/harness/joblist_display.c
Normal file
99
src/harness/joblist_display.c
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/* joblist_display.c -- xce-test-cli-ux task 2.5 remediation (bug-1711 fixes)
|
||||
*
|
||||
* See joblist_display.h for the full rationale. Verbatim extraction of
|
||||
* test.c's worker_status_str()/job_state_str()/format_job_time() -- no
|
||||
* behavior change from the pre-extraction versions.
|
||||
*/
|
||||
#include "joblist_display.h"
|
||||
|
||||
#include <stdio.h> /* snprintf */
|
||||
|
||||
#include "ctrlstatus_fmt.h" /* ctrlstatus_format_time_ms */
|
||||
#include "xce/types.h" /* XCE_WORKER_*, JOB_STAT_* bit definitions */
|
||||
|
||||
char* worker_status_str(uint32_t status)
|
||||
{
|
||||
/* Admin-set CLOSED takes priority over the dynamic UNAVAIL bit: the
|
||||
* config-upgrade-snapshot soak (task 8.6) needs to assert a host's
|
||||
* administratively-set status specifically, and a host closed via
|
||||
* hostctrl that later disconnects (e.g. its xce_test `worker`/`join`
|
||||
* connection exits after the script ends) would otherwise print only
|
||||
* "UNAVAIL", masking the admin bit this check exists to verify. */
|
||||
if ((status & XCE_WORKER_CLOSED) != 0) {
|
||||
return "CLOSED";
|
||||
} else if ((status & XCE_WORKER_UNAVAIL) != 0) {
|
||||
return "UNAVAIL";
|
||||
} else if ((status & XCE_WORKER_LOCKEDU) != 0) {
|
||||
return "LOCKU";
|
||||
} else if ((status & XCE_WORKER_LOCKEDW) != 0) {
|
||||
return "LOCKW";
|
||||
} else if ((status & XCE_WORKER_BUSY) != 0) {
|
||||
return "BUSY";
|
||||
} else if (status == 0) {
|
||||
return "OK";
|
||||
}
|
||||
|
||||
return "UNKNOWN";
|
||||
}
|
||||
|
||||
char* job_state_str(int state)
|
||||
{
|
||||
/* bug-1711 (Requirement 11.7): raw-state fallback mirroring
|
||||
* print_jobinfo's existing state_raw guard against the same ambiguity
|
||||
* (see print_jobinfo's comment above its "state_raw: 0x%x" print) -- a
|
||||
* job that is simultaneously RUN and EXIT (e.g. a worker-reported FAIL
|
||||
* received without a prior FINISH; apply_job_fail_transition ORs in
|
||||
* EXIT|PERR without clearing RUN) would otherwise silently print as
|
||||
* plain "RUN" via the if/else-if priority ordering below, hiding the
|
||||
* EXIT bit. Unlike print_jobinfo (which always has room for a separate
|
||||
* state_raw line), job_state_str's result is a single cmd_joblist table
|
||||
* cell, so the raw state is folded into the same string only for this
|
||||
* specific ambiguous case. */
|
||||
static char ambiguous_buf[32];
|
||||
|
||||
if ((state & JOB_STAT_PEND) != 0) {
|
||||
if ((state & JOB_STAT_PSUSP) != 0) {
|
||||
return "PSUSP";
|
||||
}
|
||||
|
||||
return "PEND";
|
||||
} else if ((state & JOB_STAT_RUN) != 0) {
|
||||
if ((state & JOB_STAT_USUSP) != 0) {
|
||||
return "USUSP";
|
||||
} else if ((state & JOB_STAT_SSUSP) != 0) {
|
||||
return "SSUSP";
|
||||
} else if ((state & JOB_STAT_EXIT) != 0) {
|
||||
snprintf(ambiguous_buf, sizeof(ambiguous_buf), "RUN(0x%x)", state);
|
||||
return ambiguous_buf;
|
||||
}
|
||||
return "RUN";
|
||||
} else if ((state & JOB_STAT_EXIT) != 0) {
|
||||
return "EXIT";
|
||||
} else if ((state & JOB_STAT_DONE) != 0) {
|
||||
return "DONE";
|
||||
} else if ((state & JOB_STAT_ERR) != 0) {
|
||||
return "ERROR";
|
||||
}
|
||||
|
||||
return "UNKNOWN";
|
||||
}
|
||||
|
||||
/* bug-1711 (Requirement 11.5): format a job time_t field (epoch seconds) using
|
||||
* ctrlstatus_fmt.h's ctrlstatus_format_time_ms human-readable formatter
|
||||
* (which takes epoch milliseconds), instead of printing the raw epoch-seconds
|
||||
* value directly as cmd_joblist did before. A job time of 0 means the
|
||||
* corresponding event has not happened yet (e.g. an unstarted job's
|
||||
* start_time/finish_time) -- print "-" rather than a misleading 1970-01-01
|
||||
* epoch date. */
|
||||
const char* format_job_time(time_t t, char* buf, size_t bufsize)
|
||||
{
|
||||
if (t == 0) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
if (ctrlstatus_format_time_ms((uint64_t)t * 1000u, buf, bufsize) == NULL) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return buf;
|
||||
}
|
||||
110
src/harness/join_worker_args.c
Normal file
110
src/harness/join_worker_args.c
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
/* join_worker_args.c -- xce-test-cli-ux task 2.6 remediation (bug-1557 fix)
|
||||
*
|
||||
* See join_worker_args.h for the full extraction rationale. Verbatim
|
||||
* extraction of test.c's cmd_join_worker() parsing/defaulting block -- no
|
||||
* behavior change from the pre-extraction version.
|
||||
*/
|
||||
#include "join_worker_args.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h> /* getopt_long, optarg, optind */
|
||||
|
||||
#include "flagspec.h" /* shared flagspec_t descriptor-table flag parsing +
|
||||
* uniform rejection (xce-test-cli-ux tasks 1.1/1.2),
|
||||
* migrated onto by task 3.1 */
|
||||
|
||||
/* Every flag's existing short letter, accepted value, and default are
|
||||
* unchanged by this migration (Requirement 6.3) -- only a long-form
|
||||
* equivalent and uniform rejection of an unrecognized/malformed flag are
|
||||
* added. Long names reuse the same concept spellings used elsewhere in
|
||||
* xce_test for the identical real-world concepts (e.g. "host"/"jobid" in
|
||||
* test.c's own migrated commands); the ones here (cpufactor, maxcpus,
|
||||
* maxmem, maxswap, maxtmp, ndisks) are unique to `join` itself. */
|
||||
const flagspec_t join_flags[] = {
|
||||
{ 'f', "cpufactor", "cpufactor", required_argument, 0, "1.0" },
|
||||
{ 'c', "maxcpus", "maxcpus", required_argument, 0, "4" },
|
||||
{ 'm', "maxmem", "maxmem", required_argument, 0,
|
||||
"64 * 1024 * 1024 (64 MB in bytes)" },
|
||||
{ 's', "maxswap", "maxswap", required_argument, 0, "1024" },
|
||||
{ 't', "maxtmp", "maxtmp", required_argument, 0, "2048" },
|
||||
{ 'd', "ndisks", "ndisks", required_argument, 0, "1" },
|
||||
};
|
||||
/* JOIN_NFLAGS is an internal alias of the header's JOIN_FLAGS_COUNT (a
|
||||
* compile-time literal, not sizeof()-derived) -- see join_worker_args.h's
|
||||
* doc comment for why. The array declaration above and the header's
|
||||
* `extern const flagspec_t join_flags[JOIN_FLAGS_COUNT];` must agree on
|
||||
* size, so this stays a hard build failure, not a silent drift, if a
|
||||
* future edit changes the entry count here without updating the header. */
|
||||
#define JOIN_NFLAGS JOIN_FLAGS_COUNT
|
||||
|
||||
int join_worker_parse_attrs(int argc, char* argv[], hostattrs_t* attrs)
|
||||
{
|
||||
int opt;
|
||||
struct option long_opts[JOIN_NFLAGS + 1];
|
||||
char optstring[16];
|
||||
|
||||
if (attrs == NULL) {
|
||||
return -EINVAL;
|
||||
}
|
||||
|
||||
if (flagspec_build_opts(join_flags, JOIN_NFLAGS, long_opts, optstring,
|
||||
sizeof(optstring))
|
||||
< 0) {
|
||||
fprintf(stderr, "xce_test: join: internal error building flag table\n");
|
||||
return -EINVAL;
|
||||
}
|
||||
|
||||
/* bug-1557: maxmem is bytes-canonical (see resources.yaml, hardware_
|
||||
* monitor.c's total_memory) -- this default used to read plain "64"
|
||||
* back when maxmem was MB-scale (64 MB); an earlier session converted
|
||||
* the codebase's canonical unit to bytes but missed this test-tool
|
||||
* default, leaving it at a nonsensical 64 BYTES. Expressed here as
|
||||
* 64 * 1024 * 1024 (64MB in bytes) to preserve the original intent,
|
||||
* matching this file's own byte-constant convention (see test.c's
|
||||
* main() `logsize = 64 * 1024 * 1024`). */
|
||||
attrs->cpufactor = 1.0;
|
||||
attrs->maxcpus = 4;
|
||||
attrs->maxmem = 64 * 1024 * 1024;
|
||||
attrs->maxswap = 1024;
|
||||
attrs->maxtmp = 2048;
|
||||
attrs->ndisks = 1;
|
||||
|
||||
attrs->hostmodel = "Intel64";
|
||||
attrs->hosttype = "LINUX64";
|
||||
|
||||
optind = 1;
|
||||
opterr = 0;
|
||||
|
||||
while ((opt = getopt_long(argc, argv, optstring, long_opts, NULL))
|
||||
!= -1) {
|
||||
switch (opt) {
|
||||
case 'f':
|
||||
attrs->cpufactor = atof(optarg);
|
||||
break;
|
||||
case 'c':
|
||||
attrs->maxcpus = atoi(optarg);
|
||||
break;
|
||||
case 'm':
|
||||
attrs->maxmem = atoi(optarg);
|
||||
break;
|
||||
case 's':
|
||||
attrs->maxswap = atoi(optarg);
|
||||
break;
|
||||
case 't':
|
||||
attrs->maxtmp = atoi(optarg);
|
||||
break;
|
||||
case 'd':
|
||||
attrs->ndisks = atoi(optarg);
|
||||
break;
|
||||
case '?':
|
||||
return flagspec_reject(
|
||||
"join", join_flags, JOIN_NFLAGS, argv[optind - 1],
|
||||
"join [-f <cpufactor>] [-c <maxcpus>] [-m <maxmem>] "
|
||||
"[-s <maxswap>] [-t <maxtmp>] [-d <ndisks>]");
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
59
src/harness/listfmt_json.c
Normal file
59
src/harness/listfmt_json.c
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
#include "listfmt_json.h"
|
||||
|
||||
#include <stdio.h>
|
||||
|
||||
cJSON* listfmt_json_begin(void)
|
||||
{
|
||||
return cJSON_CreateArray();
|
||||
}
|
||||
|
||||
void listfmt_json_add_string(cJSON* row, const char* key, const char* val)
|
||||
{
|
||||
if (!row || !key)
|
||||
return;
|
||||
|
||||
if (val)
|
||||
cJSON_AddStringToObject(row, key, val);
|
||||
else
|
||||
cJSON_AddNullToObject(row, key);
|
||||
}
|
||||
|
||||
void listfmt_json_add_number(cJSON* row, const char* key, double val)
|
||||
{
|
||||
if (!row || !key)
|
||||
return;
|
||||
|
||||
cJSON_AddNumberToObject(row, key, val);
|
||||
}
|
||||
|
||||
cJSON* listfmt_json_new_row(cJSON* array)
|
||||
{
|
||||
if (!array)
|
||||
return NULL;
|
||||
|
||||
cJSON* row = cJSON_CreateObject();
|
||||
if (!row)
|
||||
return NULL;
|
||||
|
||||
if (!cJSON_AddItemToArray(array, row)) {
|
||||
cJSON_Delete(row);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
void listfmt_json_finish(cJSON* array)
|
||||
{
|
||||
if (!array)
|
||||
return;
|
||||
|
||||
char* text = cJSON_Print(array);
|
||||
if (text) {
|
||||
fputs(text, stdout);
|
||||
fputc('\n', stdout);
|
||||
cJSON_free(text);
|
||||
}
|
||||
|
||||
cJSON_Delete(array);
|
||||
}
|
||||
62
src/harness/listrow_json.c
Normal file
62
src/harness/listrow_json.c
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
/* listrow_json.c -- xce-test-cli-ux task 3.4 (Requirements 5.1, 5.2, 5.3)
|
||||
*
|
||||
* See listrow_json.h for the full rationale. Pure field-mapping only: each
|
||||
* function below just calls listfmt_json_add_string()/add_number() (task
|
||||
* 2.2, unmodified) once per table column.
|
||||
*/
|
||||
#include "listrow_json.h"
|
||||
|
||||
#include "listfmt_json.h"
|
||||
|
||||
void reslist_json_row(cJSON* row, const char* name, int flags, int interval,
|
||||
const char* desc)
|
||||
{
|
||||
if (!row)
|
||||
return;
|
||||
|
||||
listfmt_json_add_string(row, "name", name);
|
||||
listfmt_json_add_number(row, "flags", flags);
|
||||
listfmt_json_add_number(row, "interval", interval);
|
||||
listfmt_json_add_string(row, "description", desc);
|
||||
}
|
||||
|
||||
void workerlist_json_row(cJSON* row, const char* name, const char* status_str,
|
||||
const char* hosttype, const char* hostmodel,
|
||||
double cpufactor, int maxcpus, double maxmem,
|
||||
double maxswap, double maxtmp, int ndisks)
|
||||
{
|
||||
if (!row)
|
||||
return;
|
||||
|
||||
listfmt_json_add_string(row, "hostname", name);
|
||||
listfmt_json_add_string(row, "status", status_str);
|
||||
listfmt_json_add_string(row, "hosttype", hosttype);
|
||||
listfmt_json_add_string(row, "hostmodel", hostmodel);
|
||||
listfmt_json_add_number(row, "cpufactor", cpufactor);
|
||||
listfmt_json_add_number(row, "maxcpu", maxcpus);
|
||||
listfmt_json_add_number(row, "maxmem", maxmem);
|
||||
listfmt_json_add_number(row, "maxswap", maxswap);
|
||||
listfmt_json_add_number(row, "maxtmp", maxtmp);
|
||||
listfmt_json_add_number(row, "ndisks", ndisks);
|
||||
}
|
||||
|
||||
void joblist_json_row(cJSON* row, const char* jobid_str, const char* user,
|
||||
const char* queue, const char* state_str,
|
||||
time_t submit_time, time_t start_time,
|
||||
time_t finish_time, uint32_t mintasks,
|
||||
uint32_t maxtasks, const char* cmdline)
|
||||
{
|
||||
if (!row)
|
||||
return;
|
||||
|
||||
listfmt_json_add_string(row, "jobid", jobid_str);
|
||||
listfmt_json_add_string(row, "user", user);
|
||||
listfmt_json_add_string(row, "queue", queue);
|
||||
listfmt_json_add_string(row, "state", state_str);
|
||||
listfmt_json_add_number(row, "submit_time", (double)submit_time);
|
||||
listfmt_json_add_number(row, "start_time", (double)start_time);
|
||||
listfmt_json_add_number(row, "finish_time", (double)finish_time);
|
||||
listfmt_json_add_number(row, "mintasks", mintasks);
|
||||
listfmt_json_add_number(row, "maxtasks", maxtasks);
|
||||
listfmt_json_add_string(row, "command", cmdline);
|
||||
}
|
||||
56
src/harness/meson.build
Normal file
56
src/harness/meson.build
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
# TEMPORARY (xce-harness-component task 1.1): '../controller/testsuite' is
|
||||
# only here so the bare `#include "ctrlstatus_fmt.h"` in test.c/
|
||||
# joblist_display.c keeps resolving -- ctrlstatus_fmt.h itself still
|
||||
# physically lives at src/controller/testsuite/ctrlstatus_fmt.h pending
|
||||
# task 1.2, which promotes it to src/include/xce/ctrlstatus_fmt.h and
|
||||
# repoints these two #include lines at it. Remove this entry when task 1.2
|
||||
# lands (it will make it redundant, since src/include is already on this
|
||||
# path via '../include').
|
||||
incdir = include_directories('.', '../include', '../lib', 'include',
|
||||
'../controller/testsuite')
|
||||
|
||||
zlib_dep = dependency('zlib')
|
||||
|
||||
# BSD-licensed readline replacement (scheduler-core spec, task 12.1 review
|
||||
# finding): GNU readline is GPL; xce_test's REPL only needs readline()/
|
||||
# add_history(), both provided by libedit's editline/readline.h emulation
|
||||
# header under a permissive license. Single dependency, no separate
|
||||
# ncurses/history split (libedit's own .pc pulls ncurses in privately).
|
||||
libedit_dep = dependency('libedit')
|
||||
ssl_dep = dependency('openssl') # also needed by configcompare.c (SHA256())
|
||||
|
||||
xce_test_src = [
|
||||
'test.c',
|
||||
'waldump.c',
|
||||
'walcompare.c',
|
||||
'snapverify.c',
|
||||
'snapdiff.c',
|
||||
'configcompare.c',
|
||||
'resval_typeresolve.c',
|
||||
'joblist_display.c',
|
||||
'join_worker_args.c',
|
||||
'rcfile.c',
|
||||
'flagspec.c',
|
||||
'interruptcall.c',
|
||||
'confirm.c',
|
||||
'listfmt_json.c',
|
||||
'listrow_json.c',
|
||||
]
|
||||
|
||||
xce_test_deps = [
|
||||
libxce_dep,
|
||||
librtti_dep,
|
||||
struct2json_dep,
|
||||
libedit_dep,
|
||||
tinylog_dep,
|
||||
zlib_dep, # waldump: crc32 WAL validation
|
||||
c_capnproto_dep, # waldump: decode JOB_SUBMIT jobid
|
||||
ssl_dep, # configcompare: SHA256() active-config hashing
|
||||
thread_dep, # interruptcall.c: xcetest_run_interruptible() detached worker (task 1.4)
|
||||
]
|
||||
|
||||
xce_test = executable('xce_test',
|
||||
xce_test_src,
|
||||
include_directories: [incdir],
|
||||
dependencies: xce_test_deps,
|
||||
install: true)
|
||||
80
src/harness/rcfile.c
Normal file
80
src/harness/rcfile.c
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
#define _GNU_SOURCE
|
||||
#include "rcfile.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/* Suffixes appended to the resolved base directory to form the rc-file
|
||||
* path -- kept as two separate constants (rather than one shared tail)
|
||||
* since the XDG_CONFIG_HOME branch is already rooted at a "config home"
|
||||
* directory while the HOME fallback still needs the "/.config" segment. */
|
||||
#define RCFILE_XDG_SUFFIX "/xce_test/rc"
|
||||
#define RCFILE_HOME_SUFFIX "/.config/xce_test/rc"
|
||||
|
||||
char* rcfile_resolve_path(void)
|
||||
{
|
||||
const char* xdg_config_home = getenv("XDG_CONFIG_HOME");
|
||||
if (xdg_config_home && *xdg_config_home) {
|
||||
size_t len = strlen(xdg_config_home) + strlen(RCFILE_XDG_SUFFIX) + 1;
|
||||
char* path = (char*)malloc(len);
|
||||
if (!path)
|
||||
return NULL;
|
||||
snprintf(path, len, "%s%s", xdg_config_home, RCFILE_XDG_SUFFIX);
|
||||
return path;
|
||||
}
|
||||
|
||||
const char* home = getenv("HOME");
|
||||
if (home && *home) {
|
||||
size_t len = strlen(home) + strlen(RCFILE_HOME_SUFFIX) + 1;
|
||||
char* path = (char*)malloc(len);
|
||||
if (!path)
|
||||
return NULL;
|
||||
snprintf(path, len, "%s%s", home, RCFILE_HOME_SUFFIX);
|
||||
return path;
|
||||
}
|
||||
|
||||
return NULL;
|
||||
}
|
||||
|
||||
int rcfile_load(const char* path, int (*runner)(char* line))
|
||||
{
|
||||
if (!path)
|
||||
return 0;
|
||||
|
||||
FILE* f = fopen(path, "r");
|
||||
if (!f) {
|
||||
if (errno == ENOENT)
|
||||
return 0; /* missing rc file: silent no-op success */
|
||||
fprintf(stderr, "xce_test: warning: could not open rc file %s: %s\n",
|
||||
path, strerror(errno));
|
||||
return 0; /* present but unreadable: non-fatal warning */
|
||||
}
|
||||
|
||||
int rc = 0;
|
||||
char* line = NULL;
|
||||
size_t cap = 0;
|
||||
ssize_t n;
|
||||
while ((n = getline(&line, &cap, f)) != -1) {
|
||||
if (n > 0 && line[n - 1] == '\n')
|
||||
line[n - 1] = '\0';
|
||||
rc = runner(line);
|
||||
if (rc != 0)
|
||||
break;
|
||||
}
|
||||
|
||||
/* fopen() succeeds on a directory on this platform -- the failure only
|
||||
* surfaces on the first read (getline() returns -1 with errno==EISDIR
|
||||
* and ferror(f) set, feof(f) NOT set). Treat that the same as an open
|
||||
* failure: one non-fatal stderr warning, no runner calls attributed to
|
||||
* it (rc is still 0 here since no line was ever successfully read). */
|
||||
if (rc == 0 && ferror(f)) {
|
||||
fprintf(stderr, "xce_test: warning: could not read rc file %s: %s\n",
|
||||
path, strerror(errno));
|
||||
}
|
||||
|
||||
free(line);
|
||||
fclose(f);
|
||||
return rc;
|
||||
}
|
||||
57
src/harness/resval_typeresolve.c
Normal file
57
src/harness/resval_typeresolve.c
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
/* resval_typeresolve.c -- resource-value-verification task 1.1
|
||||
*
|
||||
* See resval_typeresolve.h for the full rationale and TASK 1.1 STATUS note.
|
||||
*/
|
||||
#include <string.h>
|
||||
|
||||
#include "resval_typeresolve.h"
|
||||
|
||||
int resval_resolve_type(const char* name, int type_override)
|
||||
{
|
||||
/* Verbatim extraction of set_resval()'s resinfo[] table (test.c),
|
||||
* unchanged: type/model are Value_u_svalue; everything else here is
|
||||
* Value_u_fvalue. */
|
||||
static const struct {
|
||||
const char* name;
|
||||
int valtype;
|
||||
} resinfo[] = {
|
||||
{ "r15s", Value_u_fvalue },
|
||||
{ "r1m", Value_u_fvalue },
|
||||
{ "slots", Value_u_fvalue },
|
||||
{ "ut", Value_u_fvalue },
|
||||
{ "mem", Value_u_fvalue },
|
||||
{ "maxcpus", Value_u_fvalue },
|
||||
{ "ndisks", Value_u_fvalue },
|
||||
{ "cpuf", Value_u_fvalue },
|
||||
{ "maxswap", Value_u_fvalue },
|
||||
{ "maxtmp", Value_u_fvalue },
|
||||
{ "type", Value_u_svalue },
|
||||
{ "model", Value_u_svalue },
|
||||
{ "verilog", Value_u_fvalue },
|
||||
{ "dyna", Value_u_fvalue },
|
||||
{ NULL, 0 }
|
||||
};
|
||||
int i;
|
||||
|
||||
if (name == NULL) {
|
||||
return RESVAL_TYPE_UNKNOWN;
|
||||
}
|
||||
|
||||
for (i = 0; resinfo[i].name != NULL; i++) {
|
||||
if (strcmp(resinfo[i].name, name) == 0) {
|
||||
/* Known name: exact existing behavior, type_override ignored
|
||||
* (Requirement 1.4). */
|
||||
return resinfo[i].valtype;
|
||||
}
|
||||
}
|
||||
|
||||
/* task 2.1: unrecognized name -- Requirement 1.1's fallback. Default to
|
||||
* numeric (Value_u_fvalue) unless the caller passed an explicit
|
||||
* type_override (any valid Value_u_which tag other than the
|
||||
* RESVAL_TYPE_UNKNOWN sentinel), in which case honor the override. */
|
||||
if (type_override != RESVAL_TYPE_UNKNOWN) {
|
||||
return type_override;
|
||||
}
|
||||
|
||||
return Value_u_fvalue;
|
||||
}
|
||||
113
src/harness/snapdiff.c
Normal file
113
src/harness/snapdiff.c
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
#define _GNU_SOURCE
|
||||
#include "snapdiff.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <stdint.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#define SNAPDIFF_BUF_SIZE 65536
|
||||
|
||||
int snapdiff_paths(const char* path1, const char* path2, FILE* out,
|
||||
snapdiff_report_t* rep)
|
||||
{
|
||||
if (!path1 || !path2)
|
||||
return -EINVAL;
|
||||
|
||||
snapdiff_report_t local;
|
||||
memset(&local, 0, sizeof(local));
|
||||
local.diff_offset = -1;
|
||||
|
||||
int fd1 = open(path1, O_RDONLY);
|
||||
if (fd1 < 0) {
|
||||
int saved = errno;
|
||||
if (out)
|
||||
fprintf(out, "snapdiff: %s: %s\n", path1, strerror(saved));
|
||||
return -saved;
|
||||
}
|
||||
int fd2 = open(path2, O_RDONLY);
|
||||
if (fd2 < 0) {
|
||||
int saved = errno;
|
||||
close(fd1);
|
||||
if (out)
|
||||
fprintf(out, "snapdiff: %s: %s\n", path2, strerror(saved));
|
||||
return -saved;
|
||||
}
|
||||
|
||||
struct stat st1, st2;
|
||||
if (fstat(fd1, &st1) != 0 || fstat(fd2, &st2) != 0) {
|
||||
int saved = errno;
|
||||
close(fd1);
|
||||
close(fd2);
|
||||
if (out)
|
||||
fprintf(out, "snapdiff: fstat failed: %s\n", strerror(saved));
|
||||
return -saved;
|
||||
}
|
||||
local.size1 = st1.st_size;
|
||||
local.size2 = st2.st_size;
|
||||
|
||||
if (st1.st_size != st2.st_size) {
|
||||
local.size_mismatch = 1;
|
||||
close(fd1);
|
||||
close(fd2);
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out,
|
||||
"snapdiff: %s (%jd bytes) vs %s (%jd bytes): SIZE MISMATCH\n",
|
||||
path1, (intmax_t)local.size1, path2, (intmax_t)local.size2);
|
||||
return 1;
|
||||
}
|
||||
|
||||
uint8_t buf1[SNAPDIFF_BUF_SIZE];
|
||||
uint8_t buf2[SNAPDIFF_BUF_SIZE];
|
||||
off_t off = 0;
|
||||
int found_diff = 0;
|
||||
|
||||
for (;;) {
|
||||
ssize_t n1 = read(fd1, buf1, sizeof(buf1));
|
||||
ssize_t n2 = read(fd2, buf2, sizeof(buf2));
|
||||
if (n1 <= 0 || n2 <= 0)
|
||||
break;
|
||||
ssize_t n = (n1 < n2) ? n1 : n2;
|
||||
for (ssize_t i = 0; i < n; i++) {
|
||||
if (buf1[i] != buf2[i]) {
|
||||
local.diff_offset = off + (off_t)i;
|
||||
found_diff = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found_diff)
|
||||
break;
|
||||
off += (off_t)n;
|
||||
}
|
||||
close(fd1);
|
||||
close(fd2);
|
||||
|
||||
if (!found_diff) {
|
||||
local.identical = 1;
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out, "snapdiff: %s vs %s: IDENTICAL (%jd bytes)\n", path1,
|
||||
path2, (intmax_t)local.size1);
|
||||
return 0;
|
||||
}
|
||||
|
||||
local.chunk_index = local.diff_offset / SNAPDIFF_CHUNK_SIZE;
|
||||
local.at_chunk_boundary = (local.diff_offset % SNAPDIFF_CHUNK_SIZE == 0);
|
||||
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out,
|
||||
"snapdiff: %s vs %s: DIFFER at offset %jd (chunk %jd%s)\n",
|
||||
path1, path2, (intmax_t)local.diff_offset,
|
||||
(intmax_t)local.chunk_index,
|
||||
local.at_chunk_boundary
|
||||
? " -- exactly at a chunk boundary, first byte of chunk"
|
||||
: " -- NOT at a chunk boundary, mid-chunk");
|
||||
return 1;
|
||||
}
|
||||
417
src/harness/snapverify.c
Normal file
417
src/harness/snapverify.c
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
#define _GNU_SOURCE
|
||||
#include "snapverify.h"
|
||||
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <inttypes.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* Mirrors persist.c's SNAPSHOT_FORMAT_VERSION / header layout exactly — see
|
||||
* persist.c write_snapshot() (~line 1758) and load_snapshot() (~line 2325).
|
||||
* config-upgrade-snapshot task 1 bumped persist.c's SNAPSHOT_FORMAT_VERSION
|
||||
* 1->2 (host/queue admin-state frames migrated to capnp, Req 1.9/5.7) --
|
||||
* this MUST track that bump, or this tool hard-rejects every real
|
||||
* checkpoint the current codebase writes (task 8.4 finding). */
|
||||
#define SNAP_FORMAT_VERSION 2u
|
||||
#define SNAP_HEADER_MAX (16 + 1 + 8 + 32) /* magic+lsn + optional cfgref tail */
|
||||
|
||||
static void fail(snapverify_report_t* rep, int rc, off_t off, const char* why)
|
||||
{
|
||||
rep->ok = 0;
|
||||
rep->fail_offset = off;
|
||||
snprintf(rep->fail_reason, sizeof(rep->fail_reason), "%s", why);
|
||||
(void)rc;
|
||||
}
|
||||
|
||||
int snapverify_path(const char* path, FILE* out, snapverify_report_t* rep)
|
||||
{
|
||||
if (!path)
|
||||
return -EINVAL;
|
||||
|
||||
snapverify_report_t local;
|
||||
memset(&local, 0, sizeof(local));
|
||||
local.ok = 1;
|
||||
local.fail_offset = -1;
|
||||
|
||||
int fd = open(path, O_RDONLY);
|
||||
if (fd < 0) {
|
||||
int saved = errno;
|
||||
fail(&local, -saved, -1, strerror(saved));
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out, "snapverify: %s: %s\n", path, strerror(saved));
|
||||
return -saved;
|
||||
}
|
||||
|
||||
struct stat st;
|
||||
if (fstat(fd, &st) != 0) {
|
||||
int saved = errno;
|
||||
close(fd);
|
||||
fail(&local, -saved, -1, strerror(saved));
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out, "snapverify: %s: fstat: %s\n", path, strerror(saved));
|
||||
return -saved;
|
||||
}
|
||||
local.file_size = st.st_size;
|
||||
|
||||
char header[SNAP_HEADER_MAX];
|
||||
ssize_t n = read(fd, header, sizeof(header));
|
||||
if (n < 16) {
|
||||
close(fd);
|
||||
fail(&local, -EIO, 0, "short read on header (need >= 16 bytes)");
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out, "snapverify: %s: %s\n", path, local.fail_reason);
|
||||
return -EIO;
|
||||
}
|
||||
|
||||
if (memcmp(header, "XCESNAP\0", 8) != 0) {
|
||||
close(fd);
|
||||
fail(&local, -EINVAL, 0, "bad magic (not XCESNAP)");
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out, "snapverify: %s: %s\n", path, local.fail_reason);
|
||||
return -EINVAL;
|
||||
}
|
||||
|
||||
memcpy(&local.checkpoint_lsn, header + 8, 8);
|
||||
if (n >= SNAP_HEADER_MAX) {
|
||||
/* Decode the tag/hash UNCONDITIONALLY whenever the header extends
|
||||
* this far, regardless of the presence byte -- so a caller can
|
||||
* inspect the raw bytes even in edge cases. has_cfgref keeps its
|
||||
* existing meaning (whether those bytes are semantically
|
||||
* meaningful) untouched. */
|
||||
memcpy(&local.cfgref_tag, header + 17, 8);
|
||||
memcpy(local.cfgref_sha, header + 25, 32);
|
||||
if (header[16] != 0)
|
||||
local.has_cfgref = 1;
|
||||
}
|
||||
|
||||
off_t consumed = (off_t)n;
|
||||
|
||||
if (st.st_size <= consumed) {
|
||||
/* Legacy, position-only checkpoint — not a defect (design.md). */
|
||||
close(fd);
|
||||
local.consumed = consumed;
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out,
|
||||
"snapverify: %s: OK (legacy position-only, lsn=%" PRIu64
|
||||
", size=%jd)\n",
|
||||
path, local.checkpoint_lsn, (intmax_t)local.file_size);
|
||||
return 0;
|
||||
}
|
||||
|
||||
uint32_t fmt = 0;
|
||||
if (read(fd, &fmt, sizeof(fmt)) != (ssize_t)sizeof(fmt)) {
|
||||
close(fd);
|
||||
fail(&local, -EIO, consumed, "short read on format_version");
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out, "snapverify: %s: %s (offset %jd)\n", path,
|
||||
local.fail_reason, (intmax_t)consumed);
|
||||
return -EIO;
|
||||
}
|
||||
consumed += (off_t)sizeof(fmt);
|
||||
local.has_entity_section = 1;
|
||||
local.format_version = fmt;
|
||||
|
||||
if (fmt != SNAP_FORMAT_VERSION) {
|
||||
close(fd);
|
||||
fail(&local, -EINVAL, consumed - (off_t)sizeof(fmt),
|
||||
"unrecognized format_version");
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out, "snapverify: %s: %s (got %u, expected %u, offset %jd)\n",
|
||||
path, local.fail_reason, fmt, SNAP_FORMAT_VERSION,
|
||||
(intmax_t)local.fail_offset);
|
||||
return -EINVAL;
|
||||
}
|
||||
|
||||
uint32_t job_count = 0;
|
||||
if (read(fd, &job_count, sizeof(job_count)) != (ssize_t)sizeof(job_count)) {
|
||||
close(fd);
|
||||
fail(&local, -EIO, consumed, "short read on job_count");
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out, "snapverify: %s: %s (offset %jd)\n", path,
|
||||
local.fail_reason, (intmax_t)consumed);
|
||||
return -EIO;
|
||||
}
|
||||
consumed += (off_t)sizeof(job_count);
|
||||
local.job_count = job_count;
|
||||
|
||||
for (uint32_t i = 0; i < job_count; i++) {
|
||||
off_t len_off = consumed;
|
||||
uint32_t plen = 0;
|
||||
if (read(fd, &plen, sizeof(plen)) != (ssize_t)sizeof(plen)) {
|
||||
close(fd);
|
||||
fail(&local, -EIO, len_off, "short read on job payload_len");
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out, "snapverify: %s: job %u: %s (offset %jd)\n", path,
|
||||
i, local.fail_reason, (intmax_t)len_off);
|
||||
return -EIO;
|
||||
}
|
||||
consumed += (off_t)sizeof(plen);
|
||||
|
||||
if (plen == 0 || (off_t)plen > st.st_size - consumed) {
|
||||
close(fd);
|
||||
char why[160];
|
||||
snprintf(why, sizeof(why),
|
||||
"bad payload_len=%u (remaining=%jd)", plen,
|
||||
(intmax_t)(st.st_size - consumed));
|
||||
fail(&local, -EIO, len_off, why);
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out, "snapverify: %s: job %u: %s (length field at "
|
||||
"offset %jd)\n",
|
||||
path, i, local.fail_reason, (intmax_t)len_off);
|
||||
return -EIO;
|
||||
}
|
||||
|
||||
if (lseek(fd, plen, SEEK_CUR) < 0) {
|
||||
close(fd);
|
||||
fail(&local, -EIO, consumed, "seek past job payload failed");
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out, "snapverify: %s: job %u: %s\n", path, i,
|
||||
local.fail_reason);
|
||||
return -EIO;
|
||||
}
|
||||
consumed += (off_t)plen;
|
||||
local.jobs_ok++;
|
||||
}
|
||||
|
||||
uint32_t queue_count = 0;
|
||||
if (read(fd, &queue_count, sizeof(queue_count)) !=
|
||||
(ssize_t)sizeof(queue_count)) {
|
||||
close(fd);
|
||||
fail(&local, -EIO, consumed, "short read on queue_count");
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out, "snapverify: %s: %s (offset %jd)\n", path,
|
||||
local.fail_reason, (intmax_t)consumed);
|
||||
return -EIO;
|
||||
}
|
||||
consumed += (off_t)sizeof(queue_count);
|
||||
local.queue_count = queue_count;
|
||||
|
||||
for (uint32_t i = 0; i < queue_count; i++) {
|
||||
off_t len_off = consumed;
|
||||
uint32_t plen = 0;
|
||||
if (read(fd, &plen, sizeof(plen)) != (ssize_t)sizeof(plen)) {
|
||||
close(fd);
|
||||
fail(&local, -EIO, len_off, "short read on queue payload_len");
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out, "snapverify: %s: queue %u: %s (offset %jd)\n",
|
||||
path, i, local.fail_reason, (intmax_t)len_off);
|
||||
return -EIO;
|
||||
}
|
||||
consumed += (off_t)sizeof(plen);
|
||||
|
||||
if (plen == 0 || (off_t)plen > st.st_size - consumed) {
|
||||
close(fd);
|
||||
char why[160];
|
||||
snprintf(why, sizeof(why),
|
||||
"bad payload_len=%u (remaining=%jd)", plen,
|
||||
(intmax_t)(st.st_size - consumed));
|
||||
fail(&local, -EIO, len_off, why);
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out, "snapverify: %s: queue %u: %s (length field at "
|
||||
"offset %jd)\n",
|
||||
path, i, local.fail_reason, (intmax_t)len_off);
|
||||
return -EIO;
|
||||
}
|
||||
|
||||
if (lseek(fd, plen, SEEK_CUR) < 0) {
|
||||
close(fd);
|
||||
fail(&local, -EIO, consumed, "seek past queue payload failed");
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out, "snapverify: %s: queue %u: %s\n", path, i,
|
||||
local.fail_reason);
|
||||
return -EIO;
|
||||
}
|
||||
consumed += (off_t)plen;
|
||||
local.queues_ok++;
|
||||
}
|
||||
|
||||
/* Host section (config-upgrade-snapshot task 4/8.4, SNAPSHOT_FORMAT_
|
||||
* VERSION=2): new after the queue section (design.md "Checkpoint File
|
||||
* Layout", persist.c load_snapshot_entities()/write_snapshot()). Same
|
||||
* [payload_len][bytes] per-frame structural shape as the job/queue
|
||||
* loops above -- this tool validates structure only, never decodes the
|
||||
* capnp payload. */
|
||||
uint32_t host_count = 0;
|
||||
if (read(fd, &host_count, sizeof(host_count)) !=
|
||||
(ssize_t)sizeof(host_count)) {
|
||||
close(fd);
|
||||
fail(&local, -EIO, consumed, "short read on host_count");
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out, "snapverify: %s: %s (offset %jd)\n", path,
|
||||
local.fail_reason, (intmax_t)consumed);
|
||||
return -EIO;
|
||||
}
|
||||
consumed += (off_t)sizeof(host_count);
|
||||
local.host_count = host_count;
|
||||
|
||||
for (uint32_t i = 0; i < host_count; i++) {
|
||||
off_t len_off = consumed;
|
||||
uint32_t plen = 0;
|
||||
if (read(fd, &plen, sizeof(plen)) != (ssize_t)sizeof(plen)) {
|
||||
close(fd);
|
||||
fail(&local, -EIO, len_off, "short read on host payload_len");
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out, "snapverify: %s: host %u: %s (offset %jd)\n",
|
||||
path, i, local.fail_reason, (intmax_t)len_off);
|
||||
return -EIO;
|
||||
}
|
||||
consumed += (off_t)sizeof(plen);
|
||||
|
||||
if (plen == 0 || (off_t)plen > st.st_size - consumed) {
|
||||
close(fd);
|
||||
char why[160];
|
||||
snprintf(why, sizeof(why),
|
||||
"bad payload_len=%u (remaining=%jd)", plen,
|
||||
(intmax_t)(st.st_size - consumed));
|
||||
fail(&local, -EIO, len_off, why);
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out, "snapverify: %s: host %u: %s (length field at "
|
||||
"offset %jd)\n",
|
||||
path, i, local.fail_reason, (intmax_t)len_off);
|
||||
return -EIO;
|
||||
}
|
||||
|
||||
if (lseek(fd, plen, SEEK_CUR) < 0) {
|
||||
close(fd);
|
||||
fail(&local, -EIO, consumed, "seek past host payload failed");
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out, "snapverify: %s: host %u: %s\n", path, i,
|
||||
local.fail_reason);
|
||||
return -EIO;
|
||||
}
|
||||
consumed += (off_t)plen;
|
||||
local.hosts_ok++;
|
||||
}
|
||||
|
||||
/* Config-bytes section (config-upgrade-snapshot task 2.3/8.4): a
|
||||
* single flat length-prefixed blob appended LAST, after the host
|
||||
* section -- NOT a [count][frame...] shape like job/queue/host above,
|
||||
* since there is exactly one active configuration, never a set
|
||||
* (persist.c write_snapshot()). The length field itself may be
|
||||
* legitimately ABSENT (a file that ends right after the host section
|
||||
* is still a valid, if slightly older, fmt=2-consistent shape) --
|
||||
* distinguish that from the length field being present-but-zero
|
||||
* (persist.c explicitly writes an empty-but-present section when no
|
||||
* local config bytes exist yet) and from a genuinely truncated length
|
||||
* field (a short, non-zero partial read -- real corruption). */
|
||||
uint32_t cfg_bytes_len = 0;
|
||||
ssize_t cfg_len_n = read(fd, &cfg_bytes_len, sizeof(cfg_bytes_len));
|
||||
if (cfg_len_n == 0) {
|
||||
/* Clean EOF right after the host section -- no config-bytes
|
||||
* section present at all. Not a defect. */
|
||||
local.has_config_bytes_section = 0;
|
||||
} else if (cfg_len_n != (ssize_t)sizeof(cfg_bytes_len)) {
|
||||
close(fd);
|
||||
fail(&local, -EIO, consumed, "short read on config_bytes_len");
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out, "snapverify: %s: %s (offset %jd)\n", path,
|
||||
local.fail_reason, (intmax_t)consumed);
|
||||
return -EIO;
|
||||
} else {
|
||||
off_t len_off = consumed;
|
||||
consumed += (off_t)sizeof(cfg_bytes_len);
|
||||
local.has_config_bytes_section = 1;
|
||||
local.config_bytes_len = cfg_bytes_len;
|
||||
|
||||
if ((off_t)cfg_bytes_len > st.st_size - consumed) {
|
||||
close(fd);
|
||||
char why[160];
|
||||
snprintf(why, sizeof(why),
|
||||
"bad config_bytes_len=%u (remaining=%jd)", cfg_bytes_len,
|
||||
(intmax_t)(st.st_size - consumed));
|
||||
fail(&local, -EIO, len_off, why);
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out, "snapverify: %s: %s (length field at offset "
|
||||
"%jd)\n",
|
||||
path, local.fail_reason, (intmax_t)len_off);
|
||||
return -EIO;
|
||||
}
|
||||
|
||||
if (cfg_bytes_len > 0) {
|
||||
if (lseek(fd, cfg_bytes_len, SEEK_CUR) < 0) {
|
||||
close(fd);
|
||||
fail(&local, -EIO, consumed,
|
||||
"seek past config bytes failed");
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out, "snapverify: %s: %s\n", path,
|
||||
local.fail_reason);
|
||||
return -EIO;
|
||||
}
|
||||
consumed += (off_t)cfg_bytes_len;
|
||||
}
|
||||
}
|
||||
|
||||
close(fd);
|
||||
local.consumed = consumed;
|
||||
if (consumed != st.st_size) {
|
||||
char why[160];
|
||||
snprintf(why, sizeof(why),
|
||||
"trailing garbage: consumed=%jd but file_size=%jd",
|
||||
(intmax_t)consumed, (intmax_t)st.st_size);
|
||||
fail(&local, -EIO, consumed, why);
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out, "snapverify: %s: %s\n", path, local.fail_reason);
|
||||
return -EIO;
|
||||
}
|
||||
|
||||
if (rep)
|
||||
*rep = local;
|
||||
if (out)
|
||||
fprintf(out,
|
||||
"snapverify: %s: OK (lsn=%" PRIu64 " fmt=%u jobs=%u queues=%u "
|
||||
"hosts=%u cfgref_tag=%016" PRIx64 " cfg_bytes_len=%u "
|
||||
"has_cfg_bytes=%d size=%jd)\n",
|
||||
path, local.checkpoint_lsn, local.format_version,
|
||||
local.job_count, local.queue_count, local.host_count,
|
||||
local.cfgref_tag, local.config_bytes_len,
|
||||
local.has_config_bytes_section, (intmax_t)local.file_size);
|
||||
return 0;
|
||||
}
|
||||
6996
src/harness/test.c
Normal file
6996
src/harness/test.c
Normal file
File diff suppressed because it is too large
Load diff
515
src/harness/walcompare.c
Normal file
515
src/harness/walcompare.c
Normal file
|
|
@ -0,0 +1,515 @@
|
|||
#define _GNU_SOURCE
|
||||
#include "walcompare.h"
|
||||
|
||||
#include <ctype.h>
|
||||
#include <dirent.h>
|
||||
#include <errno.h>
|
||||
#include <inttypes.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
#include "persist.h" /* WAL_LEGACY_TERM */
|
||||
#include "waldump.h"
|
||||
|
||||
#define WALCMP_MAX_MEMBERS 16
|
||||
|
||||
/* One member's collected log, indexed by LSN. `slot[lsn]` is valid when
|
||||
* `present[lsn]` is set. LSNs are 1-based; index 0 is unused. */
|
||||
typedef struct {
|
||||
uint64_t recovered_tip; /* highest CRC-intact LSN held (waldump tip) */
|
||||
uint64_t committed_lsn; /* effective committed boundary for this member */
|
||||
/* raft-ha-core-safety task 5.10a / ha-entity-snapshots task 7.1: the
|
||||
* highest checkpoint LSN this member has installed (0 = never installed
|
||||
* a snapshot -- expect raw WAL from LSN 1, the pre-existing behavior,
|
||||
* unchanged). A member that caught up via a full-checkpoint snapshot
|
||||
* legitimately holds NO raw WAL entries at or below its own floor_lsn --
|
||||
* that range was restored via entity content, not WAL replay -- so it
|
||||
* must never be flagged MISSING there. Read once per member from its own
|
||||
* snap/ directory (scan_snapshot_floor); never uniform across members
|
||||
* (unlike committed_lsn, which the caller may force to one shared value). */
|
||||
uint64_t floor_lsn;
|
||||
uint64_t cap; /* allocated index size (LSN range [0..cap-1]) */
|
||||
uint64_t* term; /* raw on-disk term per LSN (0 = legacy / absent) */
|
||||
uint32_t* pcrc; /* payload CRC per LSN */
|
||||
uint8_t* present; /* 1 if this LSN is held */
|
||||
} member_log_t;
|
||||
|
||||
/* Visitor context: collect entries into a member_log_t. */
|
||||
typedef struct {
|
||||
member_log_t* log;
|
||||
int oom; /* set on an allocation failure mid-scan */
|
||||
} collect_ctx_t;
|
||||
|
||||
/* Map a raw on-disk term to its matching term: legacy 0 -> WAL_LEGACY_TERM
|
||||
* (R1.8) so two pre-feature entries are not spuriously divergent. */
|
||||
static uint64_t match_term(uint64_t raw)
|
||||
{
|
||||
return raw == 0 ? WAL_LEGACY_TERM : raw;
|
||||
}
|
||||
|
||||
/* Grow the per-LSN index so `lsn` is addressable. Returns 0 or -ENOMEM. */
|
||||
static int member_log_ensure(member_log_t* m, uint64_t lsn)
|
||||
{
|
||||
if (lsn < m->cap)
|
||||
return 0;
|
||||
uint64_t newcap = m->cap ? m->cap : 64;
|
||||
while (newcap <= lsn)
|
||||
newcap *= 2;
|
||||
|
||||
uint64_t* nt = realloc(m->term, newcap * sizeof(*nt));
|
||||
uint32_t* np = realloc(m->pcrc, newcap * sizeof(*np));
|
||||
uint8_t* ne = realloc(m->present, newcap * sizeof(*ne));
|
||||
if (!nt || !np || !ne) {
|
||||
/* realloc may have partially succeeded; keep whatever grew so the
|
||||
* eventual free() is safe, but report OOM. */
|
||||
if (nt)
|
||||
m->term = nt;
|
||||
if (np)
|
||||
m->pcrc = np;
|
||||
if (ne)
|
||||
m->present = ne;
|
||||
return -ENOMEM;
|
||||
}
|
||||
/* Zero the newly added tail. */
|
||||
memset(nt + m->cap, 0, (newcap - m->cap) * sizeof(*nt));
|
||||
memset(np + m->cap, 0, (newcap - m->cap) * sizeof(*np));
|
||||
memset(ne + m->cap, 0, (newcap - m->cap) * sizeof(*ne));
|
||||
m->term = nt;
|
||||
m->pcrc = np;
|
||||
m->present = ne;
|
||||
m->cap = newcap;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static int collect_visit(void* ctx, const waldump_entry_t* e)
|
||||
{
|
||||
collect_ctx_t* cc = ctx;
|
||||
member_log_t* m = cc->log;
|
||||
if (e->lsn == 0)
|
||||
return 0; /* LSN 0 is never a real committed entry */
|
||||
if (member_log_ensure(m, e->lsn) != 0) {
|
||||
cc->oom = 1;
|
||||
return 1; /* stop the scan */
|
||||
}
|
||||
m->term[e->lsn] = e->term;
|
||||
m->pcrc[e->lsn] = e->pcrc;
|
||||
m->present[e->lsn] = 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void member_log_free(member_log_t* m)
|
||||
{
|
||||
free(m->term);
|
||||
free(m->pcrc);
|
||||
free(m->present);
|
||||
m->term = NULL;
|
||||
m->pcrc = NULL;
|
||||
m->present = NULL;
|
||||
m->cap = 0;
|
||||
}
|
||||
|
||||
/* Scan one directory for `snap-<16-hex-digit-lsn>.{capnp,bin}` files (the
|
||||
* on-disk naming write_snapshot()/legacy checkpoints use — persist.c) and
|
||||
* return the highest embedded LSN found, or 0 if the directory doesn't exist
|
||||
* or holds no matching file. A bare 16-hex-digit match is required (skips
|
||||
* `.tmp` in-progress files and anything else) so this never mis-parses an
|
||||
* unrelated entry as a checkpoint LSN. */
|
||||
static uint64_t scan_snap_dir_floor(const char* dir)
|
||||
{
|
||||
DIR* d = opendir(dir);
|
||||
if (!d)
|
||||
return 0; /* no such dir (no snapshot ever installed here) — floor 0 */
|
||||
|
||||
uint64_t max_lsn = 0;
|
||||
struct dirent* de;
|
||||
while ((de = readdir(d)) != NULL) {
|
||||
unsigned long long lsn = 0;
|
||||
char ext[8];
|
||||
/* "snap-XXXXXXXXXXXXXXXX.capnp" / ".bin" — %16llx would also accept
|
||||
* fewer than 16 digits, so additionally check the literal width via
|
||||
* strlen on the numeric run; simplest robust check: require the
|
||||
* filename to be exactly "snap-" + 16 hex chars + "." + ext. */
|
||||
size_t len = strlen(de->d_name);
|
||||
if (len < 5 + 16 + 1 + 3 || strncmp(de->d_name, "snap-", 5) != 0)
|
||||
continue;
|
||||
int matched = sscanf(de->d_name + 5, "%16llx.%7s", &lsn, ext);
|
||||
if (matched != 2)
|
||||
continue;
|
||||
if (strcmp(ext, "capnp") != 0 && strcmp(ext, "bin") != 0)
|
||||
continue;
|
||||
/* Confirm the hex run is EXACTLY 16 chars (reject a short-digit name
|
||||
* like "snap-1.capnp" that sscanf's %16llx would still happily take). */
|
||||
int hexlen = 0;
|
||||
while (hexlen < 16 && isxdigit((unsigned char)de->d_name[5 + hexlen]))
|
||||
hexlen++;
|
||||
if (hexlen != 16 || de->d_name[5 + 16] != '.')
|
||||
continue;
|
||||
if ((uint64_t)lsn > max_lsn)
|
||||
max_lsn = (uint64_t)lsn;
|
||||
}
|
||||
closedir(d);
|
||||
return max_lsn;
|
||||
}
|
||||
|
||||
/* Find this member's own checkpoint floor (raft-ha-core-safety task 5.10a /
|
||||
* ha-entity-snapshots task 7.1). `src->path` may be either a "data" dir
|
||||
* containing sibling `wal/`+`snap/` subdirs (the soak harness's convention:
|
||||
* `$TOP/controller/ctrl-N/data`), or the WAL dir itself passed directly (seen
|
||||
* in unit-test call sites, e.g. `persist_t.wal_path`) — in the latter case the
|
||||
* sibling snap dir sits at the same parent level with `wal` replaced by
|
||||
* `snap`. Try both; a nonexistent candidate just yields floor 0 (harmless). */
|
||||
static uint64_t member_snapshot_floor(const char* path)
|
||||
{
|
||||
if (!path || !*path)
|
||||
return 0;
|
||||
|
||||
char candidate[4160];
|
||||
uint64_t floor1 = 0, floor2 = 0;
|
||||
|
||||
/* Candidate 1: <path>/snap (path is a "data" parent dir). */
|
||||
snprintf(candidate, sizeof(candidate), "%s/snap", path);
|
||||
floor1 = scan_snap_dir_floor(candidate);
|
||||
|
||||
/* Candidate 2: sibling of <path> with a trailing "/wal" (or "wal")
|
||||
* component replaced by "/snap" (path is the WAL dir itself). */
|
||||
size_t plen = strlen(path);
|
||||
const char* base = strrchr(path, '/');
|
||||
const char* leaf = base ? base + 1 : path;
|
||||
if (strcmp(leaf, "wal") == 0) {
|
||||
size_t prefix_len = (size_t)(leaf - path);
|
||||
if (prefix_len + 4 < sizeof(candidate)) {
|
||||
memcpy(candidate, path, prefix_len);
|
||||
snprintf(candidate + prefix_len, sizeof(candidate) - prefix_len, "snap");
|
||||
floor2 = scan_snap_dir_floor(candidate);
|
||||
}
|
||||
}
|
||||
(void)plen;
|
||||
|
||||
return floor1 > floor2 ? floor1 : floor2;
|
||||
}
|
||||
|
||||
/* Load one member's WAL into `*m`. Returns 0 or a negative errno. */
|
||||
static int load_member(const walcompare_member_t* src, member_log_t* m, FILE* out)
|
||||
{
|
||||
memset(m, 0, sizeof(*m));
|
||||
|
||||
waldump_stats_t st;
|
||||
memset(&st, 0, sizeof(st));
|
||||
collect_ctx_t cc = { .log = m, .oom = 0 };
|
||||
|
||||
/* Send the human dump to the caller's `out` (or a discard sink). The
|
||||
* visitor harvests the (lsn -> term, pcrc) index from the same decode. */
|
||||
FILE* dump_sink = out ? out : fopen("/dev/null", "w");
|
||||
int own_sink = (out == NULL);
|
||||
if (!dump_sink)
|
||||
return -errno;
|
||||
|
||||
int rc = waldump_path_visit(src->path, dump_sink, &st, collect_visit, &cc);
|
||||
if (own_sink)
|
||||
fclose(dump_sink);
|
||||
|
||||
if (rc != 0) {
|
||||
member_log_free(m);
|
||||
return rc; /* hard load error (missing path / no segments) */
|
||||
}
|
||||
if (cc.oom) {
|
||||
member_log_free(m);
|
||||
return -ENOMEM;
|
||||
}
|
||||
|
||||
m->recovered_tip = st.recovered_tip;
|
||||
/* An explicit committed_lsn wins; 0 means "derive from the shared
|
||||
* boundary" (filled by the caller after all members are loaded). */
|
||||
m->committed_lsn = src->committed_lsn;
|
||||
/* Always PER-MEMBER, never uniform/shared (unlike committed_lsn) — a
|
||||
* member's own checkpoint floor has nothing to do with any other
|
||||
* member's state. */
|
||||
m->floor_lsn = member_snapshot_floor(src->path);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Loaded cluster state shared by walcompare() and walcompare_report(). */
|
||||
typedef struct {
|
||||
member_log_t logs[WALCMP_MAX_MEMBERS];
|
||||
int n;
|
||||
int loaded; /* members successfully loaded (for cleanup) */
|
||||
uint64_t boundary; /* highest committed LSN compared (committed edge) */
|
||||
uint64_t max_tip; /* highest recovered tip across members (tail edge) */
|
||||
} wc_cluster_t;
|
||||
|
||||
/* Stable status token for one member at one LSN. */
|
||||
static const char* wc_status(const member_log_t* m, uint64_t lsn)
|
||||
{
|
||||
if ((lsn < m->cap) && m->present[lsn])
|
||||
return "present";
|
||||
if (lsn <= m->floor_lsn)
|
||||
return "compacted"; /* below this member's OWN checkpoint floor —
|
||||
* legitimately absent (entity-restored, not
|
||||
* WAL-held), never a divergence signal */
|
||||
if (lsn <= m->committed_lsn)
|
||||
return "MISSING"; /* a committed entry this member SHOULD hold (R4.2) */
|
||||
return "beyond"; /* legitimately not held (above its committed prefix) */
|
||||
}
|
||||
|
||||
/* Load all members and compute the committed boundary + tail edge. On a load
|
||||
* error, frees what loaded, reports to `out`, returns a negative errno. */
|
||||
static int wc_load(const walcompare_member_t* members, int n, FILE* out,
|
||||
wc_cluster_t* cl)
|
||||
{
|
||||
memset(cl, 0, sizeof(*cl));
|
||||
cl->n = n;
|
||||
|
||||
for (int i = 0; i < n; i++) {
|
||||
int rc = load_member(&members[i], &cl->logs[i], out);
|
||||
if (rc != 0) {
|
||||
if (out)
|
||||
fprintf(out, "walcompare: cannot load member %d ('%s'): %s\n", i,
|
||||
members[i].path ? members[i].path : "(null)",
|
||||
strerror(-rc));
|
||||
for (int j = 0; j < cl->loaded; j++)
|
||||
member_log_free(&cl->logs[j]);
|
||||
return rc;
|
||||
}
|
||||
cl->loaded = i + 1;
|
||||
}
|
||||
|
||||
/* Shared committed boundary = the minimum recovered tip across members:
|
||||
* the largest committed prefix every member can be expected to hold. A
|
||||
* member with an explicit committed_lsn keeps its own. */
|
||||
uint64_t shared = cl->logs[0].recovered_tip;
|
||||
for (int i = 1; i < n; i++)
|
||||
if (cl->logs[i].recovered_tip < shared)
|
||||
shared = cl->logs[i].recovered_tip;
|
||||
|
||||
for (int i = 0; i < n; i++) {
|
||||
if (cl->logs[i].committed_lsn == 0)
|
||||
cl->logs[i].committed_lsn = shared;
|
||||
if (cl->logs[i].committed_lsn > cl->boundary)
|
||||
cl->boundary = cl->logs[i].committed_lsn;
|
||||
if (cl->logs[i].recovered_tip > cl->max_tip)
|
||||
cl->max_tip = cl->logs[i].recovered_tip;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void wc_unload(wc_cluster_t* cl)
|
||||
{
|
||||
for (int i = 0; i < cl->loaded; i++)
|
||||
member_log_free(&cl->logs[i]);
|
||||
}
|
||||
|
||||
/* One machine line for member `i` at `lsn`. Fixed field order, stable tokens. */
|
||||
static void wc_emit_member(const wc_cluster_t* cl, int i, uint64_t lsn,
|
||||
int committed, FILE* out)
|
||||
{
|
||||
const member_log_t* m = &cl->logs[i];
|
||||
int holds = (lsn < m->cap) && m->present[lsn];
|
||||
fprintf(out,
|
||||
"WALCMP member=%d lsn=%" PRIu64 " term=%" PRIu64
|
||||
" pcrc=%08x status=%s committed=%d\n",
|
||||
i, lsn, holds ? match_term(m->term[lsn]) : 0,
|
||||
holds ? m->pcrc[lsn] : 0u, wc_status(m, lsn), committed);
|
||||
}
|
||||
|
||||
/* Full per-member machine table over [1..max_tip]; committed=1 within the
|
||||
* committed boundary, 0 in the uncommitted tail. */
|
||||
static void wc_emit_table(const wc_cluster_t* cl, FILE* out)
|
||||
{
|
||||
for (uint64_t lsn = 1; lsn <= cl->max_tip; lsn++) {
|
||||
int committed = (lsn <= cl->boundary) ? 1 : 0;
|
||||
for (int i = 0; i < cl->n; i++)
|
||||
wc_emit_member(cl, i, lsn, committed, out);
|
||||
}
|
||||
}
|
||||
|
||||
/* Emit the classification + per-member identity for ONE diverging LSN. */
|
||||
static void wc_emit_divergence(const wc_cluster_t* cl, uint64_t lsn,
|
||||
int committed, int compared, int present,
|
||||
FILE* out)
|
||||
{
|
||||
fprintf(out,
|
||||
"WALCMP_DIVERGENCE lsn=%" PRIu64 " class=%s compared=%d present=%d\n",
|
||||
lsn, committed ? "committed" : "uncommitted", compared, present);
|
||||
for (int i = 0; i < cl->n; i++)
|
||||
wc_emit_member(cl, i, lsn, committed, out);
|
||||
}
|
||||
|
||||
/* Compare one committed LSN. Fills `div` (committed=1) and prints the human +
|
||||
* machine report when divergent. Returns 1 if divergent, else 0. */
|
||||
static int wc_compare_committed(const wc_cluster_t* cl,
|
||||
const walcompare_member_t* members, uint64_t lsn,
|
||||
FILE* out, walcompare_divergence_t* div)
|
||||
{
|
||||
int have = 0, present = 0, have_ref = 0, divergent = 0;
|
||||
uint64_t ref_term = 0;
|
||||
uint32_t ref_pcrc = 0;
|
||||
|
||||
for (int i = 0; i < cl->n; i++) {
|
||||
if (lsn > cl->logs[i].committed_lsn)
|
||||
continue; /* beyond member i's committed prefix */
|
||||
if (lsn <= cl->logs[i].floor_lsn)
|
||||
continue; /* below member i's OWN checkpoint floor — legitimately
|
||||
* compacted (entity-restored), never a divergence */
|
||||
have++;
|
||||
const member_log_t* m = &cl->logs[i];
|
||||
int holds = (lsn < m->cap) && m->present[lsn];
|
||||
if (!holds) {
|
||||
divergent = 1; /* member should hold this committed LSN but doesn't */
|
||||
continue;
|
||||
}
|
||||
present++;
|
||||
uint64_t mt = match_term(m->term[lsn]);
|
||||
uint32_t mp = m->pcrc[lsn];
|
||||
if (!have_ref) {
|
||||
ref_term = mt;
|
||||
ref_pcrc = mp;
|
||||
have_ref = 1;
|
||||
} else if (mt != ref_term || mp != ref_pcrc) {
|
||||
divergent = 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (have < 2 || !divergent)
|
||||
return 0;
|
||||
|
||||
if (div) {
|
||||
memset(div, 0, sizeof(*div));
|
||||
div->lsn = lsn;
|
||||
div->n_members = cl->n;
|
||||
div->committed = 1;
|
||||
for (int i = 0; i < cl->n && i < 16; i++) {
|
||||
const member_log_t* m = &cl->logs[i];
|
||||
int holds = (lsn <= m->committed_lsn) && (lsn > m->floor_lsn) &&
|
||||
(lsn < m->cap) && m->present[lsn];
|
||||
div->member_present[i] = holds ? 1 : 0;
|
||||
div->member_term[i] = holds ? match_term(m->term[lsn]) : 0;
|
||||
div->member_pcrc[i] = holds ? m->pcrc[lsn] : 0;
|
||||
}
|
||||
}
|
||||
if (out) {
|
||||
fprintf(out,
|
||||
"*** DIVERGENCE at committed LSN %" PRIu64
|
||||
" (compared %d, present %d) ***\n",
|
||||
lsn, have, present);
|
||||
for (int i = 0; i < cl->n; i++) {
|
||||
if (lsn > cl->logs[i].committed_lsn) {
|
||||
fprintf(out,
|
||||
" member %d ('%s'): LSN beyond its committed prefix"
|
||||
" (committed=%" PRIu64 ")\n",
|
||||
i, members[i].path, cl->logs[i].committed_lsn);
|
||||
continue;
|
||||
}
|
||||
if (lsn <= cl->logs[i].floor_lsn) {
|
||||
fprintf(out,
|
||||
" member %d ('%s'): LSN below its own checkpoint floor"
|
||||
" (floor=%" PRIu64 ") — legitimately compacted\n",
|
||||
i, members[i].path, cl->logs[i].floor_lsn);
|
||||
continue;
|
||||
}
|
||||
int holds = (lsn < cl->logs[i].cap) && cl->logs[i].present[lsn];
|
||||
if (holds)
|
||||
fprintf(out, " member %d ('%s'): term=%" PRIu64 " pcrc=%08x\n",
|
||||
i, members[i].path, match_term(cl->logs[i].term[lsn]),
|
||||
cl->logs[i].pcrc[lsn]);
|
||||
else
|
||||
fprintf(out, " member %d ('%s'): MISSING committed entry\n", i,
|
||||
members[i].path);
|
||||
}
|
||||
wc_emit_divergence(cl, lsn, 1, have, present, out);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
|
||||
/* Scan the uncommitted tail (boundary < lsn <= max_tip): emit a NON-fatal
|
||||
* class=uncommitted classification for any LSN where >=2 members hold the entry
|
||||
* but disagree on (term, pcrc). Never changes the verdict. */
|
||||
static void wc_scan_uncommitted(const wc_cluster_t* cl, FILE* out)
|
||||
{
|
||||
for (uint64_t lsn = cl->boundary + 1; lsn <= cl->max_tip; lsn++) {
|
||||
int present = 0, have_ref = 0, divergent = 0;
|
||||
uint64_t ref_term = 0;
|
||||
uint32_t ref_pcrc = 0;
|
||||
|
||||
for (int i = 0; i < cl->n; i++) {
|
||||
const member_log_t* m = &cl->logs[i];
|
||||
int holds = (lsn < m->cap) && m->present[lsn];
|
||||
if (!holds)
|
||||
continue;
|
||||
present++;
|
||||
uint64_t mt = match_term(m->term[lsn]);
|
||||
uint32_t mp = m->pcrc[lsn];
|
||||
if (!have_ref) {
|
||||
ref_term = mt;
|
||||
ref_pcrc = mp;
|
||||
have_ref = 1;
|
||||
} else if (mt != ref_term || mp != ref_pcrc) {
|
||||
divergent = 1;
|
||||
}
|
||||
}
|
||||
if (present < 2 || !divergent)
|
||||
continue;
|
||||
wc_emit_divergence(cl, lsn, 0, present, present, out);
|
||||
}
|
||||
}
|
||||
|
||||
/* Shared driver for walcompare() (full_report=0) and walcompare_report()
|
||||
* (full_report=1). */
|
||||
static int wc_run(const walcompare_member_t* members, int n, FILE* out,
|
||||
walcompare_divergence_t* div, int full_report)
|
||||
{
|
||||
if (!members || n < 2)
|
||||
return -EINVAL;
|
||||
if (n > WALCMP_MAX_MEMBERS)
|
||||
return -E2BIG;
|
||||
|
||||
wc_cluster_t cl;
|
||||
int rc = wc_load(members, n, out, &cl);
|
||||
if (rc != 0)
|
||||
return rc;
|
||||
|
||||
if (out)
|
||||
fprintf(out,
|
||||
"== walcompare: %d members, committed boundary up to LSN %" PRIu64
|
||||
" ==\n",
|
||||
n, cl.boundary);
|
||||
|
||||
if (full_report && out)
|
||||
wc_emit_table(&cl, out);
|
||||
|
||||
/* Walk every committed LSN. At each LSN, compare the (term, pcrc) of the
|
||||
* members whose own committed boundary covers it and that hold the entry.
|
||||
* A member that does not hold a committed LSN it SHOULD is a committed-
|
||||
* prefix divergence: a committed entry is missing (R4.2 / R6.4). */
|
||||
rc = WALCMP_OK;
|
||||
for (uint64_t lsn = 1; lsn <= cl.boundary; lsn++) {
|
||||
if (wc_compare_committed(&cl, members, lsn, out, div)) {
|
||||
rc = WALCMP_DIVERGENT; /* first committed divergence is the verdict */
|
||||
goto done;
|
||||
}
|
||||
}
|
||||
|
||||
/* The committed prefix is identical. In full-report mode also classify any
|
||||
* uncommitted-tail divergence (informational, never fatal). */
|
||||
if (full_report && out)
|
||||
wc_scan_uncommitted(&cl, out);
|
||||
|
||||
if (out)
|
||||
fprintf(out, "walcompare: OK — committed prefixes identical\n");
|
||||
|
||||
done:
|
||||
wc_unload(&cl);
|
||||
return rc;
|
||||
}
|
||||
|
||||
int walcompare(const walcompare_member_t* members, int n, FILE* out,
|
||||
walcompare_divergence_t* div)
|
||||
{
|
||||
return wc_run(members, n, out, div, 0);
|
||||
}
|
||||
|
||||
int walcompare_report(const walcompare_member_t* members, int n, FILE* out,
|
||||
walcompare_divergence_t* div)
|
||||
{
|
||||
return wc_run(members, n, out, div, 1);
|
||||
}
|
||||
626
src/harness/waldump.c
Normal file
626
src/harness/waldump.c
Normal file
|
|
@ -0,0 +1,626 @@
|
|||
#define _GNU_SOURCE
|
||||
#include "waldump.h"
|
||||
|
||||
#include <dirent.h>
|
||||
#include <errno.h>
|
||||
#include <fcntl.h>
|
||||
#include <inttypes.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include <zlib.h>
|
||||
|
||||
#include "config.h" /* WAL_HDR_CRC_SIZE, WAL_CRC_NONE, MAX_WAL_PAYLOAD_BYTES */
|
||||
#include "controller.capnp.h" /* Job decode for WAL_JOB_SUBMIT */
|
||||
#include "utils.h" /* fmt_jobid — human-readable JobId formatting */
|
||||
#include "wal_types.h"
|
||||
|
||||
#define WAL_SEG_FMT "%s/seg-%016" PRIx64 ".wal"
|
||||
#define MAX_SEGS 4096
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Small helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
static const char* wal_type_name(uint32_t t)
|
||||
{
|
||||
switch (t) {
|
||||
case WAL_JOB_SUBMIT: return "JOB_SUBMIT";
|
||||
case WAL_JOB_ARRAY_SUBMIT: return "JOB_ARRAY_SUBMIT"; /* job-array-single-
|
||||
* wal-record task 6.1: name the type so the HA soak's WAL-record-count
|
||||
* assertion (exactly one JOB_ARRAY_SUBMIT per array submission,
|
||||
* regardless of array size) can grep an exact type name instead of
|
||||
* the generic "UNKNOWN" bucket, which would also catch any future,
|
||||
* genuinely-unrecognized type. */
|
||||
case WAL_JOB_START: return "JOB_START";
|
||||
case WAL_JOB_FINISH: return "JOB_FINISH";
|
||||
case WAL_JOB_FAIL: return "JOB_FAIL";
|
||||
case WAL_JOB_STOP: return "JOB_STOP";
|
||||
case WAL_JOB_RESUME: return "JOB_RESUME";
|
||||
case WAL_JOB_UPDATE: return "JOB_UPDATE";
|
||||
case WAL_JOB_RUSAGE: return "JOB_RUSAGE";
|
||||
case WAL_JOB_CKPT: return "JOB_CKPT";
|
||||
case WAL_JOB_PREASON: return "JOB_PREASON";
|
||||
case WAL_QUEUE_CTRL: return "QUEUE_CTRL";
|
||||
case WAL_WORKER_CTRL: return "WORKER_CTRL";
|
||||
case WAL_NOOP: return "NOOP";
|
||||
case WAL_CONFIG_MARKER: return "CONFIG_MARKER";
|
||||
case WAL_CHECKPOINT: return "CHECKPOINT";
|
||||
default: return "UNKNOWN";
|
||||
}
|
||||
}
|
||||
|
||||
/* Decoded fields of a WAL_JOB_SUBMIT Cap'n Proto payload (debug view). */
|
||||
typedef struct {
|
||||
uint64_t jobid;
|
||||
uint64_t subtime; /* job_submit stamps this with time(NULL) per submit */
|
||||
uint32_t state;
|
||||
} submit_info_t;
|
||||
|
||||
/* Decode a WAL_JOB_SUBMIT payload. Mirrors restore_job() (job.c) but reads a
|
||||
* few fields only, zero-alloc. Returns 0 on success, -1 if undecodable.
|
||||
* subtime is the decisive bug-845 signal: two entries of the SAME job written
|
||||
* in one submit share a subtime (a literal double-append); two separate
|
||||
* submits (e.g. a failover retry) would differ. */
|
||||
static int decode_submit_info(const uint8_t* payload, uint32_t len,
|
||||
submit_info_t* out)
|
||||
{
|
||||
if (!payload || len == 0)
|
||||
return -1;
|
||||
|
||||
struct capn cctx;
|
||||
if (capn_init_mem(&cctx, payload, len, 0) != 0)
|
||||
return -1;
|
||||
|
||||
Job_ptr jp;
|
||||
jp.p = capn_getp(capn_root(&cctx), 0, 1);
|
||||
if (jp.p.type == CAPN_NULL) {
|
||||
capn_free(&cctx);
|
||||
return -1;
|
||||
}
|
||||
|
||||
struct Job cj;
|
||||
memset(&cj, 0, sizeof(cj));
|
||||
read_Job(&cj, jp);
|
||||
out->jobid = cj.jobid;
|
||||
out->subtime = cj.subtime;
|
||||
out->state = cj.state;
|
||||
capn_free(&cctx);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* True for the WAL types whose payload is the flat, non-capnp
|
||||
* wal_jobstate_t {jobid, state, _pad} (wal_types.h) — every worker-reported
|
||||
* job-state fact (START/FINISH/FAIL/STOP/RESUME/UPDATE/RUSAGE/CKPT/PREASON).
|
||||
* Added for raft-apply-decoupling task 7.2: the kill-mid-fact-stream live
|
||||
* regression needs to verify, per jobid, which fact WAL entries actually
|
||||
* committed on each node — before this, waldump only decoded jobid for
|
||||
* WAL_JOB_SUBMIT, leaving every fact entry printed as "jobid=?". */
|
||||
static int wal_type_is_jobstate(uint32_t type)
|
||||
{
|
||||
switch (type) {
|
||||
case WAL_JOB_START:
|
||||
case WAL_JOB_FINISH:
|
||||
case WAL_JOB_FAIL:
|
||||
case WAL_JOB_STOP:
|
||||
case WAL_JOB_RESUME:
|
||||
case WAL_JOB_UPDATE:
|
||||
case WAL_JOB_RUSAGE:
|
||||
case WAL_JOB_CKPT:
|
||||
case WAL_JOB_PREASON:
|
||||
return 1;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* Decode a wal_jobstate_t payload. Native-endian flat struct (single-node WAL
|
||||
* — see wal_types.h), so this is a bounds-checked copy, not a parser. Returns
|
||||
* 0 on success, -1 if the payload is too short to hold the struct. */
|
||||
static int decode_jobstate_info(const uint8_t* payload, uint32_t len,
|
||||
wal_jobstate_t* out)
|
||||
{
|
||||
if (!payload || len < sizeof(*out))
|
||||
return -1;
|
||||
memcpy(out, payload, sizeof(*out));
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Decode a WAL_CONFIG_MARKER payload -- fixed 40-byte form (8-byte
|
||||
* big-endian version_tag + 32-byte raw sha256), never capnp-encoded
|
||||
* (config_repl.c's config_marker_encode/config_marker_decode). Hand-decoded
|
||||
* here rather than linking config_repl.c, which would pull controller-
|
||||
* internal dependencies into xce_test's link graph (meson.build's
|
||||
* xce_test_src is deliberately limited to read-only dump/compare tools).
|
||||
* Mirrors config_marker_decode's byte layout exactly. */
|
||||
static int decode_config_marker_info(const uint8_t* payload, uint32_t len,
|
||||
uint64_t* out_tag, uint8_t sha256_out[32])
|
||||
{
|
||||
if (!payload || len < WAL_CONFIG_MARKER_PAYLOAD_LEN)
|
||||
return -1;
|
||||
uint64_t tag = 0;
|
||||
for (int i = 0; i < 8; i++)
|
||||
tag = (tag << 8) | payload[i];
|
||||
*out_tag = tag;
|
||||
memcpy(sha256_out, payload + 8, 32);
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Highest LSN among sibling data/snap/snap-<hex>.capnp files next to
|
||||
* `wal_dir` (persist.c hardcodes both as siblings under a node's data/,
|
||||
* e.g. .../data/wal and .../data/snap). A leading WAL gap up to this LSN is
|
||||
* explained by normal checkpoint-triggered trim (persist.c's
|
||||
* trim_wal_segments only removes segments below a replication-safe floor,
|
||||
* never a segment a standby still needs to catch up) rather than genuine
|
||||
* loss -- without this check, EVERY long-running node's WAL (which will
|
||||
* have been checkpointed at least once) prints the same alarming
|
||||
* "(committed prefix missing!)" wording a real, unexplained loss would,
|
||||
* making the message a standing false alarm. Returns 0 if `wal_dir` isn't
|
||||
* named "wal", or no sibling snap dir/files exist (unknown -- callers keep
|
||||
* today's un-downgraded wording, the conservative default). */
|
||||
static uint64_t highest_snapshot_lsn(const char* wal_dir)
|
||||
{
|
||||
char snap_dir[4096];
|
||||
snprintf(snap_dir, sizeof(snap_dir), "%s", wal_dir);
|
||||
char* slash = strrchr(snap_dir, '/');
|
||||
if (!slash || strcmp(slash + 1, "wal") != 0)
|
||||
return 0;
|
||||
snprintf(slash + 1, sizeof(snap_dir) - (size_t)(slash + 1 - snap_dir),
|
||||
"snap");
|
||||
|
||||
DIR* d = opendir(snap_dir);
|
||||
if (!d)
|
||||
return 0;
|
||||
uint64_t max_lsn = 0;
|
||||
struct dirent* e;
|
||||
while ((e = readdir(d)) != NULL) {
|
||||
uint64_t lsn;
|
||||
if (sscanf(e->d_name, "snap-%" SCNx64 ".capnp", &lsn) == 1 &&
|
||||
lsn > max_lsn)
|
||||
max_lsn = lsn;
|
||||
}
|
||||
closedir(d);
|
||||
return max_lsn;
|
||||
}
|
||||
|
||||
/* True if `dir` directly contains at least one seg-*.wal file. */
|
||||
static int dir_has_segments(const char* dir)
|
||||
{
|
||||
DIR* d = opendir(dir);
|
||||
if (!d)
|
||||
return 0;
|
||||
int found = 0;
|
||||
struct dirent* e;
|
||||
while ((e = readdir(d)) != NULL) {
|
||||
size_t n = strlen(e->d_name);
|
||||
if (n > 4 && strncmp(e->d_name, "seg-", 4) == 0 &&
|
||||
strcmp(e->d_name + n - 4, ".wal") == 0) {
|
||||
found = 1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
closedir(d);
|
||||
return found;
|
||||
}
|
||||
|
||||
/* Resolve `path` (seg file / wal dir / node-or-data dir) to the wal dir that
|
||||
* holds the seg-*.wal files. Writes it to `out`. Returns 0, or -errno. When
|
||||
* `path` is itself a single seg file, sets *single_seg to that file path. */
|
||||
static int resolve_wal_dir(const char* path, char* out, size_t outsz,
|
||||
char* single_seg, size_t segsz)
|
||||
{
|
||||
struct stat st;
|
||||
if (stat(path, &st) != 0)
|
||||
return -errno;
|
||||
|
||||
single_seg[0] = '\0';
|
||||
|
||||
if (S_ISREG(st.st_mode)) {
|
||||
/* Only a seg-*.wal file is a WAL segment. Reject anything else (e.g. a
|
||||
* config blob like conf/cluster.bin) instead of parsing it as a WAL and
|
||||
* silently reporting 0 entries. */
|
||||
const char* base = strrchr(path, '/');
|
||||
base = base ? base + 1 : path;
|
||||
size_t bl = strlen(base);
|
||||
if (strncmp(base, "seg-", 4) != 0 || bl < 5 ||
|
||||
strcmp(base + bl - 4, ".wal") != 0)
|
||||
return -EINVAL;
|
||||
snprintf(single_seg, segsz, "%s", path);
|
||||
/* wal dir = dirname(path); only used for display */
|
||||
snprintf(out, outsz, "%s", path);
|
||||
char* slash = strrchr(out, '/');
|
||||
if (slash)
|
||||
*slash = '\0';
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!S_ISDIR(st.st_mode))
|
||||
return -ENOTDIR;
|
||||
|
||||
/* A directory: try it directly, then data/wal, then wal/. */
|
||||
if (dir_has_segments(path)) {
|
||||
snprintf(out, outsz, "%s", path);
|
||||
return 0;
|
||||
}
|
||||
char cand[4096];
|
||||
snprintf(cand, sizeof(cand), "%s/data/wal", path);
|
||||
if (dir_has_segments(cand)) {
|
||||
snprintf(out, outsz, "%s", cand);
|
||||
return 0;
|
||||
}
|
||||
snprintf(cand, sizeof(cand), "%s/wal", path);
|
||||
if (dir_has_segments(cand)) {
|
||||
snprintf(out, outsz, "%s", cand);
|
||||
return 0;
|
||||
}
|
||||
return -ENOENT; /* no segments anywhere under path */
|
||||
}
|
||||
|
||||
/* Parse the start LSN out of a seg file basename ("seg-<hex16>.wal"). */
|
||||
static int parse_seg_start(const char* name, uint64_t* lsn)
|
||||
{
|
||||
return sscanf(name, "seg-%" SCNx64 ".wal", lsn) == 1 ? 0 : -1;
|
||||
}
|
||||
|
||||
/* Collect + sort segment start LSNs under wal_dir. Returns count, or -errno. */
|
||||
static int collect_segments(const char* wal_dir, uint64_t* seg_lsns, int max)
|
||||
{
|
||||
DIR* d = opendir(wal_dir);
|
||||
if (!d)
|
||||
return -errno;
|
||||
|
||||
int n = 0;
|
||||
struct dirent* e;
|
||||
while ((e = readdir(d)) != NULL && n < max) {
|
||||
uint64_t s;
|
||||
if (parse_seg_start(e->d_name, &s) == 0)
|
||||
seg_lsns[n++] = s;
|
||||
}
|
||||
closedir(d);
|
||||
|
||||
for (int i = 1; i < n; i++) { /* insertion sort — segment count is tiny */
|
||||
uint64_t key = seg_lsns[i];
|
||||
int j = i - 1;
|
||||
while (j >= 0 && seg_lsns[j] > key) {
|
||||
seg_lsns[j + 1] = seg_lsns[j];
|
||||
j--;
|
||||
}
|
||||
seg_lsns[j + 1] = key;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Per-segment scan: prints entries, validates the CRC chain, detects */
|
||||
/* LSN gaps. Carries the chain CRC and prev LSN across segment calls. */
|
||||
/* ------------------------------------------------------------------ */
|
||||
static void scan_segment(const char* seg_path, FILE* out,
|
||||
uint32_t* last_crc, uint64_t* prev_lsn,
|
||||
uint64_t* prev_submit_jobid, uint32_t* prev_submit_pcrc,
|
||||
waldump_stats_t* st, uint64_t snap_lsn,
|
||||
waldump_visit_fn visit, void* visit_ctx,
|
||||
int* visit_stop)
|
||||
{
|
||||
int fd = open(seg_path, O_RDONLY);
|
||||
if (fd < 0) {
|
||||
fprintf(out, " !! cannot open %s: %s\n", seg_path, strerror(errno));
|
||||
return;
|
||||
}
|
||||
|
||||
uint32_t off = 0;
|
||||
for (;;) {
|
||||
wal_entry_hdr_t hdr;
|
||||
ssize_t hn = pread(fd, &hdr, sizeof(hdr), (off_t)off);
|
||||
if (hn == 0)
|
||||
break; /* clean EOF */
|
||||
if (hn < (ssize_t)sizeof(hdr))
|
||||
break; /* truncated / pre-allocated zeros */
|
||||
if (hdr.lsn == 0 && hdr.type == 0 && hdr.payload_len == 0)
|
||||
break; /* pre-allocated zero region = clean end */
|
||||
|
||||
/* Bound payload before trusting it (corruption guard). */
|
||||
if (hdr.payload_len > MAX_WAL_PAYLOAD_BYTES) {
|
||||
fprintf(out, " !! LSN %" PRIu64 " payload_len %u exceeds max — stop\n",
|
||||
hdr.lsn, hdr.payload_len);
|
||||
st->crc_broken = 1;
|
||||
break;
|
||||
}
|
||||
|
||||
/* CRC chain check (identical rule to persist recovery). */
|
||||
int chain_ok;
|
||||
if (off == 0)
|
||||
chain_ok = (hdr.prev_crc == WAL_CRC_NONE || *last_crc == WAL_CRC_NONE ||
|
||||
hdr.prev_crc == *last_crc);
|
||||
else
|
||||
chain_ok = (hdr.prev_crc == *last_crc);
|
||||
if (!chain_ok) {
|
||||
fprintf(out, " !! LSN %" PRIu64 " prev_crc chain break — stop\n",
|
||||
hdr.lsn);
|
||||
st->crc_broken = 1;
|
||||
break;
|
||||
}
|
||||
|
||||
/* Verify entry CRC over header + payload. */
|
||||
uint8_t* payload = NULL;
|
||||
uint32_t want_crc = crc32(0L, (const Bytef*)&hdr, WAL_HDR_CRC_SIZE);
|
||||
if (hdr.payload_len > 0) {
|
||||
payload = malloc(hdr.payload_len);
|
||||
if (!payload)
|
||||
break;
|
||||
ssize_t pn = pread(fd, payload, hdr.payload_len,
|
||||
(off_t)(off + sizeof(hdr)));
|
||||
if (pn != (ssize_t)hdr.payload_len) {
|
||||
free(payload);
|
||||
break; /* short read — clean end */
|
||||
}
|
||||
want_crc = crc32(want_crc, (const Bytef*)payload, hdr.payload_len);
|
||||
}
|
||||
if (hdr.crc != want_crc) {
|
||||
fprintf(out, " !! LSN %" PRIu64 " CRC mismatch — stop\n", hdr.lsn);
|
||||
st->crc_broken = 1;
|
||||
free(payload);
|
||||
break;
|
||||
}
|
||||
|
||||
/* Gap detection against the running LSN sequence. */
|
||||
if (*prev_lsn == 0) {
|
||||
if (hdr.lsn > 1) {
|
||||
int explained = snap_lsn >= hdr.lsn - 1;
|
||||
fprintf(out, " *** GAP: missing prefix LSN 1..%" PRIu64
|
||||
" (%" PRIu64 " entries absent)%s ***\n",
|
||||
hdr.lsn - 1, hdr.lsn - 1,
|
||||
explained ? " -- covered by snapshot, expected "
|
||||
"post-checkpoint trim"
|
||||
: "");
|
||||
st->n_gaps++;
|
||||
st->leading_gap_explained = explained;
|
||||
}
|
||||
} else if (hdr.lsn > *prev_lsn + 1) {
|
||||
fprintf(out, " *** GAP: missing LSN %" PRIu64 "..%" PRIu64 " ***\n",
|
||||
*prev_lsn + 1, hdr.lsn - 1);
|
||||
st->n_gaps++;
|
||||
}
|
||||
|
||||
/* payload-only CRC — isolates the blob from the header (LSN/term/ts),
|
||||
* so two entries carrying the SAME job blob share a pcrc regardless of
|
||||
* their differing LSNs. Decisive for bug-845: equal pcrc on an adjacent
|
||||
* same-jobid pair ⇒ a literal byte-for-byte double-append. */
|
||||
uint32_t pcrc = (hdr.payload_len > 0)
|
||||
? crc32(0L, (const Bytef*)payload, hdr.payload_len)
|
||||
: 0;
|
||||
|
||||
/* Surface the CRC-valid entry to the visitor so a caller can index the
|
||||
* log by (lsn -> term, pcrc) — the single canonical decoder, no second
|
||||
* parser. */
|
||||
if (visit) {
|
||||
waldump_entry_t e = {
|
||||
.lsn = hdr.lsn,
|
||||
.term = hdr.term,
|
||||
.type = hdr.type,
|
||||
.payload_len = hdr.payload_len,
|
||||
.pcrc = pcrc,
|
||||
};
|
||||
if (visit(visit_ctx, &e) != 0) {
|
||||
if (visit_stop)
|
||||
*visit_stop = 1;
|
||||
free(payload);
|
||||
break; /* visitor asked to stop */
|
||||
}
|
||||
}
|
||||
|
||||
/* Per-entry line, with jobid/subtime for submits. Sized to hold the
|
||||
* formatted JobId (fmt_jobid, up to ~40 chars) + subtime + state. */
|
||||
char info[192] = "";
|
||||
int is_dup = 0;
|
||||
int dup_identical = 0;
|
||||
if (hdr.type == WAL_JOB_SUBMIT) {
|
||||
submit_info_t si;
|
||||
uint64_t jobid = 0;
|
||||
if (decode_submit_info(payload, hdr.payload_len, &si) == 0) {
|
||||
jobid = si.jobid;
|
||||
/* Render the JobId in the same human-readable form the client
|
||||
* (joblist / jobsub) prints — fmt_jobid (utils.c) — so a waldump
|
||||
* jobid can be eyeballed against an `expect-job <id>` / joblist
|
||||
* entry without converting the raw uint64. */
|
||||
char jidStr[128];
|
||||
if (fmt_jobid(jidStr, sizeof(jidStr), si.jobid) < 0)
|
||||
snprintf(jidStr, sizeof(jidStr), "%" PRIu64, si.jobid);
|
||||
snprintf(info, sizeof(info),
|
||||
"jobid=%s subtime=%" PRIu64 " state=%u",
|
||||
jidStr, si.subtime, si.state);
|
||||
} else {
|
||||
snprintf(info, sizeof(info), "jobid=?");
|
||||
}
|
||||
st->n_submits++;
|
||||
|
||||
/* bug-845: adjacent duplicate submit — same jobid as the previous
|
||||
* entry at lsn = prev_lsn + 1. Capture identity BEFORE updating the
|
||||
* carried pcrc. */
|
||||
if (jobid != 0 && jobid == *prev_submit_jobid &&
|
||||
hdr.lsn == *prev_lsn + 1) {
|
||||
is_dup = 1;
|
||||
dup_identical = (pcrc == *prev_submit_pcrc);
|
||||
st->n_dup_submits++;
|
||||
if (dup_identical)
|
||||
st->n_dup_identical++;
|
||||
}
|
||||
*prev_submit_jobid = jobid;
|
||||
*prev_submit_pcrc = pcrc;
|
||||
} else if (wal_type_is_jobstate(hdr.type)) {
|
||||
/* Worker-reported job-state fact (task 7.2): decode the flat
|
||||
* {jobid, state, _pad} payload so a per-jobid committed-fact check
|
||||
* (e.g. "did WAL_JOB_FINISH for jobid X actually commit on this
|
||||
* node") can grep waldump output directly, the same way the
|
||||
* existing WAL_JOB_SUBMIT branch above already supports
|
||||
* expect-job/joblist cross-checks. */
|
||||
wal_jobstate_t js;
|
||||
if (decode_jobstate_info(payload, hdr.payload_len, &js) == 0) {
|
||||
char jidStr[128];
|
||||
if (fmt_jobid(jidStr, sizeof(jidStr), js.jobid) < 0)
|
||||
snprintf(jidStr, sizeof(jidStr), "%" PRIu64, js.jobid);
|
||||
snprintf(info, sizeof(info), "jobid=%s state=0x%x pad=0x%x",
|
||||
jidStr, js.state, js._pad);
|
||||
} else {
|
||||
snprintf(info, sizeof(info), "jobid=?");
|
||||
}
|
||||
/* a non-submit entry breaks the adjacency for dup detection */
|
||||
*prev_submit_jobid = 0;
|
||||
} else if (hdr.type == WAL_CONFIG_MARKER) {
|
||||
uint64_t tag = 0;
|
||||
uint8_t sha[32];
|
||||
if (decode_config_marker_info(payload, hdr.payload_len, &tag,
|
||||
sha) == 0) {
|
||||
char hex[65];
|
||||
for (int i = 0; i < 32; i++)
|
||||
snprintf(hex + i * 2, 3, "%02x", sha[i]);
|
||||
snprintf(info, sizeof(info), "tag=%016" PRIx64 " sha256=%s",
|
||||
tag, hex);
|
||||
} else {
|
||||
snprintf(info, sizeof(info), "tag=?");
|
||||
}
|
||||
*prev_submit_jobid = 0;
|
||||
} else {
|
||||
/* a non-submit entry breaks the adjacency for dup detection */
|
||||
*prev_submit_jobid = 0;
|
||||
}
|
||||
|
||||
fprintf(out, " %8" PRIu64 " | %5" PRIu64 " | %-11s | %5u | %08x | %s\n",
|
||||
hdr.lsn, hdr.term, wal_type_name(hdr.type), hdr.payload_len,
|
||||
pcrc, info);
|
||||
if (is_dup)
|
||||
fprintf(out, " ^^ DUP submit: payload %s the prior entry\n",
|
||||
dup_identical ? "BYTE-IDENTICAL to" : "DIFFERS from");
|
||||
|
||||
free(payload);
|
||||
|
||||
/* Advance watermarks. */
|
||||
if (st->first_lsn == 0 || hdr.lsn < st->first_lsn)
|
||||
st->first_lsn = hdr.lsn;
|
||||
if (hdr.lsn > st->last_lsn)
|
||||
st->last_lsn = hdr.lsn;
|
||||
if (hdr.lsn > st->recovered_tip)
|
||||
st->recovered_tip = hdr.lsn;
|
||||
st->n_entries++;
|
||||
*prev_lsn = hdr.lsn;
|
||||
*last_crc = hdr.crc;
|
||||
|
||||
off += (uint32_t)(sizeof(hdr) + hdr.payload_len);
|
||||
}
|
||||
|
||||
close(fd);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Public entry point */
|
||||
/* ------------------------------------------------------------------ */
|
||||
int waldump_path(const char* path, FILE* out, waldump_stats_t* stats)
|
||||
{
|
||||
return waldump_path_visit(path, out, stats, NULL, NULL);
|
||||
}
|
||||
|
||||
int waldump_path_visit(const char* path, FILE* out, waldump_stats_t* stats,
|
||||
waldump_visit_fn visit, void* ctx)
|
||||
{
|
||||
if (!path || !out)
|
||||
return -EINVAL;
|
||||
|
||||
char wal_dir[4096];
|
||||
char single_seg[4096];
|
||||
int rc = resolve_wal_dir(path, wal_dir, sizeof(wal_dir),
|
||||
single_seg, sizeof(single_seg));
|
||||
if (rc != 0) {
|
||||
/* Clear, actionable error instead of silently scanning a non-WAL path
|
||||
* (e.g. conf/cluster.bin) and reporting "0 entries". */
|
||||
fprintf(out,
|
||||
"waldump: no WAL segments at '%s' (%s)\n"
|
||||
" point at a node's WAL dir, e.g. "
|
||||
"<deploy>/controller/ctrl-N/data/wal\n"
|
||||
" (or a specific seg-*.wal file inside it)\n",
|
||||
path, strerror(-rc));
|
||||
return rc;
|
||||
}
|
||||
|
||||
waldump_stats_t st;
|
||||
memset(&st, 0, sizeof(st));
|
||||
|
||||
uint64_t seg_lsns[MAX_SEGS];
|
||||
int nseg;
|
||||
/* wider than wal_dir[4096] so the "/seg-<hex16>.wal" suffix never truncates */
|
||||
char seg_path[4096 + 64];
|
||||
|
||||
if (single_seg[0] != '\0') {
|
||||
/* A single seg file: parse its start for ordering display. */
|
||||
const char* base = strrchr(single_seg, '/');
|
||||
base = base ? base + 1 : single_seg;
|
||||
uint64_t start = 0;
|
||||
parse_seg_start(base, &start);
|
||||
seg_lsns[0] = start;
|
||||
nseg = 1;
|
||||
snprintf(seg_path, sizeof(seg_path), "%s", single_seg);
|
||||
|
||||
/* If the dir holds OTHER segments, this single-file dump shows only a
|
||||
* slice of the log. A node that recovered then caught up keeps its
|
||||
* prefix in seg-...001.wal and the suffix in seg-...<current_lsn>.wal,
|
||||
* so a single-file dump looks (wrongly) like missing data. Steer the
|
||||
* user to the whole dir. */
|
||||
uint64_t others[MAX_SEGS];
|
||||
int total = collect_segments(wal_dir, others, MAX_SEGS);
|
||||
if (total > 1)
|
||||
fprintf(out,
|
||||
"NOTE: %s holds %d segments — this dumps only %s.\n"
|
||||
" Run 'waldump %s' (the directory) for the full "
|
||||
"stitched 1..N log.\n",
|
||||
wal_dir, total, base, wal_dir);
|
||||
} else {
|
||||
nseg = collect_segments(wal_dir, seg_lsns, MAX_SEGS);
|
||||
if (nseg < 0)
|
||||
return nseg;
|
||||
if (nseg == 0)
|
||||
return -ENOENT;
|
||||
}
|
||||
|
||||
st.n_segments = (uint32_t)nseg;
|
||||
uint64_t snap_lsn = highest_snapshot_lsn(wal_dir);
|
||||
|
||||
fprintf(out, "== WAL: %s (%d segment%s) ==\n", wal_dir, nseg,
|
||||
nseg == 1 ? "" : "s");
|
||||
fprintf(out, " %8s | %5s | %-11s | %5s | %8s | %s\n",
|
||||
"LSN", "TERM", "TYPE", "PLEN", "PCRC", "INFO");
|
||||
|
||||
uint32_t last_crc = WAL_CRC_NONE;
|
||||
uint64_t prev_lsn = 0;
|
||||
uint64_t prev_submit_jobid = 0;
|
||||
uint32_t prev_submit_pcrc = 0;
|
||||
int visit_stop = 0;
|
||||
for (int i = 0; i < nseg && !visit_stop; i++) {
|
||||
if (single_seg[0] == '\0')
|
||||
snprintf(seg_path, sizeof(seg_path), WAL_SEG_FMT, wal_dir,
|
||||
seg_lsns[i]);
|
||||
scan_segment(seg_path, out, &last_crc, &prev_lsn,
|
||||
&prev_submit_jobid, &prev_submit_pcrc, &st, snap_lsn,
|
||||
visit, ctx, &visit_stop);
|
||||
}
|
||||
|
||||
fprintf(out, "-- summary --\n");
|
||||
fprintf(out, " segments: %u entries: %u submits: %u\n",
|
||||
st.n_segments, st.n_entries, st.n_submits);
|
||||
fprintf(out, " lsn range: %" PRIu64 "..%" PRIu64
|
||||
" recovered tip: %" PRIu64 "\n",
|
||||
st.first_lsn, st.last_lsn, st.recovered_tip);
|
||||
fprintf(out, " gaps: %u%s crc chain: %s\n", st.n_gaps,
|
||||
(st.n_gaps && st.first_lsn > 1)
|
||||
? (st.leading_gap_explained
|
||||
? " (explained by checkpoint/snapshot -- not a loss)"
|
||||
: " (committed prefix missing!)")
|
||||
: "",
|
||||
st.crc_broken ? "BROKEN" : "intact");
|
||||
if (st.n_dup_submits > 0)
|
||||
fprintf(out, " duplicate submits: %u (byte-identical: %u) — bug-845\n",
|
||||
st.n_dup_submits, st.n_dup_identical);
|
||||
|
||||
if (stats)
|
||||
*stats = st;
|
||||
return 0;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue