A registry of per-connection locks, acquired (interruptibly) before spawning a worker and released by whichever side finishes last, so an abandoned call's worker is never running concurrently with a new call on the same connection. Uses a POSIX semaphore (sem_t), not a mutex: review round 1 found the initial pthread_mutex_t implementation relied on behavior POSIX classifies as undefined for a non-owning-thread unlock (not just error-checking mutex types) -- this design's worker thread legitimately releases a lock the caller thread acquired, which sem_t supports with no ownership concept at all. bug-1791. Design.md synced to reflect the semaphore choice. 1285/1285 controller suite passing. Task 1.5 of .kiro/specs/xce-test-cli-ux/tasks.md. Requirements: 1.1, 1.5 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019jaMf9QEwpugH2W9KAuc7f
42 KiB
Design Document: xce-test-cli-ux
Overview
Purpose: This feature makes xce_test's command-line surface predictable and fail-loud for the engineers who drive controller/worker/scheduler/HA testing through it — consistent flag validation across every command, a Ctrl-C that actually cancels a stuck RPC, structured output for scripted callers, complete per-command help, a safety gate on destructive actions, and in-session script composition via source. It also fixes two known display/default bugs (bug-1711, bug-1557) found during the CLI-UX audit that motivated this spec.
Users: SDETs and engineers running interactive soak/E2E sessions against live or degraded HA clusters, and script authors piping xce_test non-interactively in automated test harnesses.
Impact: Adds a small set of cross-cutting internal modules (flag parsing/validation, an interruptible-RPC wrapper, a confirmation gate, a JSON list formatter) to the existing single-file xce_test REPL client (src/controller/testsuite/test.c, ~4550 lines), migrates ~17 commands' flag parsing to a shared pattern, and corrects two existing defects — without changing wire protocol, RPC semantics, or any existing short flag's meaning.
Goals
- Every flag-accepting command uniformly rejects an unrecognized/malformed flag and offers a long-form equivalent, named consistently for the same concept across commands.
- Ctrl-C reliably cancels a blocked RPC wait or the current input line — never the whole session, never silently doing nothing.
- The five destructive commands (
jobkill,shutdown,qctrl -i/-c,hostctrl -c,upgradeconfig) require confirmation, safely bypassable per-command or per-session for scripts. help <cmd>is complete (description, full flag docs, example, context/related-command notes) and consistent for every command.workerlist/joblist/reslistsupport a JSON output mode with the same fields as their tables, unabbreviated.source <file>composes reusable command sequences, safely (cycle-detected).- bug-1711's display defects and bug-1557's stale
joindefault are corrected.
Non-Goals
- No change to controller/worker/scheduler wire protocol or RPC semantics.
- No
xce_test→xce_harnessrename. - No full curses/TUI mode, no color/theming.
- No new
.xce_testrcauto-executed startup-file feature (already shipped by the separatexce-test-rc-filespec). - No renaming or reassignment of any existing short flag's meaning.
- No change to the auto-variable/exit-code-verdict/shell-escape mechanisms already shipped by
xce-test-e2e-primitives.
Boundary Commitments
This Spec Owns
xce_test's own CLI surface: flag parsing, validation, and typo-suggestion for every command that accepts flags (Flag Parsing Layer).- Client-side Ctrl-C cancellation of a blocked RPC wait (Interruptible Call Adapter) — cancellation is local only; it does not cancel the in-flight request server-side. Owns serializing any two calls that share a connection (including a new call issued right after a prior one was abandoned) so a connection's single-outstanding-reply state is never touched concurrently.
- The destructive-command confirmation gate, its five-command enumeration, its bypass flag, and its session-scoped toggle (Confirmation Gate).
- JSON output for
workerlist/joblist/reslist(JSON List Formatter). - Command-name tab completion scoped to the active context (Tab Completion).
help <cmd>content and generation, including the active-context-only not-found behavior (Help Renderer).- The new
source <file>command, its cycle detection, and its explicit missing-file error (Source Command). - The specific display/default corrections enumerated in Requirements 11 and 12 (bug-1711, bug-1557).
- Process-level
-h/--help/--version(Process Entry).
Out of Boundary
libxce's blocking RPC primitives (cbuf_timedget,xceapi.c'sxce_*functions) — not modified. Requirement 1 is achieved entirely client-side, insidetest.c/testsuite.- Controller/worker/scheduler wire protocol and RPC semantics — unaffected by any part of this design.
rcfile.c's own path-resolution and startup-time missing-file behavior — untouched;sourcereusesrcfile_loadas-is and layers its own missing-file/cycle behavior on top, in the new command, not inrcfile.c.- Any existing short flag's accepted values, default, or meaning — never reassigned (only a long-form alias is added alongside).
- The exit-code/auto-variable verdict machinery and shell-escape mechanism from
xce-test-e2e-primitives— read-only dependency, not modified. - Authentication/connection setup to the controller/worker/scheduler — unchanged.
- The controller daemon's HA/persistence/consensus invariants (
src/controller/CLAUDE.md's TLA+ mapping, election safety, commit durability, etc.) — checked and found not applicable:xce_testis a client binary linked againstlibxce, not the controller daemon process, and no part of this design touches daemon code, WAL, or replication state.
Allowed Dependencies
- cJSON (via the already-linked
struct2json_dep) — for the JSON List Formatter. No new external dependency. - libedit's completion API (already linked) — for Tab Completion.
- POSIX threads (already transitively linked via
libxce) — for the Interruptible Call Adapter. rcfile_load()(existing, unmodified) — called directly by the Source Command.cmd_set's existing settings mechanism — extended (not replaced) by the Confirmation Gate's toggle.
Revalidation Triggers
- Any change to
libxce's blocking-call deadline (cbuf_deadline_10s) changes the worst-case lifetime of an abandoned worker thread this design assumes. - Any change to
rcfile_load's signature or missing-file semantics invalidates the Source Command's reuse contract. - A new
cmd_*command added after this spec ships must adopt theflagspec_tpattern to stay covered by Requirements 2/6/8/10 — otherwise it silently falls outside these guarantees. - Any future decision to reassign an existing short flag's meaning (explicitly deferred, not this spec) would invalidate the
flagspec_ttables' short/long/concept mappings built here.
Architecture
Existing Architecture Analysis
- Flag parsing is three uncoordinated patterns today: a plain-
getopt()loop (~16 commands, each with its own hand-writtenswitch/default), positional-only argument checking (offline/diagnostic commands), and one fully hand-rolled long-flag string-compare loop (cmd_walcompare). No shared helper exists for parsing, rejection, or usage rendering — this disconnect between parsing code and the hand-authoredUSAGE_*string table is the direct root cause of bug-1711's drift. - Interrupt handling:
SIGINT→sigbreak()sets a staticbreak_outflag, read in exactly one place (cmd_hello's repeat loop). Every blockingxce_*RPC call waits onpthread_cond_timedwaitwith a fixed 10-second deadline (cbuf_deadline_10s/cbuf_timedget, inlibxce) — a wait a signal-flag cannot interrupt. - REPL input: libedit (
readline()/add_history()only), no completion or key-binding customization registered. - Session settings: a single global
settings[]array shared (not actually per-context) by all fourcommand_ctx_tinstances;cmd_set's-e/+e/continue-on-errortoggle is the existing pattern for a session-scoped boolean. - Serialization: cJSON is already linked (via
struct2json_dep) and used narrowly for job exdata blobs; Cap'n Proto is also linked but exclusively for wire-protocol/checkpoint-blob decoding, not general CLI output. - Script loading:
rcfile_load(path, runner)is a generic, already-reusable "read a file line by line, execute each via a callback" primitive with no cycle protection and a silent-success policy on a missing file (correct for its one existing caller, the startup rc file).
Architecture Pattern & Boundary Map
graph TB
User[Operator or Script]
Readline[Readline Loop]
Dispatch[Command Dispatcher]
FlagSpec[Flag Parsing Layer]
Confirm[Confirmation Gate]
CmdFunc[Command Handler]
Interrupt[Interruptible Call Adapter]
Json[JSON List Formatter]
Source[Source Command]
RcFile[Rcfile Loader]
LibXce[Libxce Rpc Client]
Help[Help Renderer]
User --> Readline
Readline --> Dispatch
Dispatch --> FlagSpec
FlagSpec --> Confirm
Confirm --> CmdFunc
CmdFunc --> Interrupt
Interrupt --> LibXce
CmdFunc --> Json
Dispatch --> Source
Source --> RcFile
RcFile --> Dispatch
FlagSpec --> Help
Dispatch --> Help
Architecture Integration:
- Selected pattern: a layered CLI pipeline — Readline → Dispatcher → Flag Parsing → (Confirmation Gate, destructive commands only) → Command Handler → Interruptible Call Adapter →
libxce. - Domain/feature boundaries: Flag Parsing is one cohesive layer shared by every flag-accepting command; the Interruptible Call Adapter is a second cross-cutting layer used only where a blocking RPC occurs; the Confirmation Gate is a third, used only by the five destructive commands.
- Existing patterns preserved: hand-authored
USAGE_*prose for description/example/related-command text; the single-global session-settings pattern (cmd_set);rcfile.c's file-loading primitive, reused unmodified. - New components rationale: each addresses one specific root cause found in discovery — flag drift, a non-interruptible wait, no cycle guard, no typo-suggestion utility.
- Steering compliance: TDD (a
test_<module>.cper new module, red-green-refactor),gnu17/Meson conventions, negative-errno-style (errnox.h) error propagation throughout.
Technology Stack
| Layer | Choice / Version | Role in Feature | Notes |
|---|---|---|---|
| CLI / testsuite | C (gnu17), Meson | Language/build for all new and modified files | Matches existing test.c |
| CLI parsing | getopt_long (glibc) |
Long-form flag parsing, replacing raw getopt() |
Migrates ~17 commands |
| Structured output | cJSON (already linked via struct2json_dep) |
Requirement 5 JSON mode | Adopted, no new dependency |
| Line editing | libedit (already linked) | Tab completion (Requirement 7) | rl_attempted_completion_function-style hook; exact API to confirm at implementation start |
| Concurrency | POSIX threads (already transitively linked via libxce) |
Interruptible RPC wrapper (Requirement 1) | Detached worker + CAS ownership handoff |
| Test framework | Unity (project-wide) | New test_*.c files |
#ifdef XCE_TESTING for internal-only symbols |
File Structure Plan
src/controller/testsuite/ is a flat directory; new modules follow its existing one-helper-module-per-file convention (rcfile.c/h, waldump.c/h, etc.).
New Files
flagspec.c/flagspec.h— flag-descriptor table format,getopt_longoption-table builder, shared rejection + typo-suggestion helper, help-flag-block renderer. (Requirements 2, 6, 8.2, 10)interruptcall.c/interruptcall.h—xcetest_run_interruptible(): detached-worker-thread wrapper with CAS-based heap-ownership handoff, plus a per-connection lock registry that serializes overlapping calls (including a call issued right after a prior one was abandoned) on the same connection. (Requirement 1)confirm.c/confirm.h— destructive-command confirmation gate, session-scoped toggle, stderr-routed prompt, EINTR-as-decline. (Requirement 9)listfmt_json.c/listfmt_json.h— shared cJSON array/object helpers for the three listing commands' JSON mode. (Requirement 5)test_flagspec.c,test_interruptcall.c,test_confirm.c,test_listfmt_json.c— Unity unit tests, one per new module, following the existingtest_rcfile.c-style convention.test_test_cli.c— new black-box/subprocess (pty-driven) test suite forxce_test's own CLI surface (-h/--version,help <cmd>content, tab completion, command-not-found scoping,sourcecycle/missing-file cases) — this surface has zero prior coverage.
Modified Files
src/controller/testsuite/test.c— exposes a smallxcetest_interrupted()accessor over the existingbreak_outflag forinterruptcall.cto poll; replaces the existingsignal(SIGINT, sigbreak)installation inmain()with an explicitsigaction()call withSA_RESTARTcleared, soconfirm.c's confirmation-prompt read reliably observesEINTR; every flag-parsingcmd_*migrated from rawgetopt()/hand-rolled parsing toflagspec_t+getopt_long; the ~19 blockingxce_*call sites wrapped viaxcetest_run_interruptible();cmd_setextended with the confirmation-toggle branch (mirroring its existing-e/+estyle);cmd_sourceadded and registered in all fourcommand_ttables;xcetest_complete/xcetest_complete_generatorregistered against libedit inmain();cmd_helpextended to callflagspec_render_help();USAGE_*constants split into a generated flag-list portion and retained hand-authored prose;worker_status_str,cmd_help's not-found message,cmd_runtasks,cmd_reslist,cmd_joblist,cmd_getconfcorrected per Requirement 11;cmd_join_worker's defaultmaxmemcorrected per Requirement 12; theargpdocstring inmain()extended to describe the in-sessionhelpcommand per Requirement 4.3 (4.1/4.2's-h/--help/--versionexit-without-REPL behavior is alreadyargp's default and needs no new code, only verification viatest_test_cli.c).src/controller/meson.build— adds the four new.cfiles toxce_test_src, and theirtest_*.ccounterparts plustest_test_cli.cto the appropriate test target(s).
System Flows
Interruptible RPC Call and Ctrl-C Abandonment
sequenceDiagram
participant Op as Operator
participant Cmd as CommandHandler
participant Adapter as InterruptibleAdapter
participant Lock as ConnectionLock
participant Worker as WorkerThread
participant Rpc as LibxceRpc
Op->>Cmd: invoke a command with a blocking RPC
Cmd->>Adapter: run_interruptible call
Adapter->>Lock: acquire lock for the active connection, interruptibly
Lock-->>Adapter: acquired
Adapter->>Worker: spawn detached thread
Worker->>Rpc: blocking xce call, up to ten second deadline
Adapter->>Adapter: poll break_out every fifty milliseconds
Op-->>Adapter: Ctrl-C sets break_out
Adapter->>Adapter: CAS running to abandoned succeeds
Adapter-->>Cmd: return EINTR immediately, lock stays held
Cmd-->>Op: report command as failed, prompt returns
Rpc-->>Worker: reply arrives later
Worker->>Worker: CAS running to done fails, worker frees the block
Worker->>Lock: release lock
A second command issued against the same connection after an abandonment blocks on this same lock-acquisition step — itself interruptibly — until the abandoned worker finishes and releases it, bounding the unsafe-overlap window to zero rather than letting two calls share one connection's single-outstanding-reply state concurrently. A command against a different connection is unaffected, since each connection gets its own lock.
Heap-Block Ownership Handoff
stateDiagram-v2
[*] --> Running
Running --> DoneUnclaimed: worker finishes, CAS succeeds
Running --> Abandoned: caller interrupted, CAS succeeds
DoneUnclaimed --> [*]: caller reads rc and frees the block
Abandoned --> [*]: worker frees the block after finishing
Exactly one atomic compare-and-swap decides who frees the block, regardless of which side "wins" the race — this is what prevents a dangling-stack or double-free bug in the abandonment path (see research.md, "Interruptible RPC via detached worker thread" decision). This block-ownership handoff is orthogonal to, and independent of, the per-connection lock shown in the sequence diagram above: the lock protects the connection's exclusive use across the call's full lifetime; the CAS protects the heap block's lifetime. Both are released/freed by whichever side is left holding them once the worker finishes.
Destructive Command Confirmation Gate
flowchart TD
Start[Destructive command invoked]
CheckBypass{Bypass flag given}
CheckToggle{Session toggle set to skip}
CheckTty{Input from interactive TTY}
Prompt[Prompt operator on stderr]
Answer{Operator answers}
Run[Execute command]
NotRun[Report not run]
Start --> CheckBypass
CheckBypass -- yes --> Run
CheckBypass -- no --> CheckToggle
CheckToggle -- yes --> Run
CheckToggle -- no --> CheckTty
CheckTty -- no --> Run
CheckTty -- yes --> Prompt
Prompt --> Answer
Answer -- yes --> Run
Answer -- no or ctrl c --> NotRun
The TTY check evaluates the top-level session's own stdin (g_interactive), not whether the current line originates from a sourced file (Requirement 13.7) — a sourced destructive command in an otherwise-interactive session still prompts unless the toggle is set.
Requirements Traceability
| Requirement | Summary | Components | Interfaces | Flows |
|---|---|---|---|---|
| 1.1, 1.2, 1.3, 1.4, 1.5 | Ctrl-C cancels a blocked RPC or the input line, never the session | Interruptible Call Adapter, Command Dispatcher | xcetest_run_interruptible |
Interrupt sequence + ownership state diagram |
| 2.1, 2.2, 2.3, 2.4 | Uniform rejection of unrecognized/malformed flags | Flag Parsing Layer | flagspec_reject |
- |
| 3.1, 3.2, 3.3, 3.4 | stderr/stdout discipline | Command Dispatcher, all cmd_* handlers |
- | - |
| 4.1, 4.2, 4.3 | Process-level -h/--help/--version |
Process Entry | argp options |
- |
| 5.1, 5.2, 5.3, 5.4 | JSON output for listing commands | JSON List Formatter | listfmt_json_* |
- |
| 6.1, 6.2, 6.3, 6.4 | Long-form, concept-consistent flags | Flag Parsing Layer | flagspec_t table |
- |
| 7.1, 7.2, 7.3, 7.4 | Command-name tab completion | Tab Completion | xcetest_complete* |
- |
| 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7 | Detailed, context-scoped help content | Help Renderer, Flag Parsing Layer | flagspec_render_help, cmd_help |
- |
| 9.1, 9.2, 9.3, 9.4, 9.5, 9.6, 9.7, 9.8, 9.9 | Confirmation gate for destructive commands | Confirmation Gate | confirm_destructive, cmd_set toggle |
Confirmation flowchart |
| 10.1, 10.2, 10.3 | Actionable flag-typo suggestions | Flag Parsing Layer | flagspec_reject (suggestion path) |
- |
| 11.1, 11.2, 11.3, 11.4, 11.5, 11.6, 11.7, 11.8 | bug-1711 display-defect fixes | Help Renderer, and worker_status_str/cmd_runtasks/cmd_reslist/cmd_joblist/cmd_getconf |
- | - |
| 12.1, 12.2 | bug-1557 join maxmem default fix |
cmd_join_worker, Help Renderer |
- | - |
| 13.1, 13.2, 13.3, 13.4, 13.5, 13.6, 13.7 | source command |
Source Command, rcfile_load (reused) |
cmd_source |
Confirmation flowchart (13.7) |
Components and Interfaces
| Component | Domain/Layer | Intent | Req Coverage | Key Dependencies (P0/P1) | Contracts |
|---|---|---|---|---|---|
| Flag Parsing Layer | CLI Core | getopt_long parsing, rejection, suggestion, help-flag rendering from one descriptor table |
2, 6, 8.2, 10 | getopt_long (P0) |
Service |
| Interruptible Call Adapter | CLI Core | Client-side cancellable wrapper around blocking xce_* calls |
1 | pthread (P0), libxce xceapi (P0) |
Service |
| Confirmation Gate | CLI Core | Gate + session toggle for destructive commands | 9 | cmd_set (P1), Flag Parsing Layer (P1) |
Service |
| JSON List Formatter | Output | cJSON-based structured output for the three listing commands | 5 | cJSON (P0) | Service |
| Tab Completion | REPL | libedit completion hook scoped to the active context | 7 | libedit (P0) | Service |
| Help Renderer | REPL | Assembles help <cmd> from generated flags + hand-authored prose; scopes not-found to the active context |
8, 11.2, 12.2 | Flag Parsing Layer (P0) | Service |
| Source Command | REPL | source <file>, cycle detection, reuse of rcfile_load |
13 | rcfile_load (P0), Confirmation Gate (P1) |
Service |
| Process Entry | Process | argp-based -h/--help/--version |
4 | argp (glibc) (P0) |
Service |
| Bug-1711 Fixes | Output | Text/formatting corrections in existing cmd_* bodies |
11 | — | — |
| Bug-1557 Fix | Data | join's maxmem default correction |
12 | — | — |
Only components introducing a new boundary get a full detail block below; Process Entry and the two bug-fix rows are simple, localized edits to existing functions and need no further elaboration beyond the summary and File Structure Plan.
CLI Core
Flag Parsing Layer
| Field | Detail |
|---|---|
| Intent | One small library, used by every flag-accepting command, that parses via getopt_long from a per-command descriptor table and uniformly rejects/suggests/documents against that same table |
| Requirements | 2.1, 2.2, 2.3, 2.4, 6.1, 6.2, 6.3, 6.4, 8.2, 10.1, 10.2, 10.3 |
Responsibilities & Constraints
- Owns the
flagspec_tdescriptor format, the shared rejection message format, and the shared help-flag-block format. - Does not own each command's business-logic switch on parsed values — only the parse/reject/describe surface.
- Invariant: a
flagspec_t[]table's long-form name for a repeated concept (host, jobid, command line, resource name) is identical across every command that has it — enforced by table review, not at runtime.
Dependencies
- Outbound:
getopt_long(glibc) — P0 - Outbound: a small edit-distance utility introduced by this component itself (not an external dependency) — P0
Contracts: Service [x] / API [ ] / Event [ ] / Batch [ ] / State [ ]
Service Interface
typedef struct {
char short_opt; /* 0 if long-only */
const char* long_opt; /* stable, cross-command-consistent name */
const char* concept; /* e.g. "host", "jobid", "command-line" */
int has_arg; /* no_argument | required_argument */
int required; /* 1 if the command fails without it */
const char* default_desc; /* human text, NULL if none */
} flagspec_t;
int flagspec_build_opts(const flagspec_t* spec, size_t n,
struct option* out_opts, char* out_optstring,
size_t out_optstring_cap);
int flagspec_reject(const char* cmd_name, const flagspec_t* spec, size_t n,
const char* bad_flag, const char* usage_text);
char* flagspec_render_help(const flagspec_t* spec, size_t n);
- Preconditions:
specis a stable static array for the process lifetime; the caller setsopterr = 0before itsgetopt_longloop so libc's own diagnostic never doubles up withflagspec_reject's. - Postconditions:
flagspec_rejectalways returns a negative value and never partially executes the command. - Invariants: the built optstring never begins with
:— missing-argument detection isflagspec_reject's job, not silentgetoptbehavior.
Implementation Notes
- Integration: every
getopt()-using command and the one hand-rolled long-flag command (cmd_walcompare) migrate togetopt_long+ a staticflagspec_t[]+ acase '?': return flagspec_reject(...)arm. - Validation:
test_flagspec.ccovers the suggestion threshold (exactly-one-close-match / no-match / ambiguous), the optstring builder, and the help-render format, independent of any real command. - Risks: existing short-flag collisions (
-mfive ways,-csix ways) mean the same short letter maps to a differentconceptin different commands' tables — expected, not a defect; Requirement 6.2 only requires the long-form name to be consistent per concept, not that short letters be globally unique. - Note (bug-1704):
cmd_qctrl's existingactionvariable has no initializer and no post-loop check that one of-a/-i/-o/-cwas actually given, unlike its siblingcmd_hostctrl, which already initializes to a sentinel and rejects if unset. This migration must apply the same sentinel-and-reject pattern tocmd_qctrl— not just for correctness, but because the Confirmation Gate (Requirement 9.5) depends on reliably knowing whether-i/-cwas given, which is unsafe to check against an uninitialized value.
Interruptible Call Adapter
| Field | Detail |
|---|---|
| Intent | Let a command make a blocking xce_* RPC call cancellably from the operator's perspective, without modifying libxce |
| Requirements | 1.1, 1.2, 1.3, 1.4, 1.5 |
Responsibilities & Constraints
- Owns the heap-block ownership protocol (the CAS state machine) and the poll interval.
- Owns a per-connection lock registry that serializes any two calls sharing the same connection — including a new call issued after a prior one on that connection was abandoned — so a connection's single-outstanding-reply state is never touched by two threads at once.
- Never touches
libxce/xceapi.cinternals — the wrapped call is opaque. - Invariant: a heap-allocated call block's lifetime is resolved by exactly one atomic CAS; no code path frees it from both sides.
- Invariant: at most one worker thread — running or abandoned-but-still-running — is ever active per connection at a time.
Dependencies
- Outbound: pthread create/detach, POSIX semaphore (
sem_t, one per connection, in an internal registry) — P0 (a mutex was considered and rejected during implementation review: POSIX classifies a non-owning-threadpthread_mutex_unlock()as undefined behavior for every mutex type, including the default, and this design's cross-thread release — the worker thread releasing a lock the caller thread acquired — needs a primitive with no ownership concept; see bug-1791) - Outbound: the wrapped
xce_*call, treated as opaque — P0 - Inbound: every command performing a blocking RPC (~19 call sites) — P0
Contracts: Service [x]
Service Interface
typedef int (*xcetest_blocking_fn)(void* ctx);
typedef enum { XCT_RUNNING, XCT_DONE_UNCLAIMED, XCT_ABANDONED } xcetest_call_state_t;
/* Serializes against any other in-flight or abandoned-but-still-running
* call that shares the same active connection (keyed internally by the
* current command_ctx_t's connection pointer, looked up in a small,
* lazily-populated, process-lifetime registry - at most one entry per
* context, so a linear scan suffices). Acquiring that per-connection lock
* is itself interruptible: if break_out fires while waiting for a PRIOR
* call's abandoned worker to release it, this returns -EINTR before ever
* spawning fn. */
int xcetest_run_interruptible(xcetest_blocking_fn fn, void* ctx);
- Preconditions:
fntouches only whatctxitself heap-owns — never caller-stack memory. - Postconditions: on
-EINTR, the caller must not touchctxagain — ownership has passed to the still-running worker thread. The per-connection lock is released by whichever side (caller or worker) is the last to finish with the call, never left held once both sides are done. - Invariants: exactly one of {caller, worker} frees the call block, decided by which side wins the
RUNNING -> {DONE_UNCLAIMED, ABANDONED}CAS.
Implementation Notes
- Integration: each of the ~19 blocking-RPC call sites gets a small per-site static thunk matching
xcetest_blocking_fn; none of them need to pass a connection identity explicitly — the per-connection lock key is read internally from the currently activecommand_ctx_t's connection pointer (exact global/field name to confirm againsttest.cat implementation start, alongside the libedit completion API verification already noted in Tab Completion's Risks). A call site that ever operates on a connection other than the currently active one would break this assumption — none is known to today, but this is worth a one-line check per call site during migration. - Integration: the lock 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
fnthey were given and release the lock/free the block they were handed), so there is no concurrent-insert case to guard against. - Validation:
test_interruptcall.cuses a fake slowfnto exercise both the normal-completion and forced-abandon paths, verifying no double-free and no leak; a second test spawns two overlapping calls against the same fake connection key (one abandoned, one issued immediately after) and asserts they never run concurrently — this is the regression test for the connection-race this component's lock exists to prevent. - Risks: none material remaining — the connection-race risk this component previously left as an unverified follow-up is now closed by the per-connection lock described above.
Confirmation Gate
| Field | Detail |
|---|---|
| Intent | Gate the five destructive commands behind interactive confirmation, a per-command bypass flag, or a session-scoped toggle |
| Requirements | 9.1, 9.2, 9.3, 9.4, 9.5, 9.6, 9.7, 9.8, 9.9 |
Responsibilities & Constraints
- Owns the destructive-command enumeration (9.5), the session toggle state, the stderr-routed prompt, and Ctrl-C-as-decline.
- Does not own the destructive action itself — only whether it proceeds.
Dependencies
- Outbound:
cmd_set(extended with the toggle branch) — P1 - Outbound: Flag Parsing Layer (the per-command
--yesbypass is itself aflagspec_tentry) — P1 - Outbound: the process's SIGINT installation (
main()) — must be installed withoutSA_RESTARTfor the confirmation read to reliably observeEINTR— P0 - Inbound:
cmd_jobkill,cmd_shutdown,cmd_qctrl,cmd_hostctrl,cmd_upgradeconfig— P0
Contracts: Service [x]
Service Interface
int confirm_destructive(const char* action_description);
void confirm_set_skip(int skip);
int confirm_is_skip_set(void);
- Preconditions: called only at the call sites enumerated by Requirement 9.5 (the enumeration's implementation is authoritative — no other command calls this). The process's SIGINT handler must already be installed without
SA_RESTART(see Implementation Notes) — this is a one-time startup precondition, not something each call re-checks. - Postconditions: a 0 return means the caller reports the command as "not run," never "failed" (9.2, 9.9).
- Invariants: the prompt always goes to stderr (9.8); the TTY check is the top-level session's
g_interactive, not "is this line from a sourced file" (13.7).
Implementation Notes
- Integration: each of the five call sites checks
confirm_is_skip_set() || !g_interactive || <its own --yes flag>before callingconfirm_destructive. - Integration: the existing
signal(SIGINT, sigbreak)installation inmain()(test.c:4457) is replaced with an explicitsigaction()call withSA_RESTARTcleared. This is verified, not assumed: glibc'ssignal()uses BSD semantics, which historically restart an interrupted slow syscall by default — if left as-is, the confirmation prompt's blocking read could silently retry instead of returningEINTR, and Requirement 9.9 would never fire. This is a one-time, global change made once at startup; it does not affect the Interruptible Call Adapter, which never relies onEINTR(it pollsbreak_outon a timer instead). - Validation:
test_confirm.csimulates non-TTY stdin for auto-bypass, and a piped decline/EOF/simulated-EINTR answer for the decline paths;test_test_cli.cadds a real pty-driven case that sends an actual SIGINT to the process while a confirmation prompt is displayed, proving the correctedsigaction()installation actually deliversEINTRend-to-end (not just in a simulated unit test). - Risks: none material remaining — the EINTR-reliance risk this component previously left unverified is now closed by the explicit
sigaction()/SA_RESTARTfix and its end-to-end test.
Output
JSON List Formatter
| Field | Detail |
|---|---|
| Intent | Shared cJSON array/object plumbing so the three listing commands can emit their existing per-row data as JSON |
| Requirements | 5.1, 5.2, 5.3, 5.4 |
Responsibilities & Constraints
- Owns only the array/object lifecycle helpers — not each command's field mapping. No generic "record" abstraction is introduced (per the Simplification lens — each command's row struct already exists and is stable).
Dependencies
- Outbound: cJSON (already linked) — P0
- Inbound:
cmd_workerlist,cmd_joblist,cmd_reslist— P0
Contracts: Service [x]
Service Interface
cJSON* listfmt_json_begin(void);
void listfmt_json_add_string(cJSON* row, const char* key, const char* val);
void listfmt_json_add_number(cJSON* row, const char* key, double val);
cJSON* listfmt_json_new_row(cJSON* array);
void listfmt_json_finish(cJSON* array);
- Preconditions: called only when the command's
--jsonflag (a booleanflagspec_tentry, the same long-form name across all three commands per Requirement 6.2) is set. - Postconditions: every field present in the human-readable table row is also present in the JSON object (5.1); no truncation regardless of value length (5.3).
Implementation Notes
- Integration: each command's existing print loop gains an
if (json_mode) { ... } else { existing table row }branch. - Validation:
test_listfmt_json.ccovers array/object lifecycle and long-value non-truncation; per-command field mapping is covered by that command's own test or the black-box CLI suite. - Risks: none material.
REPL
Tab Completion
| Field | Detail |
|---|---|
| Intent | Register a libedit completion function scoped to the active context's command table |
| Requirements | 7.1, 7.2, 7.3, 7.4 |
Responsibilities & Constraints
- Owns the completion-candidate lookup against
active_ctx->commands[]only.
Dependencies
- Outbound: libedit completion API — P0 (exact signature to confirm at implementation start, see
research.mdRisks)
Contracts: Service [x]
Service Interface
char** xcetest_complete(const char* text, int start, int end);
char* xcetest_complete_generator(const char* text, int state);
- Preconditions: registered only when
g_interactiveis true. - Postconditions: never offers a command invalid in the active context (7.4); an unambiguous match completes in full (7.2); multiple matches list candidates without altering the input line (7.3).
Implementation Notes
- Integration: registered once in
main()alongside the existingreadline/add_historysetup. No new header — small enough to live intest.cdirectly. - Validation: covered by
test_test_cli.c(pty-driven — libedit completion isn't practically unit-testable in isolation). - Risks: libedit API confirmation (
research.mdRisks).
Help Renderer
| Field | Detail |
|---|---|
| Intent | Compose help <cmd> from the generated flag block plus each command's hand-authored prose; scope "not found" strictly to the active context |
| Requirements | 8.1, 8.2, 8.3, 8.4, 8.5, 8.6, 8.7, 11.2, 12.2 |
Responsibilities & Constraints
- Owns the assembly order (description -> flags -> example -> related-command/context notes) and the not-found message text.
- Does not own the prose content — still hand-authored per command in the
USAGE_*constants, now split into a prose part and a flagspec-rendered part.
Dependencies
- Outbound: Flag Parsing Layer's
flagspec_render_help— P0
Contracts: Service [x]
Service Interface
Extends the existing cmd_help in test.c — no new header. command_t gains a flagspec_t*/count pair alongside its existing usage pointer; cmd_help's per-command branch calls flagspec_render_help(cmd->flagspec, cmd->flagspec_count) and concatenates it with the command's (now flag-list-free) prose constant.
Implementation Notes
- Integration: every
USAGE_*constant is edited to drop its hand-written flag-list lines (now generated) while keeping description/example/related-command prose. - Validation:
test_test_cli.cchecks a representative sample ofhelp <cmd>outputs for both a generated flag line and the retained hand-authored example, plus the corrected grammar (11.2) andjoin's corrected default (12.2). - Risks: touches all 40
USAGE_*constants — mechanical but broad; sequence as its own task after the Flag Parsing Layer lands.
Source Command
| Field | Detail |
|---|---|
| Intent | source <file>, reusing rcfile_load, with cycle detection and an explicit missing-file error |
| Requirements | 13.1, 13.2, 13.3, 13.4, 13.5, 13.6, 13.7 |
Responsibilities & Constraints
- Owns the in-flight source-path stack (cycle detection) and the missing-file check.
- Does not own
rcfile.c's file-reading loop or its startup-time missing-file policy — unchanged.
Dependencies
- Outbound:
rcfile_load(unmodified) — P0 - Outbound: Confirmation Gate (13.7's TTY-condition clarification) — P1
Contracts: Service [x]
Service Interface
int cmd_source(int argc, char* argv[]); /* registered command_t entry */
int source_push_path(const char* canonical_path); /* 0 ok, -ELOOP on cycle */
void source_pop_path(void);
- Preconditions:
argv[1]is a path;access(path, R_OK)must succeed orcmd_sourcereports the error itself (bypassingrcfile_load's silent-missing behavior). - Postconditions: the path stack is popped exactly once per successful push, on every exit path including an aborted line under
-esemantics. - Invariants:
realpath()is applied before pushing, so a cycle through symlinks or a differently-spelled relative path is still caught.
Implementation Notes
- Integration: registered as a new entry in
top_commands,worker_commands,client_commands, andscheduler_commands. - Validation:
test_test_cli.ccovers a two-file mutual-cycle case and a non-existent-file case as black-box scenarios (these need real files on disk). - Risks: the path stack must be popped on every exit path, not just the success path — call out explicitly in the implementing task.
Data Models
Minimal — xce_test has no persistent storage. The only new data shapes are the C structs already shown above (flagspec_t, the interruptible-call state enum, the per-connection lock registry entry — a connection pointer paired with a sem_t, at most one per live context — and the source-path stack). No database, no schema, no migration.
Error Handling
Error Strategy
Every new or modified path returns a negative errno-style code per this project's existing C convention (errnox.h), and every error/diagnostic message is written to stderr per Requirement 3 — this design extends the existing error-reporting philosophy uniformly rather than introducing a new one.
Error Categories and Responses
- Malformed/unrecognized flag ->
flagspec_reject(stderr + usage + optional suggestion, nonzero return) — Requirements 2, 10. - Blocked RPC interrupted ->
-EINTRfromxcetest_run_interruptible, reported as "failed" (1.3) except at a confirmation prompt, reported as "not run" (9.9). - Destructive command declined -> 0 return, explicit "not run" reporting (9.2).
sourcemissing file / cycle -> stderr error, nonzero return, no partial execution (13.2, 13.5).
Monitoring
Not applicable — xce_test is an interactive/scripted client, not a long-running service. No new logging/metrics infrastructure beyond the existing stderr diagnostics this design extends.
Testing Strategy
Unit Tests
test_flagspec.c— optstring/option-table builder correctness; suggestion logic (exactly-one-match / no-match / ambiguous-match-suppressed); help-render format;cmd_qctrl's corrected sentinel-and-reject validation (bug-1704).test_interruptcall.c— normal completion path and forced-abandon path via a fake slowfn, verifying no double-free under either; two overlapping calls against the same fake connection key (one abandoned, one issued immediately after) never run concurrently, proving the per-connection lock.test_confirm.c— non-TTY auto-bypass; decline path; simulated-EINTR-as-decline path; toggle on/off.test_listfmt_json.c— array/object lifecycle; no truncation of a long field value.
Integration Tests
cmd_sourcetwo-file mutual-cycle detection; missing-file error; nested (non-cyclic) sourcing composing correctly.- Flag rejection end-to-end through a real migrated command (e.g.
jobkill -z), confirming stderr routing + usage + nonzero exit, not just the shared helper in isolation. - Confirmation gate against a real destructive command (
qctrl -c) with the session toggle flipped, confirming no prompt appears and the command executes.
E2E / Black-box Tests (test_test_cli.c, subprocess/pty-driven — the only viable vehicle for these)
-h/--help/--versionprint and exit without entering the REPL (Requirement 4).- Tab completion at the prompt: partial command name + Tab yields the expected single completion or candidate list, scoped to the active context (Requirement 7).
help <cmd>output contains both a generated flag line and the hand-authored example for a representative command in each context;help <unknown>reports not-found without a cross-context hint (Requirement 8, 11.2).- A destructive command run non-interactively (piped stdin) executes without hanging on a prompt (Requirement 9.4).
- A real SIGINT delivered to the process while a confirmation prompt is displayed is observed as
EINTRand reported as "not run" (Requirement 9.9), proving thesigaction()/SA_RESTARTfix end-to-end rather than only in a simulated unit test.
No dedicated Performance/Load section is included — requirements.md states no performance target, and the Interruptible Call Adapter's 50ms poll interval is the only latency-relevant parameter, already justified in its component detail above.
Security Considerations
xce_test remains an internal test-only client; no new authentication/authorization surface is introduced, and connection/credential handling to the controller/worker/scheduler is unchanged and out of this spec's boundary. The Confirmation Gate (Requirement 9) is this design's one safety-relevant feature — it exists specifically to reduce the blast radius of an operator's typo or stale jobid against a live or degraded HA cluster during soak testing, not to guard against any external threat actor.