diff --git a/TODO.org b/TODO.org index a76e8535..ac3f065e 100644 --- a/TODO.org +++ b/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 diff --git a/runtime/flan_dev.c b/runtime/flan_dev.c index 503e91b2..bbd914eb 100644 --- a/runtime/flan_dev.c +++ b/runtime/flan_dev.c @@ -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); diff --git a/test/dev_limits.c b/test/dev_limits.c index 49e49bd6..06ba770a 100644 --- a/test/dev_limits.c +++ b/test/dev_limits.c @@ -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(); diff --git a/test/test_reload.ml b/test/test_reload.ml index 857406b3..57e1080c 100644 --- a/test/test_reload.ml +++ b/test/test_reload.ml @@ -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