Move xce_test's 15 production sources + headers from src/controller/testsuite/ into a new src/harness/ component with its own meson.build, and strip every now-dangling controller-side reference (11 controller_unit_test_src entries, 4 standalone test-binary blocks, 11 controller_test.c extern/RUN_TEST lines) so the controller build is never left broken. Fixes a broken relative-symlink bridge (xce_test_controller_link) added to keep two controller-embedded tests (test_test_cli.c, test_xcetest_process_entry.c) finding the relocated xce_test binary until task 2.1 moves them too. Discovered and documented in design.md: four controller-domain regression tests call functions directly from four of xce_test's own files, requiring controller_unit_test to permanently recompile them from src/harness/ — a real dependency neither the original discovery nor design audit caught. controller_unit_test: 1228 Tests 0 Failures 0 Ignored (verified via the Unity binary's own printed summary line, not just meson's aggregate count). Task 1.1 of xce-harness-component. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RLSNJhNoULvVcUhXEFafQC
354 lines
14 KiB
C
354 lines
14 KiB
C
#include "interruptcall.h"
|
|
|
|
#include <assert.h>
|
|
#include <errno.h>
|
|
#include <pthread.h>
|
|
#include <semaphore.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. `lock` is the per-connection lock (task 1.5) this call
|
|
* acquired before spawning its worker; whichever side -- caller or
|
|
* worker -- ends up owning the block also ends up owning the job of
|
|
* releasing `lock`, per the same protocol. It is a sem_t*, not a
|
|
* pthread_mutex_t* -- see interruptcall.h's "WHY THE LOCK IS A POSIX
|
|
* SEMAPHORE" section for why: the release below is a genuine cross-
|
|
* thread handoff, which sem_t (unlike pthread_mutex_t, of any type)
|
|
* defines cleanly. */
|
|
typedef struct {
|
|
xcetest_blocking_fn fn;
|
|
void* ctx;
|
|
int rc;
|
|
sem_t* lock;
|
|
_Atomic xcetest_call_state_t state;
|
|
} xcetest_call_block_t;
|
|
|
|
#ifdef XCE_TESTING
|
|
_Atomic int xcetest_test_abandoned_free_count = 0;
|
|
#endif
|
|
|
|
/* --- Per-connection lock registry (task 1.5) ---
|
|
*
|
|
* See interruptcall.h's "PER-CONNECTION LOCK REGISTRY" and "WHY THE LOCK
|
|
* IS A POSIX SEMAPHORE" file-header sections for the full rationale,
|
|
* including why this is a singly-linked list (pointer stability for a
|
|
* sem_t* a still-running abandoned worker may hold onto indefinitely)
|
|
* rather than a growable array, and why each entry is a sem_t rather
|
|
* than a pthread_mutex_t.
|
|
*/
|
|
typedef struct xcetest_conn_lock_node {
|
|
void* key;
|
|
sem_t sem;
|
|
struct xcetest_conn_lock_node* next;
|
|
} xcetest_conn_lock_node_t;
|
|
|
|
/* Only ever read/written from the single REPL thread that issues
|
|
* top-level commands -- see interruptcall.h's "CONCURRENCY OF THE
|
|
* REGISTRY ITSELF" note. Neither variable needs its own lock. */
|
|
static xcetest_conn_lock_node_t* g_conn_lock_registry = NULL;
|
|
static void* g_active_connection = NULL;
|
|
|
|
void xcetest_set_active_connection(void* conn_key)
|
|
{
|
|
g_active_connection = conn_key;
|
|
}
|
|
|
|
/* Looks up (by pointer equality) or lazily creates the lock entry for
|
|
* `key` in the process-lifetime registry. Returns NULL only if the new
|
|
* node's allocation fails. Nodes are prepended to the list and NEVER
|
|
* freed or moved for the rest of the process's life, so a pointer this
|
|
* function has already returned to a caller stays valid forever,
|
|
* regardless of how many later, unrelated connections are subsequently
|
|
* registered. */
|
|
static sem_t* xcetest_conn_lock_for(void* key)
|
|
{
|
|
xcetest_conn_lock_node_t* node;
|
|
|
|
for (node = g_conn_lock_registry; node; node = node->next) {
|
|
if (node->key == key)
|
|
return &node->sem;
|
|
}
|
|
|
|
node = malloc(sizeof(*node));
|
|
if (!node)
|
|
return NULL;
|
|
|
|
node->key = key;
|
|
/* A binary semaphore (initial value 1), used as a mutual-exclusion
|
|
* lock, NOT a pthread_mutex_t: this design's own ownership-handoff
|
|
* protocol (see xcetest_run_interruptible()'s and
|
|
* xcetest_worker_main()'s comments) REQUIRES a cross-thread release
|
|
* on the abandonment path -- the calling thread acquires the lock,
|
|
* but the still-running WORKER thread is the one that releases it
|
|
* once fn() finishes, since ownership of "release the lock" passed
|
|
* to the worker at the same moment ownership of the call block did.
|
|
* A pthread_mutex_t (ERRORCHECK/_RECURSIVE, or even the DEFAULT/
|
|
* NORMAL type) is the wrong primitive for this: POSIX classifies an
|
|
* unlock from a non-owning thread as undefined behavior for every
|
|
* mutex type -- ERRORCHECK/_RECURSIVE are simply *required* to
|
|
* detect and report it (which is why an earlier version of this code
|
|
* hit an immediate EPERM here), while NORMAL/DEFAULT are merely
|
|
* *permitted* not to detect it, not declared well-defined for it.
|
|
* sem_t has no ownership concept at all -- sem_wait()/sem_trywait()/
|
|
* sem_post() are fully defined regardless of which thread calls
|
|
* which -- so it is the textbook-correct, portable primitive for
|
|
* this exact cross-thread signal, with no UB reliance whatsoever.
|
|
* pshared=0 since this registry is intra-process only. */
|
|
sem_init(&node->sem, 0, 1);
|
|
|
|
node->next = g_conn_lock_registry;
|
|
g_conn_lock_registry = node;
|
|
return &node->sem;
|
|
}
|
|
|
|
/* 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;
|
|
sem_t* lock = block->lock;
|
|
|
|
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 AND the
|
|
* per-connection lock. Do not touch either 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 touching
|
|
* the block or the lock again; this thread now owns both. Free the
|
|
* block (and record the test-only counter) BEFORE releasing the
|
|
* lock, so that any other thread waiting on this same connection's
|
|
* lock only ever observes it become available once this cleanup has
|
|
* already fully happened. */
|
|
free(block);
|
|
#ifdef XCE_TESTING
|
|
atomic_fetch_add(&xcetest_test_abandoned_free_count, 1);
|
|
#endif
|
|
{
|
|
/* sem_post(), unlike pthread_mutex_unlock(), is well-defined from
|
|
* any thread regardless of which thread called sem_wait()/
|
|
* sem_trywait() -- see interruptcall.h's "WHY THE LOCK IS A
|
|
* POSIX SEMAPHORE" section. This cross-thread release is exactly
|
|
* the case that primitive was chosen for. */
|
|
int post_rc = sem_post(lock);
|
|
(void)post_rc; /* checked via assert() below, never inside it --
|
|
* an assert() argument's side effects vanish
|
|
* under NDEBUG, which must never apply to the
|
|
* post call itself. NOTE: this project's
|
|
* `release` build profile compiles with -DNDEBUG
|
|
* (unlike asan/debug), so this assert() -- like
|
|
* every other one in this file -- compiles out
|
|
* entirely there; it is a debug/asan-only safety
|
|
* net, not a guarantee in a release-profile
|
|
* xce_test binary. */
|
|
assert(post_rc == 0);
|
|
}
|
|
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;
|
|
sem_t* lock;
|
|
pthread_attr_t attr;
|
|
pthread_t worker;
|
|
int rc;
|
|
|
|
if (!fn)
|
|
return -EINVAL;
|
|
|
|
lock = xcetest_conn_lock_for(g_active_connection);
|
|
if (!lock)
|
|
return -ENOMEM;
|
|
|
|
/* Interruptible lock acquisition (task 1.5): poll sem_trywait() on
|
|
* the same interval used for the completion-wait poll below,
|
|
* checking xcetest_interrupted() between attempts. A plain
|
|
* sem_wait() would block uninterruptibly, defeating the whole point
|
|
* of letting a second call on this same connection be Ctrl-C'd out
|
|
* of waiting for a prior abandoned call to release it.
|
|
* sem_trywait() returns -1/EAGAIN when the semaphore is currently
|
|
* at 0 (held) -- the direct equivalent of pthread_mutex_trylock()'s
|
|
* EBUSY for this poll loop's purposes. If interrupted here, this
|
|
* call never acquires the lock and never spawns a worker -- there
|
|
* is nothing to release. */
|
|
for (;;) {
|
|
if (sem_trywait(lock) == 0)
|
|
break; /* acquired */
|
|
if (errno != EAGAIN)
|
|
return -errno; /* unexpected sem_trywait() error; nothing held */
|
|
|
|
if (xcetest_interrupted())
|
|
return -EINTR; /* never acquired; nothing to release */
|
|
|
|
xcetest_sleep_poll_interval();
|
|
}
|
|
|
|
block = malloc(sizeof(*block));
|
|
if (!block) {
|
|
rc = sem_post(lock);
|
|
(void)rc;
|
|
assert(rc == 0);
|
|
return -ENOMEM;
|
|
}
|
|
|
|
block->fn = fn;
|
|
block->ctx = ctx;
|
|
block->rc = 0;
|
|
block->lock = lock;
|
|
atomic_init(&block->state, XCT_RUNNING);
|
|
|
|
rc = pthread_attr_init(&attr);
|
|
if (rc != 0) {
|
|
free(block);
|
|
{
|
|
/* NOTE: this assert(), like every other one in this file,
|
|
* compiles out under -DNDEBUG (this project's `release`
|
|
* build profile) -- a documented debug/asan-only safety net,
|
|
* not a release-build guarantee. */
|
|
int post_rc = sem_post(lock);
|
|
(void)post_rc;
|
|
assert(post_rc == 0);
|
|
}
|
|
return -rc;
|
|
}
|
|
/* Detached: no one ever joins this thread. That is precisely why
|
|
* ownership of `block` (and of `lock`) 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, and the lock, by
|
|
* itself. */
|
|
free(block);
|
|
{
|
|
/* NDEBUG caveat: see the pthread_attr_init() failure branch
|
|
* above. */
|
|
int post_rc = sem_post(lock);
|
|
(void)post_rc;
|
|
assert(post_rc == 0);
|
|
}
|
|
return -rc;
|
|
}
|
|
|
|
for (;;) {
|
|
xcetest_call_state_t state = atomic_load(&block->state);
|
|
|
|
if (state == XCT_DONE_UNCLAIMED) {
|
|
int result = block->rc;
|
|
free(block);
|
|
{
|
|
/* NDEBUG caveat: see the pthread_attr_init() failure
|
|
* branch above. */
|
|
int post_rc = sem_post(lock);
|
|
(void)post_rc;
|
|
assert(post_rc == 0);
|
|
}
|
|
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,
|
|
* free the block, and release `lock` itself. Must not
|
|
* touch `block` or `lock` 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();
|
|
}
|
|
}
|
|
|
|
/* 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);
|
|
}
|