diff --git a/FIX.org b/FIX.org
index 2d001f2..9bae6c5 100644
--- a/FIX.org
+++ b/FIX.org
@@ -256,3 +256,30 @@ NEXT.md are annotated at their live claims.
Two of the day's fix lanes were cancelled with this (borrowed-flag, region
element); the while-condition fix merged in the morning is deleted again by
the repeal, and its pin with it.
+
+* The second round, 2026-09-18 — Pool, move-only, and the gen word
+
+Ordered by the author after the ownership repeal, on the same argument: the
+Odin position, full stop.
+
+- Pool and (Handle T) are gone — types, checker arms, runtime section,
+ fixtures. Two containers are enough; a slab with generational handles is a
+ library over a Vec when a program wants one.
+- The move-only concept is gone: everything copies as its header, copyable?
+ left the predicate list (four remain), and the struct/defdata/defunion
+ owning-field refusals are lifted. The region rule stands untouched — a
+ container of owning elements is still built against a region and released
+ by one free-all.
+- The gen word left both container headers (read by nothing since it was
+ written). A Vec is ptr len cap allocator epoch; the epoch trap stays.
+- Container globals keep both declaration rules, reworded: they start zeroed
+ (a global initialiser is a compile-time constant, and a container's only
+ constant is the empty one), and a defconst container is refused since a
+ constant is not an assignable place. sand.flan's experiment line would now
+ be refused with the reworded sentence.
+
+Everything verified together: dune test --force 232/0 with zero suite FAILs,
+@x86 122 MATCH / 0 DIFFER, @sanitize clean, and the whole-repo check sweep
+against the parent differs only where it should: the two negative fixtures
+now accepted (vec-in-struct, the clause-less generics corpus), the two pool
+fixtures deleted.
diff --git a/NEXT.md b/NEXT.md
index f033eb8..b032bcd 100644
--- a/NEXT.md
+++ b/NEXT.md
@@ -195,7 +195,7 @@ Worth stating because the notes elsewhere may still read the old way:
an `Int`, a `Ptr`, an `Alloc` or a `Handle`, with aggregates leaving through an out-pointer, and
`crossable` refuses an aggregate return from a `declare` outright. The refusal in `call_native` is
unreachable and building sret behind it would have been *wrong* — that path is the C boundary, where
- SysV returns a 16-byte slice in `rax:rdx` rather than by hidden pointer. `Vec`, `Map` and `Pool` all
+ SysV returns a 16-byte slice in `rax:rdx` rather than by hidden pointer. `Vec`, `Map` and `Pool` (since removed) all
match; item 16's "unexercised" line was stale.
- **The header check is not opt-in.** `vendor/raylib/raylib-5.5.h` is committed and
`vendor/raylib/headers` names it directly. There is no `FLAN_RAYLIB_H`.
@@ -321,7 +321,7 @@ the easy half. What is left is conditions, and the measurement moved them from '
doing first. Do that before item 1, not after.
3. **`Rt` with an aggregate return**, and with it most of the container runtime. `Vec`, `Map` and
- `Pool` have not been exercised through this backend at all.
+ `Pool` (since removed) have not been exercised through this backend at all.
4. **`Fnval`'s indirection cell.** `FnAddr (Fnval n)` emits the symbol, which is right for a
whole-program build and wrong the instant anything is redefined into it. There are no cells and no
diff --git a/docs/BUILT.md b/docs/BUILT.md
index 7521e67..410baff 100644
--- a/docs/BUILT.md
+++ b/docs/BUILT.md
@@ -2910,6 +2910,16 @@ is still right: a union has a C layout and still may not cross to C by value, be
## `(Handle T)` and the pool, which is what a stale reference answers with
+**Removed 2026-09-18**, in the second round of the repeal: two containers are
+enough, the corpus's only pools were its own fixtures, and a slab behind
+generational handles is a library over a `Vec` when a program wants one —
+which is where Odin keeps it. The `gen` word on `Vec` and `Map` went the same
+day (maintained, read by nothing; the header is `ptr len cap allocator
+epoch` now), and so did the move-only concept itself — everything copies, and
+`copyable?` left the predicate list. spec-memory.md, "The repeal", carries
+all three amendments. The section below is kept as the record of what was
+built.
+
A handle is a reference to something that can die, which reports that it died rather than silently resolving to
whatever reused its slot. `check.ml` refused `(Handle T)` by name as milestone 6; this is what it stood for, and the
pool came with it because the pool is what makes the report possible.
diff --git a/docs/REVIEW-production-readiness.md b/docs/REVIEW-production-readiness.md
index e71c50d..e744b6d 100644
--- a/docs/REVIEW-production-readiness.md
+++ b/docs/REVIEW-production-readiness.md
@@ -144,8 +144,8 @@ The containers, strings/UTF-8, sequences, random, and printing layers are decent
`flan_transfer_fail`, `flan_null_alloc_fail`, `flan_free_all_fail` → `rt_die`). Bounds
and arithmetic already park via the break-loop hook; these should too.
- **Unchecked `malloc` in `flan_argv`** (`flan_rt.c:162`) — the one in the file.
-- **`v->gen` stale-slice word is maintained and never consulted** (`flan_rt.c:1347`) —
- either implement the check or delete the field before someone trusts it.
+- ~~**`v->gen` stale-slice word is maintained and never consulted**~~ — deleted
+ 2026-09-18 with the second round of the repeal; the header is five words now.
- **`flan_slurp_into` conflates elements and bytes and skips `flan_vec_check`**
(`flan_rt.c:2746`) — safe only because check.ml pins slurp to `(Vec u8)`; a latent trap.
- **`flan run` swallows build flags as program arguments** (`bin/main.ml:634-651`) —
diff --git a/lib/check.ml b/lib/check.ml
index ca3dc2a..b8caf6f 100644
--- a/lib/check.ml
+++ b/lib/check.ml
@@ -406,26 +406,20 @@ let unimplemented loc what milestone =
Odin's [where] clause is the same shape ([core/slice/slice.odin:289] is
[where intrinsics.type_is_ordered(T)]) with forty-one predicates against
- these five. The fifth, [copyable?], has no Odin counterpart at all: Odin
- has no move semantics, so [$T] never has to answer the question. The prior
- art there is Rust's [T: Copy], with the difference that [copyable?] is a
- question the compiler answers rather than a trait a user implements. *)
-let predicate_names = [ "ordered?"; "equal?"; "hashable?"; "numeric?"; "copyable?" ]
+ these four. There is no [copyable?] any more and no Odin counterpart
+ either: Odin has no move semantics, and since the repeal neither does this
+ language, so [$T] never has to answer the question. *)
+let predicate_names = [ "ordered?"; "equal?"; "hashable?"; "numeric?" ]
(* ── What a type owns, transitively ────────────────────────────────────
- spec-memory.md: "Ownership is structural, not declared." [Types.is_move_only]
- answers the same question one level deep and deliberately stops there — a
- [Named] is not move-only, because making it so is the transitive ownership
- model that recursive teardown would need, and there is no recursive teardown.
- This walk is the *other* use of the same fact, and the two must not be
- collapsed: nothing here feeds the move checker, and a type this says yes
- about is still copied and still tracked exactly as it was yesterday.
+ The one structural ownership question that survived the repeal, because it
+ is not about copying at all.
What it decides, and the only thing it decides, is whether a container of
this element type has to be built against a region allocator — see
[region_only] below and [flan_alloc_region_only] in the runtime. That is a
question about *release*, so it is asked of every arm a release would have
- to reach and would not: a Vec, Map or Pool owns a block outright; an Option,
+ to reach and would not: a Vec or Map owns a block outright; an Option,
a fixed array, a struct or a data type's case owns whatever its payload
does.
@@ -438,12 +432,11 @@ let predicate_names = [ "ordered?"; "equal?"; "hashable?"; "numeric?"; "copyable
contain itself by value anyway — [check_finite] refuses that — only through
a container, which is an arm that answers for itself.
- [env.unions], the untagged ones, are deliberately not consulted: a move-only
- member in one is refused outright, and that refusal is not waiting on
- teardown the way these were — nothing anywhere records which member is
- live, so there is no fact a release could read. A region removes the
- teardown question and leaves that one exactly where it was, so an untagged
- union owns nothing and there is nothing here to walk. *)
+ [env.unions], the untagged ones, are deliberately not consulted: nothing
+ anywhere records which member of one is live, so there is no fact this
+ walk could read — an untagged union is treated as owning nothing, and what
+ its members point at is the program's, through whatever tag it keeps
+ beside the union. *)
let owning_fields env n =
match Hashtbl.find_opt env.structs n with
| Some s -> [ s.Tast.fields ]
@@ -454,7 +447,7 @@ let owning_fields env n =
let rec owning env ?(seen = []) (t : Types.t) =
match t with
- | Types.Vec _ | Types.Map _ | Types.Pool _ -> true
+ | Types.Vec _ | Types.Map _ -> true
| Types.Option e | Types.Array (_, e) -> owning env ~seen e
| Types.Named n ->
not (List.mem n seen)
@@ -471,7 +464,7 @@ let rec owning env ?(seen = []) (t : Types.t) =
value is the whole of the question there. *)
let region_only env (t : Types.t) =
match t with
- | Types.Vec e | Types.Pool e -> owning env e
+ | Types.Vec e -> owning env e
| Types.Map (_, v) -> owning env v
| _ -> false
@@ -488,23 +481,20 @@ let pred_holds p (t : Types.t) =
[key_pair]. *)
| "hashable?" -> Types.keyable t
| "numeric?" -> Types.is_numeric t
- | "copyable?" -> not (Types.is_move_only t)
| _ -> false
(* What one declared predicate *also* gives you. These are entailments over
the type system as it stands, not conveniences: every type [is_comparable]
- admits is a number or an enum, so it is equatable and it is not move-only.
- The table is only sound while that is true — an ordered move-only type, or
- an ordered type with no [=], would make it wrong — so it lives in one place
- and says so. The gain is real ergonomics: [{:where (ordered? $t)}] is
- enough for a [sort!] that also compares and reads its elements twice,
- rather than three predicates on one line. *)
+ admits is a number or an enum, so it is equatable. The table is only sound
+ while that is true — an ordered type with no [=] would make it wrong — so
+ it lives in one place and says so. The gain is real ergonomics:
+ [{:where (ordered? $t)}] is enough for a [sort!] that also compares,
+ rather than two predicates on one line. *)
let pred_entails ~declared ~wanted =
String.equal declared wanted
|| match wanted, declared with
| "ordered?", "numeric?" -> true
| "equal?", ("numeric?" | "ordered?") -> true
- | "copyable?", ("numeric?" | "ordered?" | "equal?" | "hashable?") -> true
| _ -> false
let declares preds v wanted =
@@ -518,27 +508,6 @@ let declares preds v wanted =
properties are the Vec's. *)
let tyvar_of (t : Types.t) = match t with Types.Var v -> Some v | _ -> None
-(* ── Move-only, with a type variable defaulting to move ────────────────
- [Types.is_move_only (Var _)] is [false] and cannot be anything else: the
- same variable is [i32] at one instantiation and [(Vec i32)] at the next, so
- the property is not decidable abstractly. The author's decision is to
- default to **move**, because move is the *stricter* rule: assuming it can
- only refuse a program that would have been fine, never admit one that
- double-frees. [copyable?] is the opt-out, exactly as Rust's [T: Copy] is.
-
- In the body this means a generic may not use a parameter twice without
- declaring [copyable?]. Since the repeal this gates the structural rules
- only — what a struct, union or pool may own — not any use of a binding.
-
- A [Var] only ever survives the abstract pass. Inside an instantiation
- [env.subst] has made everything concrete, so this is [Types.is_move_only]
- there and the strictness costs nothing at a call site. *)
-let rec move_only preds (t : Types.t) =
- match t with
- | Types.Var v -> not (declares preds v "copyable?")
- | Types.Option e | Types.Array (_, e) -> move_only preds e
- | t -> Types.is_move_only t
-
(* ── (Map K V), spec-memory.md ──────────────────────────────────────────
Both halves are checked where the type is written, not where an operation
is, so that a map nothing ever uses is still refused if it cannot work.
@@ -567,8 +536,8 @@ let map_type ?(preds = []) loc (k : Types.t) (v : Types.t) =
(* The key, as far as the type alone can say. A struct passes here and is
decided at the operation, by [key_pair], which walks its fields — the
struct table is not necessarily complete while a type is being resolved,
- and every map that exists reaches an operation anyway, because a global of
- move-only type is refused and a local needs (map-new). *)
+ and every map that exists reaches an operation anyway, because a global
+ map starts zeroed and a local needs (map-new). *)
(* A type variable is a map key exactly when the [where] clause says it is
hashable. Nothing else about it is knowable here, and falling through to
[Types.keyable] would answer no for a variable that is about to be
@@ -664,27 +633,6 @@ let rec resolve env ?(seen = []) (t : Ast.texpr) : Types.t =
(resolve env ~seen v)
| "Map", _ -> fail loc "(Map K V) takes exactly two types"
| "Result", _ -> unimplemented loc "(Result T E)" 6
- | "Pool", [ a ] ->
- let e = resolve env ~seen a in
- (* Lifted with the Vec's and pushed to the construction with it, and a
- pool is the one of the three where that is not quite the same trade,
- because a pool has a release point a Vec does not: [(release p h)]
- recycles one slot while the pool lives on. In a region that costs a
- block that stays allocated until [free-all] and is never handed back
- — the slot itself is reused, since the next insert overwrites those
- bytes, but whatever the dead element pointed at is stranded.
-
- That is a region leak bounded by the region, which is the bargain a
- region already is: an arena's whole proposition is that nothing comes
- back before the reset. It is not the unbounded leak the heap would
- take, and it is not a use-after-free — nothing is released twice
- because nothing is released once. Said here rather than left for a
- reader to work out, because "reuse" is the word that makes a pool
- look different from a Vec and it deserves an answer. *)
- 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)
@@ -857,8 +805,6 @@ let rec bind_ty subst (pat : Types.t) (arg : Types.t) =
| Types.Slice p, Types.Slice a
| Types.Ptr p, Types.Ptr a
| Types.Vec p, Types.Vec a
- | Types.Pool p, Types.Pool a
- | Types.Handle p, Types.Handle a
| Types.Option p, Types.Option a -> bind_ty subst p a
| Types.Array (n, p), Types.Array (m, a) -> Int64.equal n m && bind_ty subst p a
| Types.Map (k, v), Types.Map (k', v') ->
@@ -878,8 +824,6 @@ let rec subst_ty subst (t : Types.t) =
| Types.Map (k, v) -> Types.Map (subst_ty subst k, subst_ty subst v)
| Types.Ptr e -> Types.Ptr (subst_ty subst e)
| Types.Vec e -> Types.Vec (subst_ty subst e)
- | Types.Pool e -> Types.Pool (subst_ty subst e)
- | Types.Handle e -> Types.Handle (subst_ty subst e)
| Types.Option e -> Types.Option (subst_ty subst e)
| Types.Fn (ps, r) -> Types.Fn (List.map (subst_ty subst) ps, subst_ty subst r)
| t -> t
@@ -889,7 +833,7 @@ let rec generic_ty (t : Types.t) =
match t with
| Types.Var _ -> true
| Types.Slice e | Types.Array (_, e) | Types.Ptr e | Types.Vec e
- | Types.Pool e | Types.Handle e | Types.Option e -> generic_ty e
+ | Types.Option e -> generic_ty e
| Types.Map (k, v) -> generic_ty k || generic_ty v
| Types.Fn (ps, r) -> List.exists generic_ty ps || generic_ty r
| _ -> false
@@ -933,8 +877,6 @@ let rec mangle_ty (t : Types.t) =
| Types.Map (k, v) -> Printf.sprintf "map-%s-%s" (mangle_ty k) (mangle_ty v)
| Types.Ptr e -> "ptr-" ^ mangle_ty e
| Types.Vec e -> "vec-" ^ mangle_ty e
- | Types.Pool e -> "pool-" ^ mangle_ty e
- | Types.Handle e -> "handle-" ^ mangle_ty e
| Types.Option e -> "opt-" ^ mangle_ty e
| Types.Fn (ps, r) ->
Printf.sprintf "fn-%s-to-%s"
@@ -972,7 +914,7 @@ let rec occurs_in ~needle (t : Types.t) =
||
match t with
| Types.Slice e | Types.Array (_, e) | Types.Ptr e | Types.Vec e
- | Types.Pool e | Types.Handle e | Types.Option e -> occurs_in ~needle e
+ | Types.Option e -> occurs_in ~needle e
| Types.Map (k, v) -> occurs_in ~needle k || occurs_in ~needle v
| Types.Fn (ps, r) ->
List.exists (occurs_in ~needle) ps || occurs_in ~needle r
@@ -1251,7 +1193,6 @@ let region_sym (t : Types.t) =
match t with
| Types.Vec _ -> "flan_vec_region_only"
| Types.Map _ -> "flan_map_region_only"
- | Types.Pool _ -> "flan_pool_region_only"
| _ -> assert false
let region_check env loc (target : Tast.expr) (after : Tast.expr) =
@@ -1972,16 +1913,17 @@ and var ctx loc ~want name =
| None -> captured ctx loc name;
Loc.failk "check/unknown-name" loc "unknown name %s" name)
-(* What remains of spec-memory.md's ownership section after the repeal of
- 2026-09-18 is entirely in the types: move-only decides what may be copied,
- the struct/union/pool rules below decide what may own what, and the
- allocator's capability decides what a free means at run time. Which frees
- run, and in what order, is the program's own business — the same contract
- Odin ships with — and the dev build's generation words are the net under
- it. The flow analysis that used to live here (a per-function dead set, a
- borrow flag over container reads, a loop-iteration diff) tracked use-after-
- move and double-free statically; it was repealed rather than repaired when
- its holes proved structural. See spec-memory.md, "The repeal". *)
+(* What remains of spec-memory.md's ownership section after the repeals of
+ 2026-09-18 is the allocator's side alone: the region rule decides where a
+ container of owning elements may be built, and the allocator's capability
+ decides what a free means at run time. Everything copies — a container as
+ its header, the copies aliasing one buffer — and which frees run, and in
+ what order, is the program's own business, the same contract Odin ships
+ with; the dev build's epoch words are the net under it. The flow analysis
+ that used to live here (a per-function dead set, a borrow flag, a
+ loop-iteration diff) went in the first repeal; the move-only concept
+ itself — copy refusals, [copyable?], the struct/union owning rules — went
+ in the second. See spec-memory.md, "The repeal". *)
(* [defer_ok] is granted again before *every* form, not once before the block:
[check] withdraws it as it starts, so granting it once would let the first
@@ -3398,42 +3340,6 @@ and map_new_types ctx ~want loc args =
"nothing here says what (map-new) maps — write the key and value \
types, as (map-new string i32), or give the binding a type")
-(* The element type for [pool-new]. The same rule [vec-new] uses and for the
- same reason: a [let] has no type annotation, so a local pool has nowhere
- else to say what it holds. *)
-and pool_new_elem ctx ~want loc args =
- let named =
- match args with
- | { Ast.e = Ast.Var n; _ } :: rest
- when lookup ctx n = None
- && (not (Hashtbl.mem ctx.env.globals n))
- && type_named ctx n ->
- Some (resolve_name ctx.env ~seen:[] loc n, rest)
- | _ -> None
- in
- match named with
- | Some (t, rest) ->
- if move_only ctx.env.tvpreds t then
- fail loc
- "(Pool %s) holds a move-only element, and the type-erased runtime \
- copies and releases slots bytewise. Recursive teardown arrives with \
- drop (step 5 in NEXT.md)"
- (Types.to_string t);
- t, rest
- | None ->
- (match want with
- | Some (Types.Pool t) -> t, args
- | _ ->
- fail loc
- "nothing here says what (pool-new) is a Pool of — write the element \
- type, as (pool-new Enemy), or give the binding a type")
-
-(* The element type, or the reason this is not a Pool. *)
-and pool_elem loc what (t : Types.t) =
- match t with
- | Types.Pool e -> e
- | other -> fail loc "%s takes a (Pool T), found %s" what (Types.to_string other)
-
(* The element type, or the reason this is not a Vec. *)
and vec_elem loc what (t : Types.t) =
match t with
@@ -3925,9 +3831,9 @@ and named_call ctx ~want loc name args =
(* spec-memory.md's first release point. Since the repeal, what it consumes
it consumes at run time only: nothing marks the binding dead, so a second
[free] or a read after this one type-checks and misbehaves at run time —
- the allocator aborts on a double free it can see, and the dev build's
- generation word traps a stale read. That is the Odin contract: free is a
- thing you write, and writing it twice is yours to not do. *)
+ the allocator aborts on a double free it can see, and the epoch word
+ traps a read through a released region. That is the Odin contract: free
+ is a thing you write, and writing it twice is yours to not do. *)
| "free" ->
arity loc name 1 args;
let target = check ctx (List.hd args) in
@@ -3955,7 +3861,7 @@ and named_call ctx ~want loc name args =
container is region-allocated or it does not exist, so there is always a
[free-all] to point at. *)
(match target.Tast.ty with
- | (Types.Vec _ | Types.Map _ | Types.Pool _)
+ | (Types.Vec _ | Types.Map _)
when region_only ctx.env target.Tast.ty ->
fail loc
"%s holds elements that own storage, and free releases the block \
@@ -3973,27 +3879,12 @@ and named_call ctx ~want loc name args =
expect loc ~want
(rt loc Types.Unit "flan_map_free"
[ target; size_of loc k; size_of loc v; here loc ])
- (* The owner, not a slot. Every handle into it is stale afterwards and
- answers None, which is a strictly better afterlife than a Vec's
- binding gets — that one is a compile error and this one is a run-time
- answer, because handles are copies and the checker cannot see them
- all. That asymmetry is the reason handles exist. *)
- | Types.Pool elem ->
- expect loc ~want
- (rt loc Types.Unit "flan_pool_free"
- [ target; size_of loc elem; align_of loc elem; here loc ])
- | Types.Handle _ ->
- fail loc
- "free takes the owner, and a handle owns nothing — it is a copyable \
- number, so consuming one copy would say nothing about the others. \
- (release p h) recycles one slot; (free p) releases the pool"
| other ->
(* A field is never freed on its own: it would leave its owner partly
dead with no way to say so. *)
fail loc
- "free takes a move-only value — a Vec, a Map, or a struct that owns \
- one — found %s. A resource type with a drop hook is step 5 and does \
- not exist yet"
+ "free takes an owning container — a Vec or a Map — found %s. A \
+ resource type with a drop hook is step 5 and does not exist yet"
(Types.to_string other))
(* (clone v) uses the current allocator, (clone v a) names one. A deep,
independent copy: spec-memory.md's "copying is always explicit". *)
@@ -4064,18 +3955,6 @@ and named_call ctx ~want loc name args =
(mk loc mty (Tast.Local d))
[ size_of loc k; size_of loc v ] mty);
mk loc mty (Tast.Local d) ])))
- (* Refused by name rather than falling through to "clone takes a
- (Vec T)". Copying a pool would duplicate every slot *and* every
- generation counter, so a handle into the original would resolve in
- the copy too — two live entities behind one identity, which is the
- exact confusion the type exists to prevent. If a program wants a
- second world it builds one and inserts into it, and the new handles
- say they are new. *)
- | Types.Pool _ ->
- fail loc
- "a pool cannot be cloned: the copy would carry the same slot \
- generations, so one handle would resolve in both and name two \
- different things. Build a second pool and insert into it"
| _ ->
let elem = vec_elem loc "clone" target.Tast.ty in
let d = fresh_slot ctx (Types.Vec elem) in
@@ -4095,219 +3974,6 @@ and named_call ctx ~want loc name args =
mk loc (Types.Vec elem) (Tast.Local d) ]))))
| _ -> fail loc "clone is (clone v) or (clone v allocator)")
- (* ── (Pool T) and (Handle T), spec-memory.md ───────────────────── *)
- (* The same type-erased shape the Vec has, for the same reason: size_of and
- align_of are produced here because here is where the concrete element
- type is known, and nothing below the call site has ever heard of it. *)
-
- (* (pool-new), (pool-new T), (pool-new a), (pool-new T a). *)
- | "pool-new" ->
- let elem, args = pool_new_elem ctx ~want loc args in
- let a = allocator_arg ctx loc args in
- let pty = Types.Pool elem in
- let p = fresh_slot ctx pty in
- (* [flan_pool_init] cannot fail — a pool with no slots allocates nothing —
- but it goes under the guard anyway, so that the day it does allocate
- the site is already the one that signals. *)
- let attempt =
- rt loc (Types.Int Types.I8) "flan_pool_init"
- [ mk loc pty (Tast.Local p); a; size_of loc elem; align_of loc elem;
- here loc ]
- in
- expect loc ~want
- (mk loc pty
- (Tast.Let ([ (p, mk loc pty (Tast.Zero pty)) ],
- [ with_note loc (alloc_guard ctx loc attempt)
- (reg_note loc "flan_dev_reg_note_pool"
- (mk loc pty (Tast.Local p))
- [ size_of loc elem ] elem);
- region_check ctx.env loc (mk loc pty (Tast.Local p))
- (mk loc pty (Tast.Local p)) ])))
-
- (* (insert p x) -> (Handle T). The handle is the *only* way back to what was
- inserted: a pool hands out no index and no pointer, because an index does
- not notice a reuse and that is the entire point. *)
- | "insert" ->
- arity loc name 2 args;
- (match args with
- | [ target; x ] ->
- let target = check ctx target in
- let elem = pool_elem loc "insert" target.Tast.ty in
- let x = check ctx ~want:elem x in
- let hty = Types.Handle elem in
- (* The element is bound before the loop so that a [retry] re-attempts
- the allocation and not the expression that produced the value —
- [push]'s rule, and for the same reason. *)
- let e = fresh_slot ctx elem in
- let h = fresh_slot ctx hty in
- let attempt =
- rt loc (Types.Int Types.I8) "flan_pool_insert"
- [ target; addr_of loc (mk loc elem (Tast.Local e));
- addr_of loc (mk loc hty (Tast.Local h));
- size_of loc elem; align_of loc elem; here loc ]
- in
- expect loc ~want
- (mk loc hty
- (Tast.Let ([ (e, x); (h, mk loc hty (Tast.Zero hty)) ],
- [ region_check ctx.env loc target
- (with_note loc (alloc_guard ctx loc attempt)
- (reg_note loc "flan_dev_reg_note_pool" target
- [ size_of loc elem ] elem));
- mk loc hty (Tast.Local h) ])))
- | _ -> assert false)
-
- (* (resolve p h) -> (Option (Ptr T)).
-
- A pointer and not a value, and spec-memory.md settles it rather than this
- lane guessing: its worked example under "Mutating something you matched"
- is written out as (Option (Ptr Enemy)), for the reason stated a line
- above it — "pattern bindings bind values, so a matched struct is a copy",
- and a copy cannot be written back. Mutating the pooled thing in place is
- what a pool is for, so (Option T) would answer a question nobody asked.
-
- An [Option] rather than a trap because the whole thesis is that a stale
- reference *reports* — the same shape (get m k) has, and for the same
- reason: absence is an answer, not a failure.
-
- The hole, said plainly: the (Ptr T) is invalidated by any [insert] that
- grows the pool, exactly as a slice is invalidated by a [push]. The handle
- survives that and the pointer does not. It is spec-memory.md's explicit
- Zig/Odin contract one level down, and it is worth naming because it is
- the silent-wrong-answer mode the handle just removed, reintroduced for
- anyone who keeps the pointer across an insert. Chunked never-moving
- storage is the fix and it costs code; taking the contract is the smaller
- correct thing, given [as-slice] already established it. *)
- | "resolve" ->
- arity loc name 2 args;
- (match args with
- | [ target; h ] ->
- let target = check ctx target in
- let elem = pool_elem loc "resolve" target.Tast.ty in
- let h = check ctx ~want:(Types.Handle elem) h in
- (match h.Tast.ty with
- | Types.Handle e when Types.equal e elem -> ()
- | other ->
- fail loc "resolve takes a (Handle %s), found %s"
- (Types.to_string elem) (Types.to_string other));
- let pty = Types.Ptr elem in
- let oty = Types.Option pty in
- let out = fresh_slot ctx pty in
- let got =
- rt loc pty "flan_pool_resolve"
- [ target; h; size_of loc elem; here loc ]
- in
- (* The runtime answers a pointer or NULL and the Option is built here,
- which is [get]'s arrangement: the runtime has no idea what an
- Option's layout is, and keeping it that way is what lets one entry
- point serve every element type. *)
- let cond =
- mk loc Types.Bool
- (Tast.Prim (Tast.Ne,
- [ mk loc (Types.Int Types.I64)
- (Tast.Prim (Tast.Cast (Types.Int Types.I64),
- [ mk loc pty (Tast.Local out) ]));
- mk loc (Types.Int Types.I64) (Tast.Int (0L, Types.I64)) ]))
- in
- let some = mk loc oty (Tast.Some_ (mk loc pty (Tast.Local out))) in
- let none = mk loc oty Tast.None_ in
- expect loc ~want
- (mk loc oty
- (Tast.Let ([ (out, got) ],
- [ mk loc oty (Tast.If (cond, some, none)) ])))
- | _ -> assert false)
-
- (* (release p h) -> bool: true if this call released it, false if the handle
- was already gone.
-
- This is how a pooled value dies, and it is not [free]. [free] consumes
- its argument as a move, and a handle is a copyable number that owns
- nothing — consuming one copy would say nothing about the others. The pool
- is the owner, so the release operation is on the pool and takes the
- handle as an ordinary argument. spec-memory.md's two release points are
- untouched: (free p) is release point 1 applied to the owner, and a
- free-all of the region takes the pool with everything else. This is a
- third thing and it is not a release point — it recycles a slot inside
- storage the pool still owns.
-
- It answers a bool rather than () because the generational scheme makes a
- double release *detectable*, which is worth handing to the caller: this
- is the one place in the language where freeing something twice is an
- answer instead of a refusal. *)
- | "release" ->
- arity loc name 2 args;
- (match args with
- | [ target; h ] ->
- let target = check ctx target in
- let elem = pool_elem loc "release" target.Tast.ty in
- let h = check ctx ~want:(Types.Handle elem) h in
- (match h.Tast.ty with
- | Types.Handle e when Types.equal e elem -> ()
- | other ->
- fail loc "release takes a (Handle %s), found %s"
- (Types.to_string elem) (Types.to_string other));
- let got = rt loc (Types.Int Types.I8) "flan_pool_release"
- [ target; h; here loc ] in
- expect loc ~want
- (mk loc Types.Bool
- (Tast.Prim (Tast.Ne,
- [ got;
- mk loc (Types.Int Types.I8) (Tast.Int (0L, Types.I8)) ])))
- | _ -> assert false)
-
- (* (live p) — how many slots are live now. (len p) is the *slot high-water*,
- which is deliberately the other number: 0..(len p) are the indices
- (pool-handle p i) accepts, so a loop bounded by [len] visits every live
- entry. Bounding it by the live count instead would silently skip entries
- the moment anything had been released, which is precisely the kind of
- quiet wrong answer this whole type exists to remove. *)
- | "live" ->
- arity loc name 1 args;
- let target = check ctx (List.hd args) in
- ignore (pool_elem loc "live" target.Tast.ty);
- let n = rt loc (Types.Int Types.I64) "flan_pool_live" [ target; here loc ] in
- expect loc ~want (mk loc index_ty (Tast.Prim (Tast.Cast index_ty, [ n ])))
-
- (* (pool-handle p i) -> (Option (Handle T)): the handle of slot [i], or None
- if that slot is dead. This plus (len p) is the whole of iteration, and
- iteration is not a convenience — migrate-instances has to *enumerate*
- live instances, and a pool behind generational handles gives that by
- construction where a world arena and an owned region do not. It is the
- reason plan.org's three storage strategies are not a free choice.
-
- An index out of 0..(len p) traps, exactly as (at v i) traps: an index is
- an index here, and answering None for one would hide a bug rather than a
- death. *)
- | "pool-handle" ->
- arity loc name 2 args;
- (match args with
- | [ target; i ] ->
- let target = check ctx target in
- let elem = pool_elem loc "pool-handle" target.Tast.ty in
- let i = check ctx ~want:index_ty i in
- let hty = Types.Handle elem in
- let oty = Types.Option hty in
- let out = fresh_slot ctx hty in
- let got = rt loc hty "flan_pool_handle" [ target; i; here loc ] in
- (* 0 is the never-valid handle — generation 0 is even, and a live slot's
- generation is odd — so the runtime says "dead" with it and needs no
- second return value. *)
- let cond =
- mk loc Types.Bool
- (Tast.Prim (Tast.Ne,
- [ mk loc (Types.Int Types.I64)
- (Tast.Prim (Tast.Cast (Types.Int Types.I64),
- [ mk loc hty (Tast.Local out) ]));
- mk loc (Types.Int Types.I64) (Tast.Int (0L, Types.I64)) ]))
- in
- expect loc ~want
- (mk loc oty
- (Tast.Let ([ (out, got) ],
- [ mk loc oty
- (Tast.If (cond,
- mk loc oty (Tast.Some_ (mk loc hty (Tast.Local out))),
- mk loc oty Tast.None_)) ])))
- | _ -> assert false)
-
(* ── (Map K V), spec-memory.md ─────────────────────────────────── *)
(* Every one of these is a named call over the same type-erased runtime the
Vec uses, with the two sizes and the key's hash and equality pair produced
@@ -4379,7 +4045,8 @@ and named_call ctx ~want loc name args =
| _ -> assert false)
(* (get m k) -> (Option V). Absence is None, not an untyped nil, and the
- first implementation admits copyable values only, so this is a copy.
+ answer is a copy of the value's bytes — for an owning value, a copy of
+ its header, aliasing what the map's slot points at.
There is no allocation here and therefore no guard: a lookup that finds
nothing is an answer, not a failure. *)
| "get" ->
@@ -4816,16 +4483,9 @@ and named_call ctx ~want loc name args =
| Types.Map _ ->
let n = rt loc (Types.Int Types.I64) "flan_map_len" [ a; here loc ] in
expect loc ~want (mk loc index_ty (Tast.Prim (Tast.Cast index_ty, [ n ])))
- (* A pool's [len] is its slot high-water, not its live count, so that
- 0..(len p) stays the range of valid indices the way it is for every
- other container here. (live p) is the other number. *)
- | Types.Pool _ ->
- let n = rt loc (Types.Int Types.I64) "flan_pool_len" [ a; here loc ] in
- expect loc ~want (mk loc index_ty (Tast.Prim (Tast.Cast index_ty, [ n ])))
| other ->
fail loc
- "len takes an array, a slice, a string, a Vec, a Map or a Pool, \
- found %s"
+ "len takes an array, a slice, a string, a Vec or a Map, found %s"
(Types.to_string other))
| "at" ->
(match args with
@@ -4892,11 +4552,10 @@ and named_call ctx ~want loc name args =
and a reader who sees it has already been told where the promise comes
from.
- **It owns nothing.** The result is a [Types.Slice], which is not
- move-only, carries no allocator, and is the same non-owning view
- (as-slice v) answers — so [free] refuses it by the rule it already had
- ("free takes a move-only value"), and nothing in the move analysis needed
- to learn about this form. *)
+ **It owns nothing.** The result is a [Types.Slice], which carries no
+ allocator and is the same non-owning view (as-slice v) answers — so
+ [free] refuses it by the rule it already had ("free takes an owning
+ container"). *)
| "slice-from-ptr" ->
arity loc name 2 args;
(match args with
@@ -5239,8 +4898,8 @@ and named_call ctx ~want loc name args =
| Some b -> (match b.bty with Types.Fn _ -> true | _ -> false)
| None -> false) ->
(* The binding the guard already found, read directly. Going back through
- [check] would repeat the lookup and walk the move and capture paths for
- a type that is neither move-only nor capturable. *)
+ [check] would repeat the lookup and walk the capture path for a type
+ that is not capturable. *)
(match lookup ctx name with
| Some b -> call_value ctx ~want loc (mk loc b.bty (Tast.Local b.slot)) args
| None -> assert false)
@@ -5345,7 +5004,7 @@ and generic_call ctx ~want loc name vars pats pret args =
code the caller did not write, which is the thing the pass exists to
avoid. So the caller has to declare at least what the callee asks for,
and [pred_entails] means [ordered?] covers a callee wanting
- [copyable?] without anyone writing both. *)
+ [equal?] without anyone writing both. *)
(match Hashtbl.find_opt ctx.env.generics name with
| None -> ()
| Some gfn ->
@@ -5656,45 +5315,16 @@ let collect env (decls : Ast.decl list) =
aborts the whole compilation, so an entry left behind by a
declaration that is about to be refused is never read. *)
Hashtbl.replace env.structs n { Tast.sname = n; fields };
- (* spec-memory.md: "Ownership is structural, not declared" — a struct
- containing a Vec is itself move-only, and freeing one recurses into
- its owning fields while (free (.items b)) is refused because it
- would leave the owner partly dead. None of that transitive
- machinery exists, and it is what [drop] would have brought. So the
- field is still refused at the declaration, where the message can
- say so, rather than accepted into a struct that copies its header
- on assignment and gives two owners one buffer.
+ (* A struct field may own storage. Since the repeal a struct
+ holding a [(Vec i32)] is an ordinary value: assignment copies the
+ header bytes, the copies alias one buffer, and freeing through
+ two copies is the program's bug — Odin's contract, kept whole.
- With one exception, and it is exact: a field whose container holds
- *owning* elements. That container cannot exist outside a region —
- the guard at its construction is what makes sure of it (see
- [vec-new]) — so the struct's field is region-allocated too,
- transitively and by the same guard, and the whole graph is released
- by one [free-all]. There is nothing for a teardown to recurse into
- because there is no teardown, and the two-owners-one-buffer problem
- is not one when the owner is the region and neither copy is it.
-
- The plain case stays refused precisely because nothing enforces
- anything there: a [(Vec i32)] field is happily built against the
- heap, nothing would object, and then (free (.items b)) through two
- copies of the struct is a double free with no guard between it and
- the program. *)
- List.iter
- (fun (f : Tast.field) ->
- if Types.is_move_only f.Tast.fty
- && not (region_only env f.Tast.fty) then
- fail loc
- "%s's field %s is %s, which is move-only, and a struct that \
- owns one is move-only too — transitively, with recursive \
- teardown and with a field that cannot be freed on its own. \
- That rule does not exist; hold the %s in a local and pass \
- it. A container whose *elements* own storage is the one \
- kind admitted here, because it can only have been built \
- against a region allocator and a single (free-all) takes \
- the whole graph"
- n f.Tast.fname (Types.to_string f.Tast.fty)
- (Types.to_string f.Tast.fty))
- fields
+ The region rule is separate and survives on its own ground: a
+ field whose container holds *owning* elements can only have been
+ built against a region allocator — the guard at its construction
+ is what makes sure of it (see [vec-new]) — so that graph is
+ released by one [free-all] and no teardown recurses anywhere. *)
| Ast.Defdata (n, vs) ->
(* A data type with no cases has no value, so nothing could ever be given
one, and a parameter of that type would be a function nothing can
@@ -5729,115 +5359,23 @@ let collect env (decls : Ast.decl list) =
because a data type that could hold a container where a struct
could not would be a hole in the same rule. *)
Hashtbl.replace env.datas n { Tast.dname = n; cases };
- (* The same refusal a struct field gets, for the same reason, in the
- same words, and with the same one exception: a data type case's
- fields are a struct, the data type copies bytewise on assignment,
- and there is no recursive teardown to make that safe — except where
- the field's container holds owning elements, which can only have
- been built against a region and is therefore released whole.
-
- This is the arm the recursive dynamic value needs, and it is worth
- naming what it buys: a [Value] with a [(Vec Value)] case and a
- [(Map string Value)] case is now declarable, parsed into an arena,
- walked, and released by one [free-all] with no per-element teardown
- anywhere. What it costs is that a [Value] is copied bytewise like
- any other data value, so two copies share the inner blocks. In a
- region that is aliasing and not a double free, because neither copy
- owns anything — the region does. *)
- List.iter
- (fun (vloc, (c : Tast.variant)) ->
- List.iter
- (fun (f : Tast.field) ->
- if Types.is_move_only f.Tast.fty
- && not (region_only env f.Tast.fty) then
- fail vloc
- "%s.%s's field %s is %s, which is move-only, and a \
- data type case that owns one makes the data type \
- move-only too — transitively, with recursive teardown. \
- That rule does not exist; hold the %s in a local and \
- pass it. A \
- container whose *elements* own storage is the one kind \
- admitted here, because it can only have been built \
- against a region allocator and a single (free-all) \
- takes the whole graph"
- n c.Tast.vname f.Tast.fname (Types.to_string f.Tast.fty)
- (Types.to_string f.Tast.fty))
- c.Tast.vfields)
- cases_with_loc;
+ (* A case's fields are a struct and may own storage, on the struct's
+ terms since the repeal: copies alias, and the free is the
+ program's to write. The region rule still applies on its own
+ ground — a [(Vec Value)] case field can only have been built
+ against a region, so the recursive dynamic value parses into an
+ arena and one [free-all] releases the graph, no teardown
+ anywhere. *)
List.iter
(fun (c : Tast.variant) ->
Hashtbl.replace env.cases (n ^ "." ^ c.Tast.vname) (n, c);
Hashtbl.replace env.cases c.Tast.vname (n, c))
cases
- (* ── The untagged union ──────────────────────────────────────────
- C's semantics, deliberately and in full: the members overlay one
- storage, the size is the largest of them, the alignment the
- strictest, and nothing anywhere records which member was written
- last.
-
- {2 What Flan says about reading a member that was not written}
-
- It reads the bytes that are there, through that member's type. Not
- undefined behaviour, and not a refusal either — a *definition*, and
- this is the one place in the checker that chooses bytes over safety
- on purpose, so it is worth saying why.
-
- Refusing it was the alternative, and it would have made the feature
- nothing: type punning *is* reading the member that was not written,
- and both uses this type exists for are that read. Binding a C header
- means holding the union the library holds and reading whichever
- member the library's own tag says is live — a tag Flan cannot see,
- because it is a field of the enclosing struct and the rule that
- relates them is prose in a manual. Overlaying an f32 on a u32 to look
- at its bits is the other use and is the same read. A checker that
- refused it would be refusing the type.
-
- So the promise is the one C's implementations actually make and
- C's standard does not: the layout is the target's, the bytes are the
- bytes, and a read is a reinterpretation of them. What is *not*
- promised is anything about bytes never written — a member larger
- than the one last stored reads its own size, and the tail is
- indeterminate exactly as a struct's padding is. That is the honest
- line, and it is narrower than it sounds: the ZII rule means a union
- starts all-bytes-zero unless [uninit] says otherwise, so the tail is
- zero rather than garbage in every program that did not ask for
- garbage.
-
- {2 uninit}
-
- Allowed, unlike on a data type. The refusal there is not about
- garbage — [uninit] is garbage everywhere and says so — it is that a
- data type's tag *steers*, and a tag no case names falls past every
- comparison in a [match] into a block LLVM is entitled to treat as
- unreachable. An untagged union steers nothing. Reading a member of
- one is already a reinterpretation of whatever bytes are there, so
- [uninit] makes those bytes arbitrary and changes nothing else, which
- is exactly what it means on an [i64].
-
- {2 Why bool is not a member}
-
- An [i1] loaded out of a byte that is neither 0 nor 1 is not a
- [false], it is a value the optimiser is entitled to assume cannot
- exist, and a union is the one type that can hand it one — write the
- [u8] member 2, read the [bool] member. Nothing about that is visible
- at the read, so it cannot be refused there. The alternative was to
- load a union's bool as an [i8] and compare it against zero in both
- backends, which is a correct answer and a real cost paid by every
- bool in the language to make one type safe. Refused at the
- declaration instead, where the message can name the replacement:
- [u8], compared explicitly. The check below is recursive, because a
- bool inside a struct member is the same byte.
-
- {2 Why no member may be move-only}
-
- Because nothing knows which member is live, so nothing can tear one
- down. That is not a limitation of today's compiler, which is what
- the struct and data type refusals above say about themselves; it is
- a property of the type, and it does not go away when recursive
- teardown lands. A [drop] of a union would have to free whichever
- member is live and there is no such fact — freeing the wrong one is
- a free of a pointer that was an f64 a moment ago. *)
- | Ast.Defunion (n, ms) ->
+ (* Nothing records which member of a union is live, so nothing — the
+ program included — can free the right one through the union itself.
+ Since the repeal that is a fact about the value and not a refusal:
+ a member may own storage, and freeing it is done through whatever
+ tag the program keeps beside the union, as C does. *) | Ast.Defunion (n, ms) ->
if ms = [] then
fail loc
"%s declares no members, so it has no size and nothing could be \
@@ -5846,20 +5384,6 @@ let collect env (decls : Ast.decl list) =
if List.length (List.sort_uniq compare names) <> List.length names then
fail loc "%s declares the same member twice" n;
let fields = List.map field ms in
- List.iter
- (fun (f : Tast.field) ->
- if Types.is_move_only f.Tast.fty then
- fail loc
- "%s's member %s is %s, which is move-only, and a union may \
- not own one: the members overlay one storage and nothing \
- records which was written, so nothing can free the right \
- one. Unlike a struct's, this is not waiting on recursive \
- teardown — there is no fact for teardown to read. Hold the \
- %s beside the union, or in a struct with a tag you check \
- yourself"
- n f.Tast.fname (Types.to_string f.Tast.fty)
- (Types.to_string f.Tast.fty))
- fields;
Hashtbl.replace env.unions n { Tast.sname = n; fields }
| Ast.Defn fn ->
(* A signature that introduces a type variable is a *pattern*, not a
@@ -6133,9 +5657,7 @@ let rec check_fn env (fn : Ast.fn) : Tast.fn =
whichever call site happened to instantiate it at a type that worked.
The holes in it are real and are the report's business: [println] is
- plan.org's one compiler-provided exception and this pass rejects it, and
- move-only-ness is not decidable abstractly at all — [Types.is_move_only
- (Var _)] is false, but the same variable at [(Vec i32)] is move-only. *)
+ plan.org's one compiler-provided exception and this pass rejects it. *)
and check_generic env (fn : Ast.fn) =
let vars, params, ret = Hashtbl.find env.gsigs fn.Ast.name in
let saved_lifted = env.lifted and saved_vars = env.tyvars
@@ -6160,14 +5682,12 @@ and check_generic env (fn : Ast.fn) =
checking a function. *)
let () = check_fn_ref := check_fn
-(* A global of move-only type is legal. Before the repeal what made it legal
- was a flow rule — reading one was always a borrow, so nothing could take
- or free it. That rule is gone with the rest of the flow analysis: a global
- Vec may now be handed to a function, bound, or freed, and keeping its
- process-long lifetime honest is the program's business, on the same terms
- as every other free.
+(* A container's only compile-time constant is the zeroed one: a Vec's or a
+ Map's real value exists at run time, behind an allocator. That is a fact
+ about initialisers and it survived both repeals untouched — nothing here
+ is about copying or moving.
- What this pass still decides is how such a global may be *started*, and the
+ What this pass decides is how such a global may be *started*, and the
answer is zeroed and nothing else. A zeroed Vec is a real empty Vec — null
block, zero length, zero capacity — so the ZII value is the value a program
would have written anyway, and filling it is an ordinary (set g (slurp
@@ -6190,15 +5710,21 @@ let () = check_fn_ref := check_fn
A global *Allocator* is not any of this — an allocator is a copyable opaque
handle — which is what makes the handler-owns-the-arena shape in
exhausted.flan expressible. *)
-let move_only_global_init loc n (ty : Types.t) (init : Ast.init) =
- if Types.is_move_only ty then
+let rec zero_only (t : Types.t) =
+ match t with
+ | Types.Vec _ | Types.Map _ -> true
+ | Types.Option e | Types.Array (_, e) -> zero_only e
+ | _ -> false
+
+let container_global_init loc n (ty : Types.t) (init : Ast.init) =
+ if zero_only ty then
match init with
| Ast.Zeroed -> ()
| _ ->
fail loc
- "the global %s is %s, which is move-only, and a move-only global \
- starts zeroed: a global's initialiser is a compile-time constant and \
- %s is not one. Write (defvar %s %s) with no initialiser — a zeroed \
+ "the global %s is %s, and such a global starts zeroed: a global's \
+ initialiser is a compile-time constant, %s is not one, and a \
+ container's only constant value is the empty one. Write (defvar %s %s) with no initialiser — a zeroed \
%s is an empty one, and that is a value, not a placeholder — then \
load it with (set %s ...) in the function that loads it, which runs \
once and whose result outlives every call to main"
@@ -6206,19 +5732,21 @@ let move_only_global_init loc n (ty : Types.t) (init : Ast.init) =
(match init with Ast.Uninit -> "uninit" | _ -> "this initialiser")
n (Types.to_string ty) (Types.to_string ty) n
-(* A move-only global has to be a [defvar]. A [defconst] is not an assignable
- place — [check_place] refuses one by name — so a constant Vec could only
- ever hold the zeroed value it was declared with, and nothing could ever put
- the file's bytes in it. Refused here, where the fix is one keyword, rather
- than at the (set ...) that discovers it three forms later. *)
-let no_move_only_defconst loc n (ty : Types.t) =
- if Types.is_move_only ty then
+(* A container global has to be a [defvar]. A [defconst] is not an assignable
+ place — [check_place] refuses one by name — and a container's only constant
+ is the zeroed one, so a constant Vec could only ever hold the empty value
+ it was declared with: nothing could ever put the file's bytes in it.
+ Refused here, where the fix is one keyword, rather than at the (set ...)
+ that discovers it three forms later. *)
+let no_container_defconst loc n (ty : Types.t) =
+ if zero_only ty then
fail loc
- "the global %s is %s, which is move-only, and a move-only global is a \
- defvar and not a defconst: a constant is not an assignable place, so \
- nothing could ever load this one — it would stay the empty %s it was \
- declared as. Write (defvar %s %s) and fill it in a function"
- n (Types.to_string ty) (Types.to_string ty) n (Types.to_string ty)
+ "the global %s is %s, and a %s global is a defvar and not a defconst: \
+ a constant is not an assignable place, so nothing could ever load \
+ this one — it would stay the empty %s it was declared as. Write \
+ (defvar %s %s) and fill it in a function"
+ n (Types.to_string ty) (Types.to_string ty) (Types.to_string ty)
+ n (Types.to_string ty)
(* A union member written into a global would have to be encoded into the blob
at link time, which is the byte-level encoder a data type case does not have
@@ -6249,7 +5777,7 @@ let check_global env (d : Ast.decl) : Tast.global option =
| Ast.Defvar (n, _, init) ->
let ty, _ = Hashtbl.find env.globals n in
no_zeroed_fn d.Ast.dloc (Printf.sprintf "the global %s" n) ty;
- move_only_global_init d.Ast.dloc n ty init;
+ container_global_init d.Ast.dloc n ty init;
let ginit =
match init with
| Ast.Zeroed -> { Tast.e = Tast.Zero ty; ty; loc = d.Ast.dloc }
@@ -6282,7 +5810,7 @@ let check_global env (d : Ast.decl) : Tast.global option =
| Ast.Defconst (n, _, v) ->
let ty, _ = Hashtbl.find env.globals n in
no_zeroed_fn d.Ast.dloc (Printf.sprintf "the global %s" n) ty;
- no_move_only_defconst d.Ast.dloc n ty;
+ no_container_defconst d.Ast.dloc n ty;
(* [collect] already folded the integer constants, because an array length
has to be known before any type resolves. Use that value here rather
than the expression it came from: a global's initialiser has to be a
diff --git a/lib/dev.ml b/lib/dev.ml
index 0d3ec30..7088696 100644
--- a/lib/dev.ml
+++ b/lib/dev.ml
@@ -1462,11 +1462,10 @@ let reg_at t ~addr : (reg_entry option, string) result =
anywhere, so nothing can fall behind [Types.to_string].
And it is allowed to fail, which matters more than it looks. Not every
- recorded name is a type: [flan_rt.c] notes a pool's slot headers as
- "pool slots", because after a free-all an address landing in them must not
- come back as an element. That string is not Flan source and must not
- become one — so a name that does not resolve is refused with the name
- quoted, and never defaulted to bytes. *)
+ recorded name need be a type this session can spell — a note's name is
+ whatever string the noting site chose, not Flan source — so a name that
+ does not resolve is refused with the name quoted, and never defaulted to
+ bytes. *)
let type_of_spelling t spelling : (Types.t, string) result =
let refuse why =
Error
diff --git a/lib/emit.ml b/lib/emit.ml
index 94cfa21..083da71 100644
--- a/lib/emit.ml
+++ b/lib/emit.ml
@@ -124,15 +124,6 @@ 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. *)
@@ -296,9 +287,7 @@ let rec lay m (t : Types.t) : int * int =
| Types.Ptr _ -> 8, 8
| Types.Alloc -> 8, 8
| Types.Fn _ -> 8, 8
- | Types.Vec _ | Types.Map _ -> 48, 8
- | Types.Pool _ -> 64, 8
- | Types.Handle _ -> 8, 8
+ | Types.Vec _ | Types.Map _ -> 40, 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
@@ -523,15 +512,15 @@ let rec dty m d (t : Types.t) : int =
| Types.Alloc ->
dnode d
"!DIDerivedType(tag: DW_TAG_pointer_type, name: \"Allocator\", baseType: null, size: 64)"
- (* Shown as what it is. The two dev words are in the layout and so they
- are here too: a debugger that showed four fields of a six-field struct
- would put the reader's offsets out by two. *)
+ (* Shown as what it is. The epoch word is in the layout and so it is
+ here too: a debugger that showed four fields of a five-field struct
+ would put the reader's offsets out by one. *)
| Types.Vec e ->
composite (Types.to_string t)
[ ("ptr", Types.Ptr e); ("len", Types.Int Types.I64);
("cap", Types.Int Types.I64); ("allocator", Types.Alloc);
- ("gen", Types.Int Types.I64); ("epoch", Types.Int Types.I64) ]
- (* Six fields again, and shown as six for the same reason: a debugger
+ ("epoch", Types.Int Types.I64) ]
+ (* Five fields again, and shown as five for the same reason: a debugger
that showed fewer would put the reader's offsets out. [log2cap] is
shown rather than a capacity because that is what is stored — the
capacity is 1 << it, and a debugger that invented the shift would be
@@ -540,22 +529,8 @@ let rec dty m d (t : Types.t) : int =
composite (Types.to_string t)
[ ("data", Types.Ptr (Types.Int Types.U8));
("len", Types.Int Types.I64); ("log2cap", Types.Int Types.I64);
- ("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"
+ |> fun n -> ignore k; ignore v; n
(* 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
@@ -2043,11 +2018,6 @@ 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 ] ->
@@ -2254,7 +2224,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 _ | Types.Pool _ -> [ "ptr " ^ addr f a ]
+ | Types.Vec _ | Types.Map _ -> [ "ptr " ^ addr f a ]
| t -> [ ll t ^ " " ^ value f a ])
args)
in
@@ -2325,11 +2295,6 @@ and cast f ~guard (x : Tast.expr) target =
let concrete (t : Types.t) =
match t with
| Types.Enum _ -> Types.Int Types.I32
- (* A handle already *is* an i64 — see [ll] — so a cast involving one
- changes the reading and never the bits. [pool-handle] tests one against
- the never-valid zero, and the renderer splits one into its index and
- its generation. Unsigned, because both halves are. *)
- | Types.Handle _ -> Types.Int Types.U64
| t -> t
in
let src = concrete x.Tast.ty and target = concrete target in
@@ -2360,7 +2325,7 @@ and cast f ~guard (x : Tast.expr) target =
it, both sides being [ptr]. *)
| Types.Ptr _, Types.Ptr _ -> "bitcast"
(* Also not written in the surface language. [resolve] needs it: the
- pool answers a pointer or NULL and the Option is built in the
+ runtime answers a pointer or NULL and the Option is built in the
checker, so the null test is one integer compare on the address. *)
| Types.Ptr _, Types.Int Types.I64 -> "ptrtoint"
| _ -> failwith "unsupported cast"
@@ -2702,15 +2667,11 @@ let header = {|; Generated by flan. The layout is C's: no object headers anywher
%slice = type { ptr, i64 }
; (Vec T), spec-memory.md. The element type is nowhere in it: the runtime is
; type-erased and every operation is handed size and align at its call site.
-%vec = type { ptr, i64, i64, ptr, i64, i64 }
+%vec = type { ptr, i64, i64, ptr, i64 }
; (Map K V), spec-memory.md — Odin's open-addressed Robin Hood map. Neither key
; 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 }
+%map = type { ptr, 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 }
@@ -2775,7 +2736,6 @@ declare void @flan_arena_destroy(ptr)
declare void @flan_alloc_free_all(ptr, ptr, i64)
declare void @flan_vec_region_only(ptr, ptr, i64)
declare void @flan_map_region_only(ptr, ptr, i64)
-declare void @flan_pool_region_only(ptr, ptr, i64)
declare i8 @flan_alloc_can_free(ptr)
declare i8 @flan_alloc_can_free_all(ptr)
declare i64 @flan_alloc_epoch(ptr)
@@ -2788,7 +2748,6 @@ declare i64 @flan_alloc_budget(ptr)
declare void @flan_alloc_set_budget(ptr, i64)
declare void @flan_dev_reg_enable()
declare void @flan_dev_reg_note_vec(ptr, i64, ptr, i64)
-declare void @flan_dev_reg_note_pool(ptr, i64, ptr, i64)
declare void @flan_dev_reg_note_map(ptr, i64, i64, ptr, i64)
declare i8 @flan_vec_init(ptr, ptr, i64, i64, i64, ptr, i64)
declare i8 @flan_vec_reserve(ptr, i64, i64, i64, ptr, i64)
@@ -2801,18 +2760,6 @@ declare i64 @flan_vec_len(ptr, ptr, i64)
declare ptr @flan_vec_at(ptr, i32, i64, ptr, i64, ptr)
declare void @flan_vec_as_slice(ptr, ptr, i32, i32, i64, ptr, i64, ptr)
declare void @flan_vec_free(ptr, i64, i64, ptr, i64)
-; (Pool T) and (Handle T). A handle crosses as the i64 it is; the pool, like
-; every other owning container, crosses as its address. [resolve] answers a
-; pointer or null and [pool-handle] answers a packed handle or the never-valid
-; zero, so neither needs a second return value.
-declare i8 @flan_pool_init(ptr, ptr, i64, i64, ptr, i64)
-declare i8 @flan_pool_insert(ptr, ptr, ptr, i64, i64, ptr, i64)
-declare ptr @flan_pool_resolve(ptr, i64, i64, ptr, i64)
-declare i8 @flan_pool_release(ptr, i64, ptr, i64)
-declare i64 @flan_pool_len(ptr, ptr, i64)
-declare i64 @flan_pool_live(ptr, ptr, i64)
-declare i64 @flan_pool_handle(ptr, i32, ptr, i64)
-declare void @flan_pool_free(ptr, i64, i64, ptr, i64)
; (Map K V). The two ptr arguments before the location on put/get/clone are the
; hash and equality pair, which the checker emits per key type and passes here
; the way Odin hangs them off Map_Info.
diff --git a/lib/js.ml b/lib/js.ml
index aa661e4..97a7b4f 100644
--- a/lib/js.ml
+++ b/lib/js.ml
@@ -116,7 +116,7 @@
{1 What is refused, and why each}
Pointers and [deref], [free], allocators and [with-allocator], [Map],
- [Pool] and [(Handle T)], [declare-c] and the FFI, [embed], conditions and
+ [declare-c] and the FFI, [embed], conditions and
restarts ([signal], [handler-bind], [restart-case], [invoke-restart]), and
the type-erased container runtime's own entry points. The first group has
no counterpart in a garbage-collected object graph; the FFI and [embed]
@@ -235,14 +235,6 @@ let rec refuse_ty loc (t : Types.t) =
"(Map K V) is not in the JS dialect yet — Odin's open-addressed map is a \
type-erased runtime over raw bytes and the JS answer is a Map keyed by \
a structural key, which is its own lane"
- | Types.Pool _ ->
- at loc
- "(Pool T) is not in the JS dialect — a pool hands out slot indices into \
- storage it owns, which is the memory model this dialect leaves behind"
- | Types.Handle _ ->
- at loc
- "(Handle T) is not in the JS dialect — a handle is an index into a Pool, \
- and there is no Pool here"
| Types.Var n ->
at loc "a type variable (%s) reached the backend, which cannot happen" n
diff --git a/lib/prelude.ml b/lib/prelude.ml
index 78482b7..4eb5d96 100644
--- a/lib/prelude.ml
+++ b/lib/prelude.ml
@@ -228,29 +228,16 @@ let source = {flan|
;; variable supports only what it is declared to support — an unconstrained
;; one is refused at the *definition*, not at some later call site — and
;; [ordered?] is the predicate that admits [<], [<=], [>], [>=], [min] and
-;; [max]. It admits [=] and [copyable?] too: every type the language orders is
-;; a number or an enum, so it is equatable and it is not move-only.
+;; [max]. It admits [=] too: every type the language orders is a number or an
+;; enum, so it is equatable.
;;
-;; [{:where (copyable? $t)}] is the opt-out from the other default. A type
-;; variable is **move-only** until it says otherwise, because move is the
-;; stricter rule and assuming it can only refuse a valid program rather than
-;; admit a broken one: [reduce]'s accumulator is read into [f] and then
-;; assigned again, which is correct at [i32] and a double move at [(Vec i32)],
-;; and the checker cannot tell which until it substitutes.
-;;
-;; **Two of these ten are forced and the rest are convention, and the
-;; difference is worth knowing.** [filter] and [reduce] do not check without
-;; [copyable?]: the first returns a [(Vec $t)], and a Vec of an owning element
-;; is refused, and the second holds its accumulator in a local and reads it
-;; twice. [swap!], [reverse!], [map!] and [sort-by!] check *without* it,
-;; because the move analysis tracks locals and parameters and does not track a
-;; read out of a slice — so [(let [t (at s i)] ... (set (at s j) t))] is not
-;; seen as a move even when the element owns storage. They declare it anyway,
-;; and should: at [[(Vec i32)]] those bodies would duplicate a header. It is
-;; the one place move-by-default is not conservative, and until element-level
-;; moves are tracked, a [copyable?] on a body that moves elements between
-;; slots is a convention the reader has to keep rather than a fact the checker
-;; enforces.
+;; There is no [copyable?] any more. Since the repeal every value copies —
+;; a container copies as its header, the copies alias one buffer, and what
+;; the copies then do is the program's business, as it is in Odin. A body
+;; that reads an element into a local and writes it into another slot is
+;; duplicating a header when the element owns storage, and nothing here
+;; says otherwise any more: that sentence moved from a predicate into this
+;; comment, which is where Odin keeps it too.
;;
;; **What did not collapse, and why it should not.** [sum-i32] and [sum-f32]
;; widen their element into [i64] and [f64]; "the wider type $t accumulates
@@ -263,13 +250,11 @@ let source = {flan|
;; keeping attached to something.
(defn swap! [s [$t] i i32 j i32] ()
- {:where (copyable? $t)}
(let [t (at s i)]
(set (at s i) (at s j))
(set (at s j) t)))
(defn reverse! [s [$t]] ()
- {:where (copyable? $t)}
(let [i 0
j (- (len s) 1)]
(while (< i j)
@@ -317,7 +302,6 @@ let source = {flan|
;; predicates existed, and it stays because passing a comparison is a real
;; thing to want and not only a workaround.
(defn sort-by! [s [$t] before? (Fn [$t $t] bool)] ()
- {:where (copyable? $t)}
(let [i 1]
(while (< i (len s))
(let [j i]
@@ -369,7 +353,6 @@ let source = {flan|
;; here: it is two type variables and a second signature, and nothing has
;; wanted it.
(defn map! [s [$t] f (Fn [$t] $t)] ()
- {:where (copyable? $t)}
(dotimes [i (len s)]
(set (at s i) (f (at s i)))))
@@ -377,7 +360,6 @@ let source = {flan|
;; in. The accumulator comes first in the step, which is the order that reads
;; as (f acc x) and the order Odin's slice.reduce uses.
(defn reduce [s [$t] init $t f (Fn [$t $t] $t)] $t
- {:where (copyable? $t)}
(let [acc init]
(dotimes [i (len s)]
(set acc (f acc (at s i))))
@@ -391,7 +373,6 @@ let source = {flan|
;; runtime needed no change at all, because SizeOf and AlignOf are computed at
;; the instantiation site, where the element type is concrete.
(defn filter [s [$t] keep? (Fn [$t] bool)] (Vec $t)
- {:where (copyable? $t)}
(let [v (vec-new t)]
(dotimes [i (len s)]
(when (keep? (at s i))
diff --git a/lib/render.ml b/lib/render.ml
index 384231e..ae5a47d 100644
--- a/lib/render.ml
+++ b/lib/render.ml
@@ -187,33 +187,6 @@ let rec render c depth (e : Tast.expr) : Tast.expr list =
does not own, and the walk is what [as-slice] is for: (print (as-slice
v)) prints the elements and says at the call site that it borrowed. *)
| Types.Vec _ -> [ lit " What makes that liveable is a (vec-new t), (map-new t i32),
(min (max x lo) hi))
(defn first-or [s [$t] d $t] $t ; the variable inside a slice type
- {:where (copyable? $t)}
+ {:where (equal? $t)}
(if (= (len s) 0) d (at s 0)))
(defn one-of [x $t] (Vec $t) ; bare t is the type-name argument
- {:where (copyable? $t)}
(let [v (vec-new t)]
(push v x)
v))
@@ -927,8 +926,8 @@ $t)} at the head of the body, or take the operation as a parameter — a
where clause, written as a Clojure-style
map at the head of the body — {:where (ordered? $t)}, or a vector when
-there is more than one: {:where [(copyable? $t) (copyable? $u)]}. There
-are five predicates, and each gates builtins the compiler already has:{:where [(ordered? $t) (hashable? $u)]}. There
+are four predicates, and each gates builtins the compiler already has:
ordered? | < <= > >= min max |
equal? | = and != |
hashable? | the variable as a Map key — (map-new t V), get, put, has-key? |
copyable? | reading the value more than once; Pool and Vec element positions |
They entail each other in one direction, so one clause usually does:
-numeric? gives ordered?, ordered? gives
-equal?, and any of the four gives copyable?. A
-sort! that compares its elements and reads them twice declares
+numeric? gives ordered?, and ordered? gives
+equal?. A sort! that compares its elements declares
ordered? and nothing else.
A type variable is move-only by default, and
-copyable? is the opt-out. Types.is_move_only of a variable is
-not decidable abstractly — the same variable is i32 at one instantiation
-and (Vec i32) at the next — so the checker assumes the stricter rule,
-which can only refuse a program that would have been fine and never admit one that
-double-frees. It is Rust's T: Copy, with the difference that the compiler
-answers the question rather than a user implementing a trait. So
-(defn twice [x $t] $t (+ x x)) does not merely want
-numeric?; reading x a second time is a use after move:
x was moved at twice.flan:1:26 and cannot be used again — t is move-only, so
-binding, passing or returning one transfers ownership and the source binding is
-dead afterwards (spec-memory.md). That rule is what makes a double free
-unrepresentable; (clone x) if you wanted a second one
+Every value copies. There used to be a fifth predicate,
+copyable?, gating a second read of a move-only variable; the move
+concept was repealed on 2026-09-18 — a container copies as its header, the
+copies alias one buffer, and which free runs is the program's business, as it
+is in Odin — so the predicate went with it.
Each instantiation then checks the concrete type against what the signature declared, and refuses the call site when it does not answer: