From 9cf117bbb03446c8617ee4dca18574522c3c3421 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Thu, 30 Oct 2025 12:41:22 +0000 Subject: [PATCH] Fix Windows file locking error when rendering with --output-dir Backport Windows file locking fix for --output-dir to v1.8 Fixes "os error 32" on Windows when rendering with --output-dir flag outside of a project. Ensures file handles are closed before removing the temporary .quarto directory. Backport of #13626 to close #13625 --- src/command/render/project.ts | 17 ++++-- src/command/render/render-shared.ts | 8 +-- tests/docs/render-output-dir/.gitignore | 2 + tests/docs/render-output-dir/test.qmd | 6 +++ tests/smoke/render/render-output-dir.test.ts | 54 ++++++++++++++++++++ tests/utils.ts | 4 ++ tests/verify.ts | 2 +- 7 files changed, 84 insertions(+), 9 deletions(-) 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/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();