feat(xce-test-cli-ux): add per-connection lock to interruptible-call adapter
A registry of per-connection locks, acquired (interruptibly) before spawning a worker and released by whichever side finishes last, so an abandoned call's worker is never running concurrently with a new call on the same connection. Uses a POSIX semaphore (sem_t), not a mutex: review round 1 found the initial pthread_mutex_t implementation relied on behavior POSIX classifies as undefined for a non-owning-thread unlock (not just error-checking mutex types) -- this design's worker thread legitimately releases a lock the caller thread acquired, which sem_t supports with no ownership concept at all. bug-1791. Design.md synced to reflect the semaphore choice. 1285/1285 controller suite passing. Task 1.5 of .kiro/specs/xce-test-cli-ux/tasks.md. Requirements: 1.1, 1.5 Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019jaMf9QEwpugH2W9KAuc7f
This commit is contained in:
parent
9a06047da2
commit
204f81d134
6 changed files with 1226 additions and 60 deletions
|
|
@ -1,7 +1,9 @@
|
|||
#include "interruptcall.h"
|
||||
|
||||
#include <assert.h>
|
||||
#include <errno.h>
|
||||
#include <pthread.h>
|
||||
#include <semaphore.h>
|
||||
#include <stdlib.h>
|
||||
#include <time.h>
|
||||
|
||||
|
|
@ -17,11 +19,19 @@
|
|||
* 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. */
|
||||
* 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;
|
||||
|
||||
|
|
@ -29,6 +39,80 @@ typedef struct {
|
|||
_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
|
||||
|
|
@ -40,26 +124,51 @@ 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. Do not touch it
|
||||
* again from this thread. */
|
||||
/* 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 freeing;
|
||||
* this thread now owns the block and must free it. */
|
||||
* 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;
|
||||
}
|
||||
|
||||
|
|
@ -79,6 +188,7 @@ static void xcetest_sleep_poll_interval(void)
|
|||
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;
|
||||
|
|
@ -86,32 +196,81 @@ int xcetest_run_interruptible(xcetest_blocking_fn fn, void* ctx)
|
|||
if (!fn)
|
||||
return -EINVAL;
|
||||
|
||||
block = malloc(sizeof(*block));
|
||||
if (!block)
|
||||
lock = xcetest_conn_lock_for(g_active_connection);
|
||||
if (!lock)
|
||||
return -ENOMEM;
|
||||
|
||||
block->fn = fn;
|
||||
block->ctx = ctx;
|
||||
block->rc = 0;
|
||||
/* 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` must be resolved by the CAS protocol below
|
||||
* rather than by a join -- an abandoned caller returns without
|
||||
* waiting for this thread at all. */
|
||||
* 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 by itself. */
|
||||
* 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;
|
||||
}
|
||||
|
||||
|
|
@ -121,6 +280,13 @@ int xcetest_run_interruptible(xcetest_blocking_fn fn, void* ctx)
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
@ -129,9 +295,9 @@ int xcetest_run_interruptible(xcetest_blocking_fn fn, void* ctx)
|
|||
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. */
|
||||
* 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue