The x86 backend ran initialisers from .init_array and the LLVM one refused them by name, so (defvar frame Allocator (arena-new 262144)) — which the author kept writing — was a program on one backend and an error on the other. A rule that holds on one backend and not the other is not a rule. The checker lifts a computed initialiser into a function of its own and the global's initialiser becomes the call. That is what gives it a frame, which is the bug underneath the feature: a `let` or a `match` in an initialiser indexed a slot array of length zero and took the x86 emitter down with an uncaught Invalid_argument. Both backends call the lifted initialisers from main, after flan_rt_init and before a line of the program's own code — Odin's __$startup_runtime shape, not a constructor, so the runtime is up and the order is the compiler's to choose. x86 keeps .init_array for one thing only, and it is named: writing the constant image this backend has no folder for, which is standing in for the other backend's object image rather than for a program. The computed globals are sorted by what they read, transitively through the functions they call, so a global written above the one it reads works and a ring is refused with every name in it. A reload still re-runs nothing: a new global with a computed initialiser starts as ZII on both backends. The refusal that lived in x86.ml is now the checker's and is narrower. Nothing can escape an initialiser — the handler and restart stacks are empty and every frame it pushes it also pops — so what is refused is a signal or an invoke-restart with no handler-bind or restart-case around it, which is inert by construction. A restart-case inside one is ordinary code, which is what makes (defvar data (Vec u8) (slurp "level.edn")) an ordinary program. Three refusals go with the premise they rested on: a container global with a computed initialiser, a union member in a defvar, and a data type case in one. A defconst is untouched and keeps all three. One change here is not about any of that. sand.flan carried an unfinished line — (defvar game-data (embed (with-allocator frame ))), which parses as a declaration whose type is (embed ...) — so the checker refused the file and `dune test` was red at the tip of dev-loop before a line of this landed, verified by stashing this work and rebuilding. It is commented out rather than guessed at: the arena above it is the half that works, and what the global should read is the author's to decide.
181 lines
8.9 KiB
OCaml
181 lines
8.9 KiB
OCaml
(** What a program actually calls, and what that means for the link.
|
|
|
|
A package is imported as a whole — every declaration in the directory
|
|
becomes a declaration of the importing program — and until now the C it
|
|
binds to came with it unconditionally. So importing [vendor:raylib] linked
|
|
libraylib whatever [main] did, and on wasm32 that link cannot succeed. That
|
|
is the single fact that made sand's two halves two *files* rather than two
|
|
entry points, and it is what this module removes.
|
|
|
|
The answer is reachability, computed once on the checked program: start at
|
|
[main] and at every global initialiser, follow every call, and keep what is
|
|
reached. Two things fall out of the same walk:
|
|
|
|
- a package none of whose externs is reached contributes no [.c] file and
|
|
no linker argument, and
|
|
- the functions that would have referenced those externs are dropped from
|
|
the program, because removing [-lraylib] while still emitting a body that
|
|
calls [@InitWindow] only moves the failure from the linker's argument
|
|
list to its symbol table.
|
|
|
|
Only [fns] and [externs] are pruned. Globals, structs and data types stay:
|
|
a dropped function is a loud link error, a dropped global would be a
|
|
silently different program, and an unreferenced global is bytes in BSS that
|
|
cost nothing. A [defvar brush rl/Texture2D] in a headless build is exactly
|
|
that.
|
|
|
|
Dev builds are not pruned at all. A REPL redefines a function that the
|
|
running program has not called yet, so "not reached" there means "not
|
|
reached *so far*", which is not the same claim. *)
|
|
|
|
(* The edges, which is the whole of what this module has to say about the shape
|
|
of an expression: [Tast.walk] visits every node and this names the ones that
|
|
are a link-time reference. [Call] and [Global] are the obvious ones;
|
|
[Handled] is the one worth naming, because a handler-bind clause was lifted
|
|
into a function of its own and is reached by *address* from the body that
|
|
wrote it, never by a call. Miss it and a program with a handler loses the
|
|
handler.
|
|
|
|
A write to a global is a reference too — [Set] and [Addr] through a
|
|
[Pglobal] — which is how a program whose only mention of a global is the
|
|
(set g ...) that loads it keeps it. *)
|
|
let expr_refs f (e : Tast.expr) =
|
|
Tast.walk
|
|
(fun (e : Tast.expr) ->
|
|
match e.Tast.e with
|
|
| Tast.Global n -> f n
|
|
| Tast.Call (n, _) -> f n
|
|
(* The edges reached by address rather than by a call: a Map's hash and
|
|
equality pair, and a function *value* someone wrote the name of. A
|
|
name used as a value is never a [Call], so without the second one the
|
|
one function a program passes to [map] is the one function the link
|
|
drops. [Rtfn] is C in flan_rt.c and is linked whatever happens. *)
|
|
| Tast.FnAddr (Tast.Flanfn n) | Tast.FnAddr (Tast.Fnval n) -> f n
|
|
| Tast.Set (Tast.Pglobal n, _) | Tast.Addr (Tast.Pglobal n) -> f n
|
|
| Tast.Handled (frames, _) ->
|
|
List.iter (fun (h : Tast.hframe) -> f h.Tast.hfn) frames
|
|
(* A [CallPtr] roots no name: whatever it calls was reached as a value,
|
|
and the [FnAddr] that produced it is a node inside the callee. *)
|
|
| _ -> ())
|
|
e
|
|
|
|
(* ── What a body names, as one number ──────────────────────────────── *)
|
|
|
|
(* The globals half of what the two ends of a break loop compare about a frame,
|
|
and the companion to [Emit.slot_fingerprint] rather than a replacement for
|
|
it. The slot fingerprint is the right cut for [locals]: if the slots are
|
|
identical then the names still describe the storage, whatever else the body
|
|
changed. It is the wrong cut for the globals section, because a redefined
|
|
body can name entirely different globals while binding identical locals —
|
|
and then the section shows the new body's reference set attributed to the
|
|
frame of the old one.
|
|
|
|
Two fingerprints and not one combined, because the two facts are separately
|
|
useful: a frame can have perfectly readable locals and untrustworthy global
|
|
attribution, and the user should be told which. One hash over both would
|
|
make [locals] refuse a frame nothing is wrong with.
|
|
|
|
**A set, sorted and deduplicated, not the order the walk found them in.**
|
|
Slot indices make the slot fingerprint order-sensitive on purpose; a
|
|
reference set is not ordered, and a body that mentions the same two globals
|
|
the other way round is the same body as far as this is concerned.
|
|
|
|
Computed from [expr_refs], which is the walk that already answers "what does
|
|
this body refer to" — the same one [Dev]'s globals section uses to build the
|
|
union, so the two cannot disagree about what counts as a reference. Which
|
|
names are globals is the caller's to say: the emitter knows the program's
|
|
globals, and so does the session. *)
|
|
let ref_fingerprint ~is_global (fn : Tast.fn) =
|
|
let seen = Hashtbl.create 16 in
|
|
let note n = if is_global n && not (Hashtbl.mem seen n) then Hashtbl.add seen n () in
|
|
List.iter (expr_refs note) fn.Tast.body;
|
|
List.iter (expr_refs note) fn.Tast.fdefers;
|
|
let names = List.sort compare (Hashtbl.fold (fun n () acc -> n :: acc) seen []) in
|
|
Hashtbl.hash (String.concat ";" names) land 0x3fffffff
|
|
|
|
(* Every name reachable from [main] and from the globals, which run before it.
|
|
A name that is neither a function nor an extern — a global, a struct — is
|
|
still recorded; it costs a hashtable entry and saves asking twice. *)
|
|
let reachable (p : Tast.program) =
|
|
let fns = Hashtbl.create 64 in
|
|
List.iter (fun (fn : Tast.fn) -> Hashtbl.replace fns fn.Tast.name fn) p.Tast.fns;
|
|
let seen = Hashtbl.create 128 in
|
|
let queue = Queue.create () in
|
|
let visit n =
|
|
if not (Hashtbl.mem seen n) then begin
|
|
Hashtbl.add seen n ();
|
|
Queue.add n queue
|
|
end
|
|
in
|
|
List.iter (fun (g : Tast.global) -> expr_refs visit g.Tast.ginit) p.Tast.globals;
|
|
visit "main";
|
|
while not (Queue.is_empty queue) do
|
|
let n = Queue.pop queue in
|
|
match Hashtbl.find_opt fns n with
|
|
| None -> ()
|
|
| Some fn ->
|
|
List.iter (expr_refs visit) fn.Tast.body;
|
|
List.iter (expr_refs visit) fn.Tast.fdefers
|
|
done;
|
|
seen
|
|
|
|
(* A lifted handler clause is reached from its parent and from nowhere else,
|
|
and the parent names it in a [Handled] frame — so it is already in [seen]
|
|
when the parent is. Nothing extra is needed for it here; [fparent] only
|
|
matters to the dev registry. *)
|
|
|
|
let prune (p : Tast.program) =
|
|
let seen = reachable p in
|
|
let kept n = Hashtbl.mem seen n in
|
|
{ p with
|
|
Tast.fns = List.filter (fun (f : Tast.fn) -> kept f.Tast.name) p.Tast.fns;
|
|
externs =
|
|
List.filter (fun (e : Tast.extern) -> kept e.Tast.ename) p.Tast.externs }
|
|
|
|
(* ── What the build is told ────────────────────────────────────────── *)
|
|
|
|
(* The link, decided by the program rather than by the import list. [dev] is
|
|
the opt-out: a dev build keeps everything, because what a REPL may call next
|
|
is not a function of what it has called so far.
|
|
|
|
Returns the program to emit and the C and linker arguments that go with it,
|
|
which is why it is one function and not three — the three answers have to
|
|
agree, and a caller that took the flags without the pruned program would
|
|
link nothing and still emit the calls. *)
|
|
let link ?(dev = false) (l : Load.t) (p : Tast.program) =
|
|
if dev then (p, l.Load.csrcs, l.Load.lflags)
|
|
else begin
|
|
let p = prune p in
|
|
let used (pkg : Load.pkg) =
|
|
(* An extern of the package survived the prune, so something reachable
|
|
calls into the C it binds to. A package of pure Flan has no externs
|
|
and no C either, so it answers false and contributes nothing, which
|
|
is the same as contributing what it has. *)
|
|
let prefix = pkg.Load.alias ^ "/" in
|
|
List.exists
|
|
(fun (e : Tast.extern) -> String.starts_with ~prefix e.Tast.ename)
|
|
p.Tast.externs
|
|
in
|
|
(* The generated wrappers go the same way as the packages: a wrapper whose
|
|
flattened declaration did not survive the prune is a C function calling
|
|
a library symbol nothing reachable wants, and emitting it would put an
|
|
undefined reference in a link that deliberately has no such library.
|
|
The preamble stays; an unused typedef costs nothing. *)
|
|
let live (name, _) =
|
|
name = ""
|
|
|| List.exists (fun (e : Tast.extern) -> e.Tast.esym = name)
|
|
p.Tast.externs
|
|
in
|
|
let p =
|
|
match List.filter live p.Tast.cshim with
|
|
(* Nothing left but the preamble: no wrapper survived, so there is no
|
|
translation unit to compile. *)
|
|
| [ ("", _) ] | [] -> { p with Tast.cshim = [] }
|
|
| parts -> { p with Tast.cshim = parts }
|
|
in
|
|
let pkgs = List.filter used l.Load.pkgs in
|
|
(p,
|
|
List.concat_map (fun (k : Load.pkg) -> k.Load.pcsrcs) pkgs,
|
|
List.concat_map (fun (k : Load.pkg) -> k.Load.plflags) pkgs)
|
|
end
|