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.
110 lines
5.0 KiB
Plaintext
110 lines
5.0 KiB
Plaintext
;;;; spec-memory.md's arena rule, which is the run-time half of what replaced
|
|
;;;; the three type-level refusals a container of owning elements used to meet.
|
|
;;;;
|
|
;;;; "Constructing a container whose element type owns storage, against an
|
|
;;;; allocator that lacks can-free, is refused at the point of construction:
|
|
;;;; one branch per container, not per element." One branch and not a walk,
|
|
;;;; because the alternative is something that inspects the graph at release,
|
|
;;;; and that is the registry of destructors the frame tier's reset exists to
|
|
;;;; not have.
|
|
;;;;
|
|
;;;; It is a run-time branch and not a compile-time refusal because there is
|
|
;;;; nothing static to refuse against: with-allocator rebinds a dynamic
|
|
;;;; variable, so which tier a (vec-new) meets is not knowable where it is
|
|
;;;; written. The compiler decides only whether to *ask*.
|
|
;;;;
|
|
;;;; Argument 0 is everything that must work, argument 1 and argument 2 are the
|
|
;;;; two ways this dies. Each death is the whole test of its case, so they are
|
|
;;;; separate runs rather than one program that could pass by dying early.
|
|
|
|
(defvar frame Allocator)
|
|
|
|
(defalias Row (Vec i32))
|
|
|
|
(defdata Value [Nil (Int [n i64]) (List [items (Vec Value)])])
|
|
|
|
(defn main [args [string]] i32
|
|
(set frame (arena-new 4096))
|
|
(let [which (if (> (len args) 1) (i32 (bytes->i64 (bytes (at args 1)))) 0)]
|
|
(cond
|
|
(= which 1)
|
|
;; The refusal. The context here is the heap, which can free one
|
|
;; block, and a (Vec Value) against it is a free that would release
|
|
;; the slots and strand every inner Vec — so the construction dies
|
|
;; rather than the free three hundred lines later.
|
|
(let [bad (vec-new Value)]
|
|
(println (len bad)))
|
|
|
|
(= which 2)
|
|
;; Use after free-all, which is a different mechanism and worth
|
|
;; pinning separately: the allocator's epoch moves on every free-all
|
|
;; and every container records the epoch it was made at. The header
|
|
;; below was copied *out* of the arena container into a local before
|
|
;; the release, which is the case the check has to cover and the
|
|
;; reason spec-memory.md makes an Allocator a pointer rather than a
|
|
;; copied value — a copied allocator would carry its own epoch and
|
|
;; the copy would never notice.
|
|
(let [outer (vec-new Value frame)]
|
|
(let [inner (vec-new Value frame)]
|
|
(push inner (Value.Int {.n (i64 7)}))
|
|
(push outer (Value.List {.items inner})))
|
|
(match (at outer 0)
|
|
(List items)
|
|
(do (println (len items))
|
|
(free-all frame)
|
|
(println (len items)))
|
|
_ (println 0)))
|
|
|
|
(= which 3)
|
|
;; ZII, which is the hole a guard only at the construction would have
|
|
;; left. The items field is omitted from the literal, so it is a zeroed
|
|
;; Vec with no allocator at all — it never went near (vec-new) — and
|
|
;; the first push is what adopts the context. So the branch is emitted
|
|
;; at every growth too, and there it asks the container, which answers
|
|
;; from the allocator it will adopt when it has none of its own.
|
|
(let [v (Value.List {})]
|
|
(match v
|
|
(List items)
|
|
(do (push items (Value.Int {.n (i64 1)}))
|
|
(println (len items)))
|
|
_ (println 0)))
|
|
|
|
:else
|
|
(do
|
|
;; The control, and it is the case the frame tier exists for: a
|
|
;; (Vec (Vec i32)) owns storage at two levels and is perfectly happy
|
|
;; in a region, because free-all releases every block the region
|
|
;; handed out and the inner ones are among them. The rule asks about
|
|
;; the *allocator*, never "does this element own anything", so this
|
|
;; must be built without complaint.
|
|
(with-allocator frame
|
|
(let [rows (vec-new Row)]
|
|
(let [row (vec-new i32)]
|
|
(push row 1)
|
|
(push row 2)
|
|
(push rows row))
|
|
(println (len rows))
|
|
(println (len (at rows 0)))))
|
|
(free-all frame)
|
|
;; And the same container against the heap dies — asserted from the
|
|
;; other side in run 1 above; here the point is only that the region
|
|
;; run above got no complaint.
|
|
(with-allocator frame
|
|
(let [vs (vec-new Value)]
|
|
(push vs (Value.Int {.n (i64 41)}))
|
|
(println (len vs))))
|
|
(free-all frame)
|
|
;; And the zeroed field of run 3, this time in the region: the growth
|
|
;; guard has to pass here as surely as it has to fail there, or every
|
|
;; ZII container in an arena would be unusable.
|
|
(with-allocator frame
|
|
(let [v (Value.List {})]
|
|
(match v
|
|
(List items)
|
|
(do (push items (Value.Int {.n (i64 1)}))
|
|
(println (len items)))
|
|
_ (println 0))))
|
|
(free-all frame))))
|
|
(arena-destroy frame)
|
|
0)
|