A pool slot that remembers how many times it has been reused

(Handle T) and (Pool T) land as types and as a runtime. A handle is one
int64_t — slot index low, generation high — so it copies, zeroes and
compares like the integer it is and owns nothing. A live slot's generation
is odd, which makes a zeroed handle resolve to nothing rather than to slot
zero, and makes iteration free. Wrapping retires the slot rather than
reissuing it: 2^31 reuses is rare, and rare is not an answer when the
failure is the silent wrong one the type exists to prevent.

No surface yet — the checker still has no names for any of it.
This commit is contained in:
Joseph Ferano 2026-09-13 07:50:06 +07:00
parent 54027ca942
commit 8f429bcd5d
4 changed files with 361 additions and 4 deletions

View File

@ -429,7 +429,23 @@ let rec resolve env ?(seen = []) (t : Ast.texpr) : Types.t =
map_type loc (resolve env ~seen k) (resolve env ~seen v)
| "Map", _ -> fail loc "(Map K V) takes exactly two types"
| "Result", _ -> unimplemented loc "(Result T E)" 6
| "Handle", _ -> unimplemented loc "(Handle T)" 6
| "Pool", [ a ] ->
let e = resolve env ~seen a in
(* The same refusal (Vec T) makes, for the same reason: the pool's
runtime is type-erased and copies and releases slots bytewise, so a
release would drop what an owning element owns. Recursive teardown
arrives with drop. *)
if Types.is_move_only e then
fail loc
"(Pool %s) holds a move-only element, and the type-erased runtime \
copies and releases slots bytewise so releasing a slot would \
leak what it owns. Recursive teardown arrives with drop (step 5 \
in NEXT.md)"
(Types.to_string e);
Types.Pool e
| "Pool", _ -> fail loc "(Pool T) takes exactly one type"
| "Handle", [ a ] -> Types.Handle (resolve env ~seen a)
| "Handle", _ -> fail loc "(Handle T) takes exactly one type"
| _ ->
fail loc
"%s takes no type arguments — generics are milestone 5" name)
@ -2507,7 +2523,16 @@ and named_call ctx ~want loc name args =
in
arity loc name 2 args;
let a, b = binary ctx name loc ~want:None args in
if not (Types.is_comparable a.Tast.ty) then
(* [=] and [!=] admit one type [<] does not: a handle, which is a pair of
numbers in one word and where "the same entity" is the question the
type exists to answer. Ordering handles would order a slot index, which
is a free-list artefact and means nothing. *)
let ok =
match name with
| "=" | "!=" -> Types.is_equatable a.Tast.ty
| _ -> Types.is_comparable a.Tast.ty
in
if not ok then
fail loc
"%s compares machine numbers; %s has no built-in comparison \
(plan.org, Types)" name (Types.to_string a.Tast.ty);

View File

