Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions .claude/rules/typescript/windows-safe-exec.md
Original file line number Diff line number Diff line change
@@ -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\<name with spaces>\…` are common enough that this isn't theoretical.
1 change: 1 addition & 0 deletions news/changelog-1.10.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
68 changes: 52 additions & 16 deletions src/command/render/latexmk/texlive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"]);
}
45 changes: 45 additions & 0 deletions tests/unit/latexmk/fmtutil-handler.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading