Adds rcfile_resolve_path() (rcfile.c/rcfile.h): resolves $XDG_CONFIG_HOME/xce_test/rc when set and non-empty, else $HOME/.config/xce_test/rc, else NULL. Standalone libc-only module mirroring the configcompare.c pattern; wired into xce_test_src and controller_unit_test_src, registered in controller_test.c's Unity runner. 6 unit tests in test_rcfile.c cover all XDG/HOME combinations plus a dedicated env-var save/restore isolation test, addressing the cross-test leak risk found during design review (controller_unit_test runs 80+ test files in one process). Adds the xce-test-rc-file spec (requirements/design/tasks/research), including design fixes from validation: getline()-based line reading (task 1.2, avoids truncating long rc-file lines) and the env-isolation requirement above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AgYRU3zDTqNGV5Jk8aiKLg
16 KiB
Design Document
Overview
Purpose: This feature adds a startup rc file to xce_test, src/controller/testsuite/test.c's REPL/scripting client, so a user's usual settings (set masters ..., set secret ...) and starting context are applied automatically instead of retyped every session.
Users: Engineers driving controller/worker/scheduler/HA soak and E2E testing through xce_test, both interactively and via piped scripts.
Impact: xce_test gains one new startup step (rc-file discovery and loading, before its read loop begins) and one new flag (--norc). No existing command, output format, or scripting primitive changes.
Goals
- Auto-load and execute an rc file at a fixed, XDG-compliant path before
xce_test's interactive/scripted read loop starts, for both session kinds. - Let
--norcskip this entirely for a given invocation. - Make a failing rc-file command behave exactly like a failing regular command — no new failure-handling mechanism.
Non-Goals
- A
--rcfile <path>custom-location override (deferred; orthogonal addition if wanted later). - Any new scripting syntax/primitive beyond what
execute_line/preprocess_and_runalready supports. - A per-project/cwd rc file, or a legacy dotfile fallback (e.g.
~/.xce_testrc) — there is no prior rc-file convention to preserve. xce_testcreating, writing, or scaffolding the rc file or its directory.- Fixing
xce_test's general-h/error-stream/flag-validation issues — those are owned by the separatexce-test-cli-uxspec.
Boundary Commitments
This Spec Owns
- Resolving the rc-file path per the XDG Base Directory Specification's own defined precedence/fallback.
- Reading the rc file (if present) and feeding each line into the existing command-execution path before the read loop starts.
- The
--norcflag and itsargp/help wiring. - Non-fatal handling of an unreadable (but present) rc file.
Out of Boundary
- The behavior of any individual
xce_testcommand once invoked (unchanged — commands run exactly as they do today, whether typed, piped, or rc-sourced). preprocess_and_run's/execute_line's abort-on-failure, continue-on-error, auto-variable, or exit-code-verdict logic (owned byxce-test-e2e-primitives; this spec only calls into it).xce_test's process-level--help/-hcompleteness (owned byxce-test-cli-ux); this spec only guarantees--norcis documented via the existing--help/-?mechanism.
Allowed Dependencies
preprocess_and_run(test.c:955) as the per-line runner — called by function pointer, not reimplemented.- Standard C library only (
getenv,fopen/getline/fclose,strdup/free) — no new third-party dependency. argp(already a dependency ofxce_test) for the--norcflag.
Revalidation Triggers
- A change to
preprocess_and_run's return-code contract (0 = continue, nonzero = abort) —rcfile_load's early-stop logic depends on it unchanged. - A change to how/when
g_interactive/g_continue_on_errorare computed inmain()— the rc-file load call must stay after that computation and before the first read-loop iteration. - Introduction of a
--rcfile <path>override or a legacy-dotfile fallback in a future spec — must compose with, not replace, this path-resolution contract.
Architecture
Existing Architecture Analysis
xce_test's testsuite/ directory already has an established pattern for a small, dependency-free capability: a standalone .c/.h module (e.g. waldump.c/waldump.h, configcompare.c/configcompare.h) linked into both the xce_test executable (meson.build:57) and, paired with its own test_<name>.c, into controller_unit_test (e.g. meson.build:166-167). test.c itself only wires a thin cmd_* wrapper or, in this feature's case, a call from main(). This feature follows that exact pattern rather than growing main()/test.c inline.
Architecture Pattern & Boundary Map
flowchart TD
Start[main starts] --> ParseArgs[argp_parse fills norc flag]
ParseArgs --> ComputeMode[Compute g_interactive and g_continue_on_error]
ComputeMode --> NorcCheck{norc flag set}
NorcCheck -- yes --> ReadLoop[Enter while not done read loop]
NorcCheck -- no --> Resolve[rcfile_resolve_path]
Resolve --> HasPath{Path resolved}
HasPath -- no --> ReadLoop
HasPath -- yes --> Load[rcfile_load path preprocess_and_run]
Load --> Stopped{Runner returned nonzero}
Stopped -- yes --> Exit[Return session_failed exit code]
Stopped -- no --> ReadLoop
Architecture Integration:
- Selected pattern: standalone module (
rcfile.c/rcfile.h) + one call site intest.c'smain(). - Domain/feature boundaries:
rcfile.cowns path resolution and line iteration only; it has no knowledge of commands, contexts, or client state — it receives a runner callback and calls it once per line. - Existing patterns preserved:
configcompare.c-style module pairing in both build targets;preprocess_and_run's existing per-line contract untouched. - New components rationale:
rcfile.c/rcfile.h(new capability, independently testable); one newargpoption and one new call site intest.c(integration only). - Steering compliance: one domain per file (
structure.md); test-first,testsuite/test_<unit>.cnaming (testing.md).
Technology Stack
| Layer | Choice / Version | Role in Feature | Notes |
|---|---|---|---|
| CLI (C) | argp (existing dependency) |
Declares --norc |
Same mechanism as the existing -v/--verbose option (test.c:674-677) |
| Runtime | C standard library (getenv, stdio.h, POSIX getline) |
Path resolution + file reading | No new dependency; getline avoids a fixed-size line buffer |
File Structure Plan
Directory Structure
src/controller/testsuite/
├── test.c # Modified: --norc option, call site in main()
├── rcfile.c # New: path resolution + line-loading loop
├── rcfile.h # New: public interface for rcfile.c
└── test_rcfile.c # New: Unity unit tests for rcfile.c (fake runner)
Modified Files
src/controller/testsuite/test.c— addint norctostruct arguments; add anorcentry tooptions[]; add a symbolicOPT_NORCkey constant and its case inparse_opt; inmain(), afterg_continue_on_erroris computed and before thewhile (!done)loop, callrcfile_resolve_path()/rcfile_load()whenarguments.norcis not set, returning early with the existingg_session_failed ? 1 : 0convention ifrcfile_loadsignals abort.src/controller/meson.build— add'testsuite/rcfile.c'toxce_test_src(meson.build:57); add'testsuite/test_rcfile.c'and'testsuite/rcfile.c'tocontroller_unit_test_src, following thetest_configcompare.c/configcompare.cpairing already there (meson.build:166-167).
New Files
src/controller/testsuite/rcfile.h— declaresrcfile_resolve_path(void)andrcfile_load(const char* path, int (*runner)(char*)).src/controller/testsuite/rcfile.c— implements both functions; no dependency beyond libc.src/controller/testsuite/test_rcfile.c— Unity tests using a fakeint (*)(char*)runner (records calls, returns a scripted result) plussetenv/unsetenvto drivercfile_resolve_path.
Requirements Traceability
| Requirement | Summary | Components | Interfaces | Flows |
|---|---|---|---|---|
| 1.1 | Resolve rc path with XDG precedence | rcfile.c |
rcfile_resolve_path |
Resolve step |
| 1.2 | Execute rc lines through the existing command path | rcfile.c, test.c::main |
rcfile_load(path, preprocess_and_run) |
Load step |
| 1.3 | Missing rc file is silent, not an error | rcfile.c |
rcfile_load (ENOENT branch) |
HasPath=no / open ENOENT |
| 1.4 | Load before the first read-loop line, both session kinds | test.c::main |
call site placement | NorcCheck → Resolve → Load → ReadLoop |
| 1.5 | Context/settings carry into the read loop | test.c (existing active_ctx/settings globals) |
none new | implied by shared global state |
| 2.1 | --norc skips loading entirely |
test.c::main, parse_opt |
arguments.norc |
NorcCheck=yes |
| 2.2 | --norc documented in --help |
test.c::options[] |
argp doc string |
n/a |
| 2.3 | Default (no --norc) still loads |
test.c::main |
NorcCheck=no branch | Resolve → Load |
| 3.1 | Rc failure reported like any command failure | preprocess_and_run (reused, unmodified) |
rcfile_load's runner callback |
Load step |
| 3.2 | Interactive: continue past a failing rc line | preprocess_and_run (existing g_continue_on_error branch) |
runner return 0 on recorded failure | Load loop continues |
| 3.3 | Scripted: abort before piped script runs | preprocess_and_run (existing abort branch), rcfile_load |
runner return nonzero → early stop | Stopped=yes → Exit |
| 3.4 | Same session-verdict/exit-code accounting | preprocess_and_run (existing g_last_status/g_session_failed) |
none new | n/a |
Components and Interfaces
| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies (P0/P1) | Contracts |
|---|---|---|---|---|---|
rcfile.c/rcfile.h |
CLI startup | Resolve rc path; load and run its lines via a caller-supplied runner | 1.1, 1.2, 1.3 | none (P0: libc only) | Service |
test.c::main integration |
CLI startup | Wire --norc, call rcfile.c, honor its abort signal |
1.4, 1.5, 2.1, 2.3 | rcfile.c (P0), preprocess_and_run (P0) |
Service |
test.c::options[]/parse_opt |
CLI arg parsing | Declare and parse --norc |
2.1, 2.2 | argp (P0) |
Service |
CLI Startup
rcfile module
| Field | Detail |
|---|---|
| Intent | Resolve the rc-file path and, if present, execute its lines through a caller-supplied runner |
| Requirements | 1.1, 1.2, 1.3 |
Responsibilities & Constraints
- Owns path resolution (
$XDG_CONFIG_HOME/$HOME/.configprecedence) and line iteration only. - Has no knowledge of
xce_testcommands, contexts, or client state — the runner is an opaque callback. - Never writes to the rc file or creates its directory.
Dependencies
- Outbound: caller-supplied runner function pointer (P0) — invoked once per line, trailing newline stripped.
- External: libc
getenv/fopen/getline(P0).
Contracts: Service [x] / API [ ] / Event [ ] / Batch [ ] / State [ ]
Service Interface
/* 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". 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. */
int rcfile_load(const char* path, int (*runner)(char* line));
- Preconditions:
runneris non-NULL whenpathis non-NULL. - Postconditions: the file handle is always closed before returning.
- Invariants: lines are run in file order; iteration stops at the first nonzero runner result.
Implementation Notes
- Integration:
main()callsrcfile_resolve_path(), and — if non-NULL —rcfile_load(path, preprocess_and_run), thenfree()s the path. A nonzerorcfile_loadresult short-circuits straight toreturn g_session_failed ? 1 : 0;, skipping thewhile (!done)loop entirely (Requirement 3.3). - Line reading:
rcfile_loadreads each line with POSIXgetline()(a dynamically-growing heap buffer, freed once at loop exit), not a fixed-sizefgets()buffer.rcfile.cdeliberately has no dependency ontest.c'sMAX_LINE_LEN, so a fixed buffer sized independently of it risks silently truncating (or splitting into two runner calls) a long line — e.g. a multi-peerset masters host1:port,host2:port,...line — producing a mangled command with no error reported.getline()has no such cap and keepsrcfile.cdependency-free (libc only), consistent with its stated architecture. - Validation:
test_rcfile.cuses a fake runner to prove the stop-on-nonzero and ENOENT-is-silent contracts without depending onpreprocess_and_runor any client/network state, plus a case with a line well beyond a typical small fixed-buffer size (e.g. >4KB) to prove no truncation/splitting occurs. - Risks: none identified — the module has no I/O beyond one file read and no shared mutable state.
Error Handling
Error Strategy
- Rc file absent (
ENOENT): silent, non-fatal —rcfile_loadreturns 0 (Requirement 1.3). - Rc file present but unreadable (permission denied, is a directory, etc.): non-fatal —
rcfile_loadprints onestderrwarning naming the path andstrerror(errno), then returns 0 so startup proceeds normally. - A line in the rc file fails: handled entirely by the reused
preprocess_and_run— its existing error output,g_last_status/g_session_failedaccounting, and mode-dependent abort/continue decision apply unchanged (Requirement 3). - Neither
$XDG_CONFIG_HOMEnor$HOMEresolves a base directory:rcfile_resolve_pathreturnsNULL;main()treats this identically to "no rc file found" — no error reported.
Testing Strategy
Unit Tests (test_rcfile.c, added to controller_unit_test)
- Test isolation:
controller_unit_testlinks 80+test_*.cfiles into one process running sequentially, so anysetenv/unsetenvon$HOME/$XDG_CONFIG_HOMEhere must not leak into tests that run after it. Before each test case that touches either variable, capture the prior state withgetenv()(distinguishing "was unset" from "was empty" — e.g. via achar*that isNULLvs a duplicated non-NULL string); after the test case, restore that exact prior state (unsetenv()if it had been unset,setenv(..., 1)with the saved value otherwise). Apply this consistently across every test below that sets either variable. rcfile_resolve_pathreturns$XDG_CONFIG_HOME/xce_test/rcwhenXDG_CONFIG_HOMEis set and non-empty.rcfile_resolve_pathreturns$HOME/.config/xce_test/rcwhenXDG_CONFIG_HOMEis unset or empty.rcfile_resolve_pathreturnsNULLwhen neitherXDG_CONFIG_HOMEnorHOMEis usable.rcfile_loadreturns 0 and never calls the runner when the path does not exist.rcfile_loadcalls the runner once per line, in order, with the trailing newline stripped.rcfile_loadstops after a runner call that returns nonzero, propagates that value, and does not call the runner for remaining lines (proves Requirement 3.3's "no further input" contract at the module level).rcfile_loadon a non-ENOENTopen failure (e.g. path is a directory) prints a warning and returns 0 without invoking the runner.
Integration Tests (new testsuite/test_rcfile_integration.sh, shell-driven against the built xce_test binary — mirrors the existing clean_failover_preservation.sh pattern)
- With
HOME/XDG_CONFIG_HOMEpointed at a temp dir containing an rc file withset masters ..., piping a script intoxce_testshows the overridden setting took effect. --norcwith the same rc file present leaves the defaultmastersvalue untouched.- A deliberately failing rc-file line (e.g. malformed
set masters) in piped mode causesxce_testto exit nonzero before any of the piped script's own commands produce output. - The same failing rc-file line in an interactive (TTY-simulated) session still reaches the normal prompt afterward.
- Compatibility regression check: with no rc file present (the state every existing caller runs in today), the
xce-test-haandxce-test-resourcesskills' existing soak/verification scripts — the known live consumers ofxce_test— produce the same pass/fail result they did before this feature, proving Requirement 1.3's "no rc file → unchanged behavior" guarantee holds for real, pre-existing automation and not only for this feature's own new integration script.