diff --git a/emacs/flan-dape.el b/emacs/flan-dape.el new file mode 100644 index 0000000..84def30 --- /dev/null +++ b/emacs/flan-dape.el @@ -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 diff --git a/emacs/test-flan-dape.el b/emacs/test-flan-dape.el new file mode 100644 index 0000000..3b32e09 --- /dev/null +++ b/emacs/test-flan-dape.el @@ -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