flan/lib/program.ml
Joseph Ferano ae46c5a878 Three sentences that were true only while the state lagged
Follow-ups to the re-run flip, and all of one kind: each said something
correct about a committed re-run that still read as parked, and the flip made
it false.

flan_merged_park's note that the program stays PROGRAM_PARKED across a poll is
now true of every round but the one it leaves on — which is the round that
drains the ring with a run already taken, and the round the fix's own argument
leans on as the long one.

Program.rerun's refusal told the reader to close a window or let the program
finish. With a thunk stopped in the break loop a re-run is accepted, the state
goes running, and a second is refused by that sentence — advice about a game
loop for somebody whose thread is in a break. The C cannot see a break; this
end can, so Dev.rerun passes what it already asked the agent and the refusal
says resume or abort instead.

The five-second timeout in eval_expr and run_render_thunk reached its
break-loop sentence through the Parked arm, so a program stopped between runs
was asked whether it calls (agent/poll). Both gain the arm that names the
break. run_render_thunk's two sentences become a function first: the new one
asks the agent, and that was the body of a five-millisecond tick.

docs/BUILT.md gains the case an editor author meets — :parked nil :stopped t
with no frames running — and FIX.org records that test_dev.ml:763's refusal
assertion was racy before this and is narrowed by it.
2026-09-21 12:11:23 +07:00

104 lines
5.6 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"
external raw_wake : unit -> int = "flan_program_wake"
let state () =
match raw_state () with 0 -> Running | 1 -> Parked | _ -> Absent
(* Ask a parked thread to drain the agent's ring, and keep it parked.
The second thing a park can service, and the one that makes an expression
evaluable against a finished program. A thunk is already in the ring by the
time this is called — it got there the way every module does — and what was
missing was anybody to pick it up: a parked thread reaches no frame boundary
and there was nothing else to wake it for.
Running it *there* is the whole design, and it is smaller than it sounds.
While parked the process has no concurrency at all — the program's thread is
asleep, no frame is executing, no global is being written — so the ring is
drained under exactly the conditions a frame boundary provides, on the
thread the program's own storage belongs to, with no second stack and no new
rule about where Flan code may run. Common Lisp answers the same question by
giving evaluation a thread of its own and calling the resulting race the
programmer's problem; this needs neither the thread nor the race.
[unit] and not a result, because there is nothing here for a caller to do
about either refusal. A running program polls its own ring at the next frame
boundary and a process with no program thread has no ring to drain; in both
the request has been satisfied by somebody else or was never this end's to
make. What a caller waits on is the value, which comes back the same way it
does from a frame boundary. *)
let wake () = ignore (raw_wake ())
(* 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.
[stopped] is the one thing the refusal cannot see for itself, and without it
the advice is wrong in the case that needs it most. The C knows the program
is not available; it does not know that the thread is sitting in a break
loop, which is a thing the agent reports and this end reads. "Close its
window, or let it finish" is what to do about a program that is running; it
is no use at all to somebody whose evaluation stopped on a breakpoint, or
who has already asked for a re-run that is waiting on that same break. Both
of those are ended by resuming or aborting, so that is what those are told.
The caller passes what it already knows — see [Dev.rerun]. *)
let rerun ?(stopped = false) () =
match raw_rerun () with
| 0 -> Ok ()
| 1 when stopped ->
Error
"the program is stopped at a break, so main cannot be started or \
restarted until that ends: resume it or abort it, and the program goes \
on from there. If a re-run was already accepted, it is waiting on the \
same thing and needs no second request"
| 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"