feat(xce-test-cli-ux): add in-session source command with cycle detection

cmd_source reuses rcfile_load() unmodified for line execution, adding
an explicit missing-file check (bypassing rcfile_load's silent-missing
policy, which is correct for its one existing startup-rc-file caller
but wrong for an explicit user command) and a realpath()-canonicalized
in-flight path stack that rejects direct or transitive cycles instead
of hanging. Registered in all four command contexts. Translates
rcfile_load's raw positive "aborted" return to -1 so an enclosing
source line correctly sees a transitively-aborted nested source as
failure, not success (preprocess_and_run only treats negative returns
as failure). Proven via real fixture files driven through the black-box
pty harness: a genuine two-file mutual cycle, a missing path, and
ordered execution across a non-cyclic nested source. 1310/1310
controller suite passing (+3).

Task 2.4 of .kiro/specs/xce-test-cli-ux/tasks.md.
Requirements: 13.1, 13.2, 13.3, 13.4, 13.5, 13.6

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019jaMf9QEwpugH2W9KAuc7f
This commit is contained in:
Rongsong Shen 2026-09-09 14:25:37 +00:00
parent 0ef232a013
commit c709eba813
No known key found for this signature in database
GPG key ID: 5BCDAAE0123E9FE2
3 changed files with 467 additions and 1 deletions

View file

