org-pdftools re-loads `org-pdftools.el` on every `org-store-link` when `epdfinfo` is not built
- Dominant language
- Emacs Lisp
- Stars
- 61
- Forks
- 37
- Avg merge
- 11h 19m
- Merged PRs (30d)
- 7
Description
### 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
```elisp
(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
- https://pastebin.com/XtnVAqFQ
- https://pastebin.com/F3kcrcmg
## 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:
```elisp
(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 ...]` = **1046
samples** and `[require progn condition-case and ...]` = **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`:
```elisp
#[(&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
- Doom shim (current default branch): https://github.com/doomemacs/doomemacs/blob/master/modules/lang/org/config.el
- Introducing commit `bf8495b` "fix(org): epdfinfo error when storing/exporting links": https://github.com/doomemacs/core/commit/bf8495b4122701fb30cb6cea37281dc8f3bedcd0
- Doom `:tools pdf` config (uses `pdf-tools-install-noverify`, does not build epdfinfo): https://github.com/doomemacs/doomemacs/blob/master/modules/tools/pdf/config.el
- Doom org packages (org-pdftools pin): https://github.com/doomemacs/doomemacs/blob/master/modules/lang/org/packages.el
- Related prior report (different root cause, predates the shim, via org-capture, closed unresolved): https://github.com/doomemacs/doomemacs/issues/3979
- Org `ob-tangle.el` @ release_9.8.4 (bare `org-store-link`, no `cl-letf`): https://raw.githubusercontent.com/bzg/org-mode/release_9.8.4/lisp/ob-tangle.el
- Org `ob-tangle.el` @ release_9.7.11 (has the `cl-letf` no-op binding): https://raw.githubusercontent.com/bzg/org-mode/release_9.7.11/lisp/ob-tangle.el
- Org removal commit `f6a0f151b1a2` "ob-tangle: Fix regression after 95554543b9": https://github.com/bzg/org-mode/commit/f6a0f151b1a23deab2291fe8edaa689a43369d80
- pdf-tools `pdf-info.el` (`pdf-info-check-epdfinfo` / the "is not executable" error): https://raw.githubusercontent.com/vedang/pdf-tools/master/lisp/pdf-info.el
- org-pdftools (load chain + final `provide`): https://github.com/fuxialexander/org-pdftools
### 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](https://github.com/user-attachments/files/29304308/env.txt)
### Disclosures
- [x] This issue was written with/by AI.
Contributor guide
No contributing guide indexed for this repository
Research direction
Start in modules/lang/org/config.el at +org--pdftools-link-handler, then reproduce with :tools pdf enabled and a non-executable pdf-info-epdfinfo-program by calling org-store-link or tangling a file with many blocks. Confirm that org-pdftools is retried on each call, and consider the suggested executable check or deferred registration; done means unavailable epdfinfo no longer causes repeated package loads while working links still function after the server is built.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- emacs-lisp
- Domain
- performance
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 58/100