The result read's losing side of the seqlock is driven by a hook inside the window between the copy and the check
This commit is contained in:
parent
bf827dc55b
commit
c2f26a9909
11
TODO.org
11
TODO.org
@ -1336,10 +1336,13 @@ pops the live restart list, and the generation stamp keeps a nested break from
|
||||
claiming a choice made against the outer one. Rules out deleting them with the
|
||||
transport.
|
||||
|
||||
** TODO The seqlock's losing race has no test
|
||||
The result read copies into the caller's buffer and checks the counter either
|
||||
side; nothing drives the case where the counter moves. The one threaded test races
|
||||
the registry table instead.
|
||||
** DONE The seqlock's losing race has no test
|
||||
CLOSED: [2026-09-25]
|
||||
=flan_dev_result_read_hook= runs between the copy and the second counter read,
|
||||
and dev_limits.c's =race= mode writes from inside that window: once (the read
|
||||
retries and returns the new value), every attempt (it gives up with nothing), and
|
||||
a write left open (it never copies). A hook rather than a second thread, so the
|
||||
interleaving is the same on every run; rules out a timing-based stress test here.
|
||||
|
||||
** TODO The snapshot generation's racing stale claim has no test
|
||||
The nested-break case is tested deterministically now. What is still absent is the
|
||||
|
||||
@ -429,6 +429,13 @@ void flan_dev_result_end(void) {
|
||||
* when it was sizing something to send through a socket. */
|
||||
uint64_t flan_dev_result_cap(void) { return RESULT_MAX; }
|
||||
|
||||
/* Called between the copy and the second read of the counter, when set. It
|
||||
* exists for test/dev_limits.c and nothing else sets it: the losing side of
|
||||
* the race is a write landing inside that window, and a second thread cannot
|
||||
* be made to land there on demand. A hook that writes a value from inside the
|
||||
* window is the same interleaving, every time. */
|
||||
void (*flan_dev_result_read_hook)(void);
|
||||
|
||||
int flan_dev_result_read(char *dst, uint64_t cap, uint64_t *gen,
|
||||
uint64_t *len) {
|
||||
for (int attempt = 0; attempt < 64; attempt++) {
|
||||
@ -438,6 +445,7 @@ int flan_dev_result_read(char *dst, uint64_t cap, uint64_t *gen,
|
||||
if (n > RESULT_MAX) n = RESULT_MAX; /* a torn read cannot overrun */
|
||||
if ((uint64_t)n > cap) n = (size_t)cap;
|
||||
memcpy(dst, result, n);
|
||||
if (flan_dev_result_read_hook != NULL) flan_dev_result_read_hook();
|
||||
/* The copy must be ordered before the second read of the counter, or the
|
||||
* check is of a copy the compiler was free to make afterwards. */
|
||||
__atomic_thread_fence(__ATOMIC_ACQUIRE);
|
||||
|
||||
@ -363,15 +363,91 @@ static int regrace(void) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* ── The seqlock's losing side ───────────────────────────────────────
|
||||
*
|
||||
* [flan_dev_result_read] copies, then checks the counter has not moved. The
|
||||
* single-threaded reads above only ever take the winning side. These take the
|
||||
* other one, deterministically: [flan_dev_result_read_hook] runs inside the
|
||||
* window between the copy and the check, and what it does there is a write.
|
||||
*
|
||||
* Three writers. One that publishes a value once: the read must throw its
|
||||
* copy away and come back with the new value, numbered one higher — a read
|
||||
* that did not check would return the old bytes under the old number, which
|
||||
* is a stale answer described as current. One that publishes on every
|
||||
* attempt: the read runs out of attempts and says so, with no bytes and the
|
||||
* number of the last complete value. And one that opens a write and never
|
||||
* closes it: every attempt sees an odd counter, so the copy never happens and
|
||||
* the hook never runs. */
|
||||
extern void (*flan_dev_result_read_hook)(void);
|
||||
|
||||
static int race_calls;
|
||||
|
||||
static void publish(const char *v) {
|
||||
flan_dev_result_begin();
|
||||
flan_dev_emit((const uint8_t *)v, (int64_t)strlen(v));
|
||||
flan_dev_result_end();
|
||||
}
|
||||
|
||||
static void race_once(void) {
|
||||
race_calls++;
|
||||
flan_dev_result_read_hook = NULL;
|
||||
publish("second");
|
||||
}
|
||||
|
||||
static void race_every(void) {
|
||||
race_calls++;
|
||||
publish("again");
|
||||
}
|
||||
|
||||
static int race(void) {
|
||||
uint64_t gen0, gen, len;
|
||||
int ok;
|
||||
|
||||
publish("first");
|
||||
(void)result_read(&gen0, &len);
|
||||
|
||||
race_calls = 0;
|
||||
flan_dev_result_read_hook = race_once;
|
||||
ok = flan_dev_result_read(rbuf, sizeof rbuf, &gen, &len);
|
||||
printf("once ok %d calls %d gen +%llu value %.*s\n", ok, race_calls,
|
||||
(unsigned long long)(gen - gen0), (int)len, rbuf);
|
||||
|
||||
race_calls = 0;
|
||||
flan_dev_result_read_hook = race_every;
|
||||
ok = flan_dev_result_read(rbuf, sizeof rbuf, &gen, &len);
|
||||
flan_dev_result_read_hook = NULL;
|
||||
{
|
||||
uint64_t now, nlen;
|
||||
(void)result_read(&now, &nlen);
|
||||
printf("every ok %d calls %d len %llu gen %s\n", ok, race_calls,
|
||||
(unsigned long long)len,
|
||||
gen == now ? "last complete" : "not the last complete");
|
||||
}
|
||||
|
||||
race_calls = 0;
|
||||
(void)result_read(&gen0, &len);
|
||||
flan_dev_result_read_hook = race_every;
|
||||
flan_dev_result_begin();
|
||||
ok = flan_dev_result_read(rbuf, sizeof rbuf, &gen, &len);
|
||||
flan_dev_result_read_hook = NULL;
|
||||
printf("open ok %d calls %d len %llu gen +%llu\n", ok, race_calls,
|
||||
(unsigned long long)len, (unsigned long long)(gen - gen0));
|
||||
flan_dev_result_end();
|
||||
(void)result_read(&gen, &len);
|
||||
printf("closed gen +%llu\n", (unsigned long long)(gen - gen0));
|
||||
return 0;
|
||||
}
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
flan_rt_init(argc, argv);
|
||||
if (argc < 2) {
|
||||
fprintf(stderr,
|
||||
"usage: %s cap|names|regfull|regchurn|regrace|regoverflow\n",
|
||||
"usage: %s cap|race|names|regfull|regchurn|regrace|regoverflow\n",
|
||||
argv[0]);
|
||||
return 2;
|
||||
}
|
||||
if (strcmp(argv[1], "cap") == 0) return cap();
|
||||
if (strcmp(argv[1], "race") == 0) return race();
|
||||
if (strcmp(argv[1], "names") == 0) return names();
|
||||
if (strcmp(argv[1], "regfull") == 0) return regfull();
|
||||
if (strcmp(argv[1], "regchurn") == 0) return regchurn();
|
||||
|
||||
@ -547,6 +547,22 @@ let () =
|
||||
if code <> 0 || out <> want_cap then
|
||||
fail "the 4K result cap\n got: %S (exit %d)\n wanted: %S"
|
||||
out code want_cap;
|
||||
(* The seqlock's losing side, driven by a hook inside the window between
|
||||
the copy and the check. A write landing there once makes the read come
|
||||
back with the new value, numbered one higher, on its second attempt. A
|
||||
write landing there every time exhausts the 64 attempts and answers
|
||||
nothing, under the number of the last complete value. A write left open
|
||||
is refused before any copy, so the hook never runs. *)
|
||||
let code, out, err = mode "race" in
|
||||
let want_race =
|
||||
"once ok 1 calls 1 gen +1 value second\n\
|
||||
every ok 0 calls 64 len 0 gen last complete\n\
|
||||
open ok 0 calls 0 len 0 gen +0\n\
|
||||
closed gen +1\n"
|
||||
in
|
||||
if code <> 0 || out <> want_race then
|
||||
fail "the result read losing its race\n got: %S (exit %d, err %S)\n wanted: %S"
|
||||
out code err want_race;
|
||||
(* 4096 distinct names fit; the next one stops the process. The table is
|
||||
fixed and never moves, because a loaded module holds the address of a
|
||||
cell in it, so growing is not available and overrunning is the only
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user