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
|
||||
|
|
|
|||
|
|
@ -34,15 +34,82 @@
|
|||
* pthread_cancel() was rejected (cancellation-safety of
|
||||
* pthread_cond_timedwait()'s internal state is not established).
|
||||
*
|
||||
* NOTE ON SCOPE (task 1.4 boundary): this module has NO notion of
|
||||
* "connection identity" at all -- it does not know or care what `ctx`
|
||||
* represents, and does not serialize two calls against each other in any
|
||||
* way. The per-connection lock registry that serializes overlapping calls
|
||||
* sharing a connection (including a call issued right after a prior one
|
||||
* on that connection was abandoned) is task 1.5's addition, layered on
|
||||
* top of this exact xcetest_run_interruptible() signature -- deliberately
|
||||
* unchanged by task 1.5 so it can add that behavior without this module
|
||||
* needing to guess its shape.
|
||||
* PER-CONNECTION LOCK REGISTRY (task 1.5, Requirements 1.1, 1.5): an
|
||||
* abandoned worker thread from task 1.4's mechanism above keeps running
|
||||
* against whatever connection state `ctx` closes over. If the operator's
|
||||
* very next command reuses that SAME connection, its own call would race
|
||||
* the still-running abandoned worker for that connection's single-
|
||||
* outstanding-reply state -- not a rare edge case, but the direct, likely
|
||||
* consequence of Ctrl-C's own purpose (cancel, then immediately try
|
||||
* something else). xcetest_run_interruptible() closes this by acquiring
|
||||
* a per-connection lock -- looked up or lazily created in a small,
|
||||
* process-lifetime registry keyed by whatever `xcetest_set_active_
|
||||
* connection()` was last called with -- BEFORE spawning the worker, and
|
||||
* holding it for the call's full lifetime, including through an
|
||||
* abandonment (ownership of "release the lock" passes to the worker in
|
||||
* that case, exactly mirroring the heap-block CAS handoff above). Lock
|
||||
* acquisition is itself interruptible (the same break_out poll loop), so
|
||||
* a second call waiting on a connection a prior abandoned call still
|
||||
* holds can itself be Ctrl-C'd out of that wait, before ever acquiring
|
||||
* the lock or spawning a worker of its own. See design.md's "Interruptible
|
||||
* RPC Call and Ctrl-C Abandonment" sequence diagram and research.md's
|
||||
* "Decision: Per-connection lock serializes overlapping calls on the
|
||||
* Interruptible Call Adapter" for the full rationale.
|
||||
*
|
||||
* WHY THE LOCK IS A POSIX SEMAPHORE (sem_t), NOT A MUTEX: the handoff
|
||||
* above is a genuine CROSS-THREAD release -- the REPL/caller thread
|
||||
* acquires the lock before spawning the worker, but on the abandonment
|
||||
* path it is the still-running WORKER thread that later releases it,
|
||||
* once fn() finishes. A pthread_mutex_t (of ANY type, including the
|
||||
* default PTHREAD_MUTEX_NORMAL) is the wrong primitive for this: POSIX
|
||||
* defines pthread_mutex_t in terms of a single owning thread, and
|
||||
* classifies pthread_mutex_unlock() from a non-owning thread as
|
||||
* undefined behavior for every mutex type -- PTHREAD_MUTEX_ERRORCHECK/
|
||||
* _RECURSIVE are simply *required* to detect and report that UB (hence
|
||||
* the EPERM an earlier version of this code hit immediately), while
|
||||
* NORMAL/DEFAULT are merely *permitted* not to detect it. POSIX does NOT
|
||||
* separately declare cross-thread unlock well-defined for NORMAL; it
|
||||
* just does not mandate an error return for it. A pthread_mutex_t-based
|
||||
* fix that swaps ERRORCHECK for the default type therefore only trades a
|
||||
* loud crash for a silent reliance on non-portable, POSIX-classified-UB
|
||||
* behavior -- something that happens to hold on this project's actual
|
||||
* target (glibc/NPTL's PTHREAD_MUTEX_NORMAL has had no ownership
|
||||
* tracking for decades, and XCE's Nix-hermetic build is glibc/Linux-only)
|
||||
* but is the wrong thing to build on when a fully-defined alternative
|
||||
* exists at the same code complexity.
|
||||
*
|
||||
* sem_t has NO ownership concept at all: sem_wait()/sem_trywait()/
|
||||
* sem_post() may be called from any thread regardless of which thread
|
||||
* called which, with fully-defined POSIX semantics for exactly this
|
||||
* cross-thread signal pattern. Each registry entry is therefore a binary
|
||||
* semaphore (sem_init(&sem, 0, 1)) used as a mutual-exclusion lock:
|
||||
* sem_trywait() takes the place of pthread_mutex_trylock() in the
|
||||
* interruptible poll loop below (EAGAIN where trylock would have
|
||||
* returned EBUSY), and sem_post() takes the place of
|
||||
* pthread_mutex_unlock() at both release sites -- with no UB caveat on
|
||||
* either.
|
||||
*
|
||||
* WHY THE REGISTRY IS A SINGLY-LINKED LIST, NOT A GROWABLE ARRAY: a
|
||||
* sem_t* handed out by this registry may be cached inside a call block
|
||||
* that a still-running abandoned worker thread holds onto indefinitely
|
||||
* (up to ~10s, per the heap-block ownership handoff above). A growable
|
||||
* array's realloc() can MOVE every previously-returned element the
|
||||
* moment a later, unrelated connection is first seen -- silently
|
||||
* invalidating that worker's pointer into stale/reused memory. A linked
|
||||
* list's nodes are individually allocated and, per this registry's own
|
||||
* "entries are never removed" contract, never freed or moved for the
|
||||
* rest of the process's life, so a pointer into it stays valid forever
|
||||
* once handed out.
|
||||
*
|
||||
* CONCURRENCY OF THE REGISTRY ITSELF: per design.md's Implementation
|
||||
* Notes, "the registry's lookup/insert step needs no synchronization of
|
||||
* its own -- a new registry entry is only ever created from the single
|
||||
* REPL thread that issues commands" (detached worker threads never
|
||||
* initiate a new top-level call; they only run the `fn` they were given
|
||||
* and release the lock/free the block they were handed). The SEMAPHORE
|
||||
* each entry owns, once handed out, is of course safe for concurrent
|
||||
* wait/post from different threads -- that safety is sem_t's own job,
|
||||
* not the registry's.
|
||||
*/
|
||||
|
||||
#include <stdatomic.h>
|
||||
|
|
@ -74,6 +141,42 @@ typedef enum {
|
|||
XCT_ABANDONED
|
||||
} xcetest_call_state_t;
|
||||
|
||||
/*
|
||||
* Sets the connection-identity key that xcetest_run_interruptible() below
|
||||
* reads INTERNALLY when a new call starts, to look up (or lazily create)
|
||||
* that connection's entry in the per-connection lock registry (task 1.5,
|
||||
* see the file header above). This is a simple settable-elsewhere,
|
||||
* read-internally piece of process state -- deliberately mirroring how
|
||||
* break_out/xcetest_interrupted() already works -- NOT a parameter added
|
||||
* to xcetest_run_interruptible()'s own signature, which stays exactly
|
||||
* `(fn, ctx)` per this task's design constraint.
|
||||
*
|
||||
* SEAM FOR A FUTURE CALLER (task 3.2): production code (test.c's cmd_*
|
||||
* functions) does not exist inside this module and has no notion of
|
||||
* "the currently active command_ctx_t's connection pointer" that this
|
||||
* header can reach into directly -- test.c itself is never linked into
|
||||
* this binary's unit tests (see this file's own header and
|
||||
* xcetest_interrupt.h's). The intended real wiring is: before invoking
|
||||
* xcetest_run_interruptible() for a blocking RPC, a cmd_* function (or a
|
||||
* shared dispatch point above all of them) calls
|
||||
* xcetest_set_active_connection(<that connection's own stable pointer,
|
||||
* e.g. the command_ctx_t's connection field>). Any stable, comparable-by-
|
||||
* identity pointer works as `conn_key` -- this module never dereferences
|
||||
* it, only compares it by pointer equality against the registry's keys.
|
||||
* Until task 3.2 wires that in, xce_test's real binary calls this
|
||||
* exactly once, from wherever main()/the dispatcher sets up each
|
||||
* command's active connection context.
|
||||
*
|
||||
* Concurrency: like break_out, callable only from the single REPL thread
|
||||
* that issues top-level commands -- see the file header's "CONCURRENCY
|
||||
* OF THE REGISTRY ITSELF" note. Passing the same key across unrelated
|
||||
* connections would incorrectly serialize them against each other;
|
||||
* passing a fresh/different key per connection is what keeps unrelated
|
||||
* connections independent (Requirement 1.5, "leave ... its underlying
|
||||
* connection intact").
|
||||
*/
|
||||
void xcetest_set_active_connection(void* conn_key);
|
||||
|
||||
/*
|
||||
* Runs `fn(ctx)` on a detached worker thread while THIS (the calling)
|
||||
* thread polls xcetest_interrupted() every ~50ms (design.md's Interruptible
|
||||
|
|
@ -81,6 +184,16 @@ typedef enum {
|
|||
* it immediately instead of blocking for fn()'s own, possibly much
|
||||
* longer, duration.
|
||||
*
|
||||
* Before spawning that worker, this function first acquires -- also
|
||||
* interruptibly, via the same ~50ms xcetest_interrupted() poll, but
|
||||
* using sem_trywait() instead of a blocking sem_wait() -- the
|
||||
* per-connection lock for whichever connection key was last passed to
|
||||
* xcetest_set_active_connection() (task 1.5; see the file header's "PER-
|
||||
* CONNECTION LOCK REGISTRY" section above). This serializes this call
|
||||
* against any other in-flight or abandoned-but-still-running call on the
|
||||
* same connection, so that connection's single-outstanding-reply state
|
||||
* is never touched by two threads at once.
|
||||
*
|
||||
* Preconditions:
|
||||
* - `fn` is non-NULL.
|
||||
* - `fn` touches only what `ctx` itself heap-owns -- NEVER memory on
|
||||
|
|
@ -93,18 +206,32 @@ typedef enum {
|
|||
* - Returns fn()'s own return value when fn() finishes before this
|
||||
* thread's abandon-CAS wins the race (whether or not
|
||||
* xcetest_interrupted() ever became true during the wait) -- see the
|
||||
* header comment's "near-simultaneous" case below.
|
||||
* - Returns -EINTR when this thread observes an interrupt and its own
|
||||
* CAS (RUNNING -> ABANDONED) wins the race. On this return, the
|
||||
* caller MUST NOT touch `ctx` again -- ownership has passed to the
|
||||
* still-running worker thread, which will finish fn() and free the
|
||||
* call block on its own, asynchronously, with no further signal back
|
||||
* header comment's "near-simultaneous" case below. On this path, the
|
||||
* per-connection lock is released by this (the caller's) thread,
|
||||
* after reading rc and freeing the block.
|
||||
* - Returns -EINTR when this thread observes an interrupt WHILE
|
||||
* WAITING FOR THE PER-CONNECTION LOCK -- i.e. before ever acquiring
|
||||
* it. No worker is spawned for this call at all in this case, and
|
||||
* nothing needs releasing, since this call never held the lock to
|
||||
* begin with.
|
||||
* - Returns -EINTR when this thread observes an interrupt AFTER
|
||||
* acquiring the lock and spawning the worker, and its own CAS
|
||||
* (RUNNING -> ABANDONED) wins the race. On this return, the caller
|
||||
* MUST NOT touch `ctx` again -- ownership of both the call block AND
|
||||
* the per-connection lock has passed to the still-running worker
|
||||
* thread, which will finish fn(), free the call block, and release
|
||||
* the lock on its own, asynchronously, with no further signal back
|
||||
* to this caller.
|
||||
* - Returns -EINVAL if `fn` is NULL (fn is never invoked in this case).
|
||||
* - Returns a negative errno from pthread_attr_init()/pthread_create()
|
||||
* if the worker thread could not be set up or spawned at all; the
|
||||
* call block is freed before returning and fn() is never invoked in
|
||||
* this case either.
|
||||
* - Returns -EINVAL if `fn` is NULL (fn is never invoked in this case;
|
||||
* no lock is acquired).
|
||||
* - Returns a negative errno from the per-connection lock registry
|
||||
* lookup (e.g. -ENOMEM), from sem_trywait() itself for an
|
||||
* unexpected error other than EAGAIN, or from pthread_attr_init()/
|
||||
* pthread_create() if the worker thread could not be set up or
|
||||
* spawned at all; in every one of these cases fn() is never invoked,
|
||||
* any call block already allocated is freed before returning, and
|
||||
* the per-connection lock -- if this call had already acquired it --
|
||||
* is released before returning.
|
||||
*
|
||||
* The near-simultaneous race (this thread notices the interrupt in the
|
||||
* same window the worker finishes fn()) is resolved by the SAME single
|
||||
|
|
@ -113,14 +240,12 @@ typedef enum {
|
|||
* attempt} executes first wins, and the loser observes that the state is
|
||||
* no longer RUNNING and falls back to the OTHER side's outcome -- a
|
||||
* caller who loses the abandon-CAS falls through to the normal
|
||||
* completion path (reads rc, frees the block, returns rc) instead of
|
||||
* returning -EINTR; a worker who loses the done-CAS frees the block
|
||||
* itself instead of leaving it for the caller. Exactly one free() of the
|
||||
* block is therefore reachable on every possible interleaving -- never
|
||||
* zero, never two.
|
||||
*
|
||||
* Does not acquire any lock and has no notion of connection identity --
|
||||
* see the file header's "NOTE ON SCOPE" above.
|
||||
* completion path (reads rc, frees the block, releases the lock, returns
|
||||
* rc) instead of returning -EINTR; a worker who loses the done-CAS frees
|
||||
* the block and releases the lock itself instead of leaving them for the
|
||||
* caller. Exactly one free() of the block, and exactly one release of
|
||||
* the lock, are therefore reachable on every possible interleaving --
|
||||
* never zero, never two.
|
||||
*/
|
||||
int xcetest_run_interruptible(xcetest_blocking_fn fn, void* ctx);
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,18 @@
|
|||
* and frees the block itself, proven via a side channel rather than
|
||||
* assumed from a fixed sleep.
|
||||
*
|
||||
* ALSO proves (task 1.5, Requirements 1.1, 1.5) the per-connection lock
|
||||
* registry added on top of that same call: two calls sharing a
|
||||
* connection identity (set via xcetest_set_active_connection()) never
|
||||
* run their fn bodies concurrently -- including a call issued right
|
||||
* after a prior one on that same connection was abandoned, which must
|
||||
* wait (interruptibly) for the abandoned worker to finish and release
|
||||
* the lock -- while two calls against DIFFERENT connection identities
|
||||
* run independently/concurrently. A third scenario proves that a call
|
||||
* interrupted WHILE waiting for a lock still held by a prior abandoned
|
||||
* call returns -EINTR without ever acquiring the lock or spawning a
|
||||
* worker for itself.
|
||||
*
|
||||
* 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
|
||||
|
|
@ -73,7 +85,10 @@ static int fast_fn(void* ctx)
|
|||
|
||||
static void test_normal_completion_returns_real_result(void)
|
||||
{
|
||||
static int conn_normal; /* private fake connection key for this test */
|
||||
|
||||
g_test_break_out = 0;
|
||||
xcetest_set_active_connection(&conn_normal);
|
||||
|
||||
int rc = xcetest_run_interruptible(fast_fn, NULL);
|
||||
|
||||
|
|
@ -119,11 +134,36 @@ static void* send_sigint_after_delay(void* arg)
|
|||
return NULL;
|
||||
}
|
||||
|
||||
/* Polls the XCE_TESTING-only xcetest_test_abandoned_free_count side
|
||||
* channel (NOT part of the production API) until it reaches `expected`
|
||||
* or a generous timeout elapses, then asserts it landed exactly on
|
||||
* `expected` -- so a test proves an abandoned worker's asynchronous
|
||||
* cleanup (free + lock release, see interruptcall.c's abandoned path)
|
||||
* actually completed, rather than merely hoping enough wall-clock time
|
||||
* passed before the process moves on. Shared by every scenario below
|
||||
* that produces an abandoned-but-still-running worker. */
|
||||
static void wait_for_abandoned_free_count(int expected)
|
||||
{
|
||||
int waited_ms = 0;
|
||||
while (atomic_load(&xcetest_test_abandoned_free_count) < expected
|
||||
&& 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(expected, atomic_load(&xcetest_test_abandoned_free_count));
|
||||
}
|
||||
|
||||
static void test_forced_abandonment_returns_eintr_and_worker_cleans_up(void)
|
||||
{
|
||||
static int conn_abandon; /* private fake connection key for this test */
|
||||
|
||||
g_test_break_out = 0;
|
||||
atomic_store(&xcetest_test_abandoned_free_count, 0);
|
||||
xcetest_install_sigint(test_sigint_handler);
|
||||
xcetest_set_active_connection(&conn_abandon);
|
||||
|
||||
pthread_t signaler;
|
||||
TEST_ASSERT_EQUAL_INT(
|
||||
|
|
@ -140,27 +180,296 @@ static void test_forced_abandonment_returns_eintr_and_worker_cleans_up(void)
|
|||
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;
|
||||
}
|
||||
* at this point; wait_for_abandoned_free_count() proves its
|
||||
* asynchronous cleanup actually happened -- confirming no leak --
|
||||
* rather than merely hoping enough wall-clock time passed before the
|
||||
* process exits. */
|
||||
wait_for_abandoned_free_count(1);
|
||||
}
|
||||
|
||||
/* ---- Scenario (c): per-connection lock registry (task 1.5) ----
|
||||
*
|
||||
* Requirements 1.1, 1.5. Three scenarios, per the task's Observable
|
||||
* Completion line:
|
||||
* (i) two overlapping calls sharing the SAME fake connection key
|
||||
* (one abandoned, one issued immediately after) never run their
|
||||
* fn bodies concurrently -- the second waits for the first's
|
||||
* still-running abandoned worker to release the lock;
|
||||
* (ii) two calls against DIFFERENT fake connection keys run
|
||||
* independently -- one's abandonment does not block the other;
|
||||
* (iii) a call interrupted WHILE waiting for a lock still held by a
|
||||
* prior abandoned call returns -EINTR without ever acquiring
|
||||
* the lock or spawning a worker for itself.
|
||||
*
|
||||
* All three reuse the same real-SIGINT-delivery pattern as scenario (b)
|
||||
* above (xcetest_install_sigint() + a helper thread's kill(getpid(),
|
||||
* SIGINT)) rather than flipping g_test_break_out by hand, for the same
|
||||
* reason given in this file's header comment. g_test_break_out is
|
||||
* intentionally reset to 0 between two calls in the same scenario
|
||||
* whenever the SECOND call is expected to complete normally (not be
|
||||
* interrupted itself) -- xcetest_interrupted() is a pure, non-clearing
|
||||
* read (see xcetest_interrupt.h's CONTRACT), so a stale "interrupted"
|
||||
* flag left set by an earlier call in the same test would otherwise be
|
||||
* observed by the second call's own lock-wait or completion-wait poll
|
||||
* and spuriously abandon it too.
|
||||
*/
|
||||
|
||||
/* Shared probe: counts how many fn instances are concurrently "inside"
|
||||
* their body right now, and how many times an instance started while
|
||||
* that count was already nonzero. Used two ways below: scenario (i)
|
||||
* asserts overlap_count stays 0 (same key must serialize); scenario
|
||||
* (ii) asserts it becomes nonzero (different keys must NOT serialize). */
|
||||
typedef struct {
|
||||
_Atomic int running;
|
||||
_Atomic int overlap_count;
|
||||
_Atomic int ran;
|
||||
} overlap_probe_t;
|
||||
|
||||
static int probe_enter(overlap_probe_t* p)
|
||||
{
|
||||
int prev = atomic_fetch_add(&p->running, 1);
|
||||
if (prev != 0)
|
||||
atomic_fetch_add(&p->overlap_count, 1);
|
||||
atomic_store(&p->ran, 1);
|
||||
return prev;
|
||||
}
|
||||
|
||||
static void probe_leave(overlap_probe_t* p)
|
||||
{
|
||||
atomic_fetch_sub(&p->running, 1);
|
||||
}
|
||||
|
||||
static void sleep_ms(long ms)
|
||||
{
|
||||
struct timespec ts;
|
||||
ts.tv_sec = ms / 1000;
|
||||
ts.tv_nsec = (ms % 1000) * 1000L * 1000L;
|
||||
nanosleep(&ts, NULL);
|
||||
}
|
||||
|
||||
static long elapsed_ms_since(const struct timespec* start)
|
||||
{
|
||||
struct timespec now;
|
||||
clock_gettime(CLOCK_MONOTONIC, &now);
|
||||
return (now.tv_sec - start->tv_sec) * 1000L
|
||||
+ (now.tv_nsec - start->tv_nsec) / 1000000L;
|
||||
}
|
||||
|
||||
/* -- (i) same connection key serializes -- */
|
||||
|
||||
#define SAME_KEY_SLOW_SLEEP_MS 900
|
||||
#define SAME_KEY_FAST_SLEEP_MS 50
|
||||
#define SAME_KEY_FAST_RC 42
|
||||
#define SAME_KEY_MIN_WAIT_MS 300 /* well below the ~700ms this call
|
||||
* should actually wait for the
|
||||
* first call's abandoned worker,
|
||||
* comfortably above scheduling
|
||||
* noise if the lock were NOT
|
||||
* enforced (that failure mode
|
||||
* returns in tens of ms). */
|
||||
|
||||
static int same_key_slow_fn(void* arg)
|
||||
{
|
||||
overlap_probe_t* p = arg;
|
||||
probe_enter(p);
|
||||
sleep_ms(SAME_KEY_SLOW_SLEEP_MS);
|
||||
probe_leave(p);
|
||||
return SLOW_FN_RC;
|
||||
}
|
||||
|
||||
static int same_key_fast_fn(void* arg)
|
||||
{
|
||||
overlap_probe_t* p = arg;
|
||||
probe_enter(p);
|
||||
sleep_ms(SAME_KEY_FAST_SLEEP_MS);
|
||||
probe_leave(p);
|
||||
return SAME_KEY_FAST_RC;
|
||||
}
|
||||
|
||||
static void test_same_connection_key_serializes_overlapping_calls(void)
|
||||
{
|
||||
static int conn_a; /* address used purely as an opaque fake key */
|
||||
overlap_probe_t probe = {0};
|
||||
pthread_t signaler;
|
||||
struct timespec wait_start;
|
||||
|
||||
g_test_break_out = 0;
|
||||
atomic_store(&xcetest_test_abandoned_free_count, 0);
|
||||
xcetest_install_sigint(test_sigint_handler);
|
||||
xcetest_set_active_connection(&conn_a);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(
|
||||
0, pthread_create(&signaler, NULL, send_sigint_after_delay, NULL));
|
||||
|
||||
int rc1 = xcetest_run_interruptible(same_key_slow_fn, &probe);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(-EINTR, rc1);
|
||||
TEST_ASSERT_EQUAL_INT(0, pthread_join(signaler, NULL));
|
||||
TEST_ASSERT_TRUE_MESSAGE(atomic_load(&probe.ran),
|
||||
"the slow call must have started running "
|
||||
"before it was abandoned");
|
||||
|
||||
/* conn_a's lock is now held by the still-running abandoned worker.
|
||||
* Reset the fake interrupt flag -- this second call must NOT be
|
||||
* treated as interrupted itself; it is expected to wait for, then
|
||||
* acquire, the same lock once the first worker releases it. */
|
||||
g_test_break_out = 0;
|
||||
|
||||
clock_gettime(CLOCK_MONOTONIC, &wait_start);
|
||||
int rc2 = xcetest_run_interruptible(same_key_fast_fn, &probe);
|
||||
long waited_ms = elapsed_ms_since(&wait_start);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(SAME_KEY_FAST_RC, rc2);
|
||||
TEST_ASSERT_EQUAL_INT(0, atomic_load(&probe.overlap_count));
|
||||
TEST_ASSERT_TRUE_MESSAGE(
|
||||
waited_ms >= SAME_KEY_MIN_WAIT_MS,
|
||||
"second call on the same connection returned too quickly -- the "
|
||||
"per-connection lock did not make it wait for the first call's "
|
||||
"abandoned worker to finish and release it");
|
||||
|
||||
/* By the time rc2's call could acquire the lock, the abandoned
|
||||
* worker's cleanup (free + counter increment, THEN lock release --
|
||||
* see interruptcall.c's abandoned path) must already have happened. */
|
||||
TEST_ASSERT_EQUAL_INT(1, atomic_load(&xcetest_test_abandoned_free_count));
|
||||
}
|
||||
|
||||
/* -- (ii) different connection keys run independently -- */
|
||||
|
||||
#define DIFF_KEY_SLOW_SLEEP_MS 900
|
||||
#define DIFF_KEY_FAST_SLEEP_MS 20
|
||||
#define DIFF_KEY_FAST_RC 77
|
||||
|
||||
static int diff_key_slow_fn(void* arg)
|
||||
{
|
||||
overlap_probe_t* p = arg;
|
||||
probe_enter(p);
|
||||
sleep_ms(DIFF_KEY_SLOW_SLEEP_MS);
|
||||
probe_leave(p);
|
||||
return SLOW_FN_RC;
|
||||
}
|
||||
|
||||
static int diff_key_fast_fn(void* arg)
|
||||
{
|
||||
overlap_probe_t* p = arg;
|
||||
probe_enter(p);
|
||||
sleep_ms(DIFF_KEY_FAST_SLEEP_MS);
|
||||
probe_leave(p);
|
||||
return DIFF_KEY_FAST_RC;
|
||||
}
|
||||
|
||||
static void test_different_connection_keys_run_independently(void)
|
||||
{
|
||||
static int conn_b, conn_c; /* two distinct opaque fake keys */
|
||||
overlap_probe_t probe = {0};
|
||||
pthread_t signaler;
|
||||
|
||||
g_test_break_out = 0;
|
||||
atomic_store(&xcetest_test_abandoned_free_count, 0);
|
||||
xcetest_install_sigint(test_sigint_handler);
|
||||
xcetest_set_active_connection(&conn_b);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(
|
||||
0, pthread_create(&signaler, NULL, send_sigint_after_delay, NULL));
|
||||
|
||||
int rc1 = xcetest_run_interruptible(diff_key_slow_fn, &probe);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(-EINTR, rc1);
|
||||
TEST_ASSERT_EQUAL_INT(0, pthread_join(signaler, NULL));
|
||||
|
||||
/* Reset the fake flag before the second call, same reasoning as
|
||||
* scenario (i) -- this call is on a DIFFERENT connection and must
|
||||
* complete normally. */
|
||||
g_test_break_out = 0;
|
||||
xcetest_set_active_connection(&conn_c);
|
||||
|
||||
int rc2 = xcetest_run_interruptible(diff_key_fast_fn, &probe);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(DIFF_KEY_FAST_RC, rc2);
|
||||
TEST_ASSERT_TRUE_MESSAGE(
|
||||
atomic_load(&probe.overlap_count) >= 1,
|
||||
"different connection keys must not serialize against each "
|
||||
"other -- expected the second call to run concurrently with the "
|
||||
"first call's still-running abandoned worker");
|
||||
|
||||
wait_for_abandoned_free_count(1);
|
||||
}
|
||||
|
||||
/* -- (iii) interrupted while waiting for a held lock: no worker spawned -- */
|
||||
|
||||
#define WAIT_KEY_SLOW_SLEEP_MS 900
|
||||
|
||||
static int wait_key_slow_fn(void* arg)
|
||||
{
|
||||
(void)arg;
|
||||
sleep_ms(WAIT_KEY_SLOW_SLEEP_MS);
|
||||
return SLOW_FN_RC;
|
||||
}
|
||||
|
||||
static _Atomic int g_wait_key_fn_invoked = 0;
|
||||
|
||||
static int wait_key_never_should_run_fn(void* arg)
|
||||
{
|
||||
(void)arg;
|
||||
atomic_store(&g_wait_key_fn_invoked, 1);
|
||||
return 0;
|
||||
}
|
||||
|
||||
static void test_interrupted_while_waiting_for_lock_never_spawns_worker(void)
|
||||
{
|
||||
static int conn_d;
|
||||
pthread_t signaler1, signaler2;
|
||||
struct timespec t0;
|
||||
|
||||
g_test_break_out = 0;
|
||||
atomic_store(&xcetest_test_abandoned_free_count, 0);
|
||||
atomic_store(&g_wait_key_fn_invoked, 0);
|
||||
xcetest_install_sigint(test_sigint_handler);
|
||||
xcetest_set_active_connection(&conn_d);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(
|
||||
0, pthread_create(&signaler1, NULL, send_sigint_after_delay, NULL));
|
||||
|
||||
int rc1 = xcetest_run_interruptible(wait_key_slow_fn, NULL);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(-EINTR, rc1);
|
||||
TEST_ASSERT_EQUAL_INT(0, pthread_join(signaler1, NULL));
|
||||
|
||||
/* conn_d's lock is now held by the still-running abandoned worker
|
||||
* (up to ~750ms left of its 900ms sleep). Reset the fake flag, then
|
||||
* arm a SECOND delayed SIGINT that fires while THIS next call is
|
||||
* still waiting to acquire that same lock -- well before the
|
||||
* abandoned worker could ever release it. */
|
||||
g_test_break_out = 0;
|
||||
TEST_ASSERT_EQUAL_INT(
|
||||
0, pthread_create(&signaler2, NULL, send_sigint_after_delay, NULL));
|
||||
|
||||
clock_gettime(CLOCK_MONOTONIC, &t0);
|
||||
int rc2 = xcetest_run_interruptible(wait_key_never_should_run_fn, NULL);
|
||||
long elapsed_ms = elapsed_ms_since(&t0);
|
||||
|
||||
TEST_ASSERT_EQUAL_INT(0, pthread_join(signaler2, NULL));
|
||||
TEST_ASSERT_EQUAL_INT(-EINTR, rc2);
|
||||
TEST_ASSERT_EQUAL_INT_MESSAGE(
|
||||
0, atomic_load(&g_wait_key_fn_invoked),
|
||||
"a call interrupted while waiting for a held connection lock "
|
||||
"must never spawn a worker / invoke fn for itself");
|
||||
TEST_ASSERT_TRUE_MESSAGE(
|
||||
elapsed_ms < (WAIT_KEY_SLOW_SLEEP_MS - SIGNAL_DELAY_MS),
|
||||
"the interrupted lock-wait should return promptly, well before "
|
||||
"the held lock would ever naturally free");
|
||||
|
||||
/* The first call's abandoned worker is still running in the
|
||||
* background; wait for it to finish so this test leaves no live
|
||||
* thread behind when it returns. */
|
||||
wait_for_abandoned_free_count(1);
|
||||
}
|
||||
|
||||
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);
|
||||
RUN_TEST(test_same_connection_key_serializes_overlapping_calls);
|
||||
RUN_TEST(test_different_connection_keys_run_independently);
|
||||
RUN_TEST(test_interrupted_while_waiting_for_lock_never_spawns_worker);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue