From f72b590c658e120ff4b29b182138df309b5fb919 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 29 Oct 2025 17:31:09 +0000 Subject: [PATCH 1/8] 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 bb69187ae77eb9b78a7b317b040154047d1cdc63 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 29 Oct 2025 17:40:46 +0000 Subject: [PATCH 2/8] 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 4d3bd41aca2bc94d09b81af34daf58c496924548 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 29 Oct 2025 17:46:32 +0000 Subject: [PATCH 3/8] 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 9651aab5e331b8455994f1c12bc3b89299a74551 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 29 Oct 2025 17:46:59 +0000 Subject: [PATCH 4/8] 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 92eee3468c4edb1355cd563bd27912f5660a3a15 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 29 Oct 2025 17:59:59 +0000 Subject: [PATCH 5/8] 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 d9e16e4a94ef0c88006f969e51b0715ffabee560 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 29 Oct 2025 18:03:02 +0000 Subject: [PATCH 6/8] 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 0bb80592ad631798f3a5dbaa2055edfd33b83d39 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 29 Oct 2025 18:03:36 +0000 Subject: [PATCH 7/8] 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 548f6afcdbfdfa39821a0649274fba7058f617c2 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 30 Oct 2025 11:54:10 +0000 Subject: [PATCH 8/8] 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 36e401833d..eac82a1dc6 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -61,3 +61,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. 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; }