From edb27d7e15161e3b12af0fa5b2c3bde8295bb5d7 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Tue, 30 Jun 2026 17:42:21 -0400 Subject: fix(calendar-sync): skip overlapping syncs for the same calendar A timer tick that fired while a calendar's previous fetch was still running launched a second concurrent sync for that calendar, wasting work and racing to write the same org file. The dispatcher now skips a calendar whose status is already syncing and logs the skipped tick. The sentinel resets the status on process exit, so the skip clears on its own. load-state also clears a stale syncing status left by a crash, so a calendar can't be skipped forever. --- tests/test-calendar-sync--syncing-p.el | 84 ++++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 tests/test-calendar-sync--syncing-p.el (limited to 'tests') diff --git a/tests/test-calendar-sync--syncing-p.el b/tests/test-calendar-sync--syncing-p.el new file mode 100644 index 000000000..b346bf776 --- /dev/null +++ b/tests/test-calendar-sync--syncing-p.el @@ -0,0 +1,84 @@ +;;; test-calendar-sync--syncing-p.el --- Tests for the in-flight sync guard -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for `calendar-sync--syncing-p' (the per-calendar in-flight check +;; that lets the dispatcher skip an overlapping timer tick) and for the +;; load-state sanitize that clears a stale `syncing' status in a fresh process. + +;;; Code: + +(require 'ert) +(require 'calendar-sync) + +(defun test-cs-syncing--reset () + "Clear the module's per-calendar state hash." + (clrhash calendar-sync--calendar-states)) + +;;; calendar-sync--syncing-p + +(ert-deftest test-calendar-sync--syncing-p-normal-true-when-syncing () + "Normal: a calendar whose status is `syncing' reads as in-flight." + (test-cs-syncing--reset) + (calendar-sync--set-calendar-state "google" '(:status syncing)) + (should (calendar-sync--syncing-p "google"))) + +(ert-deftest test-calendar-sync--syncing-p-boundary-nil-when-no-state () + "Boundary: a calendar with no recorded state is not in-flight." + (test-cs-syncing--reset) + (should-not (calendar-sync--syncing-p "never-seen"))) + +(ert-deftest test-calendar-sync--syncing-p-error-nil-for-terminal-status () + "Error: a terminal status (ok / error) is not in-flight." + (test-cs-syncing--reset) + (calendar-sync--set-calendar-state "google" '(:status ok)) + (should-not (calendar-sync--syncing-p "google")) + (calendar-sync--set-calendar-state "proton" '(:status error)) + (should-not (calendar-sync--syncing-p "proton"))) + +;;; Dispatcher guard: an in-flight calendar skips both leaf syncers + +(ert-deftest test-calendar-sync--sync-calendar-skips-when-in-flight () + "Normal: `calendar-sync--sync-calendar' does not launch a second sync for a +calendar already marked syncing, so an overlapping timer tick is a no-op." + (test-cs-syncing--reset) + (let ((api-calls '()) (ics-calls '())) + (cl-letf (((symbol-function 'calendar-sync--sync-calendar-api) + (lambda (cal) (push cal api-calls))) + ((symbol-function 'calendar-sync--sync-calendar-ics) + (lambda (cal) (push cal ics-calls)))) + (calendar-sync--set-calendar-state "proton" '(:status syncing)) + (calendar-sync--sync-calendar '(:name "proton" :url "https://x/y.ics" + :file "/tmp/c.org")) + (should (null api-calls)) + (should (null ics-calls))))) + +(ert-deftest test-calendar-sync--sync-calendar-dispatches-when-idle () + "Boundary: an idle calendar (no in-flight status) still dispatches normally." + (test-cs-syncing--reset) + (let ((ics-calls '())) + (cl-letf (((symbol-function 'calendar-sync--sync-calendar-ics) + (lambda (cal) (push cal ics-calls)))) + (calendar-sync--sync-calendar '(:name "proton" :url "https://x/y.ics" + :file "/tmp/c.org")) + (should (= 1 (length ics-calls)))))) + +;;; load-state sanitize: a persisted `syncing' status is cleared on load + +(ert-deftest test-calendar-sync--load-state-clears-stale-syncing () + "Error: a `syncing' status persisted before a crash is reset on load, so the +in-flight guard cannot skip that calendar forever in the new session." + (test-cs-syncing--reset) + (let* ((dir (make-temp-file "cs-state-" t)) + (calendar-sync--state-file (expand-file-name "state.el" dir))) + (unwind-protect + (progn + (with-temp-file calendar-sync--state-file + (prin1 '((timezone-offset . nil) + (calendar-states . (("google" . (:status syncing))))) + (current-buffer))) + (calendar-sync--load-state) + (should-not (calendar-sync--syncing-p "google"))) + (delete-directory dir t)))) + +(provide 'test-calendar-sync--syncing-p) +;;; test-calendar-sync--syncing-p.el ends here -- cgit v1.2.3