The writer's side gets the probe, the reader's side gets the scan

flan_dev_reg_dead was reaching for the containment scan, and it is on the free
path: a dev build would have paid a 4096-entry sweep per free. A free hands back
the base address the allocator gave out, which is what the slot is keyed on, so
the question there is equality and never containment. Only free-all needs the
scan, and that runs once a frame.

The table is allocated when it is armed, not declared. A fixed array was a
quarter of a megabyte of BSS in a shipped game for a table that build never
writes; now a release build carries a null pointer and the not-taken branch.

The pointer arm binds its subject to a slot before naming it three times — the
slice arm's rule, and its reason: an inspect with a path reaches a leaf through
a bounds check, and three of those to render one pointer is the walk paying for
its own shape.

dev-ptr.flan shows both halves on a stopped stack. It was read by hand; the
test_dev.ml case that would drive it is another lane's file, and NEXT.md says so.
This commit is contained in:
Joseph Ferano 2026-09-13 09:24:08 +07:00
parent c897526e47
commit 6ebcea6d3a
3 changed files with 104 additions and 13 deletions

View File

@ -158,13 +158,23 @@ let rec render c depth (e : Tast.expr) : Tast.expr list =
(match c.ptrs with (match c.ptrs with
| None -> [ lit "<ptr>" ] | None -> [ lit "<ptr>" ]
| Some pt -> | Some pt ->
(* Into a slot first, the slice arm's rule and for its reason: this
arm names the pointer three times asked about, followed, and
mourned and the expression it came from may be a call. An
[inspect] with a path reaches a leaf through [flan_vec_at], which
is a bounds check and a transfer guard; three of those to render
one pointer would be the walk paying for its own shape. *)
let pv = c.alloc e.Tast.ty in
let p () = { Tast.e = Tast.Local pv; ty = e.Tast.ty; loc } in
let inner = let inner =
do_ ([ lit "<ptr " ] do_ ([ lit "<ptr " ]
@ render c (depth + 1) { Tast.e = Tast.Deref e; ty = t; loc } @ render c (depth + 1) { Tast.e = Tast.Deref (p ()); ty = t; loc }
@ [ lit ">" ]) @ [ lit ">" ])
in in
let gone = do_ [ lit "<ptr"; pt.epitaph e; lit ">" ] in let gone = do_ [ lit "<ptr"; pt.epitaph (p ()); lit ">" ] in
[ unit_ (Tast.If (pt.live e, inner, gone)) ]) [ unit_
(Tast.Let ([ (pv, e) ],
[ unit_ (Tast.If (pt.live (p ()), inner, gone)) ])) ])
(* Opaque on purpose, and for the same reason: its contents are the (* Opaque on purpose, and for the same reason: its contents are the
runtime's, its address is not stable across runs, and printing either runtime's, its address is not stable across runs, and printing either
would make an acceptance test's output depend on the heap. *) would make an acceptance test's output depend on the heap. *)

View File

@ -862,7 +862,12 @@ typedef struct {
int64_t died; /* when it was released, or 0 while it is live */ int64_t died; /* when it was released, or 0 while it is live */
} flan_reg_entry; } flan_reg_entry;
static flan_reg_entry flan_reg[FLAN_REG_CAP]; /* 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
* instead would be a quarter of a megabyte of BSS in a shipped game, for a
* 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_used; /* live + dead slots in use */
static int64_t flan_reg_seq; /* a monotonic clock, in events */ 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_on; /* only a dev build turns this on */
@ -874,7 +879,15 @@ static int flan_reg_full; /* something found no slot */
* second version of the allocator gated on a build flag is worse than a * second version of the allocator gated on a build flag is worse than a
* branch. That is a real cost and not zero; BUILT.md says so rather than * branch. That is a real cost and not zero; BUILT.md says so rather than
* repeating the claim that a release build carries nothing. */ * repeating the claim that a release build carries nothing. */
void flan_dev_reg_enable(void) { flan_reg_on = 1; } void flan_dev_reg_enable(void) {
if (flan_reg_on) return;
flan_reg = (flan_reg_entry *)calloc(FLAN_REG_CAP, sizeof *flan_reg);
/* A registry that could not be made is not worth dying over; the flag stays
off and every question about an address answers "never heard of it",
which is what a release build answers too. */
if (flan_reg == NULL) return;
flan_reg_on = 1;
}
int flan_dev_reg_enabled(void) { return flan_reg_on; } int flan_dev_reg_enabled(void) { return flan_reg_on; }
@ -889,10 +902,15 @@ static size_t flan_reg_slot(uintptr_t a) {
* fills: in a long-running program the dead are the bulk of it, and losing * fills: in a long-running program the dead are the bulk of it, and losing
* them is much cheaper than losing the live half. */ * them is much cheaper than losing the live half. */
static void flan_reg_compact(void) { static void flan_reg_compact(void) {
static flan_reg_entry old[FLAN_REG_CAP]; /* static: 160KB is not stack */ size_t bytes = FLAN_REG_CAP * sizeof(flan_reg_entry);
flan_reg_entry *old = (flan_reg_entry *)malloc(bytes);
int64_t i; int64_t i;
memcpy(old, flan_reg, sizeof old); /* Borrowed rather than kept: a second permanent copy would double what a
memset(flan_reg, 0, sizeof flan_reg); 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; }
memcpy(old, flan_reg, bytes);
memset(flan_reg, 0, bytes);
flan_reg_used = 0; flan_reg_used = 0;
for (i = 0; i < FLAN_REG_CAP; i++) { for (i = 0; i < FLAN_REG_CAP; i++) {
size_t s; size_t s;
@ -908,6 +926,7 @@ static void flan_reg_compact(void) {
} }
} }
} }
free(old);
} }
/* One note per allocation. [base] replaces whatever was recorded there, live /* One note per allocation. [base] replaces whatever was recorded there, live
@ -962,12 +981,27 @@ static flan_reg_entry *flan_reg_find(uintptr_t a) {
} }
/* One block dies. The heap allocator's free calls this, and so does a resize, /* One block dies. The heap allocator's free calls this, and so does a resize,
* for the block it moved away from. */ * for the block it moved away from.
*
* The probe, not the scan and the difference matters because this is on the
* *writer's* side. A free hands back the base address the allocator gave out,
* which is what the slot is keyed on, so the question here is equality and
* never containment. Reaching for flan_reg_find would put a 4096-entry sweep
* on every free in a dev build, which is the cost the note was careful not to
* have. */
void flan_dev_reg_dead(void *base) { void flan_dev_reg_dead(void *base) {
flan_reg_entry *e; uintptr_t a = (uintptr_t)base;
if (!flan_reg_on) return; size_t s;
e = flan_reg_find((uintptr_t)base); int64_t probe;
if (e != NULL && e->died == 0) e->died = ++flan_reg_seq; if (!flan_reg_on || a == 0) return;
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) return; /* never noted; nothing to mark */
if (flan_reg[j].base != a) continue;
if (flan_reg[j].died == 0) flan_reg[j].died = ++flan_reg_seq;
return;
}
} }
/* Every block inside [base, base+bytes) dies — which is an arena's free-all, /* Every block inside [base, base+bytes) dies — which is an arena's free-all,
@ -1040,6 +1074,7 @@ int32_t flan_dev_reg_emit(const void *p) {
* test, and for the breakdown-by-type listing that is not built yet. */ * test, and for the breakdown-by-type listing that is not built yet. */
int64_t flan_dev_reg_count(int32_t live_only) { int64_t flan_dev_reg_count(int32_t live_only) {
int64_t i, n = 0; int64_t i, n = 0;
if (!flan_reg_on) return 0;
for (i = 0; i < FLAN_REG_CAP; i++) { for (i = 0; i < FLAN_REG_CAP; i++) {
if (flan_reg[i].base == 0) continue; if (flan_reg[i].base == 0) continue;
if (live_only && flan_reg[i].died != 0) continue; if (live_only && flan_reg[i].died != 0) continue;

View File

@ -0,0 +1,46 @@
;;;; A stopped stack holding two pointers into Vec storage, one of which is
;;;; already dead. The allocation registry is what lets the inspector tell
;;;; them apart, and this is the program that shows it:
;;;;
;;;; ("live" "(Ptr Enemy)" "<ptr (Enemy {.hp 41 .x 2})>")
;;;; ("dead" "(Ptr Enemy)" "<ptr dead: was Enemy, freed at step 15>")
;;;;
;;;; Both lines are what `(:op "locals" :frame 1)` answers with today, and
;;;; both were read off a running session by hand. **No test drives this
;;;; program yet**: the case belongs beside the other `locals` and `inspect`
;;;; cases in test_dev.ml, which is another lane's file. NEXT.md says so
;;;; rather than letting the verification read as automated.
;;;;
;;;; Why a Vec rather than a struct on the stack: a stack address is not in
;;;; the registry by design — the shadow stack already answers for a local by
;;;; name — so a pointer to one renders <ptr>, which is neither half of what
;;;; this is demonstrating.
(import agent "vendor:agent")
(defstruct Boom [why i32])
(defstruct Enemy [hp i32 x i32])
(defn deeper [] i64
(restart-case
(do (error (Boom {.why 7})) 1)
(carry-on [] 5)))
(defn outer [] i64
(let [v (vec-new Enemy)
w (vec-new Enemy)]
(push v (Enemy {.hp 41 .x 2}))
(push w (Enemy {.hp 7 .x 9}))
(let [live (addr (at v 0))
dead (addr (at w 0))]
(free w)
(deeper))))
(defvar ticks i64)
(defn main [] i32
(agent/start "/tmp/flan-ptr-fallback.sock")
(print (outer)) (println "")
(dotimes [i 4000]
(agent/wait 5)
(set ticks (+ ticks 1)))
0)