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:
Joseph Ferano 2026-09-25 10:12:15 +07:00
parent bf827dc55b
commit c2f26a9909
4 changed files with 108 additions and 5 deletions

View File

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

View File

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

View File

@ -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();

View File

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