Skip to content

org-pdftools re-loads org-pdftools.el on every org-store-link when epdfinfo is not built #8829

Description

@jasonatwell

Describe your issue

Thank you for your hard work, I LOVE doom emacs. I used vim and nvim for a few years, had zero emacs experience, but it only took a few days to became fully productive in emacs thanks to Doom ❤️

Disclosure: opus 4.8 investigated and wrote this. But I can confirm that after adding this code block

(after! ob-tangle
  (defun my/org-babel-tangle-skip-store-link-a (orig &rest args)
    (cl-letf (((symbol-function 'org-store-link) #'ignore))
      (apply orig args)))
  (advice-add 'org-babel-tangle :around #'my/org-babel-tangle-skip-store-link-a))

my tangle time went from a few seconds to under 1 second in a file with 297 tangle blocks

Summary

When :tools pdf is enabled but the epdfinfo binary has never been built, Doom's lazy
org-pdftools link handler (+org--pdftools-link-handler in modules/lang/org/config.el) calls
(require 'org-pdftools nil t) on every org-store-link invocation. Because loading
org-pdftools.el aborts — via a load-time file-executable-p check on the missing epdfinfo,
raised inside its dependency pdf-annot.el — before it reaches (provide 'org-pdftools), the
feature is never registered and each require re-loads the whole file instead of being a cheap
no-op. Org's tangler calls org-store-link once per qualifying source block, so tangling a large
literate config performs hundreds of silent failed reloads. The same per-call cost applies to
org-capture, manual link storing (C-c l), and link export — tangle is simply the most visible
trigger because it multiplies the cost by one call per block.

Environment

  • OS: macOS (darwin), Apple Silicon
  • Emacs: GNU Emacs 30.2 (636f166cf)
  • Doom: v2.2.0 (ef473206, master, 2026-06-23); doom+ v26.06.0 (5a44cf7bd)
  • Modules: :lang org and :tools pdf enabled
  • Org mode: 9.8.4 (straight build at ~/.config/emacs/.local/straight/build-30.2/org/)
  • org-pdftools: pinned by Doom to 5613b7ae561e0af199f25aacc0a9c34c16638408
  • epdfinfo: not builtpdf-info-epdfinfo-program points at a non-executable path
  • State: (featurep 'org-pdftools) => nil

Profiler output

Root cause

The shim

Doom registers org-pdftools' link parameters through a wrapper that lazily requires the package on
each call. From modules/lang/org/config.el (use-package! org-pdftools), introduced in
doomemacs/core@bf8495b "fix(org): epdfinfo error when storing/exporting links" and still present
on master:

(defun +org--pdftools-link-handler (fn &rest args)
  "Produces a link handler for org-pdftools that suppresses missing-epdfinfo
errors whenever storing or exporting links."
  (lambda (&rest args)
    (and (ignore-errors (require 'org-pdftools nil t))
         (file-executable-p pdf-info-epdfinfo-program)
         (apply fn args))))

(org-link-set-parameters (or (bound-and-true-p org-pdftools-link-prefix) "pdf")
                         :follow   (+org--pdftools-link-handler #'org-pdftools-open)
                         :complete (+org--pdftools-link-handler #'org-pdftools-complete-link)
                         :store    (+org--pdftools-link-handler #'org-pdftools-store-link)
                         :export   (+org--pdftools-link-handler #'org-pdftools-export))

The :store closure is installed into Org's link-store machinery, so it is invoked by
org-link--try-link-store-functions on every org-store-link.

The require/reload mechanism

Standard require semantics: a file is loaded only when its feature is absent from features, and
the feature is added only when (provide 'FEATURE) runs. (provide 'org-pdftools) is the final
top-level form of org-pdftools.el. When epdfinfo is not built, the load aborts before reaching
it — and the error originates not in org-pdftools.el itself but in a dependency it requires
unconditionally: loading pdf-annot.el evaluates the defcustom pdf-annot-list-listed-types, whose
default value form calls (pdf-info-markup-annotations-p) at load time, which runs
pdf-info-check-epdfinfofile-executable-p → signals
"pdf-info-epdfinfo-program is not executable".

The error is swallowed by the shim's ignore-errors, org-pdftools never gets onto features,
(featurep 'org-pdftools) stays nil, and each subsequent (require 'org-pdftools nil t)
re-loads the entire file
(load-with-code-conversion). The shim's file-executable-p guard then
short-circuits, so (apply fn args) is never reached — but the full reload cost has already been
paid. The commit's intent was solely UX (suppress the loud error); it suppresses the error
correctly but does not prevent the repeated reload.

The tangle call chain (Org >= 9.8)

org-babel-tangle
 -> org-babel-tangle-collect-blocks
   -> org-babel-tangle-single-block             (once per qualifying src block)
     -> org-babel-tangle--unbracketed-link
       -> org-store-link                         (for blocks whose :comments is not "no")
         -> org-link--try-link-store-functions   (calls EVERY registered :store fn)
           -> +org--pdftools-link-handler closure
             -> (require 'org-pdftools nil t)
               -> load-with-code-conversion      (org-pdftools.el re-loaded, every call)

Org-version nuance

Both Org 9.7.11 (bundled with Emacs 30) and Org 9.8.4 keep the early-return
(unless (string= "no" (cdr (assq :comments params))) ...) in org-babel-tangle--unbracketed-link.
The relevant difference is that 9.7.x additionally wrapped the call in
(cl-letf (((symbol-function 'org-store-link-functions) (lambda () nil))) (org-store-link nil)),
which made tangling skip all third-party :store handlers. Org 9.8 removed that cl-letf binding
(commit f6a0f151b1a2, "ob-tangle: Fix regression after 95554543b9" — a deliberate
accuracy-over-performance change after id links began flowing through org-store-link-functions).
So on Org 9.7.x the shim is never reached during tangle; on Org 9.8.x it is reached once per block
whose :comments does not resolve to "no". (This is upstream behavior, not something Doom controls
— but it is what newly exposes the shim to the tangle hot path; the shim defect itself is
independent and affects every org-store-link regardless of tangling.)

Impact

From a representative CPU profile (3596 total samples, tangling a ~295-block literate config):

  • org-babel-tangle subtree: 2695 samples (~74.9%)
  • org-store-link: 2116 (~58.8%)
  • org-link--try-link-store-functions: 2086 (~58.0%)
  • the org-pdftools shim closure: 2085 (~58.0%)
  • the two dominant leaf frames are pure require+load work (proving a reload, not a cheap feature
    check): [require load-with-code-conversion require progn condition-case and <shim> ...] = 1046
    samples
    and [require progn condition-case and <shim> ...] = 989 samples — together
    ~2035 (~56.6%).
  • the smoking-gun frame for why the load aborts before provide:
    [file-executable-p pdf-info-check-epdfinfo pdf-info-process-assert-running pdf-info-query pdf-info-features pdf-info-markup-annotations-p ... custom-declare-variable byte-code require load-with-code-conversion ...].

Breadth. This is not machine-specific: the shim lives in shared Doom source and is enabled for
any user with :lang org + :tools pdf and no built epdfinfo (the common default, since
:tools pdf never builds it). The per-call cost is a full file reload, not single-digit ms, and
is environment-dependent (it can be dominated by eval-buffer / char-displayable-p font scanning).
The closely related prior report #3979 shows a single interactive org-capture taking ~20 s to
~2 min on a different machine — so single interactive actions can also be severely affected; tangle
just makes it unmissable.

Evidence

The :store handler, as printed from org-store-link-functions:

#[(&rest args)
  ((and (condition-case nil (progn (require 'org-pdftools nil t)) (error nil))
        (file-executable-p pdf-info-epdfinfo-program)
        (apply fn args)))
  ((fn . org-pdftools-store-link))]

This is exactly Doom's +org--pdftools-link-handler closure: ignore-errors macroexpands to
(condition-case nil (progn ...) (error nil)), and ((fn . org-pdftools-store-link)) is the
lexical capture of the fn argument.

Forcing the require surfaces the swallowed error:

(require 'org-pdftools)   => (error "pdf-info-epdfinfo-program is not executable")
(featurep 'org-pdftools)  => nil

Suggested fixes (options for the maintainer to weigh)

  1. Make the lazy require idempotent / don't retry a known failure. Check
    (file-executable-p pdf-info-epdfinfo-program) before attempting the require, and/or
    short-circuit when the feature is already loaded, e.g.
    (or (featurep 'org-pdftools) (and (file-executable-p pdf-info-epdfinfo-program) (ignore-errors (require 'org-pdftools nil t)))).
    Smallest change; preserves the error-suppression intent. Care needed so that building
    epdfinfo later in the same session is still picked up (re-check file-executable-p cheaply
    rather than caching a permanent negative).

  2. Only register the :store/:follow/:complete/:export handlers once epdfinfo is
    available.
    Defer org-link-set-parameters behind an executable check (e.g. after a successful
    pdf-tools-install) instead of at :init. Cleanest separation; pdf links simply aren't
    registered until the binary exists — acceptable, since they can't work without it anyway.

  3. Load org-pdftools eagerly (or provide a stub) when the module is on, so the feature is
    genuinely present and require becomes a real no-op. Removes the laziness benefit; only sensible
    if the package can load cleanly without epdfinfo.

  4. (Context, not a Doom fix.) The tangle exposure stems from Org 9.8 removing the cl-letf
    no-op binding of org-store-link-functions during tangle — a deliberate upstream
    accuracy-over-performance decision, so the actionable fix likely belongs in Doom/org-pdftools
    rather than asking Org to restore the guard.

Options 1 or 2 look like the lowest-risk, most targeted fixes.

Workaround (today, for affected users)

  • Build the server: M-x pdf-tools-install (install build deps first if needed). Once
    pdf-info-epdfinfo-program is executable and org-pdftools actually (provide)s, require
    becomes a no-op and the reloads stop.
  • Or disable :tools pdf (or the org-pdftools integration) if PDF links aren't needed, which
    removes the shim entirely.
  • For tangling specifically, ensure :comments no is genuinely in effect for your blocks (so
    org-babel-tangle--unbracketed-link early-returns and never calls org-store-link), or advise
    org-babel-tangle to bind org-store-link to #'ignore for the duration of the tangle.

References

Steps to reproduce

  1. Enable both :lang org and :tools pdf in init.el (the latter satisfies the
    :when (modulep! :tools pdf) gate on the org-pdftools block).
  2. Do not build epdfinfo (the default state — :tools pdf uses pdf-tools-install-noverify,
    which does not build it).
  3. Confirm the precondition: (featurep 'org-pdftools) => nil and
    (file-executable-p pdf-info-epdfinfo-program) => nil.
  4. Exercise the hot path. Either:
    • M-: (benchmark-run 10 (org-store-link nil)) RET with point in a normal Org buffer — each call
      re-loads org-pdftools.el; or
    • tangle a large literate Org config (C-c C-v t) on Org >= 9.8 where the blocks' :comments
      does not resolve to "no" (see "Org-version nuance").
  5. Profile it: M-x profiler-start RET cpu RET, run the action, then M-x profiler-report.

Observed: the CPU profile is dominated by repeated load-with-code-conversion of org-pdftools.el
underneath org-store-link.

System information

env.txt

Disclosures

  • This issue was written with/by AI.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    Open to PRs

    None yet

    Priority

    None yet

    Projects

    Status
    Unreviewed

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions