From 2179627593d84391ee9e7be9ec1f5dbb586c80a1 Mon Sep 17 00:00:00 2001 From: Joseph Ferano Date: Sat, 12 Sep 2026 15:35:08 +0700 Subject: [PATCH] The break buffer opens itself, and a breakpoint is not called a failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The client already knew the moment: flan-dev--absorb reads :stopped off every reply and the poll covers the case where no reply is coming. This is a hook at that point, not new plumbing. Deferred through a zero-delay timer, which is the part that is not optional. absorb notices the stop in the middle of reading a reply on the socket, with flan-dev--busy bound, and showing the buffer asks the daemon three more questions — break, layout, backtrace. Issuing those from inside the read they were triggered by would interleave two conversations on one connection. The deferred call re-checks the state rather than trusting the edge that scheduled it, because by then the edge has been consumed and the program may have been resumed. Three decisions, settled and written down beside the code. It displays and does not select. A program stops on its own clock, not the editor's, and the likeliest moment is a frame of its own game loop while someone is typing somewhere else. Taking the window would send the next keystrokes where they were not aimed. `focus' is there for anyone who disagrees, and nil goes back to the mode line alone. (pause) is not a special case, though it was worth asking: it is deliberate at the moment it is *written*, and the frame it fires on still arrives whenever the program gets there, which is no more expected than an error. What it does get is an honest headline. (pause) is `error' under a `restart-case', so nothing in the compiler knows a breakpoint from a failure and this buffer is the first place that can — calling it unhandled is a small lie at the top of the one buffer that exists to say what happened. A stop mid-edit disturbs nothing, which falls out of displaying rather than selecting. Two guards go past that: nothing happens under an active minibuffer, because a prompt is modal and rearranging windows under one is hostile; and nothing happens inside a keyboard macro, because a macro that behaves differently depending on whether the program stopped cannot be trusted. In both cases the mode line still says stopped and C-c C-b still works. --- emacs/flan-cnr.el | 25 +++++++++-- emacs/flan-dev.el | 90 +++++++++++++++++++++++++++++++++++++++- emacs/test-flan-cider.el | 76 +++++++++++++++++++++++++++++++++ 3 files changed, 186 insertions(+), 5 deletions(-) diff --git a/emacs/flan-cnr.el b/emacs/flan-cnr.el index 0dfae9b..8272755 100644 --- a/emacs/flan-cnr.el +++ b/emacs/flan-cnr.el @@ -120,11 +120,28 @@ list already says it." (defun flan-cnr--section (title) (insert (propertize (format "--- %s\n" title) 'face 'font-lock-comment-face))) +(defconst flan-cnr-breakpoint "Pause" + "The condition `(pause)' signals. +`lib/prelude.ml' writes it as an ordinary struct under a `restart-case', so +nothing in the compiler knows a breakpoint from an error and this buffer is the +first place that can tell the difference. Named here rather than spelled at +its use, because it is a fact about the prelude.") + (defun flan-cnr--insert-condition (state) - (let ((name (or (plist-get state :condition) "a condition"))) - (insert (propertize name 'face 'error)) - (insert (propertize " — unhandled; stopped on the frame that erred, nothing unwound\n" - 'face 'shadow))) + (let* ((name (or (plist-get state :condition) "a condition")) + ;; A breakpoint is a stop, not a failure. Everything underneath is + ;; identical — `(pause)' *is* `error' under a `restart-case', which is + ;; what a breakpoint is in a language that has conditions — so the + ;; only thing that can be wrong here is the word for it. Calling a + ;; breakpoint unhandled would be a small lie told at the top of the + ;; one buffer that exists to say what happened. + (paused (equal name flan-cnr-breakpoint))) + (insert (propertize name 'face (if paused 'warning 'error))) + (insert (propertize + (if paused + " — a breakpoint; stopped where (pause) was called, nothing unwound\n" + " — unhandled; stopped on the frame that erred, nothing unwound\n") + 'face 'shadow))) (insert "\n") (flan-cnr--section "Condition fields:") ;; Two refusals, not one, and keeping them apart is the point. The *shape* of diff --git a/emacs/flan-dev.el b/emacs/flan-dev.el index a1e8c03..232f6df 100644 --- a/emacs/flan-dev.el +++ b/emacs/flan-dev.el @@ -135,6 +135,90 @@ reply that request was waiting for.") ;; scrolled back is reading something. (when at-end (goto-char (point-max))))))) +;; The break buffer, which this file shows but does not draw. An autoload +;; rather than a `require': flan-cnr.el reaches the daemon through this file, +;; so requiring it here would be a cycle, and it is wanted only at the moment +;; a program stops. +(autoload 'flan-cnr-show "flan-cnr" nil t) + +;;; Opening the break buffer when the program stops + +;; The mode line and one echo-area line were the whole of it: the buffer that +;; says what happened and what can be done about it appeared only when `C-c +;; C-b' was typed. `flan-dev--absorb' below already knows the moment — it +;; reads `:stopped' off every reply, and the poll covers the case where no +;; reply is coming — so this is a hook at a point that exists rather than new +;; plumbing. +;; +;; Three things had to be settled to build it, and they are settled here. +;; +;; **It displays, it does not select.** A program stops on its own clock, not +;; the editor's: the likeliest moment is in a frame of its own game loop while +;; someone is typing in another buffer. Taking the window would send the next +;; keystrokes somewhere they were not aimed, and `q' in a break buffer is not +;; what a half-typed word wanted to be. So `display-buffer': the buffer +;; appears, point does not move, and the window that had focus keeps it. +;; `flan-dev-break-on-stop' can be set to `focus' by anyone who disagrees, and +;; to nil to go back to the mode line alone. +;; +;; **`(pause)' is not a special case.** It was worth asking — a breakpoint is +;; deliberate where an error is not, so it could be argued it has earned the +;; window. It has not, and for the reason above: `(pause)' is deliberate at +;; the moment it was *written*, and the frame it fires on still arrives +;; whenever the program gets there. Nothing about that is more expected than +;; an error, from the point of view of the hands on the keyboard. What it does +;; get is an honest headline — `Pause' is a stop and not a failure, and the +;; break buffer says so rather than calling it unhandled. +;; +;; **A stop that arrives mid-edit disturbs nothing**, which falls out of +;; displaying rather than selecting. Two guards go beyond that. Nothing +;; happens while the minibuffer is active, because a prompt is a modal thing +;; someone is in the middle of and rearranging windows under it is hostile; +;; and nothing happens while a keyboard macro is running, because a macro that +;; behaves differently depending on whether the program happened to stop is a +;; macro that cannot be trusted. In both cases the mode line still says +;; stopped and `C-c C-b' still works, so nothing is lost but the automatic +;; part. + +(defcustom flan-dev-break-on-stop 'display + "What to do when the program stops. +`display' shows the break buffer without taking focus, `focus' shows it and +selects its window, and nil leaves it to the mode line and `C-c C-b'." + :type '(choice (const :tag "Show it" display) + (const :tag "Show it and go there" focus) + (const :tag "Only the mode line" nil))) + +(defun flan-dev--auto-break () + "Show the break buffer, if the program is still stopped and it is safe to. +Runs from a timer, deliberately: `flan-dev--absorb' notices the stop in the +middle of reading a reply on the socket, with `flan-dev--busy' bound, and +`flan-cnr-show' asks the daemon three more questions. Issuing those from +inside the read they were triggered by would interleave two conversations on +one connection. + +The check is on the state rather than on the edge that scheduled this. By the +time this runs the edge has been consumed, `flan-cnr-show''s own `break' has +been through `flan-dev--absorb' again, and the program may have been resumed in +between — so what matters is whether it is stopped *now*." + (when (and flan-dev--stopped + flan-dev-break-on-stop + (not flan-dev--busy) + (process-live-p flan-dev--connection) + ;; Someone is in the middle of answering a prompt. + (not (active-minibuffer-window)) + ;; A macro must do the same thing every time it is run. + (not (or executing-kbd-macro defining-kbd-macro))) + ;; Errors are swallowed on purpose. This is a timer nobody asked to run, + ;; and a daemon that refuses it has already said so through the mode line; + ;; signalling here would put an error in the echo area in place of the + ;; message naming the condition, which is the more useful of the two. + (ignore-errors + (save-selected-window + (let ((buf (flan-cnr-show))) + (when (and (eq flan-dev-break-on-stop 'focus) (buffer-live-p buf)) + (let ((win (get-buffer-window buf))) + (when win (select-window win))))))))) + (defun flan-dev--absorb (reply) "Take from REPLY the two things every reply carries, and return it. The program's output, and whether it is stopped. Both are read here rather @@ -152,7 +236,11 @@ with it, and a rejected evaluation is a likely moment to *become* stopped." ;; echo area was saying, every second, for as long as the program sat ;; there. (when now - (message "flan: stopped on %s — C-c C-b to choose a restart" now)))) + (message "flan: stopped on %s — C-c C-b to choose a restart" now) + ;; On the edge, and out of band. See `flan-dev--auto-break' for why + ;; it cannot happen here: this runs inside the read of a reply on the + ;; socket, and showing the buffer asks three more questions down it. + (run-at-time 0 nil #'flan-dev--auto-break)))) reply) (defun flan-dev--request (form) diff --git a/emacs/test-flan-cider.el b/emacs/test-flan-cider.el index 1506760..bb1bdfd 100644 --- a/emacs/test-flan-cider.el +++ b/emacs/test-flan-cider.el @@ -18,6 +18,9 @@ (require 'flan-inspect) (require 'flan-cnr) +;; For `flan-dev--auto-break', which is a decision about globals and windows +;; and needs neither a daemon nor a socket to be asked. +(require 'flan-dev) (defvar test-flan--failures 0) (defvar test-flan--ran 0) @@ -613,6 +616,79 @@ (or (test-flan--caught #'flan-cnr-show) "")))) +;;; Opening the break buffer by itself + +;; `flan-dev--auto-break' is a decision — given the state the client is in, +;; does the break buffer appear — so it is asked here with the state bound +;; rather than by stopping a real program. Every guard in it exists because +;; the answer is no in some state a person is actually in. + +(message "\nthe break buffer opening itself") + +(defun test-flan--auto-break (&rest bindings) + "Non-nil if `flan-dev--auto-break' would show the buffer. +BINDINGS is a plist of extra state; the defaults are a live connection and a +stopped program, which is the case where it should fire." + (let ((shown nil)) + (cl-letf (((symbol-function 'flan-cnr-show) (lambda () (setq shown t) nil)) + ;; A process object that is not live, and `process-live-p' of a + ;; non-process is nil — so the default has to be overridden by + ;; the caller, which is what `:live' does. + ((symbol-function 'process-live-p) + (lambda (_) (if (plist-member bindings :live) + (plist-get bindings :live) t)))) + (let ((flan-dev--stopped + (if (plist-member bindings :stopped) + (plist-get bindings :stopped) "Missing")) + (flan-dev-break-on-stop + (if (plist-member bindings :setting) + (plist-get bindings :setting) 'display)) + (flan-dev--busy (plist-get bindings :busy)) + (executing-kbd-macro (plist-get bindings :macro)) + (flan-dev--connection 'stub)) + (flan-dev--auto-break))) + shown)) + +(test-flan--check "a stopped program opens it" + (test-flan--auto-break)) +(test-flan--check "a running one does not" + (not (test-flan--auto-break :stopped nil))) +(test-flan--check "nor does a connection that has gone" + (not (test-flan--auto-break :live nil))) +;; The reason it is deferred at all: `flan-dev--absorb' notices the stop in the +;; middle of reading a reply, and three more requests down the same socket +;; would interleave two conversations. +(test-flan--check "nor while a request is still in flight" + (not (test-flan--auto-break :busy t))) +;; A prompt is modal and someone is in the middle of it. +(test-flan--check "nor under an active minibuffer" + (let ((probe (lambda (&rest _) t))) + (advice-add 'active-minibuffer-window :override probe) + (unwind-protect (not (test-flan--auto-break)) + (advice-remove 'active-minibuffer-window probe)))) +;; A macro must do the same thing every time it is run, and whether the program +;; happened to stop during it is not something a macro can depend on. +(test-flan--check "nor inside a keyboard macro" + (not (test-flan--auto-break :macro t))) +(test-flan--check "and not at all when it is turned off" + (not (test-flan--auto-break :setting nil))) + +;; `(pause)' is a stop and not a failure, and it is the one thing about it that +;; the buffer gets to say differently. Everything underneath is identical. +(let ((text (test-flan--text + (lambda () (flan-cnr--insert-condition + (list :condition flan-cnr-breakpoint)))))) + (test-flan--check "a breakpoint is called a breakpoint" + (string-match-p "a breakpoint; stopped where (pause)" text)) + (test-flan--check "and is not called unhandled" + (not (string-match-p "unhandled" text)))) + +(let ((text (test-flan--text + (lambda () (flan-cnr--insert-condition '(:condition "Missing")))))) + (test-flan--check "while an error still is" + (string-match-p "unhandled" text))) + + ;; `flan-mode' itself — indentation, which is a function from text to text and ;; so belongs with the other fixture-driven checks rather than with anything ;; that needs a daemon. Loaded rather than run separately because