Skip to content

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

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 built — pdf-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-epdfinfo → file-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 doomemacs/core#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.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    :lang orgAIIssues/PRs written by (or with assistance from) an LLM

    Type

    Fields

    Open to PRs

    No PRs

    Priority

    None yet

    Projects

    • Status
      Investigating
    • Status
      Revise

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions