No DAP implementation here, and there should not be one. `flan build --debug' puts DWARF in the executable, lldb reads it, lldb-dap speaks the protocol — so what was actually missing was a dape-configs entry that knows to build a .flan file first and where the binary lands. The build goes through dape's own `compile' key rather than a shell-out, so a rejected program lands in a compilation buffer and next-error walks it. Flan's diagnostics are already file:line:col. `flan-debug' goes through `dape--config-eval' and not `alist-get'. `dape' takes a config whose forms are already evaluated — that is what M-x 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. Driven headlessly to prove it: a breakpoint set by line in the .flan buffer, hit, reported as flan.tick at debug.flan:19 with c and n in scope. The keybinding is registered from here rather than in flan-mode.el, so this file is the only thing anyone has to load to get it and flan-mode keeps working for someone who never installs dape. The two frictions are written down at the bottom of flan-dape.el from lldb transcripts, not from reasoning about what ought to happen, because the guess I started from was wrong. Across a reload a breakpoint set by *name* gains a second location and both stay live — the old body is still mapped and still what old call sites reach. One set by *file and line* stops firing, and not because dape pinned it to an address: the redefinition module has no line table to resolve against. Given one, lldb does re-resolve on dlopen. Which names the gap: Emit.redefinition takes ~debug and Session.eval does not pass it, so `flan reload' and the `flan dev' daemon build modules without DWARF. lib/session.ml is the dev loop's file, not this lane's. test-flan-dape.el is not in dune test. It wants Emacs, dape, lldb-dap and a built flan at once, and wiring four optional things into the acceptance table would make that table's failures mean less, not more.
230 lines
11 KiB
EmacsLisp
230 lines
11 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, while one set by FILE AND LINE stops firing —
|
|
;; because the redefinition module carries no DWARF yet.
|
|
|
|
;;; 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 does not follow, and the reason is not
|
|
;; that dape pinned it to an address. It stays at locations = 1 because the
|
|
;; redefinition module carries no line table for it to resolve against. Given
|
|
;; one it does follow: a pending breakpoint on a file the executable had never
|
|
;; heard of went from "no locations (pending)" to "1 location added" the moment
|
|
;; a .so with DWARF for that file was dlopened, and stopped with full source.
|
|
;; So the gap is exactly one missing thing, and nothing about dlopen.
|
|
;;
|
|
;; That missing thing, by name: `Emit.redefinition' takes a ~debug argument and
|
|
;; `Session.eval' does not pass it, so `flan reload' and the `flan dev' daemon
|
|
;; build modules without DWARF. Until they do, a reloaded body breaks by name
|
|
;; and shows disassembly instead of source, and a line breakpoint in the .flan
|
|
;; buffer silently stops firing after the first C-c C-c. lib/session.ml is the
|
|
;; dev loop's file and is not this one's to change.
|
|
;;
|
|
;; So, for now: debug with `dape-breakpoint-global' if you are also reloading,
|
|
;; and use line breakpoints for a program you are only running.
|
|
|
|
(provide 'flan-dape)
|
|
;;; flan-dape.el ends here
|