flan/bin/main.ml
Joseph Ferano 23a1b6c6fb The agent: a redefinition arriving in a program that is running
vendor/agent/ is a package like any other - agent.flan declares three calls,
flan_agent.c implements them, link asks for -lpthread. start listens on a unix
socket, poll installs whatever arrived and says how many, wait does the same
after waiting for something.

The split between poll and the listener is the whole design. dlopen relocates a
module and takes the loader lock, which is milliseconds and unbounded, so it
happens on the listener thread. flan_reload_install is one store per function
and must not land while a redefined function is on the stack, so it happens on
the game thread at the top of the frame, when the program asks. A ring and two
atomics connect them; the game thread never blocks on the loader.

wait exists for tests. A test that races the frame rate fails on a loaded
machine, so test/programs/agent.flan waits for the reload rather than sleeping
past it. It also sends a junk path first: the daemon is a separate process and
can send anything, and a bad path must be refused rather than take down the
program it was sent to.

Two things came out of running it. The reply goes out before the module is
queued, because the other way round the game thread can install and the program
can exit between the two, and the answer reaches the sender as a connection
reset instead of as ok. And ok means queued, not installed - the sender does
not get to know when the swap happened, since only the program knows when it is
between frames.

sand.flan now polls at the top of its loop, which is what this step was for.
Under Xvfb, one line on the socket and 455 consecutive frames drew from a
game-draw that did not exist when the process started. Building without --dev
still works: there are no cells, so a module is refused on the listener thread
and the loop never notices.

flan reload builds one module the way the daemon will. --new names what the
host was not built with, which is the one thing the command cannot work out for
itself and exactly what the session will track.
2026-09-10 21:41:27 +07:00

166 lines
6.4 KiB
OCaml