@ -2,6 +2,8 @@
#include <errno.h>
#include <getopt.h>
#include <inttypes.h>
#include <limits.h> /* PATH_MAX -- cmd_source's realpath() canonicalization
* buffer (xce-test-cli-ux task 2.4) */
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
@ -144,6 +146,7 @@ extern int cmd_walcompare(int, char*[]);
extern int cmd_snapverify(int, char*[]);
extern int cmd_snapdiff(int, char*[]);
extern int cmd_configcompare(int, char*[]);
extern int cmd_source(int, char*[]);
static int worker_house_keeping(void*);
/* Session verdict / loop state (xce-test-e2e-primitives task 1.1).
@ -287,6 +290,21 @@ static const char USAGE_CONFIGCOMPARE[] =
" member is unreadable or divergent.\n"
"example: configcompare /var/xce/ctrl-1 /var/xce/ctrl-2 /var/xce/ctrl-3\n";
static const char USAGE_SOURCE[] =
"source <file>\n"
" <file> path to a file containing xce_test commands, one per line\n"
" (required)\n"
" Reads and executes each line of <file> in order, exactly as if it\n"
" had been typed at the current prompt -- including a nested `source`\n"
" command, so one script file can compose others. A file that does\n"
" not exist or cannot be read is rejected with an explicit error and\n"
" no line of it is executed; a file that would source itself,\n"
" directly or transitively, is rejected as a cycle instead of\n"
" hanging. A failing line inside the file follows the session's\n"
" current -e/+e/continue-on-error setting (see `help set`), exactly\n"
" as it does for lines typed at the top-level prompt.\n"
"example: source /path/to/script.xt\n";
static const char USAGE_WORKER[] =
"worker\n"
" (no arguments)\n"
@ -567,6 +585,7 @@ static command_t top_commands[] = {
USAGE_SNAPDIFF },
{ "configcompare", cmd_configcompare, "Compare active config hash across members",
USAGE_CONFIGCOMPARE },
{ "source", cmd_source, "Run commands from a file", USAGE_SOURCE },
{ "quit", cmd_quit, "Quit xce test", USAGE_QUIT },
{ (char*)NULL, (cmd_func_t)NULL, (char*)NULL, (const char*)NULL }
};
@ -586,6 +605,7 @@ static command_t worker_commands[] = {
{ "jfail", cmd_jobfail, "tell controller jobfail", USAGE_JOBFAIL },
{ "resupdate", cmd_resupdate, "update res vals", USAGE_RESUPDATE },
{ "echo", cmd_echo, "Print a message", USAGE_ECHO },
{ "source", cmd_source, "Run commands from a file", USAGE_SOURCE },
{ (char*)NULL, (cmd_func_t)NULL, (char*)NULL, (const char*)NULL }
};
@ -628,6 +648,7 @@ static command_t client_commands[] = {
{ "taskrun", cmd_runtasks, "run tasks", USAGE_TASKRUN },
{ "help", cmd_help, "Display this text", USAGE_HELP },
{ "echo", cmd_echo, "Print a message", USAGE_ECHO },
{ "source", cmd_source, "Run commands from a file", USAGE_SOURCE },
{ "quit", cmd_quit, "Quit xce test", USAGE_QUIT },
{ (char*)NULL, (cmd_func_t)NULL, (char*)NULL, (const char*)NULL }
};
@ -645,6 +666,7 @@ static command_t scheduler_commands[] = {
{ "getconf", cmd_getconf, "Get conf of cluster", USAGE_GETCONF },
{ "setpend", cmd_setpend, "set job pend reason", USAGE_SETPEND },
{ "echo", cmd_echo, "Print a message", USAGE_ECHO },
{ "source", cmd_source, "Run commands from a file", USAGE_SOURCE },
{ (char*)NULL, (cmd_func_t)NULL, (char*)NULL, (const char*)NULL }
};
@ -1418,6 +1440,157 @@ int cmd_configcompare(int argc, char* argv[])
return (rc == 0) ? 0 : -1;
}
/* --------------------------------------------------------------------
* source <file> -- in-session script sourcing (xce-test-cli-ux task 2.4,
* Requirements 13.1-13.6; 13.7's TTY-condition clarification relating
* `source` to the Confirmation Gate is a separate, later validation task
* and is NOT implemented here).
*
* Reuses rcfile_load() (rcfile.c/h, xce-test-rc-file spec) UNCHANGED as the
* actual line-reading/dispatch loop, passing preprocess_and_run as its
* runner -- the same per-line command dispatcher the interactive REPL loop
* and the startup rc-file loader already use. rcfile.c itself is not
* touched: its own missing-file-is-silent-success policy is correct for
* its one other caller (the startup rc file) but wrong for an explicit
* user command, so cmd_source layers its own missing-file check (access())
* and cycle detection (a small in-flight canonical-path stack) on top,
* entirely in this file -- per research.md's "source built directly on
* rcfile_load" decision.
*
* Cycle detection: source_push_path()/source_pop_path() maintain a static
* LIFO stack of realpath()-canonicalized in-flight source paths.
* source_push_path() scans the stack for the same canonical path already
* in flight (a real cycle -- including through symlinks or a differently-
* spelled relative path, since realpath() runs before the scan) and
* returns -ELOOP without pushing if found; otherwise it pushes and returns
* 0. cmd_source calls source_pop_path() on EVERY exit path once the push
* succeeded (success, or rcfile_load returning nonzero because a line
* inside the file failed under -e/abort-on-failure semantics) -- this is
* the "path stack popped exactly once per successful push, on every exit
* path" invariant design.md requires. When source_push_path() itself
* fails (cycle detected), nothing was pushed, so cmd_source returns
* immediately WITHOUT calling source_pop_path() -- there is nothing to
* pop.
*
* Abort/continue-on-error composition (Req 13.3): a failing line inside
* the sourced file makes preprocess_and_run (the runner) return 1 once
* g_continue_on_error is false, which is exactly rcfile_load's own
* "stop on nonzero runner return" mechanism -- no extra code is needed
* here for that part; it falls out of reusing rcfile_load() unchanged.
* cmd_source's own return value then translates rcfile_load's result
* (0 == every line ran without requesting an abort) into this codebase's
* negative-errno failure convention (nonzero -> a generic -1 failure,
* mirroring cmd_configcompare's/cmd_waldump's own "rc == 0 ? 0 : -1"
* pattern) rather than forwarding rcfile_load's raw positive "stop" signal
* verbatim: forwarding it unchanged would be silently treated as SUCCESS
* by an enclosing preprocess_and_run call (only rc<0 counts as failure
* there), which would incorrectly let an enclosing script's later lines
* keep running past a `source` that itself aborted under -e, and would
* break that composition for nested sourcing (source A containing
* "source B", where B aborts). Translating to a negative value here makes
* a failed `source` compose correctly at every nesting depth, exactly
* like any other failing command.
*
* Nested sourcing (Req 13.4): "source" is registered in every command
* table (see the four command_t[] tables below); a line inside a sourced
* file that itself reads "source <otherfile>" is dispatched by
* preprocess_and_run/execute_line exactly like any other line, so nested
* sourcing falls out of dispatch being generic. The path stack is a
* single process-wide stack (not scoped per active context), so a cycle
* is caught regardless of which context each nesting level was sourced
* from. */
#define SOURCE_MAX_DEPTH 64
static char* g_source_stack[SOURCE_MAX_DEPTH];
static int g_source_depth = 0;
/* Returns 0 and pushes `canonical_path` if it is not already in flight, or
* -ELOOP without pushing if it is (a cycle -- the same canonicalized path
* sourcing itself, directly or transitively). Returns -ENOMEM if the stack
* is exhausted (SOURCE_MAX_DEPTH levels of DISTINCT, non-cyclic nested
* sourcing) or a heap copy of the path could not be made -- either way,
* nothing is pushed, so the caller must not call source_pop_path(). */
static int source_push_path(const char* canonical_path)
{
int i;
for (i = 0; i < g_source_depth; i++) {
if (strcmp(g_source_stack[i], canonical_path) == 0) {
return -ELOOP;
}
}
if (g_source_depth >= SOURCE_MAX_DEPTH) {
return -ENOMEM;
}
g_source_stack[g_source_depth] = strdup(canonical_path);
if (g_source_stack[g_source_depth] == NULL) {
return -ENOMEM;
}
g_source_depth++;
return 0;
}
/* Pops the most recently pushed path (LIFO). A no-op if the stack is
* already empty (defensive; every real call site only calls this after a
* source_push_path() call that itself returned 0). */
static void source_pop_path(void)
{
if (g_source_depth <= 0) {
return;
}
g_source_depth--;
free(g_source_stack[g_source_depth]);
g_source_stack[g_source_depth] = NULL;
}
/* source <file> -- reads and executes each line of <file> as if it had
* been typed at the current prompt, in order (Req 13.1). A missing or
* unreadable file is rejected with an explicit stderr error BEFORE
* rcfile_load() is ever called, bypassing its silent-missing-file
* behavior entirely (Req 13.2) -- rcfile.c itself is unmodified. */
int cmd_source(int argc, char* argv[])
{
char canonical[PATH_MAX];
int rc;
if (argc != 2) {
fprintf(stderr, "usage: source <file>\n");
return -EINVAL;
}
if (access(argv[1], R_OK) != 0) {
fprintf(stderr, "source: %s: %s\n", argv[1], strerror(errno));
return -errno;
}
if (realpath(argv[1], canonical) == NULL) {
fprintf(stderr, "source: %s: %s\n", argv[1], strerror(errno));
return -errno;
}
rc = source_push_path(canonical);
if (rc == -ELOOP) {
fprintf(stderr,
"source: %s: cycle detected (file sources itself, directly "
"or transitively)\n",
canonical);
return rc;
} else if (rc != 0) {
fprintf(stderr, "source: %s: %s\n", canonical, strerror(-rc));
return rc;
}
rc = rcfile_load(argv[1], preprocess_and_run);
source_pop_path(); /* popped on EVERY exit path past the push above */
return (rc == 0) ? 0 : -1;
}
int cmd_up(int argc, char* argv[])
{
if (active_ctx->parent != NULL) {