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
|
|
@ -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