;;; 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