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.
7.6 KiB
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.pitems 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,CursorandVector2appear 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_i64plus 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.