flan/emacs/flan-dape.el
Joseph Ferano d0a8339bb5 DWARF in a redefinition, and one flag that means it everywhere
Emit.redefinition has taken ~debug since it was written and was tested
with it; Session.eval never passed it, so every body installed by C-c C-c
lost its debug info in the running process.

Passing it alone would have been half a fix. Build.shared is what forces
-O0, and dev.ml built modules at -O2, so the llvm.dbg.declares would have
been emitted and then deleted by mem2reg: a line table, and no locals.
And a module with DWARF loaded into a host without it lines up against
nothing. So it is one flag — flan dev --debug and flan reload --debug —
and it sets the host build, the module builds and the emitted metadata
together. Off by default: a debug build is an -O0 build, and quietly
making every reloaded body -O0 changes the frame time of the one function
you are iterating on, in the loop whose point is watching that number.

What a dlopen'd module does to a breakpoint, measured against the reload
fixture rather than reasoned about:

  - lldb reads the new module's DWARF on the dlopen and says so: "1
    location added to breakpoint 3".
  - A breakpoint set by NAME gains a second location either way, so
    dlopen was never the difficulty. What the line table buys is that it
    stops with source instead of disassembly.
  - A FILE AND LINE breakpoint on the new body resolves only with it;
    without, it sits at locations = 0 (pending) forever.
  - A FILE AND LINE breakpoint on the HOST's copy stays pinned at
    locations = 1. That is correct, not stale: the old body is still
    mapped and every call site that has not gone through its cell again
    still reaches it.
  - The stack crosses intact — a frame in the reloaded .so and the one
    below it in the host each name their own .flan file.

    (lldb) frame variable
    (long) step = 10
    (long) prior = 11

The transcripts are in flan-dape.el, replacing the note that said the
module carries no DWARF yet.

flan-cnr.el's stack pane was refusing for the wrong reason. DWARF was
never its gap; nothing is attached to the stopped program, and a socket
cannot read another process's frames. Reworded to say that.

Source interleaving in the disassembly buffer is unblocked and not done:
objdump -dS interleaves a --debug module's Flan source correctly, so
Dev.asm_of needs the -S and a parse_listing that tolerates source lines.
2026-09-12 05:14:03 +07:00

260 lines
12 KiB
EmacsLisp

