lldb already speaks DAP; Emacs only needs to be told how to build
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.
This commit is contained in:
parent
67aa82457d
commit
5f5cc8bee9
229
emacs/flan-dape.el
Normal file
229
emacs/flan-dape.el
Normal file
@ -0,0 +1,229 @@
|
||||
;;; 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
|
||||
138
emacs/test-flan-dape.el
Normal file
138
emacs/test-flan-dape.el
Normal file
@ -0,0 +1,138 @@
|
||||
;;; test-flan-dape.el --- Drive a real dape session at a Flan program -*- lexical-binding: t; -*-
|
||||
|
||||
;; Run from the repository root, with flan on PATH and dape on the load path:
|
||||
;;
|
||||
;; emacs -Q --batch -L emacs -L /path/to/dape -l emacs/test-flan-dape.el
|
||||
;;
|
||||
;; Not part of `dune test'. It needs Emacs, dape, lldb-dap and a built flan
|
||||
;; all at once, and wiring four optional things into the acceptance table
|
||||
;; would make the table's failures mean less rather than more — the DWARF
|
||||
;; itself is tested there, against LLVM and against lldb directly. What this
|
||||
;; adds is the last link: that dape, driving lldb-dap, sets a breakpoint from
|
||||
;; a .flan buffer, hits it, and reports Flan frames and locals.
|
||||
;;
|
||||
;; It exits non-zero and says why if any of that does not happen.
|
||||
|
||||
;;; Code:
|
||||
|
||||
(require 'flan-mode)
|
||||
(require 'dape)
|
||||
(require 'flan-dape)
|
||||
|
||||
(defvar flan-dape-test--program "test/programs/debug.flan")
|
||||
(defvar flan-dape-test--failures 0)
|
||||
|
||||
(defun flan-dape-test--fail (fmt &rest args)
|
||||
(setq flan-dape-test--failures (1+ flan-dape-test--failures))
|
||||
(message "FAIL %s" (apply #'format fmt args)))
|
||||
|
||||
(defun flan-dape-test--ok (what)
|
||||
(message " ok %s" what))
|
||||
|
||||
;; Batch Emacs has no idle loop, so every wait is an explicit pump.
|
||||
(defun flan-dape-test--pump (pred secs)
|
||||
(let ((deadline (+ (float-time) secs)))
|
||||
(while (and (< (float-time) deadline) (not (funcall pred)))
|
||||
(accept-process-output nil 0.05))
|
||||
(funcall pred)))
|
||||
|
||||
(defun flan-dape-test--stopped ()
|
||||
(dape--live-connection 'stopped t))
|
||||
|
||||
(unless (executable-find flan-dape-command)
|
||||
(message "test-flan-dape: skipped (no %s on PATH)" flan-dape-command)
|
||||
(kill-emacs 0))
|
||||
(unless (executable-find flan-dape-adapter)
|
||||
(message "test-flan-dape: skipped (no %s on PATH)" flan-dape-adapter)
|
||||
(kill-emacs 0))
|
||||
|
||||
(setq dape-cwd-function (lambda () default-directory))
|
||||
|
||||
;; The breakpoint is set in the .flan buffer, by line, before anything is
|
||||
;; built — which is the only way a person would ever set one, and the thing
|
||||
;; that cannot work without a line table naming the .flan file.
|
||||
(with-current-buffer (find-file-noselect flan-dape-test--program)
|
||||
(unless (eq major-mode 'flan-mode)
|
||||
(flan-dape-test--fail "%s did not open in flan-mode" flan-dape-test--program))
|
||||
(unless (eq (key-binding (kbd "C-c C-g")) 'flan-debug)
|
||||
(flan-dape-test--fail "C-c C-g is not bound to flan-debug in a Flan buffer"))
|
||||
(goto-char (point-min))
|
||||
(search-forward "(set (.heat c)")
|
||||
(dape-breakpoint-toggle)
|
||||
(let ((config (dape--config-eval 'flan nil)))
|
||||
;; `dape--config-eval' is where the unquoted forms in `flan-dape-config'
|
||||
;; turn into strings. If this is a list rather than a path, `flan-debug'
|
||||
;; handed dape an unevaluated config and lldb would be given a program
|
||||
;; named "(flan-dape--binary ...)".
|
||||
(unless (stringp (plist-get config :program))
|
||||
(flan-dape-test--fail ":program did not evaluate to a path: %S"
|
||||
(plist-get config :program)))
|
||||
(unless (zerop (call-process-shell-command (plist-get config 'compile) nil nil))
|
||||
(flan-dape-test--fail "the configured build failed: %s"
|
||||
(plist-get config 'compile))
|
||||
(kill-emacs 1))
|
||||
(flan-dape-test--ok "the config builds the program it points lldb at")
|
||||
(dape config 'skip-compile)))
|
||||
|
||||
(if (not (flan-dape-test--pump #'flan-dape-test--stopped 60))
|
||||
(flan-dape-test--fail "the session never stopped at the entry point")
|
||||
(flan-dape-test--ok "lldb-dap launched and stopped at entry")
|
||||
(dape-continue (flan-dape-test--stopped))
|
||||
;; The entry stop has to clear before the next one counts as the breakpoint.
|
||||
(flan-dape-test--pump (lambda () (not (flan-dape-test--stopped))) 5)
|
||||
(if (not (flan-dape-test--pump #'flan-dape-test--stopped 60))
|
||||
(flan-dape-test--fail "the breakpoint in %s never hit" flan-dape-test--program)
|
||||
(let* ((conn (flan-dape-test--stopped))
|
||||
(thread (car (dape--threads conn)))
|
||||
(frames (plist-get thread :stackFrames))
|
||||
(top (car frames)))
|
||||
(flan-dape-test--ok "the breakpoint hit")
|
||||
(unless (equal (plist-get top :name) "flan.tick")
|
||||
(flan-dape-test--fail "the top frame is %S, wanted flan.tick"
|
||||
(plist-get top :name)))
|
||||
(unless (equal (plist-get (plist-get top :source) :name) "debug.flan")
|
||||
(flan-dape-test--fail "the top frame's source is %S, wanted debug.flan"
|
||||
(plist-get (plist-get top :source) :name)))
|
||||
(unless (integerp (plist-get top :line))
|
||||
(flan-dape-test--fail "the top frame has no line number"))
|
||||
(flan-dape-test--ok
|
||||
(format "frame %s at %s:%s" (plist-get top :name)
|
||||
(plist-get (plist-get top :source) :name) (plist-get top :line)))
|
||||
;; Locals, which dape fetches lazily — so they are asked for here.
|
||||
(let ((seen nil) (done nil))
|
||||
(dape-request
|
||||
conn :scopes (list :frameId (plist-get top :id))
|
||||
(lambda (body _err)
|
||||
(dolist (scope (append (plist-get body :scopes) nil))
|
||||
(dape-request
|
||||
conn :variables
|
||||
(list :variablesReference (plist-get scope :variablesReference))
|
||||
(lambda (body _err)
|
||||
(dolist (v (append (plist-get body :variables) nil))
|
||||
(push (list (plist-get v :type) (plist-get v :name)
|
||||
(plist-get v :value))
|
||||
seen)))))
|
||||
(setq done t)))
|
||||
(flan-dape-test--pump (lambda () done) 15)
|
||||
(accept-process-output nil 1.0)
|
||||
(dolist (want '(("int" "n" "41") ("Cell *" "c" nil)))
|
||||
(let ((hit (seq-find (lambda (v)
|
||||
(and (equal (nth 0 v) (nth 0 want))
|
||||
(equal (nth 1 v) (nth 1 want))
|
||||
(or (null (nth 2 want))
|
||||
(equal (nth 2 v) (nth 2 want)))))
|
||||
seen)))
|
||||
(if hit
|
||||
(flan-dape-test--ok (format "%s %s = %s" (nth 0 hit) (nth 1 hit)
|
||||
(nth 2 hit)))
|
||||
(flan-dape-test--fail "no local %s of type %s; saw %S"
|
||||
(nth 1 want) (nth 0 want) seen))))))))
|
||||
|
||||
(ignore-errors (dape-kill (dape--live-connection 'parent)))
|
||||
|
||||
(if (zerop flan-dape-test--failures)
|
||||
(progn (message "flan-dape: all tests passed") (kill-emacs 0))
|
||||
(message "%d failure(s)" flan-dape-test--failures)
|
||||
(kill-emacs 1))
|
||||
|
||||
;;; test-flan-dape.el ends here
|
||||
Loading…
x
Reference in New Issue
Block a user