flan/HANDOFF-f2.md
Joseph Ferano 6f8a7300d5 A pause mark travels beside the source, not inside it
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.
2026-09-13 10:25:47 +07:00

143 lines
9.7 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `Load`s 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-level `defn` is a declaration, and `(do (pause) (defn ...))` is not an expression.
Marking a whole `defn` therefore means *stopping on entry*, and the call goes at the front of
`fbody`. `Ast.mark_pause` does 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 `ok` reports a breakpoint that is
not there — the silent-success failure the session refuses everywhere else.
- **Desugaring makes locations non-unique.** `parse.ml` gives several nested nodes the same
location (`when` becomes an `If` whose branch is a `Do` at the `when`'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,
`None` when nothing is at that position. The synthesized `Do`/`Call` take the target's own
location, never `Loc.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; `None` for
anything else, the same narrowness as `int_field`.
- **`lib/session.ml`** — `eval` takes `?pause:(int * int)`. It is applied **after**
`Load.qualify_decl`, so a package that defines a `pause` of its own cannot capture the
synthesized call, and a position that matches nothing is a `Loc.fail` naming the position.
- **`lib/dev.ml`** — `eval` takes `~pause`, `handle` reads `:pause` off 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 `:pause` in 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 `step` with `:pause (1 1)``(:status "ok" … :pause "1:1")`, and a later `describe`
came 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
1. **`lib/dev.ml`, `eval_expr`** — accept `:pause` for `C-u C-x C-e` (§9's "last expression").
Two parts. (a) `lib/session.ml`'s `eval_expr` should take `?(pause = false)` and wrap
`Parse.expr form` in `Do [Ast.pause_call loc; e]` before `Check.expression`. (b) `eval_expr`'s
`wait` loop in `dev.ml` returns `error "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. Make `wait` three-way (`` `Value | `Stopped | `Timeout ``) and
check `state t = Stopped` **only when a pause was requested**: `test_dev.ml:519560` already
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 t` here, not `(LINE COL)`: `flan-eval-last-sexp` sends a raw `buffer-substring`
with no line padding (unlike `flan-dev--text`), so buffer coordinates do not survive that path.
2. **`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
above `flan-dev--position`: `(1+ (- (position-bytes pos) (position-bytes (line-beginning-position))))`,
*not* `current-column`. The line is the buffer's own line, which already works because
`flan-dev--text` pads with leading newlines.
3. **`emacs/flan-dev.el`, `flan-eval-defun`** — `(interactive "P")`. `C-u` marks the innermost form
point is inside (`backward-up-list`, falling back to the defun's start when point is not nested);
`C-u C-u` marks 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.el` needs no change.
4. **`emacs/flan-dev.el`, the visual indication** — a `flan-dev-pause-face` overlay over the marked
form's bounds, drawn **only** when the reply carries `:pause`, tagged with a `flan-dev-pause`
property. 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 survive
`pre-command-hook`. Remove overlays intersecting the sent region on every accepted plain eval,
and over the whole buffer in `flan-eval-buffer` — that is the visible half of "cleared by an
ordinary `C-c C-c`".
5. **`test/programs/dev-pause.flan`** — new, shaped like `dev-break.flan`'s tail so the marked
function keeps being called: `(dotimes [i 4000] (agent/wait 5) (set ticks (step)))`.
`dev-loop.flan` calls `step` only four times, which is too tight.
6. **`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: mark `step`'s `(+ ticks 1)` sub-expression
→ await `:stopped t` with a condition containing `Pause` → `break` lists `continue` → then
**re-eval `step` plainly, take `continue`, 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.
7. **`emacs/test-flan-dev.el`** — the elisp side, once items 24 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. A
`paused : bool` on `Ast.expr` would have to be threaded through `Check`, `Tast` and `Emit` for a
feature the prelude already implements as a function.
- **No parallel `paused` list on `Session.t`.** The spliced declaration in `t.decls` *is* the state
that makes the mark stick; a second `(name * position) list` would be a second source of truth
that drifts the first time some path replaces `decls` without touching it. Clearing then falls out
for free — any plain eval replaces the stored declaration with an unmarked one.
- **Marking a whole `defn` means 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 `pause` cannot 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` (at `2c232dd`, before `lib/dev.ml` existed at all) and had to be
`git reset --hard` to the branch tip before any of the files named in the task existed. Check
`git log` against `dev-loop` before starting in a fresh worktree.