From 5574eadbdf01167f5af72b45a5d438e47699f217 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 29 Oct 2025 17:31:09 +0000 Subject: [PATCH 1/9] Try a test that fails with the locking issue. --- tests/docs/render-output-dir/.gitignore | 2 + tests/docs/render-output-dir/test.qmd | 6 +++ tests/smoke/render/render-output-dir.test.ts | 50 ++++++++++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 tests/docs/render-output-dir/.gitignore create mode 100644 tests/docs/render-output-dir/test.qmd create mode 100644 tests/smoke/render/render-output-dir.test.ts diff --git a/tests/docs/render-output-dir/.gitignore b/tests/docs/render-output-dir/.gitignore new file mode 100644 index 0000000000..ad293093b0 --- /dev/null +++ b/tests/docs/render-output-dir/.gitignore @@ -0,0 +1,2 @@ +/.quarto/ +**/*.quarto_ipynb diff --git a/tests/docs/render-output-dir/test.qmd b/tests/docs/render-output-dir/test.qmd new file mode 100644 index 0000000000..67f1714253 --- /dev/null +++ b/tests/docs/render-output-dir/test.qmd @@ -0,0 +1,6 @@ +--- +title: "Test Output Dir" +format: html +--- + +This is a simple document to test rendering with --output-dir flag. diff --git a/tests/smoke/render/render-output-dir.test.ts b/tests/smoke/render/render-output-dir.test.ts new file mode 100644 index 0000000000..ee4593bf33 --- /dev/null +++ b/tests/smoke/render/render-output-dir.test.ts @@ -0,0 +1,50 @@ +/* +* render-output-dir.test.ts +* +* Test for Windows file locking issue with --output-dir flag +* Regression test for: https://github.com/quarto-dev/quarto-cli/issues/XXXXX +* +* Copyright (C) 2020-2025 Posit Software, PBC +* +*/ +import { dirname, join } from "../../../src/deno_ral/path.ts"; +import { existsSync, safeRemoveSync } from "../../../src/deno_ral/fs.ts"; +import { docs } from "../../utils.ts"; +import { isWindows } from "../../../src/deno_ral/platform.ts"; +import { pathDoNotExists } from "../../verify.ts"; +import { testRender } from "./render.ts"; + +if (isWindows) { + const inputDir = docs("render-output-dir/"); + const quartoDir = ".quarto" + const outputDir = "output-test-dir" + + testRender( + "test.qmd", + "html", + true, + [pathDoNotExists(quartoDir)], + { + cwd: () => inputDir, + setup: async () => { + // Ensure output and quarto dirs are removed before test + if (existsSync(outputDir)) { + safeRemoveSync(outputDir, { recursive: true }); + } + if (existsSync(quartoDir)) { + safeRemoveSync(quartoDir, { recursive: true }); + } + }, + teardown: async () => { + if (existsSync(outputDir)) { + safeRemoveSync(outputDir, { recursive: true }); + } + if (existsSync(quartoDir)) { + safeRemoveSync(quartoDir, { recursive: true }); + } + }, + }, + ["--output-dir", outputDir], + outputDir, + ); +} From 0783a7370dfa010e6c50697a7e4ac30022ef1376 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 29 Oct 2025 17:40:46 +0000 Subject: [PATCH 2/9] fix the test --- tests/smoke/render/render-output-dir.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/smoke/render/render-output-dir.test.ts b/tests/smoke/render/render-output-dir.test.ts index ee4593bf33..12f99860aa 100644 --- a/tests/smoke/render/render-output-dir.test.ts +++ b/tests/smoke/render/render-output-dir.test.ts @@ -2,12 +2,11 @@ * render-output-dir.test.ts * * Test for Windows file locking issue with --output-dir flag -* Regression test for: https://github.com/quarto-dev/quarto-cli/issues/XXXXX +* Regression test for: https://github.com/quarto-dev/quarto-cli/issues/13625 * * Copyright (C) 2020-2025 Posit Software, PBC * */ -import { dirname, join } from "../../../src/deno_ral/path.ts"; import { existsSync, safeRemoveSync } from "../../../src/deno_ral/fs.ts"; import { docs } from "../../utils.ts"; import { isWindows } from "../../../src/deno_ral/platform.ts"; @@ -22,7 +21,7 @@ if (isWindows) { testRender( "test.qmd", "html", - true, + false, [pathDoNotExists(quartoDir)], { cwd: () => inputDir, From bdc04fc9028da3e6e117ced5de1129346b363d6f Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 29 Oct 2025 17:46:32 +0000 Subject: [PATCH 3/9] tests - fix verify function name --- tests/verify.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/verify.ts b/tests/verify.ts index bd8689bec0..2776237152 100644 --- a/tests/verify.ts +++ b/tests/verify.ts @@ -236,7 +236,7 @@ export const fileExists = (file: string): Verify => { export const pathDoNotExists = (path: string): Verify => { return { - name: `path ${path} exists`, + name: `path ${path} do not exists`, verify: (_output: ExecuteOutput[]) => { verifyNoPath(path); return Promise.resolve(); From 4898f6b0f9b73d6be40945a29a5feba0395ef260 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 29 Oct 2025 17:46:59 +0000 Subject: [PATCH 4/9] tests - fix helper to get the output path when outputdir is set --- tests/utils.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/utils.ts b/tests/utils.ts index 66db3d6d3c..f241be7f4b 100644 --- a/tests/utils.ts +++ b/tests/utils.ts @@ -146,9 +146,13 @@ export function outputForInput( const outputPath: string = projectRoot && projectOutDir !== undefined ? join(projectRoot, projectOutDir, dir, `${stem}.${outputExt}`) + : projectOutDir !== undefined + ? join(projectOutDir, dir, `${stem}.${outputExt}`) : join(dir, `${stem}.${outputExt}`); const supportPath: string = projectRoot && projectOutDir !== undefined ? join(projectRoot, projectOutDir, dir, `${stem}_files`) + : projectOutDir !== undefined + ? join(projectOutDir, dir, `${stem}_files`) : join(dir, `${stem}_files`); return { From cce9cea56c0ce0603ddd93896ba2a06b24fdfbac Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 29 Oct 2025 17:59:59 +0000 Subject: [PATCH 5/9] Add a test for the `--no-clean` flag --- tests/smoke/render/render-output-dir.test.ts | 43 +++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/tests/smoke/render/render-output-dir.test.ts b/tests/smoke/render/render-output-dir.test.ts index 12f99860aa..371332f707 100644 --- a/tests/smoke/render/render-output-dir.test.ts +++ b/tests/smoke/render/render-output-dir.test.ts @@ -10,14 +10,15 @@ import { existsSync, safeRemoveSync } from "../../../src/deno_ral/fs.ts"; import { docs } from "../../utils.ts"; import { isWindows } from "../../../src/deno_ral/platform.ts"; -import { pathDoNotExists } from "../../verify.ts"; +import { fileExists, pathDoNotExists } from "../../verify.ts"; import { testRender } from "./render.ts"; if (isWindows) { - const inputDir = docs("render-output-dir/"); - const quartoDir = ".quarto" - const outputDir = "output-test-dir" + const inputDir = docs("render-output-dir/"); + const quartoDir = ".quarto"; + const outputDir = "output-test-dir"; + // Test 1: Default behavior (clean=true) - .quarto should be removed testRender( "test.qmd", "html", @@ -39,11 +40,41 @@ if (isWindows) { safeRemoveSync(outputDir, { recursive: true }); } if (existsSync(quartoDir)) { - safeRemoveSync(quartoDir, { recursive: true }); + safeRemoveSync(quartoDir, { recursive: true }); } }, - }, + }, ["--output-dir", outputDir], outputDir, ); + + // Test 2: With --no-clean flag - .quarto should be preserved + testRender( + "test.qmd", + "html", + false, + [fileExists(quartoDir)], + { + cwd: () => inputDir, + setup: async () => { + // Ensure output and quarto dirs are removed before test + if (existsSync(outputDir)) { + safeRemoveSync(outputDir, { recursive: true }); + } + if (existsSync(quartoDir)) { + safeRemoveSync(quartoDir, { recursive: true }); + } + }, + teardown: async () => { + if (existsSync(outputDir)) { + safeRemoveSync(outputDir, { recursive: true }); + } + if (existsSync(quartoDir)) { + safeRemoveSync(quartoDir, { recursive: true }); + } + }, + }, + ["--output-dir", outputDir, "--no-clean"], + outputDir, + ); } From 686c8f19725bc2b72ca7541bd5adfe1f76cb2363 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 29 Oct 2025 18:03:02 +0000 Subject: [PATCH 6/9] Refactor for clarity --- tests/smoke/render/render-output-dir.test.ts | 87 +++++++------------- 1 file changed, 31 insertions(+), 56 deletions(-) diff --git a/tests/smoke/render/render-output-dir.test.ts b/tests/smoke/render/render-output-dir.test.ts index 371332f707..574c6e0c7f 100644 --- a/tests/smoke/render/render-output-dir.test.ts +++ b/tests/smoke/render/render-output-dir.test.ts @@ -12,69 +12,44 @@ import { docs } from "../../utils.ts"; import { isWindows } from "../../../src/deno_ral/platform.ts"; import { fileExists, pathDoNotExists } from "../../verify.ts"; import { testRender } from "./render.ts"; +import type { Verify } from "../../test.ts"; if (isWindows) { const inputDir = docs("render-output-dir/"); const quartoDir = ".quarto"; const outputDir = "output-test-dir"; - // Test 1: Default behavior (clean=true) - .quarto should be removed - testRender( - "test.qmd", - "html", - false, - [pathDoNotExists(quartoDir)], - { - cwd: () => inputDir, - setup: async () => { - // Ensure output and quarto dirs are removed before test - if (existsSync(outputDir)) { - safeRemoveSync(outputDir, { recursive: true }); - } - if (existsSync(quartoDir)) { - safeRemoveSync(quartoDir, { recursive: true }); - } - }, - teardown: async () => { - if (existsSync(outputDir)) { - safeRemoveSync(outputDir, { recursive: true }); - } - if (existsSync(quartoDir)) { - safeRemoveSync(quartoDir, { recursive: true }); - } + const cleanupDirs = async () => { + if (existsSync(outputDir)) { + safeRemoveSync(outputDir, { recursive: true }); + } + if (existsSync(quartoDir)) { + safeRemoveSync(quartoDir, { recursive: true }); + } + }; + + const testOutputDirRender = ( + quartoVerify: Verify, + extraArgs: string[] = [], + ) => { + testRender( + "test.qmd", + "html", + false, + [quartoVerify], + { + cwd: () => inputDir, + setup: cleanupDirs, + teardown: cleanupDirs, }, - }, - ["--output-dir", outputDir], - outputDir, - ); + ["--output-dir", outputDir, ...extraArgs], + outputDir, + ); + }; + + // Test 1: Default behavior (clean=true) - .quarto should be removed + testOutputDirRender(pathDoNotExists(quartoDir)); // Test 2: With --no-clean flag - .quarto should be preserved - testRender( - "test.qmd", - "html", - false, - [fileExists(quartoDir)], - { - cwd: () => inputDir, - setup: async () => { - // Ensure output and quarto dirs are removed before test - if (existsSync(outputDir)) { - safeRemoveSync(outputDir, { recursive: true }); - } - if (existsSync(quartoDir)) { - safeRemoveSync(quartoDir, { recursive: true }); - } - }, - teardown: async () => { - if (existsSync(outputDir)) { - safeRemoveSync(outputDir, { recursive: true }); - } - if (existsSync(quartoDir)) { - safeRemoveSync(quartoDir, { recursive: true }); - } - }, - }, - ["--output-dir", outputDir, "--no-clean"], - outputDir, - ); + testOutputDirRender(fileExists(quartoDir), ["--no-clean"]); } From 15c5adfe3a3910a360c6fef290c40f68cff69037 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 29 Oct 2025 18:03:36 +0000 Subject: [PATCH 7/9] Don't make test conditional on windows. --- tests/smoke/render/render-output-dir.test.ts | 69 ++++++++++---------- 1 file changed, 34 insertions(+), 35 deletions(-) diff --git a/tests/smoke/render/render-output-dir.test.ts b/tests/smoke/render/render-output-dir.test.ts index 574c6e0c7f..22fd04f3fe 100644 --- a/tests/smoke/render/render-output-dir.test.ts +++ b/tests/smoke/render/render-output-dir.test.ts @@ -14,42 +14,41 @@ import { fileExists, pathDoNotExists } from "../../verify.ts"; import { testRender } from "./render.ts"; import type { Verify } from "../../test.ts"; -if (isWindows) { - const inputDir = docs("render-output-dir/"); - const quartoDir = ".quarto"; - const outputDir = "output-test-dir"; - const cleanupDirs = async () => { - if (existsSync(outputDir)) { - safeRemoveSync(outputDir, { recursive: true }); - } - if (existsSync(quartoDir)) { - safeRemoveSync(quartoDir, { recursive: true }); - } - }; +const inputDir = docs("render-output-dir/"); +const quartoDir = ".quarto"; +const outputDir = "output-test-dir"; - const testOutputDirRender = ( - quartoVerify: Verify, - extraArgs: string[] = [], - ) => { - testRender( - "test.qmd", - "html", - false, - [quartoVerify], - { - cwd: () => inputDir, - setup: cleanupDirs, - teardown: cleanupDirs, - }, - ["--output-dir", outputDir, ...extraArgs], - outputDir, - ); - }; +const cleanupDirs = async () => { + if (existsSync(outputDir)) { + safeRemoveSync(outputDir, { recursive: true }); + } + if (existsSync(quartoDir)) { + safeRemoveSync(quartoDir, { recursive: true }); + } +}; - // Test 1: Default behavior (clean=true) - .quarto should be removed - testOutputDirRender(pathDoNotExists(quartoDir)); +const testOutputDirRender = ( + quartoVerify: Verify, + extraArgs: string[] = [], +) => { + testRender( + "test.qmd", + "html", + false, + [quartoVerify], + { + cwd: () => inputDir, + setup: cleanupDirs, + teardown: cleanupDirs, + }, + ["--output-dir", outputDir, ...extraArgs], + outputDir, + ); +}; - // Test 2: With --no-clean flag - .quarto should be preserved - testOutputDirRender(fileExists(quartoDir), ["--no-clean"]); -} +// Test 1: Default behavior (clean=true) - .quarto should be removed +testOutputDirRender(pathDoNotExists(quartoDir)); + +// Test 2: With --no-clean flag - .quarto should be preserved +testOutputDirRender(fileExists(quartoDir), ["--no-clean"]); From 17eb4983df91be55b7a564fc278645d264afe488 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 30 Oct 2025 11:54:10 +0000 Subject: [PATCH 8/9] Fix Windows file locking error when rendering with --output-dir When --output-dir is used without a project file, Quarto creates a synthetic project context with a temporary .quarto directory to manage the render. This fix ensures file handles are closed before attempting to remove the directory, preventing Windows "os error 32" (file in use by another process). The synthetic project pattern: - Triggered when: quarto render file.qmd --output-dir output/ (no _quarto.yml) - Creates temporary .quarto directory in current directory - Uses full renderProject() path (not singleFileProjectContext()) - forceClean flag in RenderOptions signals cleanup needed - After render: close handles (context.cleanup()) then remove directory Improved comments to explain the synthetic project pattern, the dual purpose of the forceClean flag, and critical ordering requirements to avoid file locking issues on Windows. Fixes #13625 --- news/changelog-1.9.md | 1 + src/command/render/project.ts | 17 ++++++++++++----- src/command/render/render-shared.ts | 8 +++++--- 3 files changed, 18 insertions(+), 8 deletions(-) diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index a1085ae47d..ec9a97fa75 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -61,4 +61,5 @@ All changes included in 1.9: - ([#13402](https://github.com/quarto-dev/quarto-cli/issues/13402)): `nfpm` () is now used to create the `.deb` package, and new `.rpm` package. Both Linux packages are also now built for `x86_64` (`amd64`) and `aarch64` (`arm64`) architectures. - ([#13528](https://github.com/quarto-dev/quarto-cli/pull/13528)): Adds support for table specification using nested lists and the `list-table` class. - ([#13575](https://github.com/quarto-dev/quarto-cli/pull/13575)): Improve CPU architecture detection/reporting in macOS to allow quarto to run in virtualized environments such as OpenAI's `codex`. +- ([#13625](https://github.com/quarto-dev/quarto-cli/issues/13625)): Fix Windows file locking error (os error 32) when rendering with `--output-dir` flag. Context cleanup now happens before removing the temporary `.quarto` directory, ensuring file handles are properly closed. - ([#13656](https://github.com/quarto-dev/quarto-cli/issues/13656)): Fix R code cells with empty `lang: ""` option producing invalid markdown class attributes. diff --git a/src/command/render/project.ts b/src/command/render/project.ts index 4e11ac62eb..6156e9b850 100644 --- a/src/command/render/project.ts +++ b/src/command/render/project.ts @@ -886,13 +886,20 @@ export async function renderProject( ); } - // in addition to the cleanup above, if forceClean is set, we need to clean up the project scratch dir - // entirely. See options.forceClean in render-shared.ts - // .quarto is really a fiction created because of `--output-dir` being set on non-project - // renders + // Clean up synthetic project created for --output-dir + // When --output-dir is used without a project file, we create a temporary + // project context with a .quarto directory (see render-shared.ts). + // After rendering completes, we must remove this directory to avoid leaving + // debris in non-project directories (#9745). // - // cf https://github.com/quarto-dev/quarto-cli/issues/9745#issuecomment-2125951545 + // Critical ordering for Windows: Close file handles BEFORE removing directory + // to avoid "The process cannot access the file because it is being used by + // another process" (os error 32) (#13625). if (projectRenderConfig.options.forceClean) { + // 1. Close all file handles (KV database, temp context, etc.) + context.cleanup(); + + // 2. Remove the temporary .quarto directory const scratchDir = join(projDir, kQuartoScratch); if (existsSync(scratchDir)) { safeRemoveSync(scratchDir, { recursive: true }); diff --git a/src/command/render/render-shared.ts b/src/command/render/render-shared.ts index 1783e4a0d4..2affd4004a 100644 --- a/src/command/render/render-shared.ts +++ b/src/command/render/render-shared.ts @@ -48,12 +48,14 @@ export async function render( // determine target context/files let context = await projectContext(path, nbContext, options); - // if there is no project parent and an output-dir was passed, then force a project + // Create a synthetic project when --output-dir is used without a project file + // This creates a temporary .quarto directory to manage the render, which must + // be fully cleaned up afterward to avoid leaving debris (see #9745) if (!context && options.flags?.outputDir) { - // recompute context context = await projectContextForDirectory(path, nbContext, options); - // force clean as --output-dir implies fully overwrite the target + // forceClean signals this is a synthetic project that needs full cleanup + // including removing the .quarto scratch directory after rendering (#13625) options.forceClean = options.flags.clean !== false; } From e5be0a08f437299aa6f115c0d5545b9daf85e763 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 12 Nov 2025 11:05:16 +0100 Subject: [PATCH 9/9] Add a regression fix in changelog [skip ci] --- news/changelog-1.9.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index ec9a97fa75..e409e9a3b3 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -6,6 +6,7 @@ All changes included in 1.9: - ([#13441](https://github.com/quarto-dev/quarto-cli/pull/13441)): Catch `undefined` exceptions in Pandoc failure to avoid spurious error message. - ([#13046](https://github.com/quarto-dev/quarto-cli/issues/13046)): Use new url for multiplex socket.io server as default for `format: revealjs` and `revealjs.multiplex: true`. - ([#13506](https://github.com/quarto-dev/quarto-cli/issues/13506)): Fix navbar active state detection when sidebar has no logo configured. Prevents empty logo links from interfering with navigation highlighting. +- ([#13625](https://github.com/quarto-dev/quarto-cli/issues/13625)): Fix Windows file locking error (os error 32) when rendering with `--output-dir` flag. Context cleanup now happens before removing the temporary `.quarto` directory, ensuring file handles are properly closed. - ([#13633](https://github.com/quarto-dev/quarto-cli/issues/13633)): Fix detection and auto-installation of babel language packages from newer error format that doesn't explicitly mention `.ldf` filename. ## Dependencies @@ -61,5 +62,4 @@ All changes included in 1.9: - ([#13402](https://github.com/quarto-dev/quarto-cli/issues/13402)): `nfpm` () is now used to create the `.deb` package, and new `.rpm` package. Both Linux packages are also now built for `x86_64` (`amd64`) and `aarch64` (`arm64`) architectures. - ([#13528](https://github.com/quarto-dev/quarto-cli/pull/13528)): Adds support for table specification using nested lists and the `list-table` class. - ([#13575](https://github.com/quarto-dev/quarto-cli/pull/13575)): Improve CPU architecture detection/reporting in macOS to allow quarto to run in virtualized environments such as OpenAI's `codex`. -- ([#13625](https://github.com/quarto-dev/quarto-cli/issues/13625)): Fix Windows file locking error (os error 32) when rendering with `--output-dir` flag. Context cleanup now happens before removing the temporary `.quarto` directory, ensuring file handles are properly closed. - ([#13656](https://github.com/quarto-dev/quarto-cli/issues/13656)): Fix R code cells with empty `lang: ""` option producing invalid markdown class attributes.