fix(desktop): find pandoc the same way the uploader finds picgo (#2751) by Jocs · Pull Request #5525 · marktext/marktext · GitHub
Skip to content

fix(desktop): find pandoc the same way the uploader finds picgo (#2751) - #5525

Merged
Jocs merged 10 commits into
developfrom
fix/unify-pandoc-command-resolution
Sep 23, 2026
Merged

Jocs merged 10 commits into
developfrom
fix/unify-pandoc-command-resolution

Conversation

@Jocs

@Jocs Jocs commented Sep 23, 2026

Copy link
Copy Markdown
Member

Follows #5519, which left this behind: pandoc kept a second copy of the command-resolution machinery, so the fix stopped at picgo.

The duplication

src/main/utils/pandoc.ts had its own location list, its own existence check, and its own (weaker) file predicate, parallel to the resolveCommand that #5519 introduced:

uploader (after #5519) pandoc (before this)
where to look resolveCommand getCommand()
is it installed resolveCommand(...) !== null command !== 'pandoc' || commandExists.sync('pandoc')
is the candidate usable statSync().isFile() + X_OK isFile2() — any file
login shell consulted yes no

Two user-visible consequences:

The change

resolveCommand gains preferred — absolute paths to try before PATH, held to the same runnable bar as everything else it returns. pandoc passes MARKTEXT_PANDOC and pandocLocations() through it:

const getCommand = async(): Promise<string> =>
  (await resolveCommand('pandoc', { preferred: preferredLocations() })) ?? 'pandoc'

pandocLocations() stays pandoc-specific — it encodes where the Windows installer writes, which is a different question from #5518's — and so does the .bat/.cmd filter (CVE-2024-27980). Everything else is now shared.

pandoc.exists() and the spawn sites become async. The previous PR assumed they could not be; checking the call sites shows otherwise: two of the three (menu/actions/file.ts:580, :801) were already inside async functions, and the third is reached from a menu click handler whose return value is discarded. No pandoc.exists() call happens while the menu is built.

converter.stream is removed: it had no callers anywhere in the repo, and it was the only thing requiring the command to be resolved synchronously.

Tests

  • test/unit/specs/pandoc-discovery.spec.ts (new) — a pandoc reachable only through a fake login shell is found; one that is nowhere is not; an override without the executable bit is refused. Verified red against the old mechanisms: weakening the runnable check and dropping the shell import turns the first and third red.
  • test/unit/specs/resolve-command.spec.ts — preferred wins over PATH, an unrunnable preferred entry is skipped, and a miss falls through to PATH.
  • test/unit/specs/pandoc-export.spec.ts — reworked for the async resolution: the spawn now lands a microtask later, so the stub process has to be awaited into place rather than driven synchronously. The suite also pins MARKTEXT_PANDOC to a real executable, because child_process is mocked in that file and a fall-through to the login shell would hang on the mock.

Full desktop suite: 931 passing.

🤖 Generated with Claude Code

Jocs and others added 2 commits September 23, 2026 12:04
A command can live somewhere PATH does not reach for reasons the resolver has
no business knowing: an installer's own folder, an explicit user override.
Passing those in keeps one resolver instead of one per tool, and they are held
to the same runnable bar as everything else it answers with.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pandoc kept its own resolver — own location list, own existence check, own
weaker predicate — so #5518's fix stopped at picgo. A pandoc under nvm/asdf,
which only the login shell knows about, still reported missing, and an
override pointing at a file without the executable bit reported present and
then failed at spawn.

It goes through resolveCommand now, with pandocLocations and MARKTEXT_PANDOC
passed as preferred locations: the Windows installer folders stay pandoc's
business, the PATH and login-shell half is shared. pandoc.exists() and the
spawn sites become async, which the three call sites already could be — the
menu never gated on it at build time, contrary to what the last PR assumed.

The dead converter.stream goes too: it had no callers and was the only thing
forcing the command to be resolved synchronously.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Build artifacts for PR #5525:

Run: https://github.com/marktext/marktext/actions/runs/35846520397

Artifact Size Link
marktext-windows-arm64 280.1 MB Download
marktext-macos-arm64 288.2 MB Download
marktext-macos-x64 298.5 MB Download
marktext-linux 640.4 MB Download
marktext-windows-x64 288.2 MB Download

Jocs and others added 8 commits September 23, 2026 12:30
…guess

MARKTEXT_PANDOC is documented as overwriting the pandoc path, but passing it
at the head of a candidate list demoted it to a hint: a typo'd or
non-executable value was skipped without a word and some other copy ran, or
the user was told to install a tool while staring at the path they had set.

An override is now its own option, and a set-but-unusable one resolves to
missing rather than falling through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ution

getCommand() and exists() each spelled out the same resolveCommand call, which
re-opens the seam this branch exists to close — add a location to one and the
other keeps the old answer. They share a findCommand() now. exists() cannot
simply call getCommand(): a pandoc found on PATH resolves to the bare name,
which is also getCommand's miss fallback, so the shared point has to be the
string-or-null one.

The converter thunk goes with it. It existed to carry .stream, which had no
callers and was deleted, leaving a two-stage call whose only consumer
immediately invoked it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
chocolatey, scoop and winget put their shims in folders that are on no PATH —
the Windows twin of the pnpm/volta/bun dirs extraPathDirs already carries on
POSIX. They were filed under pandocLocations, so on Windows they were searched
for pandoc and for nothing else: picgo and a cliScript got none of them.

extraPathDirs returned [] on win32 because a GUI app there does inherit the
user's PATH, which is true of PATH and irrelevant to a dir that was never on
it. pandocLocations keeps the three folders the pandoc installer itself
writes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Resolution runs a blocking command -v on the main process, and one LaTeX
export now asks three times: the menu gate, the media listing, the
conversion. Measured at ~7ms each on macOS with pandoc on PATH, and 30-50ms
per miss on Windows, all of it freezing the event loop while the pandoc runs
themselves are async.

A hit is remembered. A miss is not: the notice tells the user to install
pandoc, and a cached no would make that unanswerable without a restart.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
vi.waitFor's first check runs synchronously, while toFile is still suspended
on the command resolution, so every call fell through to the default 50ms
interval and observed the result one tick later. Ten call sites, ~52ms each:
the file went from ~5ms of test time to 535ms. The stub resolves a promise
from inside its own mockImplementation now — 6ms, and the hand-kept spawn
counters go with it.

The discovery spec's six cases each repeated the same five-line baseline with
one line different; that moves to beforeEach. The narrower of the two override
cases goes: the other one covers it with a pandoc on PATH as well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two places answer "can this be run" and answer it differently. The shared
one uses access(X_OK) rather than the mode bits, which is the question the
caller actually has: only the owner bits count for the owner, so a file
carrying just the "other" bit passes a bit test and gets EACCES at spawn.
Verified against a real spawn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mt::fs::is-executable tested the mode bits while the uploader spawns through
execFile, so the preferences panel could green-tick a script that then failed
with EACCES — the check and the spawn disagreeing, which is the whole thing
this branch has been closing. Both now call isExecutableFile.

resolveCommand's private isRunnable was the second copy of that predicate; it
goes with them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both failures CI caught are real consequences of this branch that a macOS run
could not see. patchEnvPath is no longer a no-op on win32 now that the shim
dirs live there, and the case pinning the old behaviour only passed locally
because ProgramData does not exist on a mac. It sets the anchor itself now,
and a second case covers the untouched path when no anchor can be found.

Windows has no execute bit, so X_OK degrades to "exists" and a file without
the bit is executable there — what the mode-bit check this replaced also did.
The assertion says so rather than expecting POSIX everywhere.

Drive letters are avoided throughout: ':' is the POSIX path delimiter, so
'C:\\Windows' splits into two entries on a mac runner. Verified the arithmetic
under path.win32 as well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@Jocs
Jocs merged commit c1ff221 into develop Sep 23, 2026
11 checks passed
@Jocs
Jocs deleted the fix/unify-pandoc-command-resolution branch September 23, 2026 10:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant