diff --git a/.claude/rules/typescript/windows-safe-exec.md b/.claude/rules/typescript/windows-safe-exec.md new file mode 100644 index 00000000000..f2663079dff --- /dev/null +++ b/.claude/rules/typescript/windows-safe-exec.md @@ -0,0 +1,14 @@ +--- +paths: + - "src/**/*.ts" +--- + +# Windows: `safeWindowsExec` quoting + +`safeWindowsExec` (defined at `src/core/windows.ts`) is the right tool whenever a Windows process needs to be spawned through `cmd /c` instead of being launched directly by Deno. Examples include `.bat` wrappers (`tlmgr.bat`), TeX Live binaries that use `runscript.tlu` for path resolution (`fmtutil-sys.exe`, see `rstudio/tinytex#427`), registry queries via `reg.exe`, and other shell-sensitive callouts elsewhere under `src/core/` (`zip.ts`, `shell.ts`). Grep `safeWindowsExec` for the current set of call sites. + +## Quote both program and args before calling + +`safeWindowsExec` writes a temp `.bat` whose contents are `[program, ...args].join(" ")`. Any space in the program path or any arg tokenizes the line incorrectly, and the spawn either fails or runs the wrong binary. Pass the program path and the args through `requireQuoting` together, then split the returned array — the unit test `safeWindowsExec - handles program path with spaces (issue #13997)` in `tests/unit/windows-exec.test.ts` is the authoritative usage example, and `fmtutilCommand` in `src/command/render/latexmk/texlive.ts` is the in-tree consumer. + +`tlmgrCommand` quotes only the user-supplied subcommand args, not the program path, because `tlmgr.bat` historically lives at a stable TinyTeX path. New call sites should default to quoting both — paths under `C:\Users\\…` are common enough that this isn't theoretical. diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index 93f30f3ff46..d8ccd2d1505 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -70,4 +70,5 @@ All changes included in 1.10: - ([#14342](https://github.com/quarto-dev/quarto-cli/issues/14342)): Work around TOCTOU race in Deno's `expandGlobSync` that can cause unexpected exceptions to be raised while traversing directories during project initialization. - ([#14445](https://github.com/quarto-dev/quarto-cli/issues/14445)): Fix intermittent `Uncaught (in promise) TypeError: Writable stream is closed or errored.` aborting renders on Linux. `execProcess` now awaits and swallows the rejection from `process.stdin.close()` when the child closes its stdin first. The captured stderr is now also surfaced when `typst-gather analyze` falls back to staging all packages, so failures are diagnosable without bypassing `quarto`. - ([#14359](https://github.com/quarto-dev/quarto-cli/issues/14359)): Fix intermediate `.quarto_ipynb` file not being deleted after rendering a `.qmd` with Jupyter engine, causing numbered variants (`_1`, `_2`, ...) to accumulate on disk across renders. +- ([#14461](https://github.com/quarto-dev/quarto-cli/issues/14461)): Fix `quarto render --to pdf` aborting with `ERROR: Problem running 'fmtutil-sys --all' to rebuild format tree.` when an automatically-installed LaTeX package's post-update format rebuild fails. Format-tree rebuild is now treated as best-effort housekeeping (matching upstream `tinytex` R behavior) — the failure is logged as a warning and the package install completes. - ([#14472](https://github.com/quarto-dev/quarto-cli/issues/14472)): Add support for Kotlin in code annotations and YAML cell options. (author: @barendgehrels) \ No newline at end of file diff --git a/src/command/render/latexmk/texlive.ts b/src/command/render/latexmk/texlive.ts index 923e6421966..70d11fa39af 100644 --- a/src/command/render/latexmk/texlive.ts +++ b/src/command/render/latexmk/texlive.ts @@ -6,12 +6,14 @@ import * as ld from "../../../core/lodash.ts"; import { execProcess } from "../../../core/process.ts"; +import { ProcessResult } from "../../../core/process-types.ts"; import { lines } from "../../../core/text.ts"; import { requireQuoting, safeWindowsExec } from "../../../core/windows.ts"; import { hasTinyTex, tinyTexBinDir } from "../../../tools/impl/tinytex-info.ts"; import { join } from "../../../deno_ral/path.ts"; import { logProgress } from "../../../core/log.ts"; import { isWindows } from "../../../deno_ral/platform.ts"; +import { warning } from "../../../deno_ral/log.ts"; export interface TexLiveContext { preferTinyTex: boolean; @@ -242,12 +244,12 @@ async function installPackage( return Promise.reject("Problem running `tlmgr update`."); } - // Rebuild format tree + // Rebuild format tree (best-effort; failure is non-fatal — see + // fmtutilFailureMessage doc). const fmtutilResult = await fmtutilCommand(context); - if (fmtutilResult.code !== 0) { - return Promise.reject( - "Problem running `fmtutil-sys --all` to rebuild format tree.", - ); + const fmtutilWarn = fmtutilFailureMessage(fmtutilResult); + if (fmtutilWarn) { + warning(fmtutilWarn); } } @@ -281,12 +283,12 @@ async function installPackage( return Promise.reject("Problem running `tlmgr update`."); } - // Rebuild format tree + // Rebuild format tree (best-effort; failure is non-fatal — see + // fmtutilFailureMessage doc). const fmtutilResult = await fmtutilCommand(context); - if (fmtutilResult.code !== 0) { - return Promise.reject( - "Problem running `fmtutil-sys --all` to rebuild format tree.", - ); + const fmtutilWarn = fmtutilFailureMessage(fmtutilResult); + if (fmtutilWarn) { + warning(fmtutilWarn); } // Rerun the install command @@ -453,16 +455,50 @@ function tlmgrCommand( } } +// Returns a warning message when `fmtutil-sys --all` failed, or `undefined` +// when it succeeded. fmtutil failure is treated as non-fatal: package install +// already succeeded by the time we reach the recovery branches in +// `installPackage`, and the format-tree rebuild is best-effort housekeeping +// to mitigate l3kernel version-mismatch issues (#7252). +// +// Upstream tinytex R package follows the same pattern (R/tlmgr.R discards +// the `system2('fmtutil', ...)` exit code). +export function fmtutilFailureMessage( + result: ProcessResult, +): string | undefined { + if (result.code === 0) { + return undefined; + } + const stderr = result.stderr?.trim() ?? ""; + const detail = stderr.length > 0 ? `\n${stderr}` : ""; + return `Failed to rebuild format tree (\`fmtutil-sys --all\` exited ${result.code}). This is non-fatal — package installation will continue.${detail}`; +} + // Execute fmtutil // https://tug.org/texlive/doc/fmtutil.html +// +// On Windows, route through `safeWindowsExec` (mirrors `tlmgrCommand`). +// This wraps the call in a temp `.bat` invoked via `cmd /c`, which +// avoids 8.3 short-path resolution failures inside TeX Live's +// `runscript.tlu` (rstudio/tinytex#427). function fmtutilCommand(context: TexLiveContext) { const fmtutil = texLiveCmd("fmtutil-sys", context); - return execProcess( - { - cmd: fmtutil.fullPath, - args: ["--all"], + const execFmtutil = (cmd: string[]) => { + return execProcess({ + cmd: cmd[0], + args: cmd.slice(1), stdout: "piped", stderr: "piped", - }, - ); + }); + }; + if (isWindows) { + // Quote both program and args before handing them to `safeWindowsExec` + // — `safeWindowsExec` joins them into a `.bat` line with a literal space + // separator, so paths containing spaces (e.g. `C:\Users\Jane Doe\...`) + // would otherwise be tokenized incorrectly. See issue #13997 and the + // `safeWindowsExec - handles program path with spaces` unit test. + const quoted = requireQuoting([fmtutil.fullPath, "--all"]); + return safeWindowsExec(quoted.args[0], quoted.args.slice(1), execFmtutil); + } + return execFmtutil([fmtutil.fullPath, "--all"]); } diff --git a/tests/unit/latexmk/fmtutil-handler.test.ts b/tests/unit/latexmk/fmtutil-handler.test.ts new file mode 100644 index 00000000000..71fc690cb03 --- /dev/null +++ b/tests/unit/latexmk/fmtutil-handler.test.ts @@ -0,0 +1,45 @@ +/* +* fmtutil-handler.test.ts +* +* Copyright (C) 2026 Posit Software, PBC +* +*/ + +import { fmtutilFailureMessage } from "../../../src/command/render/latexmk/texlive.ts"; +import { unitTest } from "../../test.ts"; +import { assert, assertEquals } from "testing/asserts"; + +// deno-lint-ignore require-await +unitTest("fmtutilFailureMessage - undefined when fmtutil-sys succeeded", async () => { + assertEquals( + fmtutilFailureMessage({ code: 0, success: true, stdout: "", stderr: "" }), + undefined, + ); +}); + +// deno-lint-ignore require-await +unitTest("fmtutilFailureMessage - warning text when exit code is non-zero", async () => { + const msg = fmtutilFailureMessage({ + code: 1, + success: false, + stdout: "", + stderr: "fmtutil: error rebuilding format luatex", + }); + assert(msg, "expected a warning message"); + assert(msg.includes("fmtutil-sys --all"), "should mention the command"); + assert(msg.includes("non-fatal"), "should signal non-fatal"); + assert(msg.includes("fmtutil: error rebuilding format luatex"), "should include stderr"); +}); + +// deno-lint-ignore require-await +unitTest("fmtutilFailureMessage - omits stderr suffix when stderr is empty/whitespace", async () => { + const msg = fmtutilFailureMessage({ + code: 1, + success: false, + stdout: "", + stderr: " \n", + }); + const baseMsg = + `Failed to rebuild format tree (\`fmtutil-sys --all\` exited 1). This is non-fatal — package installation will continue.`; + assertEquals(msg, baseMsg); +});