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:
parent
b4f5a460fc
commit
9a06047da2
6 changed files with 467 additions and 2 deletions
150
src/controller/testsuite/interruptcall.c
Normal file
150
src/controller/testsuite/interruptcall.c
Normal 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();
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue