feat(xce-test-cli-ux): wrap every blocking RPC with the interrupt adapter

Routes all 26 blocking xcecli_* call sites across ~24 commands through
xcetest_run_interruptible(), wires xcetest_set_active_connection() at
execute_line()'s dispatch point (active_ctx->ctx, read fresh per
command), and fixes main()'s REPL loop to distinguish a real EOF from
an EINTR-interrupted readline() so Ctrl-C at an empty prompt discards
the line instead of exiting the session.

Live-discovered and fixed bug-1794: fill_job_alloc() never initializes
xce_job_alloc_t's nres_shared/allocs_shared fields -- harmless as stack
garbage before this task, genuine heap garbage once moved into a
heap-allocated ctx block (a necessary consequence of the adapter's
heap-only precondition). Fixed by switching all ~26 call-context
allocations from malloc to calloc uniformly. Also fixed an argv-aliasing
hazard: cmd_hello/cmd_jobckpt's -m values aliased optarg, which
execute_line() frees immediately after return including on the
abandoned path -- now strdup'd into the heap block.

Also closed a safety-critical gap this task's own design introduced
(bug-1795): before this task the REPL was fully synchronous, so
leave_ctx() (called by up/quit) could never run while an RPC was
outstanding. An abandoned worker can now persist past command
completion, so leave_ctx() tearing down the connection could
use-after-free it. Fixed via xcetest_wait_connection_idle(), which
blocks on the same per-connection semaphore the adapter already
tracks, then releases it, proving no worker is active before teardown.
Proven with a deterministic white-box RED->GREEN test, since the race
itself is too timing-dependent for a black-box test to reliably force.

1361/1361 across the full controller suite (+2), 0 regressions.

Task 3.2 of .kiro/specs/xce-test-cli-ux/tasks.md.
Requirements: 1.1, 1.2, 1.3, 1.4, 1.5

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 20:58:18 +00:00
parent f152c59e75
commit 45120af2cc
No known key found for this signature in database
GPG key ID: 5BCDAAE0123E9FE2
7 changed files with 1887 additions and 70 deletions

View file

@ -314,3 +314,41 @@ int xcetest_run_interruptible(xcetest_blocking_fn fn, void* ctx)
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);
}

View file

@ -249,6 +249,64 @@ void xcetest_set_active_connection(void* conn_key);
*/
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

File diff suppressed because it is too large Load diff

View file

