Emacs Nested Projects Redux

Emacs performance problems in python-mode when using project-rootfile and TRAMP. Updated on May 24, 2025 to wrap up the saga.

Back in March I wrote about using project-rootfile to handle nested projects in Eglot. At the time I said:

Hopefully there are no downsides to this setup with any of my existing projects.

It's been 2 months, but unfortunately I have now stumbled on an existing project that behaves very poorly with this setup. I use TRAMP to access the Linux kernel project on a remote Fedora host and happily use Eglot over TRAMP with the clangd language server. There is a python project called YNL that lives in the kernel git repository and it is this project that is giving me grief.

I have been contributing to the YNL project for a couple of years and Emacs has behaved faultlessly all that time. This week I came back to look at the python code and was immediately derailed by multi-second freeze-ups while trying to work with python files.

There seems to be a really bad interaction between project-rootfile, python-mode and TRAMP.

  • Editing the same files from a local copy of the project is fine
  • Only python files over TRAMP cause the issue
  • Commenting out the project-rootfile config resolves the issue
; (add-to-list 'project-find-functions #'project-rootfile-try-detect)

I used the Emacs built-in profiler to try and identify the root cause:

        9810  84%  - #<byte-code-function 975>
        9810  84%   - eldoc-print-current-symbol-info
        9810  84%    - eldoc--invoke-strategy
        9810  84%     - eldoc-documentation-default
        9810  84%      - run-hook-wrapped
        9810  84%       - #<byte-code-function B8B>
        9810  84%        - python-eldoc-function
        9810  84%         - python-eldoc--get-doc-at-point
        9810  84%          - python-shell-get-process
        9810  84%           - python-shell-get-buffer
        9810  84%            - seq-some
        9810  84%             - seq-do
        9810  84%              - mapc
        9810  84%               - #<byte-code-function 4C4>
        9810  84%                - #<byte-code-function 8BB>
        9810  84%                 - python-shell-get-process-name
        9808  84%                  - project-current
        9808  84%                   - project--find-in-directory
        9808  84%                    - run-hook-with-args-until-success
        9808  84%                     - project-rootfile-try-detect
        9808  84%                      - locate-dominating-file
        9629  82%                       - #<byte-code-function 0A5>
        9629  82%                        - project-rootfile--root-p
        9062  77%                         - seq-some
        9062  77%                          - seq-do
        9062  77%                           - mapc
        9062  77%                            - #<byte-code-function 5A1>
        9062  77%                             - #<byte-code-function 5BA>
        9043  77%                              - file-exists-p
        9043  77%                               - tramp-file-name-handler
        9039  77%                                - apply
        9038  77%                                 - tramp-sh-file-name-handler
        9037  77%                                  - apply
        9032  77%                                   - tramp-sh-handle-file-exists-p
        9005  77%                                    - tramp-send-command-and-check
        9004  77%                                     - tramp-send-command
        9004  77%                                      - apply
        9004  77%                                       - #<byte-code-function F39>
        8777  75%                                        + tramp-wait-for-output
         227   1%                                        + tramp-send-string
           1   0%                                       string-match
          19   0%                                    + expand-file-name

Firstly, this confirms that it is the combination of python-mode, project-rootfile and TRAMP that is causing Emacs to freeze. Secondly, it shows that the problem is triggered by eldoc-mode when its idle timer fires. Right enough, when I turn off eldoc-mode the problem goes away. I don't even need the mode enabled, it's just on by default in python-mode.

The fix for now is to turn off eldoc-mode when in python-mode.

(add-hook 'python-mode-hook
          (lambda ()
            (eldoc-mode -1)))

Sanity preserved!

Redux, redux

Yeah? Nope. It turns out that completion is also borked so I actually spent some time debugging the issue. First question: why does this work for (project-try-vc), the default implementation for project-find-functions. It's clear that (project-current) isn't caching anything and it gets called an awful lot from python.el.

Digging into (project-try-vc), I discover here's where the magic happens:

(defun project-try-vc (dir)
  ;; FIXME: Learn to invalidate when the value of
  ;; `project-vc-merge-submodules' or `project-vc-extra-root-markers'
  ;; changes.
  (or (vc-file-getprop dir 'project-vc)
      (let* ((backend-markers
              ;;
              ;; ...
              ;;
          (vc-file-setprop dir 'project-vc project)
          project))))

I'm about to add the same caching behaviour to project-rootfile.el, when I notice project-vc-extra-root-markers is an existing extension mechanism for (project-try-vc).

(setq project-vc-extra-root-markers '("Cargo.toml" "pyproject.toml" "requirements.txt" "go.mod"))

After that interesting excursion, I've reverted the project-rootfile experiment and configured the project native behaviour that I need.

Happy days.

The project-vc-extra-root-markers feature was added about 2½ years ago, first appearing in Emacs 29. That's a good reminder that it is worth reading through the NEWS for each new release.

emacs  eglot