From 4a1721ff4c5e1dbcfdcdaf55eea96f26e0aa43b2 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 15 Apr 2026 17:25:50 +0100 Subject: [PATCH 1/7] Add fileNotExists verification and smoke-all test for keep-ipynb cleanup - Add `fileNotExists` to verify.ts, mirroring `fileExists` but calling `verifyNoPath` instead of `verifyPath` - Wire `fileNotExists` into smoke-all.test.ts verifyMap handling block alongside the existing `fileExists` block - Add smoke-all test 14359.qmd that verifies the .quarto_ipynb intermediate file is cleaned up after render when keep-ipynb defaults to false --- .../2025/05/21/keep_ipynb_single-file/14359.qmd | 12 ++++++++++++ tests/smoke/smoke-all.test.ts | 17 +++++++++++++++++ tests/verify.ts | 10 ++++++++++ 3 files changed, 39 insertions(+) create mode 100644 tests/docs/smoke-all/2025/05/21/keep_ipynb_single-file/14359.qmd diff --git a/tests/docs/smoke-all/2025/05/21/keep_ipynb_single-file/14359.qmd b/tests/docs/smoke-all/2025/05/21/keep_ipynb_single-file/14359.qmd new file mode 100644 index 00000000000..a326a40008b --- /dev/null +++ b/tests/docs/smoke-all/2025/05/21/keep_ipynb_single-file/14359.qmd @@ -0,0 +1,12 @@ +--- +format: html +_quarto: + tests: + html: + fileNotExists: + outputPath: 14359.quarto_ipynb +--- + +```{python} +1 + 1 +``` diff --git a/tests/smoke/smoke-all.test.ts b/tests/smoke/smoke-all.test.ts index af7169d0f93..65b4b08f5f3 100644 --- a/tests/smoke/smoke-all.test.ts +++ b/tests/smoke/smoke-all.test.ts @@ -34,6 +34,7 @@ import { ensureTypstFileRegexMatches, ensureSnapshotMatches, fileExists, + fileNotExists, noErrors, noErrorsOrWarnings, ensurePptxXpath, @@ -269,6 +270,22 @@ function resolveTestSpecs( ); } } + } else if (key === "fileNotExists") { + for ( + const [path, file] of Object.entries( + value as Record, + ) + ) { + if (path === "outputPath") { + verifyFns.push( + fileNotExists(join(dirname(outputFile.outputPath), file)), + ); + } else if (path === "supportPath") { + verifyFns.push( + fileNotExists(join(outputFile.supportPath, file)), + ); + } + } } else if (["ensurePptxLayout", "ensurePptxXpath"].includes(key)) { if (Array.isArray(value) && Array.isArray(value[0])) { // several slides to check diff --git a/tests/verify.ts b/tests/verify.ts index 622120b271d..a59761dfb36 100644 --- a/tests/verify.ts +++ b/tests/verify.ts @@ -235,6 +235,16 @@ export const fileExists = (file: string): Verify => { }; }; +export const fileNotExists = (file: string): Verify => { + return { + name: `File ${file} does not exist`, + verify: (_output: ExecuteOutput[]) => { + verifyNoPath(file); + return Promise.resolve(); + }, + }; +}; + export const pathDoNotExists = (path: string): Verify => { return { name: `path ${path} do not exists`, From 6fc1008d8a1a91115500e3a64de378569153401a Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 15 Apr 2026 17:32:39 +0100 Subject: [PATCH 2/7] Restore immediate deletion of transient .quarto_ipynb after execution When keep-ipynb is false (the default), cleanupNotebook() now calls safeRemoveSync() to delete the intermediate .quarto_ipynb file immediately after execution, restoring the original behavior from 2021 that was inadvertently dropped when keep-ipynb support was added in PR #12793. --- src/execute/jupyter/jupyter.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/execute/jupyter/jupyter.ts b/src/execute/jupyter/jupyter.ts index 378515454cb..99877efce09 100644 --- a/src/execute/jupyter/jupyter.ts +++ b/src/execute/jupyter/jupyter.ts @@ -7,7 +7,7 @@ import { basename, dirname, join, relative } from "../../deno_ral/path.ts"; import { satisfies } from "semver/mod.ts"; -import { existsSync } from "../../deno_ral/fs.ts"; +import { existsSync, safeRemoveSync } from "../../deno_ral/fs.ts"; import { error, info } from "../../deno_ral/log.ts"; @@ -870,6 +870,8 @@ function cleanupNotebook( if (cached.target && cached.target.data) { (cached.target.data as JupyterTargetData).transient = false; } + } else if (data.transient) { + safeRemoveSync(target.input); } } From 90c02689502232129fd7eaec478f6be404d320bd Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 15 Apr 2026 20:50:55 +0100 Subject: [PATCH 3/7] Add manual test doc for .quarto_ipynb cleanup on ungraceful termination --- .../14359-positron-kill-quarto-ipynb.md | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 tests/docs/manual/preview/14359-positron-kill-quarto-ipynb.md diff --git a/tests/docs/manual/preview/14359-positron-kill-quarto-ipynb.md b/tests/docs/manual/preview/14359-positron-kill-quarto-ipynb.md new file mode 100644 index 00000000000..c45c7ce2994 --- /dev/null +++ b/tests/docs/manual/preview/14359-positron-kill-quarto-ipynb.md @@ -0,0 +1,42 @@ +# Manual Test: .quarto_ipynb cleanup on ungraceful termination (#14359) + +## Purpose + +Verify that `.quarto_ipynb` files are cleaned up after render even when preview +is killed ungracefully (e.g., Positron terminal bin icon). This tests the fix +that restores immediate deletion in `cleanupNotebook()`. + +**Why manual?** Ungraceful termination bypasses normal cleanup handlers. +The fix ensures deletion happens right after execution, before any signal +could interrupt the process. + +## Automated coverage + +The smoke-all test `tests/docs/smoke-all/2025/05/21/keep_ipynb_single-file/14359.qmd` +verifies the core behavior (file deleted after render). This manual test +covers the interactive preview scenario. + +## Steps + +1. Open Positron (or VS Code with Quarto extension) +2. Open `tests/docs/manual/preview/14281-quarto-ipynb-accumulation.qmd` +3. Start Quarto Preview (terminal or Quarto extension) +4. Wait for first render to complete +5. Check directory: `ls *.quarto_ipynb*` — should be zero files (file was deleted after render) +6. Edit and save the file to trigger a re-render +7. After re-render completes, check again — still zero `.quarto_ipynb` files +8. Kill preview ungracefully (Positron terminal bin icon / close terminal) +9. Check directory — still zero `.quarto_ipynb` files + +## Expected + +- Zero `.quarto_ipynb` files at all times (the file is deleted immediately + after each execution, not deferred to cleanup handlers) +- Ungraceful termination does not leave stale files because deletion already + happened + +## Related + +- #14281 — within-session accumulation (fixed by PR #14287) +- #12780 — `keep-ipynb` support (PR #12793 introduced this regression) +- posit-dev/positron#13006 — Killing Quarto Preview should exit process From 465bd81e74932282aad841ec596353eb5c465731 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Wed, 15 Apr 2026 20:51:49 +0100 Subject: [PATCH 4/7] Note Positron extension fix for clean shutdown in manual test doc --- .../manual/preview/14359-positron-kill-quarto-ipynb.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/docs/manual/preview/14359-positron-kill-quarto-ipynb.md b/tests/docs/manual/preview/14359-positron-kill-quarto-ipynb.md index c45c7ce2994..68d4ea929e4 100644 --- a/tests/docs/manual/preview/14359-positron-kill-quarto-ipynb.md +++ b/tests/docs/manual/preview/14359-positron-kill-quarto-ipynb.md @@ -35,8 +35,16 @@ covers the interactive preview scenario. - Ungraceful termination does not leave stale files because deletion already happened +## Note on Positron fix + +Quarto extension v1.131.0 (posit-dev/positron#13006) now sends +`previewTerminateRequest()` when the terminal is closed, giving Quarto +a clean shutdown. Our fix (immediate deletion after execution) and their +fix (clean shutdown signal) are complementary — either alone prevents +stale files, both together provide defense in depth. + ## Related - #14281 — within-session accumulation (fixed by PR #14287) - #12780 — `keep-ipynb` support (PR #12793 introduced this regression) -- posit-dev/positron#13006 — Killing Quarto Preview should exit process +- posit-dev/positron#13006 — Killing Quarto Preview should exit process (fixed in extension v1.131.0) From 0cadc5e83cfb6641db7107bf28f1a3f6e0577be4 Mon Sep 17 00:00:00 2001 From: christophe dervieux Date: Thu, 16 Apr 2026 14:43:38 +0100 Subject: [PATCH 5/7] Fix typst package staging to use input directory instead of file path stageTypstPackages and collectPackageSources received the execution target input path (which is the transient .quarto_ipynb for Jupyter targets). These functions only need the directory for extension discovery via inputExtensionDirs. Using the file path caused a NotFound error when the transient notebook was already cleaned up. Pass inputDir (already computed by dirAndStem) instead of input. --- src/command/render/output-typst.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/command/render/output-typst.ts b/src/command/render/output-typst.ts index 9802327b04f..ae7c3a59486 100644 --- a/src/command/render/output-typst.ts +++ b/src/command/render/output-typst.ts @@ -62,7 +62,7 @@ export interface NeededPackage { // Collect all package source directories (built-in + extensions) async function collectPackageSources( - input: string, + inputDir: string, projectDir: string, ): Promise { const sources: string[] = []; @@ -74,7 +74,7 @@ async function collectPackageSources( } // 2. Extension packages - const extensionDirs = inputExtensionDirs(input, projectDir); + const extensionDirs = inputExtensionDirs(inputDir, projectDir); const subtreePath = builtinSubtreeExtensions(); for (const extDir of extensionDirs) { const extensions = extDir === subtreePath @@ -173,7 +173,7 @@ export function stageAllPackages(sources: string[], cacheDir: string): void { // Stage typst packages to .quarto/typst-packages/ // First stages built-in packages, then extension packages (which can override) async function stageTypstPackages( - input: string, + inputDir: string, typstInput: string, projectDir?: string, ): Promise { @@ -181,7 +181,7 @@ async function stageTypstPackages( return undefined; } - const packageSources = await collectPackageSources(input, projectDir); + const packageSources = await collectPackageSources(inputDir, projectDir); if (packageSources.length === 0) { return undefined; } @@ -249,7 +249,7 @@ export function typstPdfOutputRecipe( // Stage extension typst packages const packagePath = await stageTypstPackages( - input, + inputDir, typstInput, project.dir, ); From 5c0e1fb7fc82f24dd4be6f12b7e65f4f03592e94 Mon Sep 17 00:00:00 2001 From: christophe dervieux Date: Thu, 16 Apr 2026 16:54:23 +0100 Subject: [PATCH 6/7] Add changelog entry for #14359 --- news/changelog-1.10.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/news/changelog-1.10.md b/news/changelog-1.10.md index 1131985c67d..01140ff78e6 100644 --- a/news/changelog-1.10.md +++ b/news/changelog-1.10.md @@ -51,4 +51,5 @@ All changes included in 1.10: - ([#6651](https://github.com/quarto-dev/quarto-cli/issues/6651)): Fix dart-sass compilation failing in enterprise environments where `.bat` files are blocked by group policy. - ([#14255](https://github.com/quarto-dev/quarto-cli/issues/14255)): Fix shortcodes inside inline and display math expressions not being resolved. -- ([#14342](https://github.com/quarto-dev/quarto-cli/issues/14342)): Work around TOCTOU race in Deno's `expandGlobSync` that can cause unexpected exceptions to be raised while traversing directories during project initialization. \ No newline at end of file +- ([#14342](https://github.com/quarto-dev/quarto-cli/issues/14342)): Work around TOCTOU race in Deno's `expandGlobSync` that can cause unexpected exceptions to be raised while traversing directories during project initialization. +- ([#14359](https://github.com/quarto-dev/quarto-cli/issues/14359)): Fix intermediate `.quarto_ipynb` file not being deleted after rendering a `.qmd` with Jupyter engine, causing numbered variants (`_1`, `_2`, ...) to accumulate on disk across renders. \ No newline at end of file From b8fd729d57a1ba88a4e23a94f11cd2de146cdd63 Mon Sep 17 00:00:00 2001 From: christophe dervieux Date: Thu, 16 Apr 2026 17:34:02 +0100 Subject: [PATCH 7/7] Refactor cleanupNotebook for clarity and fix keep-ipynb edge case MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure cleanupNotebook with early returns that separate three concerns: non-transient notebooks, keep-ipynb preservation, and transient deletion. Previously, when keep-ipynb: true was set but the fileInformationCache missed on target.source (e.g., path normalization mismatches or preview re-render invalidation), the else-if branch would still delete the file. The refactored logic checks keep-ipynb first and short-circuits before the cache lookup, so the user's explicit keep-ipynb: true is always honored regardless of cache state. Cache update remains an internal optimization for later project-level cleanup — not the authoritative signal for whether to delete. --- src/execute/jupyter/jupyter.ts | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/src/execute/jupyter/jupyter.ts b/src/execute/jupyter/jupyter.ts index 99877efce09..02f8c02cb2a 100644 --- a/src/execute/jupyter/jupyter.ts +++ b/src/execute/jupyter/jupyter.ts @@ -863,16 +863,26 @@ function cleanupNotebook( format: Format, project: EngineProjectContext, ) { - // Make notebook non-transient when keep-ipynb is set const data = target.data as JupyterTargetData; - const cached = project.fileInformationCache.get(target.source); - if (cached && data.transient && format.execute[kKeepIpynb]) { - if (cached.target && cached.target.data) { + + // Nothing to do for non-transient notebooks (user-authored .ipynb files) + if (!data.transient) { + return; + } + + // User wants to keep the notebook — do not delete. + // Also mark the cache entry (if any) as non-transient so later cache + // cleanup doesn't remove it. + if (format.execute[kKeepIpynb]) { + const cached = project.fileInformationCache.get(target.source); + if (cached && cached.target && cached.target.data) { (cached.target.data as JupyterTargetData).transient = false; } - } else if (data.transient) { - safeRemoveSync(target.input); + return; } + + // Otherwise the transient notebook is no longer needed; delete it. + safeRemoveSync(target.input); } interface JupyterTargetData {