Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion news/changelog-1.10.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- ([#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.
10 changes: 5 additions & 5 deletions src/command/render/output-typst.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]> {
const sources: string[] = [];
Expand All @@ -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
Expand Down Expand Up @@ -173,15 +173,15 @@ 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<string | undefined> {
if (!projectDir) {
return undefined;
}

const packageSources = await collectPackageSources(input, projectDir);
const packageSources = await collectPackageSources(inputDir, projectDir);
if (packageSources.length === 0) {
return undefined;
}
Expand Down Expand Up @@ -249,7 +249,7 @@ export function typstPdfOutputRecipe(

// Stage extension typst packages
const packagePath = await stageTypstPackages(
input,
inputDir,
typstInput,
project.dir,
);
Expand Down
22 changes: 17 additions & 5 deletions src/execute/jupyter/jupyter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -863,14 +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;
}
return;
}

// Otherwise the transient notebook is no longer needed; delete it.
safeRemoveSync(target.input);
}

interface JupyterTargetData {
Expand Down
50 changes: 50 additions & 0 deletions tests/docs/manual/preview/14359-positron-kill-quarto-ipynb.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# 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

## 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 (fixed in extension v1.131.0)
12 changes: 12 additions & 0 deletions tests/docs/smoke-all/2025/05/21/keep_ipynb_single-file/14359.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
format: html
_quarto:
tests:
html:
fileNotExists:
outputPath: 14359.quarto_ipynb
---

```{python}
1 + 1
```
17 changes: 17 additions & 0 deletions tests/smoke/smoke-all.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
ensureTypstFileRegexMatches,
ensureSnapshotMatches,
fileExists,
fileNotExists,
noErrors,
noErrorsOrWarnings,
ensurePptxXpath,
Expand Down Expand Up @@ -269,6 +270,22 @@ function resolveTestSpecs(
);
}
}
} else if (key === "fileNotExists") {
for (
const [path, file] of Object.entries(
value as Record<string, string>,
)
) {
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
Expand Down
10 changes: 10 additions & 0 deletions tests/verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
Loading