From 5caf455e6674c8c5271c224a270a84b0954d6e34 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Fri, 18 Sep 2026 07:36:45 +0700 Subject: [PATCH 1/2] A compaction that can reclaim nothing does not run, and a listing nobody could read says so The registry compacted whenever the table was three quarters full, and a compaction reclaims dead entries and nothing else. A program holding more than three quarters of the table in live blocks therefore compacted on every allocation for the rest of its life, reclaiming nothing each time and holding the table-wide epoch odd while it did. A listing racing that loop lost all eight of its attempts and answered with zero rows -- "nothing is held", about a program holding three thousand blocks, from the verb that exists to find a leak. Measured at 199 wrong answers in 200. The trigger now also asks whether there is an eighth of a table's worth of dead to reclaim, which is a count four places maintain: a death, an arena's free-all, a note written over a dead slot, and the sweep itself. That bounds the cost from the other side too, since a sweep that runs reclaims at least 512 slots and so cannot run twice in 512 allocations. Separately, flan_dev_reg_by_type answered a walk it could not take with zero rows, which is the same number a program that had freed everything gets, and stepped past slots flan_reg_snap could not copy while still calling the walk whole. It now counts those slots and returns -1 with the count, the agent refuses in a sentence the daemon already renders, and the snap contract says which caller keeps it and why reg_at is allowed not to. A note that finds no slot is still dropped -- dying because a diagnostic ran out of room would be the diagnostic shooting the patient -- and now says so on stderr once, quoting how many entries were dead rather than claiming the table is all live. test/dev_limits.c gains three modes, driven from test_reload: 3100 live blocks read under a writer thread (1 right in 200 before, 200 after), 3000 live with 600 churned on top of them to prove the sweep still runs, and a genuinely full table that must say so exactly once. --- docs/BUGS-2026-09-18.md | 10 ++- docs/BUILT.md | 17 ++++ runtime/flan_dev.c | 178 +++++++++++++++++++++++++++++++++----- test/dev_limits.c | 174 ++++++++++++++++++++++++++++++++++++- test/test_reload.ml | 23 +++++ vendor/agent/flan_agent.c | 39 ++++++++- 6 files changed, 413 insertions(+), 28 deletions(-) diff --git a/docs/BUGS-2026-09-18.md b/docs/BUGS-2026-09-18.md index 1a3b14c..e6b43fc 100644 --- a/docs/BUGS-2026-09-18.md +++ b/docs/BUGS-2026-09-18.md @@ -76,10 +76,12 @@ territory; fix or record, the lane's call. invariant, not a bounds check, but sits behind `if f.md.checks` in both backends (`emit.ml:1037`, `x86.ml:2164`; same for SliceFromPtr's `n >= 0`). A negative-length slice reaches user code. `flan_vec_as_slice` checks unconditionally — the model. -- **`reg leaks` lies at >3072 live blocks** (`runtime/flan_dev.c:1521`): compaction then - fires on every allocation, the epoch stays odd, all 8 scan retries fail, and - `flan_dev_reg_by_type` returns 0 — measured 198/200 wrong answers on a full table. - Plus `flan_reg_snap` failure is `continue`d past, contradicting its own contract. +- ~~**`reg leaks` lies at >3072 live blocks**~~ — **fixed**. Compaction now also asks + whether there is an eighth of a table's worth of dead to reclaim, so a table full of + live blocks stops thrashing the epoch; `flan_dev_reg_by_type` returns −1 with an unread + count rather than zero rows, and the agent refuses in a sentence; a note dropped by a + genuinely full table says so on stderr once. `test/dev_limits.c regfull` pins it under + a writer thread: 3100 live blocks, 200 asks, 199 wrong before and 200 right after. - **Emacs framing**: a truncated frame raises wrong-type instead of the timeout message and leaves the partial frame in the buffer, desyncing every later request by one frame (`flan-dev.el:220`); `extract-reply` reads before it deletes, so an unreadable payload diff --git a/docs/BUILT.md b/docs/BUILT.md index 5f33f1a..61e9a41 100644 --- a/docs/BUILT.md +++ b/docs/BUILT.md @@ -4977,6 +4977,16 @@ and an entry is dropped only when the allocator hands the same address out again stopped being true. When the table fills it is compacted, dropping the dead and re-inserting the live; in a long-running program the dead are the bulk of it. +**And only when the dead are worth reclaiming**, which is a correctness rule and not a tuning one. Compaction reclaims +dead entries and nothing else, so "three quarters full" alone made a program holding more than three quarters of the +table *live* compact on every allocation for the rest of its life, reclaiming nothing each time and holding the +table-wide counter odd while it did. A listing racing that loop lost all eight of its attempts and answered "nothing is +held" about a program holding three thousand blocks — 199 wrong answers in 200, measured. The trigger now also asks +whether there are at least an eighth of a table's worth of dead to reclaim, which bounds the cost from the other side +too: a compaction that runs reclaims at least 512 slots, so it cannot run more than once per 512 allocations. A table +that is genuinely full of live blocks stops rearranging itself, drops the note, and says so on stderr once — the flag +every listing already carried was only visible to somebody who asked. + ### What it is for: permission, not identification This is the part worth stating plainly, because the obvious reading is wrong. @@ -5102,6 +5112,13 @@ The whole answer here is the branch. out, and two walks would drift. Formatting is in the agent and ordering is in the daemon — biggest first, by bytes, because a breakdown in table order is a list of everything and answers nothing. +**A walk that could not be taken returns −1, never zero rows.** The two are not the same answer and this is the verb +where confusing them costs the most: no rows is what a program that has freed everything looks like, and the person +asking is asking because they suspect the opposite. A slot the writer kept winning is counted rather than stepped over +— `flan_reg_snap`'s zero means "a slot I could not read", and a walk that skipped one and still called itself whole +would be a leak report quietly missing the leak — and the agent turns a refusal into an `err` line naming how many +slots would not hold still, which the daemon already renders as the editor's message. + **"At exit" is not a hook, and the honest reason is that a game is killed.** A program stopped by a signal runs no `atexit` handler, no destructor, nothing — so no code written inside the program could report anything about the run that matters most. The authoritative reader is therefore `(:op "leaks")`, which reads the same table over the agent diff --git a/runtime/flan_dev.c b/runtime/flan_dev.c index 28d0b49..438bde9 100644 --- a/runtime/flan_dev.c +++ b/runtime/flan_dev.c @@ -1039,6 +1039,28 @@ void *flan_dev_frame_slot(const void *frame, int32_t i) { * see flan_dev_reg_note. */ #define FLAN_REG_CAP 4096 +/* How many dead entries make a compaction worth running. Not a tuning knob: it + * is the difference between a diagnostic that works on a big program and one + * that lies to it. + * + * The high-water mark alone said "three quarters full, rearrange". But a + * compaction reclaims *dead* entries and nothing else, so a program holding + * more than three quarters of the table in live blocks met that mark on every + * allocation for the rest of its life, and every one of those allocations + * dragged the table-wide epoch odd and back. A listing racing that loop lost + * all eight of its attempts and answered "nothing is held" about a program + * holding three thousand blocks — measured at 198 wrong answers in 200. The + * verb that exists to find a leak reported the absence of one. + * + * So the trigger asks the question the work can actually answer: is there + * enough dead here to be worth the sweep. An eighth of the table is the floor, + * which bounds the cost from the other side too — a compaction that runs + * reclaims at least 512 slots, so it cannot run more than once per 512 + * allocations, and the epoch is quiet in between. A table that is full of + * genuinely live blocks now stops rearranging itself and says it is full, + * which is the truth and was always the truth. */ +#define FLAN_REG_RECLAIM (FLAN_REG_CAP / 8) + typedef struct { const char *type; /* the Flan spelling, e.g. "Enemy" or "(Vec i32)" */ int64_t typelen; @@ -1089,7 +1111,18 @@ static void flan_reg_end(flan_reg_entry *e) { } /* One slot, copied whole or not at all. 0 means the writer kept winning, which - * a caller reports as a slot it could not read rather than as an empty one. */ + * a caller reports as a slot it could not read rather than as an empty one. + * + * [flan_dev_reg_by_type] keeps that: it counts the slots it could not copy and + * refuses the whole listing rather than describing a table it only partly saw, + * because "no rows" is exactly what a program that had freed everything would + * look like. [flan_dev_reg_at] steps past them instead, and is allowed to + * because it is asking a different question — it wants the one block that + * contains an address, and a slot it could not read either did not hold that + * block, in which case skipping it costs nothing, or did, in which case the + * whole call answers "never heard of this address", which is the answer it + * already gives for a stack local and the daemon already gates it to a stopped + * program. */ static int flan_reg_snap(flan_reg_entry *e, flan_reg_entry *out) { int attempt; for (attempt = 0; attempt < 64; attempt++) { @@ -1126,10 +1159,42 @@ static int flan_reg_scan_ok(uint64_t at) { * table nothing in that build ever writes. */ static flan_reg_entry *flan_reg; static int64_t flan_reg_used; /* live + dead slots in use */ +static int64_t flan_reg_dead; /* how many of those have died */ static int64_t flan_reg_seq; /* a monotonic clock, in events */ static int flan_reg_on; /* only a dev build turns this on */ static int flan_reg_full; /* something found no slot */ +/* The table filled, and whoever is reading a listing is told twice: by this + * flag, which every listing carries so that its numbers are read as a floor, + * and once on stderr at the moment it happens. + * + * The line is here because the flag alone is only seen by someone who asks. + * A note that finds no slot is dropped — killing a game because its + * diagnostic ran out of room would be the diagnostic shooting the patient — + * and a drop that nobody is told about is the table quietly becoming a + * different table than the one its reader thinks they are reading. + * + * Once, not per note, and the difference is not tidiness. This runs on the + * game thread inside the allocation hook, and stderr in a dev build is a pipe + * the daemon reads; one bounded write is what a frame can afford, and a line + * per note would be sixty a second into a pipe nobody is draining while a + * request is being served. The second line would carry nothing the first did + * not anyway. + * + * [why] is the caller's sentence because the two ways to get here are not the + * same fact and neither is "the table is full of live blocks": a probe that + * ran out of slots found every slot *in use*, which is not the same as every + * slot live, and the caller is the one holding the count that says which. */ +static void flan_reg_say_full(const char *why) { + if (flan_reg_full) return; + flan_reg_full = 1; + fprintf(stderr, + "flan: the allocation registry is full (%d blocks) — %s. Blocks " + "noted from here on are dropped, so every listing is a floor and " + "not a count.\n", + FLAN_REG_CAP, why); +} + /* Armed by the program's entry in a dev build. The free-side hooks in * flan_rt.c are called unconditionally and begin with this load and a * not-taken branch, because flan_dev.c is linked into every build and a @@ -1163,9 +1228,12 @@ static size_t flan_reg_slot(uintptr_t a) { & (FLAN_REG_CAP - 1); } -/* Drop every dead entry and re-insert the live ones. Called when the table - * fills: in a long-running program the dead are the bulk of it, and losing - * them is much cheaper than losing the live half. */ +/* Drop every dead entry and re-insert the live ones. Called when the table is + * filling *and* there is a worthwhile number of dead in it: in a long-running + * program the dead are the bulk of it, and losing them is much cheaper than + * losing the live half. When they are not the bulk of it this does nothing but + * move live entries around and hold the epoch odd while it does — see + * FLAN_REG_RECLAIM for the wrong answers that bought. */ static void flan_reg_compact(void) { size_t bytes = FLAN_REG_CAP * sizeof(flan_reg_entry); flan_reg_entry *old = (flan_reg_entry *)malloc(bytes); @@ -1173,7 +1241,11 @@ static void flan_reg_compact(void) { /* Borrowed rather than kept: a second permanent copy would double what a dev build holds for a rearrangement that happens rarely. If it cannot be had, the table simply stays as it is and says it is full. */ - if (old == NULL) { flan_reg_full = 1; return; } + if (old == NULL) { + flan_reg_say_full("there was no room to make the scratch copy a " + "rearrangement needs"); + return; + } /* Odd for the duration, so a scan that overlapped this throws its counts away rather than reporting a table half in one arrangement and half in the other. */ @@ -1192,6 +1264,7 @@ static void flan_reg_compact(void) { flan_reg_end(e); } flan_reg_used = 0; + flan_reg_dead = 0; /* the dead are what this drops; none survive the sweep */ for (i = 0; i < FLAN_REG_CAP; i++) { size_t s; int64_t probe; @@ -1224,12 +1297,20 @@ void flan_dev_reg_note(void *base, int64_t bytes, int64_t elem, size_t s; int64_t probe; if (!flan_reg_on || a == 0 || bytes <= 0) return; - if (flan_reg_used * 4 > (int64_t)FLAN_REG_CAP * 3) flan_reg_compact(); + /* Both halves, and the second is the one that matters: filling is what makes + a rearrangement urgent, but only the dead make it possible. */ + if (flan_reg_used * 4 > (int64_t)FLAN_REG_CAP * 3 + && flan_reg_dead >= FLAN_REG_RECLAIM) + flan_reg_compact(); s = flan_reg_slot(a); for (probe = 0; probe < FLAN_REG_CAP; probe++) { size_t j = (s + (size_t)probe) & (FLAN_REG_CAP - 1); if (flan_reg[j].base != 0 && flan_reg[j].base != a) continue; if (flan_reg[j].base == 0) flan_reg_used++; + /* Reusing the slot of a block that died un-counts that death: the entry + about to be written is live, and the count the compaction trigger reads + is a count of what a sweep would actually reclaim. */ + else if (flan_reg[j].died != 0) flan_reg_dead--; /* The pair a torn read would get wrong is [type] and [typelen], which is why the whole entry goes under the counter rather than the two of them being ordered somehow. */ @@ -1244,11 +1325,25 @@ void flan_dev_reg_note(void *base, int64_t bytes, int64_t elem, flan_reg_end(&flan_reg[j]); return; } - /* Full of live blocks. Killing the program because it ran out of diagnostic + /* Every slot in use, and the compaction above declined to run because what + * a sweep would reclaim is not worth the sweep. The honest version of that + * quotes the number rather than claiming the table is all live: it usually + * is, and between one dead entry and FLAN_REG_RECLAIM of them it is not, and + * a reader deciding whether this is a leak wants to know which. + * + * The note is dropped. Killing the program because it ran out of diagnostic * room would be the diagnostic shooting the patient — the watch table's rule * and the same answer: a flag, readable by whoever asks, so that a missing - * entry is never mistaken for a freed one. */ - flan_reg_full = 1; + * entry is never mistaken for a freed one. What is new is that the drop is + * also said out loud, once, rather than only being discoverable by asking. */ + { + char why[160]; + snprintf(why, sizeof why, + "every slot is in use and only %lld of them have died, too few " + "to be worth rearranging the table for", + (long long)flan_reg_dead); + flan_reg_say_full(why); + } } int flan_dev_reg_overflowed(void) { return flan_reg_full; } @@ -1294,6 +1389,7 @@ void flan_dev_reg_dead(void *base) { flan_reg_begin(&flan_reg[j]); flan_reg[j].died = ++flan_reg_seq; flan_reg_end(&flan_reg[j]); + flan_reg_dead++; } return; } @@ -1317,6 +1413,7 @@ void flan_dev_reg_dead_range(void *base, int64_t bytes) { flan_reg_begin(e); e->died = now; flan_reg_end(e); + flan_reg_dead++; } } } @@ -1476,6 +1573,23 @@ int64_t flan_dev_reg_number(const void *p) { return (int64_t)(uintptr_t)p; } * told so by the number coming back larger than [cap], which is the same * contract the watch table's count has. * + * And a walk that could not be taken returns -1 rather than a row count, + * because the two are not the same answer and this verb is the one place in + * the dev loop where confusing them costs the most. "No rows" is what a + * program that has freed everything looks like. "I could not read it" is what + * a program whose game thread is allocating through the whole read looks like, + * and the person asking is asking precisely because they suspect the second + * program of holding something. A zero handed back for a failed read is this + * file answering "nothing is held" about a table it never managed to see — + * the silent wrong answer everything else in here is written to refuse. + * + * [unread], on a -1, says which failure it was: a positive count is slots that + * were being written every time this looked at them, which is [flan_reg_snap]'s + * own contract being kept — its 0 means "a slot I could not read", and it is + * reported as that and never folded into the rows. A zero means no walk ever + * got a stable epoch at all: the table was being rearranged from end to end of + * every attempt. The caller turns either into a sentence. + * * The grouping is O(rows x types) on string compare. The table is 4096 slots * and the reader is a person, so this is the side the cost belongs on — the * same judgement [flan_reg_find] is written down for. Compared by *content* @@ -1483,9 +1597,10 @@ int64_t flan_dev_reg_number(const void *p) { return (int64_t)(uintptr_t)p; } * call site, and two modules that both allocate an Enemy emit two of them. */ int64_t flan_dev_reg_by_type(int32_t live_only, int64_t *counts, int64_t *bytes, const char **types, - int64_t *typelens, int64_t cap) { - int64_t i, n = 0; + int64_t *typelens, int64_t cap, int64_t *unread) { + int64_t i, n = 0, missed = 0, fewest = -1; int attempt; + if (unread) *unread = 0; if (!flan_reg_on) return 0; /* Read off a running program, which is what makes the counters below necessary: a row is a (pointer, length) pair that is only meaningful @@ -1496,12 +1611,16 @@ int64_t flan_dev_reg_by_type(int32_t live_only, int64_t *counts, for (attempt = 0; attempt < 8; attempt++) { uint64_t at; n = 0; + missed = 0; if (!flan_reg_scan_open(&at)) continue; for (i = 0; i < FLAN_REG_CAP; i++) { flan_reg_entry e; int64_t j; int found = 0; - if (!flan_reg_snap(&flan_reg[i], &e)) continue; + /* Counted, not stepped over. A slot the writer kept winning may hold a + block, and a walk that skipped one and still called itself whole would + be a leak report quietly missing the leak. */ + if (!flan_reg_snap(&flan_reg[i], &e)) { missed++; continue; } if (e.base == 0) continue; if (live_only && e.died != 0) continue; for (j = 0; j < n && j < cap; j++) { @@ -1521,13 +1640,20 @@ int64_t flan_dev_reg_by_type(int32_t live_only, int64_t *counts, } n++; } - if (flan_reg_scan_ok(at)) return n; + if (!flan_reg_scan_ok(at)) continue; + /* A stable epoch and every slot copied: this is a table that existed. */ + if (missed == 0) return n; + /* A stable epoch but slots that would not hold still. Worth another walk — + the writer moves on — and worth remembering the closest one, because it + is the number the refusal quotes. */ + if (fewest < 0 || missed < fewest) fewest = missed; } - /* Eight walks and a compaction through every one of them. Answering with the - last walk's rows would be answering with a table that never existed, so - this answers with none — the caller prints a header saying how many rows - follow, and zero is a number it can print. */ - return 0; + /* Eight walks, and not one of them saw the whole table. Answering with the + last walk's rows would be answering with a table that never existed, and + answering with zero rows would be answering "nothing is held" — so this + answers with neither, and the caller says so in a sentence. */ + if (unread) *unread = fewest > 0 ? fewest : 0; + return -1; } /* ── What is still held when the program returns ────────────────────── @@ -1552,8 +1678,20 @@ static void flan_reg_report(void) { enum { ROWS = 128 }; int64_t counts[ROWS], bytes[ROWS], typelens[ROWS]; const char *types[ROWS]; - int64_t n, i, blocks = 0, held = 0; - n = flan_dev_reg_by_type(1, counts, bytes, types, typelens, ROWS); + int64_t n, i, unread = 0, blocks = 0, held = 0; + n = flan_dev_reg_by_type(1, counts, bytes, types, typelens, ROWS, &unread); + /* At exit, with the program's own thread standing right here, a walk that + cannot settle means another thread is still allocating — and the report is + the one about what is still held, so saying nothing would be the loudest + possible version of the wrong answer. */ + if (n < 0) { + fprintf(stderr, + "flan: the allocation registry could not be read at exit (%s), so " + "there is no report of what was still held\n", + unread > 0 ? "slots were being written throughout" + : "the table was being rearranged throughout"); + return; + } if (n == 0) return; for (i = 0; i < n && i < ROWS; i++) { blocks += counts[i]; held += bytes[i]; } fprintf(stderr, "flan: %lld block%s still held at exit, %lld bytes\n", diff --git a/test/dev_limits.c b/test/dev_limits.c index eb146d0..caa8a53 100644 --- a/test/dev_limits.c +++ b/test/dev_limits.c @@ -18,6 +18,7 @@ * name table never shrinks and the overflow case aborts the process. */ +#include #include #include #include @@ -54,6 +55,16 @@ static const char *result_read(uint64_t *gen, uint64_t *len) { } void flan_rt_init(int32_t argc, char **argv); +void flan_dev_reg_enable(void); +void flan_dev_reg_note(void *base, int64_t bytes, int64_t elem, + const char *type, int64_t typelen); +void flan_dev_reg_dead(void *base); +int flan_dev_reg_overflowed(void); +int64_t flan_dev_reg_count(int32_t live_only); +int64_t flan_dev_reg_by_type(int32_t live_only, int64_t *counts, + int64_t *bytes, const char **types, + int64_t *typelens, int64_t cap, int64_t *unread); + #define CAP 4096 /* Emit more than fits, twice the buffer's worth, and report what came back. @@ -118,14 +129,175 @@ static int names(void) { return 0; } + +/* ── A table full of live blocks, read while it is being written ───── + * + * The shape of the bug this pins, which is worth writing down because the + * mode looks like a stress test and is not one. A compaction reclaims dead + * entries. The trigger used to be "three quarters full" and nothing else, so a + * program holding more than three quarters of the table live compacted on + * every single allocation, for ever, reclaiming nothing each time. Each + * compaction holds the table-wide epoch odd from end to end, a listing retries + * eight times and each retry meets another one, and [flan_dev_reg_by_type] + * used to answer that with zero rows — "nothing is held", about a program + * holding three thousand blocks. It was measured at 198 wrong answers in 200. + * + * So: 3100 live blocks, which is over the old three-quarter mark and under the + * 4096 the table holds, one thread noting allocations as fast as it can, and + * this thread asking the leak question two hundred times. The count printed is + * how many of those answers were right. Nothing here is timing-dependent in + * the direction that matters — a wrong answer is a wrong answer on any + * machine, and the fixed table means "right" is a number this file knows. + * + * The blocks are fake addresses rather than real allocations. The registry is + * told about a block, it never reads one, and 3100 real mallocs would only be + * measuring the allocator. */ +enum { REG_LIVE = 3100, REG_ASKS = 200 }; + +static volatile int reg_stop; + +/* The writer: re-note blocks that are already in the table, which is what a + * game loop overwriting its own allocations looks like from in here, and is + * the path that takes the compaction branch. The addresses are the ones + * already recorded, so the live count does not move and the table never + * fills. */ +static void *reg_writer(void *arg) { + int64_t n = 0; + (void)arg; + while (!reg_stop) { + for (int i = 0; i < REG_LIVE && !reg_stop; i++) { + void *p = (void *)(uintptr_t)(0x100000 + (uintptr_t)i * 64); + flan_dev_reg_note(p, 64, 8, "Enemy", 5); + n++; + } + } + return (void *)(uintptr_t)n; +} + +static int regfull(void) { + enum { ROWS = 16 }; + int64_t counts[ROWS], bytes[ROWS], typelens[ROWS], unread; + const char *types[ROWS]; + pthread_t w; + int right = 0, refused = 0, wrong = 0, zero = 0; + + flan_dev_reg_enable(); + for (int i = 0; i < REG_LIVE; i++) { + void *p = (void *)(uintptr_t)(0x100000 + (uintptr_t)i * 64); + flan_dev_reg_note(p, 64, 8, "Enemy", 5); + } + printf("live %lld\n", (long long)flan_dev_reg_count(1)); + + if (pthread_create(&w, NULL, reg_writer, NULL) != 0) { + printf("no writer thread\n"); + return 2; + } + for (int a = 0; a < REG_ASKS; a++) { + int64_t n = flan_dev_reg_by_type(1, counts, bytes, types, typelens, ROWS, + &unread); + /* One type, 3100 blocks of it. Any other answer is this table described as + something it is not — and zero rows is the specific lie, because it is + the answer a program that had freed everything would get. */ + if (n < 0) refused++; + else if (n == 0) zero++; + else if (n == 1 && counts[0] == REG_LIVE) right++; + else wrong++; + } + reg_stop = 1; + pthread_join(w, NULL); + + printf("right %d\n", right); + printf("zero %d\n", zero); + printf("wrong %d\n", wrong); + printf("refused %d\n", refused); + return 0; +} + + +/* ── The dead count the compaction trigger reads ───────────────────── + * + * [regfull] above proves a table of live blocks stops compacting. This proves + * the other half still does, which is the failure that would be silent: the + * trigger now asks how many entries are dead, four places maintain that number + * — a death, an arena's free-all, a dead slot being written over, and the sweep + * itself, which drops all of them — and a count that drifted low would simply + * stop reclaiming. Nothing would crash. The table would pin at its cap, the + * overflow flag would come on, and every listing from then on would be a floor + * quietly described as a count. + * + * So: three thousand blocks that stay live, and a pool of six hundred noted and + * freed over and over on top of them. Used passes the three-quarter mark, the + * dead pass the reclaim floor several times over the run, and the sweep runs. + * The assertion that catches a drifted count is the last one — a table that + * never reclaimed would have overflowed long before this returns. */ +enum { CHURN_LIVE = 3000, CHURN_POOL = 600, CHURN_PASSES = 8 }; + +static void *churn_addr(int i) { + return (void *)(uintptr_t)(0x800000 + (uintptr_t)i * 64); +} + +static int regchurn(void) { + enum { ROWS = 16 }; + int64_t counts[ROWS], bytes[ROWS], typelens[ROWS], unread, n; + const char *types[ROWS]; + + flan_dev_reg_enable(); + for (int i = 0; i < CHURN_LIVE; i++) + flan_dev_reg_note(churn_addr(i), 64, 8, "Enemy", 5); + /* Noted and freed on top of the live set. A note landing on the slot of a + * block that died is the decrement; the free is the increment; the sweep in + * between is the reset. */ + for (int pass = 0; pass < CHURN_PASSES; pass++) + for (int i = 0; i < CHURN_POOL; i++) { + void *p = churn_addr(CHURN_LIVE + i); + flan_dev_reg_note(p, 32, 4, "Bullet", 6); + flan_dev_reg_dead(p); + } + + printf("live %lld\n", (long long)flan_dev_reg_count(1)); + n = flan_dev_reg_by_type(1, counts, bytes, types, typelens, ROWS, &unread); + printf("rows %lld\n", (long long)n); + printf("blocks %lld\n", (long long)(n == 1 ? counts[0] : -1)); + /* The one that catches a dead count that drifted: a table that stopped + reclaiming fills, and a full table is a floor for the rest of the run. */ + printf("overflowed %d\n", flan_dev_reg_overflowed()); + return 0; +} + +/* And a table that really is full, which is the case the trigger above must + * not paper over: 4096 live blocks and nothing dead anywhere, then one more. + * The note is dropped — that is the standing decision, and dying because a + * diagnostic ran out of room would be worse — and what is checked here is that + * the drop is *said*, once, rather than only being discoverable by asking the + * flag. A second and third dropped note must add nothing. */ +static int regoverflow(void) { + flan_dev_reg_enable(); + for (int i = 0; i < CAP; i++) + flan_dev_reg_note((void *)(uintptr_t)(0x2000000 + (uintptr_t)i * 64), 64, 8, + "Enemy", 5); + printf("live %lld\n", (long long)flan_dev_reg_count(1)); + printf("overflowed %d\n", flan_dev_reg_overflowed()); + fflush(stdout); + for (int i = 0; i < 3; i++) + flan_dev_reg_note((void *)(uintptr_t)(0x9000000 + (uintptr_t)i * 64), 64, 8, + "Latecomer", 9); + printf("overflowed %d\n", flan_dev_reg_overflowed()); + printf("live %lld\n", (long long)flan_dev_reg_count(1)); + return 0; +} + int main(int argc, char **argv) { flan_rt_init(argc, argv); if (argc < 2) { - fprintf(stderr, "usage: %s cap|names\n", argv[0]); + fprintf(stderr, "usage: %s cap|names|regfull|regchurn|regoverflow\n", + argv[0]); return 2; } if (strcmp(argv[1], "cap") == 0) return cap(); if (strcmp(argv[1], "names") == 0) return names(); + if (strcmp(argv[1], "regfull") == 0) return regfull(); + if (strcmp(argv[1], "regchurn") == 0) return regchurn(); + if (strcmp(argv[1], "regoverflow") == 0) return regoverflow(); fprintf(stderr, "unknown mode %s\n", argv[1]); return 2; } diff --git a/test/test_reload.ml b/test/test_reload.ml index 39c68c8..1a79b65 100644 --- a/test/test_reload.ml +++ b/test/test_reload.ml @@ -537,6 +537,29 @@ let () = if not (has err "out of dev name slots") then fail "the registry overflowed without saying so: %S" err; + (* And the allocation registry read while it is being written, which is + the one of the three that was answering wrongly rather than not being + reached. 3100 live blocks is past the three-quarter mark that used to + trigger a compaction and short of the 4096 the table holds, so every + note taken by the writer thread used to rearrange a table with nothing + dead in it to reclaim — holding the epoch odd, losing every one of a + reader's eight attempts, and answering "no rows" about a program + holding three thousand blocks. Measured at 199 of 200 asks before the + trigger learned to ask whether a sweep would reclaim anything. + + The check is on the answers and not on timing: zero rows here is the + exact shape of the wrong answer, because it is what a program that had + freed everything would say. A refusal would be honest and is allowed — + it is what a genuinely unreadable table earns — but it should not be + what this one earns, so it is counted separately and expected to be + none. *) + let code, out, err = mode "regfull" in + let want_reg = "live 3100\nright 200\nzero 0\nwrong 0\nrefused 0\n" in + if code <> 0 || out <> want_reg then + fail + "the registry read under a writer\n got: %S (exit %d, err %S)\n wanted: %S" + out code err want_reg; + Printf.printf "reload: emit %.1fms llc %.1fms ld %.1fms (v2: emit %.1fms llc %.1fms ld %.1fms) host run %.1fms\n" emit_ms t1.Build.llc_ms t1.Build.link_ms emit2_ms t2.Build.llc_ms diff --git a/vendor/agent/flan_agent.c b/vendor/agent/flan_agent.c index 4122dde..4e61733 100644 --- a/vendor/agent/flan_agent.c +++ b/vendor/agent/flan_agent.c @@ -108,7 +108,7 @@ int32_t flan_dev_reg_at(const void *p, const char **type, int64_t *typelen, int64_t *seq, int64_t *died); int64_t flan_dev_reg_by_type(int32_t live_only, int64_t *counts, int64_t *bytes, const char **types, - int64_t *typelens, int64_t cap); + int64_t *typelens, int64_t cap, int64_t *unread); int flan_dev_reg_enabled(void); int flan_dev_reg_overflowed(void); @@ -1147,7 +1147,7 @@ static void handle_line(char *line, sink *o) { int64_t *bytes = malloc(REG_ROWS * sizeof *bytes); int64_t *typelens = malloc(REG_ROWS * sizeof *typelens); const char **types = malloc(REG_ROWS * sizeof *types); - int64_t n, i; + int64_t n, i, unread = 0; int live_only = line[4] == 'l'; if (counts == NULL || bytes == NULL || typelens == NULL || types == NULL) { free(counts); free(bytes); free(typelens); free(types); @@ -1160,7 +1160,40 @@ static void handle_line(char *line, sink *o) { return; } n = flan_dev_reg_by_type(live_only ? 1 : 0, counts, bytes, types, typelens, - REG_ROWS); + REG_ROWS, &unread); + /* A read that did not settle is refused in a sentence rather than sent as + * a header saying zero rows. The header cannot carry the difference: a row + * count is a number, and the number that means "I could not look" is the + * same number that means "nothing is held" — which is the answer this verb + * is most often asked to disprove. An [err] line is already a shape the + * daemon turns into the editor's message, so the distinction survives all + * the way to the person who asked. + * + * Both sentences name the *program's* behaviour and not the mechanism, + * because the mechanism is not actionable and the behaviour is: a table + * being rewritten this hard is a game thread allocating flat out, and the + * useful next move is to ask again, or to ask it while stopped. */ + if (n < 0) { + free(counts); free(bytes); free(typelens); free(types); + if (unread > 0) { + char msg[192]; + int k = snprintf(msg, sizeof msg, + "err the allocation registry could not be read whole: " + "%lld slot%s were being written through every one of " + "eight attempts, so any rows here would be missing " + "blocks; ask again\n", + (long long)unread, unread == 1 ? "" : "s"); + /* Clamped, because [snprintf] returns the length it wanted rather + * than the length it wrote — [flan_dev_reg_emit]'s rule. */ + if (k > (int)sizeof msg - 1) k = (int)sizeof msg - 1; + if (k > 0) emit(o, msg, (size_t)k); + } else { + reply(o, "err the allocation registry was being rearranged through " + "every one of eight attempts; no listing taken, rather than " + "one of a table that never existed — ask again\n"); + } + return; + } { char hdr[64]; int k = snprintf(hdr, sizeof hdr, "%lld %d\n", From 739b871ff111ef1a3c222db65881e711b6f66361 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Fri, 18 Sep 2026 07:47:57 +0700 Subject: [PATCH 2/2] The eight attempts at reading the table now wait between themselves Retrying immediately looked like eight chances and was one: a walk that bails at the epoch check costs almost nothing, so all eight fit inside the single compaction they were all losing to, and the listing refused having waited for nothing. A quarter of a millisecond between attempts -- the agent's break-loop idiom, legal here because the waiter is the listener thread and never the game loop -- bounds the whole refusal at two milliseconds. Measured with a writer noting and freeing on top of three thousand live blocks: 8 right answers in 200 without the pause, 200 in 200 with it. It is not magic, and the comment says so: a writer that spends most of its time rearranging the table still gets refused, which is the honest answer and used to be a zero-row lie. The two cases the last commit left unwired are wired now, and a third joins them: a listing taken while the table really is being compacted, which nothing covered -- the full-of-live case never compacts and the churn case is single-threaded, so the retry itself was exercised by nothing. It asserts only what a slower machine cannot change: never zero rows, never a count that is neither right nor a refusal. How the rest divides is printed, not pinned. --- docs/BUILT.md | 8 +++++ runtime/flan_dev.c | 36 ++++++++++++++++++-- test/dev_limits.c | 81 ++++++++++++++++++++++++++++++++++++++++++++- test/test_reload.ml | 64 +++++++++++++++++++++++++++++++++++ 4 files changed, 186 insertions(+), 3 deletions(-) diff --git a/docs/BUILT.md b/docs/BUILT.md index 61e9a41..99f0dd7 100644 --- a/docs/BUILT.md +++ b/docs/BUILT.md @@ -5119,6 +5119,14 @@ asking is asking because they suspect the opposite. A slot the writer kept winni would be a leak report quietly missing the leak — and the agent turns a refusal into an `err` line naming how many slots would not hold still, which the daemon already renders as the editor's message. +**And the eight attempts wait between themselves**, which is most of what makes them eight attempts rather than one. +A walk that bails at the epoch check costs almost nothing, so eight of them back to back fit inside the single +compaction they are all losing to. A quarter of a millisecond between them -- `flan_agent.c`'s break-loop idiom, and +legal here because the waiter is the listener thread and never the game loop -- bounds the whole refusal at two +milliseconds, and under a writer churning on top of 3000 live blocks it was the difference between 8 right answers in +200 and 200 in 200. It is not magic: a writer that spends most of its time rearranging the table still gets refused, +which is the honest answer to a question asked of a table that is never still. + **"At exit" is not a hook, and the honest reason is that a game is killed.** A program stopped by a signal runs no `atexit` handler, no destructor, nothing — so no code written inside the program could report anything about the run that matters most. The authoritative reader is therefore `(:op "leaks")`, which reads the same table over the agent diff --git a/runtime/flan_dev.c b/runtime/flan_dev.c index 438bde9..f283211 100644 --- a/runtime/flan_dev.c +++ b/runtime/flan_dev.c @@ -33,6 +33,10 @@ #include #include #include +/* For the one wait in this file: the pause between a listing's attempts at + * reading the table. See flan_reg_scan_wait. Nothing on the writer's side + * waits for anything. */ +#include #define FLAN_DEV_MAX 4096 @@ -1152,6 +1156,33 @@ static int flan_reg_scan_ok(uint64_t at) { return __atomic_load_n(&flan_reg_epoch, __ATOMIC_ACQUIRE) == at; } +/* And a pause between a reader's attempts, which is most of what makes eight + * attempts worth more than one. + * + * Retrying immediately looks like eight chances and is not. A walk that bails + * at the epoch check costs almost nothing, so eight of them back to back fit + * inside the single compaction they are all losing to, and the reader refuses + * having waited for nothing. Measured, with a writer allocating and freeing on + * top of three thousand live blocks: without this pause 8 answers in 200 were + * right and 192 were refusals; with it, 200 of 200. The rate matters and the + * pause is not magic — a writer rearranging the table more than half the time + * still gets refused, which is the honest answer to a question asked of a + * table that is never still, and is what the refusal sentence is for. + * + * Waiting is legal here and only here. The reader is the agent's listener + * thread, or the exiting program's own; the writer is a game loop and never + * reaches this. A quarter of a millisecond is well over one compaction and far + * under what a person waiting for a keypress to answer would notice, and eight + * of them bound the whole refusal at two milliseconds. It is [flan_agent.c]'s + * break-loop idiom — nanosleep a step, look again — for its reason too: a spin + * would take a core from the thread being waited on. */ +static void flan_reg_scan_wait(void) { + struct timespec step; + step.tv_sec = 0; + step.tv_nsec = 250000; /* 250us */ + nanosleep(&step, NULL); +} + /* Allocated by flan_dev_reg_enable and null until then, which is the whole of * what a release build carries: a null pointer, a zero flag, and the load and * not-taken branch each of the hooks below begins with. A fixed array here @@ -1612,7 +1643,7 @@ int64_t flan_dev_reg_by_type(int32_t live_only, int64_t *counts, uint64_t at; n = 0; missed = 0; - if (!flan_reg_scan_open(&at)) continue; + if (!flan_reg_scan_open(&at)) { flan_reg_scan_wait(); continue; } for (i = 0; i < FLAN_REG_CAP; i++) { flan_reg_entry e; int64_t j; @@ -1640,13 +1671,14 @@ int64_t flan_dev_reg_by_type(int32_t live_only, int64_t *counts, } n++; } - if (!flan_reg_scan_ok(at)) continue; + if (!flan_reg_scan_ok(at)) { flan_reg_scan_wait(); continue; } /* A stable epoch and every slot copied: this is a table that existed. */ if (missed == 0) return n; /* A stable epoch but slots that would not hold still. Worth another walk — the writer moves on — and worth remembering the closest one, because it is the number the refusal quotes. */ if (fewest < 0 || missed < fewest) fewest = missed; + flan_reg_scan_wait(); } /* Eight walks, and not one of them saw the whole table. Answering with the last walk's rows would be answering with a table that never existed, and diff --git a/test/dev_limits.c b/test/dev_limits.c index caa8a53..49e49bd 100644 --- a/test/dev_limits.c +++ b/test/dev_limits.c @@ -286,10 +286,88 @@ static int regoverflow(void) { return 0; } + +/* ── A listing taken while the table is actually being rearranged ──── + * + * [regfull] is a table of live blocks, where the fix is that no compaction + * runs at all. This is the other race: a writer churning hard enough that + * compactions do run, under a reader asking the leak question through them. + * + * What it pins is not a rate. Rates here are the machine's, and the counts + * printed are deliberately the two that cannot come out any other way on any + * machine: [zero], an answer of no rows, which is the original lie and the one + * shape a program holding three thousand blocks must never produce; and + * [low], a count that is neither right nor a refusal, which is what a torn + * read or a walk that straddled a compaction would produce. Right-versus- + * refused is the interesting number and it is printed rather than asserted, + * because a refusal is an honest answer and how often it comes up is the + * writer's business. It moves a lot: on the machine this was written on, the + * paced retry turned 8 right in 200 into 200 in 200 at one churn rate and + * changed nothing at all at another. + * + * The throttle is a spin and not a sleep because the whole interesting range + * is under nanosleep's floor of tens of microseconds. */ +enum { RACE_LIVE = 3000, RACE_ASKS = 200, RACE_SPIN = 1000 }; + +static volatile int race_stop; + +static void *race_writer(void *arg) { + long long i = 0; + (void)arg; + while (!race_stop) { + /* Noted and freed, which is what makes the dead count climb and the + * compaction actually run — the difference from [regfull]'s writer, which + * re-notes live blocks and therefore never earns a sweep. */ + void *p = (void *)(uintptr_t)(0x40000000 + (uintptr_t)(i % 100000) * 64); + flan_dev_reg_note(p, 32, 4, "Bullet", 6); + flan_dev_reg_dead(p); + for (volatile long k = 0; k < RACE_SPIN; k++) { } + i++; + } + return NULL; +} + +static int regrace(void) { + enum { ROWS = 16 }; + int64_t counts[ROWS], bytes[ROWS], typelens[ROWS], unread; + const char *types[ROWS]; + pthread_t w; + int right = 0, refused = 0, zero = 0, low = 0; + + flan_dev_reg_enable(); + for (int i = 0; i < RACE_LIVE; i++) + flan_dev_reg_note((void *)(uintptr_t)(0x100000 + (uintptr_t)i * 64), 64, 8, + "Enemy", 5); + if (pthread_create(&w, NULL, race_writer, NULL) != 0) { + printf("no writer thread\n"); + return 2; + } + for (int a = 0; a < RACE_ASKS; a++) { + int64_t n = + flan_dev_reg_by_type(1, counts, bytes, types, typelens, ROWS, &unread); + if (n < 0) refused++; + else if (n == 0) zero++; + else { + int64_t enemies = 0; + for (int64_t j = 0; j < n && j < ROWS; j++) + if (typelens[j] == 5) enemies = counts[j]; + if (enemies == RACE_LIVE) right++; else low++; + } + } + race_stop = 1; + pthread_join(w, NULL); + + printf("zero %d\n", zero); + printf("low %d\n", low); + printf("answered %d refused %d\n", right, refused); + return 0; +} + int main(int argc, char **argv) { flan_rt_init(argc, argv); if (argc < 2) { - fprintf(stderr, "usage: %s cap|names|regfull|regchurn|regoverflow\n", + fprintf(stderr, + "usage: %s cap|names|regfull|regchurn|regrace|regoverflow\n", argv[0]); return 2; } @@ -297,6 +375,7 @@ int main(int argc, char **argv) { if (strcmp(argv[1], "names") == 0) return names(); if (strcmp(argv[1], "regfull") == 0) return regfull(); if (strcmp(argv[1], "regchurn") == 0) return regchurn(); + if (strcmp(argv[1], "regrace") == 0) return regrace(); if (strcmp(argv[1], "regoverflow") == 0) return regoverflow(); fprintf(stderr, "unknown mode %s\n", argv[1]); return 2; diff --git a/test/test_reload.ml b/test/test_reload.ml index 1a79b65..99d285e 100644 --- a/test/test_reload.ml +++ b/test/test_reload.ml @@ -560,6 +560,70 @@ let () = "the registry read under a writer\n got: %S (exit %d, err %S)\n wanted: %S" out code err want_reg; + (* The other half of the same trigger: it still compacts when compacting + would reclaim something. This one would fail silently if the dead count + the trigger reads ever drifted low — nothing crashes, the table simply + stops reclaiming, pins at its cap, and every listing after that is a + floor being read as a count. [overflowed 0] is the line that catches it: + 3000 blocks held live with 600 more noted and freed on top of them, over + and over, fills a 4096-slot table several times over unless the sweep is + running. *) + let code, out, err = mode "regchurn" in + let want_churn = "live 3000\nrows 1\nblocks 3000\noverflowed 0\n" in + if code <> 0 || out <> want_churn then + fail + "the registry under churn\n got: %S (exit %d, err %S)\n wanted: %S" + out code err want_churn; + + (* A listing taken while the table really is being rearranged, which + neither case above covers: [regfull]'s table never compacts, and the + churn above is single-threaded, so the epoch retry — the thing that + decides between an answer and a refusal — was exercised by nothing. + + What is asserted is only what cannot come out differently on a slower + machine: never an answer of no rows, which is the original lie, and + never a count that is neither right nor refused, which is what a walk + that straddled a compaction would produce. How the rest divides between + answered and refused is the writer's business and is printed rather + than pinned — a refusal is an honest answer, and a case that went red + because a shared runner was busy would teach whoever runs this to skim + past red. *) + let code, out, err = mode "regrace" in + let first_two = + match String.split_on_char '\n' out with + | a :: b :: _ -> a ^ "\n" ^ b ^ "\n" + | _ -> out + in + if code <> 0 || first_two <> "zero 0\nlow 0\n" then + fail + "a listing taken during a compaction\n got: %S (exit %d, err %S)\n wanted no zero-row and no wrong-count answers" + out code err; + + (* And a table that is genuinely full, which is the state the trigger above + must not paper over. The note is dropped — a diagnostic that killed the + program because it ran out of room would be the diagnostic shooting the + patient — and the decision this pins is that the drop is *said*, once. + Once matters: this is the game thread inside the allocation hook, and a + line per dropped note would be sixty a second down a pipe nobody drains + while a request is being served. *) + let code, out, err = mode "regoverflow" in + let want_over = "live 4096\noverflowed 0\noverflowed 1\nlive 4096\n" in + if code <> 0 || out <> want_over then + fail "a full registry\n got: %S (exit %d)\n wanted: %S" out + code want_over; + let said_full = + let needle = "the allocation registry is full" in + let rec go i n = + if i + String.length needle > String.length err then n + else if String.sub err i (String.length needle) = needle then + go (i + 1) (n + 1) + else go (i + 1) n + in + go 0 0 + in + if said_full <> 1 then + fail "a full registry said so %d times, not once: %S" said_full err; + Printf.printf "reload: emit %.1fms llc %.1fms ld %.1fms (v2: emit %.1fms llc %.1fms ld %.1fms) host run %.1fms\n" emit_ms t1.Build.llc_ms t1.Build.link_ms emit2_ms t2.Build.llc_ms