diff --git a/docs/handoffs/HANDOFF-dyn-m1.md b/docs/handoffs/HANDOFF-dyn-m1.md new file mode 100644 index 0000000..d9063a7 --- /dev/null +++ b/docs/handoffs/HANDOFF-dyn-m1.md @@ -0,0 +1,124 @@ +# dyn, milestone 1 — what was decided and what is left + +The compiler half of dynamic-by-default. The runtime half is a sibling's, built +in parallel against `runtime/flan_dyn.h`, which is the fixed ABI and the thing +the two copies are diffed against. + +## Two decisions that differ from the brief + +**The return slot stays mandatory; `dyn` is written out in it.** The brief +expected `ret = None` to grow a third state meaning "unannotated", and listed +mechanical fallout in `load.ml`, `shim.ml` and `cimport.ml`. That fallout does +not exist, because the change was not made. The reason is in `parse.ml` beside +the `defn` case: an optional return slot has *no* syntactic resolution, since a +capitalised head in a list is both a type application and a struct literal — +`(defn f [] (Rune {.code 65}) (bar))` is the misparse that removed the old +optional slot, and it would come straight back. A parameter vector has no such +case, because every slot in it is a name or a type and never an expression. So +`ret = None` still means Unit and only `declare` and the shim produce it. One +token in the return position buys a decision the file paid for twice in one day. + +**The parameter rule is resolved in `Check`, not in `parse.ml`.** The brief +asked for "a known type name is a type, anything else is another dyn param", +written where `parse.ml` argues its other misparse-closing decisions. That +lookup is exactly the one `parse.ml:904` records being removed for being wrong +twice in one day, and at parse time the set of type names is incomplete *by +construction* — macros generate definitions, packages are loaded later, C +headers are imported later. `cimport.ml` decides it: `named env n = tname n` +passes C type names through verbatim, so POSIX's `stat` and `timespec` are +lowercase Flan type names writable in parameter position, and no syntactic rule +("capitalised is a type") can be made sound. + +So the vector is carried undecided as `Ast.pitem`s and paired in +`Check.pair_params`, after every file is loaded, every macro expanded and every +header imported. The argument is written at `parse.ml`'s `defn` case as asked. + +## The residual the parent owns + +The set of type names is complete at a point in time and **not across time**. +`(defn f [x y] ...)` is two dyn parameters until somebody writes +`(defstruct y ...)` — or imports a header that declares one — and then it is one +parameter of type `y`, with no edit to `f`. The signature changes underneath it, +and arity changes with it. + +`Session.compatible` is where that is felt: it compares with `Types.equal` over +parameters and return, so a redefinition that changes dyn-ness is refused like +any other signature change (this falls out; it is pinned in `test_session.ml`). +But the *first* definition after such an edit is the one that changes, and +nothing warns. + +## What the feature costs, and what was taken back + +A parameter slot with no type used to be a syntax error. It is now a `dyn` +parameter, so **a mistyped type silently becomes an extra parameter** — the +arity changes with no diagnostic, which is the failure class `parse.ml` calls +the worst available. Two rules take most of it back, in `dyn_param_or_typo`: + +- a name within one edit of a type's name gets the resolver's own "did you + mean", and +- an unknown **capitalised** name is reported as an unknown type. Not one + parameter in the corpus is capitalised, while `Form`, `Cursor` and `Vector2` + appear in these vectors constantly. + +What is left uncovered is a lowercase name resembling no type: `(defn f [x +widget] ())` is two dyn parameters and nothing in the text says otherwise. That +is the feature working as specified. + +**Sharp edge of the near-miss rule.** `near_miss` treats any two single-char +names as one edit apart, and it compares against every struct name in scope. So +a `(defstruct D ...)` anywhere in the program makes `(defn f [a d] ...)` a +refusal rather than two dyn parameters. The message is actionable — write the +type, or rename — but it is a refusal a user will meet without having done +anything wrong. + +## Open ABI point for the integrator + +**A rooted slot holding 0 is not a value, and the collector must skip it.** +This is written into `runtime/flan_dyn.h` beside the root functions, and it is +the one thing in that header decided by one side alone. Roots are pushed in the +function's entry block, before the code that fills them has run and possibly for +a branch that never runs, so the compiler zeroes every root slot and must mean +something by it — and 0 is the only pattern it can write without knowing the +encoding. + +If the real runtime NaN-boxes and integer zero is the zero word, this is wrong +and the two sides need a different sentinel. Do not fix it on one side. + +## Not in milestone 1, each refused by name with a location + +- a typed container boxing into dyn (`(Vec i64)` → dyn): "not yet"; the + heterogeneous container is the runtime's own from `(vec-new dyn)` +- a dyn in a condition's payload, or in a field of one: milestone 2 — a payload + crosses a handler boundary and must stay rooted across the transfer +- a dyn crossing to C through `declare`/`declare-c`: it is one word and would + have passed as an integer with nothing on the other side able to ask what it + means. This one was **not** in the brief and is the dangerous one, because the + general "cannot cross to C" arm would have caught it with advice (`pass (Ptr + T)`) that is wrong for dyn. +- integer widths other than i64 and floats other than f64 unboxing from dyn: + the ABI carries one of each, and a `need_i64` plus a truncation would put an + implicit narrowing at the one boundary where the value's type was already + uncertain +- the x86 dev backend, and the JS dialect, refuse dyn entirely + +## Roots: what is and is not verified + +Every dyn slot and every dyn-producing runtime call is rooted, pushed in the +entry block and popped at every `ret` — which is the funnel all five exits pass +through, the transfer landing block included. Pushes and pops balance **by +construction**: `dyn_roots` counts before emission, the slots are minted from +that count, and `dyn_tmp` only hands them out. + +**The stub verifies none of this.** `flan_dyn_stub.c` mallocs and never frees, +so a program with entirely wrong root discipline passes every test that runs +against it. What is checked instead is the IR: an early-return function pops on +both paths, and a function with a defer pops on the transfer path. When the real +collector lands, that is the area to re-examine first. + +Cost: a rooted alloca has its address escape through `flan_dyn_root_push`, so +mem2reg cannot promote it. Every dyn local and every dyn temporary is a real +stack slot with a real store, at every optimisation level. That is inherent to a +precise collector with an address-registration ABI rather than stack maps. + +A function with no dyn emits nothing — no push, no pop, not a `pop(0)` — which +is what makes `--no-gc` byte-identity hold. diff --git a/runtime/flan_dyn.h b/runtime/flan_dyn.h index 4dd5c38..27c4454 100644 --- a/runtime/flan_dyn.h +++ b/runtime/flan_dyn.h @@ -100,7 +100,29 @@ int32_t flan_dyn_need_bool(flan_dyn v); * one [flan_dyn_root_pop] per scope carrying the count the scope pushed. * * The address is registered, not the word: the slot is written again while it - * is rooted, and a collection in between has to see the current value. */ + * is rooted, and a collection in between has to see the current value. + * + * ── OPEN POINT FOR THE INTEGRATOR ────────────────────────────────────── + * + * A ROOTED SLOT HOLDING 0 IS NOT A VALUE, AND THE COLLECTOR MUST SKIP IT. + * + * This is a constraint the compiler puts on the encoding, and it is the one + * thing in this header that was decided by one side alone. The reason it is + * forced: roots are pushed in the function's entry block, before the code that + * fills them has run, and a slot may belong to a branch that never runs at + * all. So the compiler zeroes every root slot at entry and has to mean + * something by it, and 0 is the only bit pattern it can write without knowing + * how values are encoded. Globals get the same treatment for free, from BSS. + * + * If the real runtime's encoding makes 0 a legitimate value — a NaN-boxing + * scheme where integer zero is the zero word is the obvious way this breaks — + * then this is wrong and the two sides need a different empty sentinel, which + * is a change to this header that both make together. Do not resolve it by + * changing one side. + * + * The root stack is strictly LIFO and the pops say how many, because there is + * no way to read its depth. Globals are pushed once, in main, before anything + * else and never popped. */ void flan_dyn_root_push(flan_dyn *slot); void flan_dyn_root_pop(int64_t n); diff --git a/test/test_session.ml b/test/test_session.ml index 8cc38fb..e32b55e 100644 --- a/test/test_session.ml +++ b/test/test_session.ml @@ -51,6 +51,20 @@ let () = refuses "a changed arity" "(defn outer [a i64 b i64] i64 (bump))" "changes signature"; + (* Dyn-ness is part of a signature like anything else, and this falls out of + [compatible] rather than being added to it: the comparison is + [Types.equal] over the parameters and the return, and dyn is equal to + itself and to nothing else. Pinned anyway, because it is the one place the + word "signature" covers a change the source does not spell out — the + return type here went from [i64] to [dyn] by being written differently, + and a parameter can change the same way by a *type* being declared + elsewhere in the program. *) + refuses "a return type that became dyn" + "(defn outer [] dyn (bump))" + "changes signature"; + refuses "a parameter that became dyn" + "(defn outer [x] i64 (bump))" + "changes signature"; (* The storage exists and has a shape: reusing it reads at the wrong offsets, and replacing it discards the state the reload exists to preserve. *) refuses "a retyped global"