From c1328eec5a03ed1693820d29a477baddf275415f Mon Sep 17 00:00:00 2001 From: zinc Date: Wed, 14 Oct 2026 20:33:50 +0330 Subject: docs: draft design for destructors on heap objects --- docs/design/destructors.md | 121 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 docs/design/destructors.md (limited to 'docs') diff --git a/docs/design/destructors.md b/docs/design/destructors.md new file mode 100644 index 0000000..151ecca --- /dev/null +++ b/docs/design/destructors.md @@ -0,0 +1,121 @@ +# destructors on heap objects (draft) + +status: design draft, no compiler code. it parks `return defer x;` and covers that use case with owned results instead. section numbers refer to `docs/SPEC.md` at `d728a06`. + +## the problem + +an owned heap object 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. acorn's `return defer` idea was meant to fill that gap. a destructor fills it more directly: the cleanup belongs to the type, so it follows the object wherever ownership goes. + +## 1. declaration: one destructor per type + +proposal: a method named `drop` with no parameters and no result, declared in the struct body. + +```zc +struct file { + int fd; + void drop() { os.close(this.fd); } +} +``` + +- each struct type has at most one `drop` (error: file: has more than one drop). a generic struct's `drop` is compiled once per instance, like its other methods (2.13.1). +- `this` is borrowed (`.m`) inside `drop`. the body may read fields and call functions, but it can't give `this` away, store it in a global, or free it (error: drop can't give this away). the runtime frees the block after `drop` returns, as it does now. +- `drop` is never called by name (error: drop is called by the runtime, not directly). an early close is `rt.free(x)`, which runs it. +- 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. +- types with no `drop` stay as they are. + +alternatives, briefly: +- `~file() { ... }` (c++ style). needs a new token form and reads as foreign next to the rest of zc. +- `dtor file(file*.m f) { ... }` as a top-level item. this works for types declared elsewhere, but it splits the type and allows duplicates across units. +- a parse word registered by a library (7.2), like actor. nice for the compiler size, but every free path needs the hook anyway, so it buys little. +- a per-object callback (`rt.on_free(p, fn)`). flexible, but costs a word on every object or a side table, and it can't be checked statically. it could still be offered later for untyped `new(n)` blocks. + +## 2. when it runs + +`drop` runs exactly once, at the moment the object 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)`. +- a temporary held in a hidden frame slot (6.5.4) is freed after its full expression. +- 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 collector reclaims an unowned object (section 5). + +## 3. moves transfer the duty + +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. `drop` rides on that same path, so it inherits the guarantee. no runtime flag is needed. + +- **partial moves.** zc fields carry no tags (2.12.2), and a field's child is owned through `own`. moving a child out (`word c = p.child; p.child = 0;`) is what code does today. proposal: moving out of a field of a droppable struct is allowed only by swapping in a replacement (`take(p.child)` sets the field to 0 and returns the old value fresh). the parent's `drop` must handle a null field. a direct field read that leaves the field still pointing at a moved child is an error when the parent type has a `drop` (error: p.child: moved out of a type with drop; use take). without that rule the parent's child walk would drop the child a second time. +- **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. `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, and no drop flag is needed. +- **reassigning an owner.** today overwriting a still-owning variable is an error (6.8.5: overwriting it would leak it). keep that error. the alternative, dropping the old value implicitly on assignment, is c++-like and hides a side effect in `x = y;`. open question 3. +- **aliases and borrows** never drop. only the owner's death does. + +## 4. returning an owned value + +a function that returns a `.u` result gives the caller the object, and with it the drop. the caller's scope end runs it: + +```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. the cleanup is written once on the type instead of at each return, the handoff shows up in the result type, and passing it along further is just another `return`. `return f();` hands the object up because a call to an owned-result function is fresh (6.1). if the caller binds it to an `m` local instead, the drop runs at that local's block end. this is the same as zcoder's proposed rule for `return defer`, so the semantics don't change for that use case. + +a cleanup with no object behind it (a log line at the caller's scope end, say) still needs a carrier: a tiny struct with a `drop`. that is the price of parking `return defer`. open question 4. + +## 5. the collector + +objects reachable only through globals, plain pointers, or unchecked code belong to the collector (6.9.3). the collector reclaims them whenever a sweep happens to run. so their `drop` runs late, or never if the program exits first. 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 should say so plainly, and memdebug can warn (section 7). + +- **when it runs.** proposal: the sweep never runs user code. `gc()` gathers unmarked live objects whose type has a `drop` onto a finalize list instead of calling `bfree`. those blocks keep their state, so they stay out of the free lists. after `gc_active` is cleared, the runtime calls each `drop` and then frees the block. running user code mid-sweep would let it call `new` (which could reuse a block the sweep hasn't reached) or store pointers into blocks that are about to go. +- **order.** within one sweep, finalize order is the heap address order of the sweep walk, so it is effectively unspecified. a parent and its owned child are both unmarked. the parent runs first, and its children follow through the normal walk, because the collector frees the parent and the parent's release frees the children. two unrelated unreachable objects have no defined order, and a `drop` must not assume the other is still open. +- **resurrection.** a `drop` that stores `this` (or a child) somewhere reachable would bring it back to life. the static rule in section 1 forbids giving `this` away, but unchecked code inside `drop` could still do it. proposal: an object is finalized at most once. a header bit `F_DROPPED` is set before the call. if a later mark finds the object alive, it stays alive, but its `drop` won't run again. under memdebug, finding a dropped object reachable at the next sweep prints `zy: object revived by drop from line N`. +- **shared objects** (`F_SHARE`) have no single owner. `free` and `release` skip them today. their `drop` runs only from the collector. + +## 6. 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`. this keeps 4.14.7 true: a deferred statement sees its locals still alive. so `defer flush(log);` runs before `log`'s drop closes it. an explicit `defer rt.free(x);` runs `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. two notes: an o3 untyped `rt.new(n)` is `new_oz` with no type stamp, so it can't have a drop. and an inserted free in an o3 function of a type with a `drop` is a call out of the region, which will cost inlining in the llvm/x86 tiers. that's acceptable, since droppable objects 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`. an explicit `rt.free` in unchecked code still runs `drop` (the runtime does it, not the analyzer). unbounded affects only index checks, so it has no effect here. the runtime itself is unchecked and unbounded, so it can't depend on the analyzer to drop anything for it. + +### traps and exit +- a runtime trap (exit 2) does not unwind. no defers run today, so no drops run either. resources are left to the os. this matches defer. +- a normal exit (main returns, or `rt.exit`): main's scope-end frees run their drops before exit, as usual. objects still held by globals or the collector are **not** finalized at exit by default (no final sweep). open question 6. +- a trap inside a `drop` is a trap like any other. + +### recycle, quarantine, memdebug +- `free` → `release` leaves the block quarantined until a sweep (6.9.3). `drop` runs before the quarantine, while the object is still fully valid: the header is still live, and `len` and indexing work. that is because `release` clears `n` when it marks the block quarantined. +- `recycle` and `recycle_leaf`: run `drop` before the child walk. `recycle_leaf` is the fast path for leaf types. a type with a `drop` gets no `recycle_leaf`; the compiler routes it to the general free instead (see the cost section). +- double free under memdebug is caught by `quar(p)` before `drop`, so a double free never runs `drop` twice. without memdebug a double free of a released block finds `n == 0` and a cleared state, and `drop` is skipped by checking `F_DROPPED`. +- `memdebug scrub` fills the payload after `drop`, never before. +- new: `rt.memreport()` marks leaked objects whose type has a `drop` (`leak: line N W words (drop: file)`), since those are leaked resources, not just leaked words. + +### actors +- `spawn` snapshots the whole vm into a worker process. every owned object in the snapshot is copied too, so the worker's owners drop their copies, and the parent drops its own. a `drop` that closes an fd would then close it twice, once per process. the fd is duplicated across the fork, so the os is fine, but a drop that commits or deletes something external would act twice. proposal: an `actor`-safe marker is not needed yet. document that a droppable object must not be live across `spawn` unless its drop is idempotent per process, and have the analyzer warn on a live droppable local at a `spawn` call (hint level, 4.12). +- messages copy their typed object graphs (ACTOR.md). the sender keeps and drops its graph; the receiver's copy is a new owner with its own drop. a copied fd handle is just an integer, which is wrong for real resources. proposal: a type with a `drop` can't be sent by copy (error: file: has drop and can't be copied into a message). sending it by move, when actor gains move sends, transfers the duty to the receiver and makes the sender's local dead, as for any `.d` give-away. +- a worker exiting normally runs its main-level drops, like any program. + +## 7. cost + +- **types without drop pay nothing.** whether a type has a `drop` is known when the type is compiled. the type record (`p[-1]`) gains one field: the drop address, or 0. the compiler emits drop calls only for frees whose static type has a drop. every other free keeps today's code, including `recycle_leaf`. untyped `new(n)` and `new_oz` blocks have no type record, so they never drop. +- **free/release/recycle.** the runtime still has to handle frees where the static type isn't known (unchecked `rt.free`, children found by the walk, the collector). each of those adds one load and test: `t = p[-1]; if (t > 0 && t[DROP]) ...`, which is cheap next to the existing `scan_ptr` walk. the `F_DROPPED` bit takes one free header flag; a header bit audit should confirm which one. +- **the collector.** one extra test per unmarked object in the sweep, plus a finalize list that grows like the mark stack (`reserve_mark`). +- **genome.** the parser adds about one method name check and one type record field. the analyzer adds the `take`/partial-move rule and the actor send check. the runtime adds the drop test in three places and the finalize list in `gc`. a rough guess is a few hundred words. the compiler itself defines no droppable types, so it runs no drops. zc-lines should measure the fixpoint once code exists. + +## open questions for acorn + +1. is `drop` as a method name right, or do you prefer `~T()` or a separate top-level `dtor` item? +2. should `drop` see live children (parent first, as proposed), or should children drop first, like c++ members? +3. reassigning a still-owning variable: keep the error (6.8.5), or drop the old value implicitly? +4. with `return defer` parked, is a small carrier struct with a `drop` good enough for cleanups that have no object behind them? +5. partial moves: is the `take(p.field)` rule acceptable, or should droppable types simply forbid moving children out? +6. at a normal exit, should the runtime run one last sweep so collector-owned objects get their drop, or leave them to the os (faster exit, as proposed)? +7. collector finalization: finalize after the sweep with a once-only `F_DROPPED` bit, as proposed, or forbid `drop` on objects that can reach the collector at all? +8. actors: hard error on sending a droppable type by copy, and only a hint on a live droppable at `spawn`. too strict, or not strict enough? +9. should untyped blocks get an opt-in callback (`rt.on_free`), or stay drop-free for good? -- cgit v1.2.3 From 1b7da03c1442053637243bcd6743a0d885d83095 Mon Sep 17 00:00:00 2001 From: zinc Date: Wed, 14 Oct 2026 21:08:02 +0330 Subject: docs: revise destructors draft to acorn's rulings (drop on any heap value, heap() builtin, gc runs drops) --- docs/design/destructors.md | 183 +++++++++++++++++++++++++++++---------------- 1 file changed, 117 insertions(+), 66 deletions(-) (limited to 'docs') diff --git a/docs/design/destructors.md b/docs/design/destructors.md index 151ecca..36c9f20 100644 --- a/docs/design/destructors.md +++ b/docs/design/destructors.md @@ -1,55 +1,98 @@ -# destructors on heap objects (draft) +# destructors on heap values (draft) -status: design draft, no compiler code. it parks `return defer x;` and covers that use case with owned results instead. section numbers refer to `docs/SPEC.md` at `d728a06`. +status: design draft, no compiler code. 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 object 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. acorn's `return defer` idea was meant to fill that gap. a destructor fills it more directly: the cleanup belongs to the type, so it follows the object wherever ownership goes. +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. declaration: one destructor per type +## 1. heap(v): boxing any value -proposal: a method named `drop` with no parameters and no result, declared in the struct body. +`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 -struct file { - int fd; - void drop() { os.close(this.fd); } -} +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 ``` -- each struct type has at most one `drop` (error: file: has more than one drop). a generic struct's `drop` is compiled once per instance, like its other methods (2.13.1). -- `this` is borrowed (`.m`) inside `drop`. the body may read fields and call functions, but it can't give `this` away, store it in a global, or free it (error: drop can't give this away). the runtime frees the block after `drop` returns, as it does now. -- `drop` is never called by name (error: drop is called by the runtime, not directly). an early close is `rt.free(x)`, which runs it. -- 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. -- types with no `drop` stay as they are. +- **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. -alternatives, briefly: -- `~file() { ... }` (c++ style). needs a new token form and reads as foreign next to the rest of zc. -- `dtor file(file*.m f) { ... }` as a top-level item. this works for types declared elsewhere, but it splits the type and allows duplicates across units. -- a parse word registered by a library (7.2), like actor. nice for the compiler size, but every free path needs the hook anyway, so it buys little. -- a per-object callback (`rt.on_free(p, fn)`). flexible, but costs a word on every object or a side table, and it can't be checked statically. it could still be offered later for untyped `new(n)` blocks. +## 2. drop: attaching cleanup to a value -## 2. when it runs +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`). -`drop` runs exactly once, at the moment the object dies: +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 per-value drop on such a value is an error, the same as setting it twice (see open question 1). +- `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)`. -- a temporary held in a hidden frame slot (6.5.4) is freed after its full expression. +- **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 collector reclaims an unowned object (section 5). +- 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. -## 3. moves transfer the duty +## 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. `drop` rides on that same path, so it inherits the guarantee. no runtime flag is needed. +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. -- **partial moves.** zc fields carry no tags (2.12.2), and a field's child is owned through `own`. moving a child out (`word c = p.child; p.child = 0;`) is what code does today. proposal: moving out of a field of a droppable struct is allowed only by swapping in a replacement (`take(p.child)` sets the field to 0 and returns the old value fresh). the parent's `drop` must handle a null field. a direct field read that leaves the field still pointing at a moved child is an error when the parent type has a `drop` (error: p.child: moved out of a type with drop; use take). without that rule the parent's child walk would drop the child a second time. -- **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. `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, and no drop flag is needed. -- **reassigning an owner.** today overwriting a still-owning variable is an error (6.8.5: overwriting it would leak it). keep that error. the alternative, dropping the old value implicitly on assignment, is c++-like and hides a side effect in `x = y;`. open question 3. +- **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. -## 4. returning an owned value +## 5. returning an owned value -a function that returns a `.u` result gives the caller the object, and with it the drop. the caller's scope end runs it: +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; } @@ -60,62 +103,70 @@ fn main() { } // log's drop closes the fd here ``` -this covers what `return defer x;` was for. the cleanup is written once on the type instead of at each return, the handoff shows up in the result type, and passing it along further is just another `return`. `return f();` hands the object up because a call to an owned-result function is fresh (6.1). if the caller binds it to an `m` local instead, the drop runs at that local's block end. this is the same as zcoder's proposed rule for `return defer`, so the semantics don't change for that use case. +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: -a cleanup with no object behind it (a log line at the caller's scope end, say) still needs a carrier: a tiny struct with a `drop`. that is the price of parking `return defer`. open question 4. +```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. -## 5. the collector +## 6. the collector -objects reachable only through globals, plain pointers, or unchecked code belong to the collector (6.9.3). the collector reclaims them whenever a sweep happens to run. so their `drop` runs late, or never if the program exits first. 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 should say so plainly, and memdebug can warn (section 7). +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). -- **when it runs.** proposal: the sweep never runs user code. `gc()` gathers unmarked live objects whose type has a `drop` onto a finalize list instead of calling `bfree`. those blocks keep their state, so they stay out of the free lists. after `gc_active` is cleared, the runtime calls each `drop` and then frees the block. running user code mid-sweep would let it call `new` (which could reuse a block the sweep hasn't reached) or store pointers into blocks that are about to go. -- **order.** within one sweep, finalize order is the heap address order of the sweep walk, so it is effectively unspecified. a parent and its owned child are both unmarked. the parent runs first, and its children follow through the normal walk, because the collector frees the parent and the parent's release frees the children. two unrelated unreachable objects have no defined order, and a `drop` must not assume the other is still open. -- **resurrection.** a `drop` that stores `this` (or a child) somewhere reachable would bring it back to life. the static rule in section 1 forbids giving `this` away, but unchecked code inside `drop` could still do it. proposal: an object is finalized at most once. a header bit `F_DROPPED` is set before the call. if a later mark finds the object alive, it stays alive, but its `drop` won't run again. under memdebug, finding a dropped object reachable at the next sweep prints `zy: object revived by drop from line N`. -- **shared objects** (`F_SHARE`) have no single owner. `free` and `release` skip them today. their `drop` runs only from the collector. +- **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. -## 6. interactions +## 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`. this keeps 4.14.7 true: a deferred statement sees its locals still alive. so `defer flush(log);` runs before `log`'s drop closes it. an explicit `defer rt.free(x);` runs `drop` at the defer's point, and the local isn't freed again. +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. two notes: an o3 untyped `rt.new(n)` is `new_oz` with no type stamp, so it can't have a drop. and an inserted free in an o3 function of a type with a `drop` is a call out of the region, which will cost inlining in the llvm/x86 tiers. that's acceptable, since droppable objects are rarely in hot loops. the optimiser must not reorder a drop past another side effect. +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`. an explicit `rt.free` in unchecked code still runs `drop` (the runtime does it, not the analyzer). unbounded affects only index checks, so it has no effect here. the runtime itself is unchecked and unbounded, so it can't depend on the analyzer to drop anything for it. +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 run today, so no drops run either. resources are left to the os. this matches defer. -- a normal exit (main returns, or `rt.exit`): main's scope-end frees run their drops before exit, as usual. objects still held by globals or the collector are **not** finalized at exit by default (no final sweep). open question 6. -- a trap inside a `drop` is a trap like any other. +- 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` leaves the block quarantined until a sweep (6.9.3). `drop` runs before the quarantine, while the object is still fully valid: the header is still live, and `len` and indexing work. that is because `release` clears `n` when it marks the block quarantined. -- `recycle` and `recycle_leaf`: run `drop` before the child walk. `recycle_leaf` is the fast path for leaf types. a type with a `drop` gets no `recycle_leaf`; the compiler routes it to the general free instead (see the cost section). -- double free under memdebug is caught by `quar(p)` before `drop`, so a double free never runs `drop` twice. without memdebug a double free of a released block finds `n == 0` and a cleared state, and `drop` is skipped by checking `F_DROPPED`. -- `memdebug scrub` fills the payload after `drop`, never before. -- new: `rt.memreport()` marks leaked objects whose type has a `drop` (`leak: line N W words (drop: file)`), since those are leaked resources, not just leaked words. +- `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 -- `spawn` snapshots the whole vm into a worker process. every owned object in the snapshot is copied too, so the worker's owners drop their copies, and the parent drops its own. a `drop` that closes an fd would then close it twice, once per process. the fd is duplicated across the fork, so the os is fine, but a drop that commits or deletes something external would act twice. proposal: an `actor`-safe marker is not needed yet. document that a droppable object must not be live across `spawn` unless its drop is idempotent per process, and have the analyzer warn on a live droppable local at a `spawn` call (hint level, 4.12). -- messages copy their typed object graphs (ACTOR.md). the sender keeps and drops its graph; the receiver's copy is a new owner with its own drop. a copied fd handle is just an integer, which is wrong for real resources. proposal: a type with a `drop` can't be sent by copy (error: file: has drop and can't be copied into a message). sending it by move, when actor gains move sends, transfers the duty to the receiver and makes the sender's local dead, as for any `.d` give-away. +- **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. -## 7. cost +## 8. cost -- **types without drop pay nothing.** whether a type has a `drop` is known when the type is compiled. the type record (`p[-1]`) gains one field: the drop address, or 0. the compiler emits drop calls only for frees whose static type has a drop. every other free keeps today's code, including `recycle_leaf`. untyped `new(n)` and `new_oz` blocks have no type record, so they never drop. -- **free/release/recycle.** the runtime still has to handle frees where the static type isn't known (unchecked `rt.free`, children found by the walk, the collector). each of those adds one load and test: `t = p[-1]; if (t > 0 && t[DROP]) ...`, which is cheap next to the existing `scan_ptr` walk. the `F_DROPPED` bit takes one free header flag; a header bit audit should confirm which one. -- **the collector.** one extra test per unmarked object in the sweep, plus a finalize list that grows like the mark stack (`reserve_mark`). -- **genome.** the parser adds about one method name check and one type record field. the analyzer adds the `take`/partial-move rule and the actor send check. the runtime adds the drop test in three places and the finalize list in `gc`. a rough guess is a few hundred words. the compiler itself defines no droppable types, so it runs no drops. zc-lines should measure the fixpoint once code exists. +- **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 needs per-value storage. proposal: a header flag `F_HASDROP` plus a side table keyed by block address (open hash), so values without a drop pay nothing in size. the type default needs no table entry. see open question 2. +- **values without drop pay one test.** the runtime's free paths (unchecked `rt.free`, children found by the walk, the collector) add one load and test: `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`. +- **header bits.** `F_HASDROP` and `F_DROPPED` take two free header flags; a header bit audit should confirm which. +- **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. ## open questions for acorn -1. is `drop` as a method name right, or do you prefer `~T()` or a separate top-level `dtor` item? -2. should `drop` see live children (parent first, as proposed), or should children drop first, like c++ members? -3. reassigning a still-owning variable: keep the error (6.8.5), or drop the old value implicitly? -4. with `return defer` parked, is a small carrier struct with a `drop` good enough for cleanups that have no object behind them? -5. partial moves: is the `take(p.field)` rule acceptable, or should droppable types simply forbid moving children out? -6. at a normal exit, should the runtime run one last sweep so collector-owned objects get their drop, or leave them to the os (faster exit, as proposed)? -7. collector finalization: finalize after the sweep with a once-only `F_DROPPED` bit, as proposed, or forbid `drop` on objects that can reach the collector at all? -8. actors: hard error on sending a droppable type by copy, and only a hint on a live droppable at `spawn`. too strict, or not strict enough? -9. should untyped blocks get an opt-in callback (`rt.on_free`), or stay drop-free for good? +1. a struct with a type-level `drop`: should a per-value drop be an error (as written), replace the default, or run in addition to it? +2. per-value drop storage: side table keyed by address (no size cost, one hash lookup per drop) or an extra header word on every block (fast, costs memory everywhere)? -- cgit v1.2.3 From 65b44a70ed3f4b6b202d983f27ef7a068fb56aa5 Mon Sep 17 00:00:00 2001 From: zinc Date: Wed, 14 Oct 2026 21:36:55 +0330 Subject: docs: destructors: value and type drops both run, side table storage, F_HASDROP/F_DROPPED bits --- docs/design/destructors.md | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) (limited to 'docs') diff --git a/docs/design/destructors.md b/docs/design/destructors.md index 36c9f20..8fb6498 100644 --- a/docs/design/destructors.md +++ b/docs/design/destructors.md @@ -45,7 +45,7 @@ let y = new(int); y.drop = fn() { std.put_n(*this); }; // state } ``` - a per-value drop on such a value is an error, the same as setting it twice (see open question 1). + 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. @@ -160,13 +160,10 @@ untyped `rt.new(n)` blocks take a drop like any other value, so there is no sepa ## 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 needs per-value storage. proposal: a header flag `F_HASDROP` plus a side table keyed by block address (open hash), so values without a drop pay nothing in size. the type default needs no table entry. see open question 2. -- **values without drop pay one test.** the runtime's free paths (unchecked `rt.free`, children found by the walk, the collector) add one load and test: `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`. -- **header bits.** `F_HASDROP` and `F_DROPPED` take two free header flags; a header bit audit should confirm which. +- **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. - **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. - -## open questions for acorn - -1. a struct with a type-level `drop`: should a per-value drop be an error (as written), replace the default, or run in addition to it? -2. per-value drop storage: side table keyed by address (no size cost, one hash lookup per drop) or an extra header word on every block (fast, costs memory everywhere)? -- cgit v1.2.3 From ee13eba61d36b373c801218933b72b348b3a5a78 Mon Sep 17 00:00:00 2001 From: zinc Date: Wed, 14 Oct 2026 21:37:34 +0330 Subject: docs: destructors: clear drop entry and bits on free; planned reuse test --- docs/design/destructors.md | 5 +++++ 1 file changed, 5 insertions(+) (limited to 'docs') diff --git a/docs/design/destructors.md b/docs/design/destructors.md index 8fb6498..1f085f3 100644 --- a/docs/design/destructors.md +++ b/docs/design/destructors.md @@ -165,5 +165,10 @@ untyped `rt.new(n)` blocks take a drop like any other value, so there is no sepa - **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 a block is freed, at the same point where `release` clears the requested size n (bits 32..62 of `p[-4]`) on quarantine, and in `recycle`/`recycle_leaf`/`bfree` when the block goes back on a free list, its side-table entry is removed and `F_HASDROP` and `F_DROPPED` are cleared. this happens after the drop has run. 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. -- cgit v1.2.3 From 8af3174b04f18d858653ad70669dced71f5eaa99 Mon Sep 17 00:00:00 2001 From: zinc Date: Wed, 14 Oct 2026 23:10:49 +0330 Subject: destructors: heap(v), drop(v, f) / v.drop(f) / v.drop = f, struct drop methods, collector drops after the sweep (once, resurrection), freed blocks forget their drop, temporaries drop at statement end with a hint, actor send check; o3: no collection inside a region (pending until the outermost exit), no drops set or ended inside o3; spec 3.11.6, 4.10.3-4, 6.15 --- docs/SPEC.md | 16 ++ docs/design/destructors.md | 10 +- src/compiler/analyzer.zc | 5 +- src/compiler/base.zc | 5 + src/compiler/builtins.zc | 93 ++++++++++ src/compiler/expr.zc | 18 +- src/compiler/stmt.zc | 10 +- src/runtime/rt.zc | 196 ++++++++++++++++++++- tests/actor/reject/drop_message.zc | 13 ++ tests/actor/reject/drop_value.zc | 12 ++ .../6-ownership/drop_ends_in_o3_rejected.zc | 9 + tests/conformance/6-ownership/drop_forms.zc | 14 ++ .../6-ownership/drop_free_in_o3_rejected.zc | 10 ++ tests/conformance/6-ownership/drop_gc_once.zc | 17 ++ tests/conformance/6-ownership/drop_gc_resurrect.zc | 21 +++ tests/conformance/6-ownership/drop_heap_values.zc | 15 ++ .../conformance/6-ownership/drop_in_o3_rejected.zc | 9 + .../6-ownership/drop_parent_before_child.zc | 17 ++ .../conformance/6-ownership/drop_reused_address.zc | 22 +++ tests/conformance/6-ownership/drop_set_late.zc | 10 ++ tests/conformance/6-ownership/drop_set_twice.zc | 10 ++ .../6-ownership/drop_temporary_statement.zc | 13 ++ .../conformance/6-ownership/drop_value_and_type.zc | 11 ++ tests/conformance/8-runtime/o3_no_gc_inside.zc | 39 ++++ tests/data/drop_hint.zc | 2 + tests/drivers/actor_test.py | 2 +- tests/test.sh | 1 + 27 files changed, 589 insertions(+), 11 deletions(-) create mode 100644 tests/actor/reject/drop_message.zc create mode 100644 tests/actor/reject/drop_value.zc create mode 100644 tests/conformance/6-ownership/drop_ends_in_o3_rejected.zc create mode 100644 tests/conformance/6-ownership/drop_forms.zc create mode 100644 tests/conformance/6-ownership/drop_free_in_o3_rejected.zc create mode 100644 tests/conformance/6-ownership/drop_gc_once.zc create mode 100644 tests/conformance/6-ownership/drop_gc_resurrect.zc create mode 100644 tests/conformance/6-ownership/drop_heap_values.zc create mode 100644 tests/conformance/6-ownership/drop_in_o3_rejected.zc create mode 100644 tests/conformance/6-ownership/drop_parent_before_child.zc create mode 100644 tests/conformance/6-ownership/drop_reused_address.zc create mode 100644 tests/conformance/6-ownership/drop_set_late.zc create mode 100644 tests/conformance/6-ownership/drop_set_twice.zc create mode 100644 tests/conformance/6-ownership/drop_temporary_statement.zc create mode 100644 tests/conformance/6-ownership/drop_value_and_type.zc create mode 100644 tests/conformance/8-runtime/o3_no_gc_inside.zc create mode 100644 tests/data/drop_hint.zc (limited to 'docs') diff --git a/docs/SPEC.md b/docs/SPEC.md index aad7ff6..7177b14 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -467,6 +467,8 @@ pn((int)(p + 1) - (int)p); // 1: p + 1 moves one word 3.11.5 `new(T)` without `rt.` is accepted as `rt.new(T)`; the name `new` alone is the only rt name that resolves unqualified (any other, `put_c(65)`, is error: put_c is used but never defined; did you mean to use rt.put_c?). +3.11.6 `heap(e)` copies the value of `e` into a fresh owned heap block sized to it and returns a pointer to the block (a fresh object, 6.1): one word for a scalar or pointer (`heap(10)` is an `int*`), the characters and the terminating 0 for a `char*` word string (`heap("hi")` is a `char*`), and `sizeof(T)` words for a struct or array value (`heap(v)` with `v` a `foo` is a `foo*`). pointer fields of a copied struct are copied as plain words. a struct whose body declares `drop` gives the block its type drop (6.15). `heap` is found only when no variable or function of that name is in scope. + ### 3.12 `sizeof` and `typeof` 3.12.1 `sizeof(T)` and `sizeof(e)` give the size in words. the expression form is compiled but never run: `int i = 0; int n = sizeof(i++);` leaves `i` 0 and `n` 1. a `u8[100]` is 13 words, a struct of four `u8` fields is 1. @@ -622,6 +624,9 @@ switch (s) { case circle(r): pn(r); case rect(w, h): pn(w * h); case dot: ps("do o3 int sum(fixed int[?] a) { int s = 0; for (int i = 0; i < len(a); i++) s += a[i]; return s; } ``` +4.10.3 no collection runs inside an o3 region, from any code path. an allocation that would collect grows the heap instead and leaves a collection pending; an explicit `rt.gc()` inside the region is pending too. when the outermost region is left (the end of an `o3 { }` block, a `return` out of it, or the return of an o3 function called from normal code), the pending collection runs once. if the heap can't grow, the allocation is a runtime trap (out of heap memory, exit status 2). +4.10.4 drops (6.15) don't run inside o3: setting a drop inside an o3 region is error: a drop can't be set inside an o3 region, and ending the lifetime of a value with a drop inside an o3 region, by `rt.free`, at a scope end, or as a temporary, is error: x: has drop and can't end inside an o3 region. a drop the compiler can't see (unchecked code) that comes to run inside o3 stops the program: zy: a drop can't run inside an o3 region. + ### 4.11 memdebug `memdebug on;` turns on heap header checks and allocation-site tracking from this point in the file (top level) or from this point in the function (statement); the runtime enables the checks before `main` for the top-level form. `memdebug scrub;` additionally fills freed payloads with 57005; `memdebug off;` stops. with checks on, a use after free prints `zy: use after free from line N`, a double free prints `zy: double free from line N` and returns, a stray address prints `zy: bad heap pointer`. `rt.memreport()` prints a line `leak: line N W words` per live recorded object (W is the n words new asked for, not the block's payload) and returns the count. @@ -982,6 +987,17 @@ the `tests/errors/*.zc` files are exact reproductions; a verifier can run `bash the compiler also prints non-error findings as `zc: line N: warning: ...` (a switch that does not cover a tagged enum, a constant index out of range of a `T[?]`, a use after free provable at compile time, ...). warnings do not stop the program. +### 6.15 drops + +6.15.1 any heap value can have a drop: a `fn()` that runs once, when the value dies. three forms set it: `drop(v, f)` and `v.drop(f)` give `v` back (same type and ownership), and `v.drop = f;` is a statement. inside `f`, `this` is the value, borrowed. a drop is set once (error: x: drop is already set) and before the value is first read, assigned through or passed (error: x: drop set after the value was used). a struct body may declare `void drop() { ... }`: it is the default drop of every value of that type made by `new(T)` or `heap`; when a value has both, its own drop runs first, then the type's. +``` +rt.put_n(*heap(15).drop(fn() { rt.put_s(" bye "); rt.put_n(*this); })); // 15 bye 15 +let guard = drop(heap(0), fn() { unlock(); }); // unlocks at block end +``` +6.15.2 a value dies, and its drop runs, where 6.5 frees it: at the end of its owner's block or an early exit, at `rt.free`, or with its container, whose drop runs before its children's. a value with a drop that nothing holds (a temporary) dies at the end of its full statement, with hint: value with drop is never bound; it drops at the end of this statement. an owned local with a drop is not "never used" (6.5.2): holding it is its use. +6.15.3 the collector runs the drop of an unreachable value after its sweep, never during it, and at most once: the value is kept until a later collection finds it unreachable again. a drop that stores `this` somewhere reachable keeps the value alive, and its drop does not run again. there is no collection at exit, so collector-owned values are not dropped then. a freed block forgets its drop, so a block reused at the same address has none. +6.15.4 copying a value with a drop into an actor message is error: x: has drop and can't be copied into a message. + ## 7. compile-time execution the compiler lives in vm memory with the program. functions and globals of the compiler are callable through the module `run` (`include run;`). diff --git a/docs/design/destructors.md b/docs/design/destructors.md index 1f085f3..777db2c 100644 --- a/docs/design/destructors.md +++ b/docs/design/destructors.md @@ -1,6 +1,6 @@ # destructors on heap values (draft) -status: design draft, no compiler code. 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`. +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 @@ -172,3 +172,11 @@ untyped `rt.new(n)` blocks take a drop like any other value, so there is no sepa ## 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`, `dclear`, `dmove` (used by `dgrow`); `F_HASDROP` 16, `F_DROPPED` 32 and `F_FINAL` 64 (collector found it unreachable). `release` and `recycle` run the drop first and clear the entry when the block goes back; `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). diff --git a/src/compiler/analyzer.zc b/src/compiler/analyzer.zc index d1f6f3d..34c0a8b 100644 --- a/src/compiler/analyzer.zc +++ b/src/compiler/analyzer.zc @@ -389,6 +389,7 @@ int newloc(int n) { if (if_local) if_local[i] = 0; if (!local_origin_mask) local_origin_mask = chk_resize(0, 0, loccap); local_origin_mask[i] = 0; if (!local_cell) local_cell = chk_resize(0, 0, loccap); local_cell[i] = 0; + if (!local_dropset) local_dropset = chk_resize(0, 0, loccap); local_dropset[i] = 0; if (!local_captured) local_captured = chk_resize(0, 0, loccap); local_captured[i] = 0; if (!local_ended) local_ended = chk_resize(0, 0, loccap); local_ended[i] = 0; if (!local_end_info) local_end_info = chk_resize(0, 0, loccap); local_end_info[i] = 0; @@ -920,6 +921,7 @@ int touch(int i) { // an expression of class c used as a plain value (a lend, an operand). a new // object can't be used that way, since nothing would own it. int plainuse(int c) { + if (c == C_FRESH && drop_tail == here && fchk && infn) { drop_temporary(); return C_PLAIN; } if (c == C_FRESH) cerr("a new object can't be used as a plain value; bind it to a name first"); if (c >= C_VAR) touch(c - C_VAR); return C_PLAIN; @@ -933,6 +935,7 @@ int szclass(int n) { // freeing an owned local. one that holds a typed object with no pointer // fields goes back through recycle_leaf, which knows its class int freevar(int i) { + if (o3on && fchk && ldroppable(i)) cerrn(i, "has drop and can't end inside an o3 region"); if (local_captured && local_captured[i]) { lit(0); laddr(lslot[i]); op(O_ST); return 0; } vaddr(i); op(O_LD); if (lleaf[i] && !o3on && !dry && lookup(s_recycle_leaf) >= 0) { @@ -966,7 +969,7 @@ int exit_vars(int dep, int final) { // block ends. int unused_check(int from) { while (from < nloc) { - if (fchk && lown[from] && !lused[from] && !(lrskind[from] == RS_PARAM && rt.eq_w(lname[from], "_"))) serrn(lname[from], "owned object is never used"); + if (fchk && lown[from] && !lused[from] && !ldroppable(from) && !(lrskind[from] == RS_PARAM && rt.eq_w(lname[from], "_"))) serrn(lname[from], "owned object is never used"); from++; } return 0; diff --git a/src/compiler/base.zc b/src/compiler/base.zc index b326c80..2fcc7f7 100644 --- a/src/compiler/base.zc +++ b/src/compiler/base.zc @@ -255,6 +255,10 @@ int fn_return_type; // a consistent return type inferred from the function body const int EF_READ = 1, EF_WRITE = 2, EF_RETAIN = 4, EF_CONSUME = 8, EF_UNKNOWN = 16, EF_ALIAS = 32, EF_IDENTITY = 64; word effect_symbols, effect_edges, effect_requirements, effect_current; int effect_floor, effect_argument_base; word local_origin, local_origin_mask, local_cell, local_captured, local_ended, local_end_info; word effect_value, argorigin_mask; int capture_enabled; word[1] capture_dummy; +// drops (docs/design/destructors.md): the code position just past a drop form (a fresh +// value with a drop still on the stack there), the type `this` gets in the next closure, +// a local whose drop is set, an actor send whose arguments are checked, and o3 block depth. +int drop_tail = -1, send_check, o3blk; word closure_this, local_dropset; int borrow_pending, borrow_depth, borrow_jump = -1, borrow_entry; word borrow_slots; int argorigin = -1, argtemporary, argowner = -1; word argowner_fact; int stubgen; // 1 while an extern's generated stub is compiled: the stub gets no hints @@ -748,6 +752,7 @@ int reserve_locals(int need) { if (local_origin) local_origin = chk_resize(local_origin, nloc, cap); if (local_origin_mask) local_origin_mask = chk_resize(local_origin_mask, nloc, cap); if (local_cell) local_cell = chk_resize(local_cell, nloc, cap); + if (local_dropset) local_dropset = chk_resize(local_dropset, nloc, cap); if (local_captured) local_captured = chk_resize(local_captured, nloc, cap); if (local_ended) local_ended = chk_resize(local_ended, nloc, cap); if (local_end_info) local_end_info = chk_resize(local_end_info, nloc, cap); diff --git a/src/compiler/builtins.zc b/src/compiler/builtins.zc index 5efe379..8c00636 100644 --- a/src/compiler/builtins.zc +++ b/src/compiler/builtins.zc @@ -838,6 +838,99 @@ int array_member(int c, int n) { if (n == intern("pop")) return array_pop(t); return array_resize(t); } +// ---- drops and heap() (docs/design/destructors.md) ---- + +// a pointer to a struct whose body declares drop: its values drop through the type +int tdroppable(word t) { + word e; + if (!t || !isptr(t)) return 0; + e = t[4]; + return e[0] == TY_STRUCT && e[3] >= 0 && methof(e, intern("drop")) >= 0; +} +// local i holds a value with a drop: one set on it, or one its type declares +int ldroppable(int i) { return (local_dropset && local_dropset[i]) || tdroppable(ltype[i]); } +// entering o3 from normal code: the collector waits until the outermost region is left +int o3_enter_code() { callname(intern("rt.o3_enter")); op(O_DROP); return 0; } +// a fresh value with a drop that nothing holds: it is held in a hidden slot and +// dropped when the full statement ends +int drop_temporary() { + emit_borrow_hold(); + if (hint_begin(tpack(), HINT_SMALL)) { rt.put_s("value with drop is never bound; it drops at the end of this statement"); dg_close(); } + return 0; +} +// the value is on the stack. compile the drop (the next expression, usually fn() { ... } +// with this bound to the value), attach it with rt.set_drop, and leave the value there +// with the class and facts it had. owner is the local that owns the value, or -1. +int drop_attach(word vty, int owner) { + word t = xty, af = xaf, ev = effect_value; int bor = xbor, st = xstr, c; + if (o3on) serr("a drop can't be set inside an o3 region"); + if (owner >= 0 && fchk && local_dropset) { + if (local_dropset[owner]) cerrn(owner, "drop is already set"); + local_dropset[owner] = 1; + } + lit(0); op(O_PICK); + closure_this = vty; c = expr(); rval(); closure_this = 0; + if (c >= C_VAR) touch(c - C_VAR); + callname(intern("rt.set_drop")); op(O_DROP); + xty = t; xaf = af; effect_value = ev; xbor = bor; xstr = st; + lv = 0; lvk = LV_NONE; xzero = 0; xconst = 0; xfloat = 0; + return 0; +} +// drop(v, fn() { ... }): v, with the drop attached +int drop_call() { + int c, prior = 0, i = 0 - 1; + next(); expect(s_lp); + if (tk == T_ID) { peek(); if (pk_tk == T_OP && (pk_tv == s_comma)) { i = findloc(tv); if (i >= 0) prior = ltag[i] & LREAD; } } + c = expr(); rval(); + i = 0 - 1; + if (c >= C_VAR) i = lroot(c - C_VAR); + if (i >= 0 && prior && fchk) cerrn(i, "drop set after the value was used"); + tcneed(isptr(xty) || xty == tword, "drop needs a heap value", xty); + expect(s_comma); + drop_attach(xty, i); + expect(s_rp); + if (c == C_FRESH) drop_tail = here; + return c; +} +// v.drop(fn() { ... }) gives v back; v.drop = fn() { ... }; is a statement +int drop_member(int c, word borrowed) { + int prior = 0, i = 0 - 1, assign = is_op(s_eq); + if (lv && lvk == LV_LOCAL) prior = ltag[lvi] & LREAD; + rval(); + xaf = borrowed; + if (c >= C_VAR) { i = lroot(c - C_VAR); touch(c - C_VAR); } + if (i >= 0 && prior && fchk) cerrn(i, "drop set after the value was used"); + next(); + if (assign && c == C_FRESH && fchk && infn) { drop_temporary(); c = C_PLAIN; } + drop_attach(xty, i); + if (!assign) expect(s_rp); + if (c == C_FRESH) drop_tail = here; + if (assign) { xty = tint; xaf = 0; xbor = 0 - 1; return C_PLAIN; } + return c; +} +// heap(v): v copied into a fresh owned block sized to it +int heap_box() { + int c, place = tpack(), ms; word t; + next(); expect(s_lp); + c = expr(); rval(); plainuse(c); + expect(s_rp); + t = xty; + stamp_site(); + if (isagg(t)) { + lit(tsz(t)); lit(t); callname(intern("rt.heap_copy")); + newsz = tsz(t); xty = tptr(t); + if (t[0] == TY_STRUCT) { ms = methof(t, intern("drop")); if (ms >= 0) { fnref(ms); callname(intern("rt.set_tdrop")); } } + } else if (isptr(t) && t[4][0] == TY_CHAR) { + callname(intern("rt.heap_s")); newsz = -1; xty = t; + } else { + if (xfloat) xfloat = 0; + lit(t); callname(intern("rt.heap_w")); newsz = 1; xty = tptr(t); + } + newleaf = 0; + xaf = af_new(newsz, 2, place); + lv = 0; lvk = LV_NONE; xzero = 0; xconst = 0; xstr = 0; xbor = 0 - 1; effect_value = 0; + return C_FRESH; +} // compile sizeof(T) or sizeof(e): the size of a type or expression in words. int kw_sizeof() { int t; diff --git a/src/compiler/expr.zc b/src/compiler/expr.zc index 8768658..e6acfc8 100644 --- a/src/compiler/expr.zc +++ b/src/compiler/expr.zc @@ -68,6 +68,7 @@ int args(int tags, word ft, int bs, int fat) { freeing = sname[cs] == s_free || sname[cs] == s_recycle; harmless = sname[cs] == s_put_s || rt.eq_w(sname[cs], "rt.put_n") || rt.eq_w(sname[cs], "rt.put_c") || rt.eq_w(sname[cs], "len") || rt.eq_w(sname[cs], "cap"); } + int sending = send_check; send_check = 0; xgive = 0; ifcall = 0 - 1; effect_argument_base = 0; expect(s_lp); if (!is_op(s_rp)) { @@ -76,6 +77,10 @@ int args(int tags, word ft, int bs, int fat) { closure_expected = 0; if (ft && np + parameter_base < ft[3] && is_id(s_fn)) closure_expected = subst(ft[7 + np + parameter_base], bs); place = tpack(); c = expr(); rval(); closure_expected = expected; + if (sending && fchk && ((c >= C_VAR && ldroppable(lroot(c - C_VAR))) || tdroppable(xty) || (c == C_FRESH && drop_tail == here))) { + if (c >= C_VAR) serrn(lname[lroot(c - C_VAR)], "has drop and can't be copied into a message"); else serr("a value with drop can't be copied into a message"); + } + if (freeing && o3on && fchk && c >= C_VAR && ldroppable(lroot(c - C_VAR))) serrn(lname[lroot(c - C_VAR)], "has drop and can't end inside an o3 region"); current = effect_origin(c); current_mask = effect_mask(c); origins = origins | current_mask; effect_args_mask(cs, np + parameter_base, current_mask); if (!np) origin = current; else if (origin != current) origin = -1; tag = (tags >> (np + np)) & 3; @@ -176,6 +181,7 @@ int newtyped() { expect(s_rp); stamp_site(); if (fast) callname(s_new_k); else callname(s_new_t); vf_effect(); + if (t[0] == TY_STRUCT && t[3] >= 0) { int dm = methof(t, intern("drop")); if (dm >= 0) { fnref(dm); callname(intern("rt.set_tdrop")); } } xaf = af_new(length, 2, place); lv = 0; lvk = LV_NONE; xty = tptr(t); xzero = 0; xstamp = 1; return C_FRESH; @@ -462,6 +468,7 @@ int call(int s) { } if (!(sflag[s] & F_DEF) || s == finsym) sflag[s] = sflag[s] | F_EARLY; // called before its body: an owned return is not inferred for it if (sflag[s] & F_EXT) { lit(sval[s][-1]); op(620); } // an extern: the native call opcode, with the block's address + else if (!o3on && (sflag[s] & F_O3)) { o3_enter_code(); fnref(s); op(O_CALL); callname(intern("rt.o3_leave")); op(O_DROP); } // into o3 from normal code: no collection until it returns else { fnref(s); op(O_CALL); } if (!o3on && (sflag[s] & F_O3) && ft) { // non-o3 called o3: fatten the result here, not inside the callee rhs_type = subst(ft[4], bs); @@ -649,14 +656,18 @@ word closure_layout(int closure_index, int count) { int closure() { char* xcf; int np = 0; char*[24] pn; word[24] pty; int hop, entry, i, t, envl, ncap, c, result, declared = 0, tags = 0, tag, lt, lfl = tline; word[24] ptags; - word expected = closure_expected; closure_expected = 0; + word expected = closure_expected, this_type = closure_this; closure_expected = 0; closure_this = 0; reserve_closures(nclo + 1); if (o3on) serr("closures can't be made inside an o3 region"); next(); // fn:type(args) names the result; returns are coerced and checked against it if (accept(s_colon)) declared = ptype0(); expect(s_lp); - if (!is_op(s_rp)) { + if (this_type) { // a drop: fn() { ... } gets the value as this, borrowed + pn[0] = s_this; pty[0] = this_type; ptags[0] = 1; pnoffs[0] = toff; pnplaces[0] = tpack(); np = 1; + if (!is_op(s_rp)) serr("a drop takes no parameters; the value is this"); + } + if (!is_op(s_rp) && !this_type) { while (1) { pty[np] = tword; if (expected && expected[0] == TY_FN && np < expected[3]) pty[np] = expected[7 + np]; @@ -1022,6 +1033,7 @@ int primary() { // wins over a global (a top-level function or constant) of the same name if (mstruct && !ginst) { ms = lookup(methname(mstruct, n)); if (ms >= 0 && (skind[ms] == K_CONST || (skind[ms] == K_FN && (sflag[ms] & F_STATIC)))) s = ms; } if (s < 0 && rt.eq_w(n, "new")) s = source_lookup(s_new); + if (s < 0 && (rt.eq_w(n, "heap") || rt.eq_w(n, "drop"))) { peek(); if (pk_tk == T_OP && pk_tv == s_lp) { if (rt.eq_w(n, "heap")) return heap_box(); return drop_call(); } } if (s >= 0) rs_put(toff, RS_SYM, rs_sym(s), s); if (s >= 0 && skind[s] == K_KW) { if (!(sflag[s] & F_EXPR)) dien(n, "can't be used inside an expression"); @@ -1185,6 +1197,8 @@ word field(int c) { } fieldpos = tpack(); n = name(); fieldoff = nameoff; st = xty; + if (rt.eq_w(n, "drop") && (is_op(s_lp) || is_op(s_eq)) && (isptr(st) || st == tword) && !(isptr(st) && st[4][0] == TY_STRUCT && tfindfield(st[4], n) >= 0)) return drop_member(c, borrowed); + if (rt.eq_w(n, "send") && is_op(s_lp) && isptr(st) && st[4][0] == TY_STRUCT && tfindfield(st[4], intern("__mailbox")) >= 0) send_check = 1; if (st[0] == TY_ARR || st[0] == TY_DYN) { if (pmatch) probe_member(st); if (is_op(s_lp) && array_method(n)) { xaf = borrowed; return array_member(c, n); } diff --git a/src/compiler/stmt.zc b/src/compiler/stmt.zc index 0182728..6d5abe7 100644 --- a/src/compiler/stmt.zc +++ b/src/compiler/stmt.zc @@ -30,7 +30,7 @@ int stmt_inner() { if (tv == s_semi) { next(); return 0; } if (tv == s_lb) { block(); return 0; } } - c = expr(); rval(); op(O_DROP); + c = expr(); rval(); if (c == C_FRESH && drop_tail == here) c = plainuse(c); op(O_DROP); expect(s_semi); discard(c); return 0; @@ -145,7 +145,7 @@ int localdecl(int typed) { if (t && t[0] == TY_DYN && is_op(s_lb)) c = array_dynamic_literal(t); else c = expr(); rval(); nomagic = 0; kc = xconst && !xfloat; if (kc) ltag[i] = ltag[i] | LCONST; - if (c == C_FRESH) { lnsz[i] = newsz; lleaf[i] = newleaf; } + if (c == C_FRESH) { lnsz[i] = newsz; lleaf[i] = newleaf; if (drop_tail == here) local_dropset[i] = 1; } if (t) fcoerce(t); vaddr(i); op(O_ST); @@ -683,6 +683,7 @@ int kw_return() { borrow_cleanup(); defer_run(1, 0); // the value is on the stack; the deferred code is stack neutral exit_vars(1, 0); + if (o3blk) { callname(intern("rt.o3_leave")); op(O_DROP); } // a return leaves the o3 block too epichain = fixnew(here, epichain); lit(rt.MW - 1); op(O_JMP); live = 0; @@ -699,7 +700,9 @@ int kw_o3() { next(); if (!is_op(s_lb)) { o3one = 1; localdecl(1); o3one = 0; return 0; } if (o3on || !infn) return block(); + o3_enter_code(); reserve_code(2); lo = here + 2; lit(lo); op(O_JMP); + o3blk++; o3on = 1; o3reset(); save = nloc; outer = nloc; i = lbase; @@ -713,7 +716,7 @@ int kw_o3() { i++; } block(); - o3on = 0; + o3on = 0; o3blk--; if (no3pend >= o3pendcap) { o3pend = chk_resize(o3pend, no3pend * 67, o3pendcap * 2 * 67); o3pendcap = o3pendcap * 2; } { p = o3pend + no3pend * 67; p[0] = lo; p[1] = here; p[2] = no3sl; @@ -733,6 +736,7 @@ int kw_o3() { } i++; } + callname(intern("rt.o3_leave")); op(O_DROP); return 0; } // unchecked turns the analyzer off for one statement diff --git a/src/runtime/rt.zc b/src/runtime/rt.zc index ad18f85..afff233 100644 --- a/src/runtime/rt.zc +++ b/src/runtime/rt.zc @@ -424,6 +424,11 @@ int zshl(int a, int b) { return a * pow2[b & 63]; } const int HDR = 5; // p[-4] bit, orthogonal to live / mark / releasing. const int F_SHARE = 8; +// p[-4] bits for drops (docs/design/destructors.md): the value has a drop in the side +// table (dtab), its drop has run (it never runs again), and the collector found it +// unreachable and runs its drop once the sweep is over. +const int F_HASDROP = 16, F_DROPPED = 32, F_FINAL = 64; +int o3_depth, gc_pending; // flags live in these bits; the size class is above them. const int F_LOW = 65535; int hbump; word[32] freel; int gc_runs, gc_freed, gc_auto = 1; @@ -822,7 +827,9 @@ int typeof_obj(word p) { int paylen(word p) { return p[-4] >> 32; } // frees p and, recursively, every object p owns. the block stays quarantined. int release(word p) { - word b = p - HDR; int n = paylen(p), i = 0; word w; + word b = p - HDR; int n, i = 0; word w; + if (b[1] & F_HASDROP) dropnow(p); + n = paylen(p); b[1] = (b[1] & ~7) | 2; // going: not live, and not free yet either, so a child's buddy check can't merge into it while (i < n) { i = i + scan_ptr(p + i, n - i); @@ -830,6 +837,7 @@ int release(word p) { w = p[i]; if (isobj(w) && !(w[-4] & F_SHARE) && w[-2] == p) release(w); i++; } + if (b[1] & F_HASDROP) dclear(p); b[1] = b[1] & 2097144; // quarantined (flags but the state, and the class; n is cleared, so len is 0 and indexing traps). the check word and the bytes stay; the next sweep reclaims it return 0; } @@ -863,6 +871,7 @@ unchecked int recycle(.d p) { } if (!isobj(p) || (p[-4] & F_SHARE)) return 0; b = p - HDR; + if (b[1] & F_HASDROP) dropnow(p); n = paylen(p); b[1] = (b[1] & ~7) | 2; while (i < n) { @@ -871,6 +880,7 @@ unchecked int recycle(.d p) { w = p[i]; if (isobj(w) && !(w[-4] & F_SHARE) && w[-2] == p) recycle(w); i++; } + if (b[1] & F_HASDROP) dclear(p); if (dbg & 2) fill_w(p, 57005, n); bdrop(b); return 0; @@ -892,6 +902,109 @@ unchecked word recycle_leaf(.d p, int t, int k) { freel[k] = b; return 0; } +// ---- drops ---- + +// a value's drop lives in dtab, an open hash keyed by the payload address: +// three words an entry, [key, value drop, type drop]; key 0 is empty, 1 a removed +// entry. the block carries F_HASDROP so the free paths look in the table only then. +// dtab is F_STACK, so the collector keeps it without scanning it: a key in the +// table doesn't keep its value alive. gc marks the drops of live values itself. +word dtab; int dt_cap, dcount, dfill; +int dhash(word p) { return ((p >> 3) ^ (p >> 11)) & (dt_cap - 1); } +// the index of p's entry, or -1 +int dfind(word p) { + int i; + if (!dt_cap) return 0 - 1; + i = dhash(p); + while (dtab[i * 3]) { + if (dtab[i * 3] == p) return i; + i = (i + 1) & (dt_cap - 1); + } + return 0 - 1; +} +// make room for one more entry: rehash into a table twice the size when 3/4 full +int dgrowtab() { + word old = dtab; int oc = dt_cap, i = 0, j, auto = gc_auto; + if ((dfill + 1) * 4 < dt_cap * 3) return 0; + dt_cap = 16; while (dt_cap * 3 <= (dcount + 1) * 8) dt_cap = dt_cap * 2; + gc_auto = 0; dtab = new_oz(dt_cap * 3); gc_auto = auto; + dtab[-4] = dtab[-4] | F_INTERNAL | F_STACK; + dfill = 0; + while (i < oc) { + if (old[i * 3] > 1) { + j = dhash(old[i * 3]); + while (dtab[j * 3]) j = (j + 1) & (dt_cap - 1); + dtab[j * 3] = old[i * 3]; dtab[j * 3 + 1] = old[i * 3 + 1]; dtab[j * 3 + 2] = old[i * 3 + 2]; + dfill++; + } + i++; + } + if (old) recycle(old); + return 0; +} +// the entry for p, made when it has none +int dentry(word p) { + int i = dfind(p); + if (i >= 0) return i; + dgrowtab(); + i = dhash(p); + while (dtab[i * 3] > 1) i = (i + 1) & (dt_cap - 1); + if (!dtab[i * 3]) dfill++; + dtab[i * 3] = p; dtab[i * 3 + 1] = 0; dtab[i * 3 + 2] = 0; + dcount++; + p[-4] = p[-4] | F_HASDROP; + return i; +} +// set the drop of value p to f, and return p. a value has one drop of its own; +// checked code proves that statically, this traps in unchecked code. +word set_drop(word p, word f) { + int i; + if (!isobj(p)) { put_s("zy: drop needs a heap value\n"); trace_at(1); trap(); return p; } + i = dentry(p); + if (dtab[i * 3 + 1] || (p[-4] & F_DROPPED)) { put_s("zy: drop is already set\n"); trace_at(1); trap(); return p; } + dtab[i * 3 + 1] = f + 1; // stored plus one: a function can sit at address 0 + return p; +} +// a value of a type whose struct declares drop: the compiler adds the type's drop at new +word set_tdrop(word p, word f) { + int i; + if (!isobj(p)) return p; + i = dentry(p); + dtab[i * 3 + 2] = f + 1; + return p; +} +// run p's drops, once: the value's own, then its type's. p is still live, so the drop +// sees its fields and children. the entry stays until the block is freed. +int dropnow(word p) { + int i; word f, g; + if (p[-4] & F_DROPPED) return 0; + if (o3_depth) { put_s("zy: a drop can't run inside an o3 region\n"); trace_at(1); trap(); return 0; } + p[-4] = p[-4] | F_DROPPED; + i = dfind(p); + if (i < 0) return 0; + f = dtab[i * 3 + 1]; g = dtab[i * 3 + 2]; + if (f) { f = f - 1; f(p); } + if (g) { g = g - 1; g(p); } + return 0; +} +// p's block is going back: forget its entry and bits, so a reused address has no drop +int dclear(word p) { + int i = dfind(p); + if (i >= 0) { dtab[i * 3] = 1; dtab[i * 3 + 1] = 0; dtab[i * 3 + 2] = 0; dcount--; } + p[-4] = p[-4] & ~(F_HASDROP | F_DROPPED | F_FINAL); + return 0; +} +// a resize copied old into new: the entry moves to new, and freeing old drops nothing +int dmove(word old, word nw) { + int i = dfind(old), j; word f, g; int bits = old[-4] & (F_DROPPED); + if (i < 0) { old[-4] = old[-4] & ~(F_HASDROP | F_DROPPED); return 0; } + f = dtab[i * 3 + 1]; g = dtab[i * 3 + 2]; + dtab[i * 3] = 1; dtab[i * 3 + 1] = 0; dtab[i * 3 + 2] = 0; dcount--; + old[-4] = old[-4] & ~(F_HASDROP | F_DROPPED); + j = dentry(nw); dtab[j * 3 + 1] = f; dtab[j * 3 + 2] = g; + nw[-4] = nw[-4] | bits; + return 0; +} // make p the owner of c, so that freeing p also frees c. it returns c, so the call // composes: h[0] = own(b, h). a shared object has no owner and is left alone. word own(word c, int p) { @@ -1002,6 +1115,7 @@ unchecked int dgrow(word h, int need) { i++; } h[2] = own(it, h); h[1] = c; + if (old[-4] & F_HASDROP) dmove(old, it); free(old); return 0; } @@ -1284,12 +1398,79 @@ int gc_frames() { while (p) { mark(p); markrange(p[0], p[2]); p = p[3]; } return 0; } +// heap(v): a fresh block holding a copy of v. a word, typed t +word .u heap_w(int v, int t) { word p = new_t(1, t); p[0] = v; return p; } +// n words copied from src (a struct or array value), typed t +unchecked word .u heap_copy(word src, int n, int t) { word p = new_t(n, t); copy_w(p, src, n); return p; } +// a zero-terminated character string +unchecked char* .u heap_s(char* s) { int n = len_w(s) + 1; char* p = new(n); copy_w(p, s, n); return p; } +// o3 regions never collect: an allocation that would collect grows the heap instead and +// leaves gc_pending, and leaving the outermost region runs that one collection +int o3_enter() { o3_depth++; return 0; } +int o3_leave() { + if (o3_depth > 0) o3_depth--; + if (!o3_depth && gc_pending) { gc_pending = 0; gc(); } + return 0; +} +// the collector's part, between mark and sweep. the drops of live values are kept; +// an unreachable value with a drop that hasn't run is kept for one more cycle and +// flagged F_FINAL, unless another such value reaches it (its parent drops first and +// it waits for a later collection). nothing runs user code here. +int gc_drops() { + int i, again = 1; word k; + while (again) { + i = 0; + while (i < dt_cap) { + k = dtab[i * 3]; + if (k > 1 && (k[-4] & 7) == 3) { mark(dtab[i * 3 + 1] - 1); mark(dtab[i * 3 + 2] - 1); } + i++; + } + again = ms_top; drain(); + } + // candidates: unmarked live values whose drop is still to run. what they reach is + // marked, so a candidate inside another candidate isn't finalized this time + i = 0; + while (i < dt_cap) { + k = dtab[i * 3]; + if (k > 1 && (k[-4] & 7) == 1 && !(k[-4] & F_DROPPED)) { + k[-4] = k[-4] | F_FINAL; + markrange(k, k + k[-5] - HDR); mark(dtab[i * 3 + 1] - 1); mark(dtab[i * 3 + 2] - 1); + drain(); + } + i++; + } + i = 0; + while (i < dt_cap) { + k = dtab[i * 3]; + if (k > 1 && (k[-4] & F_FINAL)) { + if ((k[-4] & 7) == 1) mark(k); else k[-4] = k[-4] & ~F_FINAL; // reached from another candidate: later + } + i++; + } + drain(); + return 0; +} +// after the sweep: run the drops the collector found. a drop may store its value +// somewhere reachable (it lives on, and its drop never runs again); otherwise a +// later collection frees it. +int gc_finalize() { + int i = 0, found = 1; word k; + while (found) { + found = 0; i = 0; + while (i < dt_cap) { + k = dtab[i * 3]; + if (k > 1 && (k[-4] & F_FINAL)) { k[-4] = k[-4] & ~F_FINAL; dropnow(k); found = 1; } + i++; + } + } + return 0; +} // gc_roots is defined in the compiler half (it identifies the compiler's // own tables live); it calls markrange over every root range int run.gc_roots(); // runs a collection: the blocks nothing points at are reclaimed, quarantined blocks // included, and the number of blocks freed is returned. cycles are reclaimed too. -unchecked int gc() { +unchecked int gc_sweep() { word b = 0; int sz = 0, n = 0; // a leftover pointer here would be a root and pin a block ms_top = 0; ms_over = 0; gc_active = 1; run.gc_roots(); @@ -1303,10 +1484,11 @@ unchecked int gc() { drain(); } } + if (dcount) gc_drops(); b = HEAP_LO; while (b < hstop) { sz = b[0]; - if ((b[1] & 7) == 1) { bfree(b); n++; b = fend; } // unmarked live object, shared or not + if ((b[1] & 7) == 1) { if (b[1] & F_HASDROP) dclear(b + HDR); bfree(b); n++; b = fend; } // unmarked live object, shared or not else if ((b[1] & 7) == 3) { b[1] = (b[1] & ~7) | 1; b = b + sz; } else if ((b[1] & 7) == 4) { b[1] = b[1] & ~7; b = b + sz; } // still pointed at: stays quarantined else if (!(b[1] & 7) && b[2] == ((b + HDR) ^ MAGIC)) { bfree(b); n++; b = fend; } @@ -1317,6 +1499,14 @@ unchecked int gc() { gc_freed = gc_freed + n; return n; } +// a collection, then the drops it found, outside the collector's frame +unchecked int gc() { + int n; + if (o3_depth) { gc_pending = 1; return 0; } + n = gc_sweep(); + if (dcount) gc_finalize(); + return n; +} // the number of live heap objects, for tests and leak checks. int live_objects() { word b = HEAP_LO; int n = 0; diff --git a/tests/actor/reject/drop_message.zc b/tests/actor/reject/drop_message.zc new file mode 100644 index 0000000..fb37d5f --- /dev/null +++ b/tests/actor/reject/drop_message.zc @@ -0,0 +1,13 @@ +// destructors 7: copying a value with a drop into an actor message is an error; two processes would drop it twice +namespace; +include rt, actor; +struct res { int fd; void drop() { rt.put_s("closed"); } } +actor Keeper { int total; int handle(res* r) { return r.fd; } } +fn main() { + Keeper* k = rt.new(Keeper); + res* r = rt.new(res); r.fd = 3; + k.spawn(); + k.send(r); + k.close(); + return 0; +} diff --git a/tests/actor/reject/drop_value.zc b/tests/actor/reject/drop_value.zc new file mode 100644 index 0000000..10e941d --- /dev/null +++ b/tests/actor/reject/drop_value.zc @@ -0,0 +1,12 @@ +// destructors 7: a value whose own drop is set can't be copied into an actor message either +namespace; +include rt, actor; +actor Echo { int handle(int* p) { return *p; } } +fn main() { + Echo* e = rt.new(Echo); + int* p = rt.new(int); p.drop = fn() { rt.put_s("dropped"); }; + e.spawn(); + e.send(p); + e.close(); + return 0; +} diff --git a/tests/conformance/6-ownership/drop_ends_in_o3_rejected.zc b/tests/conformance/6-ownership/drop_ends_in_o3_rejected.zc new file mode 100644 index 0000000..4f29537 --- /dev/null +++ b/tests/conformance/6-ownership/drop_ends_in_o3_rejected.zc @@ -0,0 +1,9 @@ +// expect error: r: has drop and can't end inside an o3 region +// destructors, o3: a droppable value's lifetime can't end inside an o3 scope +include rt; +struct res { int fd; void drop() { rt.put_s("closed"); } } +fn main() { + o3 { res* r = rt.new(res); r.fd = 1; } + return 0; +} +// zc0: skip diff --git a/tests/conformance/6-ownership/drop_forms.zc b/tests/conformance/6-ownership/drop_forms.zc new file mode 100644 index 0000000..e9e4c07 --- /dev/null +++ b/tests/conformance/6-ownership/drop_forms.zc @@ -0,0 +1,14 @@ +// expect: 15i'm free as a bird! 15/body y7 x g +// destructors 2: drop(v, f) and v.drop(f) return v, v.drop = f; is a statement; this is the value. the spec example drops its temporary at the end of the statement +include rt; +fn main() { + rt.put_n(*heap(15).drop(fn() { rt.put_s("i'm free as a bird! "); rt.put_n(*this); })); + rt.put_c(10); + let g = drop(heap(1), fn() { rt.put_s("g "); }); + let x = rt.new(int).drop(fn() { rt.put_s("x "); }); + let y = rt.new(int); y.drop = fn() { rt.put_s("y"); rt.put_n(*this); rt.put_s(" "); }; + *y = 7; + rt.put_s("body "); + return 0; +} +// zc0: skip diff --git a/tests/conformance/6-ownership/drop_free_in_o3_rejected.zc b/tests/conformance/6-ownership/drop_free_in_o3_rejected.zc new file mode 100644 index 0000000..b318d1f --- /dev/null +++ b/tests/conformance/6-ownership/drop_free_in_o3_rejected.zc @@ -0,0 +1,10 @@ +// expect error: x: has drop and can't end inside an o3 region +// destructors, o3: an explicit free of a droppable value inside o3 is an error +include rt; +fn main() { + int* x = rt.new(int); + x.drop = fn() { rt.put_s("a"); }; + o3 { rt.free(x); } + return 0; +} +// zc0: skip diff --git a/tests/conformance/6-ownership/drop_gc_once.zc b/tests/conformance/6-ownership/drop_gc_once.zc new file mode 100644 index 0000000..abe1d95 --- /dev/null +++ b/tests/conformance/6-ownership/drop_gc_once.zc @@ -0,0 +1,17 @@ +// expect: 0 drop 7 1 1 1 0/ +// destructors 6: the collector runs an unreachable value's drop after the sweep, exactly once; later collections never run it again +include rt; +int n; +word g; +fn make() { g = drop(heap(7), fn() { n++; rt.put_s("drop "); rt.put_n(*this); rt.put_c(32); }); return 0; } +fn main() { + make(); + rt.gc(); rt.put_n(n); rt.put_c(32); + g = 0; + rt.gc(); rt.put_n(n); rt.put_c(32); + rt.gc(); rt.put_n(n); rt.put_c(32); + rt.gc(); rt.put_n(n); rt.put_c(32); + rt.put_n(rt.dcount); rt.put_c(10); // its entry is gone with the block + return 0; +} +// zc0: skip diff --git a/tests/conformance/6-ownership/drop_gc_resurrect.zc b/tests/conformance/6-ownership/drop_gc_resurrect.zc new file mode 100644 index 0000000..d964eba --- /dev/null +++ b/tests/conformance/6-ownership/drop_gc_resurrect.zc @@ -0,0 +1,21 @@ +// expect: drop 8 alive 8 1 gone 1/ +// destructors 6: a drop that stores this somewhere reachable revives the value; it stays usable, its drop never runs again, and a later collection reclaims it +include rt; +int n; +word g, saved; +unchecked int keep(word p) { saved = p; return 0; } +fn make() { g = drop(heap(8), fn() { n++; keep(this); rt.put_s("drop "); rt.put_n(*this); rt.put_c(32); }); return 0; } +fn main() { + make(); + g = 0; + rt.gc(); + rt.gc(); + unchecked { word p = saved; rt.put_s("alive "); rt.put_n(p[0]); rt.put_c(32); } + rt.put_n(n); rt.put_c(32); + saved = 0; + rt.gc(); rt.gc(); + rt.put_s("gone "); rt.put_n(n); + rt.put_c(10); + return 0; +} +// zc0: skip diff --git a/tests/conformance/6-ownership/drop_heap_values.zc b/tests/conformance/6-ownership/drop_heap_values.zc new file mode 100644 index 0000000..63d07c9 --- /dev/null +++ b/tests/conformance/6-ownership/drop_heap_values.zc @@ -0,0 +1,15 @@ +// expect: 10 hi 2 4 9 / +// destructors 1: heap(v) boxes an int, a string and a struct into a fresh owned block sized to the value +include rt; +struct foo { int bar; int baz; } +fn main() { + int* a = heap(10); + char* s = heap("hi"); + foo v; v.bar = 4; v.baz = 9; + foo* f = heap(v); + rt.put_n(*a); rt.put_c(32); rt.put_s(s); rt.put_c(32); rt.put_n(rt.len_w(s)); rt.put_c(32); + rt.put_n(f.bar); rt.put_c(32); rt.put_n(f.baz); rt.put_c(32); + rt.put_c(10); + return 0; +} +// zc0: skip diff --git a/tests/conformance/6-ownership/drop_in_o3_rejected.zc b/tests/conformance/6-ownership/drop_in_o3_rejected.zc new file mode 100644 index 0000000..9095269 --- /dev/null +++ b/tests/conformance/6-ownership/drop_in_o3_rejected.zc @@ -0,0 +1,9 @@ +// expect error: a drop can't be set inside an o3 region +// destructors, o3: setting a drop inside o3 is an error +include rt; +fn main() { + int* x = rt.new(int); + o3 { x.drop = fn() { rt.put_s("a"); }; } + return 0; +} +// zc0: skip diff --git a/tests/conformance/6-ownership/drop_parent_before_child.zc b/tests/conformance/6-ownership/drop_parent_before_child.zc new file mode 100644 index 0000000..9f68c52 --- /dev/null +++ b/tests/conformance/6-ownership/drop_parent_before_child.zc @@ -0,0 +1,17 @@ +// expect: parent sees 5 child / +// destructors 3: a child owned by a field drops after its parent's drop returns, so the parent still sees it +include rt; +struct node { int* kid; } +int* seen; +fn main() { + { + node* p = rt.new(node); + p.drop = fn() { rt.put_s("parent sees "); rt.put_n(*this.kid); rt.put_c(32); }; + int* k = rt.new(int); k.drop = fn() { rt.put_s("child "); }; + *k = 5; + p.kid = k; + } + rt.put_c(10); + return 0; +} +// zc0: skip diff --git a/tests/conformance/6-ownership/drop_reused_address.zc b/tests/conformance/6-ownership/drop_reused_address.zc new file mode 100644 index 0000000..97ef21d --- /dev/null +++ b/tests/conformance/6-ownership/drop_reused_address.zc @@ -0,0 +1,22 @@ +// expect: drop 1 same 1 1 0/ +// destructors 9: a freed block's drop and its bits are cleared, so a block reused at the same address runs no old drop +include rt; +int n, first; +int addr(int* p) { return p as int; } +int clean(int* p) { unbounded { return (p[-4] & (rt.F_HASDROP | rt.F_DROPPED)) == 0; } } +// the drop is set in a helper, so main has no closure and its locals take no cells from the heap +int* .u armed() { int* p = rt.new(int); p.drop = fn() { n++; rt.put_s("drop "); }; return p; } +fn main() { + { int* p = armed(); *p = 1; first = addr(p); } + rt.put_n(n); rt.put_c(32); + { + int* q = rt.new(int); *q = 2; + if (addr(q) == first) rt.put_s("same "); else rt.put_s("moved "); + rt.put_n(clean(q)); rt.put_c(32); + } + rt.put_n(n); rt.put_c(32); + rt.put_n(rt.dcount); + rt.put_c(10); + return 0; +} +// zc0: skip diff --git a/tests/conformance/6-ownership/drop_set_late.zc b/tests/conformance/6-ownership/drop_set_late.zc new file mode 100644 index 0000000..9bd8310 --- /dev/null +++ b/tests/conformance/6-ownership/drop_set_late.zc @@ -0,0 +1,10 @@ +// expect error: x: drop set after the value was used +// destructors 2: the drop must be set before the value is first read, reassigned or passed +include rt; +fn main() { + int* x = rt.new(int); + *x = 4; + x.drop = fn() { rt.put_s("a"); }; + return 0; +} +// zc0: skip diff --git a/tests/conformance/6-ownership/drop_set_twice.zc b/tests/conformance/6-ownership/drop_set_twice.zc new file mode 100644 index 0000000..843694a --- /dev/null +++ b/tests/conformance/6-ownership/drop_set_twice.zc @@ -0,0 +1,10 @@ +// expect error: x: drop is already set +// destructors 2: a value's drop can be set only once +include rt; +fn main() { + int* x = rt.new(int); + x.drop = fn() { rt.put_s("a"); }; + x.drop = fn() { rt.put_s("b"); }; + return 0; +} +// zc0: skip diff --git a/tests/conformance/6-ownership/drop_temporary_statement.zc b/tests/conformance/6-ownership/drop_temporary_statement.zc new file mode 100644 index 0000000..fd27623 --- /dev/null +++ b/tests/conformance/6-ownership/drop_temporary_statement.zc @@ -0,0 +1,13 @@ +// expect: unlocked a b unlocked c / +// destructors 3: a value nothing holds drops at the end of its full statement; a bound guard drops at block end +include rt; +void unlock() { rt.put_s("unlocked "); } +fn main() { + heap(0).drop = fn() { unlock(); }; + rt.put_s("a "); + { let guard = drop(heap(0), fn() { unlock(); }); rt.put_s("b "); } + rt.put_s("c "); + rt.put_c(10); + return 0; +} +// zc0: skip diff --git a/tests/conformance/6-ownership/drop_value_and_type.zc b/tests/conformance/6-ownership/drop_value_and_type.zc new file mode 100644 index 0000000..2979e4f --- /dev/null +++ b/tests/conformance/6-ownership/drop_value_and_type.zc @@ -0,0 +1,11 @@ +// expect: body value type only-type / +// destructors 2: a struct body's drop is the default for its type; a value's own drop runs first, then the type's +include rt; +struct res { int fd; void drop() { if (this.fd) rt.put_s("type "); else rt.put_s("only-type "); } } +fn main() { + { res* r = rt.new(res); r.drop = fn() { rt.put_s("value "); }; r.fd = 1; rt.put_s("body "); } + { res* q = rt.new(res); q.fd = 0; } + rt.put_c(10); + return 0; +} +// zc0: skip diff --git a/tests/conformance/8-runtime/o3_no_gc_inside.zc b/tests/conformance/8-runtime/o3_no_gc_inside.zc new file mode 100644 index 0000000..532765c --- /dev/null +++ b/tests/conformance/8-runtime/o3_no_gc_inside.zc @@ -0,0 +1,39 @@ +// expect: 0 1 0 1 0 1/ +// spec 4.10.3: no collection runs inside an o3 region; an allocation that would collect grows the heap, and leaving the outermost region runs the pending collection once +include rt; +word sink; +int inside; +o3 int churn(int n) { + int i = 0; + while (i < n) { sink = rt.new(64); i++; } + rt.gc(); + return 0; +} +int early(int base) { + o3 { + int i = 0; + while (i < 20000) { sink = rt.new(64); i++; } + rt.gc(); + inside = rt.gc_runs - base; + return 0; + } + return 1; +} +fn main() { + int before = rt.gc_runs; + o3 { + int i = 0; + while (i < 20000) { sink = rt.new(64); i++; } + inside = rt.gc_runs - before; + } + rt.put_n(inside); rt.put_c(32); rt.put_n(rt.gc_runs - before); rt.put_c(32); + before = rt.gc_runs; + churn(20000); + rt.put_n(0); rt.put_c(32); rt.put_n(rt.gc_runs - before); rt.put_c(32); + before = rt.gc_runs; + early(before); + rt.put_n(inside); rt.put_c(32); rt.put_n(rt.gc_runs - before); + rt.put_c(10); + return 0; +} +// zc0: skip diff --git a/tests/data/drop_hint.zc b/tests/data/drop_hint.zc new file mode 100644 index 0000000..c690bc5 --- /dev/null +++ b/tests/data/drop_hint.zc @@ -0,0 +1,2 @@ +include rt; +fn main() { heap(0).drop = fn() { rt.put_s("u"); }; return 0; } diff --git a/tests/drivers/actor_test.py b/tests/drivers/actor_test.py index 0cfa51f..929bbf4 100644 --- a/tests/drivers/actor_test.py +++ b/tests/drivers/actor_test.py @@ -23,7 +23,7 @@ for case in sorted((root / 'tests/actor').glob('*/*.zc')): proc = subprocess.run([str(root / args.vm)], input=source, text=True, capture_output=True, cwd=root, timeout=15) elapsed = time.monotonic() - start if case.parent.name == 'reject': - required = {'no_include': "actor: isn't a type", 'input_type': 'type error:', 'output_type': 'type error:', 'explicit_this': 'this is implicit', 'nested_missing_import': 'module access requires `include rt;`', 'generic_input': 'type error:', 'generic_method_return': 'type error:'}[case.stem] + required = {'no_include': "actor: isn't a type", 'input_type': 'type error:', 'output_type': 'type error:', 'explicit_this': 'this is implicit', 'nested_missing_import': 'module access requires `include rt;`', 'generic_input': 'type error:', 'generic_method_return': 'type error:', 'drop_message': "r: has drop and can't be copied into a message", 'drop_value': "p: has drop and can't be copied into a message"}[case.stem] assert required in proc.stdout, f'{case}: expected {required!r}, got {proc.stdout!r}' else: actual = proc.stdout diff --git a/tests/test.sh b/tests/test.sh index 1be4557..bd731ed 100755 --- a/tests/test.sh +++ b/tests/test.sh @@ -578,6 +578,7 @@ s_lsp() { case "$h" in *"$w"*) chk 1 1 "lsp hint: $w";; *) chk 0 1 "lsp hint: $w";; esac done chk "$($lsp < examples/struct_closure_opt.zc | grep -c error)" "0" "lsp: methods and closures check clean" + case "$($lsp < tests/data/drop_hint.zc)" in *"value with drop is never bound; it drops at the end of this statement"*) chk 1 1 "lsp hint: a value with drop that is never bound";; *) chk 0 1 "lsp hint: a value with drop that is never bound";; esac # a file of a larger build checks on its own: its bodyless declarations are not errors in check mode for f in src/runtime/rt.zc src/runtime/run.zc src/lsp/server.zc; do chk "$($lsp < $f | grep -c ': error:')" "0" "lsp: $f checks alone"; done # the compiler is several files that only make sense together: check them as one document -- cgit v1.2.3 From 24b6dfd8e8e1ba678e79e1789583f78b3e2dda9d Mon Sep 17 00:00:00 2001 From: zinc Date: Thu, 15 Oct 2026 00:17:37 +0330 Subject: docs: destructors: the free paths clear the entry in dropfree, right after the drop and before the children --- docs/design/destructors.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) (limited to 'docs') diff --git a/docs/design/destructors.md b/docs/design/destructors.md index 777db2c..0691228 100644 --- a/docs/design/destructors.md +++ b/docs/design/destructors.md @@ -165,7 +165,7 @@ untyped `rt.new(n)` blocks take a drop like any other value, so there is no sepa - **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 a block is freed, at the same point where `release` clears the requested size n (bits 32..62 of `p[-4]`) on quarantine, and in `recycle`/`recycle_leaf`/`bfree` when the block goes back on a free list, its side-table entry is removed and `F_HASDROP` and `F_DROPPED` are cleared. this happens after the drop has run. so a reused address never inherits a drop from the block that was there before. +- **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. @@ -175,7 +175,7 @@ untyped `rt.new(n)` blocks take a drop like any other value, so there is no sepa ## 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`, `dclear`, `dmove` (used by `dgrow`); `F_HASDROP` 16, `F_DROPPED` 32 and `F_FINAL` 64 (collector found it unreachable). `release` and `recycle` run the drop first and clear the entry when the block goes back; `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. +- 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. -- cgit v1.2.3