doomemacs / doomemacs/modules

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

Open
#75 0 comments 0 reactions 0 assignees View on GitHub
:lang org AI
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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.