lib/session.ml holds the declarations a running process was built from plus every change accepted since, which is what an editor needs and what a one-shot compiler cannot have. Transactionality came for free. Check.program builds a fresh environment from a declaration list on every call, so a form that fails to check mutates nothing and the accumulated list is simply not replaced - no scratch-environment machinery, which is what I was about to build. Re-checking the whole program each evaluation costs the frontend, under 10ms, less than the llc after it. There is a test for the case that matters: a typo, then a good form, in the same session. Which names the process was built with comes from the checked program, not from any accumulated AST, because Check.program prepends the prelude and no AST contains it. Derive it from declarations and print-line reads as new, gets a registry cell nobody publishes, and the first call jumps to null. Three changes are refused with a reason rather than loaded. A function's signature, because a cell is a bare ptr and every call site compiled before the change still passes the old arguments through it. A global's type, because 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. A struct's fields, because the values the process is holding have the old layout. Note what the checker already catches on its own: change a parameter type and the caller fails to type check first, loudly. These rules only get a turn on a change the checker accepts, which is a name nothing else in the program uses - exactly where the silent version lives. Hence an unused defvar and a C-called defn in the fixtures. The accumulated list is the post-Load one, so an evaluated import is spliced as its expansion. Otherwise re-evaluating a file that imports something appends a second import, Load expands it again, and the duplicate-name pass rejects it. C-c C-k on sand.flan's own text is the test. flan reload now takes a program and a file of changed forms rather than a list of function names and a --new list: the session works out which names are new, which is the thing a bare CLI could not. Also fixed, found by running the agent test under load: the agent took SIGPIPE when a sender read part of a reply and closed. Replies go out with MSG_NOSIGNAL, per call rather than by installing a handler, because the signal disposition belongs to the program the agent is embedded in.
107 lines
5.2 KiB
OCaml
107 lines
5.2 KiB
OCaml
(* The session: the declarations a running process was built from, plus every
|
|
change accepted since (NEXT.md, the dev loop).
|
|
|
|
Two halves. First, what a session refuses — every case here is a change that
|
|
would compile, load, install, and then be wrong, because a cell is a bare
|
|
pointer and storage that already exists already has a shape. Second, that
|
|
refusing leaves the session usable, which is the failure people actually hit:
|
|
one typo must not poison every later evaluation. *)
|
|
|
|
open Flan
|
|
|
|
let failures = ref 0
|
|
let fail fmt = Printf.ksprintf (fun s -> incr failures; print_endline ("FAIL " ^ s)) fmt
|
|
|
|
let has hay needle =
|
|
let n = String.length needle and h = String.length hay in
|
|
let rec go i = i + n <= h && (String.sub hay i n = needle || go (i + 1)) in
|
|
go 0
|
|
|
|
(* Every rejection is asserted on its reason, not just on the failure: the
|
|
reason is the part that has to survive a refactor. *)
|
|
let refuses ?(file = "programs/reload.flan") name src reason =
|
|
let t, _ = Session.create ~file in
|
|
match Session.eval t src with
|
|
| _ -> fail "%s was accepted" name
|
|
| exception Loc.Error (_, msg) ->
|
|
if not (has msg reason) then
|
|
fail "%s\n said: %S\n wanted it to mention: %S" name msg reason
|
|
|
|
let () =
|
|
(* A cell carries no signature, so every call site compiled before the change
|
|
still passes the old arguments through it. *)
|
|
(* [outer] is called only from C, and [spare] is read by nothing, so the
|
|
checker has no complaint about either change and the session is the only
|
|
thing that can refuse them. A change something else in the program uses is
|
|
an ordinary type error first, which is a different and louder failure. *)
|
|
refuses "a changed parameter type"
|
|
"(defn outer [x i64] i64 (bump))"
|
|
"changes signature";
|
|
refuses "a changed return type"
|
|
"(defn outer [] i32 (i32 (bump)))"
|
|
"changes signature";
|
|
refuses "a changed arity"
|
|
"(defn outer [a i64 b i64] 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"
|
|
"(defvar spare i32)"
|
|
"changes type";
|
|
(* Values of the type are already in the running program's memory. *)
|
|
refuses ~file:"programs/values.flan" "a restructured struct"
|
|
"(defstruct P [x i32 y i32])"
|
|
"changes layout";
|
|
|
|
(* An ordinary redefinition, and what the session works out about it. *)
|
|
let t, _ = Session.create ~file:"programs/reload.flan" in
|
|
let c = Session.eval t "(defn bump [] i64 (set counter (+ counter 5)) counter)" in
|
|
if c.Session.fns <> [ "bump" ] then
|
|
fail "redefining bump reported %s" (String.concat " " c.Session.fns);
|
|
(* The prelude is in the checked program and in no accumulated AST, so a
|
|
session that derived [known] from declarations would call print-line
|
|
through a registry cell nobody ever publishes. *)
|
|
if not (has c.Session.ir "@\"flan.cell.print-line\" = external global ptr") then
|
|
fail "the prelude was treated as new";
|
|
if has c.Session.ir "flan_dev_cell" then
|
|
fail "a name the host has went through the registry";
|
|
|
|
(* A form that does not check must leave the session exactly as it was. This
|
|
is the one that decides whether a REPL survives a typo. *)
|
|
(match Session.eval t "(defn bump [] i64 nonsense)" with
|
|
| _ -> fail "an unresolvable name was accepted"
|
|
| exception Loc.Error _ -> ());
|
|
(match Session.eval t "(defn bump [] i64 (set counter (+ counter 6)) counter)" with
|
|
| c -> if c.Session.fns <> [ "bump" ] then fail "the session did not recover"
|
|
| exception Loc.Error (_, m) -> fail "the session was poisoned by a typo: %s" m);
|
|
|
|
(* Names the process was never built with go through the registry instead of
|
|
binding to a symbol, and adding one is allowed where retyping one is not. *)
|
|
let c = Session.eval t "(defvar fresh i64) (defn use-fresh [] i64 (set fresh 3) fresh)" in
|
|
if not (List.mem "fresh" c.Session.names && List.mem "use-fresh" c.Session.fns) then
|
|
fail "adding a var and a function reported %s" (String.concat " " c.Session.names);
|
|
if not (has c.Session.ir "call ptr @flan_dev_global") then
|
|
fail "a new global did not go through the registry";
|
|
(* And once added, it is part of the session: a later form can use it. *)
|
|
(match Session.eval t "(defn use-fresh [] i64 (set fresh 4) fresh)" with
|
|
| _ -> ()
|
|
| exception Loc.Error (_, m) -> fail "a name added earlier was forgotten: %s" m);
|
|
|
|
(* A file with imports, re-evaluated whole — the C-c C-k case. The session
|
|
keeps the *expanded* declarations, so the package's names are replaced in
|
|
place rather than appended a second time and rejected as duplicates. *)
|
|
let t, _ = Session.create ~file:"../sand.flan" in
|
|
let src = In_channel.with_open_bin "../sand.flan" In_channel.input_all in
|
|
(match Session.eval t src with
|
|
| c ->
|
|
if not (List.mem "game-draw" c.Session.fns) then
|
|
fail "reloading sand.flan did not include its own functions"
|
|
| exception Loc.Error (_, m) ->
|
|
fail "reloading a file with imports failed: %s" m);
|
|
|
|
if !failures = 0 then print_endline "session: all tests passed"
|
|
else begin
|
|
Printf.printf "\n%d failure(s)\n" !failures;
|
|
exit 1
|
|
end
|