From f182fb47281131f3f2bdcc887ad2212e5e806df7 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Mon, 14 Sep 2026 10:34:49 +0700 Subject: [PATCH] flan dev --x86: the host and its modules, chosen together Item 3, and the reason the backend was written. Until now --x86 was read only by flan build's argument list; the daemon built both halves through LLVM, so none of this reached the dev loop at all. The choice is a session setting, not a per-command flag, and it is spelled exactly as [debug] already is -- one field on Session.t, set once in Dev.start, carried on every change the session emits. session.ml's comment on [debug] already gives the reason and it is the same one: the modules have to match the process they are loaded into. Session.redefinition is the single place that picks a backend, so the six call sites cannot disagree and the refusal has one home. Session.change carries the answer beside the text, so the builder and the text can never come from two different decisions. There is no fallback and there must not be one. X86.redefinition refusing a form is reported to the editor; quietly building an LLVM module instead is precisely the crossed pair flan.abi.x86 exists to refuse at dlopen. A refusal reaches the editor as a diagnostic like any other -- X86.Unsupported is re-raised as a Loc.Error at the form it is about, because every caller already handles that and none handled the other, and a session that died on the first unsupported form would be worse than one that says so and stays up. flan reload got the same flag at the same time. A command that could build a module for a host the other backend compiled is how the crossed pair was reachable from the CLI at all; the aggregate handoff's two-line reproduction no longer has a second half. And the finding: flan dev --x86 refuses the merged daemon. A merged build is the program and the compiler in one process, and the compiler expands macros by dlopening a module Build.macro_module made through Emit.program, cached on disk by the macro source rather than by the backend. The merged host is linked -rdynamic so a redefinition module can reach its cells, which also exports every flan.* body it has -- so the macro module's own copy of a prelude function is interposed by the host's. With an LLVM host nobody notices. With an --x86 host the caller is LLVM and the body it lands in is this backend's, and the process dies inside flan.[clamp] during the first macro expansion, before the program has started. flan.abi.x86 does not catch it and was never meant to: a macro module deliberately neither defines nor requires a marker. The honest fix is hidden visibility on a macro module's Flan bodies, which changes the cached object for both backends and wants a lane of its own. Until then the refusal names the mechanism and the remedy, and --two-process has no such meeting. start_merged keeps its --x86 plumbing, unreachable for now, because it is the half that is right and will be wanted the day the macro module is fixed. test_dev.ml drives an --x86 daemon through C-c C-c, C-x C-e, a literal, a new defvar with a value of its own and a new defn, and asserts (twice fresh) is 82 -- which only holds if both registry lookups resolved. The merged refusal is asserted there too. bin/main.ml learned to print a bare Failure as a sentence rather than an uncaught exception and its backtrace. --- bin/main.ml | 53 +++++++++++--- lib/dev.ml | 182 +++++++++++++++++++++++++++++++++++++---------- lib/session.ml | 84 +++++++++++++++++----- test/test_dev.ml | 100 ++++++++++++++++++++++++++ 4 files changed, 353 insertions(+), 66 deletions(-) diff --git a/bin/main.ml b/bin/main.ml index 49fb41c..6f7915c 100644 --- a/bin/main.ml +++ b/bin/main.ml @@ -23,6 +23,14 @@ let with_errors path f = prerr_endline ("x86: " ^ m); ignore path; exit 3 + (* A refusal with no location: a combination of flags this command does not + offer, or a build step that failed. Every [failwith] this binary can reach + is one of those, and a sentence is what a user can act on where an + uncaught OCaml exception and its backtrace are not. *) + | Failure m -> + prerr_endline ("flan: " ^ m); + ignore path; + exit 1 let summarise (d : Flan.Ast.decl) = let open Flan.Ast in @@ -485,6 +493,15 @@ let () = each redefinition module to still be firing after C-c C-c. It implies -O0 on both, so it is asked for rather than assumed. *) let debug = List.mem debug_flag rest in + (* One flag for both halves of the session, which is what makes it safe at + all: the host and every module this daemon sends are compiled by the + same backend, because there is one place that says which. The two + conventions agree on every scalar and disagree on every aggregate, so a + crossed pair is correct until the first redefined function takes or + returns a struct — and [flan.abi.x86] refuses that pair at [dlopen] if + this is ever got wrong. Off by default: LLVM stays the default path + here exactly as it is for [flan build]. *) + let x86 = List.mem x86_flag rest in let merged = not (List.mem two_process_flag rest) in let rest = List.filter (fun a -> not (is_flag a)) rest in let sock = @@ -493,10 +510,12 @@ let () = | [] -> Filename.concat (Filename.dirname path) ".flan-dev.sock" | _ -> prerr_endline - "usage: flan dev [-s socket] [--debug] [--two-process]"; + "usage: flan dev [-s socket] [--debug] [--x86] \ + [--two-process]"; exit 2 in - with_errors path (fun () -> Flan.Dev.start ~debug ~merged ~file:path ~sock ()) + with_errors path (fun () -> + Flan.Dev.start ~debug ~merged ~x86 ~file:path ~sock ()) (* One redefinition, built the way an editor will ask for it: a session over the program the process was built from, and a file of the forms that @@ -505,6 +524,12 @@ let () = given only a list of function names could. *) | _ :: "reload" :: prog :: forms :: rest -> let debug = List.mem debug_flag rest in + (* The same flag, for the same reason, and it had to arrive with [flan + dev]'s: a command that could build a module for a host the other backend + compiled is how the crossed pair was reachable from the CLI at all. + Building an --x86 host with [flan build --x86 --dev] and then reloading + into it now has a spelling that produces a module it can load. *) + let x86 = List.mem x86_flag rest in let rest = List.filter (fun a -> not (is_flag a)) rest in let out = match rest with @@ -512,17 +537,25 @@ let () = | [] -> Filename.remove_extension (Filename.basename forms) ^ ".so" | _ -> prerr_endline - "usage: flan reload [-o out.so] [--debug]"; + "usage: flan reload [-o out.so] \ + [--debug] [--x86]"; exit 2 in with_errors forms (fun () -> - let t, _ = Flan.Session.create ~debug ~file:prog () in + let t, _ = Flan.Session.create ~debug ~x86 ~file:prog () in let src = In_channel.with_open_bin forms In_channel.input_all in let c = Flan.Session.eval ~origin:forms t src in - let opts = { Flan.Build.default with dev = true; debug } in - let timing = Flan.Build.shared ~opts ~ir:c.Flan.Session.ir ~out () in - Printf.eprintf "%s %s llc %.1fms ld %.1fms\n" out - (String.concat " " c.Flan.Session.fns) timing.Flan.Build.llc_ms + let opts = { Flan.Build.default with dev = true; debug; x86 } in + let timing = + if c.Flan.Session.x86 then + Flan.Build.shared_x86 ~opts ~asm:c.Flan.Session.ir ~out () + else Flan.Build.shared ~opts ~ir:c.Flan.Session.ir ~out () + in + (* [as] where the other path has [llc], which is the number the whole + backend exists to move. Named for what ran. *) + Printf.eprintf "%s %s %s %.1fms ld %.1fms\n" out + (String.concat " " c.Flan.Session.fns) + (if x86 then "as " else "llc") timing.Flan.Build.llc_ms timing.Flan.Build.link_ms) (* [run] builds and execs. A .wasm is not executable, and picking a runtime for it is a decision this command has no business making, so a cross @@ -555,6 +588,6 @@ let () = \ flan build [-o out] [--no-bounds-checks] [--dev] \ [--debug] [--sanitize] [--x86] [--target=wasm32-wasi|web]\n\ \ flan run [args...]\n\ - \ flan reload [-o out.so]\n\ - \ flan dev [-s socket]"; + \ flan reload [-o out.so] [--x86]\n\ + \ flan dev [-s socket] [--x86]"; exit 2 diff --git a/lib/dev.ml b/lib/dev.ml index a515446..2f41940 100644 --- a/lib/dev.ml +++ b/lib/dev.ml @@ -418,6 +418,29 @@ let error ?loc msg = ^ (match loc with None -> "" | Some l -> " :loc " ^ Wire.quote l) ^ ")" +(* One module, built by whichever backend wrote it. The choice travels on the + change rather than being asked again here, so the text and the builder can + never come from two different answers — and an [--x86] host therefore gets + [--x86] modules by construction, which is the licence [lib/x86.ml] rests on. + [flan.abi.x86] is the backstop if this is ever got wrong: a crossed pair + fails the [dlopen] naming both backends. + + The extension follows for the same reason. What [Build.shared_x86] is handed + is assembly, and the copy kept beside the [.so] is what [disassemble] reads + back ten reloads later. *) +let module_ext (c : Session.change) = if c.Session.x86 then ".s" else ".ll" + +let build_module (c : Session.change) ~debug ~out = + if c.Session.x86 then + Build.shared_x86 + ~opts:{ Build.default with Build.dev = true; Build.x86 = true; + Build.debug = debug } + ~asm:c.Session.ir ~out () + else + Build.shared + ~opts:{ Build.default with Build.dev = true; Build.debug = debug } + ~ir:c.Session.ir ~out () + (* [pause], when given, is the position of the form to stop at — §9. It rides beside the code rather than in it, and the reply echoes it back so an editor marks the buffer only for a mark the session actually applied. *) @@ -441,12 +464,12 @@ let eval t ~code ~origin ~pause = after the module. Writing our own copy beside the .so is what makes [disassemble] able to show the IR of a body installed ten reloads ago: nothing else on this machine still has that text. *) - let ll = Filename.concat t.dir (Printf.sprintf "m%d.ll" t.n) in + let ll = + Filename.concat t.dir + (Printf.sprintf "m%d%s" t.n (module_ext c)) + in write_file ll c.Session.ir; - (match Build.shared - ~opts:{ Build.default with Build.dev = true; - Build.debug = t.session.Session.debug } - ~ir:c.Session.ir ~out () with + (match build_module c ~debug:t.session.Session.debug ~out with | timing -> (match deliver t out with | "ok" -> @@ -485,10 +508,7 @@ let eval_expr t ~code ~origin ~pause = let before = match result t with Some (g, _) -> g | None -> 0L in t.n <- t.n + 1; let out = Filename.concat t.dir (Printf.sprintf "e%d.so" t.n) in - (match Build.shared - ~opts:{ Build.default with Build.dev = true; - Build.debug = t.session.Session.debug } - ~ir:c.Session.ir ~out () with + (match build_module c ~debug:t.session.Session.debug ~out with | _ -> (match deliver t out with | "ok" -> @@ -845,12 +865,7 @@ let run_render_thunk t ~tag ~(c : Session.change) : (string, string) result = let before = match result t with Some (g, _) -> g | None -> 0L in t.n <- t.n + 1; let out = Filename.concat t.dir (Printf.sprintf "%s%d.so" tag t.n) in - match - Build.shared - ~opts:{ Build.default with Build.dev = true; - Build.debug = t.session.Session.debug } - ~ir:c.Session.ir ~out () - with + match build_module c ~debug:t.session.Session.debug ~out with | exception Failure m -> Error m | _ -> (match deliver t out with @@ -1250,11 +1265,10 @@ let render_addr (s : Session.t) ~addr ~(ty : Types.t) Tast.fns = s.Session.program.Tast.fns @ [ thunk ]; externs = s.Session.program.Tast.externs @ Session.externs @ [ addr_extern ] } in - let ir = - Emit.redefinition ~dev:true ~debug:s.Session.debug ~known:(Session.known s) - ~call:name program ~fns:[ name ] - in - Ok { Session.ir; names = []; fns = []; installs = true } + (* Through the session's own chooser, so that this thunk is compiled by + whichever backend built the process it is about to be loaded into. *) + let ir = Session.redefinition s ~call:name program ~fns:[ name ] in + Ok { Session.ir; x86 = s.Session.x86; names = []; fns = []; installs = true } (* [(:op "at" :addr N :type "Enemy")] — point at any heap address. @@ -2473,7 +2487,7 @@ let accept_loop t ls = deletes it — and silently making every reloaded body -O0 would change the frame time of the one function you are iterating on, in the loop whose whole point is watching that number. *) -let two_process ?(debug = false) ~file ~sock () = +let two_process ?(debug = false) ?(x86 = false) ~file ~sock () = let t0 = Unix.gettimeofday () in (* Absolute, because every location this daemon ever reports is derived from it and an editor is not in this process's working directory. [flan dev @@ -2481,7 +2495,7 @@ let two_process ?(debug = false) ~file ~sock () = "src/game.flan:12:7", which the editor can only resolve by guessing which directory it was relative to. *) let file = try Unix.realpath file with Unix.Unix_error _ -> file in - let session, l = Session.create ~debug ~file () in + let session, l = Session.create ~debug ~x86 ~file () in let dir = Filename.concat (Filename.get_temp_dir_name ()) (Printf.sprintf "flan-dev-%d" (Unix.getpid ())) @@ -2502,11 +2516,16 @@ let two_process ?(debug = false) ~file ~sock () = ignore (Build.executable ~opts:{ Build.default with Build.dev = true; Build.keep = true; - Build.debug } + Build.debug; Build.x86 } ~csrcs:l.Load.csrcs ~lflags:l.Load.lflags session.Session.host ~out:exe); - let host_ll = Filename.concat dir "host.ll" in + (* Host and modules are chosen together, which is the whole licence: an + [--x86] host gets [--x86] modules because one flag set both, and the + source [Build.executable] kept is assembly rather than IR. *) + let host_ll = Filename.concat dir (if x86 then "host.s" else "host.ll") in (try - Sys.rename (Filename.concat (Build.workdir ()) (Filename.basename exe ^ ".ll")) + Sys.rename + (Filename.concat (Build.workdir ()) + (Filename.basename exe ^ if x86 then ".s" else ".ll")) host_ll with Sys_error _ -> ()); let agent = Filename.concat dir "agent.sock" in @@ -2859,6 +2878,34 @@ let rename_program_main ir = String.sub ir 0 i ^ "define i32 @flan_program_main(" ^ String.sub ir (i + n) (len - i - n) +(* The same rename on assembly, for an [--x86] merged build. [X86.emit_main] + writes exactly one [main] with no quotes around it -- every Flan symbol is + quoted and prefixed, so ["flan.main"] cannot be confused for it -- and the + two places it appears are the header and the [.size] that closes it. The + spike's finding holds here too: the rename is all it takes. *) +let rename_program_main_asm asm = + let hdr = "\t.globl\tmain\n\t.type\tmain, @function\nmain:\n" + and hdr' = + "\t.globl\tflan_program_main\n\t.type\tflan_program_main, @function\n\ + flan_program_main:\n" + and siz = "\t.size\tmain, . - main\n" + and siz' = "\t.size\tflan_program_main, . - flan_program_main\n" in + let replace hay needle by = + let n = String.length needle and h = String.length hay in + let rec go i = + if i + n > h then None + else if String.sub hay i n = needle then Some i + else go (i + 1) + in + match go 0 with + | None -> + failwith + "no main in the emitted assembly — the merged build renames it so a C \ + main can own the process" + | Some i -> String.sub hay 0 i ^ by ^ String.sub hay (i + n) (h - i - n) + in + replace (replace asm hdr hdr') siz siz' + (* The link, which is [Build.executable]'s with three additions: the program's [@main] renamed, the C above, and the compiler object. @@ -2874,11 +2921,17 @@ let merged_executable ~opts ~csrcs ~lflags ~pnames (p : Tast.program) ~out ~ll = a --debug build that came out -O2 makes [basis] and the listing lie. *) let opts = if opts.debug then { opts with opt = "-O0" } else opts in let tflags = target_flags opts in - let ir = - Emit.program ~checks:opts.checks ~dev:opts.dev ~debug:opts.debug ~pnames - ~sanitize:opts.sanitize p - in - write ll (rename_program_main ir); + (* The same one fork [Build.executable] has: the dev backend hands clang an + assembly file where LLVM hands it IR text, and clang takes either on its + command line, so everything past this point is the same link. *) + write ll + (if opts.x86 then + rename_program_main_asm + (X86.program ~checks:opts.checks ~dev:opts.dev ~debug:opts.debug p) + else + rename_program_main + (Emit.program ~checks:opts.checks ~dev:opts.dev ~debug:opts.debug + ~pnames ~sanitize:opts.sanitize p)); let cc src name = compile_c ~opts ~tflags ~src ~name () in let objs = (cc Runtime_src.source "flan_rt.c" @@ -2900,6 +2953,11 @@ let merged_executable ~opts ~csrcs ~lflags ~pnames (p : Tast.program) ~out ~ll = String.concat " " ([ Filename.quote clang; opts.opt; "-Wno-override-module" ] @ cflags opts + (* The hand-written DWARF 4 compile unit in the .s, for the reason + [Build.executable] gives at the same place: the assembler's own stub + line table is a DWARF 5 header otherwise, and readelf calls it + corrupt. *) + @ (if opts.x86 && opts.debug then [ "-gdwarf-4" ] else []) (* Still needed, and for the same reason: a delivered module reaches the host's cells and globals through the dynamic symbol table. *) @ (if opts.dev then [ "-rdynamic" ] else []) @@ -2946,12 +3004,13 @@ let merged_setup () = let host_ll = need_env "FLAN_DEV_HOST_LL" in let agent = need_env "FLAN_AGENT_SOCKET" in let debug = Sys.getenv_opt "FLAN_DEV_DEBUG" = Some "1" in + let x86 = Sys.getenv_opt "FLAN_DEV_X86" = Some "1" in (* The session is built a second time here rather than carried across the exec. It is the frontend only — about 12ms — and the alternative is marshalling a [Session.t] through a file, which buys nothing: the source cannot have changed between the two, because the build that produced this binary is the one that exec'd it. *) - let session, _ = Session.create ~debug ~file () in + let session, _ = Session.create ~debug ~x86 ~file () in (* The program's output has to reach an editor exactly as it did when the daemon held the other end of a pipe. Same pipe, one process: fd 1 is replaced before the program starts, and the accept loop drains it — @@ -3017,10 +3076,10 @@ let merged_serve () = (* The merged build is made here and then [exec]'d, so what an editor talks to is the program itself rather than something that launched it. The launcher does not survive: there is one process from the first reply onwards. *) -let start_merged ?(debug = false) ~file ~sock () = +let start_merged ?(debug = false) ?(x86 = false) ~file ~sock () = let t0 = Unix.gettimeofday () in let file = try Unix.realpath file with Unix.Unix_error _ -> file in - let session, l = Session.create ~debug ~file () in + let session, l = Session.create ~debug ~x86 ~file () in let dir = Filename.concat (Filename.get_temp_dir_name ()) (Printf.sprintf "flan-dev-%d" (Unix.getpid ())) @@ -3032,10 +3091,10 @@ let start_merged ?(debug = false) ~file ~sock () = in this file, so it can simply be told where to put it. It is the text clang was given, with [@main] renamed — which is what this binary really was built from, and what [basis] must not misreport. *) - let host_ll = Filename.concat dir "host.ll" in + let host_ll = Filename.concat dir (if x86 then "host.s" else "host.ll") in ignore (merged_executable - ~opts:{ Build.default with Build.dev = true; Build.debug } + ~opts:{ Build.default with Build.dev = true; Build.debug; Build.x86 } ~csrcs:l.Load.csrcs ~lflags:l.Load.lflags ~pnames:[] session.Session.host ~out:exe ~ll:host_ll); let agent = Filename.concat dir "agent.sock" in @@ -3049,6 +3108,10 @@ let start_merged ?(debug = false) ~file ~sock () = Unix.putenv "FLAN_DEV_DIR" dir; Unix.putenv "FLAN_DEV_HOST_LL" host_ll; Unix.putenv "FLAN_DEV_DEBUG" (if debug then "1" else "0"); + (* The session is rebuilt inside the exec'd binary, and it has to come back + with the same backend: the modules it emits are loaded into this very + process, which was just compiled by that backend. *) + Unix.putenv "FLAN_DEV_X86" (if x86 then "1" else "0"); (* Not read by the exec'd binary's dev path but by [Macro]: the merged binary expands the prelude a second time, and the object cache's macro key is keyed on the compiler's identity. Its own [Sys.executable_name] is this @@ -3065,6 +3128,49 @@ let start_merged ?(debug = false) ~file ~sock () = flan.cmxa beside the binary — and it is what every behaviour in this file was written against, so it stays until the transport it exists to drive is actually deleted. *) -let start ?(debug = false) ?(merged = true) ~file ~sock () = - if merged then start_merged ~debug ~file ~sock () - else two_process ~debug ~file ~sock () +let start ?(debug = false) ?(merged = true) ?(x86 = false) ~file ~sock () = + (* [--x86] and [--debug] are refused together here, and only here: [flan + build --x86 --debug] is deliberately allowed, because [X86.program] emits + a hand-written DWARF 4 unit. [X86.redefinition] does not, so a [--debug] + session would build a host with a line table and then send it modules with + none -- a breakpoint on a line in the buffer would fire before the first + C-c C-c and stop firing after it, which is worse than not offering the + combination. Accepting the flag and ignoring it would be worse still. *) + if x86 && debug then + failwith + "flan dev --x86 --debug: the dev backend emits DWARF for a whole program \ + but not yet for a redefinition module, so a breakpoint set on a line \ + would stop firing at the first C-c C-c. Use one or the other."; + (* And the merged daemon is refused outright, which is a finding and not a + convenience. A merged build is the program and the compiler in one + process, and the compiler expands macros by [dlopen]ing a module + [Build.macro_module] made -- through [Emit.program], always, and cached on + disk by the macro source rather than by the backend. A merged host is + linked [-rdynamic] so a redefinition module can reach its cells, which + also exports every [flan.*] body it has; the macro module's own copy of a + prelude function is then interposed by the host's. In an LLVM session both + are LLVM and nobody notices. In an [--x86] one the caller is LLVM and the + body it lands in is this backend's, which is the crossed pair -- measured + here as a SIGSEGV inside [flan.\[clamp\]] during the *first* macro + expansion, before the program had started. + + [flan.abi.x86] does not catch it and was never meant to: it guards a + redefinition module, and a macro module deliberately neither defines nor + requires a marker (docs/handoffs/HANDOFF-x86-abi-marker.md says so, and + the reasoning was right for what it covered). This is a third path. The + honest fix is hidden visibility on a macro module's Flan bodies, which + changes the cached object for both backends and wants a lane of its own. + + [--two-process] has no such meeting: the compiler is a separate binary + that LLVM built, the macro module is loaded into it and never into the + program, and the only thing crossing between them is a redefinition module + -- which this session now builds with the same backend as the host. *) + if x86 && merged then + failwith + "flan dev --x86 needs --two-process. A merged daemon expands macros by \ + loading a module LLVM built into the program itself, and a --rdynamic \ + x86 host interposes that module's own prelude bodies -- an LLVM caller \ + lands in an x86 body and the process dies during the first macro \ + expansion. See docs/handoffs/HANDOFF-x86-devloop.md."; + if merged then start_merged ~debug ~x86 ~file ~sock () + else two_process ~debug ~x86 ~file ~sock () diff --git a/lib/session.ml b/lib/session.ml index 8226c16..f6b0ba3 100644 --- a/lib/session.ml +++ b/lib/session.ml @@ -56,6 +56,14 @@ type t = { against and nothing to line up the host's own frames with. Both ends are set from one flag — see [Dev.start]. *) debug : bool; + (* Which backend compiles the modules this session emits, and it belongs to + the session for exactly the reason [debug] does: it has to match the + process they are loaded into. The two backends agree on every scalar and + disagree on every aggregate, so a module from one dlopened into a host + from the other is correct until the first redefined function takes or + returns a struct. Both ends are set from one flag -- see [Dev.start] -- + and [flan.abi.x86] is the backstop if they ever come apart. *) + x86 : bool; } let fail = Loc.fail @@ -103,7 +111,7 @@ let own_macros (forms : Form.t list) : Form.t list = | _ -> None) forms -let create ?(debug = false) ~file () = +let create ?(debug = false) ?(x86 = false) ~file () = let forms = Reader.read_file file in let l = Load.program ~file forms in let p, env = Check.program_with_env l.Load.decls in @@ -112,7 +120,7 @@ let create ?(debug = false) ~file () = package the program imports, the bare name wins for a form typed into that buffer. [macro_union] keeps the left. *) macros = Load.macro_union (own_macros forms) l.Load.macros; - thunks = 0; debug }, l) + thunks = 0; debug; x86 }, l) (* Which package a file being edited belongs to, if any. @@ -335,6 +343,12 @@ let compatible_enums ~loc old_ new_ = 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 *) + (* Which backend wrote [ir], and therefore which builder and which file + extension it wants: LLVM IR through [Build.shared], or x86-64 assembly + through [Build.shared_x86]. It rides on the change rather than being + looked up again at the build, so the text and the choice of builder can + never come from two different answers to the same question. *) + x86 : bool; 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 @@ -355,6 +369,45 @@ type change = { it — which is an ordinary [C-c C-c] over the same form, with no [:pause]. That is §9's settled behaviour, and it is the same one statement that accepts every other change. *) +(* The one place the backend choice is made, so that the six callers below + cannot disagree about it and the refusal has one home. + + There is no fallback and there must not be one. If [X86.redefinition] + refuses a form, the daemon reports the refusal; quietly building an LLVM + module instead would hand an [--x86] host a module from the other backend, + which is the crossed pair [flan.abi.x86] exists to refuse at [dlopen]. A + refusal a user can read is the right answer; a segfault three frames later + is not. *) +let redefinition (t : t) ?retains ?call ?(consts = []) program ~fns = + if not t.x86 then + Emit.redefinition ~dev:true ~debug:t.debug ~known:(known t) ?retains + ~consts ?call program ~fns + else + match + X86.redefinition ~checks:true ~dev:true ~known:(known t) ?retains ~consts + ?call program ~fns + with + | asm -> asm + (* The dev backend covers a subset of the IR and refuses the rest by name, + which is what makes a build that succeeds one it really compiled. A + refusal has to reach the editor as a diagnostic like every other, so it + is re-raised at the form it is about -- the first name being redefined, + which is where a reader would look. Every caller already handles + [Loc.Error]; none of them handles [X86.Unsupported], and a session that + died on the first unsupported form would be worse than one that says so + and stays up. *) + | exception X86.Unsupported m -> + let loc = + match + List.find_opt + (fun (f : Tast.fn) -> List.mem f.Tast.name fns) + program.Tast.fns + with + | Some f -> f.Tast.floc + | None -> Loc.unknown + in + fail loc "the x86 dev backend cannot compile this: %s" m + let eval ?(origin = "") ?pause t src : change = let forms = Reader.read_all ~file:origin src in Parse.with_imported t.macros @@ fun () -> @@ -534,10 +587,7 @@ let eval ?(origin = "") ?pause t src : change = program.Tast.globals) names in - let ir = - Emit.redefinition ~dev:true ~debug:t.debug ~known:(known t) ~consts program - ~fns - in + let ir = redefinition t ~consts program ~fns in let allocates = List.exists (fun (g : Tast.global) -> not (known t g.Tast.gname)) @@ -550,7 +600,7 @@ let eval ?(origin = "") ?pause t src : change = t.decls <- decls; t.program <- program; t.env <- env; - { ir; names; fns; installs = fns <> [] || allocates || consts <> [] } + { ir; x86 = t.x86; names; fns; installs = fns <> [] || allocates || consts <> [] } (* ── Evaluating an expression ──────────────────────────────────────── *) @@ -791,11 +841,10 @@ let render_locals ?(origin = "") t ~frame ~(fn : Tast.fn) ~bound externs = t.program.Tast.externs @ externs } in let ir = - Emit.redefinition ~dev:true ~debug:t.debug ~known:(known t) ~call:name - program ~fns:[ name ] + redefinition t ~call:name program ~fns:[ name ] in ignore origin; - ({ ir; names = []; fns = []; installs = true }, List.rev !refused) + ({ ir; x86 = t.x86; names = []; fns = []; installs = true }, List.rev !refused) (* ── One slot of a stopped frame, walked ───────────────────────────── *) @@ -1050,12 +1099,12 @@ let render_slot ?(origin = "") t ~frame ~(fn : Tast.fn) ~slot ~path externs = t.program.Tast.externs @ externs } in let ir = - Emit.redefinition ~dev:true ~debug:t.debug ~known:(known t) + redefinition t ~call:tname program ~fns:[ tname ] in ignore origin; Ok - ({ ir; names = []; fns = []; installs = true }, + ({ ir; x86 = t.x86; names = []; fns = []; installs = true }, name ^ path_text path, Types.to_string v.Tast.ty))) @@ -1139,11 +1188,10 @@ let render_globals ?(origin = "") t ~(globals : Tast.global list) externs = t.program.Tast.externs @ externs } in let ir = - Emit.redefinition ~dev:true ~debug:t.debug ~known:(known t) ~call:name - program ~fns:[ name ] + redefinition t ~call:name program ~fns:[ name ] in ignore origin; - ({ ir; names = []; fns = []; installs = true }, List.rev !refused) + ({ ir; x86 = t.x86; names = []; fns = []; installs = true }, List.rev !refused) (* [pause] is [C-u C-x C-e] — §9's "last expression" target. It is a flag and not a position, because there is only one form here and it is the whole of @@ -1234,8 +1282,8 @@ let eval_expr ?(origin = "") ?(pause = false) t src : change = function nobody sets a breakpoint on by name, but it is a frame on the stack when the expression signals, and a frame the debugger cannot name is the thing the conditions buffer is trying to stop showing. *) - Emit.redefinition ~dev:true ~debug:t.debug ~known:(known t) ~call:name - program ~fns:(List.map (fun (f : Tast.fn) -> f.Tast.name) fresh @ [ name ]) + redefinition t ~call:name program + ~fns:(List.map (fun (f : Tast.fn) -> f.Tast.name) fresh @ [ name ]) in (* The copies stay in the session's program, unlike the thunk: the thunk is not a declaration and there is nothing to keep, but a copy that has been @@ -1247,7 +1295,7 @@ let eval_expr ?(origin = "") ?(pause = false) t src : change = a body that no module was ever written for. A daemon that answers and has lost track of what the program contains is worse than one that died. *) t.program <- { t.program with Tast.fns = t.program.Tast.fns @ fresh }; - { ir; names = []; fns = []; installs = true } + { ir; x86 = t.x86; names = []; fns = []; installs = true } (* ── What a macro call expands to ──────────────────────────────────── *) diff --git a/test/test_dev.ml b/test/test_dev.ml index 715a091..d549100 100644 --- a/test/test_dev.ml +++ b/test/test_dev.ml @@ -3132,6 +3132,106 @@ let () = List.iter (fun f -> try Sys.remove f with Sys_error _ -> ()) [ rsock; rout; rerr ]; + (* The dev loop on the other backend, through the daemon rather than + through the library. [test_reload.ml] builds an --x86 host and --x86 + modules by hand and loads them into a C host; this is the same thing + arriving the way a user meets it — one flag on [flan dev], and every + module the session emits compiled by the backend that built the process + they are loaded into. A C-c C-c that works in [test_reload.ml] and not + here is not a dev loop. + + [--two-process], because [--x86] refuses the merged daemon and says why: + a merged host exports every [flan.*] body for -rdynamic and so interposes + the prelude bodies of the LLVM-built macro module the compiler loads into + itself. The refusal is asserted below. *) + let xsock2 = tmp "x86.sock" and xout2 = tmp "x86.out" in + (try Sys.remove xsock2 with Sys_error _ -> ()); + let xfd2 = + Unix.openfile xout2 [ Unix.O_WRONLY; Unix.O_CREAT; Unix.O_TRUNC ] 0o600 + in + let xpid2 = + Unix.create_process flan + [| flan; "dev"; "programs/dev-repl.flan"; "-s"; xsock2; "--x86"; + "--two-process" |] + Unix.stdin xfd2 Unix.stderr + in + Unix.close xfd2; + if not (listening ~pid:xpid2 xsock2) then begin + fail "the --x86 daemon %s" !listen_why; + (try Unix.kill xpid2 Sys.sigkill with Unix.Unix_error _ -> ()) + end + else begin + let c = connect xsock2 in + let value r = Option.value ~default:"" (Wire.string_field r "value") in + let said r = Option.value ~default:"" (Wire.string_field r "message") in + (* C-c C-c on a name the host was built with: the case X86.redefinition + could already compile, now reached through the daemon. *) + let r = + request c + "(:op \"eval\" :code \"(defn step [] i64 (set ticks (+ ticks 5)) ticks)\" :file \"/tmp/x86buf.flan\")" + in + if status r <> "ok" then fail "x86 C-c C-c: %s" (said r); + (* C-x C-e: the transient thunk and the marker the agent unloads on, + which is a different emitter from the one above. *) + let r = + request c "(:op \"eval-expr\" :code \"(+ 2 3)\" :file \"/tmp/x86buf.flan\")" + in + if status r <> "ok" then fail "x86 C-x C-e: %s" (said r) + else if value r <> "5" then fail "x86 C-x C-e answered %S" (value r); + (* A string literal in the thunk. The module keeps its mapping rather + than claiming to be transient — the value is copied out, but a module + holding a literal can never say nothing points into it. *) + let r = + request c + "(:op \"eval-expr\" :code \"\\\"hi\\\"\" :file \"/tmp/x86buf.flan\")" + in + if status r <> "ok" then fail "x86 C-x C-e on a literal: %s" (said r) + else if value r <> "\"hi\"" then + fail "x86 C-x C-e on a literal answered %S" (value r); + (* A defvar the host has no storage for, with a value of its own, and a + defn the host has no cell for: both go through flan_dev.c's registry + into slots this backend fills at install time. The expression after + them reads one and calls the other, so the answer is what says the + lookups resolved rather than that the module merely loaded. *) + let r = + request c + "(:op \"eval\" :code \"(defvar fresh i64 41)\" :file \"/tmp/x86buf.flan\")" + in + if status r <> "ok" then fail "x86 new defvar: %s" (said r); + let r = + request c + "(:op \"eval\" :code \"(defn twice [x i64] i64 (* x 2))\" :file \"/tmp/x86buf.flan\")" + in + if status r <> "ok" then fail "x86 new defn: %s" (said r); + let r = + request c + "(:op \"eval-expr\" :code \"(twice fresh)\" :file \"/tmp/x86buf.flan\")" + in + if status r <> "ok" then fail "x86 new name round trip: %s" (said r) + else if value r <> "82" then + fail "x86 (twice fresh) answered %S, so the registry lookups did not resolve" (value r); + ignore (request c "(:op \"close\")"); + (try Unix.close c with Unix.Unix_error _ -> ()); + (try ignore (Unix.waitpid [] xpid2) with Unix.Unix_error _ -> ()) + end; + (* And the merged daemon refuses --x86 by name. A refusal is the whole + deliverable here: the alternative was a SIGSEGV inside a prelude + function during the first macro expansion, before the program started, + with nothing having said a word. *) + let mout = tmp "x86merged.err" in + let mcode = + Sys.command + (Printf.sprintf "%s dev programs/dev-repl.flan --x86 > /dev/null 2> %s" + (Filename.quote flan) (Filename.quote mout)) + in + let msaid = In_channel.with_open_bin mout In_channel.input_all in + if mcode = 0 then fail "the merged daemon accepted --x86 (exit 0)"; + if not (contains_sub msaid "--two-process") then + fail "the merged daemon refused --x86 without naming the remedy: %S" + msaid; + List.iter (fun f -> try Sys.remove f with Sys_error _ -> ()) + [ xsock2; xout2; mout ]; + List.iter (fun f -> try Sys.remove f with Sys_error _ -> ()) [ sock; out; bsock; bout ]; if !failures = 0 then print_endline "dev: all tests passed"