aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-11 14:54:03 -0500
committerCraig Jennings <c@cjennings.net>2026-06-11 14:54:03 -0500
commit6b8918b4cc44dc39e70a5544e8286eb781a97ccc (patch)
tree045a26a0a64d69bd2d3c562388956e502810ca71 /tests
parent5541e60418b5c1cf44c0a0b6999589d10d20f7bd (diff)
downloadchime-6b8918b4cc44dc39e70a5544e8286eb781a97ccc.tar.gz
chime-6b8918b4cc44dc39e70a5544e8286eb781a97ccc.zip
test: add failing tests for the async fetch watchdog
Diffstat (limited to 'tests')
-rw-r--r--tests/test-chime-async-watchdog.el153
-rw-r--r--tests/test-chime-numeric-defcustom-setters.el26
2 files changed, 179 insertions, 0 deletions
diff --git a/tests/test-chime-async-watchdog.el b/tests/test-chime-async-watchdog.el
new file mode 100644
index 0000000..473131d
--- /dev/null
+++ b/tests/test-chime-async-watchdog.el
@@ -0,0 +1,153 @@
+;;; test-chime-async-watchdog.el --- Tests for the async fetch watchdog -*- lexical-binding: t; -*-
+
+;; Copyright (C) 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.
+
+;;; Commentary:
+
+;; A hung async child froze the modeline for 15+ hours: org-agenda-list hit
+;; org's interactive "Non-existent agenda file ... [R]emove or [A]bort?"
+;; prompt inside the -batch child, which blocked forever, and the overlap
+;; guard in `chime--fetch-and-process' turned every subsequent tick into a
+;; silent no-op. A child that never returns is invisible to every failure
+;; path — `chime--consecutive-async-failures' stays 0.
+;;
+;; Two layers under test here:
+;;
+;; 1. The child payload from `chime--retrieve-events' must set
+;; `org-agenda-skip-unavailable-files' so a vanished agenda file is
+;; skipped instead of prompting. Any prompt in a -batch child is a
+;; permanent hang.
+;;
+;; 2. The watchdog: a live child older than `chime-async-timeout' is
+;; interrupted, recorded through `chime--record-async-failure', and
+;; replaced by a fresh spawn in the same tick.
+
+;;; Code:
+
+(require 'test-bootstrap (expand-file-name "test-bootstrap.el"))
+(require 'cl-lib)
+
+;;;; Layer 1 — prompt-proof child payload
+
+(ert-deftest test-chime-retrieve-events-payload-skips-unavailable-files ()
+ "Normal: the child payload disables org's missing-agenda-file prompt.
+`org-agenda-skip-unavailable-files' must be set to t in the child form so
+`org-agenda-list' skips a vanished file with a message instead of blocking
+forever on an interactive prompt no one can answer."
+ (let ((form (chime--retrieve-events)))
+ (should (member '(setf org-agenda-skip-unavailable-files t) form))))
+
+;;;; Layer 2 — watchdog on the overlap guard
+
+(ert-deftest test-chime-fetch-and-process-young-child-blocks-tick ()
+ "Normal: a live child younger than the timeout blocks the tick untouched."
+ (let* ((now (current-time))
+ (interrupted nil)
+ (spawned nil)
+ (chime--process 'fake-live-process)
+ (chime--process-start-time (time-subtract now (seconds-to-time 10)))
+ (chime-async-timeout 120))
+ (cl-letf (((symbol-function 'current-time) (lambda () now))
+ ((symbol-function 'process-live-p)
+ (lambda (proc) (eq proc 'fake-live-process)))
+ ((symbol-function 'interrupt-process)
+ (lambda (proc) (setq interrupted proc)))
+ ((symbol-function 'async-start)
+ (lambda (&rest _) (setq spawned t) 'unused)))
+ (chime--fetch-and-process (lambda (_events) nil)))
+ (should-not interrupted)
+ (should-not spawned)
+ (should (eq 'fake-live-process chime--process))))
+
+(ert-deftest test-chime-fetch-and-process-child-at-threshold-not-interrupted ()
+ "Boundary: a child exactly at `chime-async-timeout' seconds is left alone.
+Interruption requires strictly exceeding the timeout."
+ (let* ((now (current-time))
+ (interrupted nil)
+ (spawned nil)
+ (chime--process 'fake-live-process)
+ (chime--process-start-time (time-subtract now (seconds-to-time 120)))
+ (chime-async-timeout 120))
+ (cl-letf (((symbol-function 'current-time) (lambda () now))
+ ((symbol-function 'process-live-p)
+ (lambda (proc) (eq proc 'fake-live-process)))
+ ((symbol-function 'interrupt-process)
+ (lambda (proc) (setq interrupted proc)))
+ ((symbol-function 'async-start)
+ (lambda (&rest _) (setq spawned t) 'unused)))
+ (chime--fetch-and-process (lambda (_events) nil)))
+ (should-not interrupted)
+ (should-not spawned)))
+
+(ert-deftest test-chime-fetch-and-process-stale-child-interrupted-and-respawned ()
+ "Error: an over-age child is interrupted, recorded as a failure, replaced.
+The hung child must feed the existing consecutive-failures machinery (via
+`chime--record-async-failure') instead of silently blocking every tick, and
+the same tick spawns a fresh child."
+ (let* ((now (current-time))
+ (interrupted nil)
+ (recorded nil)
+ (spawned nil)
+ (chime--process 'fake-live-process)
+ (chime--process-start-time (time-subtract now (seconds-to-time 121)))
+ (chime-async-timeout 120))
+ (cl-letf (((symbol-function 'current-time) (lambda () now))
+ ((symbol-function 'process-live-p)
+ (lambda (proc) (eq proc 'fake-live-process)))
+ ((symbol-function 'interrupt-process)
+ (lambda (proc) (setq interrupted proc)))
+ ((symbol-function 'chime--record-async-failure)
+ (lambda (err prefix) (setq recorded (cons prefix err))))
+ ((symbol-function 'async-start)
+ (lambda (&rest _) (setq spawned t) 'new-fake-process)))
+ (chime--fetch-and-process (lambda (_events) nil)))
+ (should (eq 'fake-live-process interrupted))
+ (should (equal "Async watchdog" (car recorded)))
+ (should (string-match-p "chime-async-timeout"
+ (error-message-string (cdr recorded))))
+ (should spawned)
+ (should (eq 'new-fake-process chime--process))))
+
+(ert-deftest test-chime-fetch-and-process-nil-timeout-disables-watchdog ()
+ "Boundary: `chime-async-timeout' nil disables the watchdog entirely.
+Even an ancient live child is left alone and still blocks the tick."
+ (let* ((now (current-time))
+ (interrupted nil)
+ (spawned nil)
+ (chime--process 'fake-live-process)
+ (chime--process-start-time (time-subtract now (seconds-to-time 99999)))
+ (chime-async-timeout nil))
+ (cl-letf (((symbol-function 'current-time) (lambda () now))
+ ((symbol-function 'process-live-p)
+ (lambda (proc) (eq proc 'fake-live-process)))
+ ((symbol-function 'interrupt-process)
+ (lambda (proc) (setq interrupted proc)))
+ ((symbol-function 'async-start)
+ (lambda (&rest _) (setq spawned t) 'unused)))
+ (chime--fetch-and-process (lambda (_events) nil)))
+ (should-not interrupted)
+ (should-not spawned)
+ (should (eq 'fake-live-process chime--process))))
+
+(ert-deftest test-chime-fetch-and-process-spawn-records-start-time ()
+ "Normal: a fresh spawn records the spawn timestamp for the watchdog."
+ (let* ((now (current-time))
+ (chime--process nil)
+ (chime--process-start-time nil)
+ (chime-async-timeout 120))
+ (cl-letf (((symbol-function 'current-time) (lambda () now))
+ ((symbol-function 'async-start)
+ (lambda (&rest _) 'fake-process)))
+ (chime--fetch-and-process (lambda (_events) nil)))
+ (should (eq 'fake-process chime--process))
+ (should (equal now chime--process-start-time))))
+
+(provide 'test-chime-async-watchdog)
+;;; test-chime-async-watchdog.el ends here
diff --git a/tests/test-chime-numeric-defcustom-setters.el b/tests/test-chime-numeric-defcustom-setters.el
index 7fb5afa..ad339b9 100644
--- a/tests/test-chime-numeric-defcustom-setters.el
+++ b/tests/test-chime-numeric-defcustom-setters.el
@@ -161,6 +161,32 @@
'chime-max-consecutive-failures -1)
:type 'user-error)))
+;;;; chime-async-timeout — integer >= 1, or nil to disable
+
+(ert-deftest test-chime-async-timeout-accepts-positive-integer ()
+ "Normal: a positive timeout in seconds lands."
+ (let ((chime-async-timeout 120))
+ (customize-set-variable 'chime-async-timeout 300)
+ (should (= 300 chime-async-timeout))))
+
+(ert-deftest test-chime-async-timeout-accepts-nil ()
+ "Boundary: nil disables the watchdog (per docstring)."
+ (let ((chime-async-timeout 120))
+ (customize-set-variable 'chime-async-timeout nil)
+ (should (null chime-async-timeout))))
+
+(ert-deftest test-chime-async-timeout-rejects-zero ()
+ "Error: a zero-second timeout would interrupt every spawn immediately."
+ (let ((chime-async-timeout 120))
+ (should-error (customize-set-variable 'chime-async-timeout 0)
+ :type 'user-error)))
+
+(ert-deftest test-chime-async-timeout-rejects-non-integer ()
+ "Error: a string raises `user-error' at customize time."
+ (let ((chime-async-timeout 120))
+ (should-error (customize-set-variable 'chime-async-timeout "120")
+ :type 'user-error)))
+
;; Note: `chime--validation-max-retries' was demoted from defcustom to
;; defvar in 0.8 — no customize-time setter, no validation tests here.