flan/lib/session.ml
Joseph Ferano 52d3898116 Four ways C-c C-c could lie, found by trying a defconst
Asked whether a defconst could be redefined, probed it, and got ":status ok"
for a change that did nothing at all - the module was built, delivered,
installed, and the program went on using the old value. That is the
silent-wrongness class the house rule exists to prevent, so it is now four
refusals and a fix.

A defconst's value is folded into its call sites - into an array length at
worst, which is decided before any type resolves - so it lives in the running
program's code and not only in its storage. Refused. A defenum member is the
same thing: :space is erased to an i32 literal in the caller. Refused, and
compared over declarations rather than over Tast.program, which carries no
enums at all for exactly that reason.

A defvar's initial value is deliberately not refused. Its storage holds live
state the program moved past long ago, and refusing to change the initialiser
would be refusing "edit the code, keep the sand". Same Tast.global record as a
defconst, opposite answers, told apart by gconst.

The value comparison is structural and conservative - anything it does not
recognise counts as changed. Comparing emitted text would be wrong, since
Emit.const on a string allocates a name off a per-module counter and two
different strings in two throwaway modules both come out as @".str.0".

Third: a new global's declared initial value was being dropped. flan_dev_global
callocs, so (defvar n i64 42) added at run time was silently zero. It now takes
the initial value as a blob, copies it on the allocation and ignores it
afterwards - the second half being where "a reload must not reset the program's
state" lives. In the allocation path rather than a branch at the call site, so
it cannot be got wrong at one of them.

Fourth: a change with no body to publish and no storage to allocate now answers
"nothing to install" instead of shipping an empty module. That is what the
defconst probe actually did, and it cost the program a frame's worth of reload
it did not need.
2026-09-10 22:36:55 +07:00

266 lines
12 KiB
OCaml

