Say in the modeline whether there is a program, and reconnect to one

Whether a program is on the other end is the one fact worth a permanent place
on screen, because every command in the client is a lie without it. Until now
it was discovered by something failing, which is the worst moment to learn it.

Three states, not two. `off' is never connected; `lost' is a daemon that has
gone away, which is the ordinary case rather than an error — `flan dev' ends
when its program does, and a program under development exits all the time. So
`lost' is reconnected from, on the socket it was on, the next time anything is
sent.

The reconnect is strictly *before* a send and never after one. A connection
that dies mid-request might have died after the daemon took the request and
ran it; resending would install a definition twice, or evaluate a
side-effecting expression twice. That case now reports what happened and says
it was not resent, rather than silently doing it again.

A socket that is not there is refused by name with the path, and a deliberate
`flan-disconnect' forgets the socket, so the next command says "not connected"
instead of quietly reopening what was just closed.
This commit is contained in:
Joseph Ferano 2026-09-11 17:48:06 +07:00
parent aab6c28450
commit 12f99702b4
2 changed files with 131 additions and 14 deletions

View File

@ -24,6 +24,7 @@
(require 'subr-x)
(require 'seq)
(require 'pcase)
(defgroup flan-dev nil
"Talking to a running Flan program."
@ -73,7 +74,16 @@
(accept-process-output proc 0.05))
(goto-char (point-min))
(unless (re-search-forward "\\`\\([0-9]+\\)\n" nil t)
(error "flan dev: no reply"))
;; Deliberately not retried. If the daemon took the request and died
;; before replying, the evaluation may well have happened — sending it
;; again would install it twice, or run a side-effecting expression
;; twice. Reconnecting happens before a send, never after one.
(if (process-live-p proc)
(error "flan dev: no reply in 30s from %s"
(abbreviate-file-name (or flan-dev--socket "the daemon")))
(error
"flan dev: the daemon on %s closed the connection; not resent, because it may already have run"
(abbreviate-file-name (or flan-dev--socket "?")))))
(let* ((n (string-to-number (match-string 1)))
(body-start (point)))
(while (and (< (- (position-bytes (point-max)) (position-bytes body-start)) n)
@ -120,11 +130,50 @@
flan-dev-socket-name)))
(and dir (expand-file-name flan-dev-socket-name dir))))
(defun flan-dev--open (socket)
"Open a connection to SOCKET and make it the current one."
(when (process-live-p flan-dev--connection)
(delete-process flan-dev--connection))
(let ((buf (get-buffer-create " *flan-dev*")))
;; Unibyte, because the framing counts bytes and this buffer is where they
;; are counted.
(with-current-buffer buf (erase-buffer) (set-buffer-multibyte nil))
(setq flan-dev--connection
(make-network-process
:name "flan-dev" :buffer buf :family 'local :service socket
:coding 'binary :noquery t))
(setq flan-dev--socket socket))
(force-mode-line-update t)
flan-dev--connection)
;; A daemon restarted while Emacs was not looking is the ordinary case, not an
;; exceptional one: `flan dev' ends when its program does, and a program under
;; development exits all the time. So a dead connection is reopened on the
;; socket it was on rather than reported — but only *before* a request goes
;; out. Reconnecting after one has been sent and lost would be a retry, and a
;; retry of `eval-expr' runs the expression a second time.
(defun flan-dev--live-connection ()
"The open connection, or signal an error saying how to get one."
(unless (and flan-dev--connection
(process-live-p flan-dev--connection))
(error "Not connected: M-x flan-connect, or start `flan dev program.flan'"))
"The open connection, reconnecting if the daemon has been restarted."
(unless (process-live-p flan-dev--connection)
(cond
((null flan-dev--socket)
(error "Not connected: M-x flan-connect, or start `flan dev program.flan'"))
((not (file-exists-p flan-dev--socket))
(setq flan-dev--connection nil)
(force-mode-line-update t)
(error "flan dev: nothing is listening on %s; start `flan dev program.flan' again"
(abbreviate-file-name flan-dev--socket)))
(t
(condition-case err
(progn (flan-dev--open flan-dev--socket)
(message "flan dev: reconnected to %s"
(abbreviate-file-name flan-dev--socket)))
(error
(setq flan-dev--connection nil)
(force-mode-line-update t)
(error "flan dev: cannot reconnect to %s: %s"
(abbreviate-file-name flan-dev--socket)
(error-message-string err)))))))
flan-dev--connection)
;;;###autoload
@ -135,15 +184,7 @@ With no argument, look for `flan-dev-socket-name' up from this buffer."
(list (or (flan-dev--find-socket)
(read-file-name "flan dev socket: "))))
(unless socket (user-error "No %s found above this buffer" flan-dev-socket-name))
(when (process-live-p flan-dev--connection)
(delete-process flan-dev--connection))
(let ((buf (get-buffer-create " *flan-dev*")))
(with-current-buffer buf (erase-buffer) (set-buffer-multibyte nil))
(setq flan-dev--connection
(make-network-process
:name "flan-dev" :buffer buf :family 'local :service socket
:coding 'binary :noquery t))
(setq flan-dev--socket socket))
(flan-dev--open socket)
(let ((r (flan-dev--request '(:op "describe"))))
(message "flan dev: connected to %s (%d functions, %d globals)"
(abbreviate-file-name socket)
@ -157,8 +198,50 @@ With no argument, look for `flan-dev-socket-name' up from this buffer."
(ignore-errors (flan-dev--request '(:op "close")))
(delete-process flan-dev--connection))
(setq flan-dev--connection nil)
;; Forgotten, not kept: this was a deliberate disconnect, so the next
;; request should say so rather than quietly reopening what was just closed.
(setq flan-dev--socket nil)
(force-mode-line-update t)
(message "flan dev: disconnected"))
;;; The modeline
;; Whether there is a program on the other end is the one thing worth a
;; permanent place on screen, because every other command in here is a lie
;; without it. Before this it was discovered by a command failing.
(defface flan-dev-live-face '((t :inherit success))
"Face for the modeline indicator when a program is connected."
:group 'flan-dev)
(defface flan-dev-lost-face '((t :inherit warning))
"Face for the modeline indicator when the daemon has gone away."
:group 'flan-dev)
(defun flan-dev-state ()
"Whether a program is connected: `live', `lost', or `off'.
`lost' means there was one and the daemon is gone a restart away, not a
mistake, so it is distinguished from never having connected."
(cond ((process-live-p flan-dev--connection) 'live)
(flan-dev--socket 'lost)
(t 'off)))
(defun flan-dev-mode-line ()
"The Flan connection indicator, for `mode-line-misc-info'."
(when (derived-mode-p 'flan-mode 'flan-repl-mode)
(pcase (flan-dev-state)
('live (propertize " flan:live" 'face 'flan-dev-live-face
'help-echo (format "Connected to %s" flan-dev--socket)))
('lost (propertize " flan:lost" 'face 'flan-dev-lost-face
'help-echo
(format "%s has gone away; the next command reconnects"
flan-dev--socket)))
(_ (propertize " flan:off" 'face 'shadow
'help-echo "Not connected (C-c C-z)")))))
;; Appended rather than prepended: this is the least urgent thing in the line.
(add-to-list 'mode-line-misc-info '(:eval (flan-dev-mode-line)) t)
;;;###autoload
(defun flan-show-output ()
"Show the running program's output, after collecting anything pending."

View File

@ -31,8 +31,42 @@
(setq buffer-read-only nil)
(test-flan--check "flan-mode is on for a .flan file" (eq major-mode 'flan-mode))
(test-flan--check "the modeline says so before connecting"
(and (eq (flan-dev-state) 'off)
(string-match-p "off" (flan-dev-mode-line))))
(flan-connect socket)
(test-flan--check "connected" (process-live-p flan-dev--connection))
(test-flan--check "the modeline says a program is there"
(and (eq (flan-dev-state) 'live)
(string-match-p "live" (flan-dev-mode-line))))
(test-flan--check "and says nothing in a buffer that is not Flan's"
(with-temp-buffer (null (flan-dev-mode-line))))
;; A daemon restarted while Emacs was not looking is the ordinary case. The
;; socket outlives this connection, so dropping the process and asking again
;; is the same situation the client meets after a restart, and it must come
;; back rather than fail.
(delete-process flan-dev--connection)
(test-flan--check "a dead connection reads as lost, not as never-connected"
(and (eq (flan-dev-state) 'lost)
(string-match-p "lost" (flan-dev-mode-line))))
(let ((r (flan-dev--request '(:op "describe"))))
(test-flan--check "the next request reconnects on its own"
(and (process-live-p flan-dev--connection)
(member "step" (plist-get r :fns)))))
;; But a socket nobody is listening on is refused by name, rather than
;; retried forever or reported as some other failure.
(let ((flan-dev--connection nil)
(flan-dev--socket "/nonexistent/flan-dev-not-here.sock")
(raised nil))
(condition-case err (flan-dev--request '(:op "describe"))
(error (setq raised (error-message-string err))))
(test-flan--check "a socket that is gone is refused by name"
(and raised
(string-match-p "flan-dev-not-here.sock" raised)
(string-match-p "nothing is listening" raised))))
(let ((r (flan-dev--request '(:op "describe"))))
(test-flan--check "describe lists the program's functions"