Skip to content

Fix non-ASCII characters garbled in large output from external tools - #15346

Merged
cderv merged 3 commits into
mainfrom
execprocess-garbles-a-multi-byte-character-that
Nov 12, 2026
Merged

cderv merged 3 commits into
mainfrom
execprocess-garbles-a-multi-byte-character-that

Conversation

@cderv

@cderv cderv commented Nov 12, 2026

Copy link
Copy Markdown
Member

When an external tool run through execProcess (Pandoc, knitr, LaTeX, ...) writes a lot of non-ASCII output, a character can come back as two �. #15341 found this with quarto dev-call build-artifacts, where the CSL event-place description sent through Pandoc ended up garbled in the generated editor tooling files.

Root cause

Subprocess output is read in chunks of up to 64 KiB, and execProcess decoded each chunk on its own. A UTF-8 character straddling two chunks was decoded as two replacement characters. This affects piped stdout and stderr, stderr with a filter, and merged output, including the knitr output printed to the console during render.

Fix

Output is now decoded with a streaming TextDecoder, which holds back an incomplete character until the next chunk completes it, and flushes at the end of the stream.

  • filteredAsyncIterator is now a generator with one decoder per stream. A chunk holding only part of a character yields nothing, so the stderr filter never receives an empty string.
  • When stdout and stderr are merged, both streams go through it before being combined, so a pending character on one stream can't be joined with bytes from the other. Decoding once after the merge was considered, but it has exactly that problem.
  • processOutput decodes each chunk once and uses that text both for printing and for the returned result. It used to decode the same chunk twice when printing inherited output, which a streaming decoder can't do.
  • When the child's output is passed through unchanged (quarto pandoc, quarto run), the raw bytes are still written as is, so binary output like quarto pandoc -t docx -o - is unaffected.

The new unit tests start the child process through a new execPath() in src/deno_ral/process.ts rather than calling the Deno API directly. dev-docs/runtime-migration/README.md records where the runtime-migration plans that rewrite process.ts need to account for this change.

Fixes #15341

Checklist

I have (if applicable):

  • referenced the GitHub issue this PR closes
  • updated the appropriate changelog in the PR
  • ensured the present test suite passes
  • added new tests
  • created a separate documentation PR in Quarto's website repo and linked it to this PR
AI-assisted PR
  • AI tool used: Claude Code
  • Codebase grounding: local clone
  • Human review: I have reviewed, tested, and verified the AI-generated content before submitting.

Note: autonomous AI agents submitting PRs without human oversight are not permitted — see the Code of Conduct.

execProcess decoded each read chunk of subprocess output on its own. Pipe
reads come in at most 64 KiB, which is not a multiple of 3 or 4, so a
multi-byte UTF-8 character straddling a chunk boundary was decoded as two
U+FFFD replacement characters in both the returned text and what was
printed.

filteredAsyncIterator is now an async generator with a streaming decoder
per stream, and merged output always routes both streams through it. The
merge multiplexer therefore only carries whole characters, so a character
pending on one stream cannot be spliced with bytes from the other. A chunk
that holds only part of a character decodes to nothing and is skipped, so
the stderr filter never receives an empty string (it would print bare
colour escapes). processOutput decodes each chunk once, with streaming, and
reuses that text for printing and accumulation.

The respectStreams path still writes the child's raw bytes unchanged, so
binary output such as `quarto pandoc -t docx -o -` is not affected.

The regression tests spawn the current runtime through a new execPath()
in deno_ral/process.ts, so they add no direct runtime calls.
The merged execProcess UTF-8 fix changes three assumptions in unexecuted
runtime-migration plans: PR 08's replacement processOutput and pooledMap
import would revert it, PR 28's merged-mode addStream snippet would drop
the per-stream decoding, and the new test starts its child with a
Deno-only eval invocation that PR 23's inventory must list. Each executor
reads these rows at step 0.
The cited line 25 is in PR 08's context section, not Task 1. The snippet
replaces only the signature and raw writes, so applied over the current
processOutput it would print inherited output twice as well as revert the
streaming decode; the row now says so.
@posit-snyk-bot

posit-snyk-bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Collaborator

✅ Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
✅ Open Source Security 0 0 0 0 0 issues
✅ Licenses 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@cderv
cderv merged commit 27e746a into main Nov 12, 2026
52 of 54 checks passed
@cderv
cderv deleted the execprocess-garbles-a-multi-byte-character-that branch November 12, 2026 14:05
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.

execProcess garbles a multi-byte character that falls across two output chunks

2 participants