The stack section draws the frames it was written for

The web build's three artifacts are ignored alongside the native executables:
a page, its loader and the module are output, not source.
This commit is contained in:
Joseph Ferano 2026-09-12 12:16:30 +07:00
parent bdb3f105f2
commit 81d46c342e
4 changed files with 119 additions and 10 deletions

8
.gitignore vendored
View File

@ -42,6 +42,14 @@ old-ocaml/
/calc-me
/sand
/conditions-play
# What `flan build --target=web` drops beside a source file. Anchored like the
# native executables above, and three patterns rather than one glob because the
# page, its loader and the module are three files with three extensions the
# repo otherwise has legitimate uses for.
/sand.html
/sand.js
/sand.wasm
.claude/
probe
probe.c

View File

@ -355,3 +355,37 @@ Commands with no key: `M-x flan-dev` (start a program), `M-x flan-dev-quit`
There is no Flan parser in any of them. The client sends text and the compiler
answers; anything that needs to know what a form means asks.
## The stack, and what a frame was holding
`C-c C-b` opens the conditions-and-restarts buffer, and its Stack section is no
longer empty. It lists the stopped program's frames, innermost first, each with
where it is and how many named slots it has.
`TAB` on a frame opens it and shows what its locals hold — name, type and
value, rendered the same way the inspector renders anything else. They are
fetched the first time a frame is opened and then kept, because a stopped
program's locals cannot change underneath you and a round trip behind a key
that looks like folding would be a surprise.
Two kinds of frame are marked. A `program` frame was on the stack when the
error happened. An `eval` frame belongs to an expression you evaluated *inside*
the break loop, sitting on top of them. Those are shown rather than hidden, on
the same principle the unreachable restarts are: a frame you did not write is
better explained than silently removed.
Not everything can be shown, and what cannot is refused by name under the frame
rather than left blank:
- a slot the compiler invented, which has no name in your source — showing it
as `s4` would put a variable in front of you that is not in the file;
- a slot whose binding had not run yet when the error happened, which has no
address to read;
- a `Vec` or a pointer, which render as `<vec>` and `<ptr>` here exactly as
they do everywhere else.
**One known wrong answer.** If a function's body is redefined while the program
is stopped inside it, and the new body happens to have the same number of slots
of the same types, the frame will show the *new* names against the *old*
values. The check that should catch this does not fire. There is a failing test
pinned to it, so this is recorded rather than lurking.

View File

