flan/test/programs/arena-edn.flan
Joseph Ferano d57eeb3265 The refusal was about teardown, and a region has none
A (Vec Value) where a Value may itself hold a (Vec Value) — the recursive
dynamic value an EDN reader has to answer with when nobody hands it a target
struct type — was refused five different ways, and every one of the five gave
the same reason: the container runtime is type-erased, so it copies and
releases slots bytewise and cannot reach inside a slot. A free would release
the slots and leave every block they point at stranded.

That reason is about teardown, and it does not hold for a region. free-all
never releases an individual slot; it takes the whole arena, and every block
the elements own is in it, because they came out of it. The refusals were
over-broad, and what they were guarding was never ownership — ownership
tracking is untouched here, moves are still moves, and Types.is_move_only is
the same function it was.

So the question moved rather than disappeared. It could not stay at the type,
because can-free is a capability on an allocator value and with-allocator
rebinds a dynamic variable: which tier a (vec-new) will meet is not a property
of the place its type is written. What is decided at compile time is only
whether to ask, which is a property of the element type; the answer is a
run-time branch on the allocator, one per container and never per element,
because the alternative is a walk at release and a walk at release is the
registry of destructors the frame tier's reset exists to not have. It is
emitted at every growth and not only at the construction, because ZII means a
container can exist without ever passing through (vec-new) — a case field left
out of a literal, a global that starts zeroed — and those adopt the context on
their first push.

free on such a container is refused rather than made quietly shallow. It cannot
recurse, which is the whole premise, and releasing the outer block alone would
be "I freed it" written over a program that stranded everything inside; this
runtime refuses that collapse everywhere else. The message names free-all,
which is reachable by construction. clone stays refused for a reason the region
does not dissolve, and the old message had bundled the two failures under one
sentence: what disqualifies clone is not that it copies a header — so do at and
get, and they are fine, because they promise nothing — it is that clone
allocates a new block and promises independence, and a bytewise copy hands back
elements still pointing into the original's region.

A struct or union field is admitted only where the field's container holds
owning elements, because that container can only have been built against a
region. A field holding a plain (Vec u8) stays refused: nothing would force
that one into a region, and two copies of the aggregate would be two headers
over one heap block. vec-in-struct.flan still pins that.

The epoch already covered use after free-all, including the case this makes
reachable — an inner header copied out of an arena-held element into a local
still traps, because an Allocator is a pointer and a copied-by-value one would
carry its own epoch.

arena-value.flan builds the value by hand; arena-edn.flan reads a real document
through the tokenizer, and its reader takes no allocator and names none,
because spec-memory.md already puts the allocator in the calling convention.
arena-region.flan is the branch itself: run 0 is the (Vec (Vec i32)) control
that must not trap, and runs 1 and 2 are the two ways this dies.
2026-09-17 20:37:08 +07:00

176 lines
7.0 KiB
Plaintext

;;;; An EDN document read into a dynamic value, against an arena.
;;;;
;;;; This is the other half of programs/edn.flan. That one reads a document
;;;; whose shape is known into a struct, by hand, which is what the compiler's
;;;; (read-edn Enemy bytes) will emit. This one is what a reader handed *no*
;;;; target type has to answer with: a data type naming itself through a
;;;; (Vec Value) and a (Map string Value), holding whatever was in the file.
;;;;
;;;; ── The allocator story, which is the point of the program ───────────
;;;;
;;;; read-value below takes no allocator and names none. It does not need to:
;;;; spec-memory.md puts the allocator in the calling convention, so every
;;;; (vec-new) and (map-new) inside it takes the *context*, and the caller
;;;; chooses the tier with (with-allocator ...) around the call. An explicit
;;;; allocator at a construction site overrides that, which is how a reader
;;;; would take one as a parameter if it wanted to — but the existing idiom
;;;; already does the job, so there is no new machinery here and none needed.
;;;;
;;;; The tier has to be a region and not the heap, and that is enforced rather
;;;; than documented: a (Vec Value) whose elements own storage traps at its
;;;; construction against any allocator that can free one block. See
;;;; programs/arena-region.flan.
;;;;
;;;; ── What the region buys, said plainly ───────────────────────────────
;;;;
;;;; The document below is four levels deep and every level allocates. There is
;;;; no teardown anywhere in this file: no drop, no destructor, no recursive
;;;; free, not even a (free) call. One (free-all frame) at the bottom of main
;;;; releases every Vec block, every Map block and every entry in them, because
;;;; they all came out of the same region.
;;;;
;;;; Odin's core:encoding/json ships a hand-written recursive destroy_value in
;;;; the *library* for the heap case, and names parsing against temp_allocator
;;;; and calling free_all as the idiomatic alternative. This is that
;;;; alternative, and it needs nothing from the language that was not already
;;;; there.
;;;;
;;;; ── One lifetime that is not the region's ────────────────────────────
;;;;
;;;; A Token's text is a slice INTO the source buffer, and (string ...) over it
;;;; is a view and not a copy — so every Text, every Key and every map key here
;;;; points at `src`, not at the arena. The document outlives free-all in that
;;;; one respect and dies with the buffer instead. That is edn.flan's stated
;;;; contract and not a new one; it is repeated because a reader looking at a
;;;; value that survived a free-all would otherwise think the region had
;;;; leaked.
(import edn "vendor:edn")
(defvar frame Allocator)
(defdata Value
[(Nil [])
(Bool [b bool])
(Int [n i64])
(Float [x f64])
(Text [s string])
(Key [s string])
(List [items (Vec Value)])
(Table [entries (Map string Value)])])
;; One token in hand, and the cursor for whatever the token opens. A vector and
;; a map recurse; everything else is a leaf.
(defn read-value [c (Ptr edn/Cursor) t edn/Token] Value
(cond
(= (.kind t) edn/tok-bool)
(Value.Bool {.b (match (edn/bool-of t) (Some v) v None false)})
(= (.kind t) edn/tok-int)
(Value.Int {.n (match (edn/int-of t) (Some v) v None (i64 0))})
(= (.kind t) edn/tok-float)
(Value.Float {.x (match (edn/float-of t) (Some v) v None 0.0)})
(= (.kind t) edn/tok-string) (Value.Text {.s (string (.text t))})
(= (.kind t) edn/tok-keyword) (Value.Key {.s (string (.text t))})
(= (.kind t) edn/tok-symbol) (Value.Key {.s (string (.text t))})
(= (.kind t) edn/tok-vec-open)
(let [items (vec-new Value)
u (edn/next c)]
(while (and (edn/ok? c)
(!= (.kind u) edn/tok-vec-close)
(!= (.kind u) edn/tok-eof))
(push items (read-value c u))
(set u (edn/next c)))
(Value.List {.items items}))
;; A map's key is whatever token is there — a keyword here, and its text
;; slice is the key. The value is read by the same recursion, so a map of
;; vectors of maps is one call per level and no special case.
(= (.kind t) edn/tok-map-open)
(let [entries (map-new string Value)
k (edn/next c)]
(while (and (edn/ok? c)
(!= (.kind k) edn/tok-map-close)
(!= (.kind k) edn/tok-eof))
(let [v (edn/next c)]
(put entries (string (.text k)) (read-value c v)))
(set k (edn/next c)))
(Value.Table {.entries entries}))
:else Value.Nil))
;; Walking it back. (at v i) addresses an element in place and (get m k)
;; answers a copy of the value's bytes; in a region the two are the same thing,
;; an alias into storage nobody individually owns, so a document is read back
;; with the operations that were already there.
(defn count-leaves [v Value] i32
(match v
(List items)
(let [n 0]
(dotimes [i (len items)]
(set n (+ n (count-leaves (at items i)))))
n)
;; map-next! fills an out-parameter with a copy of the value's bytes,
;; which for a Value holding a container is a second header over the same
;; block. In a region that is an alias and not a second owner — nothing
;; here owns anything, the arena does — so walking a map is the ordinary
;; iteration and needs no accessor of its own.
(Table entries)
(let [n 0
cur (i64 0)
k ""
v Value.Nil]
(while (map-next! entries (addr cur) (addr k) (addr v))
(set n (+ n (count-leaves v))))
n)
_ 1))
(defn sum-ints [v Value] i64
(match v
(Int n) n
(List items)
(let [t (i64 0)]
(dotimes [i (len items)]
(set t (+ t (sum-ints (at items i)))))
t)
(Table entries)
(match (get entries "xs") (Some x) (sum-ints x) None (i64 0))
_ (i64 0)))
(defn describe [v Value] string
(match v
Nil "nil" (Bool _b) "bool" (Int _n) "int" (Float _x) "float"
(Text _s) "string" (Key _s) "keyword" (List _i) "vector" (Table _e) "map"))
(defn read-doc [src string] Value
(let [b (bytes src)
c (edn/cursor b)
t (edn/next (addr c))]
(read-value (addr c) t)))
(defconst doc
"{:name \"level-1\"
:xs [1 2 3]
:spawns [{:kind :grunt :at [10 20]}
{:kind :boss :at [30 40]}]
:gravity 9.8
:looping true}")
(defn main [] i32
(set frame (arena-new 65536))
(with-allocator frame
(let [v (read-doc doc)]
(println (describe v))
(println (count-leaves v))
(println (sum-ints v))
(match v
(Table entries)
(match (get entries "name")
(Some n) (println (describe n))
None (println "missing"))
_ (println "not a map"))))
;; The whole document, in one operation and with no per-element teardown.
(free-all frame)
(arena-destroy frame)
0)