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:
parent
0ef232a013
commit
c709eba813
3 changed files with 467 additions and 1 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue