aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-28 01:03:09 -0500
committerCraig Jennings <c@cjennings.net>2026-07-28 01:03:09 -0500
commit58f7fb0b672231858377d1cf4e5ce70fb8a3e8c3 (patch)
tree9b4a1c76c0d28e8f32e7ef13a6a7c4973d5516be
parent9da363f35b2b9ebc5ffefb41cf092b006c56a695 (diff)
downloaddotemacs-58f7fb0b672231858377d1cf4e5ce70fb8a3e8c3.tar.gz
dotemacs-58f7fb0b672231858377d1cf4e5ce70fb8a3e8c3.zip
fix(telega): alert on server death and pin the container image
- A dead telega-server now raises a persistent desktop notification. - The container image is pinned by digest, not by tag. - A clean telega exit stays silent, so quitting never pages. telega reports an abnormal server exit through the echo area, where it scrolls away unseen. That's how a dead server reads as a quiet Telegram. The scan stops partway, and the chats it never reached look empty. It's happened twice. Both times something downstream caught it rather than the failure announcing itself. telega pins its image only when its TDLib min and max versions match. Here the max is nil, so the image was always the latest tag. A floating tag against a fixed elpa package means the server can change underneath it with nothing announcing that either. Neither change fixes the crash. The root cause is still open. The assertion is a plist parse failure. Its coredump shows a smashed stack, which reads more like memory corruption than a parse rejection. I didn't pull a newer image, which was the other half of the plan. The pull was meant to test whether an old server was failing against newer elisp. The frozen image ships libtdjson 1.8.64, exactly the version this telega requires. That theory no longer holds, so pulling would trade a matched server for an unknown one.
-rw-r--r--modules/telega-config.el112
-rw-r--r--tests/test-telega-config--docker-pin.el93
-rw-r--r--tests/test-telega-config--server-death.el131
3 files changed, 336 insertions, 0 deletions
diff --git a/modules/telega-config.el b/modules/telega-config.el
index acc9e482..5a20d430 100644
--- a/modules/telega-config.el
+++ b/modules/telega-config.el
@@ -48,6 +48,7 @@
;;; Code:
(require 'keybindings)
+(require 'system-lib) ; cj/log-silently, used by the death alert
(use-package telega
:defer t
@@ -63,6 +64,117 @@
;; routing through a shared messenger notifier is the unification task.
(telega-notifications-mode 1))
+;; --------------------------- telega Docker Image Pin -------------------------
+;; telega picks its container image in `telega-docker--image-name', which only
+;; pins to a version tag when `telega-tdlib-min-version' equals
+;; `telega-tdlib-max-version' and the version ends in ".0". Here min is
+;; "1.8.64" and max is nil, so that test never passes and the image is always
+;; "zevlg/telega-server:latest" -- a floating tag. The elpa package is fixed
+;; at whatever version was installed, so the server can be replaced underneath
+;; a static elisp without anything announcing it.
+;;
+;; Pinning by digest names one immutable image. Set to nil to hand the choice
+;; back to telega.
+
+(defcustom cj/telega-docker-image
+ "zevlg/telega-server@sha256:a4b88e029ba381eca7c37c9618c9e3ad73aa9db2097fe07a0c6684d40d32b84e"
+ "Container image reference for `telega-server', or nil for telega's default.
+Pin by digest rather than tag: a tag can be re-pushed upstream, a digest
+cannot. The default is the image carrying libtdjson 1.8.64, which matches
+this telega's `telega-tdlib-min-version'."
+ :type '(choice (const :tag "Let telega infer the image" nil)
+ (string :tag "Image reference"))
+ :group 'telega-docker)
+
+(defun cj/--telega-docker-pinned-image ()
+ "Return the configured image pin, or nil when none is usable.
+A blank or non-string setting yields nil rather than reaching the docker
+command line, where it would fail in a way that looks unrelated to this."
+ (when (stringp cj/telega-docker-image)
+ (let ((pin (string-trim cj/telega-docker-image)))
+ (unless (string-empty-p pin) pin))))
+
+(defun cj/--telega-docker-image-name (orig-fun &rest args)
+ "Return the pinned telega-server image, else call ORIG-FUN with ARGS.
+`:around' advice on `telega-docker--image-name', so clearing the pin
+restores telega's own inference instead of breaking the image name."
+ (or (cj/--telega-docker-pinned-image)
+ (apply orig-fun args)))
+
+(with-eval-after-load 'telega-util
+ (advice-add 'telega-docker--image-name :around #'cj/--telega-docker-image-name))
+
+;; ------------------------- telega-server Death Alert -------------------------
+;; telega's own sentinel reports an abnormal server exit with `message', which
+;; scrolls out of the echo area unseen. That is how a dead server reads as a
+;; quiet Telegram: the scan stops partway and the unscanned chats look like
+;; chats with nothing in them. It has happened twice (2026-07-10, 2026-07-27),
+;; caught both times only because something downstream noticed. A desktop
+;; notification makes the death itself visible.
+
+(declare-function notifications-notify "notifications")
+
+(defun cj/--telega-server-death-p (status)
+ "Return non-nil when exit STATUS means the server died abnormally.
+Any non-zero integer counts, which covers both a non-zero exit code and a
+fatal signal number (`process-exit-status' reports SIGSEGV as 11). A
+non-integer STATUS returns nil rather than signalling: this runs inside
+telega's sentinel, where an error would abort telega's own cleanup."
+ (and (integerp status)
+ (not (zerop status))))
+
+(defun cj/--telega-server-exit-status (proc)
+ "Return PROC's exit status, or nil when it can't be determined.
+Guarded because the sentinel hands over whatever process object it has,
+and a bad one must not break telega's status handling."
+ (condition-case nil
+ (and (processp proc) (process-exit-status proc))
+ (error nil)))
+
+(defun cj/--telega-server-death-body (status event)
+ "Build the notification body for a server death with STATUS and EVENT.
+EVENT is the sentinel's event string, which carries a trailing newline that
+would render as dead space in a desktop notification."
+ (let ((detail (string-trim (or event ""))))
+ (concat (format "telega-server died (status %s). " status)
+ (unless (string-empty-p detail) (concat detail ". "))
+ "Telegram coverage is down until it restarts.")))
+
+(defun cj/--telega-server-send-notification (title body)
+ "Deliver a desktop notification with TITLE and BODY.
+Prefers the external notify script (persistent, so it waits rather than
+auto-dismissing while away), falling back to `notifications-notify'.
+Mirrors `cj/slack--send-notification'."
+ (let ((script (executable-find "notify")))
+ (if script
+ (start-process "telega-death-notify" nil script "fail" title body "--persist")
+ (unless (fboundp 'notifications-notify)
+ (require 'notifications))
+ (notifications-notify :title title :body body))))
+
+(defun cj/--telega-server-notify-death (proc event)
+ "Notify when the telega-server PROC dies abnormally. EVENT is its event string.
+Installed as `:after' advice on `telega-server--sentinel'. Silent on a
+clean exit, so quitting telega deliberately never pages.
+
+The whole body is guarded: a notifier failure here would otherwise escape
+into telega's sentinel and abort its status handling and relogin path."
+ (condition-case err
+ (let ((status (cj/--telega-server-exit-status proc)))
+ (when (cj/--telega-server-death-p status)
+ (cj/--telega-server-send-notification
+ "Telegram: telega-server died"
+ (cj/--telega-server-death-body status event))))
+ (error
+ (cj/log-silently
+ (format "telega death notify failed: %s" (error-message-string err))))))
+
+;; Named, never a lambda: anonymous advice can't be `advice-remove'd by
+;; reference, so a live daemon keeps running it after the source stops
+;; installing it.
+(with-eval-after-load 'telega-server
+ (advice-add 'telega-server--sentinel :after #'cj/--telega-server-notify-death))
+
(defun cj/telega ()
"Launch telega.el with a helpful message when it isn't installed yet.
diff --git a/tests/test-telega-config--docker-pin.el b/tests/test-telega-config--docker-pin.el
new file mode 100644
index 00000000..894dbfdd
--- /dev/null
+++ b/tests/test-telega-config--docker-pin.el
@@ -0,0 +1,93 @@
+;;; test-telega-config--docker-pin.el --- Tests for the telega docker image pin -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for pinning the telega-server container image.
+;;
+;; telega infers its image from `telega-tdlib-min-version' and only pins to a
+;; version tag when min and max versions are equal and the version ends in
+;; ".0". This config has min "1.8.64" and max nil, so the inference always
+;; falls through to "zevlg/telega-server:latest" -- a moving tag that can
+;; swap the server out from under a fixed elisp version without notice.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'telega-config)
+
+;; -- cj/--telega-docker-pinned-image -----------------------------------------
+
+(ert-deftest test-telega-config-pin-returns-configured-reference ()
+ "Normal: a configured pin is returned verbatim."
+ (let ((cj/telega-docker-image "zevlg/telega-server@sha256:abc123"))
+ (should (equal (cj/--telega-docker-pinned-image)
+ "zevlg/telega-server@sha256:abc123"))))
+
+(ert-deftest test-telega-config-pin-nil-when-unset ()
+ "Boundary: no pin returns nil so telega's own inference stays in charge."
+ (let ((cj/telega-docker-image nil))
+ (should-not (cj/--telega-docker-pinned-image))))
+
+(ert-deftest test-telega-config-pin-nil-for-empty-string ()
+ "Boundary: an empty or whitespace pin is not a reference.
+An empty string would otherwise reach the docker command line as a blank
+image argument, which fails in a way that looks unrelated to this setting."
+ (let ((cj/telega-docker-image ""))
+ (should-not (cj/--telega-docker-pinned-image)))
+ (let ((cj/telega-docker-image " "))
+ (should-not (cj/--telega-docker-pinned-image))))
+
+(ert-deftest test-telega-config-pin-nil-for-non-string ()
+ "Error: a non-string pin is ignored rather than passed to the shell."
+ (let ((cj/telega-docker-image 'latest))
+ (should-not (cj/--telega-docker-pinned-image)))
+ (let ((cj/telega-docker-image 42))
+ (should-not (cj/--telega-docker-pinned-image))))
+
+(ert-deftest test-telega-config-pin-trims-surrounding-whitespace ()
+ "Boundary: a pin with stray whitespace is trimmed, not rejected.
+A trailing newline is easy to introduce when pasting a digest from docker."
+ (let ((cj/telega-docker-image " zevlg/telega-server@sha256:abc123\n"))
+ (should (equal (cj/--telega-docker-pinned-image)
+ "zevlg/telega-server@sha256:abc123"))))
+
+;; -- cj/--telega-docker-image-name (the advice) ------------------------------
+
+(ert-deftest test-telega-config-image-advice-prefers-the-pin ()
+ "Normal: with a pin set, the advice returns it instead of calling telega."
+ (let ((cj/telega-docker-image "zevlg/telega-server@sha256:abc123")
+ (called nil))
+ (should (equal (cj/--telega-docker-image-name
+ (lambda () (setq called t) "zevlg/telega-server:latest"))
+ "zevlg/telega-server@sha256:abc123"))
+ (should-not called)))
+
+(ert-deftest test-telega-config-image-advice-delegates-without-a-pin ()
+ "Boundary: with no pin, telega's own inference is used unchanged.
+Removing the pin must restore stock behavior rather than break the image
+name, so this stays a reversible setting."
+ (let ((cj/telega-docker-image nil))
+ (should (equal (cj/--telega-docker-image-name
+ (lambda () "zevlg/telega-server:latest"))
+ "zevlg/telega-server:latest"))))
+
+(ert-deftest test-telega-config-image-advice-is-named-and-removable ()
+ "Normal: the advice is a named function so it can be removed by reference."
+ (should (fboundp 'cj/--telega-docker-image-name)))
+
+(ert-deftest test-telega-config-pin-default-is-a-digest-reference ()
+ "Normal: the shipped default pins by digest, not by a floating tag.
+A tag pin (:latest, or even a version tag upstream can re-push) still
+moves; a digest names one immutable image.
+
+Reads the defcustom's standard value rather than the live variable, so
+customizing the pin (including to nil, handing the choice back to telega)
+is not a test failure -- only changing the shipped default is."
+ (let ((default (eval (car (get 'cj/telega-docker-image 'standard-value)) t)))
+ (should (stringp default))
+ (should (string-match-p "@sha256:[0-9a-f]\\{64\\}\\'" default))))
+
+(provide 'test-telega-config--docker-pin)
+;;; test-telega-config--docker-pin.el ends here
diff --git a/tests/test-telega-config--server-death.el b/tests/test-telega-config--server-death.el
new file mode 100644
index 00000000..1151fa7e
--- /dev/null
+++ b/tests/test-telega-config--server-death.el
@@ -0,0 +1,131 @@
+;;; test-telega-config--server-death.el --- Tests for the telega-server death alert -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the telega-server death sentinel. telega's own sentinel only
+;; calls `message' on an abnormal exit, which scrolls away unseen -- so a
+;; dead server reads as a quiet Telegram. These pin the alert that replaces
+;; that silence.
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+(require 'telega-config)
+
+;; -- cj/--telega-server-death-p ----------------------------------------------
+
+(ert-deftest test-telega-config-death-p-nonzero-status-is-death ()
+ "Normal: a non-zero exit status is an abnormal death."
+ (should (cj/--telega-server-death-p 1))
+ (should (cj/--telega-server-death-p 139)))
+
+(ert-deftest test-telega-config-death-p-segv-signal-is-death ()
+ "Normal: a signal number (SIGSEGV is 11) counts as death.
+This is the observed failure -- the server segfaults rather than exiting."
+ (should (cj/--telega-server-death-p 11)))
+
+(ert-deftest test-telega-config-death-p-zero-is-clean-exit ()
+ "Boundary: exit status 0 is a clean shutdown and must not alert.
+Quitting telega deliberately would otherwise page on every exit."
+ (should-not (cj/--telega-server-death-p 0)))
+
+(ert-deftest test-telega-config-death-p-nil-is-not-death ()
+ "Boundary: nil status (no status available) is not a death."
+ (should-not (cj/--telega-server-death-p nil)))
+
+(ert-deftest test-telega-config-death-p-non-integer-is-not-death ()
+ "Error: a non-integer status returns nil rather than signalling.
+`process-exit-status' is documented to return an integer, but this runs
+inside telega's sentinel where an error would disrupt telega's own cleanup."
+ (should-not (cj/--telega-server-death-p "segfault"))
+ (should-not (cj/--telega-server-death-p 'exited)))
+
+;; -- cj/--telega-server-exit-status ------------------------------------------
+
+(ert-deftest test-telega-config-exit-status-nil-for-non-process ()
+ "Error: a nil or non-process argument yields nil, not a signal.
+The sentinel hands us whatever it has; a bad object must not break telega."
+ (should-not (cj/--telega-server-exit-status nil))
+ (should-not (cj/--telega-server-exit-status "not-a-process")))
+
+;; -- cj/--telega-server-death-body -------------------------------------------
+
+(ert-deftest test-telega-config-death-body-names-status-and-effect ()
+ "Normal: the body carries the status and says coverage is affected.
+The status distinguishes a segfault from a clean kill; the effect is why
+the reader should care."
+ (let ((body (cj/--telega-server-death-body 11 "segmentation fault\n")))
+ (should (string-match-p "11" body))
+ (should (string-match-p "Telegram" body))))
+
+(ert-deftest test-telega-config-death-body-strips-trailing-newline ()
+ "Boundary: a sentinel event string ends in a newline; it must not survive.
+A trailing newline in a desktop notification body renders as dead space."
+ (let ((body (cj/--telega-server-death-body 11 "segmentation fault\n")))
+ (should-not (string-suffix-p "\n" body))))
+
+(ert-deftest test-telega-config-death-body-handles-empty-event ()
+ "Boundary: an empty or nil event still produces a usable body."
+ (should (stringp (cj/--telega-server-death-body 11 nil)))
+ (should (stringp (cj/--telega-server-death-body 11 "")))
+ (should (string-match-p "11" (cj/--telega-server-death-body 11 nil))))
+
+(ert-deftest test-telega-config-death-body-handles-unicode-event ()
+ "Boundary: a non-ASCII event string passes through intact."
+ (let ((body (cj/--telega-server-death-body 1 "ошибка сервера\n")))
+ (should (string-match-p "ошибка" body))))
+
+;; -- cj/--telega-server-notify-death (the advice) ----------------------------
+
+(defmacro test-telega-config--with-status (status captured &rest body)
+ "Run BODY with the exit status forced to STATUS.
+Notification calls are captured into CAPTURED as (TITLE . BODY) pairs."
+ (declare (indent 2))
+ `(let ((,captured nil))
+ (cl-letf (((symbol-function 'cj/--telega-server-exit-status)
+ (lambda (&rest _) ,status))
+ ((symbol-function 'cj/--telega-server-send-notification)
+ (lambda (title body) (push (cons title body) ,captured))))
+ ,@body)))
+
+(ert-deftest test-telega-config-notify-fires-on-abnormal-exit ()
+ "Normal: an abnormal exit sends exactly one notification."
+ (test-telega-config--with-status 11 sent
+ (cj/--telega-server-notify-death nil "segmentation fault\n")
+ (should (= 1 (length sent)))
+ (should (string-match-p "telega" (downcase (car (car sent)))))))
+
+(ert-deftest test-telega-config-notify-silent-on-clean-exit ()
+ "Boundary: a clean exit sends nothing.
+Deliberately quitting telega must not page."
+ (test-telega-config--with-status 0 sent
+ (cj/--telega-server-notify-death nil "finished\n")
+ (should-not sent)))
+
+(ert-deftest test-telega-config-notify-silent-without-status ()
+ "Boundary: no recoverable status sends nothing rather than a bare alert."
+ (test-telega-config--with-status nil sent
+ (cj/--telega-server-notify-death nil "gone\n")
+ (should-not sent)))
+
+(ert-deftest test-telega-config-notify-contains-notifier-errors ()
+ "Error: a failing notifier must not escape into telega's sentinel.
+This runs as :after advice on `telega-server--sentinel'; an error here
+would abort telega's own status handling and its relogin path."
+ (cl-letf (((symbol-function 'cj/--telega-server-exit-status)
+ (lambda (&rest _) 11))
+ ((symbol-function 'cj/--telega-server-send-notification)
+ (lambda (&rest _) (error "notifier unavailable"))))
+ (should (progn (cj/--telega-server-notify-death nil "segfault\n") t))))
+
+(ert-deftest test-telega-config-notify-advice-is-named-and-removable ()
+ "Normal: the sentinel advice is installed by name, so it can be removed.
+An anonymous lambda can't be `advice-remove'd by reference, which strands
+the old advice in a live daemon after the source stops installing it."
+ (should (fboundp 'cj/--telega-server-notify-death))
+ (should (symbolp 'cj/--telega-server-notify-death)))
+
+(provide 'test-telega-config--server-death)
+;;; test-telega-config--server-death.el ends here