diff --git a/docs/BUGS-2026-09-18.md b/docs/BUGS-2026-09-18.md index a143d87..ad11187 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..99f0dd7 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,21 @@ 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. + +**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 28d0b49..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 @@ -1039,6 +1043,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 +1115,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++) { @@ -1119,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 @@ -1126,10 +1190,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 +1259,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 +1272,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 +1295,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 +1328,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 +1356,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 +1420,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 +1444,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 +1604,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 +1628,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 +1642,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; - if (!flan_reg_scan_open(&at)) continue; + missed = 0; + 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; 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 +1671,21 @@ 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)) { 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 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 +1710,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..49e49bd 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,254 @@ 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; +} + + +/* ── 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\n", argv[0]); + fprintf(stderr, + "usage: %s cap|names|regfull|regchurn|regrace|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], "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 39c68c8..99d285e 100644 --- a/test/test_reload.ml +++ b/test/test_reload.ml @@ -537,6 +537,93 @@ 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; + + (* 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 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",