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