flan/runtime/flan_dyn.h
Joseph Ferano 7f92136401 Old instances of a redefined class now follow the class
CLHS 4.3.6's update protocol, minus the user hook, on the dyn side's
defclass. Redefining a class used to be silent: a class is sugar for a
constructor defn, so the edit replaced a body and the instances already in
the program kept their old keys for ever.

Three pieces. A registry in flan_dyn.c holding each class's current slot
list and a generation, made only of interned kw_entry pointers so the
collector has nothing to trace in it and no root to push for it. A uint32
generation on the instance, fitted into the padding kind and mark leave in
front of len's alignment — sizeof(flan_obj) is 48 with it and was 48
without, and flan_dyn_obj_size is there so a later field that moves it
fails a test. And a registration thunk per reload, run by the agent
through flan_reload_call after the module's bodies are published: it has
to be a thunk, because the case this exists for is a class redefined and
not constructed.

Migration is lazy, at want_map, len's map arm and dyn_equal's. Slots kept
by name, gained slots nil, dropped slots gone, identity preserved, entries
rebuilt in the class's order so a migrated instance is indistinguishable
from a fresh one. Equality migrates both operands first, so it is over the
class as it is now.

The session had to stop refusing the constructor's signature change, and
does so only for a defclass and only when no compiled caller is left
behind. The checker gets there first in practice; the walk in eval holds
the reason locally rather than inheriting it.

The registry is advisory: a class instance is an open map, so a key a raw
put wrote that the class never declared is dropped by the next migration.
FIX.org says that plainly rather than pretending enforcement.
2026-09-20 19:46:57 +07:00

433 lines
23 KiB
C

