diff --git a/news/changelog-1.9.md b/news/changelog-1.9.md index f5953cd5088..fe359ba3d8b 100644 --- a/news/changelog-1.9.md +++ b/news/changelog-1.9.md @@ -59,3 +59,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 4e11ac62eb2..6156e9b8508 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 1783e4a0d44..2affd4004a7 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; } diff --git a/tests/docs/render-output-dir/.gitignore b/tests/docs/render-output-dir/.gitignore new file mode 100644 index 00000000000..ad293093b07 --- /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 00000000000..67f17142538 --- /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 00000000000..22fd04f3fe0 --- /dev/null +++ b/tests/smoke/render/render-output-dir.test.ts @@ -0,0 +1,54 @@ +/* +* 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/13625 +* +* Copyright (C) 2020-2025 Posit Software, PBC +* +*/ +import { existsSync, safeRemoveSync } from "../../../src/deno_ral/fs.ts"; +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"; + + +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 testOutputDirRender = ( + quartoVerify: Verify, + extraArgs: string[] = [], +) => { + testRender( + "test.qmd", + "html", + false, + [quartoVerify], + { + cwd: () => inputDir, + setup: cleanupDirs, + teardown: cleanupDirs, + }, + ["--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 +testOutputDirRender(fileExists(quartoDir), ["--no-clean"]); diff --git a/tests/utils.ts b/tests/utils.ts index 66db3d6d3c5..f241be7f4ba 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 { diff --git a/tests/verify.ts b/tests/verify.ts index bd8689bec0a..27762371522 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();