@ -113,6 +113,15 @@ let rec ll (t : Types.t) =
map's address so the shape exists only so that a slot, a struct field
and a copy in the IR are the right number of bytes. *)
| Types.Map _ -> "%map"
(* items + slots + len + cap + live + free + allocator + epoch. Nothing here
reads a field of one either every operation is a runtime call taking
the pool's address. *)
| Types.Pool _ -> "%pool"
(* A handle is one 64-bit number: the slot index in the low half and that
slot's generation in the high half. Packed rather than a two-field struct
so that copying, zeroing and [=] are what they are for an integer, with no
backend arm anywhere except the one comparison below. *)
| Types.Handle _ -> "i64"
| Types.Option e -> Printf.sprintf "{ i8, %s }" (ll e)
| Types.Var _ ->
(* The checker rejects it by name — nothing reaches here. *)
@ -267,6 +276,8 @@ let rec lay m (t : Types.t) : int * int =
| Types.Alloc -> 8, 8
| Types.Fn _ -> 8, 8
| Types.Vec _ | Types.Map _ -> 48, 8
| Types.Pool _ -> 64, 8
| Types.Handle _ -> 8, 8
(* [n x T] adds no padding of its own: T's size already carries its tail. *)
| Types.Array (n, e) -> let s, a = lay m e in Int64.to_int n * s, a
| Types.Option e -> let s, a, _ = lay_fields m [ Types.Int Types.I8; e ] in s, a
@ -460,6 +471,19 @@ let rec dty m d (t : Types.t) : int =
("allocator", Types.Alloc); ("gen", Types.Int Types.I64);
("epoch", Types.Int Types.I64) ]
|> fun n -> ignore k; ignore v; n
(* Eight fields, shown as eight, for the reason the two above are. *)
| Types.Pool e ->
composite (Types.to_string t)
[ ("items", Types.Ptr e);
("slots", Types.Ptr (Types.Int Types.U8));
("len", Types.Int Types.I64); ("cap", Types.Int Types.I64);
("live", Types.Int Types.I64); ("free", Types.Int Types.I64);
("allocator", Types.Alloc); ("epoch", Types.Int Types.I64) ]
(* An i64 under lldb, which is what it is. Splitting it into a two-field
composite would be describing a struct that is not there: the packing
is the runtime's, and [p h] answering with the number is honest. *)
| Types.Handle _ ->
basic (Types.to_string t) 64 "DW_ATE_unsigned"
(* A pointer to code, and lldb is told exactly that and no more. DWARF
has DW_TAG_subroutine_type for the signature behind it, and spelling
one out here would buy a reader nothing they cannot get from the
@ -1684,6 +1708,11 @@ and prim f (e : Tast.expr) (p : Tast.prim) (args : Tast.expr list) =
location. Signed, because a member may be declared negative. *)
| Types.Enum _ ->
ins f "%s = icmp %s %s %s, %s" t (icmp_op true p) (ll x.Tast.ty) a b
(* [Types.is_equatable] admits a handle and [is_comparable] does not, so
only [Eq]/[Ne] arrive here one unsigned integer compare over the
packed (index, generation) pair. *)
| Types.Handle _ ->
ins f "%s = icmp %s i64 %s, %s" t (icmp_op false p) a b
| t' -> failwith ("comparison on " ^ Types.to_string t'));
t
| (Tast.BitAnd | Tast.BitOr | Tast.BitXor | Tast.Shl | Tast.Shr), [ x; y ] ->
@ -1822,7 +1851,7 @@ and prim f (e : Tast.expr) (p : Tast.prim) (args : Tast.expr list) =
lets an operation mutate the caller's container in place.
Passing the header by value here would hand the runtime a
copy to grow and leave the caller's untouched. *)
| Types.Vec _ | Types.Map _ -> [ "ptr " ^ addr f a ]
| Types.Vec _ | Types.Map _ | Types.Pool _ -> [ "ptr " ^ addr f a ]
| t -> [ ll t ^ " " ^ value f a ])
args)
in
@ -2242,6 +2271,10 @@ let header = {|; Generated by flan. The layout is C's: no object headers anywher
; nor value type appears in it, for the same reason: one type-erased runtime,
; handed the two sizes and a hash/equality pair at each call site.
%map = type { ptr, i64, i64, ptr, i64, i64 }
; (Pool T) slab storage handed out behind (Handle T). Type-erased in exactly
; the same way; the element type is nowhere in it. items and slots are grown
; together and share one cap, so a slot index is an index into both.
%pool = type { ptr, ptr, i64, i64, i64, i64, ptr, i64 }
; A handler frame: the one it displaced, the condition type it matches, and
; the lifted function that runs. Allocated on the establishing frame's stack.
%handler = type { ptr, i32, ptr }

View File

@ -44,6 +44,25 @@ type t =
so this is a container without generics the concrete type is known only
at the call site, which is exactly where the two numbers are produced. *)
| Vec of t
(* [(Pool T)]: slab storage handed out behind [(Handle T)]. Owning and
move-only exactly as a [Vec] is, and built on the same type-erased
runtime over (size, align). It is not a second [Vec]: a [Vec]'s indices
shift when something is removed and a [Pool]'s slot index never moves,
which is the whole reason a handle into one stays meaningful. *)
| Pool of t
(* [(Handle T)]: a reference to something that can die, which reports that
it died rather than silently resolving to whatever reused its slot
(spec-memory.md, "Borrowing" "Cross-referencing long-lived objects uses
(Handle a) into a pool, never a raw pointer or slice. A stale handle is
detectable").
It is a plain 64-bit number a slot index in the low 32 bits and that
slot's generation counter in the high 32 so it copies, compares and
zeroes like an integer and owns nothing. A zeroed handle is generation 0,
and a live slot's generation is always odd, so [Zero] of a handle is a
handle that resolves to nothing rather than one that resolves to slot 0.
See runtime/flan_rt.c's pool section for the packing. *)
| Handle of t
| Option of t (* (Option T) *)
| Fn of t list * t (* (Fn [T ...] R) *)
| Var of string (* a type variable — milestone 5 *)
@ -94,6 +113,8 @@ let rec equal a b =
| Ptr x, Ptr y -> equal x y
| Alloc, Alloc -> true
| Vec x, Vec y -> equal x y
| Pool x, Pool y -> equal x y
| Handle x, Handle y -> equal x y
| Option x, Option y -> equal x y
| Fn (ps, r), Fn (ps', r') ->
List.length ps = List.length ps'
@ -116,6 +137,8 @@ let rec to_string = function
| Ptr t -> "(Ptr " ^ to_string t ^ ")"
| Alloc -> "Allocator"
| Vec t -> "(Vec " ^ to_string t ^ ")"
| Pool t -> "(Pool " ^ to_string t ^ ")"
| Handle t -> "(Handle " ^ to_string t ^ ")"
| Option t -> "(Option " ^ to_string t ^ ")"
| Fn (ps, r) ->
Printf.sprintf "(Fn [%s] %s)"
@ -130,7 +153,10 @@ let is_numeric = function Int _ | Float _ -> true | _ -> false
[free] needs no analysis of its own. A struct that owns one is move-only
too; that arrives with [drop], which is the step after this one. *)
let rec is_move_only = function
| Vec _ | Map _ -> true
(* A [Pool] owns its storage; a [Handle] into one owns nothing, which is the
point of it handles are copied freely, and the pool is the single
owner that [free] applies to. *)
| Vec _ | Map _ | Pool _ -> true
| Option t -> is_move_only t
| Array (_, t) -> is_move_only t
| _ -> false
@ -153,6 +179,11 @@ let rec keyable = function
| Float _ -> false (* NaN /= NaN, and 0.0 and -0.0 differ bytewise *)
| Array (_, t) -> keyable t
| Named _ -> true (* [Check] decides, by walking the fields *)
(* A [Handle] is not a map key, for the reason a [Ptr] is not: hashing an
identity is a different operation from hashing what it names, and a
handle whose slot has been reused hashes the same as it always did while
naming nothing. The type exists to make that difference visible, so
burying it under a key is the one thing it must not do. *)
| _ -> false
(* Ordering and equality are defined on machine types and on nothing else at
@ -160,6 +191,15 @@ let rec keyable = function
unconstrained type supports only what every type supports (plan.org, Types). *)
let is_comparable = function Enum _ -> true | t -> is_numeric t
(* [=] and [!=] admit one more type than [<] does. A [Handle] is a pair of
numbers in a 64-bit word, so "is this the same entity" is one integer
compare and is worth having two handles are equal exactly when they name
the same slot at the same generation, so a stale handle is never equal to
the live one that replaced it. Ordering handles would compare a slot index,
which means nothing: allocation order is a free-list artefact. Hence two
predicates rather than one. *)
let is_equatable = function Handle _ -> true | t -> is_comparable t
(* [Never] is the type of an expression that does not produce a value: return,
an early-returning `some`, exit. It fits anywhere, and that is the only
place anything resembling subtyping exists. *)

View File

@ -1007,6 +1007,265 @@ int8_t flan_vec_clone(flan_vec *dst, flan_vec *src, flan_allocator *a,
return 1;
}
/* ── (Pool T) and (Handle T), spec-memory.md ─────────────────────────
*
* A handle is a reference to something that can die, which reports that it
* died rather than silently resolving to whatever reused its slot. That is
* the whole design, and every decision below follows from it.
*
* THE PACKING. A handle is one int64_t: the slot index in the low 32 bits and
* that slot's generation counter in the high 32. One word, so it copies,
* zeroes and compares like the integer it is, and owns nothing the pool is
* the single owner. 32 bits of index because a Vec's index is an i32 here and
* widening indices is one change across every container, not a pool question.
*
* LIVE IS ODD. A slot's generation starts at 0 and is bumped on every
* allocation and on every release, so an odd generation means live and an
* even one means dead. Two things fall out of that and both are load-bearing:
* a zeroed handle is generation 0, which is even, so it resolves to nothing
* rather than to slot 0 ZII gives a handle field the right meaning for
* free; and iteration can ask a slot whether it is live without a second
* array or a spare bit.
*
* WRAPPING RETIRES THE SLOT. 32 bits is 2^31 allocate/release pairs on one
* slot every frame at 60fps for a year and a bit but "rare" is not an
* answer when the failure is the silent wrong one this type exists to
* prevent. So a release from generation 0xFFFFFFFF bumps to 0 and does *not*
* put the slot back on the free list. The slot is retired: dead forever, its
* payload leaked, and no future handle can ever collide with an old one.
* Leaking is defined behaviour here (spec-memory.md, "Leaking is defined
* behaviour") and one slot is a bounded price for making the collision
* unrepresentable rather than unlikely.
*
* TWO FAILURES, KEPT APART. A stale handle answers "gone" it is an answer,
* not an error. A pool whose allocator was released traps, through the same
* epoch check a Vec gets. They answer different questions and must not be
* conflated, exactly as the Vec's generation and epoch words must not be.
*
* GROWTH IS TRANSACTIONAL, and that is not tidiness. spec-memory.md's
* StorageExhausted restart re-attempts *the same call*, so a failed grow has
* to leave the pool byte for byte as it was including a cap that still
* agrees with the real block sizes, since the next attempt passes cap as the
* allocator's old_size. Two blocks grow together, so a resize-in-place of the
* first followed by a failure on the second would leave cap describing
* neither. Allocate both, copy, then release the old pair: the only state
* mutated after the last thing that can fail.
*/
typedef struct flan_pool_slot {
uint32_t gen; /* odd: live. even: dead. 0: never allocated, or retired. */
int32_t next; /* free-list link, -1 for the end. Meaningless while live. */
} flan_pool_slot;
typedef struct flan_pool {
void *items; /* cap payloads, size bytes each */
flan_pool_slot *slots; /* cap slot headers, index-parallel with items */
int64_t len; /* slot high-water: 0..len have ever been handed out */
int64_t cap;
int64_t live; /* how many of those are live now */
int64_t free; /* head of the free list, -1 when empty */
flan_allocator *alloc;
int64_t epoch;
} flan_pool;
static int64_t flan_handle_pack(int64_t i, uint32_t gen) {
return (int64_t)(((uint64_t)gen << 32) | (uint64_t)(uint32_t)i);
}
static int64_t flan_handle_index(int64_t h) {
return (int64_t)(uint32_t)(uint64_t)h;
}
static uint32_t flan_handle_gen(int64_t h) {
return (uint32_t)((uint64_t)h >> 32);
}
/* The same epoch check a Vec gets, and for the same reason. A pool that never
* allocated has no allocator and nothing to check. */
static void flan_pool_check(flan_pool *p, const uint8_t *loc, int64_t loclen) {
if (p->alloc) {
int64_t now = (int64_t)p->alloc->epoch;
if (now != p->epoch) flan_vec_stale_fail(loc, loclen, p->epoch, now);
}
}
static flan_allocator *flan_pool_adopt(flan_pool *p) {
if (!p->alloc) {
p->alloc = flan_context_allocator();
p->epoch = (int64_t)p->alloc->epoch;
}
return p->alloc;
}
static int8_t flan_pool_grow(flan_pool *p, int64_t want, int64_t size,
int64_t align) {
flan_allocator *a = flan_pool_adopt(p);
int64_t cap = p->cap, sslot = (int64_t)sizeof(flan_pool_slot);
void *ni, *ns;
if (want <= cap) return 1;
/* Doubling from four, exactly as the Vec grows. */
if (cap < 4) cap = 4;
while (cap < want) {
if (cap > (int64_t)1 << 40) { cap = want; break; }
cap *= 2;
}
flan_fail_bytes = cap * size + cap * sslot;
flan_fail_align = align;
flan_fail_id = (int64_t)(intptr_t)a;
ni = a->proc(a, FLAN_ALLOC_ALLOC, NULL, 0, cap * size, align);
if (!ni) return 0;
ns = a->proc(a, FLAN_ALLOC_ALLOC, NULL, 0, cap * sslot, 8);
if (!ns) {
/* An allocator without can-free leaks the first block here. That is the
* defined outcome and not a new one: the request failed because the
* region is exhausted, and the region is about to be released whole or
* the ceiling raised and the call re-attempted. */
if (a->caps & FLAN_CAN_FREE)
a->proc(a, FLAN_ALLOC_FREE, ni, cap * size, 0, align);
return 0;
}
if (p->len > 0) {
memcpy(ni, p->items, (size_t)(p->len * size));
memcpy(ns, p->slots, (size_t)(p->len * sslot));
}
if (p->items && (a->caps & FLAN_CAN_FREE)) {
a->proc(a, FLAN_ALLOC_FREE, p->items, p->cap * size, 0, align);
a->proc(a, FLAN_ALLOC_FREE, p->slots, p->cap * sslot, 0, 8);
}
p->items = ni;
p->slots = ns;
p->cap = cap;
return 1;
}
int8_t flan_pool_init(flan_pool *p, flan_allocator *a, int64_t size,
int64_t align, const uint8_t *loc, int64_t loclen) {
(void)size; (void)align;
/* Null for the same reason and with the same answer flan_vec_init gives:
* the no-allocator-named case never arrives here as NULL. */
if (!a) flan_null_alloc_fail(loc, loclen);
p->items = NULL;
p->slots = NULL;
p->len = 0;
p->cap = 0;
p->live = 0;
p->free = -1;
p->alloc = a;
p->epoch = (int64_t)a->epoch;
return 1;
}
/* 1/0 for "did it fit", like every other allocating entry point. The handle
* goes out through [out] rather than being returned, so that the compiler's
* alloc_guard reads the answer and the handle separately. */
int8_t flan_pool_insert(flan_pool *p, const void *elem, int64_t *out,
int64_t size, int64_t align, const uint8_t *loc,
int64_t loclen) {
int64_t i;
flan_pool_check(p, loc, loclen);
if (p->free >= 0) {
i = p->free;
p->free = p->slots[i].next;
} else {
if (p->len + 1 > p->cap && !flan_pool_grow(p, p->len + 1, size, align))
return 0;
i = p->len++;
p->slots[i].gen = 0;
p->slots[i].next = -1;
}
p->slots[i].gen++; /* even -> odd: this slot is live */
p->live++;
memcpy((uint8_t *)p->items + i * size, elem, (size_t)size);
*out = flan_handle_pack(i, p->slots[i].gen);
return 1;
}
/* NULL when the handle names nothing, which the compiler turns into None. The
* index is bounded with the unsigned comparison flan_vec_at uses, because the
* low half of a handle can be any 32 bits at all. */
void *flan_pool_resolve(flan_pool *p, int64_t h, int64_t size,
const uint8_t *loc, int64_t loclen) {
int64_t i = flan_handle_index(h);
uint32_t g = flan_handle_gen(h);
flan_pool_check(p, loc, loclen);
if (!(g & 1u)) return NULL; /* a zeroed or dead handle */
if ((uint64_t)i >= (uint64_t)p->len) return NULL;
if (p->slots[i].gen != g) return NULL; /* the slot was reused */
return (uint8_t *)p->items + i * size;
}
/* 1 if this call released it, 0 if the handle was already gone. Releasing
* twice is therefore an answer rather than undefined behaviour which is the
* generational scheme paying for itself a second time, since a pool is the
* one place a double free is *detectable* rather than merely refused. */
int8_t flan_pool_release(flan_pool *p, int64_t h, const uint8_t *loc,
int64_t loclen) {
int64_t i = flan_handle_index(h);
uint32_t g = flan_handle_gen(h), was;
flan_pool_check(p, loc, loclen);
if (!(g & 1u)) return 0;
if ((uint64_t)i >= (uint64_t)p->len) return 0;
if (p->slots[i].gen != g) return 0;
was = p->slots[i].gen;
p->slots[i].gen = was + 1; /* odd -> even: dead, and every old handle with it */
p->live--;
/* The wrap. See the header: the slot is retired rather than reissued. */
if (was != 0xFFFFFFFFu) {
p->slots[i].next = (int32_t)p->free;
p->free = i;
}
return 1;
}
int64_t flan_pool_len(flan_pool *p, const uint8_t *loc, int64_t loclen) {
flan_pool_check(p, loc, loclen);
return p->len;
}
int64_t flan_pool_live(flan_pool *p, const uint8_t *loc, int64_t loclen) {
flan_pool_check(p, loc, loclen);
return p->live;
}
/* The handle of slot [i], or 0 — the never-valid handle — if that slot is
* dead. This plus (len p) is the whole of enumeration, which is what
* migrate-instances needs and what a Vec behind an index cannot give: a Vec's
* indices shift under a removal and a pool's never do. Out of range traps
* rather than answering 0, because an index is an index here and 0..len are
* the valid ones. */
int64_t flan_pool_handle(flan_pool *p, int32_t i, const uint8_t *loc,
int64_t loclen) {
uint32_t g;
flan_pool_check(p, loc, loclen);
if ((uint64_t)(int64_t)i >= (uint64_t)p->len)
flan_vec_bounds_fail(loc, loclen, (int64_t)i, p->len);
g = p->slots[i].gen;
if (!(g & 1u)) return 0;
return flan_handle_pack((int64_t)i, g);
}
/* spec-memory.md's first release point, applied to the owner. Zeroed rather
* than left dangling, for the reason flan_vec_free zeroes. Every handle into
* it is stale afterwards and says so: len goes to 0, so the bound check
* answers "gone" for all of them. */
void flan_pool_free(flan_pool *p, int64_t size, int64_t align,
const uint8_t *loc, int64_t loclen) {
flan_pool_check(p, loc, loclen);
if (p->items && p->alloc && (p->alloc->caps & FLAN_CAN_FREE)) {
p->alloc->proc(p->alloc, FLAN_ALLOC_FREE, p->items, p->cap * size, 0, align);
p->alloc->proc(p->alloc, FLAN_ALLOC_FREE, p->slots,
p->cap * (int64_t)sizeof(flan_pool_slot), 0, 8);
}
p->items = NULL;
p->slots = NULL;
p->len = 0;
p->cap = 0;
p->live = 0;
p->free = -1;
p->alloc = NULL;
p->epoch = 0;
}
/* ── (Map K V), spec-memory.md ──────────────────────────────────────────
*
* Odin's map, followed deliberately: open-addressed Robin Hood hashing at a