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
143
src/controller/testsuite/interruptcall.h
Normal file
143
src/controller/testsuite/interruptcall.h
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
#pragma once
|
||||
/*
|
||||
* interruptcall.h -- xcetest_run_interruptible(): a detached-worker-thread
|
||||
* wrapper that lets a command make a blocking xce_* RPC call cancellably
|
||||
* from the operator's Ctrl-C, without ever touching libxce's own blocking
|
||||
* primitive (xce-test-cli-ux spec, task 1.4; Requirements 1.1, 1.2, 1.3,
|
||||
* 1.4).
|
||||
*
|
||||
* ROOT CAUSE this module addresses: every blocking xce_* call
|
||||
* (src/lib/xceapi.c) waits on pthread_cond_timedwait() with a fixed
|
||||
* 10-second deadline (cbuf_deadline_10s/cbuf_timedget, src/lib/mylock.c) --
|
||||
* a wait a signal-setting flag alone cannot interrupt (glibc's condvar
|
||||
* wait retries internally). Modifying that shared libxce primitive is out
|
||||
* of this spec's boundary (research.md's Architecture Pattern Evaluation:
|
||||
* "Modify libxce's cbuf_timedget to poll a cancellation token" -- Rejected,
|
||||
* out of boundary). The fix is entirely client-side: run the existing
|
||||
* blocking call, UNMODIFIED, on a detached worker thread; have the calling
|
||||
* thread poll xcetest_interrupted() (xcetest_interrupt.h, task 1.3) on a
|
||||
* short interval instead of blocking on the call itself; on interrupt,
|
||||
* abandon the wait and return immediately, leaving the worker thread to
|
||||
* finish (or hit its own 10s deadline) on its own -- satisfying "leave the
|
||||
* connection intact" (Requirement 1.5) and the design's "cancelling a
|
||||
* client-side wait does not need to cancel the server-side request" note.
|
||||
*
|
||||
* THE HARD PART this module exists to get right: the worker thread and the
|
||||
* calling thread both have a legitimate reason to touch the heap-allocated
|
||||
* call state backing one call (the worker to run fn()/store its result;
|
||||
* the caller to read that result) -- but at most ONE of them may free it,
|
||||
* and it must be resolved without a lock the caller would have to block
|
||||
* on (that would defeat the whole point of "abandon immediately"). See
|
||||
* design.md's "Heap-Block Ownership Handoff" state diagram and
|
||||
* research.md's "Decision: Interruptible RPC via detached worker thread
|
||||
* with CAS-based ownership handoff" for the full rationale, including why
|
||||
* 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.
|
||||
*/
|
||||
|
||||
#include <stdatomic.h>
|
||||
|
||||
/* A blocking call this module runs on a detached worker thread. `ctx` is
|
||||
* whatever heap-owned state `fn` needs -- see xcetest_run_interruptible()'s
|
||||
* preconditions below for the one hard rule about what `fn` may touch. */
|
||||
typedef int (*xcetest_blocking_fn)(void* ctx);
|
||||
|
||||
/* The heap-allocated call block's lifecycle, resolved by exactly one
|
||||
* atomic compare-and-swap regardless of which side "wins":
|
||||
* RUNNING -- fn() has not yet returned, and no one has decided
|
||||
* to abandon the call yet. Initial state.
|
||||
* DONE_UNCLAIMED -- fn() returned and the worker's CAS from RUNNING to
|
||||
* this state succeeded (i.e. the caller had not yet
|
||||
* abandoned it). The CALLER is left owning the
|
||||
* block: it reads rc and frees it.
|
||||
* ABANDONED -- the caller's CAS from RUNNING to this state
|
||||
* succeeded (i.e. it observed an interrupt before
|
||||
* fn() finished). The WORKER is left owning the
|
||||
* block: once fn() eventually returns, its own CAS
|
||||
* attempt (RUNNING -> DONE_UNCLAIMED) fails against
|
||||
* this state, and it frees the block itself instead.
|
||||
* See design.md's "Heap-Block Ownership Handoff" stateDiagram-v2 -- this
|
||||
* enum's three values are exactly its three states. */
|
||||
typedef enum {
|
||||
XCT_RUNNING,
|
||||
XCT_DONE_UNCLAIMED,
|
||||
XCT_ABANDONED
|
||||
} xcetest_call_state_t;
|
||||
|
||||
/*
|
||||
* Runs `fn(ctx)` on a detached worker thread while THIS (the calling)
|
||||
* thread polls xcetest_interrupted() every ~50ms (design.md's Interruptible
|
||||
* Call Adapter component), so a Ctrl-C observed during the wait abandons
|
||||
* it immediately instead of blocking for fn()'s own, possibly much
|
||||
* longer, duration.
|
||||
*
|
||||
* Preconditions:
|
||||
* - `fn` is non-NULL.
|
||||
* - `fn` touches only what `ctx` itself heap-owns -- NEVER memory on
|
||||
* this function's caller's own stack frame. This is the precondition
|
||||
* that makes the -EINTR postcondition below safe: once ownership of
|
||||
* `ctx` has passed to the still-running worker, the caller's own
|
||||
* stack frame may have long since unwound.
|
||||
*
|
||||
* Postconditions:
|
||||
* - 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
|
||||
* 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.
|
||||
*
|
||||
* The near-simultaneous race (this thread notices the interrupt in the
|
||||
* same window the worker finishes fn()) is resolved by the SAME single
|
||||
* CAS deciding both sides at once: whichever of {this thread's
|
||||
* RUNNING->ABANDONED attempt, the worker's RUNNING->DONE_UNCLAIMED
|
||||
* 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.
|
||||
*/
|
||||
int xcetest_run_interruptible(xcetest_blocking_fn fn, void* ctx);
|
||||
|
||||
#ifdef XCE_TESTING
|
||||
/*
|
||||
* Test-only instrumentation, NOT part of the production API and never
|
||||
* even declared unless XCE_TESTING is defined (i.e. never visible to the
|
||||
* real xce_test binary): incremented (via atomic_fetch_add) by the worker
|
||||
* thread immediately after it frees an ABANDONED call block.
|
||||
*
|
||||
* Exists solely so test_interruptcall.c's forced-abandonment test can
|
||||
* confirm the abandoned worker's asynchronous cleanup genuinely completed
|
||||
* before the test process exits, rather than merely hoping a fixed sleep
|
||||
* was long enough -- a still-running detached worker racing a leak
|
||||
* detector's exit-time check could otherwise either mask a real bug or
|
||||
* (more likely, if the implementation above is correct) flag a false
|
||||
* leak on an allocation that was always going to be freed, just not yet.
|
||||
*/
|
||||
extern _Atomic int xcetest_test_abandoned_free_count;
|
||||
#endif
|
||||
Loading…
Add table
Add a link
Reference in a new issue