;;; flan-dape.el --- Debug a Flan program with dape and lldb-dap -*- lexical-binding: t; -*-
;; The other half of the dev loop. flan-dev.el is about a program that keeps
;; running while you change it; this is about stopping one and reading it.
;;
;; There is no DAP implementation here and there should not be. `flan build
;; --debug' writes DWARF into the executable, lldb reads it, and `lldb-dap'
;; speaks DAP on lldb's behalf — so what is left for Emacs is a `dape-configs'
;; entry that knows how to build a .flan file and where the binary lands.
;;
;; It works at all because of the layout. A Flan struct is its C struct, a
;; slot is an ordinary alloca and there are no tag words or object headers
;; anywhere (plan.org, Memory), so lldb's own C support prints a Flan value
;; correctly with nothing taught to it. The compile unit says DW_LANG_C99 for
;; that reason.
;;
;; M-x flan-debug builds the file the current buffer is visiting and stops it
;; at `main'. Breakpoints are ordinary dape breakpoints in the .flan buffer —
;; `dape-breakpoint-toggle' on a line — because the DWARF line table names the
;; .flan file, not the generated .ll. `dape-breakpoint-global' works too, and
;; is the way to break on a function without hunting for its first line.
;;
;; Two things are worth knowing before they surprise you; both have their own
;; heading at the bottom of this file, written from lldb transcripts rather
;; than from reasoning about what ought to happen:
;;
;; - a --dev build and a --debug build are different builds, and M-x
;; flan-debug does not attach to the program `flan dev' is running;
;; - across a redefinition a breakpoint set by NAME gains a second location
;; and both stay live; one set by FILE AND LINE follows the reload if the
;; module was built with `flan dev --debug', and does not otherwise.
;;; Code:
(require 'subr-x)
(declare-function dape "dape" (config &optional skip-compile))
(declare-function dape--config-eval "dape" (key options &optional skip-functions))
(declare-function dape-breakpoint-toggle "dape" ())
(defvar dape-configs)
(defgroup flan-dape nil
"Debugging a Flan program under lldb."
:group 'flan
:prefix "flan-dape-")
(defcustom flan-dape-command "flan"
"The flan executable used to build a program for debugging.
Its own option rather than flan-dev.el's `flan-dev-command', because this
file is meant to load without that one: editing Flan, debugging Flan and
attaching to a running Flan are three independent things to want."
:type 'string)
(defcustom flan-dape-adapter "lldb-dap"
"The DAP adapter binary.
Fedora and Debian ship it as lldb-dap; older LLVM called it lldb-vscode."
:type 'string)
(defcustom flan-dape-stop-at-entry t
"Whether to stop at the program's entry before running.
On by default: a debug session that starts by running to completion has
told you nothing, and the first thing anyone does is set a breakpoint."
:type 'boolean)
(defcustom flan-dape-extra-flags '()
"Extra flags passed to `flan build' alongside --debug.
--dev belongs here if you want the cells as well as the line tables; see
\"Reloading and breakpoints\" at the bottom of flan-dape.el for what that
does and does not buy."
:type '(repeat string))
(defun flan-dape--source ()
"The .flan file this session is about.
The buffer's own file, or the nearest one up from it — so M-x flan-debug
from a *compilation* buffer or a dired still has an answer."
(or (and buffer-file-name
(string-suffix-p ".flan" buffer-file-name)
buffer-file-name)
(car (directory-files default-directory t "\\.flan\\'"))
(user-error "No .flan file here to debug")))
(defun flan-dape--binary (source)
"Where the debug build of SOURCE goes.
Not beside the source. A debug build is -O0 with DWARF in it and is not
the artefact anyone means by the program's name, so it must not overwrite
one made by `flan build'."
(expand-file-name
(concat "flan-dbg-" (file-name-base source))
temporary-file-directory))
(defun flan-dape--compile-command (source)
"The shell command that builds SOURCE for debugging."
(mapconcat #'shell-quote-argument
(append (list flan-dape-command "build" source "--debug")
flan-dape-extra-flags
(list "-o" (flan-dape--binary source)))
" "))
;; The entry itself. `compile' is dape's own pre-launch hook, so the build
;; happens through `compile-command' and its errors land in a compilation
;; buffer that `next-error' walks — which is the whole reason not to shell out
;; from here. Flan's diagnostics are file:line:col, so they are already in a
;; shape compilation-mode understands.
;;
;; The unquoted forms are evaluated by `dape--config-eval' when a session
;; starts, in the buffer it started from — which is why `flan-dape--source'
;; can just read `buffer-file-name'. `dape' itself expects an already
;; evaluated config, so `flan-debug' below must not hand it the raw entry.
(defconst flan-dape-config
'(modes (flan-mode)
ensure dape-ensure-command
command-cwd dape-command-cwd
compile (flan-dape--compile-command (flan-dape--source))
:type "lldb-dap"
:request "launch"
:cwd "."
:program (flan-dape--binary (flan-dape--source))
:args []
:stopOnEntry flan-dape-stop-at-entry)
"The `dape-configs' entry for a Flan program, without the adapter command.
Separate from the registration below so a user who wants a variant — a
different adapter, extra launch arguments — can start from this rather
than retype it.")
;;;###autoload
(defun flan-dape-register ()
"Add the `flan' entry to `dape-configs'.
Idempotent, so reloading this file does not stack duplicates."
(when (boundp 'dape-configs)
(setq dape-configs
(cons (cons 'flan (append (list 'command flan-dape-adapter)
(copy-sequence flan-dape-config)))
(assq-delete-all 'flan dape-configs)))))
;;;###autoload
(defun flan-debug ()
"Build the Flan file at point with debug info and start dape on it.
Equivalent to \\[dape] with the `flan' configuration, and exists so the
common case is one command rather than a config prompt."
(interactive)
(require 'dape)
(flan-dape-register)
;; Through `dape--config-eval', not `alist-get': `dape' takes a config whose
;; forms have already been evaluated — that is what \[dape] does after
;; reading one — and handing it the stored entry would pass the *list*
;; (flan-dape--binary (flan-dape--source)) to lldb as a program name.
(dape (dape--config-eval 'flan nil)))
;; Registered on load and again after dape loads, because either order
;; happens: a user may load this from their init before dape exists.
(with-eval-after-load 'dape (flan-dape-register))
(flan-dape-register)
;; The binding goes in here rather than in flan-mode.el so that this file is
;; the only thing that has to be loaded to get it, and flan-mode keeps working
;; for anyone who never installs dape. C-c C-g, for "go": every other letter
;; that suggests debugging is taken — C-c C-d is `flan-describe', C-c C-b is
;; `flan-break', which is the condition system's break loop and a different
;; thing entirely.
;;;###autoload
(with-eval-after-load 'flan-mode
(define-key (symbol-value 'flan-mode-map) (kbd "C-c C-g") #'flan-debug))
;;; --dev and --debug are different builds
;;
;; `flan dev' — what flan-dev.el connects to — builds with --dev: every
;; cross-function call goes through a cell so a redefinition can be installed,
;; and -rdynamic exports those cells. `flan build --debug' is a different
;; axis: -O0, DWARF, and no cells unless --dev is also passed.
;;
;; They compose, and `flan-dape-extra-flags' is where to say so, but they do
;; not share a process. M-x flan-debug launches its own program under lldb;
;; it does not attach to the one `flan dev' is running. Attaching to that one
;; would want lldb-dap's attach request and a pid, which is a further thing and
;; is not implemented here — said plainly rather than half-offered.
;;
;; What --dev costs the debugger is less than it sounds. A call site becomes a
;; load from a mutable global and an indirect call through the result, so the
;; callee is found at run time rather than bound at link time. Stack walking
;; is unaffected: the frame is laid out the same way and lldb reads it the
;; same way, so a backtrace through a cell still names the Flan caller and its
;; line. Stepping *into* a call is where it shows — `step' lands in whatever
;; the cell currently holds, which is the honest answer and occasionally not
;; the one on the screen, if the body was redefined since.
;;
;;; Reloading and breakpoints
;;
;; The first confusing thing anyone will hit, so it is written down rather
;; than discovered. What follows was measured with lldb against a --dev
;; --debug build of test/programs/reload.flan and a redefinition module built
;; by `flan reload'; none of it is inference.
;;
;; Each redefinition is a fresh .so that the program dlopens, and the cell is
;; then pointed at the new body. Nothing is ever dlclosed, so the old body is
;; still mapped, and every call site that has not gone through the cell again
;; still reaches it. There are therefore two live bodies, and what a
;; breakpoint does depends on how it was set.
;;
;; A breakpoint set by NAME follows the reload by itself. lldb re-resolves
;; name breakpoints against each module as it loads, so on the dlopen it prints
;; "1 location added to breakpoint 1" and then has two:
;;
;; 1: name = 'flan.bump', locations = 2, resolved = 2, hit count = 2
;; 1.1: where = host`flan.bump + 12 at reload.flan:34:3, ... hit count = 1
;; 1.2: where = v2.so`flan.bump, address = 0x00007ffff7fba1a0, ... hit count = 1
;;
;; Both fire, and both are correct — 1.1 is not stale, it is the body the old
;; call sites still run. That is `dape-breakpoint-global', which sets by name.
;;
;; A breakpoint set by FILE AND LINE depends on how the module was built, and
;; the difference is a line table and nothing about dlopen. Both halves were
;; measured against the same host, one redefinition module built each way.
;;
;; Without DWARF in the module the line breakpoint stays where it was:
;;
;; 2: file = 'v2local.flan', line = 25, locations = 0 (pending)
;;
;; and the name breakpoint still gains its second location — so dlopen was
;; never the problem — but stops into disassembly, because there is no source
;; to show:
;;
;; frame #0: 0x7ffff7fba190 nodbg-v2.so`flan.bump
;; -> 0x7ffff7fba190 <+0>: pushq %rbx
;;
;; With DWARF in the module, the same breakpoint resolves on the dlopen — lldb
;; prints "1 location added to breakpoint 3" as the module loads — and stops
;; with source and named locals:
;;
;; 3: file = 'v2local.flan', line = 25, locations = 1, resolved = 1
;; 3.1: where = v2.so`flan.bump + 78 at v2local.flan:25:21, resolved
;;
;; (lldb) frame variable
;; (long) step = 10
;; (long) prior = 11
;;
;; The stack crosses the boundary intact, which is the part worth knowing: a
;; frame in the reloaded .so and the frame below it in the host each name their
;; own .flan file, and the C host below both.
;;
;; frame #0: v2.so`flan.bump at v2local.flan:25:21
;; frame #2: host`flan.outer at reload.flan:38:20
;; frame #3: host`main at reload_host.c:94:44
;;
;; A breakpoint set by FILE AND LINE on the *host's* copy stays at locations = 1
;; and does not move. That is correct rather than stale: the old body is still
;; mapped and every call site that has not gone through the cell again still
;; reaches it, so pinning there is the only honest thing to do.
;;
;; How to get it: `flan dev --debug'. It is one flag on purpose — the host
;; needs a line table for a breakpoint to fire before the first C-c C-c, and
;; each module needs one for it to still be firing after — and it is off by
;; default because a debug build is an -O0 build, which is not what you want
;; under a frame budget unless you asked for it.
;;
;; So: with `flan dev --debug', line breakpoints work across a reload. Without
;; it, debug with `dape-breakpoint-global', which sets by name.
(provide 'flan-dape)
;;; flan-dape.el ends here