Skip to content

Fix async diff buffer race causing wrong/missing highlights and errors - #281

Closed
LemonBreezes wants to merge 1 commit into
dgutov:masterfrom
LemonBreezes:fix-async-diff-buffer-race
Closed

Fix async diff buffer race causing wrong/missing highlights and errors#281
LemonBreezes wants to merge 1 commit into
dgutov:masterfrom
LemonBreezes:fix-async-diff-buffer-race

Conversation

@LemonBreezes

@LemonBreezes LemonBreezes commented May 31, 2026

Copy link
Copy Markdown

I've been getting recurring errors with diff-hl for years and I'm hoping this fixes it:

Debugger entered--Lisp error: (error "No buffer named  *diff-hl-reference* ")
  diff-hl--resolve(" *diff-hl-reference* " #f(compiled-function (ref-changes) #<bytecode -0x15e845b644d57d3>))
  #f(compiled-function (changes) #<bytecode 0xa558579df63854e>)(((19 1 1 change) (37 14 0 insert)))
  #f(compiled-function () #<bytecode 0x13c70aa882a85abb>)()
  #f(compiled-function () #<bytecode -0x1cd22bda0b376558>)()
  #f(compiled-function (proc msg) #<bytecode -0x17dde961f272a0e7>)(#<process Diff> "exited abnormally with code 1\n")
  apply(#f(compiled-function (proc msg) #<bytecode -0x17dde961f272a0e7>) (#<process Diff> "exited abnormally with code 1\n"))
  #f(advice #f(compiled-function (proc msg) #<bytecode -0x17dde961f272a0e7>) :after #f(compiled-function (proc msg) #<bytecode 0x81d02bf837bf743>))(#<process Diff> "exited abnormally with code 1\n")
  #<subr completing-read-default>("[magit-gptcommit] Find file: " #f(compiled-function (string pred action) #<bytecode -0x8be503018da454c>) nil nil nil nil nil nil)
  apply((#<subr completing-read-default> "[magit-gptcommit] Find file: " #f(compiled-function (string pred action) #<bytecode -0x8be503018da454c>) nil nil nil nil nil nil))
  #f(compiled-function (&rest app) #<bytecode 0x1a4535676121e62d>)(#<subr completing-read-default> "[magit-gptcommit] Find file: " #f(compiled-function (string pred action) #<bytecode -0x8be503018da454c>) nil nil nil nil nil nil)
  apply(#f(compiled-function (&rest app) #<bytecode 0x1a4535676121e62d>) (#<subr completing-read-default> "[magit-gptcommit] Find file: " #f(compiled-function (string pred action) #<bytecode -0x8be503018da454c>) nil nil nil nil nil nil))
  vertico--advice(#<subr completing-read-default> "[magit-gptcommit] Find file: " #f(compiled-function (string pred action) #<bytecode -0x8be503018da454c>) nil nil nil nil nil nil)
  apply(vertico--advice #<subr completing-read-default> ("[magit-gptcommit] Find file: " #f(compiled-function (string pred action) #<bytecode -0x8be503018da454c>) nil nil nil nil nil nil))
  completing-read-default("[magit-gptcommit] Find file: " #f(compiled-function (string pred action) #<bytecode -0x8be503018da454c>) nil nil nil nil nil nil)
  completing-read("[magit-gptcommit] Find file: " #f(compiled-function (string pred action) #<bytecode -0x8be503018da454c>) nil nil nil)
  projectile-completing-read("Find file: " ("LICENSE" "README.md" "magit-gptcommit.el") :caller projectile-read-file)
  projectile--find-file(nil)
  projectile-find-file(nil)
  funcall-interactively(projectile-find-file nil)
  call-interactively(projectile-find-file)
  (cond ((and projectile-project-root (file-equal-p projectile-project-root default-directory)) (if (doom-project-p default-directory) nil (setq projectile-enable-caching nil)) (call-interactively (if (doom-module-active-p :completion 'ivy) #'counsel-projectile-find-file #'projectile-find-file))) ((and (and (boundp 'ivy-mode) ivy-mode) (fboundp 'counsel-file-jump)) (call-interactively #'counsel-file-jump)) ((and (and (boundp 'helm-mode) helm-mode) (fboundp 'helm-find-files)) (call-interactively #'helm-find-files)) ((let* ((project-current-directory-override (and t dir)) (pr (and project-current-directory-override (project-current t dir)))) (if pr (condition-case _ (project-find-file-in nil (list dir) pr t) (wrong-type-argument (call-interactively #'find-file)))))) ((call-interactively #'find-file)))
  (let* ((default-directory (file-truename dir)) (projectile-project-root (doom-project-root dir)) (projectile-enable-caching projectile-enable-caching)) (cond ((and projectile-project-root (file-equal-p projectile-project-root default-directory)) (if (doom-project-p default-directory) nil (setq projectile-enable-caching nil)) (call-interactively (if (doom-module-active-p :completion 'ivy) #'counsel-projectile-find-file #'projectile-find-file))) ((and (and (boundp 'ivy-mode) ivy-mode) (fboundp 'counsel-file-jump)) (call-interactively #'counsel-file-jump)) ((and (and (boundp 'helm-mode) helm-mode) (fboundp 'helm-find-files)) (call-interactively #'helm-find-files)) ((let* ((project-current-directory-override (and t dir)) (pr (and project-current-directory-override (project-current t dir)))) (if pr (condition-case _ (project-find-file-in nil (list dir) pr t) (wrong-type-argument (call-interactively ...)))))) ((call-interactively #'find-file))))
  doom-project-find-file("/home/st/src/magit-gptcommit/")
  funcall(doom-project-find-file "/home/st/src/magit-gptcommit/")
  (if current-prefix-arg nil (funcall +workspaces-switch-project-function proot))
  (let* ((ws-param '+workspace-project) (ws (+workspace-get pname t)) (ws (if (and ws (condition-case nil (progn (file-equal-p ... proot)) (error nil))) ws (let* ((parts (nreverse ...)) (pre (cdr parts)) (post (list ...))) (while (and pre (setq ws ...) (not ...)) (setq post (cons ... post))) (if pre nil ws)))) (ws (or ws (+workspace-get pname t) (+workspace-new pname)))) (set-persp-parameter ws-param proot ws) (+workspace-switch pname) (save-current-buffer (set-buffer (doom-fallback-buffer)) (setq default-directory (prog1 proot (make-local-variable 'default-directory))) (hack-dir-local-variables-non-file-buffer)) (if current-prefix-arg nil (funcall +workspaces-switch-project-function proot)) (+workspace-message (format "Switched to '%s' in new workspace" pname) 'success))
  (if (and (not (null +workspaces-on-switch-project-behavior)) (or (eq +workspaces-on-switch-project-behavior t) (+workspace--protected-p (safe-persp-name (get-current-persp))) (+workspace-buffer-list))) (let* ((ws-param '+workspace-project) (ws (+workspace-get pname t)) (ws (if (and ws (condition-case nil (progn ...) (error nil))) ws (let* ((parts ...) (pre ...) (post ...)) (while (and pre ... ...) (setq post ...)) (if pre nil ws)))) (ws (or ws (+workspace-get pname t) (+workspace-new pname)))) (set-persp-parameter ws-param proot ws) (+workspace-switch pname) (save-current-buffer (set-buffer (doom-fallback-buffer)) (setq default-directory (prog1 proot (make-local-variable 'default-directory))) (hack-dir-local-variables-non-file-buffer)) (if current-prefix-arg nil (funcall +workspaces-switch-project-function proot)) (+workspace-message (format "Switched to '%s' in new workspace" pname) 'success)) (save-current-buffer (set-buffer (doom-fallback-buffer)) (setq default-directory (prog1 proot (make-local-variable 'default-directory))) (hack-dir-local-variables-non-file-buffer) (message "Switched to '%s'" pname)) (condition-case err (+workspace-rename (+workspace-current-name) pname) ((debug error) (message "Workspace error: %s" err) nil)) (if current-prefix-arg nil (funcall +workspaces-switch-project-function proot)))
  (progn (if (and (not (null +workspaces-on-switch-project-behavior)) (or (eq +workspaces-on-switch-project-behavior t) (+workspace--protected-p (safe-persp-name (get-current-persp))) (+workspace-buffer-list))) (let* ((ws-param '+workspace-project) (ws (+workspace-get pname t)) (ws (if (and ws (condition-case nil ... ...)) ws (let* (... ... ...) (while ... ...) (if pre nil ws)))) (ws (or ws (+workspace-get pname t) (+workspace-new pname)))) (set-persp-parameter ws-param proot ws) (+workspace-switch pname) (save-current-buffer (set-buffer (doom-fallback-buffer)) (setq default-directory (prog1 proot (make-local-variable 'default-directory))) (hack-dir-local-variables-non-file-buffer)) (if current-prefix-arg nil (funcall +workspaces-switch-project-function proot)) (+workspace-message (format "Switched to '%s' in new workspace" pname) 'success)) (save-current-buffer (set-buffer (doom-fallback-buffer)) (setq default-directory (prog1 proot (make-local-variable 'default-directory))) (hack-dir-local-variables-non-file-buffer) (message "Switched to '%s'" pname)) (condition-case err (+workspace-rename (+workspace-current-name) pname) ((debug error) (message "Workspace error: %s" err) nil)) (if current-prefix-arg nil (funcall +workspaces-switch-project-function proot))))
  (if persp-mode (progn (if (and (not (null +workspaces-on-switch-project-behavior)) (or (eq +workspaces-on-switch-project-behavior t) (+workspace--protected-p (safe-persp-name (get-current-persp))) (+workspace-buffer-list))) (let* ((ws-param '+workspace-project) (ws (+workspace-get pname t)) (ws (if (and ws ...) ws (let* ... ... ...))) (ws (or ws (+workspace-get pname t) (+workspace-new pname)))) (set-persp-parameter ws-param proot ws) (+workspace-switch pname) (save-current-buffer (set-buffer (doom-fallback-buffer)) (setq default-directory (prog1 proot (make-local-variable ...))) (hack-dir-local-variables-non-file-buffer)) (if current-prefix-arg nil (funcall +workspaces-switch-project-function proot)) (+workspace-message (format "Switched to '%s' in new workspace" pname) 'success)) (save-current-buffer (set-buffer (doom-fallback-buffer)) (setq default-directory (prog1 proot (make-local-variable 'default-directory))) (hack-dir-local-variables-non-file-buffer) (message "Switched to '%s'" pname)) (condition-case err (+workspace-rename (+workspace-current-name) pname) ((debug error) (message "Workspace error: %s" err) nil)) (if current-prefix-arg nil (funcall +workspaces-switch-project-function proot)))))
  (let* ((default-directory (or dir default-directory)) (pname (doom-project-name)) (proot (file-truename default-directory)) projectile-project-root) (if persp-mode (progn (if (and (not (null +workspaces-on-switch-project-behavior)) (or (eq +workspaces-on-switch-project-behavior t) (+workspace--protected-p (safe-persp-name ...)) (+workspace-buffer-list))) (let* ((ws-param '+workspace-project) (ws (+workspace-get pname t)) (ws (if ... ws ...)) (ws (or ws ... ...))) (set-persp-parameter ws-param proot ws) (+workspace-switch pname) (save-current-buffer (set-buffer (doom-fallback-buffer)) (setq default-directory (prog1 proot ...)) (hack-dir-local-variables-non-file-buffer)) (if current-prefix-arg nil (funcall +workspaces-switch-project-function proot)) (+workspace-message (format "Switched to '%s' in new workspace" pname) 'success)) (save-current-buffer (set-buffer (doom-fallback-buffer)) (setq default-directory (prog1 proot (make-local-variable ...))) (hack-dir-local-variables-non-file-buffer) (message "Switched to '%s'" pname)) (condition-case err (+workspace-rename (+workspace-current-name) pname) ((debug error) (message "Workspace error: %s" err) nil)) (if current-prefix-arg nil (funcall +workspaces-switch-project-function proot))))))
  +workspaces-switch-to-project-h()
  projectile-switch-project-by-name("~/src/magit-gptcommit/" nil)
  #f(compiled-function (project) #<bytecode 0x4c89b65f2a01916>)("~/src/magit-gptcommit/")
  projectile-completing-read("Switch to project: " ("~/src/magit-gptcommit/" "~/.config/emacs/" "~/src/ghostel/" "~/src/embark/" "~/.config/doom/secrets/" "~/src/doomemacs/" "~/" "/etc/portage/" "~/.emacs.d/" "~/.config/temple-emacs/" "~/src/temple-os-emacs-theme/" "~/org/" "~/src/hasktorch-first/" "~/src/cyber-angel-emacs-elisp-trash/" "~/src/evil-easymotion/" "~/src/lsp-treemacs/" "~/src/emacs/" "~/src/oasis-poker-helper/" "~/src/topos/" "~/src/roulette-calculator/" "~/src/portage/" "~/src/kitty-graphics.el/" "~/src/dotfiles/" "~/src/casino-tracker-service/" "~/src/casino-tracker/" "~/src/buddy/" "~/src/alert/") :action #f(compiled-function (project) #<bytecode 0x4c89b65f2a01916>) :caller projectile-read-project)
  projectile-switch-project(nil)
  funcall-interactively(projectile-switch-project nil)
  command-execute(projectile-switch-project)

When `diff-hl-update-async' is enabled, diffs run as background processes
and finish in a sentinel.  All updates shared the global buffer names
" *diff-hl* " and " *diff-hl-reference* ", so several updates in flight at
once (e.g. a project/workspace switch refreshing many buffers, or the
flydiff idle timer) would clobber or empty each other's diff output.
Worse, if such a shared buffer was killed before its continuation ran,
`diff-hl--resolve' dereferenced it by name unguarded on Emacs 31's
`vc-run-delayed-success' path, signaling

    error "No buffer named  *diff-hl-reference* "

Give every update its own freshly generated diff buffers, and have the
resolvers kill them once parsed (including on the process-killed path, so
a sibling buffer can't leak).  Also guard the buffer access in both
`diff-hl--resolve' branches so a buffer killed out from under us degrades
to "no highlights this cycle" instead of erroring.

The async test polled the old fixed buffer names to wait for completion;
update it to wait on any live diff-hl process.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@dgutov

dgutov commented May 31, 2026

Copy link
Copy Markdown
Owner

Hi! If you don't have a specific reproduction scenario, you might want to ask your coding agent to generate one just based on the brief description and the error text from the messages buffer - they can be handy at that.

@LemonBreezes
LemonBreezes marked this pull request as ready for review May 31, 2026 22:56
@LemonBreezes

Copy link
Copy Markdown
Author
;; Self-contained reproduction for the bug
;;
;;     error "No buffer named  *diff-hl-reference* "
;;
;; signalled from a diff-hl async update finishing in a process sentinel.
;;
;; It bootstraps straight.el into a throwaway tree, installs diff-hl from a
;; chosen repo/branch, opens a Git-tracked file with both staged and unstaged
;; changes, kicks off an *asynchronous* diff-hl update, and lets a concurrent
;; buffer kill (what a project/workspace switch does to the shared, fixed-name
;; diff buffers) race with the update's still-pending continuation.
;;
;; HOW TO RUN
;;
;;   Upstream master (reproduces the bug):
;;     emacs -Q --batch -l repro-async-race.el
;;
;;   The proposed fix (no error):
;;     DIFFHL_REPO=LemonBreezes/diff-hl \
;;     DIFFHL_BRANCH=fix-async-diff-buffer-race \
;;       emacs -Q --batch -l repro-async-race.el
;;
;; Each (repo,branch) gets its own isolated straight tree under
;; /tmp/diff-hl-repro/, so this never touches your real Emacs config.  Exit
;; code is 1 when the bug reproduces, 0 when it does not.

;;; --- Pick which diff-hl to test -------------------------------------------

(defvar repro-repo   (or (getenv "DIFFHL_REPO")   "dgutov/diff-hl"))
(defvar repro-branch (or (getenv "DIFFHL_BRANCH") "master"))

;; Isolate everything (straight tree, repos, build) per variant.
(setq user-emacs-directory
      (file-name-as-directory
       (expand-file-name
        (format "%s--%s"
                (replace-regexp-in-string "/" "_" repro-repo)
                repro-branch)
        "/tmp/diff-hl-repro/")))
(make-directory user-emacs-directory t)
(setq straight-base-dir user-emacs-directory)

(message "\n==== diff-hl async race repro ====")
(message "Emacs   : %s" emacs-version)
(message "diff-hl : %s @ %s" repro-repo repro-branch)
(message "tree    : %s\n" user-emacs-directory)

;;; --- Bootstrap straight (verbatim from the report) ------------------------

(defvar bootstrap-version)

(let ((bootstrap-file
       (expand-file-name "straight/repos/straight.el/bootstrap.el" user-emacs-directory))
      (bootstrap-version 5))
  (unless (file-exists-p bootstrap-file)
    (with-current-buffer
        (url-retrieve-synchronously "https://raw.githubusercontent.com/raxod502/straight.el/develop/install.el"
                                    'silent 'inhibit-cookies)
      (goto-char (point-max))
      (eval-print-last-sexp)))
  (load bootstrap-file nil 'nomessage))

;; Install diff-hl from the chosen repo/branch instead of the default recipe.
(straight-use-package
 `(diff-hl :type git :host github :repo ,repro-repo :branch ,repro-branch))

(require 'diff-hl)

;; Report exactly which commit got built, so the comparison is unambiguous.
(let ((default-directory
       (expand-file-name "straight/repos/diff-hl/" user-emacs-directory)))
  (message "diff-hl commit: %s"
           (string-trim
            (with-output-to-string
              (with-current-buffer standard-output
                (call-process "git" nil t nil "rev-parse" "--short" "HEAD"))))))

;;; --- Record any error that escapes the async update ----------------------

(defvar repro-errors nil)

;; diff-hl--resolve is where the doomed `(with-current-buffer
;; " *diff-hl-reference* " ...)` lives on the Emacs 31+ `vc-run-delayed-success'
;; path.  It runs inside a process sentinel.  In a live session the error
;; escapes as a top-level "error in process sentinel: No buffer named
;; " *diff-hl-reference* "" (exactly the reported backtrace); here we catch it
;; so the script can print a clean verdict and exit code instead of aborting.
(advice-add 'diff-hl--resolve :around
            (lambda (orig &rest args)
              (condition-case err
                  (apply orig args)
                (error
                 (push err repro-errors)
                 (message "error in process sentinel: %s" (error-message-string err))
                 nil))))

;;; --- Build a tiny Git repo with staged + unstaged changes ----------------

(defun repro-git (dir &rest args)
  (let ((default-directory dir))
    (apply #'process-file "git" nil nil nil args)))

(let* ((repo (file-name-as-directory (make-temp-file "diff-hl-repro-repo" t)))
       (file (expand-file-name "foo.txt" repo)))
  (repro-git repo "init" "-q")
  (repro-git repo "config" "user.email" "repro@example.com")
  (repro-git repo "config" "user.name" "Repro")
  ;; Committed baseline.
  (with-temp-file file
    (dotimes (i 60) (insert (format "line %d\n" i))))
  (repro-git repo "add" "foo.txt")
  (repro-git repo "commit" "-qm" "base")
  ;; A staged edit ...
  (with-temp-file file
    (dotimes (i 60) (insert (format "line %d\n" i)))
    (goto-char (point-min)) (forward-line 5)
    (insert "STAGED edit\n"))
  (repro-git repo "add" "foo.txt")
  ;; ... and an unstaged edit on top.  With `diff-hl-show-staged-changes' nil
  ;; this makes diff-hl compute BOTH a working diff (" *diff-hl* ") and a
  ;; reference diff (" *diff-hl-reference* ").
  (with-temp-file file
    (insert (with-temp-buffer (insert-file-contents file) (buffer-string)))
    (goto-char (point-min)) (forward-line 30)
    (insert "UNSTAGED edit\n"))

  (find-file file)
  (setq-local diff-hl-update-async t)        ; the async path
  (setq-local diff-hl-show-staged-changes nil) ; force the reference diff
  (diff-hl-mode 1)

  ;; Kick off an async update: this spawns the background "diff-files" and
  ;; "diff-index" git processes that write into the shared, fixed-name buffers.
  (diff-hl-update)

  ;; Now race a concurrent buffer kill against the still-pending update -- this
  ;; is what a project/workspace switch (or any churn that refreshes many
  ;; buffers at once) does to the *globally shared* " *diff-hl-reference* "
  ;; buffer while another buffer's update is mid-flight.
  (when-let* ((rb (get-buffer " *diff-hl-reference* ")))
    (kill-buffer rb))

  ;; Drain the background processes so the working diff's continuation runs.
  ;; On upstream it then does `(with-current-buffer " *diff-hl-reference* " ...)'
  ;; on the now-dead buffer and signals the reported error.
  (dotimes (_ 250)
    (accept-process-output nil 0.02)))

;;; --- Verdict --------------------------------------------------------------

(message "\n==== RESULT ====")
(if repro-errors
    (progn
      (message "BUG REPRODUCED: %S" (car (last repro-errors)))
      (message "(%d error(s) escaped the async diff-hl update)" (length repro-errors))
      (kill-emacs 1))
  (message "No error escaped the async update -- this build is NOT affected.")
  (kill-emacs 0))

;;; repro-async-race.el ends here

@dgutov dgutov closed this in 902d082 Jun 1, 2026
@dgutov

dgutov commented Jun 1, 2026

Copy link
Copy Markdown
Owner

Thanks, this should resolve it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants