From 86c598773d2a50c19a36d506c4ba704cef85a904 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 09:05:50 +0700 Subject: [PATCH] Say when storage dies, and what dies with it --- spec-memory.md | 232 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 231 insertions(+), 1 deletion(-) diff --git a/spec-memory.md b/spec-memory.md index 8e28940..deecd60 100644 --- a/spec-memory.md +++ b/spec-memory.md @@ -2,6 +2,10 @@ Status: **frozen**. Closes plan.org open decisions #6 and #10, and resolves the contradiction between "value structs copy on assignment" and owning containers. +The Allocators section additionally settles the four things that had to be +decided before `Vec` and `Map` are written: when storage is released, the `drop` +hook, alignment, and allocation failure. One question there is left open on +purpose and says so. Everything else in the design references this vocabulary. It governs plain fixed-layout `struct` values, not the separately planned managed `class` facility (see plan.org, "Managed classes"). @@ -58,7 +62,10 @@ allocator; `(clone x alloc)` names one. Value types (`[n T]`, structs of value types, primitives) need no `clone` — assignment already copies them. A struct containing a `Vec` field is itself move-only. Ownership is structural, -not declared: a type is a value type iff all of its fields are. +not declared: a type is a value type iff all of its fields are **and it declares +no `drop` hook** (see Allocators). A `drop` hook makes a type move-only for the +same reason a `Vec` field does — exactly one owner, so the hook fires exactly +once — and a type with one cannot be `clone`d. ## Borrowing @@ -211,3 +218,226 @@ The allocator is part of the calling convention (`context/allocator`, `free` and `clone` never need it named again. Allocation uses the current implicit allocator by default, as in Odin; an operation never falls back to a hidden global allocator, and an explicit allocator can override the context. + +### The allocator is one type-erased procedure + +As in Odin (`base/runtime/core.odin:422`, `Allocator_Proc`), an allocator is a +procedure plus an opaque data pointer, and every operation takes `size` and +`align` as parameters: + +| Operation | Meaning | +|------------------------------------|----------------------------------------| +| `alloc size align` | new block | +| `resize p old-size new-size align` | grow or shrink | +| `free p` | release one block | +| `free-all` | release everything the allocator holds | + +It is type-erased on purpose. `Vec` and `Map` are one runtime over `(size, +align)` and, for `Map`, a compiler-emitted hash and equality pair passed as +arguments — Odin's `Map_Info` (`base/runtime/core.odin:369`). No generics are +involved, and none are needed. + +An allocator declares which operations it implements. Odin's arena answers +`.Free` with `.Mode_Not_Implemented` (`core/mem/allocators.odin:307`); Flan's +equivalent is a **capability set** on the allocator value, readable at run time. +The one that is load-bearing below is `can-free`. + +### When storage is released + +There are exactly two release points, and neither of them is a scope. + +1. **`(free v)`** — explicit. `v` is any move-only value: a `Vec`, a `Map`, a + struct that owns one, or a struct that owns a resource rather than storage + (a `Texture2D`, a socket, a file handle — see `drop` below). For a value that + holds a resource and no storage, `free` runs `drop` and nothing else; it is + still the release operation, and it is how a `Texture2D` in a local is + released. `free` consumes its argument exactly as any other move does: the source + binding is dead afterwards and using it is a compile error. That rule is + already what makes a double free unrepresentable, so `free` needs no new + analysis. +2. **Region release** — `(free-all a)` on an allocator, which releases + everything made from it at once, including storage reachable from bindings + that are still in scope. The per-frame `(free-all context/temp)` at the top + of a game loop *is* the frame arena, and it is the normal way arena-tier + storage dies. + +**Nothing is released at scope exit.** Not at the end of a `let`, not at the end +of a function, not at the end of a `with-allocator` body. `with-allocator` +rebinds the current allocator for its dynamic extent and releases nothing; the +region it names is released, if ever, by an explicit `free-all` somewhere else. + +This is deliberate, and it is the point on which the two obvious precedents were +rejected: + +- **Odin's `defer delete`** cannot be written here. `defer` is function-scoped + (`check.ml:505` refuses it in a `let`, a loop or a branch) and, because `let` + is a block, a top-level `defer` is checked in a scope containing only the + parameters and globals (`check.ml:1670`). `(defer (free v))` for a `let`-bound + `v` is **not expressible today**. It becomes expressible with either + block-scoped `defer` or a sequential top-of-body binder; until one of those + exists, no idiom in this spec may depend on it. +- **Carp's scope-end frees** are a whole-program linear analysis that inserts a + teardown call at every binding's last use (`Memory.hs`, and `Info.hs`'s + `Deleter`). Carp could not reconcile that with an arena and therefore has no + allocator abstraction at all. A release point the programmer cannot see is + exactly what makes a frame arena unstateable. + +**Leaking is defined behaviour.** Storage that is never freed and whose +allocator is never released is leaked, and for the permanent arena (symbols, +code) and the dev/REPL tier that is the correct program. "Did you forget to +free" is not a type question here; it is an allocator-tier question, and dev +builds answer it by reporting a general-purpose allocator's outstanding blocks +when it is destroyed. + +**`free` applies to a whole owner.** It recurses structurally into owning +fields. A field is never freed on its own: `(free (.textures e))` is refused, +because it would leave `e` partly dead with no way to say so. + +### Dev builds detect a released region + +A `Vec` or `Map` records its allocator (see above). In a dev build it also +records that allocator's **epoch** — a counter the allocator bumps on every +`free-all`. Any operation on a container whose recorded epoch has moved traps, +naming the allocation site and the release site. This is a second and separate +counter from the per-`Vec` generation word that catches stale slices; the two +answer different questions and must not be conflated. Both are dev-only: the +release layout of a `Vec` is `ptr + len + cap + allocator` and nothing more. + +### `drop` — owning something that is not memory + +A type may name one hook: + +``` +(drop Texture [t (Ptr Texture)] ...) +``` + +It takes a **pointer, not the value**, which is Carp's shape and for Carp's +reason. Carp shipped `delete` — auto-generated per type, consuming, and +responsible for the recursive teardown of every field — and then had to add +`drop` separately, because a user who redefined `delete` to close a file had to +re-implement that whole teardown by hand. Carp's `drop` is looked up per +teardown site (`Memory.hs:806`, `getDropFunc`, at `RefTy t` where `delete` is +`FuncTy [t]`) and emitted immediately before the teardown call +(`Emit.hs:1042`), so the hook *composes with* compiler-generated teardown rather +than replacing it. Flan takes that arrangement unchanged. + +- `(free v)` runs `drop` on `v` first, then tears down `v`'s owning fields in + declaration order, each by the same rule. +- A `drop` hook may read and mutate through its pointer. It may **not** move out + of the value, and it may not `free` it. +- A type has a `drop` hook transitively: a struct any of whose fields has one, + has one. +- **A type with a `drop` hook is move-only and cannot be `clone`d.** Move-only, + because a value type copies on assignment and two copies of one socket would + each run `drop`; the same argument that makes a `Vec` field move-only. Not + `clone`able, because duplicating a texture id or a file descriptor is not the + compiler's decision to make — Carp needed a separate `copy` interface for + exactly this. A type that *can* be duplicated says so with an ordinary named + function. + +**Nothing runs `drop` when an arena resets — because such a value cannot be in +an arena.** Constructing a container whose element type transitively has a +`drop` hook, or allocating such a value, against an allocator that lacks +`can-free` is **refused at the point of construction**: one branch per +container, not per element. `free-all` therefore never has to walk a list of +registered destructors, which is what keeps the frame tier's reset genuinely +free (plan.org's memory table) and keeps a destructor list — an allocation +nobody wrote — out of the core. + +The consequence, stated plainly because a reader will assume otherwise: + +> **`drop` is not a destructor.** A `Texture2D` held in a local, a parameter, or +> a plain stack struct never has `drop` run, because Flan has no scope-end +> anything. `drop` fires at exactly one place — inside `free` — and resources in +> locals are released explicitly, exactly as memory is. Carp's `drop` fires at +> scope end only because Carp has scope-end frees, which the section above +> rejects. + +### Alignment + +Alignment is a property of the **type**, computed at the **call site**, and +passed as a **parameter** to the type-erased allocator. All three, and they are +not alternatives. + +Odin arranges it exactly this way: `elem_align` is threaded through every +type-erased dynamic-array entry point (`base/runtime/dynamic_array_internal.odin` +— `__dynamic_array_reserve`, `__dynamic_array_resize`, `__dynamic_array_append`), +and `align_of_type` sits in `Map_Cell_Info` (`base/runtime/core.odin:350`). The +monomorphised wrapper is the only place the concrete type is known, so it is the +only place that can produce the number. + +Alignment is **not stored** in the `Vec` or `Map` header. That is safe because of +a condition worth writing down: every operation that needs it — `push`, +`reserve`, `resize`, `clone`, `free` — is compiler-emitted at a site where the +concrete element type is known. Any future type-erased teardown path would break +that condition; there is not to be one. (This is the second reason the `drop` +registry above was rejected: it would have been exactly such a path.) + +The natural alignment of `T` is `align-of T`. Raising it above natural — 16 +bytes for `#soa` and for component-wise fixed arrays — is declared **on the +type**, so that every site computing `align-of T` gets the raised number with no +further plumbing. The surface syntax for that declaration is deliberately not +fixed here; nothing is built that needs it yet. + +### Allocation failure + +**No allocating operation returns an error, and none can fail silently.** When +the allocator cannot satisfy a request, the operation signals + +``` +(StorageExhausted {:bytes n :align a :allocator id}) +``` + +with `error`, whose type is `Never` (spec-conditions.md §2), inside a +`restart-case` offering `retry`. This is one rule over *every* allocating +operation — `vec-new`, `map-new`, `push`, `put`, `reserve`, `clone` — so their +result types stay `(Vec T)`, `Unit`, `Unit` and so on, with no `Result` and no +out-parameter anywhere. + +What that buys, against the alternative: Odin's `append` returns an ignorable +`Allocator_Error` (`base/runtime/core_builtin.odin:767`, +`#optional_allocator_error`), and the type-erased path underneath returns the old +length on a failed reserve, marked +`// TODO(bill): Better error handling for failed reservation` +(`base/runtime/dynamic_array_internal.odin`). An `append` that appends nothing +and says nothing is the outcome this rule exists to make impossible. + +- The condition is a value struct on the signalling frame's stack, with fixed + numeric fields and **no rendered message**, because formatting would allocate + and this is the one path that must not. Rendering happens in the handler or the + break loop, where a working allocator is known. +- Unhandled, `error` enters the dev break loop or aborts in release + (spec-conditions.md §2). It is never a no-op; `signal` is not used here. +- A handler that frees something, releases a scratch region, or grows the arena + and then invokes `retry` re-attempts the same request. A handler that wants a + *different* allocator needs a restart taking an argument, which does not exist + yet; until it does, such a handler rebinds the context allocator and retries. +- Because an allocating operation can transfer, every caller of one checks the + transfer channel after the call (spec-conditions.md §6). `push` is not a leaf + call, and that per-call-site check is the price of not being Odin. + +**This is the named exception to plan.org's "restarts go at the resync point, +once".** That rule is right for program-level errors and wrong here: a restart +established at a parser's top-level loop cannot re-attempt an allocation, and +only the allocation site can. Compiler-emitted restarts at the point of failure +are the exception, in the same way Common Lisp's runtime establishes +`store-value` at an unbound-variable error rather than at a resync point. No +*user* code establishes restarts below a resync point. + +### Open: catching a use-after-release statically + +Both release points above are dynamic, and the frame arena is the reason. A +static rule — "a move-only value constructed under a given allocator may not +outlive it" — needs to know statically which allocator a construction used, and +`with-allocator` plus `context/allocator` are precisely the mechanisms that deny +that knowledge. The lexical subset (a value made inside a `with-allocator` body +and returned out of it) is checkable; the general case is not; and shipping only +the subset would teach a rule that silently stops applying at the loop where it +matters most. + +Until a provenance pass exists (plan.org open decision #3), the answer is the +dev-build epoch trap above: detection, loud and immediate, rather than +prevention. Settling this needs one thing that does not exist yet — real Flan +programs using arenas, to say whether the escapes that actually occur are +lexical. It is not settleable from the design alone, and it is not papered over +here.