aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-05-25 22:10:18 -0500
committerCraig Jennings <c@cjennings.net>2026-05-25 22:10:18 -0500
commitc9ff295f02c355f889d1605853c148bbce404d52 (patch)
treea1702eb32d958f6e53efe9dbe58c0e678c7e22f9
parent33c5da1bc33b59f2969f176f434ca66050deab23 (diff)
downloadpearl-c9ff295f02c355f889d1605853c148bbce404d52.tar.gz
pearl-c9ff295f02c355f889d1605853c148bbce404d52.zip
fix(save): extend the killed-buffer guard to the text-field savers
The atomic savers got this guard last commit, and the free-text savers share the same async exposure. pearl--save-field-push advances the provenance hash and runs the :after-push thunk through the marker after the push returns, and the description saver's outcome callback resyncs the Org hash the same way -- both signal if the buffer was killed in between. Both now check pearl--marker-live-p and skip the buffer touch while still reporting the remote outcome. I moved pearl--marker-live-p above the first caller so it's defined before all three use sites (no forward reference).
-rw-r--r--pearl.el34
-rw-r--r--tests/test-pearl-save.el26
2 files changed, 46 insertions, 14 deletions
diff --git a/pearl.el b/pearl.el
index e78123b..f148992 100644
--- a/pearl.el
+++ b/pearl.el
@@ -3246,6 +3246,15 @@ is nil or a symbol detailing a non-success outcome (`fetch-failed',
:label (plist-get spec :label)
:message (pearl--save-outcome-detail (plist-get spec :label) status reason)))
+(defun pearl--marker-live-p (marker)
+ "Non-nil if MARKER is nil, or points into a still-live buffer.
+A nil MARKER (synthetic callers with no buffer) passes; a marker into a killed
+buffer fails. Guards async commit callbacks that touch the buffer after a push
+the buffer may not have survived."
+ (or (null marker)
+ (let ((buffer (marker-buffer marker)))
+ (and buffer (buffer-live-p buffer)))))
+
(defun pearl--save-field-push (spec text callback)
"Push TEXT for SPEC, advancing the provenance hash on success.
CALLBACK gets a `pushed' outcome after any :after-push bookkeeping runs, or a
@@ -3255,10 +3264,14 @@ CALLBACK gets a `pushed' outcome after any :after-push bookkeeping runs, or a
(lambda (result)
(if (plist-get result :success)
(progn
- (org-entry-put marker (plist-get spec :prop)
- (secure-hash 'sha256 text))
- (when (plist-get spec :after-push)
- (funcall (plist-get spec :after-push) result marker))
+ ;; The push can land after the buffer was killed; only run
+ ;; the buffer-touching bookkeeping when it's still live, but
+ ;; still report the remote success.
+ (when (pearl--marker-live-p marker)
+ (org-entry-put marker (plist-get spec :prop)
+ (secure-hash 'sha256 text))
+ (when (plist-get spec :after-push)
+ (funcall (plist-get spec :after-push) result marker)))
(funcall callback (pearl--save-outcome spec 'pushed nil)))
(funcall callback (pearl--save-outcome spec 'failed 'push-failed)))))))
@@ -3330,7 +3343,9 @@ used by the local dirty scan), so the next scan sees the ticket as clean."
(lambda (outcome)
;; A push or use-remote leaves the buffer body current; resync the Org
;; hash from it so the lossless local scan agrees with the markdown one.
- (when (memq (plist-get outcome :status) '(pushed resolved-remote))
+ ;; Skip when the buffer was killed before this async callback fired.
+ (when (and (memq (plist-get outcome :status) '(pushed resolved-remote))
+ (pearl--marker-live-p marker))
(org-with-point-at marker
(org-entry-put marker "LINEAR-DESC-ORG-SHA256"
(secure-hash 'sha256 (pearl--entry-body-at-point)))))
@@ -3447,15 +3462,6 @@ Surface BUFFER when any field pushed. The shared close-out for
(message "%s: %s" label (pearl--save-summary-message outcomes))
(when pushed (pearl--surface-buffer buffer))))
-(defun pearl--marker-live-p (marker)
- "Non-nil if MARKER is nil, or points into a still-live buffer.
-A nil MARKER (synthetic callers with no buffer) passes; a marker into a killed
-buffer fails. Guards async commit callbacks that touch the buffer after a push
-the buffer may not have survived."
- (or (null marker)
- (let ((buffer (marker-buffer marker)))
- (and buffer (buffer-live-p buffer)))))
-
(defun pearl--run-atomic-field-save (spec callback)
"Save one atomic (enum/relation) field per SPEC, calling CALLBACK once.
SPEC keys: :issue-id :identifier :field :marker :label; :live and :baseline
diff --git a/tests/test-pearl-save.el b/tests/test-pearl-save.el
index 8050e6d..1f4f273 100644
--- a/tests/test-pearl-save.el
+++ b/tests/test-pearl-save.el
@@ -288,6 +288,32 @@ the conflict choice (stubbed). Returns (:outcome :fetched :pushed :applied
(should (equal '("remote changed") (plist-get r :applied)))
(should (string= (secure-hash 'sha256 "remote changed") (plist-get r :hash)))))
+(ert-deftest test-pearl-save-field-push-killed-buffer-no-signal ()
+ "A text-field push whose buffer is killed before the success callback does not signal.
+The hash-advance and :after-push touch the marker; on a dead buffer they must be
+skipped, with the remote push still reported as pushed."
+ (let ((buf (generate-new-buffer "pearl-field-push-kill")) outcome after-ran)
+ (unwind-protect
+ (let (marker)
+ (with-current-buffer buf
+ (insert "* h\n:PROPERTIES:\n:LINEAR-ID: a\n:END:\n")
+ (org-mode)
+ (goto-char (point-min))
+ (setq marker (point-marker)))
+ (pearl--save-field-push
+ (list :issue-id "u" :identifier "ENG-1" :field 'title :marker marker
+ :prop "LINEAR-TITLE-SHA256" :label "ENG-1 title"
+ ;; the push kills the buffer, then reports success
+ :push (lambda (_text cb) (kill-buffer buf) (funcall cb '(:success t)))
+ ;; touching the dead marker would signal without the guard
+ :after-push (lambda (_r m) (setq after-ran t) (org-entry-put m "X" "y")))
+ "new text"
+ (lambda (o) (setq outcome o)))
+ (should-not (buffer-live-p buf))
+ (should (eq 'pushed (plist-get outcome :status)))
+ (should-not after-ran))
+ (when (buffer-live-p buf) (kill-buffer buf)))))
+
;;; --run-atomic-field-save (the per-field engine for enum/relation fields)
(cl-defun test-pearl-save--atomic (&key live baseline remote (push-success t) resolution)