Tyblog

All the posts unfit for blogging
blog.tjll.net

« Asynchronous Emacs Direnv Devshells

  • 17 June, 2026
  • 881 words
  • 3 minute read time

emacs-counsel.jpg

Figure 1: The Meeting of San Carlo Borromeo and San Filippo Neri ; emacs logo licensed under GPLv3 from Wikimedia

On the bad side of town underneath the freeway overpass with a needle full of lisp is a wild-eyed man who likes to abuse emacs. That man is me.

My eccentric technology tastes take on a particularly esoteric flavor when I start setting up language servers. In short:

String all of that mental illness together and you get one annoying side-effect: if I want eglot-ensure hooks to find my sandboxed language servers, it should always wait for direnv to finish before running, otherwise my executables may not be there. Emacs being what it is, reacting to asynchronous events is unpaved territory!

Glimpsing the Sin

This bothered me for a long time until I decided to take action to fix it. There currently isn’t any “blessed” way to deal with this weird situation. Although the “run a function after entering a mode” use case is well-understood, the “run a function after <something> happens at a later time” has no officially sanctioned method.

So I had to dream up a big messy solution.

There is one signal we can key onto which helps identify when an asynchronous direnv export from the envrc package is done: it will call envrc--apply in all cases (whether there’s new environment variables to export or not):

elisp
(defun envrc--apply (buf result)
  "Update BUF with RESULT, which is a result of `envrc--export'.")

“Okay,” I thought to myself. We can abuse this.

Sloppy, But Not Slop

I wrote a global minor mode that looks like this:

elisp
(define-minor-mode tjl/direnv-defer-mode
  "Minor mode that will pause eglot until envrc has been sourced."
  :global t
  :init-value nil
  (if tjl/direnv-defer-mode
      (progn
        (advice-add 'eglot-ensure :around #'tjl/direnv-defer--eglot-ensure)
        (advice-add 'envrc--apply :after #'tjl/direnv-defer--eglot)
        (add-to-list 'global-mode-string '(:eval (tjl/direnv-defer-mode-info))))
    (advice-remove 'eglot-ensure #'tjl/direnv-defer--eglot-ensure)
    (advice-remove 'envrc--apply #'tjl/direnv-defer--eglot)
    (remove-hook 'global-mode-string '(:eval (tjl/direnv-defer-mode-info)))))

(defun tjl/direnv-defer-mode-info ()
  "Print a waiting icon if functions are waiting on envrc"
  (if (and (boundp 'tjl/direnv-defer--fn)
           tjl/direnv-defer--fn)
      (concat " [" (propertize "envrc ⌛ eglot" :face 'tjl/direnv-defer-waiting-face) "] ")
    ""))

The mode uses one of my favorite cursed elisp functions: advice-add (or add-function):

(add-function HOW PLACE FUNCTION &optional PROPS)

Add a piece of advice on the function stored at PLACE.
FUNCTION describes the code to add.  HOW describes how to add it.
HOW can be explained by showing the resulting new function, as the
result of combining FUNCTION and the previous value of PLACE, which we
call OLDFUN here:

The rest of the docstring explains ways to change the targeted function. My favorite resource to understand advice-add is this article that includes visuals to understand them.

We use two in that code snippet plus one bit of porcelain:

  1. :around on eglot-ensure to replace it with our own function, which is given the old function’s definition at the time our replacement function gets called.
  2. :after on envrc--apply to call our function after envrc--apply is called.
  3. A small mode-line widget that will emit a string when we’re waiting in envrc purgatory.

That’s enough to make our disaster work!

First: our shim that dethrones eglot-ensure with a different function. Recall that original-fn here will be the eglot-ensure function at call time:

elisp
(defvar-local tjl/direnv-defer--fn nil
  "Function to fire upon completion of envrc sourcing.")

(defun tjl/direnv-defer--eglot-ensure (original-fn)
  "If envrc is enabled, wait until it's loaded before trying eglot"
  (if (and (or (bound-and-true-p envrc-global-mode)
               (bound-and-true-p envrc-mode))
           (not (member envrc--status '(on error))))
      (let ((mode major-mode))
        ;; Helper function to save the callback for later
        (cl-labels ((setup
                     ()
                     (when (and (eq major-mode mode)
                                (not (eglot-managed-p))
                                (memq (intern (subr-name original-fn))
                                      (symbol-value (intern (concat (symbol-name major-mode) "-hook"))))
                                (not tjl/direnv-defer--fn))
                       (setq tjl/direnv-defer--fn original-fn))))
          (setup)
          ;; Aside from ourselves, also mark sibling project buffers as waiting
          (when-let ((project (project-current)))
            (dolist (buf (project-buffers project))
              (with-current-buffer buf (setup))))))
    ;; We don't care about waiting, just call eglot.
    (funcall original-fn)))

The comments are self-explanatory, but to torture you a little more, this will hold off calling elgot-ensure by detecting if we expect envrc-mode to run and, if so, saving the original function into a buffer-local variable that we’ll use later. As an extra goodie, we do this for all sibling buffers in the current project we expect to undergo the same treatment (same major mode, also see the original function in their list of hooks, also have envrc active.)

Recall that we advice’d the following function :after envrc--apply. It’ll use that direnv-defer--fn buffer-local variable we saved:

elisp
(defun tjl/direnv-defer--eglot (buf &optional _result)
  "Resume deferred startup for buffers matching the current envrc context."
  (with-current-buffer buf
    (when tjl/direnv-defer--fn
      (funcall tjl/direnv-defer--fn)
      (setq tjl/direnv-defer--fn nil))))

Note that the call site for envrc--apply is already in a loop iterating through all of the buffers that envrc-mode thinks should get updated so we don’t need to deal with that in our code: just invoke the deferred function if it was held. I suppose this could cause problems if you apply this strategy to functions that behave differently than eglot-ensure but we’re cowboys doing whatever the hell we want here, baby.

Seconds of Glory

What does our toil buy? Here’s a contrived example I recorded opening an example .nix file with an artificial five second delay introduced to the devshell. Watch the modeline on the lower right indicate that the devshell is loading via direnv along with an “eglot is pending” modeline segment before it finishes and fires eglot-ensure after:

Ooh la la, aren’t we fancy?

You Aren’t Getting These 3 Minutes of Reading Back

I hope you enjoyed this brief adventure. As you can tell from the fact that I overengineered this into a minor mode (and then overthought it into a blog post), maybe you could extend it to defer other functions until after direnv completes. I guess.

Don’t let anybody tell you what you can and can’t do with your computer and your geriatric lisp machine.