C-u before an eval marks a form so the program stops when it runs (DISCUSS.md 9). The mark arrives as a position in a separate :pause field and is applied to the Ast after parsing: splicing text into the source would move every line and column after it, and the error overlays, the layout, the break loop's frame locations and DWARF all read those. Ast.mark_pause puts a (pause) call at whatever starts at that position -- wrapping a sub-expression in a do, or going to the front of a defn's body, since a declaration cannot be wrapped. A position that matches nothing is refused rather than installed unmarked, which would report a breakpoint that is not there. It sticks with no extra state: the marked declaration is what goes into the session, so an ordinary C-c C-c over the same form clears it. The daemon half only; the Emacs command and its overlay are not built. HANDOFF-f2.md has the rest, in order.
9.7 KiB
Handoff: pause marking from Emacs (DISCUSS.md §9)
The daemon half is built and works end to end over the wire. The Emacs half is not built.
Nothing half-written was left behind: dune build is clean and there is no new test.
The design, as it stands after reading the code
C-u before an eval marks a form so the program stops when that form runs. The mark is not
spliced into the source text — that would move every line and column after the insertion, and the
error overlays, layout, the break loop's frame locations and DWARF all read those. Instead the
editor sends a position beside the code:
(:op "eval" :code "(defn step [] i64 ...)" :file "/x/y.flan" :pause (LINE COL))
The daemon parses and Loads as usual, then walks the resulting Ast.decl list and puts a
(pause) call at whatever starts at that position. (pause) is an ordinary prelude function
(error under a restart-case with a continue clause), so an instrumented body is just a body
that calls one more function, and the break loop it lands in is the one an unhandled condition
already builds. Nothing in the compiler changes.
It sticks with no extra machinery: the marked declaration is what goes into Session.t.decls,
so it stays marked until an evaluation replaces it — an ordinary C-c C-c over the same form with
no :pause, or C-c C-k over the buffer. That is §9's settled behaviour and it costs one
statement that was already there.
What §9 left out or got slightly wrong
- §9 says "send the top-level form with that span replaced by
(do (pause) <span>)". That wrapping is right for a sub-expression but impossible for the first of its three targets: a whole top-leveldefnis a declaration, and(do (pause) (defn ...))is not an expression. Marking a wholedefntherefore means stopping on entry, and the call goes at the front offbody.Ast.mark_pausedoes both, chosen by what the position lands on. - §9 does not say the mark can be refused. It has to be: a position that matches nothing must
be an error, because installing an unmarked body and answering
okreports a breakpoint that is not there — the silent-success failure the session refuses everywhere else. - Desugaring makes locations non-unique.
parse.mlgives several nested nodes the same location (whenbecomes anIfwhose branch is aDoat thewhen's own position — lines 141, 164, 651, 654). The walk is pre-order and stops at the first hit, so the outermost node at that position wins, which is the one the editor pointed at. - The third target ("the form point is inside") needs no new daemon work — it is the same position field, computed differently in Emacs.
What was built, file by file
All four are working (built, and exercised against a real daemon and a real running program by hand — see "How it was checked").
lib/ast.ml— new section at the end:map_children : (expr -> expr) -> expr -> expr, an exhaustive one-level rebuild. Exhaustive on purpose: a missing constructor is a form you silently cannot stop inside.pause_call : Loc.t -> expr—(pause)at a given location.mark_pause : line:int -> col:int -> decl list -> decl list option— pre-order, first hit wins,Nonewhen nothing is at that position. The synthesizedDo/Calltake the target's own location, neverLoc.unknown, because DWARF and the break loop's frame location read it.
lib/wire.ml—pos_field form key, reading(LINE COL)as a pair of ints;Nonefor anything else, the same narrowness asint_field.lib/session.ml—evaltakes?pause:(int * int). It is applied afterLoad.qualify_decl, so a package that defines apauseof its own cannot capture the synthesized call, and a position that matches nothing is aLoc.failnaming the position.lib/dev.ml—evaltakes~pause,handlereads:pauseoff the request, and a successful install echoes:pause "LINE:COL"back so an editor marks the buffer only for a mark the session actually applied. With no:pausein the request, every byte of the old behaviour is unchanged.
How it was checked
A daemon over test/programs/dev-loop.flan, driven by a raw socket client:
- eval of
stepwith:pause (1 1)→(:status "ok" … :pause "1:1"), and a laterdescribecame back:stopped t :condition "Pause"— the program stopped, on the prelude's own condition. :pause (1 999)→(:status "error" :message "nothing to pause at line 1, column 999 of the form sent").- a plain re-eval of the same form → accepted.
dune build is clean. dune test was not run (budget). The changes are additive: the new
session argument is optional and the new reply field only appears when :pause was sent, so no
existing path changes shape — but the suite should be run first thing next session anyway. Note
test_dev.ml's first block is separately known-flaky (a socket bind race, ~1 in 4).
What remains, in order
lib/dev.ml,eval_expr— accept:pauseforC-u C-x C-e(§9's "last expression"). Two parts. (a)lib/session.ml'seval_exprshould take?(pause = false)and wrapParse.expr forminDo [Ast.pause_call loc; e]beforeCheck.expression. (b)eval_expr'swaitloop indev.mlreturnserror "the program did not reach a frame boundary…"after 5s, which is exactly what a thunk that stopped in the break loop will do — so it would report the working feature as a failure. Makewaitthree-way (`Value | `Stopped | `Timeout) and checkstate t = Stoppedonly when a pause was requested:test_dev.ml:519–560already asserts the current timeout shape for the no-pause case ("an expression that stopped inside a break answered anyway"), and that must stay byte-identical. Use:pause there, not(LINE COL):flan-eval-last-sexpsends a rawbuffer-substringwith no line padding (unlikeflan-dev--text), so buffer coordinates do not survive that path.emacs/flan-dev.el,flan-dev--eval— take an optional pause position and put:pause (LINE COL)on the request. The column is a 1-based byte offset, per the comment aboveflan-dev--position:(1+ (- (position-bytes pos) (position-bytes (line-beginning-position)))), notcurrent-column. The line is the buffer's own line, which already works becauseflan-dev--textpads with leading newlines.emacs/flan-dev.el,flan-eval-defun—(interactive "P").C-umarks the innermost form point is inside (backward-up-list, falling back to the defun's start when point is not nested);C-u C-umarks the top-level form itself, i.e. stop on entry. That plus item 1 covers §9's three targets with no new keybinding —emacs/flan-mode.elneeds no change.emacs/flan-dev.el, the visual indication — aflan-dev-pause-faceoverlay over the marked form's bounds, drawn only when the reply carries:pause, tagged with aflan-dev-pauseproperty. Copy the shape of the error overlays (flan-dev--show-error) but not their lifetime: a pause mark is an annotation on the program, not feedback about one command, so it must survivepre-command-hook. Remove overlays intersecting the sent region on every accepted plain eval, and over the whole buffer inflan-eval-buffer— that is the visible half of "cleared by an ordinaryC-c C-c".test/programs/dev-pause.flan— new, shaped likedev-break.flan's tail so the marked function keeps being called:(dotimes [i 4000] (agent/wait 5) (set ticks (step))).dev-loop.flancallssteponly four times, which is too tight.test/test_dev.ml— a block in the style of the break-loop block (its own daemon, its own program, its own output buffer). Assert both halves: markstep's(+ ticks 1)sub-expression → await:stopped twith a condition containingPause→breaklistscontinue→ then re-evalstepplainly, takecontinue, and confirm it runs on without stopping again. The second assertion is the one that tests the settled "it sticks until evaluated plainly" decision and the one most likely to be skipped.emacs/test-flan-dev.el— the elisp side, once items 2–4 exist.
Decisions made that were not already settled
- The mark is not a field on
Ast. It is an ordinary(pause)call spliced into the tree. Apaused : boolonAst.exprwould have to be threaded throughCheck,TastandEmitfor a feature the prelude already implements as a function. - No parallel
pausedlist onSession.t. The spliced declaration int.declsis the state that makes the mark stick; a second(name * position) listwould be a second source of truth that drifts the first time some path replacesdeclswithout touching it. Clearing then falls out for free — any plain eval replaces the stored declaration with an unmarked one. - Marking a whole
defnmeans stopping on entry, since it cannot be wrapped (above). - A position matching nothing is refused, rather than installed unmarked (above).
- The splice happens after qualification, so a package's own
pausecannot capture it. - The reply echoes
:pause "LINE:COL"so the editor draws its overlay off the daemon's confirmation and can never claim a mark that was refused.
Tried and abandoned
- Nothing failed outright. Worth recording: the worktree this was done in was created 485 commits
behind
dev-loop(at2c232dd, beforelib/dev.mlexisted at all) and had to begit reset --hardto the branch tip before any of the files named in the task existed. Checkgit logagainstdev-loopbefore starting in a fresh worktree.