diff --git a/lib/check.ml b/lib/check.ml index d22ad87..1844936 100644 --- a/lib/check.ml +++ b/lib/check.ml @@ -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); diff --git a/lib/emit.ml b/lib/emit.ml index f668f2d..638824c 100644 --- a/lib/emit.ml +++ b/lib/emit.ml @@ -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 } diff --git a/lib/types.ml b/lib/types.ml index 463343d..e080d5e 100644 --- a/lib/types.ml +++ b/lib/types.ml @@ -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. *) diff --git a/runtime/flan_rt.c b/runtime/flan_rt.c index 6f38c55..066b329 100644 --- a/runtime/flan_rt.c +++ b/runtime/flan_rt.c @@ -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