diff --git a/README.md b/README.md index 4edd3ee..39e82bf 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ The spec states *what* the language does; the **why** lives in a parallel set of | [`stories/adt.md`](stories/adt.md) | [`spec/adt.md`](spec/adt.md) — splitting `enum` from `variant` against the hype, the shared struct body, escaping the matcher machine with case overloads and the turn to a central `match` block, matching variants rather than patterns, keeping enum data outside the members, reducing a match group to sugar for one arm per case, and building a variant by naming a case rather than calling a constructor | | [`stories/generics.md`](stories/generics.md) | [`spec/generics.md`](spec/generics.md) — the parameter model, the `<>`/`()` split, size-in-the-type, and the deferred features | | [`stories/dependencies.md`](stories/dependencies.md) | [`spec/dependencies.md`](spec/dependencies.md) — URL identity, the manifest/resolution split, prebuilt distribution, symbol-rewriting, the browsable global cache, the package-graph acyclicity rule, opt-in remapping, and why `core` became a bundled implementation package | -| [`stories/memory.md`](stories/memory.md) | [`spec/memory.md`](spec/memory.md) — the no-GC-no-lifetimes goal, the move problem and the anchor, lazy backpointer creation, the indexed heap table, the rooted-guest rules and the host/guest terminology split, the collapse to one value/reference axis with a borrowed receiver, and the shift to segmented chunked bump arenas | +| [`stories/memory.md`](stories/memory.md) | [`spec/memory.md`](spec/memory.md) — the no-GC-no-lifetimes goal, the move problem and the anchor, lazy backpointer creation, the indexed heap table, the rooted-guest rules and the host/guest terminology split, the collapse to one value/reference axis with a borrowed receiver, the shift to segmented chunked bump arenas, and the split into fixed-size and dynamic regions with anchors moved to a runtime-global recyclable pool | | [`stories/lifetimes.md`](stories/lifetimes.md) | [`spec/lifetimes.md`](spec/lifetimes.md) — lexical scope in place of a borrow checker, what may be moved, the declaration-block rule that kills flow analysis, downgrade instead of use-after-move, parameter-rooted returned guests, and why each strict rule is the minimal guard against one specific memory corruption | | [`stories/effects.md`](stories/effects.md) | [`spec/effects.md`](spec/effects.md) — inferring effects instead of annotating them, receiver-scoped `mut`, capabilities in place of ambient I/O, the four-level ladder and the Total-Pure/Pure split, what deliberately is not an effect, and mutation through a borrowed receiver | | [`stories/concurrency.md`](stories/concurrency.md) | [`spec/concurrency.md`](spec/concurrency.md) — the parallelism/concurrency split and the refusal of `async` coloring, why `spawn` marks only a call, water-tower lifetimes, signature-based safety without locks, and value-typed mutation closing the aliased-write gap | diff --git a/spec/glossary.md b/spec/glossary.md index c879ed1..995f11c 100644 --- a/spec/glossary.md +++ b/spec/glossary.md @@ -175,7 +175,7 @@ This file gives short, reusable names to concepts that appear across multiple sp - **Canonical home:** [`functions.md`](functions.md) §1 ### 3.23 anchor cell -- **Meaning:** A runtime `u32` cell holding the current segmented offset of one hosted object — the stable indirection point through which tethers resolve. It is bump-allocated when the first guest is created, in a dedicated anchor-cell region of the host's scope arena. +- **Meaning:** A runtime cell whose `u32` payload holds the current segmented offset of one hosted object — the stable indirection point through which tethers resolve. It occupies one 8-byte slot in the runtime-global anchor pool, allocated when the first guest is created and returned to the pool's free-address stack when the hosting lineage ends. Its own segmented offset is the anchor identity that a whole hosting lineage keeps, across overwrite and rehosting. - **Why this name:** The cell is the fixed point that lets a moving object remain reachable: rehosting updates the cell while existing tethers keep pointing to it. - **Canonical home:** [`memory.md`](memory.md) §4.1 @@ -185,8 +185,8 @@ This file gives short, reusable names to concepts that appear across multiple sp - **Canonical home:** [`memory.md`](memory.md) §4.2 ### 3.25 arena placement -- **Meaning:** A reference-type instance is bump-allocated in the arena of the scope that creates it, and is copied (promoted) into a parent arena only if it escapes that scope. Only dynamic size or escape changes where an instance lives. Placement is an unobservable implementation choice. -- **Why this name:** Placement is a choice among **arenas** — the per-scope bump regions — rather than between a stack and a heap; the creating scope's arena is the default, a parent arena the fallback on escape. +- **Meaning:** A scope's arena has two regions: statically sized storage — value slots, reference-type hosts, and dynamic handles — is bump-allocated inline in the fixed-size region of the scope that creates it, while a resizable backing store goes in that scope's dynamic region. An instance that escapes is **promoted**: its fixed-size bytes — the inline payload, or the handle of a dynamically-sized type — are copied into a parent arena, while a dynamic backing store transfers to the new host without being copied. Placement is an unobservable implementation choice. +- **Why this name:** Placement is a choice among **arenas** — the per-scope regions — rather than between a stack and a heap; the creating scope's arena is the default, a parent arena the fallback on escape. - **Canonical home:** [`memory.md`](memory.md) §3.5 ### 3.26 capability marker diff --git a/spec/lifetimes.md b/spec/lifetimes.md index 4eea393..122a12c 100644 --- a/spec/lifetimes.md +++ b/spec/lifetimes.md @@ -214,6 +214,30 @@ Because a floated result is kept rather than dropped, no guest dangles and no ho > **Story:** [`stories/lifetimes.md`](../stories/lifetimes.md#the-signature-is-the-whole-contract-retiring-inferred-consumption) — "The signature is the whole contract: retiring inferred consumption". +### 1.10 A move needs live guests on at most one side + +A move into an already-initialized host is rejected when **both** the source and the destination have live guests at that point. One hosting lineage keeps one anchor identity ([`memory.md`](memory.md) §4.5), and two live guest sets name two identities that the single canonical cell cannot carry forward. The compiler decides this from lexical guest liveness alone, the same way it decides guest assignment (§1.1). + +```zane +a Node() +b Node() +ra &Node = a +b = a // legal: only the source has a live guest; its anchor becomes canonical +ra:inspect() // ra reaches the value in its new home, b +``` + +```zane +c Node() +d Node() +rc &Node = c +rd &Node = d +d = c // ILLEGAL: both sides have live guests +rc:inspect() +rd:inspect() +``` + +Every permitted move stays O(1) in the number of guests, and the guests on the surviving side keep reaching the value in its new home (§1.6). + --- ## 2. Lifetime and Destruction @@ -254,6 +278,7 @@ Because scope rules (§1.1) prevent guests from outliving their hosts, the runti | Move-source | A direct host symbol (local or parameter) or a hosting verb result; not an `&`, field, container element, or other access path | | Move declaration-block restriction | A direct host symbol may only be moved in the exact lexical block where it was declared; parameters may be moved at the body top level | | Move destination scope | Destination host must be in the same or a higher lexical scope than the source host | +| Move guest liveness | A move into an initialized host is rejected when both the source and the destination have live guests | | Post-move downgrade | After a move, the source symbol downgrades to an `&` and remains readable but is no longer a move-source | | Parameter scope | A reference parameter belongs to the call-site scope, not the body, so a value passed by hosting access outlives the call | | Hosting argument | A verb takes a **guest** (`&T`, caller keeps it), **relays** the host (`T` and returns a hosting handle, caller may bind it to host again), or **consumes** it (`T`, no host returned, caller keeps a guest); passing to a plain `T` downgrades the caller to a guest whatever the body does | diff --git a/spec/memory.md b/spec/memory.md index b2623a5..5991141 100644 --- a/spec/memory.md +++ b/spec/memory.md @@ -250,6 +250,8 @@ Each lexical scope owns an **arena** made from two independent allocation region Each region is a separate chain of fixed-size **1 MiB chunks** mapped from the OS on demand. A chunk belongs to exactly one region: fixed-size slots and dynamic backing stores never coexist in the same chunk. A region maps no chunk until its first allocation. When its current chunk cannot satisfy an allocation, the runtime maps another chunk for that region, assigns it the next **chunk id**, and makes it current. +Scopes nest last-in-first-out, and their arenas nest with them: both regions of a scope are unmapped in full the moment the scope drains (§3.2, [`lifetimes.md`](lifetimes.md) §2.1). Arena granularity is an implementation choice, like boolean packing (§3.4) and placement (§3.5) — the compiler may fold several lexical scopes into one arena. What the language fixes is the observable behavior: a scope's memory is released together when that scope drains, and no guest ever resolves into released memory. A value that escapes is promoted out of the draining scope first (§3.5, §3.7), and its guests reach the promoted value through the canonical anchor (§4.5). + Anchors do not belong to any scope arena. The runtime owns one **global anchor pool**, implemented as a lazy chain of anchor-only 1 MiB pages. Every anchor occupies an **8-byte-aligned, 8-byte physical slot**: the first four bytes hold the `u32` payload offset and the remaining four bytes are reserved padding. An anchor page therefore contains 131072 addressable slots. The pool maps its first page only when the program creates its first guest and adds another page only when its current frontier and free-address stack cannot satisfy an allocation. ```text @@ -267,21 +269,22 @@ Scope chunks and global anchor pages draw ids from the same chunk directory, so ``` u32 segmented offset - ┌───────────────┬─────────────────────────┐ + ┌───────────────┬──────────────────────────┐ │ chunk id │ in-chunk word offset │ │ (high bits) │ (low bits) │ - └───────────────┴─────────────────────────┘ + └───────────────┴──────────────────────────┘ ``` -Allocations are at least 8-byte aligned, so the low bits count 8-byte words: a 1 MiB chunk holds 2¹⁷ words, so **17 low bits** address any slot in a chunk and the remaining **15 high bits** select one of up to 32768 live chunks — a reach of 32 GiB. The chunk directory maps a chunk id to the chunk's native base address. +Allocations are at least 8-byte aligned, so the low bits count 8-byte words: a 1 MiB chunk holds 2¹⁷ words, so **17 low bits** address any slot in a chunk and the remaining **15 high bits** select one of up to 32768 live chunks — a reach of 32 GiB. The chunk directory maps a chunk id to the chunk's native base address, so an address is materialized only at use, as `directory[chunk id] + word offset × 8`: splitting the `u32` is a shift and a mask, and the directory lookup is one load. -Tethers (§4.2), per-host backpointers (§4.2), anchor cells (§4.1), dynamic handles, and size-stack entries (§3.2) use segmented offsets. The value `0` is the *untethered* sentinel wherever an anchor identity is expected. The global anchor pool never assigns segmented offset `0`; fixed-size payloads may still occupy it. +Tethers (§4.2), per-host backpointers (§4.2), anchor cells (§4.1), dynamic handles, and size-stack entries (§3.2) use segmented offsets. The value `0` is the *untethered* sentinel wherever an anchor identity is expected. The global anchor pool never issues `0` as an anchor identity: if its first page is assigned chunk id `0`, that page's first slot is left permanently unused. Payloads carry no such restriction and may occupy segmented offset `0`, so a region's first allocation sits at a chunk base. > **Story:** [`stories/memory.md`](../stories/memory.md#the-last-table-problem-and-the-segmented-offset) — "The last table problem, and the segmented offset". +> **Story:** [`stories/memory.md`](../stories/memory.md#two-payload-streams-and-the-anchor-that-leaves-the-scope) — "Two payload streams, and the anchor that leaves the scope". ### 3.2 Allocation, reuse, and teardown -The fixed-size region is a pure bump allocator. A host is a fixed-size storage slot, so overwriting it destroys the current occupant and initializes the replacement directly in the same slot (§2.2, §3.7); it consumes no new arena space. +The fixed-size region is a pure bump allocator: no size classes, no free list, no coalescing. A host is a fixed-size storage slot, so overwriting it destroys the current occupant and initializes the replacement directly in the same slot (§2.2, §3.7); it consumes no new arena space. Nothing in this region is reclaimed individually — a slot whose occupant dies before the scope drains stays dead space until teardown. The dynamic region adds exact-size reuse on top of its bump frontier. Dynamic blocks use power-of-two byte sizes beginning at **128 bytes**. Each scope maintains one LIFO **size stack** for every block size that has become reusable. To allocate a dynamic block of size `S`, the runtime first pops `size_stack[S]`; only when that stack is empty does it bump the dynamic frontier. It never satisfies a request from another size stack and never coalesces neighbouring blocks. @@ -289,9 +292,10 @@ Returning a dynamic block pushes its base segmented offset onto the stack for th The global anchor pool has one LIFO **free-address stack**, because every anchor slot has the same size. Creating an anchor pops that stack first; only when it is empty does allocation bump the global anchor frontier, mapping another anchor page as needed. Returning an anchor pushes its segmented offset onto the same stack. Anchor pages remain mapped and retain their chunk-directory entries until runtime shutdown, including when every slot on a page is free; consequently every offset retained by the stack always resolves to its original anchor slot and anchor chunk ids are never repurposed during the run. -When a scope drains — after all its spawned work completes ([`concurrency.md`](concurrency.md) §4.1) — the runtime unmaps its fixed-size and dynamic chunks in bulk. Global anchor pages are not tied to scope teardown: individual slots are returned when their hosting lineages end (§4.6), while the pages themselves remain mapped until runtime shutdown. +When a scope drains — after all its spawned work completes ([`concurrency.md`](concurrency.md) §4.1) — the runtime unmaps its fixed-size and dynamic chunks in bulk, with no per-object teardown pass threaded through the exit. Logical destruction timing is independent of this: a value dies when its host, container, or scope does ([`lifetimes.md`](lifetimes.md) §2.1); it is the *memory* that is reclaimed together at drain. Global anchor pages are not tied to scope teardown: individual slots are returned when their hosting lineages end (§4.6), while the pages themselves remain mapped until runtime shutdown. > **Story:** [`stories/memory.md`](../stories/memory.md#when-the-free-stacks-fragment-and-the-arena-takes-the-scope) — "When the free stacks fragment, and the arena takes the scope". +> **Story:** [`stories/memory.md`](../stories/memory.md#two-payload-streams-and-the-anchor-that-leaves-the-scope) — "Two payload streams, and the anchor that leaves the scope". ### 3.3 Value and reference layout follow declaration order @@ -340,7 +344,8 @@ A block never grows in place across a chunk boundary, and an oversized span is n Dynamic chunks, ordinary power-of-two blocks, and oversized spans begin at cache-line-aligned addresses. Because the minimum block is 128 bytes and every larger block doubles, frontier allocations, reused blocks, and dedicated spans preserve cache-line alignment without mixing backing stores into fixed-size chunks. -> **Story:** [`stories/memory.md`](../stories/memory.md#the-sentinel-that-costs-one-reserved-identity-and-the-buffer-that-wanted-a-line) — "The sentinel that costs one reserved identity, and the buffer that wanted a line". +> **Story:** [`stories/memory.md`](../stories/memory.md#the-sentinel-that-costs-nothing-and-the-buffer-that-wanted-a-line) — "The sentinel that costs nothing, and the buffer that wanted a line". +> **Story:** [`stories/memory.md`](../stories/memory.md#two-payload-streams-and-the-anchor-that-leaves-the-scope) — "Two payload streams, and the anchor that leaves the scope". ### 3.7 Moving a value reuses the destination slot @@ -357,11 +362,12 @@ Moves only ever target the same or a higher scope ([`lifetimes.md`](lifetimes.md ### 4.1 The global anchor pool -Tethers are tracked through **anchor cells** in one runtime-global pool rather than through scope-local anchor regions. An anchor cell has a 4-byte logical `u32` payload holding the current segmented offset (§3.1) of a hosted reference-type value, but occupies one 8-byte-aligned physical slot so every cell identity is representable by the shared 8-byte-word offset encoding. The cell's own segmented offset is the stable anchor identity for the complete hosting lineage, even when the value is rehosted across scopes. +Tethers are tracked through **anchor cells** in one runtime-global pool. An anchor cell has a 4-byte logical `u32` payload holding the current segmented offset (§3.1) of a hosted reference-type value, but occupies one 8-byte-aligned physical slot so every cell identity is representable by the shared 8-byte-word offset encoding. The cell's own segmented offset is the stable anchor identity for the complete hosting lineage, even when the value is rehosted across scopes. Anchor pages contain only equal-sized 8-byte slots. The pool therefore needs one free-address stack and one bump frontier rather than size classes. Pages are allocated lazily, never move, and remain mapped until runtime shutdown. > **Story:** [`stories/memory.md`](../stories/memory.md#where-the-cells-live-and-the-scan-that-pays-for-them) — "Where the cells live, and the scan that pays for them". +> **Story:** [`stories/memory.md`](../stories/memory.md#two-payload-streams-and-the-anchor-that-leaves-the-scope) — "Two payload streams, and the anchor that leaves the scope". ### 4.2 Tethers are segmented offsets, not pointers @@ -439,6 +445,7 @@ Every permitted operation is O(1) in the number of guests. Promotion never creat This is also how a moved-from symbol stays readable: after a permitted move the host-capable symbol enters guest state and stores the canonical tether, so reads resolve through the anchor to the value's new home (see [`lifetimes.md`](lifetimes.md) §1.6). > **Story:** [`stories/memory.md`](../stories/memory.md#the-move-problem-and-the-anchor-that-never-moves) — "The move problem, and the anchor that never moves". +> **Story:** [`stories/memory.md`](../stories/memory.md#two-payload-streams-and-the-anchor-that-leaves-the-scope) — "Two payload streams, and the anchor that leaves the scope". ### 4.6 Hosting-lifetime end returns the anchor @@ -446,6 +453,8 @@ An anchor is returned to the global free-address stack when its **hosting lineag At the actual end of the hosting lineage, lexical scope rules guarantee that every guest capable of naming the anchor has already ceased to exist ([`lifetimes.md`](lifetimes.md) §1, [`concurrency.md`](concurrency.md) §4). The runtime may therefore recycle the slot immediately. No generation counter, delayed reuse, or ABA protection is required: a stale guest is not a representable program state. +> **Story:** [`stories/memory.md`](../stories/memory.md#two-payload-streams-and-the-anchor-that-leaves-the-scope) — "Two payload streams, and the anchor that leaves the scope". + ### 4.7 Why tethers never dangle or misdirect A dangling or misdirected tether would require a guest to outlive its host, an anchor cell to move, or an anchor slot to be reused while an old guest remains. The model forbids all three. Scope checking proves the first impossible; the global pool gives each live hosting lineage one stable cell identity; and the same scope rule makes immediate slot reuse safe after teardown. @@ -509,6 +518,7 @@ A single global free stack and frontier require synchronization under concurrent | Backpointer | Each hosted payload stores the stable `u32` identity of its anchor cell for move updates and tether minting; `0` means no cell has been allocated | | Anchor lifecycle | Lazily allocated on first guest; preserved across overwrite and rehosting; returned to the global free-address stack when the hosting lineage ends | | Anchor reuse safety | Immediate reuse is safe because lexical scope rules make a live stale guest unrepresentable | +| Move guest liveness | A move is rejected when both sides have live guests; with live guests on exactly one side, that side's anchor becomes the canonical one; with none on either side, a moved-from slot that stays readable anchors lazily (see [`lifetimes.md`](lifetimes.md) §1.10) | | Tethered-instance cost | Minimum 16-byte physical footprint: one 4-byte tether, one 8-byte anchor slot, and one 4-byte backpointer | > **See also:** [`lifetimes.md`](lifetimes.md) §4 for the summary of scope, move, and destruction rules. diff --git a/stories/memory.md b/stories/memory.md index f6e8b7f..0ddc8d7 100644 --- a/stories/memory.md +++ b/stories/memory.md @@ -40,7 +40,7 @@ The same rooted-source idea runs through parameters, but with a twist that prote Two last pressures pull in opposite directions — one locks a door, the other opens one — and they are worth telling together because both are about how far the value layer can be trusted to behave. The locked door is the struct. Structs are plain inline values: copied by overwriting bytes, with no anchor and no destruction tracking. That is only sound if a struct can never smuggle in something that *needs* tracking — so a struct field may hold primitives and other structs and nothing else, checked transitively through the whole nested graph ([§2.10](https://github.com/zane-lang/spec/blob/9abc6748ebcbf3b27a011f7729b78f98f110d9f8/spec/memory.md#210-struct-downstream-enforcement-transitive-struct-field-restrictions)). Let a struct contain a class and a byte-copy would silently duplicate ownership; let it contain an `&` and a byte-copy would silently duplicate ref-tracking state without ever going through the anchor system that makes that state correct. Both break the one invariant that lets struct copies be mechanical, so the closed value world is enforced rather than hoped for — the strictness-buys-speed bargain of the [foundations story](foundations.md#strictness-is-the-performance-model) in miniature. -The opened door is placement. Because the anchor model makes a ref resolve identically no matter *where* its owner physically lives — the ref walks to a cell, and the cell can hold a stack address as easily as a heap one — the compiler is free to put a class instance wherever is cheapest, stack or heap, with no language-visible consequence ([§3.5](https://github.com/zane-lang/spec/blob/9abc6748ebcbf3b27a011f7729b78f98f110d9f8/spec/memory.md#35-class-instances-may-be-placed-on-the-stack)). It uses the stack whenever the instance is statically sized and does not escape in a way a move cannot satisfy, and is forced to the heap only by genuine dynamic size or escape. The thing that makes this freedom *broad* rather than rare is that dynamic size is kept from leaking upward: dynamically-sized reference types such as `List` and `String` are represented as fixed-size handles whose backing stores live separately, so a type containing one stays statically sized, with only the backing store requiring dynamic storage ([§3.6](../spec/memory.md#36-handle-typed-dynamic-reference-types-have-fixed-footprint)). Placement, like the boolean-packing latitude beside it, is something the language hands to the compiler precisely because it has been arranged to be unobservable — and it is unobservable only because the anchor indirection, the thing this whole story is built around, already decoupled a ref from any fixed address. The cost is the one the chapter cannot remove: this only holds for as long as the model keeps placement semantically invisible, and every feature that might let a program *observe* where a value physically sits — raw addresses, layout introspection — is a feature this freedom quietly forbids. +The opened door is placement. Because the anchor model makes a ref resolve identically no matter *where* its owner physically lives — the ref walks to a cell, and the cell can hold a stack address as easily as a heap one — the compiler is free to put a class instance wherever is cheapest, stack or heap, with no language-visible consequence ([§3.5](https://github.com/zane-lang/spec/blob/9abc6748ebcbf3b27a011f7729b78f98f110d9f8/spec/memory.md#35-class-instances-may-be-placed-on-the-stack)). It uses the stack whenever the instance is statically sized and does not escape in a way a move cannot satisfy, and is forced to the heap only by genuine dynamic size or escape. The thing that makes this freedom *broad* rather than rare is that dynamic size is kept from leaking upward: the core dynamically-sized types — `List`, `String` — are represented as fixed-size handles whose backing store lives on the heap, so a type containing one stays statically sized and stack-eligible, with only the backing store forced onto the heap ([§3.6](https://github.com/zane-lang/spec/blob/9abc6748ebcbf3b27a011f7729b78f98f110d9f8/spec/memory.md#36-handle-typed-core-classes-have-fixed-footprint)). Placement, like the boolean-packing latitude beside it, is something the language hands to the compiler precisely because it has been arranged to be unobservable — and it is unobservable only because the anchor indirection, the thing this whole story is built around, already decoupled a ref from any fixed address. The cost is the one the chapter cannot remove: this only holds for as long as the model keeps placement semantically invisible, and every feature that might let a program *observe* where a value physically sits — raw addresses, layout introspection — is a feature this freedom quietly forbids. ## The kinds collapse into one axis, and `this` becomes a borrow