aboutsummaryrefslogtreecommitdiff
path: root/tests/test-chime-async-lifecycle.el
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-09 19:58:27 -0500
committerCraig Jennings <c@cjennings.net>2026-07-09 19:58:27 -0500
commit67eb495da255d0cf00c88448f8cfd232c88563d1 (patch)
treec8b8f43f55b4fdd52c128729e7ad4f782b40543f /tests/test-chime-async-lifecycle.el
parentf19880a641e323b1db9a19a98b7cbe5bfd694be5 (diff)
downloadchime-67eb495da255d0cf00c88448f8cfd232c88563d1.tar.gz
chime-67eb495da255d0cf00c88448f8cfd232c88563d1.zip
test: add failing tests for the async generation guard and kill path
The tests cover a fresh result being processed, a stale one discarded without touching the replacement child's handle or the failure counter, the watchdog and chime--stop both superseding the generation, and chime--kill-async-process reaping a real child and its buffer. Where a mock can't show the behavior, the tests spawn real processes. async.el's buffer handling and the sentinel silencing are the thing under test.
Diffstat (limited to 'tests/test-chime-async-lifecycle.el')
-rw-r--r--tests/test-chime-async-lifecycle.el184
1 files changed, 184 insertions, 0 deletions
diff --git a/tests/test-chime-async-lifecycle.el b/tests/test-chime-async-lifecycle.el
new file mode 100644
index 0000000..9ee0bee
--- /dev/null
+++ b/tests/test-chime-async-lifecycle.el
@@ -0,0 +1,184 @@
+;;; test-chime-async-lifecycle.el --- Tests for async process lifecycle -*- lexical-binding: t; -*-
+
+;; Copyright (C) 2024-2026 Craig Jennings
+
+;; Author: Craig Jennings <c@cjennings.net>
+
+;; This program is free software: you can redistribute it and/or modify
+;; it under the terms of the GNU General Public License as published by
+;; the Free Software Foundation, either version 3 of the License, or
+;; (at your option) any later version.
+
+;; This program is distributed in the hope that it will be useful,
+;; but WITHOUT ANY WARRANTY; without even the implied warranty of
+;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+;; GNU General Public License for more details.
+
+;; You should have received a copy of the GNU General Public License
+;; along with this program. If not, see <http://www.gnu.org/licenses/>.
+
+;;; Commentary:
+
+;; Unit tests for the async child's lifecycle: the spawn-generation guard
+;; that discards a late callback, and the kill path that reaps a child the
+;; watchdog abandoned.
+;;
+;; Two failure modes motivate these.
+;;
+;; A watchdog interrupt races a child that finishes just past the timeout.
+;; The late callback nils `chime--process' -- which by then holds the
+;; *replacement* child -- so the overlap guard breaks and a third child can
+;; spawn. It also resets the failure counter the watchdog just incremented.
+;;
+;; `interrupt-process' only asks. A child stuck in a blocking read ignores
+;; SIGINT and lives on as a zombie, invisible because `chime--process' was
+;; already nil'd. And async.el only kills a child's process buffer on a
+;; zero exit, so every signalled child leaks its buffer. A persistent hang
+;; leaks one per `chime-async-timeout' until Emacs restarts.
+;;
+;; Tests cover normal cases, boundary cases, and error cases.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+
+;;; Helpers
+
+(defun test-chime-lifecycle--reset ()
+ "Reset the async state these tests touch."
+ (setq chime--process nil)
+ (setq chime--process-start-time nil)
+ (setq chime--process-generation 0)
+ (setq chime--consecutive-async-failures 0))
+
+(defun test-chime-lifecycle--sleeper ()
+ "Spawn a real, long-running child with a process buffer."
+ (let ((process (start-process "chime-lifecycle-test"
+ (generate-new-buffer " *chime-test*")
+ "sleep" "60")))
+ (set-process-query-on-exit-flag process nil)
+ process))
+
+;;; Normal Cases
+
+(chime-deftest test-chime-lifecycle-fresh-result-is-processed ()
+ "A result from the current generation is processed."
+ (test-chime-lifecycle--reset)
+ (let ((handled nil))
+ (setq chime--process-generation 3)
+ (cl-letf (((symbol-function 'chime--handle-async-success)
+ (lambda (_callback events) (setq handled events))))
+ (chime--handle-async-result 3 #'ignore '(:an-event))
+ (should (equal handled '(:an-event))))))
+
+(chime-deftest test-chime-lifecycle-fresh-result-clears-process-state ()
+ "Processing a fresh result clears the process handle and its start time."
+ (test-chime-lifecycle--reset)
+ (setq chime--process 'a-process)
+ (setq chime--process-start-time '(1 2))
+ (cl-letf (((symbol-function 'chime--handle-async-success) (lambda (&rest _) nil)))
+ (chime--handle-async-result 0 #'ignore '())
+ (should-not chime--process)
+ (should-not chime--process-start-time)))
+
+;;; Boundary Cases
+
+(chime-deftest test-chime-lifecycle-stale-result-is-discarded ()
+ "A result from a superseded generation is ignored entirely."
+ (test-chime-lifecycle--reset)
+ (let ((handled nil)
+ (failed nil))
+ ;; Generation 1 was abandoned; generation 2 is the live child.
+ (setq chime--process-generation 2)
+ (setq chime--process 'the-replacement-child)
+ (setq chime--consecutive-async-failures 1)
+ (cl-letf (((symbol-function 'chime--handle-async-success)
+ (lambda (&rest _) (setq handled t)))
+ ((symbol-function 'chime--record-async-failure)
+ (lambda (&rest _) (setq failed t))))
+ (chime--handle-async-result 1 #'ignore '(:late-events))
+ (should-not handled)
+ (should-not failed)
+ ;; The replacement child's handle survives.
+ (should (eq chime--process 'the-replacement-child))
+ ;; The watchdog's failure count is not reset by the straggler.
+ (should (= chime--consecutive-async-failures 1)))))
+
+(chime-deftest test-chime-lifecycle-stale-error-result-is-discarded ()
+ "A late error sexp from a superseded child is ignored too."
+ (test-chime-lifecycle--reset)
+ (let ((failed nil))
+ (setq chime--process-generation 5)
+ (cl-letf (((symbol-function 'chime--record-async-failure)
+ (lambda (&rest _) (setq failed t))))
+ (chime--handle-async-result 4 #'ignore '(async-signal error "boom"))
+ (should-not failed))))
+
+(chime-deftest test-chime-lifecycle-watchdog-supersedes-the-generation ()
+ "Interrupting a stale child bumps the generation, orphaning its callback."
+ (test-chime-lifecycle--reset)
+ (let ((now (current-time))
+ (chime-async-timeout 10))
+ (setq chime--process 'stuck-child)
+ (setq chime--process-start-time (time-subtract now (seconds-to-time 60)))
+ (setq chime--process-generation 7)
+ (cl-letf (((symbol-function 'process-live-p) (lambda (_p) t))
+ ((symbol-function 'chime--kill-async-process) (lambda (_p) nil))
+ ((symbol-function 'chime--record-async-failure) (lambda (&rest _) nil)))
+ (chime--interrupt-stale-process)
+ (should (= chime--process-generation 8))
+ (should-not chime--process)
+ (should-not chime--process-start-time))))
+
+(chime-deftest test-chime-lifecycle-stop-supersedes-the-generation ()
+ "`chime--stop' orphans an in-flight callback and clears the counters."
+ (test-chime-lifecycle--reset)
+ (setq chime--process 'a-child)
+ (setq chime--process-start-time '(1 2))
+ (setq chime--process-generation 2)
+ (setq chime--consecutive-async-failures 4)
+ (cl-letf (((symbol-function 'chime--kill-async-process) (lambda (_p) nil)))
+ (chime--stop)
+ (should (= chime--process-generation 3))
+ (should-not chime--process)
+ (should-not chime--process-start-time)
+ ;; A restart must not resume with the old failure count.
+ (should (= chime--consecutive-async-failures 0))))
+
+;;; Error Cases
+
+(chime-deftest test-chime-lifecycle-kill-async-process-kills-child-and-buffer ()
+ "`chime--kill-async-process' leaves neither a live child nor its buffer.
+`interrupt-process' only asks; async.el only reaps the buffer on a zero exit."
+ (let* ((process (test-chime-lifecycle--sleeper))
+ (buffer (process-buffer process)))
+ (should (process-live-p process))
+ (should (buffer-live-p buffer))
+ (chime--kill-async-process process)
+ (should-not (process-live-p process))
+ (should-not (buffer-live-p buffer))))
+
+(chime-deftest test-chime-lifecycle-kill-async-process-tolerates-a-dead-child ()
+ "Killing an already-dead process is a no-op, not an error."
+ (let ((process (test-chime-lifecycle--sleeper)))
+ (chime--kill-async-process process)
+ (should-not (condition-case nil
+ (progn (chime--kill-async-process process) nil)
+ (error t)))))
+
+(chime-deftest test-chime-lifecycle-kill-async-process-tolerates-nil ()
+ "Killing nil is a no-op. `chime--stop' calls it unconditionally."
+ (should-not (condition-case nil
+ (progn (chime--kill-async-process nil) nil)
+ (error t))))
+
+(chime-deftest test-chime-lifecycle-kill-async-process-silences-the-sentinel ()
+ "The killed child's sentinel does not run, so async.el can't act on it."
+ (let ((process (test-chime-lifecycle--sleeper))
+ (sentinel-ran nil))
+ (set-process-sentinel process (lambda (&rest _) (setq sentinel-ran t)))
+ (chime--kill-async-process process)
+ (should-not sentinel-ran)))
+
+(provide 'test-chime-async-lifecycle)
+;;; test-chime-async-lifecycle.el ends here