Merge branch 'allocator-decisions' into dev-loop

The four things spec-memory.md never said, settled before any of Vec is
written: storage is released by the allocator and never by a scope, drop
takes a pointer and runs only inside free, alignment is a property of the
type computed at the call site, and allocation failure signals
StorageExhausted with a retry restart.

The interaction is the payoff: release fires only at free and at region
release, region release refuses drop-types, so drop fires at exactly one
place. And the premise behind the first was stronger than thought --
(defer (free v)) for a let-bound v is not expressible at all today, since
defer is refused anywhere but a function body's top level.
This commit is contained in:
Joseph Ferano 2026-09-12 09:08:15 +07:00
commit 838548b548

View File

@ -2,6 +2,10 @@
Status: **frozen**. Closes plan.org open decisions #6 and #10, and resolves the Status: **frozen**. Closes plan.org open decisions #6 and #10, and resolves the
contradiction between "value structs copy on assignment" and owning containers. 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 Everything else in the design references this vocabulary. It governs plain
fixed-layout `struct` values, not the separately planned managed `class` fixed-layout `struct` values, not the separately planned managed `class`
facility (see plan.org, "Managed classes"). 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. types, primitives) need no `clone` — assignment already copies them.
A struct containing a `Vec` field is itself move-only. Ownership is structural, 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 ## 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 `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 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. 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.