(* flan — milestone 2 driver. *)
let with_errors path f =
try f () with
| Flan.Loc.Error (loc, msg) ->
Printf.eprintf "%s: %s\n" (Flan.Loc.to_string loc) msg;
ignore path;
exit 1
let summarise (d : Flan.Ast.decl) =
let open Flan.Ast in
match d.d with
| Package n -> Printf.sprintf "package %s" n
| Import (a, p) -> Printf.sprintf "import %s %S" a p
| Defalias (n, _) -> Printf.sprintf "defalias %s" n
| Defstruct (n, fs) -> Printf.sprintf "defstruct %s (%d fields)" n (List.length fs)
| Defunion (n, vs) -> Printf.sprintf "defunion %s (%d cases)" n (List.length vs)
| Defvar (n, _, _) -> Printf.sprintf "defvar %s" n
| Defconst (n, _, _) -> Printf.sprintf "defconst %s" n
| Declare (fn, csym) ->
Printf.sprintf "declare %s (%d params) = %s" fn.name (List.length fn.params)
csym
| Defenum (n, ms) -> Printf.sprintf "defenum %s (%d members)" n (List.length ms)
| Defn fn ->
Printf.sprintf "defn %s (%d params, %s return, %d body forms)"
fn.name (List.length fn.params)
(match fn.ret with None -> "Unit" | Some _ -> "explicit")
(List.length fn.fbody)
(* Every path past [parse] goes through [Load]: an import is resolved into the
declarations it stands for, and the package's C shim and linker arguments
come back with them. *)
let load path : Flan.Load.t =
Flan.Load.program ~file:path (Flan.Parse.program (Flan.Reader.read_file path))
let checked path = Flan.Check.program (load path).decls
(* Bounds checks are on unless a build asks for them off — the release
decision, not the optimisation level (NEXT.md, Bounds checks). *)
let no_checks_flag = "--no-bounds-checks"
(* A dev build is the one a REPL can attach to: every call goes through a cell
so a redefinition can be installed, and the cells and globals are exported
so a loaded module can reach them (NEXT.md, the dev loop). *)
let dev_flag = "--dev"
let flags = [ no_checks_flag; dev_flag ]
let () =
match Array.to_list Sys.argv with
| _ :: "read" :: files when files <> [] ->
List.iter
(fun path ->
with_errors path (fun () ->
Flan.Reader.read_file path
|> List.iter (fun f -> print_endline (Flan.Form.to_string f))))
files
| _ :: "parse" :: files when files <> [] ->
List.iter
(fun path ->
with_errors path (fun () ->
Flan.Reader.read_file path
|> Flan.Parse.program
|> List.iter (fun d -> print_endline (summarise d))))
files
| _ :: "check" :: files when files <> [] ->
List.iter
(fun path ->
with_errors path (fun () ->
let p = checked path in
List.iter
(fun (g : Flan.Tast.global) ->
Printf.printf "%s %s %s\n"
(if g.gconst then "defconst" else "defvar")
g.gname (Flan.Types.to_string g.gty))
p.globals;
List.iter
(fun (f : Flan.Tast.fn) ->
Printf.printf "defn %s : (Fn [%s] %s) %d slots\n" f.name
(String.concat " "
(List.map Flan.Types.to_string f.params))
(Flan.Types.to_string f.ret) (Array.length f.slots))
p.fns))
files
| _ :: "emit" :: args when List.exists (fun a -> not (List.mem a flags)) args ->
let checks = not (List.mem no_checks_flag args) in
let dev = List.mem dev_flag args in
let files = List.filter (fun a -> not (List.mem a flags)) args in
List.iter
(fun path ->
with_errors path (fun () ->
checked path |> Flan.Emit.program ~checks ~dev |> print_string))
files
| _ :: "build" :: path :: rest ->
let checks = not (List.mem no_checks_flag rest) in
let dev = List.mem dev_flag rest in
let out =
match List.filter (fun a -> not (List.mem a flags)) rest with
| [ "-o"; o ] -> o
| [] -> Filename.remove_extension (Filename.basename path)
| _ ->
prerr_endline
"usage: flan build <file.flan> [-o out] [--no-bounds-checks] [--dev]";
exit 2
in
with_errors path (fun () ->
let l = load path in
let p = Flan.Check.program l.decls in
ignore (Flan.Build.executable
~opts:{ Flan.Build.default with checks; dev }
~csrcs:l.csrcs ~lflags:l.lflags p ~out))
(* One redefinition, built the way the daemon will build it: the forms named
become a module the running process can install. [--new] is the names the
host was *not* built with, which is the one thing this command cannot work
out for itself — a session tracks it, a CLI has to be told. *)
| _ :: "reload" :: path :: rest when rest <> [] ->
let rec split acc out news = function
| "-o" :: o :: r -> split acc (Some o) news r
| "--new" :: n :: r ->
split acc out (news @ String.split_on_char ',' n) r
| f :: r -> split (acc @ [ f ]) out news r
| [] -> (acc, out, news)
in
let fns, out, news = split [] None [] rest in
let out =
match out with
| Some o -> o
| None -> Filename.remove_extension (Filename.basename path) ^ ".so"
in
if fns = [] then begin
prerr_endline
"usage: flan reload <file.flan> <fn>... [-o out.so] [--new name,...]";
exit 2
end;
with_errors path (fun () ->
let p = Flan.Check.program (load path).decls in
let known n = not (List.exists (String.equal n) news) in
let opts = { Flan.Build.default with dev = true } in
let t =
Flan.Build.shared ~opts
~ir:(Flan.Emit.redefinition ~dev:true ~known p ~fns) ~out ()
in
Printf.eprintf "%s llc %.1fms ld %.1fms\n" out t.Flan.Build.llc_ms
t.Flan.Build.link_ms)
| _ :: "run" :: path :: args ->
with_errors path (fun () ->
let exe =
Filename.concat (Filename.get_temp_dir_name ())
(Printf.sprintf "flan-run-%d" (Unix.getpid ()))
in
let l = load path in
let p = Flan.Check.program l.decls in
ignore (Flan.Build.executable ~csrcs:l.csrcs ~lflags:l.lflags p ~out:exe);
let code =
Sys.command (String.concat " " (List.map Filename.quote (exe :: args)))
in
(try Sys.remove exe with Sys_error _ -> ());
exit code)
| _ ->
prerr_endline
"usage: flan (read|parse|check|emit) <file.flan>...\n\
\ flan build <file.flan> [-o out] [--no-bounds-checks] [--dev]\n\
\ flan run <file.flan> [args...]\n\
\ flan reload <file.flan> <fn>... [-o out.so] [--new name,...]";
exit 2