A re-run under --two-process builds the program again from the session and starts it in a new process, and the daemon outlives a finished child

This commit is contained in:
Joseph Ferano 2026-09-25 15:35:23 +07:00
parent c30f52c5a7
commit f67498c789
5 changed files with 189 additions and 56 deletions

View File

@ -1543,12 +1543,6 @@ A finished program parks instead of dying, and a daemon op wakes it and re-enter
Globals are not reset between runs — the process never died. Rules out a fresh Globals are not reset between runs — the process never died. Rules out a fresh
process per run. process per run.
** NEXT Re-run does not work under --two-process
Decided 2026-09-25: re-run under =--two-process= starts a fresh child, installed redefinitions included, and says that globals start over because the process is new.
A finished child process is genuinely gone, so there is nothing to wake. Re-run is
merged-build only, and since the default backend runs merged it is no longer the
blocked case.
** DONE An accepted re-run reads as running ** DONE An accepted re-run reads as running
CLOSED: [2026-09-21] CLOSED: [2026-09-21]
A caller that asked for a re-run and then waited for the program to park was A caller that asked for a re-run and then waited for the program to park was

View File

@ -28,11 +28,15 @@ type t = {
(* The running program. [Some pid] is the two-process daemon, which launched (* The running program. [Some pid] is the two-process daemon, which launched
it; [None] is the merged build, where the program is *this* process and it; [None] is the merged build, where the program is *this* process and
the compiler is a thread inside it. That is the whole of the difference at the compiler is a thread inside it. That is the whole of the difference at
this layer — see [merged_setup] for why there is no third case. *) this layer — see [merged_setup] for why there is no third case. A re-run
child : int option; under --two-process replaces the child with a new one. *)
mutable child : int option;
agent : string; (* where it listens for modules *) agent : string; (* where it listens for modules *)
dir : string; (* modules are built here, one per eval *) dir : string; (* modules are built here, one per eval *)
stdout : Unix.file_descr; (* the program's output, on its way to here *) mutable stdout : Unix.file_descr; (* the program's output, on its way here *)
(* --two-process only: build the program again from the session as it is
now and start it, answering the new child and its stdout. *)
relaunch : (unit -> int * Unix.file_descr) option;
out : Buffer.t; (* ...buffered until an editor asks for it *) out : Buffer.t; (* ...buffered until an editor asks for it *)
mutable n : int; (* dlopen caches by path: never reuse one *) mutable n : int; (* dlopen caches by path: never reuse one *)
(* Bookkeeping for disassembly, and the reason it can exist at all: the (* Bookkeeping for disassembly, and the reason it can exist at all: the
@ -822,7 +826,8 @@ let build_module (c : Session.change) ~debug ~out =
spelled once so that every op tells the same story. spelled once so that every op tells the same story.
[gone] is what all of them used to say and is now said only where it is [gone] is what all of them used to say and is now said only where it is
true: there is no process left and nothing short of a new one will help. true: there is no process left and nothing short of a new one will help —
which, under --two-process, a re-run is.
[parked] is the new half, and the sentence it appends is the whole point of [parked] is the new half, and the sentence it appends is the whole point of
the distinction. Somebody reading it has a program that is *there* — its the distinction. Somebody reading it has a program that is *there* — its
@ -833,7 +838,7 @@ let build_module (c : Session.change) ~debug ~out =
refused for want of a frame boundary and an op refused for want of a stopped refused for want of a frame boundary and an op refused for want of a stopped
stack are refused by the same state for different causes, and a reader who stack are refused by the same state for different causes, and a reader who
cannot tell them apart cannot tell what to do instead. *) cannot tell them apart cannot tell what to do instead. *)
let gone = "the program exited; restart flan dev" let gone = "the program exited; M-x flan-rerun starts it again"
let parked_msg why = let parked_msg why =
why why
@ -3658,7 +3663,58 @@ let abort t =
park, the next request went out while the first run had not started, and the park, the next request went out while the first run had not started, and the
pair of them produced one run — or, a moment later, a refusal saying the pair of them produced one run — or, a moment later, a refusal saying the
program was already running. Both faces are gone with the lag. *) program was already running. Both faces are gone with the lag. *)
(* Under --two-process a finished child is gone and there is no thread to
wake, so a re-run is a new process: the program is built again from the
session as it stands, which puts every accepted redefinition in it from
the start, and its globals start over. *)
let relaunch_child t relaunch =
match liveness t with
| Live | Parked ->
error
(if parked_break t then
"the program is stopped at a break, so it cannot be started again \
until that ends: resume it or abort it"
else
"the program is still running; a re-run starts it again in a new \
process once this one has finished. Close its window, or let it \
finish, and ask again")
| Gone ->
let s = t.session in
(match Session.stale_sites s.Session.built s.Session.program with
| (x : Session.stale) :: _ as ss ->
error ~loc:(Loc.to_string x.Session.at)
(Printf.sprintf
"a re-run builds the program again, and %d call%s compiled for a \
signature %s function no longer has, starting with %s calling %s \
here. Recompile the caller with C-c C-c, or change %s back, and \
ask again"
(List.length ss)
(if List.length ss = 1 then " was" else "s were")
(if List.length ss = 1 then "its" else "their")
x.Session.caller x.Session.target x.Session.target)
| [] ->
(match relaunch () with
| child, rd ->
drain t;
(try Unix.close t.stdout with Unix.Unix_error _ -> ());
t.stdout <- rd;
t.child <- Some child;
t.finished <- false;
t.died <- None;
(* Every body is in the new host now, so no module owns one. *)
Hashtbl.reset t.owners;
ok
[ ":note "
^ Wire.quote
"started the program again in a new process, built with \
every change loaded so far; its globals start over, \
because the process is new" ]
| exception Failure m -> error m))
let rerun t = let rerun t =
match t.relaunch with
| Some relaunch -> relaunch_child t relaunch
| None ->
match liveness t with match liveness t with
| Gone -> error gone | Gone -> error gone
(* A file started with no [main] runs a stub that returns at once; running (* A file started with no [main] runs a stub that returns at once; running
@ -5057,8 +5113,11 @@ let accept_loop ?grace t ls =
where a session with no editor attached spends its time. *) where a session with no editor attached spends its time. *)
agent_check t; agent_check t;
match liveness t with match liveness t with
| Gone -> () | Gone when t.relaunch = None -> ()
| (Live | Parked) as live -> | live ->
(* A --two-process child that has ended can be started again, so the
session waits as a parked one does, on the parked grace. *)
let live = if live = Gone then Parked else live in
let idle = Unix.gettimeofday () -. !since in let idle = Unix.gettimeofday () -. !since in
if orphaned ~grace ~served:!served ~idle live then if orphaned ~grace ~served:!served ~idle live then
(* The measured gap and not the threshold it crossed: the threshold is (* The measured gap and not the threshold it crossed: the threshold is
@ -5071,7 +5130,10 @@ let accept_loop ?grace t ls =
else else
(* The program's pipe is in the same select as the listening socket: it (* The program's pipe is in the same select as the listening socket: it
has to be drained whether or not an editor is asking for anything. *) has to be drained whether or not an editor is asking for anything. *)
match Unix.select [ ls; t.stdout ] [] [] 0.2 with (* Not once it has read EOF: an ended child's pipe is readable for
ever, and the loop would spin on it. *)
let fds = if t.finished then [ ls ] else [ ls; t.stdout ] in
match Unix.select fds [] [] 0.2 with
| [], _, _ -> go () | [], _, _ -> go ()
| ready, _, _ when not (List.mem ls ready) -> drain t; go () | ready, _, _ when not (List.mem ls ready) -> drain t; go ()
| _ -> | _ ->
@ -5246,19 +5308,22 @@ let two_process ?(debug = false) ?(x86 = true) ~file ~sock () =
against each module as it loads either way, but a breakpoint set on a line against each module as it loads either way, but a breakpoint set on a line
in the .flan buffer needs a line table on both sides — the host's to fire in the .flan buffer needs a line table on both sides — the host's to fire
before the first C-c C-c, the module's to follow the reload. *) before the first C-c C-c, the module's to follow the reload. *)
let _, kept =
Build.executable
~opts:{ Build.default with Build.dev = true; Build.keep = true;
Build.debug; Build.x86 }
~csrcs ~lflags session.Session.host ~out:exe
in
(* Host and modules are chosen together, which is the whole licence: an (* Host and modules are chosen together, which is the whole licence: an
[--x86] host gets [--x86] modules because one flag set both, and the [--x86] host gets [--x86] modules because one flag set both, and the
source [Build.executable] kept is assembly rather than IR. *) source [Build.executable] kept is assembly rather than IR. *)
let host_ll = Filename.concat dir (if x86 then "host.s" else "host.ll") in let host_ll = Filename.concat dir (if x86 then "host.s" else "host.ll") in
(match kept with let build_host () =
| Some src -> (try Sys.rename src host_ll with Sys_error _ -> ()) let _, kept =
| None -> ()); Build.executable
~opts:{ Build.default with Build.dev = true; Build.keep = true;
Build.debug; Build.x86 }
~csrcs ~lflags session.Session.host ~out:exe
in
match kept with
| Some src -> (try Sys.rename src host_ll with Sys_error _ -> ())
| None -> ()
in
build_host ();
let agent = Filename.concat dir "agent.sock" in let agent = Filename.concat dir "agent.sock" in
(* The program's source names some socket path; the daemon is the one that (* The program's source names some socket path; the daemon is the one that
@ -5294,23 +5359,39 @@ let two_process ?(debug = false) ?(x86 = true) ~file ~sock () =
the pipe it was writing to; that also meant the pipe could never reach the pipe it was writing to; that also meant the pipe could never reach
EOF while the child lived, so "wait for EOF on the daemon's end" was never EOF while the child lived, so "wait for EOF on the daemon's end" was never
the mechanism it looked like it could be. *) the mechanism it looked like it could be. *)
let rd, wr = Unix.pipe ~cloexec:true () in let spawn () =
let child = Unix.create_process exe [| exe |] Unix.stdin wr Unix.stderr in (* A socket file a previous child left behind would answer the wait below
Unix.close wr; before this child has bound anything. *)
Unix.set_nonblock rd; (try Unix.unlink agent with Unix.Unix_error _ -> ());
let rd, wr = Unix.pipe ~cloexec:true () in
(* Wait for it to bind before accepting an evaluation. One that arrives first let child = Unix.create_process exe [| exe |] Unix.stdin wr Unix.stderr in
would fail for a reason that reads like a compiler bug. *) Unix.close wr;
if not (await (fun () -> Sys.file_exists agent)) then begin Unix.set_nonblock rd;
(try Unix.kill child Sys.sigterm with Unix.Unix_error _ -> ()); (* Wait for it to bind before accepting an evaluation. One that arrives
failwith first would fail for a reason that reads like a compiler bug. *)
("the program did not open its agent socket at " ^ agent if not (await (fun () -> Sys.file_exists agent)) then begin
^ ". Under --two-process every edit reaches the program through that \ (try Unix.kill child Sys.sigterm with Unix.Unix_error _ -> ());
socket.") (try Unix.close rd with Unix.Unix_error _ -> ());
end; failwith
("the program did not open its agent socket at " ^ agent
^ ". Under --two-process every edit reaches the program through that \
socket.")
end;
(child, rd)
in
let child, rd = spawn () in
(* A re-run: the host is built again from the session as it stands, so
every redefinition accepted so far is in the new process from its first
instruction rather than delivered to it later. *)
let relaunch () =
Session.rehost session;
build_host ();
spawn ()
in
let t = let t =
{ session; child = Some child; agent; dir; stdout = rd; { session; child = Some child; agent; dir; stdout = rd;
relaunch = Some relaunch;
out = Buffer.create 4096; n = 0; gen = 0; owners = Hashtbl.create 32; out = Buffer.create 4096; n = 0; gen = 0; owners = Hashtbl.create 32;
host_ll; host_exe = exe; finished = false; agent_watch = None; host_ll; host_exe = exe; finished = false; agent_watch = None;
park_noted = false; died = None; dropped = 0 } park_noted = false; died = None; dropped = 0 }
@ -5324,9 +5405,12 @@ let two_process ?(debug = false) ?(x86 = true) ~file ~sock () =
((Unix.gettimeofday () -. t0) *. 1000.); ((Unix.gettimeofday () -. t0) *. 1000.);
Fun.protect Fun.protect
~finally:(fun () -> ~finally:(fun () ->
(try Unix.kill child Sys.sigterm with Unix.Unix_error _ -> ()); (match t.child with
| Some child ->
(try Unix.kill child Sys.sigterm with Unix.Unix_error _ -> ())
| None -> ());
(try Unix.close ls with Unix.Unix_error _ -> ()); (try Unix.close ls with Unix.Unix_error _ -> ());
(try Unix.close rd with Unix.Unix_error _ -> ()); (try Unix.close t.stdout with Unix.Unix_error _ -> ());
(try Unix.unlink sock with Unix.Unix_error _ -> ())) (try Unix.unlink sock with Unix.Unix_error _ -> ()))
(fun () -> accept_loop t ls); (fun () -> accept_loop t ls);
(* Here only when the loop returned: an exception out of it has already (* Here only when the loop returned: an exception out of it has already
@ -6169,7 +6253,7 @@ let merged_setup () =
with Unix.Unix_error _ -> Sys.executable_name with Unix.Unix_error _ -> Sys.executable_name
in in
let t = let t =
{ session; child = None; agent; dir; stdout = rd; { session; child = None; agent; dir; stdout = rd; relaunch = None;
out = Buffer.create 4096; n = 0; gen = 0; owners = Hashtbl.create 32; out = Buffer.create 4096; n = 0; gen = 0; owners = Hashtbl.create 32;
host_ll; host_exe = exe; finished = false; agent_watch = None; host_ll; host_exe = exe; finished = false; agent_watch = None;
park_noted = false; died = None; dropped = 0 } park_noted = false; died = None; dropped = 0 }

View File

@ -98,6 +98,5 @@ let rerun ?(stopped = false) () =
its window, or let it finish, and ask again" its window, or let it finish, and ask again"
| _ -> | _ ->
Error Error
"this session's program is a process of its own, so there is no parked \ "this process has no program thread of its own, so there is nothing \
thread here to send round again; it is the merged build that can re-run \ here to run again"
a program, not --two-process"

View File

@ -65,7 +65,7 @@ type t = {
mutable decls : Ast.decl list; (* post-Load: flat, one namespace *) mutable decls : Ast.decl list; (* post-Load: flat, one namespace *)
mutable program : Tast.program; (* the last thing that checked *) mutable program : Tast.program; (* the last thing that checked *)
mutable env : Check.env; (* the same, as the checker sees it *) mutable env : Check.env; (* the same, as the checker sees it *)
host : Tast.program; (* what the process was built from *) mutable host : Tast.program; (* what the process was built from *)
pkgs : Load.pkg list; (* alias, directory, names owned *) pkgs : Load.pkg list; (* alias, directory, names owned *)
(* Every [defmacro] this session can expand a call to: the imports', under (* Every [defmacro] this session can expand a call to: the imports', under
their aliases, and the buffer's own, under the names the buffer writes. their aliases, and the buffer's own, under the names the buffer writes.
@ -782,6 +782,13 @@ let restore t h =
newest one and no older activation is left running. *) newest one and no older activation is left running. *)
let rerun t = t.live <- SM.empty let rerun t = t.live <- SM.empty
(* The process is about to be built again from what the session holds now
(a --two-process re-run), so that becomes what it was built from. *)
let rehost t =
t.host <- t.program;
t.built <- record_built t.env t.program t.program.Tast.fns SM.empty;
t.live <- SM.empty
(* [forms], when given, are [src] already read — [pruned] runs this over a (* [forms], when given, are [src] already read — [pruned] runs this over a
file a form fewer each round and has no text for the subset. [base] is the file a form fewer each round and has no text for the subset. [base] is the
file an [(import ...)] in them is resolved against, the session's own when file an [(import ...)] in them is resolved against, the session's own when

View File

@ -5121,20 +5121,69 @@ let () =
ignore (ask "(:op \"describe\")"); ignore (ask "(:op \"describe\")");
contains_sub (Buffer.contents seen) "42")) contains_sub (Buffer.contents seen) "42"))
then fail "--two-process: the reload was never installed"; then fail "--two-process: the reload was never installed";
(* And the one verb this shape cannot have. Running [main] again means (* A re-run here is a new process. Refused while the child runs; once
waking a thread that parked inside this process, and here the program it has finished, the program is built again from the session, so the
is a child: when it finishes it is gone, and there is nothing to wake. redefined [step] is what the new run's first line prints — the host
Refused by naming what this daemon is rather than with the message a the daemon started with would print 1. *)
merged one gives, because "the program is already running" would send
somebody back to try again after it had exited — and [--x86] arrives
here too, since it refuses the merged daemon for the -rdynamic reason
given below. *)
let r = ask "(:op \"rerun\")" in let r = ask "(:op \"rerun\")" in
let why = Option.value ~default:(status r) (Wire.string_field r "message") in let why = Option.value ~default:(status r) (Wire.string_field r "message") in
if status r <> "error" then if status r <> "error" || not (contains_sub why "still running") then
fail "--two-process answered a rerun it cannot perform" fail "--two-process: a rerun while the child runs answered %s: %s"
else if not (contains_sub why "two-process") then (status r) why;
fail "--two-process refuses a rerun as: %s" why; (* Two more deliveries take the program past its last two waits. *)
List.iter
(fun n ->
let r =
ask
(Printf.sprintf
"(:op \"eval\" :code \"(defn step [] i64 %d)\" \
:file \"/tmp/buf.flan\")" n)
in
if status r <> "ok" then fail "--two-process: eval %d was refused" n;
if not
(await (fun () ->
ignore (ask "(:op \"describe\")");
contains_sub (Buffer.contents seen) (string_of_int n)))
then fail "--two-process: %d was never installed" n)
[ 43; 44 ];
(* 44 was the old child's last line, so anything from here on is the
new child's. *)
Buffer.clear seen;
let taken = ref (ask "(:op \"describe\")") in
if not
(await ~ms:10000 (fun () ->
taken := ask "(:op \"rerun\")";
status !taken = "ok"))
then
fail "--two-process: a rerun after the child finished: %s"
(Option.value ~default:(status !taken)
(Wire.string_field !taken "message"))
else begin
let note = Option.value ~default:"" (Wire.string_field !taken "note") in
if not (contains_sub note "globals start over") then
fail "--two-process: the rerun's note does not say the globals \
start over: %S" note;
if not
(await (fun () ->
ignore (ask "(:op \"describe\")");
contains_sub (Buffer.contents seen) "\n"))
then fail "--two-process: the new child printed nothing"
else if not (String.starts_with ~prefix:"44\n" (Buffer.contents seen))
then
fail "--two-process: the new child did not start with the \
redefinition: %S" (Buffer.contents seen);
(* And it is reachable: a delivery to the new child installs. *)
let r =
ask
"(:op \"eval\" :code \"(defn step [] i64 45)\" :file \"/tmp/buf.flan\")"
in
if status r <> "ok" then fail "--two-process: eval after rerun refused";
if not
(await (fun () ->
ignore (ask "(:op \"describe\")");
contains_sub (Buffer.contents seen) "45"))
then fail "--two-process: the new child never installed a delivery"
end;
ignore (ask "(:op \"close\")"); ignore (ask "(:op \"close\")");
Unix.close tc Unix.close tc
end; end;