flan/lib/program.ml
Joseph Ferano 0c523cfe8b The program can be run again, in the process that is already there
You run a program under flan dev, it opens a raylib window, you close the
window, main returns — and there is no way to get another window short of
flan-dev-restart-program, which throws away the build, the session and every
global with it. In Common Lisp or Clojure the image outlives main, so you call
it again. The process here already outlived main: the exit hook flushed, closed
stdout and sat in for (;;) pause(). Nothing could wake it.

So main() is a loop. The hook records the status and longjmps back into a
setjmp in main() — there is no return available, since flan_exit is reached
from wherever the program happened to be — and the thread waits on a condition
variable until the new rerun op signals it. The main thread is the one that
runs main again: a window belongs to the thread that opened it, and on macOS to
the first thread of the process. A longjmp pops no frame, so the park first
empties the handler stack, the restart stack and the shadow frame chain, each
of which was a chain of allocas in stack the next run is about to write over.
Nothing else is reset; the second run reads whatever the first left in the
globals, which is the semantics that was asked for.

Closing stdout had to go with it. That was how the compiler learned the program
was done, but a pipe delivers EOF once, so the signal and the program's output
were the same resource and spending it left the second run with nowhere to
print. The descriptor hazard the old code reopened /dev/null for goes away with
the close that caused it. Liveness is asked for instead, through a weak symbol
in the same style as the agent's, and is now three states rather than two: Live,
Parked and Gone. Every guard branches on that before consulting the break
state, because the agent's listener answers "running" while the program is
parked and telling somebody whose program has finished that it is running is
worse than saying nothing. Only eval accepts a parked program — it queues and
waits for nothing, and the queued module installs at the first frame boundary
of the next run, so a body can be fixed while parked and the re-run executes
it. Everything else needs a frame boundary or a stopped stack, has neither, and
says which, naming the command that gets the program back.

A re-run while the program is running is refused rather than queued: the test
and the signal happen under one mutex, so two mains writing the same globals at
once never starts.

:parked rides on every reply beside :stopped, for the reason :stopped does —
finishing is as unannounced as stopping, more so when the way it happens is a
mouse click on a title bar. Emacs shows flan:parked in the modeline and binds
flan-rerun to C-c C-M-x.
2026-09-17 19:01:01 +07:00

62 lines
3.0 KiB
OCaml

(** The Flan program's own thread, as the compiler reaches it when both are in
one process.
[flan dev] builds one binary that is the compiled program and holds this
compiler. The program owns the main thread; this compiler is a thread
beside it. What changed to make this module necessary is that a program
which finishes no longer ends the process — the main thread parks, keeping
every global the run left behind, and can be sent round [main] again. That
is the Common Lisp and Clojure arrangement and it is wanted for their
reason: closing a window should not cost the session, and getting another
one should not cost a rebuild.
Two facts cross from there to here, and both are a call into
[lib/dynload_stubs.c] rather than anything the daemon can infer. Liveness
used to be inferred — the program closed its stdout and the compiler read
EOF off the pipe — and that could not survive a program that runs again: a
pipe delivers EOF once, so the signal and the program's output were the
same resource. Asking is what replaces it.
[Absent] means there is no program thread in this process: the [flan]
binary that builds a merged program, [flan reload], the two-process daemon,
every test. As with [Agent.request] the answer comes from the linker — the
symbols are weak and null in a binary that links no merged entry point — so
it cannot disagree with reality the way a flag could. *)
type state =
| Running (* inside [main] *)
| Parked (* finished, waiting to be sent round again *)
| Absent (* this process has no program of its own *)
external raw_state : unit -> int = "flan_program_state"
external raw_rerun : unit -> int = "flan_program_rerun"
let state () =
match raw_state () with 0 -> Running | 1 -> Parked | _ -> Absent
(* Run [main] again, on the thread that ran it before.
The thread is not negotiable and is why there is no [Thread.create] here: a
window belongs to the thread that opened it, and on macOS to the first
thread of the process, so a second [main] anywhere else would draw nothing.
This only leaves a request and wakes the sleeper, which is the same rule the
agent already follows for everything the game thread has to do.
The refusal is the program's to make, not this end's. Checking the state
here and signalling after would leave a gap for the program to finish or
start in; the C does both under one lock, so a request is either taken or
told the program is running, and never both. *)
let rerun () =
match raw_rerun () with
| 0 -> Ok ()
| 1 ->
Error
"the program is already running; a re-run starts main again, and two \
mains in one process would be writing the same globals at once. Close \
its window, or let it finish, and ask again"
| _ ->
Error
"this session's program is a process of its own, so there is no parked \
thread here to send round again; it is the merged build that can re-run \
a program, not --two-process"