@ -78,9 +78,9 @@ from fixtures, and so `flan-dev.el' is named in one place.")
(params
. "restart arguments are checked at run time against a frame that does not record its arity [needs a field in the restart frame]")
(stack
. "nothing here is attached to the stopped program. DWARF is not the gap and has not been for a while: `flan build --debug' emits it, `flan dev --debug' now builds the host and every redefinition module with it, and lldb walks a stack that crosses from a reloaded .so back into the host naming both sides' .flan files. But this buffer reaches the program over the daemon's socket, and a socket cannot read another process's frames — the break loop stopped itself, it is not being debugged. So this wants either an unwinder in the agent, beside `flan_rt.c', or lldb attached to the same pid and this buffer reading it [needs one of those two, not DWARF]")
. "the program is running. A backtrace is the game thread's frame chain and it is changing while it runs, so the daemon refuses to take one [not a missing feature: ask again once it has stopped]")
(locals
. "same reason as the stack above it, plus a renderer aimed at an address rather than at an expression. The names and types are in the DWARF now — a let-bound local is its own name there, not `s0' — so whatever walks the frames can read them; nothing is walking the frames [needs the same attachment, and the same thunk the condition's fields need]"))
. "this frame has no named locals the daemon can read. A slot the compiler invented is refused by name rather than shown as `s4', and a slot whose binding had not run when the error happened has no address yet [both are refusals with reasons, not gaps — the frame's own line says how many slots it has]"))
"Why a section of this buffer is empty, by name.")
(defun flan-cnr--why (key)
@ -224,8 +224,15 @@ list already says it."
;; one level down.
(when (memq i flan-cnr--open)
(let ((locals (plist-get fr :locals)))
(dolist (r (plist-get fr :refused))
(insert (format " %s%s\n"
(if (string-empty-p (nth 0 r)) ""
(propertize (format "%s: " (nth 0 r))
'face 'shadow))
(propertize (nth 1 r) 'face 'shadow))))
(if (null locals)
(insert (flan-cnr--unavailable 'locals))
(unless (plist-get fr :refused)
(insert (flan-cnr--unavailable 'locals)))
(dolist (l locals)
(let ((start (point)))
(insert (format " %s %s = %s\n"
@ -314,6 +321,17 @@ list already says it."
(setq flan-cnr--open
(if (memq i flan-cnr--open) (delq i flan-cnr--open)
(cons i flan-cnr--open)))
;; Fetched on first open and then kept: a frame's locals cannot change
;; while the program is stopped, and re-asking on every fold would put a
;; round trip behind a keystroke that looks like pure redrawing.
(when (memq i flan-cnr--open)
(let ((fr (nth i (plist-get flan-cnr--state :stack))))
(when (and fr (not (plist-get fr :fetched)))
(let ((answer (flan-cnr-locals i)))
(setcar (nthcdr i (plist-get flan-cnr--state :stack))
(plist-put (plist-put (plist-put fr :locals (nth 0 answer))
:refused (nth 1 answer))
:fetched t))))))
(let ((line (line-number-at-pos)))
(flan-cnr--render flan-cnr--state)
(goto-char (point-min))
@ -382,7 +400,7 @@ list already says it."
"What a stopped Flan program is offering."
(setq buffer-read-only t))
(defun flan-cnr-state-from-reply (reply &optional fields)
(defun flan-cnr-state-from-reply (reply &optional fields stack)
"The buffer's state, out of a `break' REPLY.
FIELDS is the condition's layout, if it was asked for and answered a list of
(NAME TYPE VALUE), where VALUE is nil because no running program was consulted
@ -397,9 +415,11 @@ data and the fixture-driven tests can drive it without a socket."
(list :condition (plist-get reply :condition)
:restarts (plist-get reply :restarts)
:fields fields
;; Still nil deliberately: nothing here is attached to the stopped
;; program's frames, and the renderer says so by name.
:stack nil
;; STACK is passed in rather than fetched, for the same reason FIELDS
;; is: this stays a function from data to data, so the fixture tests
;; drive it with no socket. Nil is still a legitimate answer — the
;; renderer draws the reason rather than leaving the section out.
:stack stack
:locals nil))
(defun flan-cnr-layout (type)
@ -427,6 +447,47 @@ it is empty, which is the degradation the buffer is already built for."
(list (nth 0 f) (nth 1 f) nil))
(plist-get r :fields))))))
(defun flan-cnr-backtrace ()
"The stopped program's frames, innermost first, as the renderer wants them.
Unlike `flan-cnr-layout', this *does* reach the program: the frame chain is the
shadow stack the dev build pushes, and only the stopped thread can read it. It
is taken from a snapshot made when the break was entered, for the same reason
the restart list is a stack that is still moving cannot be numbered.
Frames are marked `program' or `eval'. An `eval' frame belongs to an
expression evaluated *inside* the break loop, sitting on top of the frames that
were there when it stopped; it is shown rather than hidden, because \"why is
there a frame I did not write\" is a fair question and silence is the wrong
answer to it.
Locals are left nil here and fetched per frame when one is opened. A stopped
program has as many sets of locals as it has frames, and rendering all of them
to draw one is the backtrace problem one level down."
(let ((r (funcall flan-cnr-request-function '(:op "backtrace"))))
(when (equal (plist-get r :status) "ok")
(mapcar (lambda (f)
(list :fn (nth 0 f) :loc (nth 1 f)
:kind (nth 2 f) :nslots (nth 3 f)
:locals nil :fetched nil))
(plist-get r :frames)))))
(defun flan-cnr-locals (frame)
"What FRAME's named locals hold, as (NAME TYPE VALUE) rows.
Returns two values in one list: the rows, and the refusals a slot the daemon
declined to show, with the reason. The refusals are shown, not dropped: a
local that is missing because the compiler invented it and a local that is
missing because this is broken look identical if only one of them is drawn.
Nothing is copied out of the program. The daemon compiles a thunk that renders
the types it already knows at the addresses the program hands back, which is
why this works at all while the thread is stopped."
(let ((r (funcall flan-cnr-request-function (list :op "locals" :frame frame))))
(if (equal (plist-get r :status) "ok")
(list (plist-get r :locals) (plist-get r :refused))
(list nil (list (list "" (or (plist-get r :message) "refused")))))))
;;;###autoload
(defun flan-cnr-show ()
"Show what the stopped program is offering, in a buffer.
@ -442,7 +503,9 @@ walk from a running program."
(with-current-buffer buf
(unless (derived-mode-p 'flan-cnr-mode) (flan-cnr-mode))
(flan-cnr--render
(flan-cnr-state-from-reply r (flan-cnr-layout (plist-get r :condition)))))
(flan-cnr-state-from-reply r
(flan-cnr-layout (plist-get r :condition))
(flan-cnr-backtrace))))
(pop-to-buffer buf)
buf)))

View File

@ -317,8 +317,12 @@
;; refusals, and with nothing at all the outer one is what shows.
(test-flan--check "the condition's fields are refused, not omitted"
(string-match-p "Condition fields:\n not available.*Tast.structs" text))
(test-flan--check "the stack is refused, naming DWARF"
(string-match-p "Stack.*\n not available.*DWARF"
;; The stack is no longer refused for want of a mechanism — the shadow stack
;; landed and the daemon answers `backtrace'. What this fixture has is no
;; stack *passed in*, which is the running-program case, so the reason it
;; names is that one.
(test-flan--check "the stack says the program is running, not that it is unbuilt"
(string-match-p "Stack.*\n not available.*running"
(substring text (string-match "--- Stack" text))))
(test-flan--check "and the keys are shown" (string-match-p "TAB fold a frame" text)))