Skip to content

Commit ce20eae

Browse files
committed
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
1 parent 7061456 commit ce20eae

File tree

7 files changed

+84
-9
lines changed

7 files changed

+84
-9
lines changed

src/command/render/project.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -886,13 +886,20 @@ export async function renderProject(
886886
);
887887
}
888888

889-
// in addition to the cleanup above, if forceClean is set, we need to clean up the project scratch dir
890-
// entirely. See options.forceClean in render-shared.ts
891-
// .quarto is really a fiction created because of `--output-dir` being set on non-project
892-
// renders
889+
// Clean up synthetic project created for --output-dir
890+
// When --output-dir is used without a project file, we create a temporary
891+
// project context with a .quarto directory (see render-shared.ts).
892+
// After rendering completes, we must remove this directory to avoid leaving
893+
// debris in non-project directories (#9745).
893894
//
894-
// cf https://github.com/quarto-dev/quarto-cli/issues/9745#issuecomment-2125951545
895+
// Critical ordering for Windows: Close file handles BEFORE removing directory
896+
// to avoid "The process cannot access the file because it is being used by
897+
// another process" (os error 32) (#13625).
895898
if (projectRenderConfig.options.forceClean) {
899+
// 1. Close all file handles (KV database, temp context, etc.)
900+
context.cleanup();
901+
902+
// 2. Remove the temporary .quarto directory
896903
const scratchDir = join(projDir, kQuartoScratch);
897904
if (existsSync(scratchDir)) {
898905
safeRemoveSync(scratchDir, { recursive: true });

src/command/render/render-shared.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,14 @@ export async function render(
4848
// determine target context/files
4949
let context = await projectContext(path, nbContext, options);
5050

51-
// if there is no project parent and an output-dir was passed, then force a project
51+
// Create a synthetic project when --output-dir is used without a project file
52+
// This creates a temporary .quarto directory to manage the render, which must
53+
// be fully cleaned up afterward to avoid leaving debris (see #9745)
5254
if (!context && options.flags?.outputDir) {
53-
// recompute context
5455
context = await projectContextForDirectory(path, nbContext, options);
5556

56-
// force clean as --output-dir implies fully overwrite the target
57+
// forceClean signals this is a synthetic project that needs full cleanup
58+
// including removing the .quarto scratch directory after rendering (#13625)
5759
options.forceClean = options.flags.clean !== false;
5860
}
5961

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/.quarto/
2+
**/*.quarto_ipynb
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
title: "Test Output Dir"
3+
format: html
4+
---
5+
6+
This is a simple document to test rendering with --output-dir flag.
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
* render-output-dir.test.ts
3+
*
4+
* Test for Windows file locking issue with --output-dir flag
5+
* Regression test for: https://github.com/quarto-dev/quarto-cli/issues/13625
6+
*
7+
* Copyright (C) 2020-2025 Posit Software, PBC
8+
*
9+
*/
10+
import { existsSync, safeRemoveSync } from "../../../src/deno_ral/fs.ts";
11+
import { docs } from "../../utils.ts";
12+
import { isWindows } from "../../../src/deno_ral/platform.ts";
13+
import { fileExists, pathDoNotExists } from "../../verify.ts";
14+
import { testRender } from "./render.ts";
15+
import type { Verify } from "../../test.ts";
16+
17+
18+
const inputDir = docs("render-output-dir/");
19+
const quartoDir = ".quarto";
20+
const outputDir = "output-test-dir";
21+
22+
const cleanupDirs = async () => {
23+
if (existsSync(outputDir)) {
24+
safeRemoveSync(outputDir, { recursive: true });
25+
}
26+
if (existsSync(quartoDir)) {
27+
safeRemoveSync(quartoDir, { recursive: true });
28+
}
29+
};
30+
31+
const testOutputDirRender = (
32+
quartoVerify: Verify,
33+
extraArgs: string[] = [],
34+
) => {
35+
testRender(
36+
"test.qmd",
37+
"html",
38+
false,
39+
[quartoVerify],
40+
{
41+
cwd: () => inputDir,
42+
setup: cleanupDirs,
43+
teardown: cleanupDirs,
44+
},
45+
["--output-dir", outputDir, ...extraArgs],
46+
outputDir,
47+
);
48+
};
49+
50+
// Test 1: Default behavior (clean=true) - .quarto should be removed
51+
testOutputDirRender(pathDoNotExists(quartoDir));
52+
53+
// Test 2: With --no-clean flag - .quarto should be preserved
54+
testOutputDirRender(fileExists(quartoDir), ["--no-clean"]);

tests/utils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,9 +146,13 @@ export function outputForInput(
146146

147147
const outputPath: string = projectRoot && projectOutDir !== undefined
148148
? join(projectRoot, projectOutDir, dir, `${stem}.${outputExt}`)
149+
: projectOutDir !== undefined
150+
? join(projectOutDir, dir, `${stem}.${outputExt}`)
149151
: join(dir, `${stem}.${outputExt}`);
150152
const supportPath: string = projectRoot && projectOutDir !== undefined
151153
? join(projectRoot, projectOutDir, dir, `${stem}_files`)
154+
: projectOutDir !== undefined
155+
? join(projectOutDir, dir, `${stem}_files`)
152156
: join(dir, `${stem}_files`);
153157

154158
return {

tests/verify.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ export const fileExists = (file: string): Verify => {
236236

237237
export const pathDoNotExists = (path: string): Verify => {
238238
return {
239-
name: `path ${path} exists`,
239+
name: `path ${path} do not exists`,
240240
verify: (_output: ExecuteOutput[]) => {
241241
verifyNoPath(path);
242242
return Promise.resolve();

0 commit comments

Comments
 (0)