@ -464,6 +464,112 @@ static void test_interrupted_while_waiting_for_lock_never_spawns_worker(void)
wait_for_abandoned_free_count(1);
}
/* -- (iv) xcetest_wait_connection_idle() (task 3.2 remediation round 1,
* bug-1795) -- deterministic proof of the exact mechanism leave_ctx() now
* relies on to close the connection-teardown use-after-free.
*
* test_test_cli.c's black-box test_interrupt_then_immediate_teardown_no_uaf
* exercises the SAME fix end-to-end through a real xce_test subprocess, but
* the actual race window there (between an abandoned worker being woken by
* xcecli_wakeup() and leave_ctx()'s teardown proceeding) is sub-millisecond
* and timing-dependent -- it did not reliably reproduce as a failing test
* even with the fix disabled in this environment (matching the original
* round-1 review's own observation that a clean ASAN run is evidence the
* race didn't fire on THAT run's timing, not evidence the gap doesn't
* exist). This test instead proves xcetest_wait_connection_idle() itself,
* in isolation, with a WIDE, deterministic timing margin (a 700ms abandoned
* worker, not a sub-millisecond one) -- a direct, reliable RED (this
* function returning early would fail it) / GREEN (the shipped
* implementation passes it) demonstration of the invariant leave_ctx()
* depends on. */
#define WCI_SLOW_SLEEP_MS 700
#define WCI_MIN_WAIT_MS 400 /* comfortably below the ~700ms - \
* SIGNAL_DELAY_MS remaining sleep once the \
* call is abandoned, comfortably above \
* scheduling noise if \
* xcetest_wait_connection_idle() did NOT \
* actually wait (that failure mode returns \
* in ~0ms). */
static int wci_slow_fn(void* arg)
{
(void)arg;
sleep_ms(WCI_SLOW_SLEEP_MS);
return SLOW_FN_RC;
}
static void test_wait_connection_idle_blocks_until_abandoned_worker_finishes(
void)
{
static int conn_e; /* private fake connection key for this test */
pthread_t signaler;
struct timespec wait_start;
long waited_ms;
int rc2;
g_test_break_out = 0;
atomic_store(&xcetest_test_abandoned_free_count, 0);
xcetest_install_sigint(test_sigint_handler);
xcetest_set_active_connection(&conn_e);
TEST_ASSERT_EQUAL_INT(
0, pthread_create(&signaler, NULL, send_sigint_after_delay, NULL));
int rc = xcetest_run_interruptible(wci_slow_fn, NULL);
TEST_ASSERT_EQUAL_INT(-EINTR, rc);
TEST_ASSERT_EQUAL_INT(0, pthread_join(signaler, NULL));
/* At this instant the abandoned worker is still running wci_slow_fn()
* -- it has NOT yet freed its call block or released conn_e's lock
* (interruptcall.c's abandoned path). This is exactly the production
* race leave_ctx() closes: xcetest_wait_connection_idle() below must
* genuinely block until that worker finishes, not return immediately. */
TEST_ASSERT_EQUAL_INT_MESSAGE(
0, atomic_load(&xcetest_test_abandoned_free_count),
"the abandoned worker must still be running at this point for "
"this test to actually exercise the wait -- if this fires, the "
"worker finished implausibly fast and the timing margin above "
"needs to grow, not shrink");
clock_gettime(CLOCK_MONOTONIC, &wait_start);
xcetest_wait_connection_idle(&conn_e);
waited_ms = elapsed_ms_since(&wait_start);
TEST_ASSERT_TRUE_MESSAGE(
waited_ms >= WCI_MIN_WAIT_MS,
"xcetest_wait_connection_idle() returned too quickly -- it must "
"genuinely block until the abandoned worker finishes, not return "
"immediately (this is precisely the bug-1795 use-after-free gap: "
"a caller proceeding to tear down the connection before this "
"point would race the still-running worker)");
TEST_ASSERT_EQUAL_INT_MESSAGE(
1, atomic_load(&xcetest_test_abandoned_free_count),
"the abandoned worker's cleanup must have genuinely completed by "
"the time xcetest_wait_connection_idle() returns");
/* The semaphore must be left available (posted back), not held --
* proven by a normal subsequent call on the SAME connection key
* completing without waiting at all. Reset the fake interrupt flag
* first -- xcetest_interrupted() is a pure, non-clearing read (see
* xcetest_interrupt.h's CONTRACT), so the flag the earlier SIGINT set
* is still "on" here and would otherwise spuriously abandon this call
* too, same reasoning as every other multi-call scenario in this
* file. */
g_test_break_out = 0;
clock_gettime(CLOCK_MONOTONIC, &wait_start);
rc2 = xcetest_run_interruptible(fast_fn, NULL);
waited_ms = elapsed_ms_since(&wait_start);
TEST_ASSERT_EQUAL_INT(NORMAL_FN_RC, rc2);
TEST_ASSERT_TRUE_MESSAGE(
waited_ms < WCI_MIN_WAIT_MS,
"xcetest_wait_connection_idle() must release the semaphore back "
"immediately after proving idleness, not hold it -- a subsequent "
"call on the same connection should not itself have to wait");
}
void test_interruptcall(void)
{
UnitySetTestFile(__FILE__);
@ -472,4 +578,5 @@ void test_interruptcall(void)
RUN_TEST(test_same_connection_key_serializes_overlapping_calls);
RUN_TEST(test_different_connection_keys_run_independently);
RUN_TEST(test_interrupted_while_waiting_for_lock_never_spawns_worker);
RUN_TEST(test_wait_connection_idle_blocks_until_abandoned_worker_finishes);
}

