A defer is in the typed IR twice -- spliced into the body for the normal path, and again in fdefers for the path a transfer leaves through -- so a dyn temporary inside one is emitted twice. dyn_roots counted only the body's, and the second copy went into slots nothing had rooted. Nothing failed, and that is the whole reason this is worth a commit of its own. dyn_tmp falls back to a plain slot rather than unbalancing the stack, so the pushes and the pops still matched, the program ran and printed the right answer, and the values were simply invisible. Against a stub that never collects there is no symptom to find -- no leak, no crash, no wrong number. It would have become a symptom the week the real collector landed, in a defer reached only on a handled condition, which is close to the worst place to start looking. What found it was the IR: a rooted slot is spelled %dr and the fallback %dx, and the assertion is that no dyn program in the corpus emits one of the latter. That is now a test over all five dyn programs, and it is the only check in the lane that can see a missing root while there is still nothing to lose one by. When the collector arrives it is the thing to extend rather than replace. Also checked, both clean: flan dev --llvm builds and runs a dyn program, which is the route the x86 refusal sends people to and would have been a link error in the worst possible place; and the daemon's own refusal already names the flag.
139 lines
7.6 KiB
Markdown
139 lines
7.6 KiB
Markdown
# 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, and that check earned its keep —
|
|
it found a real hole. A defer appears twice in the typed IR, spliced into `body`
|
|
for the normal path and again in `fdefers` for the path a transfer leaves
|
|
through, so a dyn temporary inside one is emitted twice; `dyn_roots` counted
|
|
only the body's, and the second copy went into slots the collector had never
|
|
been told about.
|
|
|
|
Nothing failed, which is the point. `dyn_tmp` falls back to a plain unrooted
|
|
slot rather than unbalancing the stack, so the pushes and the pops still
|
|
matched, the program ran and printed the right answer, and four dyn values were
|
|
simply invisible. Under a stub that never collects there is no symptom at all.
|
|
|
|
The assertion that caught it is in `test_acceptance.ml`: a rooted slot is
|
|
spelled `%dr` and the fallback `%dx`, and no dyn program in the corpus may emit
|
|
the latter. When the real collector lands, that is the check to extend rather
|
|
than replace — it is the only one that can see a missing root before there is a
|
|
collector to lose one by.
|
|
|
|
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.
|