# The dynamic-value runtime: NaN-boxed values over a mark-sweep heap Milestone 1 of dynamic-by-default. Unannotated code computes with values that carry their type at run time; fully annotated code compiles to exactly what it compiled to before; and a build that wants neither a tag nor a collector can be given one that has neither. This document is the runtime half — `runtime/flan_dyn.h`, `runtime/flan_dyn.c`, and the C tests in `test/dyn_ops.c` driven by `test/test_dyn.ml`. The compiler half — deciding which locals are dyn, emitting the root pushes, enforcing `--no-gc` — is a parallel lane and is built against the ABI in the header, which is fixed. Everything here is implemented and green. `dune test --force` includes `test_dyn`, which is 29 processes and 0.42 seconds; `dune build @sanitize` covers the same C under ASan and UBSan. --- ## 1. Value representation: NaN-boxing `flan_dyn` is `uint64_t`, one machine word, so a dyn local is a register or a stack slot and never a struct whose return convention two backends would have to agree about. That much was fixed by the ABI. What was open was the tagging scheme, and the two candidates are the usual pair. **Low-bit tagging** puts the tag in the bottom three bits of a word and requires every payload to be either a pointer to something aligned or an integer with three bits to spare. Its cost lands on floats: an `f64` uses all 64 of its bits, so a low-bit scheme either boxes every float on the heap or steals the mantissa's low bits and computes with a shortened double. **NaN-boxing** does the opposite. A double is *itself* — the bit pattern is returned unchanged — and everything else hides inside the quiet-NaN space, which IEEE 754 leaves as 2^52 unused encodings. This codebase decides it. `f64` is a first-class type here, not a library afterthought: `sand.flan` runs a physics loop, `calc-me.flan` is a float calculator, the prelude carries a hand-written `format-f64` because `%g` was not enough, and `flan_rt.c` has three paragraphs on how to print a NaN. A representation whose float path is the expensive one is the wrong representation for this language. Under NaN-boxing `flan_dyn_from_f64` is a `memcpy` of 8 bytes and `flan_dyn_need_f64` is the same in reverse; neither allocates, neither branches except on the NaN canonicalisation below. The layout: ``` 63 62..52 51 50..48 47..0 1 1...1 1 tag payload ^ sign ^ quiet ``` A word is *boxed* when `(v & 0xFFF8000000000000) == 0xFFF8000000000000` — sign bit set, exponent all ones, quiet bit set. Bits 50..48 are three tag bits; bits 47..0 are the payload, which is exactly the width of an x86-64 user-space pointer (48-bit canonical form, top 16 bits zero for user addresses). Anything else is a double, read straight back. Four box tags are used and four are free: | tag | meaning | payload | |-----|---------|---------| | 0 | nil | 0 | | 1 | bool | 0 or 1 | | 2 | int | 48-bit two's complement, sign-extended on read | | 3 | object | pointer to a `flan_obj` (text, vec, or a wide int) | | 4–7 | unassigned | reserved for interop; see §7 | ### The negative-NaN collision, and why canonicalising is free here Every NaN-boxing scheme has to answer for the doubles that are already negative quiet NaNs: their bits are indistinguishable from a box. `flan_dyn_from_f64` answers it by mapping *every* NaN to the one positive quiet NaN on the way in. That is not a new rule invented for this file. `flan_rt.c`'s `flan_f64_to_bytes` already renders every NaN as `nan` with no sign, and carries the argument at length: IEEE 754 does not specify the sign of a NaN any operation produces, LLVM's constant folder and `divsd` disagree about `(/ 0.0 0.0)`, and the same two-line program printed `nan` through one backend and `-nan` through the other. The runtime already decided that a NaN's sign is not a fact about the arithmetic. This takes the same line one step further and declines to *store* it. Nothing observable is lost: NaN is not equal to itself, so no comparison can see which NaN it is, and the printer was already refusing to say. `-0.0` is untouched and keeps its sign, which is tested. ### Integers: 48 bits inline, the rest on the heap `i64` is first class here and 48 bits is not 64. The two honest options were a 48-bit integer with a 64-bit name, or a box for the overflow. This takes the box. An `i64` in ±2^47 — that is ±140,737,488,355,327 — is inline: every array index, every byte count, every timestamp in milliseconds until the year 6429, every value any program in the corpus computes. Outside that range `flan_dyn_from_i64` allocates a `flan_obj` of kind `OBJ_INT` holding the full 64 bits. Both shapes report `int` from `flan_dyn_tag`, compare and print identically, and round-trip through `flan_dyn_need_i64`; nothing above the ABI can tell them apart. The cost is one range test on the constructor and one branch in `dyn_int_value`, and the benefit is that a dyn `i64` is an `i64`. `u64` has no constructor in this ABI, deliberately. Typed Flan distinguishes `u64` from `i64` — that is why `flan_u64_to_bytes` exists beside `flan_i64_to_bytes` — and a `u64` above 2^63 has no dyn spelling in milestone 1. It is an interop question (§7), not a representation one: the box already holds 64 bits and a seventh tag would distinguish the signedness. ### Why not a two-word value It would have made all of the above go away: a tag word and a payload word, no NaN games, no integer boxing. It was not available — `typedef uint64_t flan_dyn` is in the fixed ABI — and it would have been the wrong call anyway, for the reason `flan_rt.c`'s header gives about returning structs by value: the emitted `.ll` would then have to agree with the platform's struct-return convention, which works on x86-64 and silently does not on wasm32, and this project has two backends and a third target. --- ## 2. Heap objects One header, three kinds, one singly-linked list of everything allocated. ```c typedef struct flan_obj { struct flan_obj *next; /* every object ever allocated, newest first */ uint8_t kind; /* OBJ_TEXT | OBJ_VEC | OBJ_INT */ uint8_t mark; int64_t len; /* bytes of a text, elements of a vec */ union { int64_t i; /* OBJ_INT */ struct { flan_dyn *items; int64_t cap; } v; /* OBJ_VEC */ } u; /* OBJ_TEXT's bytes trail */ } flan_obj; ``` - **Text** is immutable and length-prefixed, with the bytes inline after the header — one allocation per string, and a length that is a count and not a NUL scan. `flan_dyn_from_bytes` copies, so a literal in `.rodata`, a frame slot, or a slice about to be dropped are all legitimate sources. Nothing in the ABI writes into a text; `flan_dyn_set_at` on one refuses by name rather than copying on write. - **Vec** is mutable, with its elements in a plain `malloc` block hanging off the header rather than in a GC object of their own. Growth is then a `realloc` rather than a copy this file writes, and the elements are never reachable except through their vec, so giving them an identity would buy nothing and cost a header. Their bytes are counted in the live figure and freed when the vec is swept. - **Int** is the overflow case above. `mark` is a byte in the header and not a bit in a side table. A side table is the right answer when the sweep is the cost, and the sweep is never going to be the cost here — see §3. There is no free list, no size class, and no interning. Two texts built from the same bytes are two objects, which `test_dyn` asserts directly: equality is bytewise (§5) and a test that only compared structurally would pass against an implementation that had quietly shared. --- ## 3. The collector **Mark-sweep, precise, non-moving, stop-the-world, in about 120 lines.** The author's constraint was explicit — *"the answer to 'I need more performance' will never be a faster GC, it will be to type the whole program"* — so there is no generation, no card table, no write barrier, no incremental phase and no reference counting. The design goal is that somebody can read the whole collector in one sitting and believe it. ### Trigger Collection happens inside `gc_alloc` and nowhere else. The policy is the plainest one that bounds the heap: ``` if (gc_bytes + need > gc_next) collect(); ... gc_next = gc_bytes * 2; if (gc_next < gc_floor) gc_next = gc_floor; ``` Collect when this allocation would carry the heap past a limit; afterwards set the limit to twice what survived, with a floor (1 MiB by default) so a program with a tiny live set does not collect every other allocation. That gives amortised O(1) collection work per byte allocated, and a heap bounded at roughly twice the live set plus the floor — which is the property `test_dyn`'s million-allocation case asserts, as a bound rather than as an exact figure, because pinning the high-water mark would make a tuning change a test failure. `flan_gc_set_floor` is an ABI extension and exists for the tests: a megabyte of floor would make the million-allocation case a megabyte of arithmetic before it proved anything. It recomputes the trigger from the new floor rather than only raising it to meet it — raising alone left a heap that had been given a *lower* floor still running to the old one, which is a bug this document's first draft had. A vec's element array growing is a `realloc` and not a collection point, deliberately: the value being pushed may not be rooted yet. The bytes are charged to the heap immediately so the trigger sees them at the next real allocation, but nothing is swept in the middle of a push. ### Mark An explicit worklist, not recursion. A vec of a vec of a vec is an ordinary dyn value and its depth is the program's, not the runtime's; a recursive marker would put the heap's depth on the C stack and a long enough chain would overflow it *during a collection*, which is the worst available moment. The mark stack is grown on demand and kept between collections, so a steady program stops paying for it after the first. `test_dyn`'s `nested` mode is a chain 64 deep and would notice a marker that stopped being iterative. Only a vec has anything to trace; a text and a boxed int are leaves and marking them is the whole visit. ### Sweep Walk the all-objects list, unlink and free anything unmarked, clear the mark on anything else, and subtract the freed bytes. A vec's element array is freed with its header. ### What "precise" buys The collector never guesses whether a word is a pointer, which matters more here than usual. NaN-boxing makes conservative scanning wrong *in both directions*: a live double is bit-identical to a boxed pointer often enough to retain garbage indefinitely, and a payload with the box stripped is not the pointer a scanner would recognise. Precision is cheaper than the arguments about it. --- ## 4. Roots No conservative stack scanning. The compiler emits `flan_dyn_root_push` per dyn local on entry and one `flan_dyn_root_pop(n)` for the lot on the way out — shadow-stack style, exactly as `flan_dev.c`'s frame chain works and for the same reasons its header gives: a pushed record needs no agreement with the optimiser about what a frame looks like, is the same on wasm32 as on x86-64, and needs no `.eh_frame` walk to borrow (this language lowers every non-local exit explicitly and has none). The root stack holds *addresses*, not values, so a local that is reassigned needs no second push. It grows on demand, because a deep recursion over dyn locals is an ordinary program and a fixed table would be a limit nobody could predict. Dyn globals go through the same pair, pushed once at startup and never popped. **The one contract the compiler lane must honour: a slot must hold a valid `flan_dyn` before it is pushed.** The collector reads every registered address on every mark, and an uninitialised slot is a word of stack garbage that will be decoded. Storing `flan_dyn_nil()` at the declaration is the whole of it. `flan_dyn_root_pop` clamps at empty rather than refusing. A pop that outruns its pushes means the frame machinery is already out of step, and the useful thing at that point is a heap that still collects. `flan_dyn_root_reset` is an ABI extension with one caller: the merged dev build's `main`, which is re-entered by `longjmp` and therefore pops no frame, so every root the finished run pushed still points into stack the next run is about to write over. It is the exact counterpart of `flan_dev_frames_reset` and `flan_condition_stacks_reset`, which exist for the same `longjmp`. ### The temporaries ring, and the hazard it closes "Collection triggers only inside `flan_gc_alloc`, so between pushes nothing moves" protects against *relocation*. It does not protect against *freeing*, and mark-sweep frees whatever is unreachable — which includes an object allocated a moment ago and not yet stored anywhere the collector looks. Concretely: ```c flan_dyn_push(v, flan_dyn_add(flan_dyn_from_bytes(p, n), other)); ``` Two allocating calls in one expression. C leaves argument evaluation order unspecified, so this is not even a window a careful emitter could close by ordering its calls. The answer is the smallest one that does not depend on another agent having read this document: **every object `flan_dyn.c` allocates is written into a fixed 64-slot ring, and the marker roots the whole ring unconditionally.** Any expression making at most 64 allocations before rooting its result is safe. The cost is one store and a masked increment per allocation, plus up to 64 objects of float in the heap, which the trigger absorbs because the trigger is a fraction of live bytes and not a count. There is no ABI change and no contract for the compiler lane to get wrong. The visible consequence, and the only one: after a full collection up to 64 unreachable objects are still alive. `test_dyn`'s `unrooted` mode asserts exactly that — "reclaimed all but the ring" — rather than pretending the figure is zero. An expression with 65 allocating calls and no intervening root would be a single Flan form with 65 constructors in it. If that ever exists, the compiler will have rooted its intermediates and the ring is belt on top of braces. --- ## 5. The operations, as implemented ### Arithmetic — `+ - * / %` Two ints answer an int; a float anywhere answers a float; anything non-numeric traps. **The promotion is the one place dyn is more permissive than the typed language, and it is deliberate.** Typed Flan has no implicit widening anywhere — `(print-i64 x)` used to force an explicit `(i64 x)` at every site, and the prelude's history is full of that. But `(+ 1 2.5)` has exactly one sensible answer, and a dynamic language that refuses it is not dynamic in any useful sense. A program that wants the refusal annotates, which is the whole bargain of dynamic-by-default. Integer division and remainder by zero trap, matching typed Flan, which signals `ArithError` and dies with "divide by zero" if nothing handles it. `INT64_MIN / -1` gets its own sentence for the reason `flan_rt.c` gives it one. The float cases are left to IEEE: `1.0/0.0` is `inf` and that is an answer. Float `%` is computed by truncated division rather than by calling `fmod`, which would pull `math.h` into this translation unit for one operator; the quotient is range-checked first, because casting a double past 2^63 to an integer is undefined rather than merely wrong. ### Ordering — `< <= > >=` Numbers against numbers (promoting across the two tags), text against text bytewise, and nothing else. Bytewise means `memcmp` with the shorter operand first on a tie — the order a sort of `(Vec string)` wants, and the only one needing no locale and no collation table. A number against a text traps. The temptation is to order by tag so that everything is comparable and sorting never fails; the reason not to is that the resulting order is an artefact of this file's tag numbering, and a program sorting a mixed vec would get a stable answer that means nothing. NaN is unordered in all four directions, which is what IEEE says and what `test_dyn` asserts. ### Equality — `=` Structural, and **the only operation here that never traps**: two values of unrelated tags are unequal, which is an answer. Making it an error would mean a dyn program could not ask "is this the string I expected" without first checking that it is a string at all. - Numbers compare by value across the two tags: `(= 1 1.0)` is true. - Text is **bytewise, not by identity**. `flan_dyn_from_bytes` copies and does not intern, so two texts built from the same bytes are two objects; the only defensible equality for an immutable byte string is its bytes. Embedded NULs are compared like any other byte — the length is a count, not a `strlen`. - A vec is equal element by element, with an identity shortcut first. - NaN is not equal to itself. Cycles: `flan_dyn_set_at` lets a vec contain itself, so the recursion is capped at 64. Past the cap two vecs are equal only if they are the same vec, which terminates and answers correctly for the case that actually arises — a cycle compared against itself. Two *distinct* cyclic vecs of the same shape answer false, which is a wrong answer to a question nobody has asked yet; the honest fix is a visited set and it can be added the day somebody needs it. ### `len`, `at`, `set-at`, `push` `len` is bytes of a text or elements of a vec. `at` is an element of a vec or a byte of a text as an int — which is what `(at s i)` on a `(Slice u8)` does in the typed language; codepoints are `utf8`'s job and stay there. `set-at` and `push` are vec-only, and a `set-at` on a text says *"a text is immutable — build another one"* rather than quietly copying. An index that is not an int is a different sentence from a container that is not indexable, because `(at v "1")` and `(at 3 1)` are different mistakes and one message naming both would name neither. ### `print` Structural, and per tag it renders what typed `print` renders. The reference forms were **captured from a running program**, not read off `lib/render.ml` — that file is the REPL's inspector and not necessarily `println`'s expansion: | tag | rendering | example | |-----|-----------|---------| | int | `%lld` | `42` | | float | `%g`, NaN unsigned | `3.5`, `1`, `nan` | | bool | the word | `true` | | text | bare at the top level | `hi` | | text | quoted and escaped inside a structure | `"a b"`, `"q\"\n"` | | vec | a slice's spelling | `[ 1 2 3]` | | nil | — | `nil` | The leading space before every element is what `lib/render.ml`'s slice loop emits and what a Flan program prints today; a tidier answer would diverge from an acceptance test. The escape table is the third copy of the one in `flan_rt.c`'s `flan_escape_bytes` and `flan_dev.c`'s `flan_dev_emit_str` — those two are already kept identical because the REPL parses the printed form back, and this one joins them. If that table changes, change all three. **A typed `Vec` prints as `` and a dyn vec does not.** That looks like a mismatch and is not. The typed printer's refusal is about walking storage it does not own — `render.ml` says so, and directs you to `(print (as-slice v))`, which borrows explicitly at the call site. A dyn vec's storage belongs to the collector, the printer is inside the runtime that owns it, and there is nobody to ask permission from. So a dyn vec prints the way a *slice* does, which is also the only rendering that carries any information. `print` has a depth cap of 16. A typed value cannot contain itself, so the typed printer needs no run-time cap; `set-at` makes a dyn vec that can, so this one has one, and past it prints `...`, which is the mark `render.ml` uses for the same idea. --- ## 6. The typed boundary `flan_dyn_need_i64`, `_need_f64`, `_need_bool` are what an annotated parameter does with a dyn argument and what a dyn expression does where the checker wants a machine value. **There is no widening and no coercion at the boundary**: `flan_dyn_need_f64` of an int traps. That is the asymmetry worth defending, because the operators *do* promote (§5). The two are different questions. An operator has no stated expectation to violate — `(+ 1 2.5)` was written by somebody who wanted a number and there is one obvious number. An annotation *is* a stated expectation, written down by a person, and quietly turning their `i64` into an `f64` would make the boundary the one place in the language where a *value* changed type without anybody writing it. Typed Flan does not widen a value: ```lisp (defn g [x f64] f64 x) (defn h [y i64] f64 (g y)) ; scratch.flan:2:24: expected f64, found i64 ``` ### The one divergence the compiler lane has to know about Typed Flan does not widen a *value*, but a **literal adopts its expected type**. `(g 1)` compiles and prints `1`, because the checker gives the literal `1` the `f64` the parameter asks for; `(vec-new i64)` followed by `(push v 1)` works the same way. A dyn value has no such history. Once `1` has been through `flan_dyn_from_i64` it is tagged `int`, and nothing downstream can recover that it was written as a literal in a position that wanted a float. So: ```lisp (g 1) ; typed: compiles, prints 1 (g some-dyn) ; dyn, where some-dyn came from the literal 1: traps, "a float was wanted" ``` That is a real divergence between the same source read as typed and read as dyn, and it is stated here rather than discovered later. Two ways out, both the compiler's and neither this file's: 1. **Tag the literal at the constructor.** Where the checker can see that an unannotated literal flows to a float context, emit `flan_dyn_from_f64` instead of `flan_dyn_from_i64`. This is the same inference the checker already performs for typed literals and it keeps the boundary sharp. 2. **Emit a conversion at the call**, where both sides are visible, rather than softening `flan_dyn_need_f64` — which sees only a tag and could not tell a literal-derived int from a computed one. Softening the boundary itself is the option not to take: it would accept `(g (len xs))` as readily as `(g 1)`, and those are not the same mistake. --- ## 7. Interop, which is out of scope for M1 and not precluded A typed `(Vec i64)` crossing into dyn code, without copying, is milestone 2. The layout leaves room for it in three specific ways. **Four spare tags.** Bits 50..48 give eight box tags and four are used. Tag 4 is the natural home for a *typed handle*: payload = pointer to a typed object, with the element type recovered from a side table or from a word in the object's own header. Tag 5 can carry `u64` above 2^63, which is the other value typed Flan holds and this ABI currently cannot name. **The object header is not the only thing the collector can trace.** `mark_push` dispatches on `kind`, and a fourth kind whose tracer is "call a function pointer in a descriptor" is a local change — a handle to a typed `(Vec Enemy)` would need the collector to know which fields of `Enemy` are dyn, and a per-type descriptor emitted by the compiler is how that arrives. Nothing in the current layout has to move for that: the `union` grows an arm. **A typed vec is not a `flan_obj` and does not need to become one.** The interop that matters is the cheap direction — a typed vec is `{ptr, len, cap}` in memory the *arena* owns, and a dyn handle to it should be a borrow, not a copy. That means the collector must be able to trace *into* memory it did not allocate and must never free it. The `kind` byte is what distinguishes "I own these bytes" from "I am a window onto someone else's", and the sweep already branches on it. What is genuinely open, and is stated here so the M2 lane does not discover it late: **a typed vec's lifetime is its arena's, and the collector has no say in it.** A dyn value outliving the arena its handle points into is a dangling read that the current design cannot detect. The allocation registry in `flan_dev.c` already answers exactly that question for `Ptr` — `render.ml`'s pointer arm follows a live one and mourns a dead one — so the shape of the answer exists. It is not built here. --- ## 8. Dropping the collector `--no-gc` enforcement is the compiler lane's. This lane's contribution is that **it is possible**, which is a property of the build and not a wish: - `flan_dyn.c` is its own translation unit and always has been. It is compiled from `Runtime_src.dyn_source` in `lib/build.ml` (both `executable` and `macro_module`) and in `lib/dev.ml`, beside `flan_rt.c` and `flan_dev.c` and never merged with them. - **The dependency runs one way only.** `flan_dyn.c` calls `flan_write_stdout` and `flan_trap` in `flan_rt.c`. Nothing in `flan_rt.c` or `flan_dev.c` names a symbol in `flan_dyn.c`. One back-reference — `flan_rt_init` calling `flan_gc_init`, say, which was the obvious thing to write — would make the collector unconditional and the refusal a lie, so the heap initialises itself lazily on first allocation instead. **The linker does not drop it today, and this document will not claim it does.** `flan_dyn.o` is a named object on the link line, not an archive member, and `ld` includes a named object in full — symbol-driven selection is a `.a` rule. Dead-code elimination *inside* an included object needs `-ffunction-sections -fdata-sections -Wl,--gc-sections`, which the link line does not pass. Measured rather than assumed: build any corpus program and `nm` it, and `flan_dyn_add` and `flan_gc_collect` are both there as defined symbols. What the one-way dependency buys is the better mechanism anyway. The object is selected at the **file** level: `--no-gc` is a one-line change at each of the three sites that name `Runtime_src.dyn_source` — the same per-target selection `select_csrcs` already performs for a package's C — and once it is not compiled, nothing has to be dropped. That only works because nothing else in the runtime refers to it; `flan_dev.c` is compiled into every build today precisely because it *is* referred to (a package's C names it, and package sources are collected whatever `main` does), and its own comment says so at length. `flan_dyn.c` has no such entanglement, and keeping it that way is the constraint this section exists to record. The residual-dynamism refusal itself — deciding that a program is *not* fully annotated and saying which form is not — is the compiler's, and is not attempted here. --- ## 9. Cost sketch, per operation No microbenchmarks; these are counts of what the code does. | operation | cost | |-----------|------| | `from_f64`, `need_f64` | one 8-byte copy; one compare for the NaN canonicalisation | | `from_i64` | one range compare, then a mask and an or — or an allocation past ±2^47 | | `need_i64` | tag test, then a shift pair to sign-extend | | `from_bool`, `nil`, `need_bool` | mask and or; tag test | | `tag` | one mask-compare, one shift, and a load for the object case | | `add`/`sub`/`mul` | two tag tests, the arithmetic, one `from_i64` (which may allocate at the extremes) | | `div`/`rem` | the above plus a zero test and an overflow test | | `lt`/`le`/`gt`/`ge` | two tag tests and a compare; text is `memcmp` | | `eq` | word compare first; then tags, then bytes or elements — O(size) at worst | | `len` | tag test and a load | | `at` | two tag tests, a bounds compare, a load | | `set_at` | the same, plus a store; **no write barrier**, because there is no generation to have one for | | `push` | tag test, a capacity test, amortised O(1); the doubling is a `realloc` | | `print` | O(size), streamed, no allocation | | `root_push` / `root_pop` | a store and an increment; a `realloc` when the stack doubles | | allocation | a trigger compare, a `malloc`, a header init, a ring store | | collection | O(live) to mark, O(all) to sweep; amortised O(1) per byte allocated | The shape to take away: **every operation is a handful of instructions plus whatever the arithmetic costs, and the only unbounded ones are `eq`, `print` and collection itself.** If that is not fast enough, type the program. --- ## 10. ABI extensions The header in `runtime/flan_dyn.h` is the agreed ABI with nothing removed and no signature changed. Seven things are added; none is something the compiler lane must emit. | addition | why | |----------|-----| | `flan_dyn_root_reset()` | the merged dev build's `longjmp` re-entry, exactly as `flan_dev_frames_reset` | | `flan_dyn_tag()` | the break loop and the inspector; the first question anyone asks a stopped dyn value | | `flan_dyn_tag_name()` | the same table the trap messages are written from, so a message and an inspector cannot disagree | | `FLAN_DYN_TAG_*` | the numbers `flan_dyn_tag` answers with | | `flan_gc_count()` | the tests: a live-bytes figure alone cannot tell a collecting heap from one with small objects | | `flan_gc_set_floor()` | the tests: a 1 MiB floor makes the million-allocation case slow and uninformative | | `flan_trap()` in `flan_rt.c` | a thin exported wrapper over the existing static `rt_trap`, so a dyn type error takes the *same* path as `flan_bounds_fail` and the other six rather than re-implementing the hook, the flush, the socket and `_exit(134)` | One more thing the compiler lane should know about, which is a build fact rather than an ABI addition: **`Build.compile_c` now drops `runtime/flan_dyn.h` into the directory it compiles each translation unit in**, so a package's C — or an emitted shim — can `#include "flan_dyn.h"`. `flan_dyn.c` itself does not include it and declares its own prototypes, because the object cache is keyed on source text and a header edit would serve an object compiled against the previous one. The two copies are kept honest mechanically: `test/dyn_ops.c` includes the header and names every function in it, so a divergence is a compile or link error in `dune test`. --- ## 11. Traps A dyn type error is a trap and not an abort: it takes `flan_trap`, which calls `flan_trap_hook` if one is installed and then `rt_die`. In a dev session the hook parks the program for inspection; in a standalone build nothing installs it and the program dies where it stands. The sentence is the same either way, which is the point of routing through the hook rather than calling `abort` in the runtime. The sentence names the operation, both tags as **words**, and both values: ``` dyn +: int and text, and it takes two numbers — (+ 3 "hi") dyn at: index 9 is out of bounds for text of length 2 — "hi" dyn f64: int, and a float was wanted — (f64 1) ``` Tag names are always words — `int`, `float`, `text`, `vec`, `nil`, `bool` — and never numbers. They come from one table that `flan_dyn_tag_name` also reads. Values are rendered into a bounded stack buffer with a depth cap of 2, truncated rather than allocated: a trap is the one moment when allocating would be a second thing to go wrong. Four trap names, because they are four different mistakes and somebody standing in one wants to know which without reading the sentence twice: | name | when | |------|------| | `DynType` | a tag that is not what the operation wanted | | `DynRange` | an index outside a vec or a text | | `DynArith` | division by zero, or the one quotient that overflows | | `DynHeap` | an allocation the host refused | The bounds case does **not** signal `BoundsError` the way typed indexing does, and that is a limitation rather than a preference: `flan_bounds_error` takes a `loc` and a transfer channel, and a dyn operation has neither in its hands. It is the same reason the six traps in `flan_rt.c` park rather than signal. If the compiler lane threads a `loc` through — it knows one at every call site — the dyn bounds case can join the condition system unchanged, and that is the obvious next increment. --- ## 12. What is tested `test/dyn_ops.c`, driven by `test/test_dyn.ml` against `test/programs/dyn-host.flan` — a C main linked against a Flan program with no `main`, the arrangement `dev_limits.c` and `reload_host.c` already use, because these are C entry points with no Flan spelling. One binary, 29 runs, 0.42 s. | mode | what it establishes | |------|--------------------| | `ops` | every operation's happy path; every tag and its word; f64 round trips at `0`, `-0.0`, `1e308` and NaN; i64 at both inline edges, both boxed edges, `INT64_MIN` and `INT64_MAX`; arithmetic crossing into the box; NaN unordered in all four directions; text identity vs equality; embedded NULs; the empty text; vec growth past its initial capacity; a vec that contains itself, compared and printed; every printed form as an exact string | | `gc` | a million allocations against a hundred rooted values; heap high-water under 512 KiB where a non-collecting heap would hold ~40 MiB; the live set intact afterwards | | `unrooted` | **the positive control** — an object nothing points at is reclaimed. Without it every other case would pass against a collector that never freed | | `nested` | a chain of vecs 64 deep traced through one root, across real collections | | `sharing` | one vec in three slots: identity (not just equality), a write through one path read through another, survival when the direct root goes, and a single sweep when the last reference does | | `refuse:*` | 24 refusals, one process each, asserted on the sentence as well as the exit status — a process that died some other way is not the guard firing, and the code cannot tell them apart | `dune build @sanitize` runs the same C under ASan and UBSan. That sweep matters more here than anywhere else in the corpus: every other program in it allocates and never frees, which is a policy ASan can only agree with, and this one frees — a mark-sweep collector is precisely a machine for freeing something that is still reachable. A use-after-free is what a wrong marker looks like from outside, and it is invisible to the assertions above, because the freed bytes are usually still the bytes that were there. Leaks stay off, as they are for the whole file: the temporaries ring holds 64 objects alive at exit on purpose, and every one of them would be reported.