From 81d46c342e49f34d19cc7346c55923415ff1baa0 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 12:16:30 +0700 Subject: [PATCH] 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. --- .gitignore | 8 ++++ emacs/MANUAL.md | 34 +++++++++++++++++ emacs/flan-cnr.el | 79 ++++++++++++++++++++++++++++++++++++---- emacs/test-flan-cider.el | 8 +++- 4 files changed, 119 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index ceef522..e1895b1 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/emacs/MANUAL.md b/emacs/MANUAL.md index a378fb9..18732f9 100644 --- a/emacs/MANUAL.md +++ b/emacs/MANUAL.md @@ -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 `` and `` 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. diff --git a/emacs/flan-cnr.el b/emacs/flan-cnr.el index b95038c..35164b6 100644 --- a/emacs/flan-cnr.el +++ b/emacs/flan-cnr.el @@ -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))) diff --git a/emacs/test-flan-cider.el b/emacs/test-flan-cider.el index 5f3698a..6c1177d 100644 --- a/emacs/test-flan-cider.el +++ b/emacs/test-flan-cider.el @@ -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)))