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,150 @@
#include "interruptcall.h"
#include <errno.h>
#include <pthread.h>
#include <stdlib.h>
#include <time.h>
#include "xcetest_interrupt.h"
/* design.md's Interruptible Call Adapter component: poll interval is a
* fixed 50ms, per the component's own Implementation Notes ("poll
* break_out every fifty milliseconds"). Fast enough that an operator's
* Ctrl-C feels immediate; slow enough not to spin the calling thread. */
#define XCETEST_POLL_INTERVAL_NS (50L * 1000L * 1000L) /* 50ms */
/* The heap-allocated call block backing one xcetest_run_interruptible()
* call. `state` is the single atomic word whose CAS transitions decide
* who frees this block -- see interruptcall.h's xcetest_call_state_t
* comment and xcetest_run_interruptible()'s own header comment for the
* full protocol. */
typedef struct {
xcetest_blocking_fn fn;
void* ctx;
int rc;
_Atomic xcetest_call_state_t state;
} xcetest_call_block_t;
#ifdef XCE_TESTING
_Atomic int xcetest_test_abandoned_free_count = 0;
#endif
/* Runs on the detached worker thread. Invokes fn(ctx), stores its result,
* then resolves ownership of `block` via the RUNNING -> DONE_UNCLAIMED
* CAS: on success, the caller (still polling, or about to read the result
* of its own successful poll) owns the block from here and this function
* must not touch it again. On failure, the caller has already forced
* RUNNING -> ABANDONED and returned -EINTR without touching the block
* again -- this thread now owns it and frees it. */
static void* xcetest_worker_main(void* arg)
{
xcetest_call_block_t* block = (xcetest_call_block_t*)arg;
xcetest_call_state_t expected;
block->rc = block->fn(block->ctx);
expected = XCT_RUNNING;
if (atomic_compare_exchange_strong(&block->state, &expected,
XCT_DONE_UNCLAIMED)) {
/* Won the CAS: the caller still owns the block. Do not touch it
* again from this thread. */
return NULL;
}
/* CAS failed. The caller is the only other party that ever writes
* block->state, and the only value it ever writes is XCT_ABANDONED --
* so `expected` (and the block's actual state) can only be
* XCT_ABANDONED here. The caller returned -EINTR without freeing;
* this thread now owns the block and must free it. */
free(block);
#ifdef XCE_TESTING
atomic_fetch_add(&xcetest_test_abandoned_free_count, 1);
#endif
return NULL;
}
/* Sleeps one poll interval. A short nanosleep() legitimately interrupted
* early by a delivered signal (e.g. SIGINT, once xcetest_interrupt.h's
* SA_RESTART-cleared installation is in effect) just means the next
* poll-loop iteration happens slightly sooner than 50ms -- harmless, so
* the return value is deliberately ignored. */
static void xcetest_sleep_poll_interval(void)
{
struct timespec ts;
ts.tv_sec = 0;
ts.tv_nsec = XCETEST_POLL_INTERVAL_NS;
nanosleep(&ts, NULL);
}
int xcetest_run_interruptible(xcetest_blocking_fn fn, void* ctx)
{
xcetest_call_block_t* block;
pthread_attr_t attr;
pthread_t worker;
int rc;
if (!fn)
return -EINVAL;
block = malloc(sizeof(*block));
if (!block)
return -ENOMEM;
block->fn = fn;
block->ctx = ctx;
block->rc = 0;
atomic_init(&block->state, XCT_RUNNING);
rc = pthread_attr_init(&attr);
if (rc != 0) {
free(block);
return -rc;
}
/* Detached: no one ever joins this thread. That is precisely why
* ownership of `block` must be resolved by the CAS protocol below
* rather than by a join -- an abandoned caller returns without
* waiting for this thread at all. */
pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
rc = pthread_create(&worker, &attr, xcetest_worker_main, block);
pthread_attr_destroy(&attr);
if (rc != 0) {
/* The worker never started, so fn() was never invoked and this
* thread unambiguously still owns the block by itself. */
free(block);
return -rc;
}
for (;;) {
xcetest_call_state_t state = atomic_load(&block->state);
if (state == XCT_DONE_UNCLAIMED) {
int result = block->rc;
free(block);
return result;
}
if (xcetest_interrupted()) {
xcetest_call_state_t expected = XCT_RUNNING;
if (atomic_compare_exchange_strong(&block->state, &expected,
XCT_ABANDONED)) {
/* Won the CAS: the worker (still running fn()) will see
* ABANDONED via its own failed CAS once fn() returns and
* free the block itself. Must not touch `block` again
* from here. */
return -EINTR;
}
/* CAS failed: the worker is the only other party that ever
* writes block->state, and the only value it ever writes is
* XCT_DONE_UNCLAIMED -- so the block's actual state can only
* be XCT_DONE_UNCLAIMED here. The worker won the race right
* as the interrupt was noticed; fall through (via the loop's
* next iteration) to the normal completion path above
* instead of returning -EINTR, since the call genuinely
* finished. */
continue;
}
xcetest_sleep_poll_interval();
}
}