# destructors on heap values (draft) status: implemented on work/dtor (spec 3.11.6, 4.10.3, 4.10.4, 6.15); see "what is implemented" at the end. revised to acorn's rulings on the nine questions of the first draft. it parks `return defer x;` and covers that use case with owned results and drops on values instead. section numbers refer to `docs/SPEC.md` at `d728a06`. ## the problem an owned heap value can stand for something outside the heap: a file descriptor, a socket, a lock, a native buffer, a registration in a table. today the analyzer frees the memory at scope end (6.5.1, `rt.recycle`), and the collector reclaims what nobody owns (6.9.3). neither one closes the outside resource. the caller has to remember a `defer close(x);`, and a function that opens something can't hand the cleanup up to its caller. a drop fills that gap: the cleanup is attached to the value, so it follows the value wherever ownership goes. ## 1. heap(v): boxing any value `heap(v)` is a new builtin. it copies any value into a fresh owned heap slot sized to it and returns a pointer to that slot. ```zc int*.u a = heap(10); // one word char*.u s = heap("hi"); // string block, len 2 foo*.u f = heap((struct foo) { .bar = 4, .baz = p }); // struct block ``` - **size.** the slot is sized to the value: one word for a scalar (`int`, `word`, pointer, fn), `len + 1` bytes rounded up for a string (the bytes are copied, `len(s)` works on the result), and `sizeof(T)` for a struct literal or struct value. the size is fixed at the call; "dynamic" means it follows the argument, not that the block grows. - **ownership.** the result is fresh with tag `.u` (6.1), like `rt.new`. bind it to an `m` local and it is freed at block end; return it and the caller owns it. pointer fields inside a boxed struct are copied as plain words: `heap` doesn't take ownership of what they point to. - **type.** `heap(e)` where `e : T` has type `T*.u`. a string literal gives `char*.u`. the block carries `T`'s type record, so typed frees, the collector walk and `drop` (below) work on it. - `heap` of an already-owned pointer boxes the pointer word, not the object it points to. it is not a move. ## 2. drop: attaching cleanup to a value any heap value can have a drop, not only structs: `heap` slots, `rt.new` blocks (typed or raw), strings, arrays. a drop is a `fn()` with no parameters and no result. inside it, `this` is the value (borrowed, `.m`). three forms, all setting the same thing: ```zc let g = drop(heap(0), fn() { unlock(); }); // call form, returns the value let x = new(int).drop(fn() { std.put_s("one two buckle my shoe"); }); // method form, returns the value let y = new(int); y.drop = fn() { std.put_n(*this); }; // statement form ``` - `drop(v, f)` and `v.drop(f)` return `v` itself, with `v`'s type and tag, so they chain and can be passed along. `v.drop = f;` is a statement and has no value. - **set once.** a value's drop can be set only once (error: x: drop is already set). - **set early.** the drop must be set before the value is first read, reassigned or passed anywhere (error: x: drop set after the value was used). this is checked statically by the analyzer on the owning local; in practice the drop is set right at the allocation or on the next statement. this rule keeps every reader of the value seeing the same cleanup and means no copy of the pointer can escape without it. - **type default.** a struct body may still declare a `drop` method. it is the default drop for every value of that type: ```zc struct file { int fd; void drop() { os.close(this.fd); } } ``` a value of such a type may still set its own drop. then both run: the value's drop first, then the type's. set-once applies to the per-value drop only. - `this` can be read and passed to borrowing calls, but not freed. storing it somewhere reachable revives the value (section 6). - a drop is never called by hand. an early close is `rt.free(x)`, which runs it. example (this is the spec example): ```zc std.put_n(*heap(15).drop(fn() { std.put_s("i'm free as a bird! "); std.put_n(*this); })); ``` prints `15`, then `i'm free as a bird! 15`: the temporary is dropped at the end of the statement, after `put_n` returns. note the `*`: `drop` returns the pointer, so without it `put_n` would print an address. ## 3. when it runs a drop runs exactly once, when the value dies: - an `m` local reaches the end of its block or an early exit (`return`, `break`, `continue`), in the same reverse declaration order as today's inserted frees (6.5.1). - an explicit `rt.free(x)` or `rt.recycle(x)`. - **temporaries.** a value nothing holds (a temporary in a hidden frame slot, 6.5.4) dies at the end of its full statement. so ```zc heap(0).drop = fn() { unlock(); }; // unlocks right away: nothing holds it ``` unlocks immediately. the scope guard idiom binds it: ```zc let guard = drop(heap(0), fn() { unlock(); }); // unlocks at block end ``` the analyzer gives a hint when a value with a drop is never bound (hint: value with drop is never bound; it drops at the end of this statement). - a container is freed and its children go with it (6.5.3, 6.8.6). a `T[?]` of droppable elements drops them in index order. `pop` hands the element out fresh, so the container no longer drops it. - the owner is reassigned (section 4). - the collector reclaims an unowned value (section 6). **order: parent before children.** a child owned by a field (`own`, `w[-2] == p`) is dropped after its parent's drop returns, in field order. this matches `release`/`recycle`, which already walk the children after the parent is marked going, so the parent's drop still sees live children. ## 4. ownership and moves the analyzer already proves that an owner is freed once: giving a value away (`.d` parameter, store into a global or container, `return`) makes the local dead (6.9.2), and the dead local isn't freed. drops ride on that same path, so they inherit the guarantee. no runtime flag is needed for checked code. - **no moving children out.** moving a child out of a value that has a drop (`word c = p.child;` used as an owner, or giving `p.child` away) is an error (error: p.child: can't move out of a value with drop). the parent's drop may depend on that child, and moving it out would leave the drop with a dangling or missing field. there is no `take(p.field)`. borrowing a child (`.m`) is fine. - **conditional moves.** 6.10.1 already handles a value given away on one path: the compiler frees it on the other paths on their way into the join. the drop runs at that inserted free. a loop can't give a value away on a path that comes back around (6.10.2), so there is no "maybe moved" state at runtime. - **reassigning an owner** (changes 6.8.5). overwriting a still-owning variable is no longer an error: the old value is freed, running its drop, before the new value is stored. the analyzer gives a hint (hint: x: reassigning frees the old value). `x = x.next` style self-reference is evaluated first: the right side is computed, then the old value is freed, then stored. - **aliases and borrows** never drop. only the owner's death does. ## 5. returning an owned value a function that returns a `.u` result gives the caller the value, and with it the drop: ```zc file*.u open_log() { file*.u f = rt.new(file); f.fd = os.open("log"); return f; } fn main() { file*.m log = open_log(); write(log, "hi"); } // log's drop closes the fd here ``` this covers what `return defer x;` was for. `return f();` hands the value up because a call to an owned-result function is fresh (6.1). a cleanup with no object behind it uses a guard value: ```zc int*.u locked() { lock(); return drop(heap(0), fn() { unlock(); }); } fn main() { let g = locked(); ... } // unlocks here ``` so no extra feature is needed. `return defer` stays parked. ## 6. the collector values reachable only through globals, plain pointers, or unchecked code belong to the collector (6.9.3). the collector **does** run their drops, but only when a sweep happens to reclaim them, so the drop runs late. rule: **a resource that must be closed on time has to be owned by a checked local or by an owned chain from one.** the docs say so plainly, and memdebug can warn (section 8). - **after the sweep.** the sweep itself never runs user code. `gc()` puts unmarked live values that have a drop onto a finalize list instead of calling `bfree`; those blocks keep their state and stay out of the free lists. after `gc_active` is cleared, the runtime runs each drop, then frees the block. running user code mid-sweep would let it call `new` (reusing a block the sweep hasn't reached) or store pointers into blocks that are about to go. - **at most once.** a header bit `F_DROPPED` is set just before the drop is called. a value with `F_DROPPED` set never runs its drop again, on any path (collector, `rt.free`, a double free without memdebug). - **resurrection.** a drop that stores `this` (or a child) somewhere reachable, a global or a live container, keeps the value alive: after the drop returns, the runtime checks whether the block was reached again and, if so, does not free it. the value is then an ordinary live value with `F_DROPPED` set: it is usable, it is reclaimed by a later sweep or free once unreachable again, but its drop does not run a second time. under memdebug, finding a dropped value alive at the next sweep prints `zy: value revived by drop from line N`. the simplest implementation: the finalize list is itself a root for the next mark; a finalized block is only freed by the following sweep if it is still unmarked. - **order.** within one sweep, finalize order is the heap address order of the sweep walk, so it is unspecified. a parent and its owned child are both unmarked; the parent's drop runs first and its children follow through its release, keeping parent-before-children. two unrelated unreachable values have no defined order, and a drop must not assume the other is still open. - **shared values** (`F_SHARE`) have no single owner. `free` and `release` skip them today. their drop runs only from the collector. - **no final sweep at exit.** at a normal exit, collector-owned values are left to the os; their drops don't run. ## 7. interactions ### defer at a scope end, the order is: the block's defers (newest first, 4.14.2), then the inserted frees of the block's `m` locals in reverse declaration order, each running its drop. a deferred statement sees its locals still alive (4.14.7). an explicit `defer rt.free(x);` runs the drop at the defer's point, and the local isn't freed again. ### o3 regions o3 changes speed, not meaning (4.10.1), so drop calls stay. an inserted free of a value that may have a drop is a call out of the region, which costs inlining in the llvm/x86 tiers; acceptable, since droppable values are rarely in hot loops. the optimiser must not reorder a drop past another side effect. ### unchecked and unbounded unchecked code has no inserted frees (6.12). what it allocates belongs to the collector or to an explicit `rt.free`, which still runs the drop (the runtime does it, not the analyzer). the set-once and set-early rules can't be checked in unchecked code; there the runtime refuses a second set (trap) and the set-early rule is the programmer's job. unbounded affects only index checks. ### traps and exit - a runtime trap (exit 2) does not unwind. no defers and no drops run. resources are left to the os. - a normal exit (main returns, or `rt.exit`): main's scope-end frees run their drops as usual. collector-owned values are not finalized (no final sweep). - a trap inside a drop is a trap like any other. ### recycle, quarantine, memdebug - `free` → `release` runs the drop before the quarantine, while the value is still fully valid (header live, `len` and indexing work), since `release` clears `n` when it quarantines the block. - `recycle` and `recycle_leaf`: run the drop before the child walk. a value that may have a drop doesn't take the `recycle_leaf` fast path. - double free under memdebug is caught by `quar(p)` before the drop. without memdebug, `F_DROPPED` stops a second run. - `memdebug scrub` fills the payload after the drop, never before. - `rt.memreport()` marks leaked values that have a drop (`leak: line N W words (drop)`), since those are leaked resources, not just leaked words. ### raw blocks untyped `rt.new(n)` blocks take a drop like any other value, so there is no separate `rt.on_free` hook. ### actors - **messages.** copying a value with a drop into an actor message is a hard error (error: x: has drop and can't be copied into a message). a copied fd handle is just an integer, and two drops would close or commit twice. sending it by move, when actors gain move sends, transfers the duty to the receiver and makes the sender's local dead. - `spawn` snapshots the whole vm into a worker process, so both processes hold and drop their copies. document that a droppable value must not be live across `spawn` unless its drop is safe to run once per process; the analyzer gives a hint on a live droppable local at a `spawn` call (4.12). - a worker exiting normally runs its main-level drops, like any program. ## 8. cost - **where the drop lives.** a type default lives in the type record (`p[-1]`, one new field: drop address or 0). a per-value drop lives in a runtime side table keyed by block address (open hash, address → fn), and the block gets the header flag `F_HASDROP` so the free paths only look in the table when the flag is set. values without a drop pay nothing in size. - **header bits.** `p[-4]` holds the state in bits 0..2 (values 1..4), `F_SHARE` = 8 (bit 3), `F_INTERNAL` = 512 (bit 9), `F_STACK` = 1024 (bit 10), all in the low 16 flag bits (`F_LOW`); the size class is in bits 16..20 and the requested size n in bits 32..62. free flag bits: 4..8 (16..256) and 11..15 (2048..32768). chosen: **`F_HASDROP = 16` (bit 4)** and **`F_DROPPED = 32` (bit 5)**, next to `F_SHARE` in `rt.zc`. - **fast paths stay correct for free.** `recycle_leaf` already sends a block to the general `recycle` unless `(f & F_LOW) == 1`, so a block with `F_HASDROP` set can't take the leaf fast path even if the compiler guessed wrong. the collector's sweep tests `(b[1] & 7) == 1` for an unmarked live block; it adds `F_HASDROP` (or a type default) as the test for the finalize list. - **values without drop pay one test.** the runtime's free paths (unchecked `rt.free`, children found by the walk, the collector) add `if (hdr & F_HASDROP || (t = p[-1]) > 0 && t[DROP]) ...`, cheap next to `scan_ptr`. where the analyzer can see that a local never had a drop set and its type has no default, it emits today's free code, including `recycle_leaf`. - **moving blocks.** the heap never moves a live block on its own: it is a buddy allocator in `rt.zc` (`balloc`/`bfree`), the collector marks and sweeps in place with no compaction, and `core.c` only offers word fill/copy primitives (syscall cases 18 and 19) with no relocation. the one place a payload moves is a reallocation: `dgrow` (behind `push`, `dresize`, byte arrays) allocates a bigger items block, copies, and frees the old one. any such resize must move the side-table entry and the `F_HASDROP` bit to the new block before freeing the old one, and that free must not run the drop. a future `rt.realloc` follows the same rule. - **clearing on free.** when `release` or `recycle` frees a block (and `recycle_leaf` falls back to `recycle` for a block with the bit), its side-table entry is removed and `F_HASDROP` and `F_DROPPED` are cleared right after its drop returns, before the walk over its children (`dropfree`: `dropnow`, then `dclear`). the block is already past its drop, so nothing needs the entry while the children go. so a reused address never inherits a drop from the block that was there before. - **the collector.** one extra test per unmarked block in the sweep, a finalize list that grows like the mark stack (`reserve_mark`), and one extra mark root. - **genome.** the parser adds `heap`, the three drop forms, and the type-default method. the analyzer adds set-once/set-early, the no-move-out rule, the never-bound hint, the reassignment free, and the actor send check. the runtime adds the drop test in three places, the side table, and the finalize list. a rough guess is several hundred words; zc-lines should measure the fixpoint once code exists. ## 9. planned tests - **reused address inherits no drop.** set a drop on a block, free it (the drop fires once), then allocate a block of the same size, which under memdebug comes back at the same address. check the address matches, then free the new block and check that no drop fires for it and that its header has neither `F_HASDROP` nor `F_DROPPED` set. ## what is implemented - runtime (`rt.zc`): `dtab` side table ([key, value drop + 1, type drop + 1], open hash, `F_STACK` so it is kept but not scanned), `set_drop`, `set_tdrop`, `dropnow`, `dropfree`, `dclear`, `dmove` (used by `dgrow`); `F_HASDROP` 16, `F_DROPPED` 32 and `F_FINAL` 64 (collector found it unreachable). `release` and `recycle` take one `F_HASDROP` test and call `dropfree`, which runs the drop and then clears the entry, before they walk the children; `recycle_leaf` already falls back to `recycle` for a block with the bit. `gc` is `gc_sweep` (mark, `gc_drops` between mark and sweep, sweep) then `gc_finalize`, which runs the drops outside the collector's frame. - `gc_drops`: drops of marked values are marked (to a fixpoint); each unmarked value whose drop hasn't run gets `F_FINAL` and is kept with what it reaches; a candidate reached by another candidate waits for a later collection, so parents drop before children. - o3: `rt.o3_enter`/`rt.o3_leave` around `o3 { }` blocks and calls from normal code into o3 functions; `gc` inside sets `gc_pending`, the outermost leave runs it. - compiler: `heap(e)`, the three drop forms (a closure there gets `this`), the struct `drop` method as type drop (`set_tdrop` after `new(T)` and `heap`), set-once and set-early on locals, temporaries held in a hidden slot until the statement ends with a hint, the actor `send` check, the o3 errors. - not yet: reassigning an owner that still holds a value (still error 6.8.5, not the free-with-hint), the no-move-out-of-a-droppable rule, the spawn hint, memreport's `(drop)` marker, `break`/`continue` out of an `o3 { }` block (they skip `o3_leave`), o3 methods called from normal code through a method call, and a cycle of unreachable droppable values (each reaches the other, so neither is finalized).