(** A live program: the declarations a running process was built from, plus
every change accepted since.
This is what makes an editor possible. [Check.program] builds a fresh
environment from a declaration list on every call, which is exactly the
property a session needs and the reason there is no scratch-environment
machinery here: a form that fails to check leaves nothing behind, because
nothing was mutated. The list is only replaced once the check has
succeeded. Re-checking the whole program each time costs the whole frontend,
which is under 10ms — less than the [llc] that follows it.
Two things the session knows that no single evaluation could:
- **which names the running process was built with.** A name it has is a
symbol the loaded module binds to; a name it lacks goes through the
by-name registry in runtime/flan_dev.c. Getting this wrong is silent:
treating [print-line] as new gives it a registry cell nobody publishes,
and the first call jumps to null. It has to come from the *checked*
program, because [Check.program] prepends the prelude and no accumulated
AST contains it.
- **what the memory of that process looks like.** A cell is a bare pointer
and carries no signature, so a redefined function whose parameters
changed is called by every existing call site with the old ones — no link
error, no trap, a wrong number. Struct fields and global types are the
same class. Those are refused here, with the reason, rather than loaded.
Not here, and deliberately: evaluating an expression. That is a separate
primitive — synthesize a function around the form, call it, render the
value — and it is not what redefining a name is. *)
type t = {
file : string; (* resolves an import's relative path *)
mutable decls : Ast.decl list; (* post-Load: flat, one namespace *)
mutable program : Tast.program; (* the last thing that checked *)
host : Tast.program; (* what the process was built from *)
}
let fail = Loc.fail
(* Structural, and conservative: anything this does not recognise counts as
changed. Comparing emitted text instead would be wrong — [Emit.const] on a
string allocates a name off a per-module counter, so two different strings
in two throwaway modules both come out as [@".str.0"] and compare equal. *)
let rec same_const (a : Tast.expr) (b : Tast.expr) =
match (a.Tast.e, b.Tast.e) with
| Tast.Int (x, k), Tast.Int (y, l) -> Int64.equal x y && k = l
| Tast.Float (x, k), Tast.Float (y, l) -> Float.equal x y && k = l
| Tast.Bool x, Tast.Bool y -> x = y
| Tast.Str x, Tast.Str y -> String.equal x y
| Tast.Unit, Tast.Unit -> true
| Tast.Zero x, Tast.Zero y -> Types.equal x y
| Tast.Arr xs, Tast.Arr ys ->
List.length xs = List.length ys && List.for_all2 same_const xs ys
| Tast.Make (x, xs), Tast.Make (y, ys) ->
String.equal x y && List.length xs = List.length ys
&& List.for_all2 same_const xs ys
| _ -> false
let create ~file =
let l = Load.program ~file (Parse.program (Reader.read_file file)) in
let p = Check.program l.Load.decls in
({ file; decls = l.Load.decls; program = p; host = p }, l)
(* A name the running process exports. Everything else is looked up by name at
install time — see [Emit.redefinition]'s [known]. *)
let known t n =
List.exists (fun (f : Tast.fn) -> String.equal f.Tast.name n) t.host.Tast.fns
|| List.exists
(fun (g : Tast.global) -> String.equal g.Tast.gname n)
t.host.Tast.globals
(* ── What a running process cannot be told ─────────────────────────── *)
(* Everything here is a change that would load cleanly and then be wrong. The
house rule (NEXT.md, Watch for) says recognise it and refuse with the
reason, so each one names what it would have broken. *)
let compatible ~loc (old_ : Tast.program) (new_ : Tast.program) =
let find_fn p n =
List.find_opt (fun (f : Tast.fn) -> String.equal f.Tast.name n) p.Tast.fns
in
List.iter
(fun (f : Tast.fn) ->
match find_fn old_ f.Tast.name with
| None -> ()
| Some g ->
let same =
List.length f.Tast.params = List.length g.Tast.params
&& List.for_all2 Types.equal f.Tast.params g.Tast.params
&& Types.equal f.Tast.ret g.Tast.ret
in
(* A cell holds a bare pointer. Every call site compiled before this
change still passes the old arguments through it. *)
if not same then
fail loc
"%s changes signature, from (Fn [%s] %s) to (Fn [%s] %s); \
the calls already compiled into the running program pass the old \
one. Restart to change it."
f.Tast.name
(String.concat " " (List.map Types.to_string g.Tast.params))
(Types.to_string g.Tast.ret)
(String.concat " " (List.map Types.to_string f.Tast.params))
(Types.to_string f.Tast.ret))
new_.Tast.fns;
List.iter
(fun (g : Tast.global) ->
match
List.find_opt
(fun (h : Tast.global) -> String.equal h.Tast.gname g.Tast.gname)
old_.Tast.globals
with
(* A [defconst] is folded into its call sites — into an array length, at
worst, which is decided before any type resolves — so its value is in
the running program's code and not only in its storage. A [defvar]'s
initial value is the opposite case and must *not* be refused: the
storage holds live state the program has long since moved past, which
is the whole of "edit the code, keep the sand". Same record, opposite
answers, told apart by [gconst]. *)
| Some h
when h.Tast.gconst && g.Tast.gconst
&& Types.equal g.Tast.gty h.Tast.gty
&& not (same_const g.Tast.ginit h.Tast.ginit) ->
fail loc
"%s changes value; the running program folded the old one into its \
code, where a reload cannot reach it. Restart to change it."
g.Tast.gname
| Some h when not (Types.equal g.Tast.gty h.Tast.gty) ->
(* The storage exists and has a shape. Reusing it for another one
reads fields at the wrong offsets; allocating fresh storage would
silently discard the state the reload exists to preserve. *)
fail loc
"%s changes type, from %s to %s; the running program already laid \
that storage out. Restart to change it."
g.Tast.gname (Types.to_string h.Tast.gty) (Types.to_string g.Tast.gty)
| _ -> ())
new_.Tast.globals;
List.iter
(fun (s : Tast.structure) ->
match
List.find_opt
(fun (r : Tast.structure) -> String.equal r.Tast.sname s.Tast.sname)
old_.Tast.structs
with
| Some r ->
let fields (x : Tast.structure) =
List.map (fun (f : Tast.field) -> (f.Tast.fname, f.Tast.fty))
x.Tast.fields
in
let same =
List.length s.Tast.fields = List.length r.Tast.fields
&& List.for_all2
(fun (an, at) (bn, bt) -> String.equal an bn && Types.equal at bt)
(fields s) (fields r)
in
(* Every value of this type in the running program has the old layout,
including ones held in globals that the reload is preserving. *)
if not same then
fail loc
"%s changes layout; the values the running program is holding have \
the old one. Restart to change it."
s.Tast.sname
| None -> ())
new_.Tast.structs
(* An enum member is erased to an [i32] literal in the caller — [:space] at a
call site resolves to a number and is folded there — so changing one cannot
reach code that is already compiled, exactly like a [defconst]. It has to be
compared over declarations rather than over [Tast.program], which carries no
enums at all for that same reason. *)
let compatible_enums ~loc old_ new_ =
let members (ds : Ast.decl list) =
List.filter_map
(fun (d : Ast.decl) ->
match d.Ast.d with Ast.Defenum (n, ms) -> Some (n, ms) | _ -> None)
ds
in
let before = members old_ in
List.iter
(fun (n, ms) ->
match List.assoc_opt n before with
| Some old_ms when old_ms <> ms ->
fail loc
"%s changes its members; the running program folded the old values \
into every call site that names one. Restart to change it."
n
| _ -> ())
(members new_)
(* ── Accepting a change ────────────────────────────────────────────── *)
(* The redefinition unit is a list of top-level forms, so this is one path for
both editor commands: C-c C-c sends one form, C-c C-k sends a file. *)
type change = {
ir : string; (* the module to build and send *)
names : string list; (* everything the forms declared *)
fns : string list; (* the subset that has a body to install *)
(* False when the module would define nothing: no body to publish and no
storage to allocate. Building and delivering one anyway reports success
for a change that cannot have had an effect, and costs the program a
frame's worth of reload it did not need. *)
installs : bool;
}
let eval ?(origin = "<eval>") t src : change =
let forms = Reader.read_all ~file:origin src in
(* Through [Load] like any other source, so an evaluated (import ...) means
what it means in a file. Its expansion is what gets spliced, which is also
why the accumulated list is the post-Load one: re-evaluating a file that
imports something would otherwise append a second copy of the import and
the duplicate-name pass would reject it. *)
let incoming = (Load.program ~file:t.file (Parse.program forms)).Load.decls in
let loc =
match incoming with d :: _ -> d.Ast.dloc | [] -> Loc.unknown
in
let names = List.filter_map Ast.declared_name incoming in
let replacement n =
List.find_opt
(fun (d : Ast.decl) -> Ast.declared_name d = Some n)
incoming
in
(* Replaced in place and appended only when genuinely new, so declaration
order — which is emission order for globals — does not shuffle on every
evaluation. *)
let replaced = ref [] in
let kept =
List.map
(fun (d : Ast.decl) ->
match Ast.declared_name d with
| Some n ->
(match replacement n with
| Some nd -> replaced := n :: !replaced; nd
| None -> d)
| None -> d)
t.decls
in
let added =
List.filter
(fun (d : Ast.decl) ->
match Ast.declared_name d with
| Some n -> not (List.exists (String.equal n) !replaced)
| None -> false)
incoming
in
let decls = kept @ added in
(* Nothing above this line has changed the session. A [Loc.Error] from here
leaves it exactly as it was. *)
let program = Check.program decls in
compatible ~loc t.program program;
compatible_enums ~loc t.decls decls;
let fns =
List.filter
(fun n ->
List.exists
(fun (f : Tast.fn) -> String.equal f.Tast.name n)
program.Tast.fns)
names
in
let ir = Emit.redefinition ~dev:true ~known:(known t) program ~fns in
let allocates =
List.exists
(fun (g : Tast.global) -> not (known t g.Tast.gname))
program.Tast.globals
in
t.decls <- decls;
t.program <- program;
{ ir; names; fns; installs = fns <> [] || allocates }