feat(xce-test-cli-ux): build interruptible-call heap-ownership mechanism

xcetest_run_interruptible() runs a blocking call on a detached worker
thread while the caller polls xcetest_interrupted() every 50ms. A single
atomic CAS (RUNNING -> DONE_UNCLAIMED | ABANDONED) decides which side
frees the heap-allocated call block, so an abandoned call's worker
thread never double-frees or leaks once it eventually finishes. Proven
under active ASAN with a real detached thread and a real SIGINT-forced
abandonment. 1282/1282 controller suite passing (+2).

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

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 08:29:14 +00:00
parent b4f5a460fc
commit 9a06047da2
No known key found for this signature in database
GPG key ID: 5BCDAAE0123E9FE2
6 changed files with 467 additions and 2 deletions

View file

@ -0,0 +1,166 @@
/*
* test_interruptcall.c -- proves xcetest_run_interruptible()'s (task 1.4,
* Requirements 1.1, 1.2, 1.3, 1.4) heap-block CAS ownership handoff: a
* normal/fast call returns its real result and frees cleanly, and a
* forced-abandonment call returns -EINTR immediately with no double-free
* and no leak -- the abandoned worker thread finishes in the background
* and frees the block itself, proven via a side channel rather than
* assumed from a fixed sleep.
*
* xcetest_interrupted() (declared in xcetest_interrupt.h) is normally
* defined in test.c, over test.c's own private break_out flag -- but
* test.c itself is never linked into controller_unit_test (see
* xcetest_interrupt.h's own header comment: test.c pulls in libedit/
* cJSON/libxce/Cap'n Proto/OpenSSL and defines its own main(), both of
* which make it impractical to link into a small Unity binary). So this
* file provides its OWN local fake definition of xcetest_interrupted(),
* over its own local flag -- the same "local fake standing in for a
* real dependency this binary never links" pattern already used
* elsewhere in this directory (e.g. test_commit_barrier_reply.c's/
* test_worker_join.c's own fakes for on_scheduler_join()/notify()/
* collect_confvars(), per src/controller/meson.build's comment on
* sched_push_unit_test_src). interruptcall.c only ever calls
* xcetest_interrupted() through its documented pure-read contract, so a
* fake reading a test-local flag exercises interruptcall.c's real CAS
* state machine exactly as production would.
*
* The forced-abandonment test triggers a REAL, OS-delivered SIGINT (via
* kill(getpid(), SIGINT), sent from a small helper thread once the
* worker has had time to start) rather than flipping the fake flag
* directly by hand, and installs its handler via xcetest_install_sigint()
* (task 1.3) -- the exact installer test.c's own main() uses -- so this
* test also doubles as a real, signal-level exercise of the same
* interrupt path a live xce_test process would take, not a purely
* hand-simulated one. This is choice (ii) from the task brief (a real
* SIGINT), preferred over choice (i) (a direct break_out setter) because
* no such setter exists on the REAL break_out anyway (xcetest_interrupt.h
* deliberately exposes none) -- and a real signal exercises the actual
* delivery path end-to-end, the same thing test_xcetest_interrupt.c
* already does for xcetest_install_sigint() itself.
*/
#include <errno.h>
#include <pthread.h>
#include <signal.h>
#include <stdatomic.h>
#include <time.h>
#include <unistd.h>
#include <unity/unity.h>
#include "interruptcall.h"
#include "xcetest_interrupt.h"
/* --- local fake for xcetest_interrupted() (see file header) --- */
static volatile sig_atomic_t g_test_break_out = 0;
int xcetest_interrupted(void) { return g_test_break_out; }
static void test_sigint_handler(int signo)
{
(void)signo;
g_test_break_out = 1;
}
/* ---- Scenario (a): normal, uninterrupted completion ---- */
#define NORMAL_FN_RC 7
static int fast_fn(void* ctx)
{
(void)ctx;
return NORMAL_FN_RC;
}
static void test_normal_completion_returns_real_result(void)
{
g_test_break_out = 0;
int rc = xcetest_run_interruptible(fast_fn, NULL);
TEST_ASSERT_EQUAL_INT(NORMAL_FN_RC, rc);
/* No side channel to poll here: on this path the CALLING thread's own
* poll loop reads block->rc and frees the block synchronously, before
* xcetest_run_interruptible() ever returns -- so by the time the
* TEST_ASSERT_EQUAL_INT above runs, cleanup has unconditionally
* already happened. The remaining proof is this whole suite running
* clean under ASAN (no leak, no double-free) in the validating
* build+test run. */
}
/* ---- Scenario (b): forced abandonment via a real SIGINT ---- */
#define SLOW_FN_RC 99
#define SLOW_FN_SLEEP_MS 900
#define SIGNAL_DELAY_MS 150
#define WAIT_FOR_FREE_MS 3000
#define POLL_STEP_MS 20
static int slow_fn(void* ctx)
{
(void)ctx;
struct timespec ts;
ts.tv_sec = 0;
ts.tv_nsec = (long)SLOW_FN_SLEEP_MS * 1000L * 1000L;
nanosleep(&ts, NULL);
return SLOW_FN_RC;
}
/* Runs on its own helper thread so it can deliver SIGINT to the process
* WHILE the main test thread is blocked inside
* xcetest_run_interruptible()'s poll loop below. */
static void* send_sigint_after_delay(void* arg)
{
(void)arg;
struct timespec ts;
ts.tv_sec = 0;
ts.tv_nsec = (long)SIGNAL_DELAY_MS * 1000L * 1000L;
nanosleep(&ts, NULL);
kill(getpid(), SIGINT);
return NULL;
}
static void test_forced_abandonment_returns_eintr_and_worker_cleans_up(void)
{
g_test_break_out = 0;
atomic_store(&xcetest_test_abandoned_free_count, 0);
xcetest_install_sigint(test_sigint_handler);
pthread_t signaler;
TEST_ASSERT_EQUAL_INT(
0, pthread_create(&signaler, NULL, send_sigint_after_delay, NULL));
/* SLOW_FN_SLEEP_MS (900ms) comfortably exceeds SIGNAL_DELAY_MS (150ms)
* plus several 50ms poll intervals, so the interrupt is reliably
* noticed well before slow_fn() itself would have returned -- this
* call is expected to return -EINTR, not SLOW_FN_RC. */
int rc = xcetest_run_interruptible(slow_fn, NULL);
TEST_ASSERT_EQUAL_INT(-EINTR, rc);
TEST_ASSERT_EQUAL_INT(0, pthread_join(signaler, NULL));
/* The abandoned worker is still running slow_fn() in the background
* at this point. Poll the XCE_TESTING-only side channel (NOT part of
* the production API) instead of a single fixed sleep, so this test
* proves the abandoned worker's asynchronous cleanup actually
* happened -- confirming no leak -- rather than merely hoping enough
* wall-clock time passed before the process exits. */
int waited_ms = 0;
while (atomic_load(&xcetest_test_abandoned_free_count) == 0
&& waited_ms < WAIT_FOR_FREE_MS) {
struct timespec poll;
poll.tv_sec = 0;
poll.tv_nsec = (long)POLL_STEP_MS * 1000L * 1000L;
nanosleep(&poll, NULL);
waited_ms += POLL_STEP_MS;
}
TEST_ASSERT_EQUAL_INT(1, atomic_load(&xcetest_test_abandoned_free_count));
}
void test_interruptcall(void)
{
UnitySetTestFile(__FILE__);
RUN_TEST(test_normal_completion_returns_real_result);
RUN_TEST(test_forced_abandonment_returns_eintr_and_worker_cleans_up);
}