/* flan_dyn — the dynamic-value runtime's ABI.
*
* This header is not compiled into a program. The build embeds the runtime's
* .c files as strings and hands each one to clang on its own, with no include
* path (see [Build.compile_c]), so runtime/flan_dyn.c declares everything it
* defines and this file declares it a second time. What keeps that second
* copy honest is partial, and the exact shape of it is worth writing down
* rather than overclaiming: test/dyn_ops.c includes this header and calls
* most of what it declares, so a name that is spelled one way here and
* another way in flan_dyn.c fails to link in `dune test`.
*
* Two holes in that. A signature that drifts while the name stays is not
* caught at all — C links on names and not on types, so a changed parameter
* or return type compiles on both sides and goes wrong at the call site
* instead of failing the build. And five of the names below are never
* referenced by dyn_ops.c — [flan_dyn_map_get], [flan_dyn_map_set],
* [flan_dyn_map_contains], [flan_dyn_is_nil] and [flan_dyn_need_not_nil] —
* so not even the rename check reaches them. Calling those five from
* dyn_ops.c would close the second hole; nothing available from here closes
* the first.
*
* Who reads it: the compiler lane, which emits calls to these names, and the
* C tests. The whole of the boundary is here. What is behind it — the value
* representation, the heap layout, the collector — is flan_dyn.c's business
* and is argued in docs/SPIKE-DYNAMIC.md.
*/
#ifndef FLAN_DYN_H
#define FLAN_DYN_H
#include <stdint.h>
#ifdef __cplusplus
extern "C" {
#endif
/* A dynamic value. One machine word, always — the whole point of the type is
* that a dyn local is a register or a stack slot and never a struct the ABI
* has to agree about. NaN-boxed; see the design doc. */
typedef uint64_t flan_dyn;
/* A type's dyn map — where the dyn words are inside one instance of it.
*
* The compiler emits one of these as static data for every type that holds a
* dyn anywhere: a struct with a dyn field, a struct holding such a struct by
* value, a fixed array of either. The offsets are flattened at compile time,
* so nesting costs nothing here — an inner struct's dyn word appears at the
* outer offset plus the inner one, and there is no walking of a type graph at
* run time and no second descriptor to follow.
*
* [size] is the stride of one instance. The collector does not read it; the
* typed-container view will, which is the reason it is here now rather than
* being added later to data both lanes already emit.
*
* Nothing in this ABI ever writes a descriptor, and no value ever points at
* one. See [flan_dyn_root_push_desc]. */
typedef struct flan_desc {
int64_t size;
int64_t n;
const int64_t *offs;
} flan_desc;
/* ── Constructors ──────────────────────────────────────────────────── */
flan_dyn flan_dyn_nil(void);
flan_dyn flan_dyn_from_i64(int64_t x);
flan_dyn flan_dyn_from_f64(double x);
flan_dyn flan_dyn_from_bool(uint8_t b);
/* Copies the bytes into the GC heap. The result is a text value, immutable
* from that moment: nothing in this ABI writes into one. [p] may point
* anywhere — a literal in .rodata, a frame slot, a slice the caller is about
* to drop — because the bytes are copied before this returns. */
flan_dyn flan_dyn_from_bytes(const uint8_t *p, int64_t n);
flan_dyn flan_dyn_vec_new(void);
flan_dyn flan_dyn_map_new(void);
/* A map carrying a shape tag: what a (defclass point [x y]) constructor
* builds. [k] is the class's name as a keyword and anything else traps.
*
* The tag lives in the object's header and not in an entry of the map, so it
* is invisible to [get], [set], [contains] and [len] — an instance's length
* is its slot count and no key a program can write collides with it. What can
* see it is [flan_dyn_class_of], [flan_dyn_eq] (two values of different
* classes are unequal, and an instance is never equal to a plain map) and
* [flan_dyn_print] (an instance renders as #point{ :x 1 :y 2}).
*
* The tag is not traced and does not have to be: an interned keyword entry is
* immortal and is not a collector object. */
flan_dyn flan_dyn_map_new_class(flan_dyn k);
/* The class's name as a keyword, or nil for anything that is not an instance
* — an ordinary map included. Never traps. */
flan_dyn flan_dyn_class_of(flan_dyn v);
/* A class definition, registered or re-registered: [name] is the class's name
* as a keyword and [slots]/[n] is its slot names packed into one string,
* newline between and no leading colons. The compiler emits one call per
* (defclass ...) into the thunk a reload runs, so a definition that changed
* lands here before anything touches an instance.
*
* The generation moves only when the slot list really differs, so re-running
* a file's definitions unchanged — every C-c C-k — costs a comparison and
* migrates nothing. When it does move, every instance built against an
* earlier definition migrates lazily at its next [get], [put], [has-key?],
* [len] or equality comparison: slots the class still has keep their values
* matched by name, slots it has gained appear as nil, and keys it no longer
* declares are dropped. The instance's identity is preserved throughout;
* this is CLHS 4.3.6 without the user hook.
*
* The drop is unconditional, which is the honest cost of a class instance
* being an open map: a key written by a raw [put] that the class never
* declared is dropped by the next migration too. The registry describes the
* class's intention and does not enforce it. */
void flan_dyn_class_def(flan_dyn name, const uint8_t *slots, int64_t n);
/* [sizeof(flan_obj)], for the one test that asserts it. The generation a
* class instance carries was fitted into the padding between [mark] and
* [len] precisely so that this number did not move; a field that pushed it
* up would cost every dyn value in the heap, so the number is asserted
* rather than left to a comment. */
int64_t flan_dyn_obj_size(void);
/* A keyword: :foo as a run-time value. Interned — the runtime keeps one entry
* per distinct name forever, so two keywords with the same bytes are the same
* word and equality is an identity compare, never a memcmp. The entries are
* immortal by construction and the collector never traces or frees one.
* [p] may point anywhere; the bytes are copied on the first interning. The
* name is the bytes after the colon: flan_dyn_kw("a", 1) is :a. */
flan_dyn flan_dyn_kw(const uint8_t *p, int64_t n);
/* ── Operations ────────────────────────────────────────────────────────
*
* Every one of these may trap, and a trap does not return: it prints a
* sentence naming the operation, the tags it was given and the values, and
* then takes flan_rt.c's [flan_trap] — which parks the program for inspection
* in a dev session and ends it in a standalone build. The three that cannot
* trap say so on their own line.
*
* The nine below take the site as well: [loc]/[loclen] are the bytes of a
* "file:line:col" string the emitter already has, and the trap prints them as
* a GNU prefix so the failure is somewhere rather than nowhere. It is the same
* pair flan_rt.c's bounds and arithmetic traps take, and the same pair
* [flan_dyn_cast_kind] takes below. A caller with no site — the C tests, and
* anything outside a compiled Flan program — passes (NULL, 0) and gets the
* sentence with no prefix. */
flan_dyn flan_dyn_add(flan_dyn a, flan_dyn b, const uint8_t *loc, int64_t loclen);
flan_dyn flan_dyn_sub(flan_dyn a, flan_dyn b, const uint8_t *loc, int64_t loclen);
flan_dyn flan_dyn_mul(flan_dyn a, flan_dyn b, const uint8_t *loc, int64_t loclen);
flan_dyn flan_dyn_div(flan_dyn a, flan_dyn b, const uint8_t *loc, int64_t loclen);
flan_dyn flan_dyn_rem(flan_dyn a, flan_dyn b, const uint8_t *loc, int64_t loclen);
/* Answer a bool dyn. Numbers compare as numbers and text compares bytewise;
* a mixture of the two, or anything else, traps. */
flan_dyn flan_dyn_lt(flan_dyn a, flan_dyn b, const uint8_t *loc, int64_t loclen);
flan_dyn flan_dyn_le(flan_dyn a, flan_dyn b, const uint8_t *loc, int64_t loclen);
flan_dyn flan_dyn_gt(flan_dyn a, flan_dyn b, const uint8_t *loc, int64_t loclen);
flan_dyn flan_dyn_ge(flan_dyn a, flan_dyn b, const uint8_t *loc, int64_t loclen);
/* Structural, and the one operation in this file that never traps: two values
* of unrelated tags are not an error, they are unequal. */
flan_dyn flan_dyn_eq(flan_dyn a, flan_dyn b);
/* Bytes of a text, elements of a vec. Anything else traps. */
flan_dyn flan_dyn_len(flan_dyn v);
/* Element of a vec, or the byte of a text as an int. Out of range traps. */
flan_dyn flan_dyn_at(flan_dyn v, flan_dyn i);
/* Vec only — a text is immutable and says so rather than being copied. */
void flan_dyn_set_at(flan_dyn v, flan_dyn i, flan_dyn x);
void flan_dyn_push(flan_dyn v, flan_dyn x);
/* Map only; anything else traps by name. Keys and values are both dyn and a
* key is compared structurally, so a keyword, a text, an int, or a whole map
* may key one. [get] on an absent key answers nil — absence is an answer, the
* same line [eq] takes about unrelated tags — and [contains] is the question
* to ask when nil might also be stored. [set] replaces the value of an equal
* key in place, so a key occurs once and insertion order is print order. */
flan_dyn flan_dyn_map_get(flan_dyn m, flan_dyn k);
void flan_dyn_map_set(flan_dyn m, flan_dyn k, flan_dyn v);
flan_dyn flan_dyn_map_contains(flan_dyn m, flan_dyn k);
/* Structural, and per type it renders what typed [print] renders. Never
* traps: every tag has a rendering, including nil. */
void flan_dyn_print(flan_dyn v);
/* ── The typed boundary ────────────────────────────────────────────────
*
* What an annotated parameter does with a dyn argument, and what a dyn
* expression does where the checker wants a machine value. A tag that is not
* the one asked for traps; there is no widening and no coercion here, which
* is deliberate — see the doc's boundary section. */
int64_t flan_dyn_need_i64(flan_dyn v);
double flan_dyn_need_f64(flan_dyn v);
uint8_t flan_dyn_need_bool(flan_dyn v);
/* A numeric cast written on a dyn — [(f64 d)], [(u32 d)] — FIX.org
* 2026-09-20. Unlike the parameter boundary above this one coerces: it
* answers which numeric tag the box holds (1 float, 0 int) and the caller
* branches, so the conversion itself is the cast the compiler already emits
* for a typed operand of that tag. A box holding anything else traps, bool
* included, exactly as [flan_dyn_need_i64] refuses one.
*
* [want_float] says what the target type is, and a disagreement writes one
* line to stderr — once per [loc], not once per value, because these casts
* run inside frame loops. [loc] and [target] are Flan slices: pointer and
* length, not NUL-terminated. */
int32_t flan_dyn_cast_kind(flan_dyn v, const uint8_t *loc, int64_t loc_len,
const uint8_t *target, int64_t target_len,
int32_t want_float);
/* nil <-> None at an (Option T) boundary, and (Some nil)'s refusal — M2 item
* 4. [flan_dyn_is_nil] is the tag test the boundary's runtime half needs and
* does not want to build out of [flan_dyn_tag] and a comparison at every call
* site; it answers 1 for nil and 0 for every other tag, and cannot trap.
* [flan_dyn_need_not_nil] is the other half: it answers [v] unchanged when
* [v] is not nil, and traps when it is — the run-time case of (Some nil),
* for a dyn value that is not known to be nil until the program runs. */
int32_t flan_dyn_is_nil(flan_dyn v);
flan_dyn flan_dyn_need_not_nil(flan_dyn v);
/* Truthiness for a dyn used where a typed value would need a strict bool —
* an [if]'s condition when the scrutinee's own type is dyn. Clojure's rule:
* nil and false are falsey, every other value is truthy, including 0, 0.0,
* "", an empty vec, an empty map, and any keyword. Never traps. */
uint8_t flan_dyn_truthy(flan_dyn v);
/* ── Typed containers as views — M2 item 3 ─────────────────────────────
*
* A [(Vec T)], a [T] slice, or a fixed [n T] array crossing into dyn is a
* VIEW, not a copy: the box holds a small heap record naming where the
* elements live and what one of them is, and every read or write goes
* straight through to the container's own storage. [flan_dyn_at] boxes an
* element on the way out; [flan_dyn_set_at] tag-checks the dyn value it is
* given against the element type on the way in and traps, by [flan_trap],
* on a mismatch — never a silent coercion.
*
* T is restricted to i64, f64 and bool — exactly the set [flan_dyn_need_i64]
* and friends already treat as crossing the typed boundary both ways. That
* is not an arbitrary cut: the excluded case that matters is a string
* element, whose dyn form is a pointer into this collector's heap, while a
* typed container's storage is arena or stack memory the collector never
* scans. Writing such a pointer into that memory would be a live reference
* nothing ever traces — a use-after-free the collector cannot see coming,
* not a bug in this file but a hazard the type admits. i64, f64 and bool
* carry no such pointer, so a view restricted to them cannot manufacture
* it. [box] in lib/check.ml keeps the "does not cross into dyn yet" refusal
* for every other element type, and this paragraph is why.
*
* Two kinds, because the containers split exactly here: a [(Vec T)] can grow
* and move (a push may reallocate), a slice and a fixed array cannot.
*
* [flan_dyn_view_vec] takes the address of the Vec's own header — the
* struct [flan_vec] in flan_rt.c, restated in flan_dyn.c under the same
* "if either table changes, change both" rule this whole boundary already
* lives under. That address is the Vec's home, fixed for as long as the Vec
* exists — but "as long as the Vec exists" is the whole of the guarantee,
* which is why [permanent_root] in lib/check.ml admits only storage that
* outlives every frame: a global, a field or an array element of one, or a
* slice cut from one at the crossing. A local's slot is a home too, and it
* is precisely the one that is refused. Every operation re-reads that
* header's [ptr] and [len] fresh, so a push that grows and moves the Vec is
* never seen as stale — [flan_vec_grow] overwrites the SAME header's [ptr]
* field in place, and there is no snapshot anywhere to go stale. That is
* what makes the failure the open design question worried about
* (a push through dyn holding a dangling pointer) impossible rather than
* merely unlikely: there is nothing captured at the crossing for a later
* push to invalidate.
*
* [flan_dyn_view_flat] takes a data address and a length captured once, at
* the crossing — sound for a slice and for a fixed array because neither
* ever moves or grows. Note the asymmetry is not an oversight: pointing
* *this* case at the value's own slot instead would be worse than a
* snapshot, because a slot's lifetime is not the slice's, and a slice taken
* from a Vec is already one push away from dangling on its own account
* (flan_vec_grow's own comment says so) — the view is exactly as
* stale-safe as the thing it is a view of, no more and no less.
*/
#define FLAN_VIEW_I64 0
#define FLAN_VIEW_F64 1
#define FLAN_VIEW_BOOL 2
flan_dyn flan_dyn_view_vec(void *hdr, int32_t elem);
flan_dyn flan_dyn_view_flat(void *data, int64_t len, int32_t elem);
/* ── The collector ─────────────────────────────────────────────────────
*
* Mark-sweep, precise, and never moving. [flan_gc_init] is idempotent, and the
* first allocation calls it if nobody else has — deliberately, because
* flan_rt_init calling it would be flan_rt.c naming a symbol in flan_dyn.c,
* and the whole of the droppability argument is that the dependency runs one
* way only. So an emitted program need not call it at all; it is exported
* because a test that wants a heap in a known state wants to say so.
*
* [flan_gc_collect] is a full collection on demand, which nothing in an
* emitted program needs — collection happens inside allocation — and which
* the tests and a break loop want. [flan_gc_live_bytes] is what the heap holds
* after the last sweep, counted the way the trigger counts it. */
void flan_gc_init(void);
void flan_gc_collect(void);
int64_t flan_gc_live_bytes(void);
/* Roots, shadow-stack style, exactly as flan_dev.c's frame chain is: the
* compiler emits a push per dyn local on entry and one pop for the lot on the
* way out. The address is remembered, not the value, so a local that is
* reassigned needs no second push.
*
* **The 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 as a pointer. Storing nil first
* is the whole of the contract; the compiler lane zeroes a slot at its
* declaration anyway.
*
* A zeroed slot satisfies it, and this used to be the one thing in this header
* decided by one side alone. It is checkable now that both sides exist:
* flan_dyn.c's mark walks a value only when it is boxed, and boxed means the
* quiet-NaN prefix is set, which the zero word does not have. So zero decodes
* as the double 0.0 — an ordinary value, and never an address anything
* follows. Both backends zero, and they are right to.
*
* Globals go through the same pair, pushed once at startup and never popped.
*
* [flan_dyn_root_pop] takes a count rather than an address because that is
* what a function epilogue knows cheaply. Popping more than are pushed is
* clamped at empty rather than being a second failure on top of the first. */
void flan_dyn_root_push(flan_dyn *slot);
void flan_dyn_root_pop(int64_t n);
/* The same stack, for a slot that holds an aggregate rather than a dyn word:
* a struct with a dyn field, a struct holding one of those by value, an array
* of either. [base] is the first byte of the instance and [d] says where the
* dyn words are inside it.
*
* The question this answers, and it is the only interesting one about the
* whole mechanism: how does the collector get from a run of bytes to the
* descriptor for the type at those bytes? It does not. **The instance never
* carries a pointer to its descriptor, and the collector never derives one.**
* The pairing is made here, at the push, by the code that put the value there
* and therefore knows its static type. That is what makes a bare struct on the
* stack the easy case rather than the impossible one, and it is why no Flan
* struct grows a header word: a header would change the layout C interop
* agrees on, change the stride of an array, and change what embedding a struct
* in another one costs.
*
* The same contract as the dyn form: **the dyn words named by [d] must hold
* valid flan_dyn values before the push**, which zero satisfies. The compiler
* zeroes those words and not the whole instance — the rest of the bytes are
* never read through this stack.
*
* One entry, so one pop takes it off like any other, and a function's pop
* count is still the number of pushes it made. [d] is static data with the
* lifetime of the program; nothing copies it. */
void flan_dyn_root_push_desc(void *base, const flan_desc *d);
/* ── Extensions ────────────────────────────────────────────────────────
*
* Additions to the agreed ABI, none of which the compiler lane has to emit.
* They are here because something in this repository needs them; each says
* what. */
/* The *frames*' roots, dropped. The counterpart of flan_dev.c's
* [flan_dev_frames_reset] and there for its one caller: the merged dev build's
* [main] is re-entered by longjmp, which pops no frame, so every root the
* finished run pushed still points into stack the next run is about to write
* over. Marking through those addresses would decode whatever the new run put
* there. Called between runs, on the thread that runs them.
*
* The globals' roots are not dropped with them, and that is the whole of the
* distinction: a finished run's frames are gone but its globals are not — the
* dev daemon parks with them readable, and runs evaluated thunks against them
* that allocate and therefore collect. Emptying the stack outright unrooted
* every dyn global for the whole of the park. This resets to the line
* [flan_dyn_root_globals_end] recorded. */
void flan_dyn_root_reset(void);
/* Where that line comes from. The emitted [main] brackets its global pushes
* with these: [begin] immediately before the first, [end] immediately after
* the last, with nothing but the pushes in between — nothing there may
* allocate, because between the two the globals are unrooted and still hold
* whatever a previous run left in them. [begin] empties the stack rather than
* adding to it, so a [main] entered a second time re-roots the same globals
* instead of pushing a second copy of each.
*
* A program with no dyn globals need not call either: the line starts at zero,
* which is what an empty push list should leave it at. */
void flan_dyn_root_globals_begin(void);
void flan_dyn_root_globals_end(void);
/* The tag of a value, as a number and as the word that number is printed as.
* The numbers are FLAN_DYN_TAG_* below. The break loop and the inspector want
* both — a value's tag is the first thing anyone asks a stopped dyn program —
* and the trap messages in flan_dyn.c are written from the same table, so a
* message and an inspector cannot disagree about what to call a value. */
#define FLAN_DYN_TAG_NIL 0
#define FLAN_DYN_TAG_BOOL 1
#define FLAN_DYN_TAG_INT 2
#define FLAN_DYN_TAG_FLOAT 3
#define FLAN_DYN_TAG_TEXT 4
#define FLAN_DYN_TAG_VEC 5
#define FLAN_DYN_TAG_KEYWORD 6
#define FLAN_DYN_TAG_MAP 7
int32_t flan_dyn_tag(flan_dyn v);
const char *flan_dyn_tag_name(int32_t tag);
/* How many objects the heap holds, and the floor under the collection
* trigger. Both are the tests': a live-bytes figure alone cannot tell a heap
* that is collecting from one whose objects happen to be small, and a floor of
* a megabyte would make the million-allocation case a megabyte of arithmetic
* before it proved anything. Setting the floor takes effect at the next
* allocation and never shrinks a heap by itself; pass 0 for the default. */
int64_t flan_gc_count(void);
void flan_gc_set_floor(int64_t bytes);
/* Reports flan_dyn.c's own mirror of flan_rt.c's [flan_vec] — [size, then
* the offset of ptr, len, cap, alloc, epoch] — for test/dyn_ops.c's
* "layout" mode to compare against flan_rt.c's [flan_vec_layout] and
* against its own hand-built mirror. See [flan_vec_layout]'s comment in
* flan_rt.c for what this ties together and why nothing at compile time
* otherwise does. */
void flan_dyn_vec_hdr_layout(int64_t out[6]);
#ifdef __cplusplus
}
#endif
#endif /* FLAN_DYN_H */