From cf410c5da3c00eec01131f962543000128bbc7b6 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Fri, 18 Sep 2026 07:24:45 +0700 Subject: [PATCH 1/4] A declaration that fails to build or deliver leaves the session as it found it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session.eval committed the checked program the moment a form checked, and the two steps that can still refuse it — the build, and the agent taking the module — come after that, in the daemon. Either one left the editor holding an error and the session holding a declaration the process has no body for. The next module built for that session lists the name in its install prologue, which interns a cell for it and never stores anything into it, and a dev build's call through a cell has no null test in front of it: the game thread jumps to address 0 at the next C-x C-e, locals render or globals refresh. Session.held takes the four fields eval commits as one unit and Session.restore puts them back. Dev.eval and Dev.eval_expr take one before checking and restore it on every arm where nothing was accepted — a failed build, a refused delivery, an unreachable agent. eval_expr needed it for the generic instances it keeps, whose own comment already claimed the invariant: "the session must not come to believe it holds a body that no module was ever written for." A delivered module that then times out is not a refusal and does not roll back: the agent has it and will install it at a frame boundary. --- lib/dev.ml | 47 +++++++++++++++++++++---- lib/session.ml | 81 +++++++++++++++++++++++++++++++++++++++++--- test/test_session.ml | 49 +++++++++++++++++++++++++++ 3 files changed, 166 insertions(+), 11 deletions(-) diff --git a/lib/dev.ml b/lib/dev.ml index c671d76..5690dce 100644 --- a/lib/dev.ml +++ b/lib/dev.ml @@ -545,6 +545,18 @@ let parked why = error (parked_msg why) let eval t ~code ~origin ~pause = let now = liveness t in let parked_now = now = Parked in + (* What the session was before the form was checked, and every failure below + puts it back. [Session.eval] commits as soon as the check succeeds, which + is two fallible steps too early: the build can fail and the agent can + refuse, and a session left holding a declaration no module was accepted + for hands the *next* module a name to intern a cell for and nothing to put + in it — a null cell the first call through jumps to. See [Session.held]. + + Only the failures restore. The [installs = false] arm is a real + acceptance: there is nothing to build and nothing to deliver, so there is + nothing that can go wrong after it. *) + let before = Session.held t.session in + let refused msg = Session.restore t.session before; error msg in if now = Gone then error gone else match Session.eval ~origin ?pause t.session code with @@ -595,13 +607,20 @@ let eval t ~code ~origin ~pause = when it is run again rather than at its next frame \ boundary" ] else [])) - | reply -> error ("the program refused the module: " ^ reply) + | reply -> refused ("the program refused the module: " ^ reply) | exception Unix.Unix_error (e, _, _) -> - error + refused ("cannot reach the program on " ^ t.agent ^ ": " ^ Unix.error_message e)) - | exception Failure m -> error m) - | exception Loc.Error { Loc.dloc = l; dmsg = msg; _ } -> error ~loc:(Loc.to_string l) msg + | exception Failure m -> refused m) + (* Nothing to put back: the check itself raised, so [Session.eval] never + reached its assignments. The restore is written anyway rather than + reasoned about at each arm — a refusal that costs one record copy is + cheaper than a reader working out which of the four fields this one + could have moved. *) + | exception Loc.Error { Loc.dloc = l; dmsg = msg; _ } -> + Session.restore t.session before; + error ~loc:(Loc.to_string l) msg (* Redefining a name installs a body; evaluating an expression has no name to install into, so the module carries a thunk the agent runs once. The value @@ -620,6 +639,15 @@ let eval_expr t ~code ~origin ~pause = "an expression is evaluated at a frame boundary, and a parked program \ reaches none" | Live -> + (* The same rollback [eval] takes, for the same reason and a smaller + cargo. A thunk is not a declaration and never joins the session, but the + generic instances the expression forced *are* kept — [Session.eval_expr] + says why — and they are kept before this module has been built or taken. + An instance the session holds and no module ever defined is a null cell + exactly as a stranded [defn] is, and the next expression that mentions + the same instantiation would list it as already there. *) + let held = Session.held t.session in + let refused msg = Session.restore t.session held; error msg in match Session.eval_expr ~origin ~pause t.session code with | c -> let before = match result t with Some (g, _) -> g | None -> 0L in @@ -682,10 +710,15 @@ let eval_expr t ~code ~origin ~pause = error "the program did not reach a frame boundary; is it calling \ (agent/poll)?") - | reply -> error ("the program refused the module: " ^ reply) + (* A module that was taken is the program's from here on, whatever + the wait then says: a timeout is a frame boundary not reached + yet, not a module refused, so the instances in it stay in the + session. Only the two arms below, where nothing was accepted, + put the session back. *) + | reply -> refused ("the program refused the module: " ^ reply) | exception Unix.Unix_error (e, _, _) -> - error ("cannot reach the program: " ^ Unix.error_message e)) - | exception Failure m -> error m) + refused ("cannot reach the program: " ^ Unix.error_message e)) + | exception Failure m -> refused m) | exception Loc.Error { Loc.dloc = l; dmsg = msg; _ } -> error ~loc:(Loc.to_string l) msg (* What a macro call expands to — [C-c C-m], and the one verb here that never diff --git a/lib/session.ml b/lib/session.ml index 0a3e4cb..f1c12e8 100644 --- a/lib/session.ml +++ b/lib/session.ml @@ -408,6 +408,67 @@ let redefinition (t : t) ?retains ?call ?(consts = []) program ~fns = in fail loc "the x86 dev backend cannot compile this: %s" m +(* ── Undoing an acceptance ─────────────────────────────────────────── *) + +(* Checking is not the last thing that can fail, and until this existed the + session behaved as though it were. [eval] assigns the four mutable fields + the moment a form checks; the *build* and the *delivery* happen afterwards, + in the daemon, and either can refuse — llc can fail, and the agent's reload + ring can be full, which a parked program guarantees after enough queued + installs, since nothing drains the ring while the main thread waits. The + editor saw an error either way, so the evaluation looked refused; the + session went on holding the declaration anyway. + + That leftover is not untidiness, it is a segfault. A declaration the session + holds and the host does not export is a name every later module lists in its + install prologue, and the prologue interns a cell for it. A cell nothing + ever stored a body into is NULL, and a dev build's call through a cell is a + load and an indirect call with no test in front of it — so the next + [C-x C-e], the next locals render, the next globals refresh jumps the game + thread to address 0. One failed build, and the next thing anybody types + kills the program. + + So the four fields are one unit that can be put back. [held] is what the + session was before the evaluation and [restore] is the session being that + again — all four together, because they are four views of one answer: the + declarations, the checked program, the checker's environment, and the macros + a later form expands against. Putting back three of them would leave the + checker willing to accept a call to a name the program does not have, which + is the same crash by a longer road. + + What a restored session can claim is the honest sentence rather than the one + anyone would rather have: it holds no declaration that no module was + *accepted* for. Accepted is not installed — the agent queues a module and + the game thread installs it at a frame boundary — but a queued module is one + the process has and will run, and that is as far as this side can see. + + A [defmacro] evaluated in the same breath as a [defn] that fails to build + goes back with it, and that is right: they arrived as one form and were + accepted as one. A [defmacro] on its own never reaches a restore, because a + change with no body to install and no storage to allocate is answered before + anything is built. + + [thunks] is not in here. It is a counter that keeps two evaluations from + naming their thunks alike, not something the session believes about the + program, and the agent refuses a full ring *before* it dlopens — so a + rolled-back number would be reused for a module nothing ever mapped, and + reusing it is the one way to make two live modules share a symbol. *) +type held = { + hdecls : Ast.decl list; + hprogram : Tast.program; + henv : Check.env; + hmacros : Form.t list; +} + +let held t = + { hdecls = t.decls; hprogram = t.program; henv = t.env; hmacros = t.macros } + +let restore t h = + t.decls <- h.hdecls; + t.program <- h.hprogram; + t.env <- h.henv; + t.macros <- h.hmacros + let eval ?(origin = "") ?pause t src : change = let forms = Reader.read_all ~file:origin src in Parse.with_imported t.macros @@ fun () -> @@ -593,9 +654,15 @@ let eval ?(origin = "") ?pause t src : change = (fun (g : Tast.global) -> not (known t g.Tast.gname)) program.Tast.globals in - (* Every one of these together, and after the last thing that can raise: - until this line the session is still the one the evaluation started - against, which is what makes a refusal cost nothing. *) + (* Every one of these together, and after the last thing *here* that can + raise: until this line the session is still the one the evaluation started + against, which is what makes a refusal cost nothing. + + It is not the last thing that can fail, though, and this line used to be + written as if it were. The build and the delivery come after it, in the + daemon, and both can refuse — so the caller takes a [held] first and puts + it back when they do. See [restore] above for what a session that kept the + declaration anyway does to the program. *) t.macros <- !macros; t.decls <- decls; t.program <- program; @@ -1297,7 +1364,13 @@ let eval_expr ?(origin = "") ?(pause = false) t src : change = After [Emit], not before it: the session must not come to believe it holds 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. *) + lost track of what the program contains is worse than one that died. + + Written for is as far as this line can get, and it is not far enough on + its own: the module still has to build and still has to be taken. The + caller closes that half by taking a [held] before this and restoring it + when either fails — a copy the session holds and no module defines is a + null cell exactly as a stranded declaration is. *) t.program <- { t.program with Tast.fns = t.program.Tast.fns @ fresh }; { ir; x86 = t.x86; names = []; fns = []; installs = true } diff --git a/test/test_session.ml b/test/test_session.ml index 4d03c9c..cc7e2c5 100644 --- a/test/test_session.ml +++ b/test/test_session.ml @@ -189,6 +189,55 @@ let () = | exception Loc.Error { Loc.dmsg = m; _ } -> fail "the session was poisoned by a bad expression: %s" m); + (* The other half of "a refusal costs nothing", and the half that used to be + missing: a form can check and *then* fail, in the build or at the agent, + and the session that already accepted it has no way to hear about it + unless the caller puts it back. [Session.held] and [Session.restore] are + that way, and this is the crash they close. + + Rehearsed rather than simulated: neither llc nor a full reload ring can be + summoned from here, and what matters is not which of them failed but what + the session does afterwards. So the declaration is accepted, shown to be + callable — the module for an expression that calls it names it, which is + the name a later install prologue would intern a cell for and never store + a body into — and then the session is put back and asked again. + + Both sides are asserted. Without the first, a test that only checked the + refusal would pass on a session that had never accepted the [defn] at + all. *) + (let rt, _ = Session.create ~file:"programs/reload.flan" () in + let h = Session.held rt in + (match Session.eval rt "(defn stranded [] i64 7)" with + | c -> + if c.Session.fns <> [ "stranded" ] then + fail "a new declaration did not offer its body to install" + | exception Loc.Error { Loc.dmsg = m; _ } -> + fail "a new declaration was refused: %s" m); + (match Session.eval_expr rt "(println (stranded))" with + | e -> + if not (has e.Session.ir "stranded") then + fail "an expression calling a fresh declaration did not name it" + | exception Loc.Error { Loc.dmsg = m; _ } -> + fail "a fresh declaration was not callable: %s" m); + Session.restore rt h; + (* The module that would have jumped to address 0. After the restore the + name is simply not one this session has, which is the whole of the + fix: a refusal in the editor instead of a segfault in the program. *) + (match Session.eval_expr rt "(println (stranded))" with + | _ -> + fail "a restored session still let an undelivered declaration be called" + | exception Loc.Error { Loc.dmsg = m; _ } -> + if not (has m "stranded") then + fail "the refusal after a restore was about something else: %S" m); + (* And it is a restore, not a poisoning: the same form offered again is + accepted again, which is what an editor does after reading the error. *) + match Session.eval rt "(defn stranded [] i64 7)" with + | c -> + if c.Session.fns <> [ "stranded" ] then + fail "a re-sent declaration did not offer its body to install" + | exception Loc.Error { Loc.dmsg = m; _ } -> + fail "the session was poisoned by a restore: %s" m); + (* A declaration the program already has, with no body and no new storage, is accepted and has nothing to send. Building a module for it would report success for a change that cannot have taken effect, and would cost the From d36c77ba6be6d81c9c14b94dd41285099a44a496 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Fri, 18 Sep 2026 07:26:07 +0700 Subject: [PATCH 2/4] A parked program's full reload ring says why it is full MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The agent refuses a module it has no room to queue with "the program is not calling agent/poll". For a running program that is the cause. For a parked one it is confidently wrong: there is no game thread left to poll with, nothing drains the ring until somebody runs the program again, and the ring is full precisely because Dev.eval accepts evaluations while parked and promises they install at the next run. At install sixty-five that promise breaks, and the reply sent the reader to inspect a loop that is not running. The agent cannot know this — parking is the merged shim's state — so the daemon, which has just asked liveness, rewrites that one reply the way abi_mismatch rewrites dlerror's. Every other refusal is still quoted in the agent's own words, and a running program's is unchanged. Pinned on Dev.refusal rather than end to end: filling the ring is sixty-four real clang runs, and what the daemon does with the agent's words is the whole of the change. --- lib/dev.ml | 47 ++++++++++++++++++++++++++++++++++++++++------- test/test_dev.ml | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 7 deletions(-) diff --git a/lib/dev.ml b/lib/dev.ml index 5690dce..25d3ff7 100644 --- a/lib/dev.ml +++ b/lib/dev.ml @@ -527,6 +527,40 @@ let parked_msg why = let parked why = error (parked_msg why) +let contains hay needle = + let n = String.length needle and h = String.length hay in + let rec go i = i + n <= h && (String.sub hay i n = needle || go (i + 1)) in + n = 0 || go 0 + +(* A module the agent would not take, said in the daemon's words rather than + only in the agent's. + + The agent refuses a full reload ring with "the program is not calling + agent/poll", which is the right cause for a program that is running and the + wrong one for a program that has finished. A parked process is not failing + to poll: it has no game thread left to poll with, it is waiting in the park + for somebody to run it again, and nothing drains the ring until then — so + the sixty-fifth module queued since it parked is refused for the very + behaviour [eval] below promises it — that a body redefined while parked + installs when the program is run again. A reader told to check their + [agent/poll] calls would go looking at a loop that is not running. + + The agent cannot say this itself. Parking is the merged shim's state and the + agent is vendored beside it knowing nothing about runs; what knows is this + daemon, which has just asked [liveness] and is about to quote a reply. So + the substitution is made here, on a substring of a reply this side did not + write — the same move [abi_mismatch] makes on [dlerror]'s text, and made for + the same reason: the component that has the words does not have the + context. *) +let refusal ~parked reply = + if parked && contains reply "reload queue full" then + "the program refused the module: its reload ring is full, and a parked \ + program drains none of it — every module queued since it finished is \ + still waiting for a frame boundary, and M-x flan-rerun is what gives it \ + one; this one was not queued, so send it again once the program is \ + running" + else "the program refused the module: " ^ reply + (* [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. @@ -607,7 +641,7 @@ let eval t ~code ~origin ~pause = when it is run again rather than at its next frame \ boundary" ] else [])) - | reply -> refused ("the program refused the module: " ^ reply) + | reply -> refused (refusal ~parked:parked_now reply) | exception Unix.Unix_error (e, _, _) -> refused ("cannot reach the program on " ^ t.agent ^ ": " @@ -714,7 +748,11 @@ let eval_expr t ~code ~origin ~pause = the wait then says: a timeout is a frame boundary not reached yet, not a module refused, so the instances in it stay in the session. Only the two arms below, where nothing was accepted, - put the session back. *) + put the session back. + + No [refusal] either, and that is not an omission: this verb + refuses a parked program before it compiles anything, so the one + reply whose cause that rewrites cannot arrive here. *) | reply -> refused ("the program refused the module: " ^ reply) | exception Unix.Unix_error (e, _, _) -> refused ("cannot reach the program: " ^ Unix.error_message e)) @@ -2064,11 +2102,6 @@ let run_capture cmd = let code = match Unix.close_process_in ic with Unix.WEXITED c -> c | _ -> -1 in (code, Buffer.contents b) -let contains hay needle = - let n = String.length needle and h = String.length hay in - let rec go i = i + n <= h && (String.sub hay i n = needle || go (i + 1)) in - n = 0 || go 0 - (* The IR of one function out of a module's text. [Emit] writes a define's closing brace at column 0 and nowhere else, so the end is unambiguous without parsing LLVM. One .ll can carry several bodies — [C-c C-k] sends a diff --git a/test/test_dev.ml b/test/test_dev.ml index 32c67df..05190c6 100644 --- a/test/test_dev.ml +++ b/test/test_dev.ml @@ -152,6 +152,42 @@ let contains_sub hay needle = in go 0 +(* ── Whose fault a full reload ring is ──────────────────────────────── *) + +(* The agent refuses a module it has no room to queue with "the program is not + calling agent/poll", and for a running program that is the cause. For a + parked one it is the wrong cause said confidently: there is no game thread + left to poll with, the ring is full precisely *because* the program + finished, and [Dev.eval]'s own note promises exactly that a body redefined + while parked installs when the program is run again. Somebody sent + the sixty-fifth one, and the reply sent them to read a loop that is not + running. + + Pinned on the decision rather than end to end. Filling the ring means + sixty-four modules through a real clang, which is minutes of [dune test] to + assert one sentence; what the daemon *does* with the agent's words is the + whole of the change, and it is a function of two arguments. The pass-through + is asserted too: a running program's refusal must still arrive in the + agent's own words, because for it they are true. *) +let () = + let full = "err reload queue full; the program is not calling agent/poll" in + let parked = Dev.refusal ~parked:true full in + if contains_sub parked "not calling agent/poll" then + fail "a parked program's queue-full refusal still blames its poll: %S" + parked; + if not (contains_sub parked "flan-rerun") then + fail "a parked program's queue-full refusal names no way out: %S" parked; + let live = Dev.refusal ~parked:false full in + if not (contains_sub live "not calling agent/poll") then + fail "a running program's queue-full refusal lost the agent's reason: %S" + live; + (* Only that one reply is rewritten. Every other refusal a parked program + can give — a bad ABI, a module with no installer — is the agent's to + explain and is quoted as it stands. *) + let other = Dev.refusal ~parked:true "err flan.abi.x86: the module is x86" in + if not (contains_sub other "flan.abi.x86") then + fail "a parked program's other refusals were rewritten too: %S" other + let () = match Sys.command "command -v clang > /dev/null 2>&1 && command -v llc > /dev/null 2>&1" with | 0 -> From b23dcf73f31f88229068545c0dd4661b0aad2114 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Fri, 18 Sep 2026 07:42:41 +0700 Subject: [PATCH 3/4] The session is put back however the module failed to land MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three named arms are not every way a step after the check can fail. write_file makes the daemon's copy of the module's text before llc is called at all, and a Sys_error from it went out through serve's guard with the session already holding the declaration — the same stranded declaration under a different exception. Dev.eval now restores on the way past anything that escapes, and carries an accepted flag so that a module the agent has already taken is never rolled back out from under the process. That is also the seam the crash can be reproduced through. test_dev takes the daemon's working directory away from it — its own, not the macro cache the two failures above use, which cannot fail a build with no macro in it — evaluates a defn, and then calls it. Without the restore the daemon lists probe-two in describe and the call closes the socket: the null cell, the game thread, and address 0. --- lib/dev.ml | 19 +++++++++++ test/test_dev.ml | 86 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 105 insertions(+) diff --git a/lib/dev.ml b/lib/dev.ml index 25d3ff7..a4a575f 100644 --- a/lib/dev.ml +++ b/lib/dev.ml @@ -603,6 +603,23 @@ let eval t ~code ~origin ~pause = [ ":names " ^ Wire.strings c.Session.names; ":fns ()"; ":note " ^ Wire.quote "nothing to install" ] | c -> + (* Everything from here to the delivery is inside the restore, and by + exception type as well as by arm. The three named below are the ones + with a sentence to say; what is left is every other way a file system + can refuse — [write_file] cannot create its copy of the module's text, + the working directory went away underneath the daemon — which used to + leave through [serve]'s guard with the session already holding the + declaration. That is the same stranding under a different exception, + and [serve] still writes the reply: this only puts the session back on + the way past. + + [accepted] is what the catch-all needs to know and the arms cannot + tell it. Once the agent has said "ok" the module is the program's, + whatever goes wrong while this reply is being written, and rolling + the session back then would strand the declaration the other way + round — the process holding a body the session has forgotten. *) + let accepted = ref false in + (try t.n <- t.n + 1; let out = Filename.concat t.dir (Printf.sprintf "m%d.so" t.n) in (* [Build.shared] deletes its own .ll unless asked to keep it, and what it @@ -619,6 +636,7 @@ let eval t ~code ~origin ~pause = | timing -> (match deliver t out with | "ok" -> + accepted := true; t.gen <- t.gen + 1; List.iter (fun n -> @@ -647,6 +665,7 @@ let eval t ~code ~origin ~pause = ("cannot reach the program on " ^ t.agent ^ ": " ^ Unix.error_message e)) | exception Failure m -> refused m) + with e when not !accepted -> Session.restore t.session before; raise e) (* Nothing to put back: the check itself raised, so [Session.eval] never reached its assignments. The restore is written anyway rather than reasoned about at each arm — a refusal that costs one record copy is diff --git a/test/test_dev.ml b/test/test_dev.ml index 05190c6..f366138 100644 --- a/test/test_dev.ml +++ b/test/test_dev.ml @@ -3215,6 +3215,92 @@ let () = | None -> fail "(probe-one) produced no value") | Some r -> fail "(probe-one): %s %s" (status r) (message r) | None -> ()); + + (* ── A form that checks and then fails to land ─────────────────── *) + + (* The two above fail inside the check — a macro module that will not + build is refused before [Session.eval] has committed anything, which + is the easy half. This is the other half, and it is the one that used + to end in a segfault: a form that checks, joins the session, and then + cannot be built or cannot be delivered. The editor reads an error and + the session goes on holding the declaration; the next module built for + that session lists the name in its install prologue, interns a cell + for it, stores nothing in it, and the first call through that cell + jumps the game thread to address 0. + + Reached by taking the daemon's own working directory away from it, + which is where a redefinition module is written — a different lever + from the cache above, and deliberately so: the cache is the macro + module's and cannot fail a build that has no macro in it. The + directory is named after the daemon's pid, which is the one this test + started. + + The last step is the crash. Pre-fix, [(probe-two)] compiled to a call + through a null cell and this file died with the program; the assertion + it now makes is that the daemon refuses by name instead. *) + let devdir = + Filename.concat (Filename.get_temp_dir_name ()) + (Printf.sprintf "flan-dev-%d" rpid) + in + let probe_two = "(defn probe-two [] i64 7)" in + let before = knows () in + (try Unix.chmod devdir 0o500 with Unix.Unix_error _ -> ()); + (match + ask_or "a redefinition the daemon cannot write its module for" + (Printf.sprintf "(:op \"eval\" :code %s :file %s)" + (Wire.quote probe_two) (Wire.quote origin)) + with + | Some r -> + if status r <> "error" then + fail "a redefinition whose module could not be written answered %s" + (status r) + | None -> ()); + (try Unix.chmod devdir 0o700 with Unix.Unix_error _ -> ()); + (match knows () with + | k when k <> before -> + fail "a redefinition that never landed changed what the session \ + knows:\n was: %s\n now: %s" before k + | k -> + if contains_sub k "probe-two" then + fail "a redefinition that never landed left probe-two in the \ + session"); + (match + ask_or "an expression calling a redefinition that never landed" + (Printf.sprintf "(:op \"eval-expr\" :code \"(probe-two)\" :file %s)" + (Wire.quote origin)) + with + | Some r -> + if status r <> "error" then + fail "an expression calling a body no module carries answered %s" + (status r); + if not (contains_sub (message r) "probe-two") then + fail "the refusal did not name the body that never landed: %S" + (message r) + | None -> ()); + (* Usable afterwards, for the thing that just failed: the same form + again, and then the call that could not be made. *) + (match + ask_or "the redefinition after one that could not be written" + (Printf.sprintf "(:op \"eval\" :code %s :file %s)" + (Wire.quote probe_two) (Wire.quote origin)) + with + | Some r when status r = "ok" -> () + | Some r -> + fail "the redefinition after one that could not be written: %s %s" + (status r) (message r) + | None -> ()); + (match + ask_or "a call to the body the recovered evaluation installed" + (Printf.sprintf "(:op \"eval-expr\" :code \"(probe-two)\" :file %s)" + (Wire.quote origin)) + with + | Some r when status r = "ok" -> + (match Wire.string_field r "value" with + | Some "7" -> () + | Some v -> fail "(probe-two) evaluated to %s, not 7" v + | None -> fail "(probe-two) produced no value") + | Some r -> fail "(probe-two): %s %s" (status r) (message r) + | None -> ()); (* ── An editor that leaves before its reply does ──────────────── *) (* The failure this closes was a flake in test_emacs, and it read like nothing it was: two break-loop checks failing, and then From 1d788dfda43549ef87233e2445e079cd522e6840 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Fri, 18 Sep 2026 07:43:38 +0700 Subject: [PATCH 4/4] The queue-full refusal stops contradicting the note two arms above it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It ended "send it again once the program is running", which reads as a withdrawal of what eval promises a parked program on every other evaluation: that a body redefined while parked installs when the program is run again. What is true is narrower — this module was not taken, and the run that drains the sixty-four already queued is what makes room for it. --- lib/dev.ml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/lib/dev.ml b/lib/dev.ml index a4a575f..01762a5 100644 --- a/lib/dev.ml +++ b/lib/dev.ml @@ -556,9 +556,8 @@ let refusal ~parked reply = if parked && contains reply "reload queue full" then "the program refused the module: its reload ring is full, and a parked \ program drains none of it — every module queued since it finished is \ - still waiting for a frame boundary, and M-x flan-rerun is what gives it \ - one; this one was not queued, so send it again once the program is \ - running" + still waiting for a frame boundary, and M-x flan-rerun is what gives them \ + one; this one was not taken, so send it again after that run" else "the program refused the module: " ^ reply (* [pause], when given, is the position of the form to stop at — §9. It rides