View file

@ -151,8 +151,10 @@
* CONCERNS in this task's status report if a future task instead needs
* cross-file reuse.
*/
#include <arpa/inet.h>
#include <errno.h>
#include <limits.h>
#include <netinet/in.h>
#include <poll.h>
#include <pty.h>
#include <signal.h>
@ -160,8 +162,10 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <sys/wait.h>
#include <termios.h>
#include <time.h>
#include <unistd.h>
#include <unity/unity.h>
@ -1434,6 +1438,465 @@ static void test_walcompare_unrecognized_flag_rejected_with_usage(void)
clitest_buf_free(&err);
}
/* ------------------------------------------------------------------ */
/* Interruptible Call Adapter end-to-end (task 3.2, Requirement 1) */
/* ------------------------------------------------------------------ */
/* clitest_start_blackhole_listener - starts a "black hole" TCP peer on
* 127.0.0.1:`port`: it accepts every incoming connection but never reads
* or writes a single byte on any of them, so a client that connects and
* sends a request gets no reply, ever. This is the vehicle for exercising
* Requirement 1's "blocked awaiting a cluster RPC reply" scenario
* deterministically: xce_test's own client library still completes a
* fast, successful TCP connect() (something IS listening and accepting),
* so the actual multi-second block this test needs to interrupt is
* exactly the one interruptcall.c's own header describes -- the blocking
* `xceapi.c` reply wait (`cbuf_timedget`'s fixed 10s deadline), not the
* OS-level connect() path. Returns the listener's pid (to be reaped via
* clitest_stop_blackhole_listener()) on success, or -1 on failure. */
static pid_t clitest_start_blackhole_listener(int port)
{
int lfd;
struct sockaddr_in addr;
int opt = 1;
pid_t pid;
lfd = socket(AF_INET, SOCK_STREAM, 0);
if (lfd < 0) {
return -1;
}
setsockopt(lfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK);
addr.sin_port = htons((uint16_t)port);
if (bind(lfd, (struct sockaddr*)&addr, sizeof(addr)) != 0) {
close(lfd);
return -1;
}
if (listen(lfd, 16) != 0) {
close(lfd);
return -1;
}
pid = fork();
if (pid < 0) {
close(lfd);
return -1;
}
if (pid == 0) {
/* Child: accept forever, never read/write any accepted socket --
* a deliberate silent peer, so the parent xce_test's blocking RPC
* wait never completes on its own. */
for (;;) {
int cfd = accept(lfd, NULL, NULL);
if (cfd < 0) {
if (errno == EINTR) {
continue;
}
_exit(0);
}
/* Leave `cfd` open and untouched; just keep accepting more. */
}
}
close(lfd); /* the parent process only needs the child running */
return pid;
}
static void clitest_stop_blackhole_listener(pid_t pid)
{
if (pid > 0) {
kill(pid, SIGKILL);
waitpid(pid, NULL, 0);
}
}
static long clitest_elapsed_ms(const struct timespec* start)
{
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
return ((long)(now.tv_sec - start->tv_sec) * 1000L)
+ ((now.tv_nsec - start->tv_nsec) / 1000000L);
}
/* test_interrupt_abandons_blocked_rpc_well_before_timeout -- Requirement
* 1.1/1.3/1.5, this task's Required Test Scenario 1+2: a command blocked
* on a real RPC reply (against the black-hole peer above, so the ~10s
* cbuf_timedget deadline is genuinely reachable) is interrupted by a real
* SIGINT; control must return to the prompt in a small fraction of that
* 10s deadline, the command must be reported as failed (Requirement 1.3),
* and the SAME connection/context must accept a normal, non-RPC follow-up
* command immediately afterward (proving the context/connection were left
* intact, Requirement 1.5), all without the session exiting. */
static void test_interrupt_abandons_blocked_rpc_well_before_timeout(void)
{
char xce_test_path[PATH_MAX];
char* const argv[] = { "xce_test", "--norc", NULL };
clitest_session_t sess;
clitest_buf_t out;
clitest_buf_t err;
int exit_code = -999;
int port = 19990 + (int)(getpid() % 1000);
char masters_cmd[64];
pid_t blackhole;
struct timespec interrupt_sent_at;
blackhole = clitest_start_blackhole_listener(port);
TEST_ASSERT_TRUE_MESSAGE(blackhole > 0,
"must be able to start the black-hole TCP "
"listener this test blocks against");
TEST_ASSERT_EQUAL_INT_MESSAGE(
0, resolve_xce_test_path(xce_test_path, sizeof(xce_test_path)),
"must be able to resolve the sibling xce_test binary's path");
clitest_buf_init(&out);
clitest_buf_init(&err);
TEST_ASSERT_EQUAL_INT_MESSAGE(
0, clitest_spawn(&sess, xce_test_path, argv),
"clitest_spawn must launch xce_test behind a real pty");
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "xce>", false, 5000),
"expected the initial 'xce>' interactive prompt on stdout");
snprintf(masters_cmd, sizeof(masters_cmd), "set masters 127.0.0.1:%d",
port);
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, masters_cmd));
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "client"));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "xce/client>", false, 5000),
"entering the client context must succeed even though its "
"connection target is a silent black hole (connect setup does not "
"itself block)");
/* `hello` issues exactly one blocking xcecli_hello() RPC (interrupt-
* call-adapter-wrapped, task 3.2) -- against a peer that never
* replies, this call would otherwise block for ~10s. */
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "hello"));
/* Give the command time to actually reach the blocking RPC wait
* (connect + send are fast; this margin is generous relative to
* both) before sending the interrupt, so this test genuinely
* exercises the "interrupt a wait already in flight" path rather
* than racing dispatch itself. */
usleep(300000); /* 300ms */
/* Reset the capture buffers before sending the interrupt: "xce/client>"
* already appears once in `out` from when this session first entered
* the client context above, so waiting for that SAME substring against
* the accumulated buffer would trivially "succeed" on the very first
* poll regardless of whether a NEW prompt is genuinely printed after
* the interrupt -- discarding prior output makes the wait below (and
* the elapsed-time measurement it gates) observe a real, fresh event. */
clitest_buf_free(&out);
clitest_buf_free(&err);
clitest_buf_init(&out);
clitest_buf_init(&err);
clock_gettime(CLOCK_MONOTONIC, &interrupt_sent_at);
TEST_ASSERT_EQUAL_INT(0, clitest_send_signal(&sess, SIGINT));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "xce/client>", false, 3000),
"the client-context prompt must reappear after the interrupt, well "
"before the RPC's own ~10s deadline");
TEST_ASSERT_LESS_THAN_MESSAGE(
2000L, clitest_elapsed_ms(&interrupt_sent_at),
"control must return to the prompt in a small fraction of the RPC's "
"~10s deadline (observed well under 2s in practice, given the "
"adapter's documented 50ms poll interval)");
/* Requirement 1.5: the SAME connection/context must accept a normal
* follow-up command immediately -- `echo` makes no RPC call at all,
* so an immediate, correct reply here proves the session (and its
* client context) is genuinely alive and responsive, not merely
* appearing to be so. */
TEST_ASSERT_EQUAL_INT(
0, clitest_send_line(&sess, "echo cli_after_interrupt_marker"));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "cli_after_interrupt_marker",
false, 3000),
"a follow-up command on the same connection/context must execute "
"normally right after the interrupt");
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "quit"));
TEST_ASSERT_EQUAL_INT_MESSAGE(
0, clitest_wait_for_exit(&sess, &out, &err, 5000, &exit_code),
"xce_test did not exit within the harness's safety timeout after "
"'quit'");
TEST_ASSERT_EQUAL_INT_MESSAGE(
1, exit_code,
"the interrupted 'hello' must be recorded as a failed command "
"(Requirement 1.3), matching this session's overall exit code");
clitest_kill_and_reap(&sess);
clitest_close(&sess);
clitest_stop_blackhole_listener(blackhole);
clitest_buf_free(&out);
clitest_buf_free(&err);
}
/* test_interrupt_at_empty_prompt_discards_line_without_exiting --
* Requirement 1.2/1.4, this task's Required Test Scenario 3: a Ctrl-C with
* nothing in flight (a partially-typed, unsubmitted line at the prompt)
* must discard that line and redisplay the prompt, never exit the session
* as a side effect -- proven by sending a real, valid command right after
* and confirming it executes normally. */
static void test_interrupt_at_empty_prompt_discards_line_without_exiting(void)
{
char xce_test_path[PATH_MAX];
char* const argv[] = { "xce_test", "--norc", NULL };
clitest_session_t sess;
clitest_buf_t out;
clitest_buf_t err;
int exit_code = -999;
TEST_ASSERT_EQUAL_INT_MESSAGE(
0, resolve_xce_test_path(xce_test_path, sizeof(xce_test_path)),
"must be able to resolve the sibling xce_test binary's path");
clitest_buf_init(&out);
clitest_buf_init(&err);
/* disable_echo=false (clitest_spawn_ex, not plain clitest_spawn): this
* test needs libedit's REAL interactive line-editing state (in
* particular, that a partial, uncommitted line is genuinely discarded
* from its edit buffer on the EINTR path) -- clitest_spawn()'s default
* echo-off session makes THIS project's linked libedit fall back to a
* silent/non-interactive input mode (task 2.3's own documented finding,
* see clitest_spawn_ex()'s header comment) that does NOT discard a
* pending partial line across an interrupted read(), so a truly
* interactive echo-on session is required to observe the correct
* (and, empirically confirmed against a genuine terminal-like pty,
* actually-happening) discard behavior here. */
TEST_ASSERT_EQUAL_INT_MESSAGE(
0, clitest_spawn_ex(&sess, xce_test_path, argv, false),
"clitest_spawn_ex must launch xce_test behind a real pty with echo "
"on");
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "xce>", false, 5000),
"expected the initial 'xce>' interactive prompt on stdout");
/* A partial, unsubmitted line -- nothing in flight, no command has
* been dispatched yet. */
TEST_ASSERT_EQUAL_INT(0, clitest_send_partial(&sess, "ech"));
usleep(300000); /* let libedit's readline() actually be blocked on it */
/* Reset the capture buffers before sending the interrupt: "xce>"
* already appears once in `out` from the session's initial prompt
* above, so waiting for that SAME substring against the accumulated
* buffer would trivially "succeed" on the very first poll regardless
* of whether a NEW prompt is genuinely printed after the interrupt. */
clitest_buf_free(&out);
clitest_buf_free(&err);
clitest_buf_init(&out);
clitest_buf_init(&err);
TEST_ASSERT_EQUAL_INT(0, clitest_send_signal(&sess, SIGINT));
/* Wait for the REDISPLAYED prompt (main()'s new break_out-then-printf
* path) before sending anything else -- this is what makes the next
* step race-free: only once "xce>" has genuinely reappeared do we know
* the interrupted readline() call has already returned and a BRAND
* NEW readline() call, with a fresh/empty edit buffer, is active.
* Sending the next line immediately after clitest_send_signal()
* (without waiting for this) would race the child's own signal
* delivery/handling latency -- if that next line's bytes arrive before
* the child has actually re-entered its blocking read(), they get
* queued as more input to the STILL-PENDING original read() instead of
* starting a fresh one, undetectably from this harness's side (this
* was empirically confirmed while building this test: it manifests as
* a corrupted "echecho ..." command whose raw, still-echoed-back typed
* text nonetheless contains the search marker, making the naive
* text-search assertion below pass despite the underlying command
* actually failing -- caught instead by the exit-code assertion at the
* end, which is what motivated this explicit wait). */
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "xce>", false, 3000),
"the prompt must be redisplayed after Ctrl-C at an empty/partial "
"prompt, before this test sends anything further");
/* Requirement 1.4: never exit as a side effect of a single Ctrl-C.
* Send a real, valid command and confirm it executes normally --
* this is the semantic, redraw-independent proof this session is
* still alive and its discarded partial line did not leak into the
* next command's input (e.g. becoming "echecho ..."). */
TEST_ASSERT_EQUAL_INT(
0, clitest_send_line(&sess, "echo cli_still_alive_marker"));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "cli_still_alive_marker",
false, 3000),
"a real command sent right after Ctrl-C at an empty/partial prompt "
"must execute normally, proving the session did not exit");
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "quit"));
TEST_ASSERT_EQUAL_INT_MESSAGE(
0, clitest_wait_for_exit(&sess, &out, &err, 5000, &exit_code),
"xce_test did not exit within the harness's safety timeout after "
"'quit'");
TEST_ASSERT_EQUAL_INT_MESSAGE(
0, exit_code,
"a session with no genuinely failing command must still exit 0");
clitest_kill_and_reap(&sess);
clitest_close(&sess);
clitest_buf_free(&out);
clitest_buf_free(&err);
}
/* test_interrupt_then_immediate_teardown_no_uaf -- xce-test-cli-ux task 3.2
* remediation round 1 regression guard (bug-1795). Interrupts a blocked RPC
* exactly like test_interrupt_abandons_blocked_rpc_well_before_timeout
* above, but where that test deliberately WAITS for the interrupted prompt
* to redisplay before sending anything else (so ITS OWN elapsed-time
* measurement is race-free), THIS test does the opposite on purpose: it
* sends ".." (the "go up a context" command, bound to cmd_up) IMMEDIATELY
* after the SIGINT, with no settling wait at all -- racing leave_ctx()'s
* connection teardown (invoked via cmd_up) against
* whatever worker thread was abandoned by the interrupt, which may still
* be running for up to its own ~10s deadline (interruptcall.c's
* xcetest_run_interruptible()/xcecli_wakeup()). This is exactly the crux
* the round-1 review flagged as a genuine use-after-free: before this
* remediation, leave_ctx() called xcecli_stop()/xcecli_ctx_fini() (which
* unconditionally frees the connection's api_ctx_t, src/lib/client.c)
* right after a merely best-effort xcecli_wakeup() nudge, with nothing
* forcing it to actually wait for that abandoned worker to finish
* touching the connection first. The fix under test is
* xcetest_wait_connection_idle() (interruptcall.c/.h), which leave_ctx()
* now calls, right before teardown, to block until the SAME per-connection
* semaphore registry xcetest_run_interruptible() itself uses proves no
* worker is active on this connection.
*
* The actual proof this test relies on is running under ASAN (this
* project's `asan` build profile) with a completely clean stderr -- a
* genuine heap-use-after-free is caught by ASAN the instant it happens,
* independent of anything this test does afterward, so the exit-code and
* "no AddressSanitizer text anywhere in stderr" assertions below are what
* actually catch a regression; a merely-non-hanging, prompt-reappeared
* session alone would not have caught the original bug on a timing-lucky
* run. */
static void test_interrupt_then_immediate_teardown_no_uaf(void)
{
char xce_test_path[PATH_MAX];
char* const argv[] = { "xce_test", "--norc", NULL };
clitest_session_t sess;
clitest_buf_t out;
clitest_buf_t err;
int exit_code = -999;
int port = 19990 + (int)(getpid() % 1000) + 1;
char masters_cmd[64];
pid_t blackhole;
blackhole = clitest_start_blackhole_listener(port);
TEST_ASSERT_TRUE_MESSAGE(blackhole > 0,
"must be able to start the black-hole TCP "
"listener this test blocks against");
TEST_ASSERT_EQUAL_INT_MESSAGE(
0, resolve_xce_test_path(xce_test_path, sizeof(xce_test_path)),
"must be able to resolve the sibling xce_test binary's path");
clitest_buf_init(&out);
clitest_buf_init(&err);
TEST_ASSERT_EQUAL_INT_MESSAGE(
0, clitest_spawn(&sess, xce_test_path, argv),
"clitest_spawn must launch xce_test behind a real pty");
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "xce>", false, 5000),
"expected the initial 'xce>' interactive prompt on stdout");
snprintf(masters_cmd, sizeof(masters_cmd), "set masters 127.0.0.1:%d",
port);
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, masters_cmd));
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "client"));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "xce/client>", false, 5000),
"entering the client context must succeed even though its "
"connection target is a silent black hole");
/* Same as the sibling test above: issue the blocking `hello` RPC
* against the black hole, then give it a moment to actually reach the
* blocking wait before interrupting -- this margin is only about
* getting the WORKER thread genuinely blocked inside the RPC, not
* about settling anything after the interrupt (see below, which is
* the entire point of this test). */
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "hello"));
usleep(300000); /* 300ms */
/* Reset capture buffers before the interrupt, same rationale as the
* sibling test above ("xce/client>" already appears once from
* entering the context). */
clitest_buf_free(&out);
clitest_buf_free(&err);
clitest_buf_init(&out);
clitest_buf_init(&err);
TEST_ASSERT_EQUAL_INT(0, clitest_send_signal(&sess, SIGINT));
/* THE POINT OF THIS TEST: send the "go up a context" command ("..",
* bound to cmd_up -- see test.c's command tables) IMMEDIATELY, with NO
* settling wait for the interrupted prompt to reappear first (unlike
* the sibling test above). This is deliberately the adversarial timing
* for the use-after-free the review found -- leave_ctx() (invoked
* here via cmd_up) must genuinely BLOCK, via
* xcetest_wait_connection_idle(), until the abandoned worker thread
* has actually finished with this connection before
* xcecli_stop()/xcecli_ctx_fini() free it, rather than racing it. */
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, ".."));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "xce>", false, 5000),
"the top-level 'xce>' prompt must reappear after '..' tears down "
"the interrupted client connection, with no crash in between");
/* A real follow-up command on the brand-new top-level context proves
* the whole process (not merely the pty) is still alive and
* undamaged after the teardown race above. */
TEST_ASSERT_EQUAL_INT(
0, clitest_send_line(&sess, "echo cli_after_teardown_marker"));
TEST_ASSERT_TRUE_MESSAGE(
clitest_wait_for_text(&sess, &out, &err, "cli_after_teardown_marker",
false, 3000),
"a follow-up command after the interrupt-then-immediate-teardown "
"race must execute normally");
TEST_ASSERT_EQUAL_INT(0, clitest_send_line(&sess, "quit"));
TEST_ASSERT_EQUAL_INT_MESSAGE(
0, clitest_wait_for_exit(&sess, &out, &err, 5000, &exit_code),
"xce_test did not exit within the harness's safety timeout after "
"'quit'");
TEST_ASSERT_TRUE_MESSAGE(
exit_code >= 0,
"the session must exit normally, never via a fatal signal (e.g. "
"ASAN's SIGABRT/SIGSEGV on a detected heap-use-after-free)");
TEST_ASSERT_NULL_MESSAGE(
strstr(err.data, "AddressSanitizer"),
"no AddressSanitizer diagnostic (e.g. heap-use-after-free) may "
"appear anywhere in stderr across this whole session");
clitest_kill_and_reap(&sess);
clitest_close(&sess);
clitest_stop_blackhole_listener(blackhole);
clitest_buf_free(&out);
clitest_buf_free(&err);
}
void test_test_cli(void)
{
UnitySetTestFile(__FILE__);
@ -1446,4 +1909,7 @@ void test_test_cli(void)
RUN_TEST(test_qctrl_flag_forms_short_and_long_both_accepted);
RUN_TEST(test_migrated_commands_sample_reject_unrecognized_flag);
RUN_TEST(test_walcompare_unrecognized_flag_rejected_with_usage);
RUN_TEST(test_interrupt_abandons_blocked_rpc_well_before_timeout);
RUN_TEST(test_interrupt_at_empty_prompt_discards_line_without_exiting);
RUN_TEST(test_interrupt_then_immediate_teardown_no_uaf);
}