From cc4984b0daa90ab6c38ea9b5ff1380bc08047d8e Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 23 Oct 2026 12:17:05 +0200 Subject: [PATCH 1/4] Keep the Sass cache store per V8 release branch Deno 2.9 writes V8 serializer format 16 (2.7 writes 15), and an older V8 rejects newer formats, so a Quarto on Deno 2.9 poisons the shared sass.kv for every older Quarto on the machine (RangeError: could not deserialize value). Open the store and write the compiled CSS under v8-. inside the requested cache dir; the untagged files are never touched, so older versions keep working. The registry stays keyed by the requested path, so cleanup passes that key explicitly (#14594, #13955). --- src/core/sass/cache.ts | 18 +++++++++------ src/deno_ral/platform.ts | 4 ++++ tests/unit/sass-cache.test.ts | 42 +++++++++++++++++++++++++++++++++-- 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/core/sass/cache.ts b/src/core/sass/cache.ts index dd3790eb51c..53959dad64b 100644 --- a/src/core/sass/cache.ts +++ b/src/core/sass/cache.ts @@ -10,6 +10,7 @@ import { InternalError } from "../lib/error.ts"; import { md5HashAsync } from "../hash.ts"; import { join } from "../../deno_ral/path.ts"; import { ensureDirSync, existsSync } from "../../deno_ral/fs.ts"; +import { v8Branch } from "../../deno_ral/platform.ts"; import { TempContext } from "../temp.ts"; import { safeRemoveIfExists } from "../path.ts"; import * as log from "../../deno_ral/log.ts"; @@ -143,7 +144,9 @@ class SassCache implements Cloneable { } // add a cleanup method to register a cleanup handler - cleanup(temp: TempContext | undefined) { + // registryKey is the path the cache was requested with, which differs from + // this.path (the per-V8-branch store dir under it). + cleanup(temp: TempContext | undefined, registryKey: string) { const registerCleanup = temp ? temp.onCleanup : onCleanup; const cachePath = this.path; registerCleanup(() => { @@ -158,7 +161,7 @@ class SassCache implements Cloneable { } finally { // Drop the registry entry so a later resolve of the same path opens a // fresh KV handle instead of reusing this now-closed one (#14594, #13955). - delete _sassCache[cachePath]; + delete _sassCache[registryKey]; } }); } @@ -205,14 +208,15 @@ export async function sassCache( temp: TempContext | undefined, ): Promise { if (!_sassCache[path]) { - log.debug(`Creating SassCache at ${path}`); - ensureDirSync(path); - const kvFile = join(path, "sass.kv"); + const storeDir = join(path, `v8-${v8Branch}`); + log.debug(`Creating SassCache at ${storeDir}`); + ensureDirSync(storeDir); + const kvFile = join(storeDir, "sass.kv"); const kv = await Deno.openKv(kvFile); await checkVersion(kv, kvFile); - _sassCache[path] = new SassCache(kv, path); + _sassCache[path] = new SassCache(kv, storeDir); // register cleanup for this cache - _sassCache[path].cleanup(temp); + _sassCache[path].cleanup(temp, path); } log.debug(`Returning SassCache at ${path}`); const result = _sassCache[path]; diff --git a/src/deno_ral/platform.ts b/src/deno_ral/platform.ts index 62a1b422f2c..95cb0de7cc9 100644 --- a/src/deno_ral/platform.ts +++ b/src/deno_ral/platform.ts @@ -11,3 +11,7 @@ export const isLinux = Deno.build.os === "linux"; export const os = Deno.build.os; export const arch = Deno.build.arch; + +// Deno KV stores are kept per V8 release branch (".") because +// the V8 serializer format can change between branches. +export const v8Branch = Deno.version.v8.split(".").slice(0, 2).join("."); diff --git a/tests/unit/sass-cache.test.ts b/tests/unit/sass-cache.test.ts index 6f93363b4df..6a26f690287 100644 --- a/tests/unit/sass-cache.test.ts +++ b/tests/unit/sass-cache.test.ts @@ -10,11 +10,14 @@ */ import { unitTest } from "../test.ts"; -import { assertEquals } from "testing/asserts"; -import { join } from "../../src/deno_ral/path.ts"; +import { assert, assertEquals, assertMatch } from "testing/asserts"; +import { dirname, join } from "../../src/deno_ral/path.ts"; +import { ensureDirSync, existsSync } from "../../src/deno_ral/fs.ts"; +import { v8Branch } from "../../src/deno_ral/platform.ts"; import { safeRemoveIfExists } from "../../src/core/path.ts"; import { createTempContext, TempContext } from "../../src/core/temp.ts"; import { sassCache } from "../../src/core/sass/cache.ts"; +import { withTempDir } from "../utils.ts"; // A syntactically valid md5 hash that will never be present in a fresh cache, // so getFromHash exercises kv.get() and returns a clean cache miss (null). @@ -56,3 +59,38 @@ unitTest( } }, ); + +// Bytes an older Quarto's store could hold; not a SQLite file, so opening it +// with Deno.openKv throws "file is not a database". +const LEGACY_SENTINEL = "legacy sass.kv owned by another Quarto version"; +const ID_HASH = "a".repeat(32); +const INPUT_HASH = "1".repeat(32); + +unitTest( + "sassCache - stores index and outputs under v8- and never opens a legacy sass.kv", + async () => { + await withTempDir(async (root) => { + const cachePath = join(root, "sass"); + ensureDirSync(cachePath); + const legacy = join(cachePath, "sass.kv"); + Deno.writeTextFileSync(legacy, LEGACY_SENTINEL); + + const temp = createTempContext(); + try { + const cache = await sassCache(cachePath, temp); + const out = await cache.setFromHash(ID_HASH, INPUT_HASH, "id", (p) => { + Deno.writeTextFileSync(p, "/* css */"); + return Promise.resolve(); + }); + assertMatch(v8Branch, /^\d+\.\d+$/); + const storeDir = join(cachePath, `v8-${v8Branch}`); + assertEquals(dirname(out), storeDir); + assert(existsSync(join(storeDir, "sass.kv"))); + assertEquals(await cache.getFromHash(ID_HASH, INPUT_HASH), out); + assertEquals(Deno.readTextFileSync(legacy), LEGACY_SENTINEL); + } finally { + temp.cleanup(); + } + }, "quarto-sass-cache-v8"); + }, +); From 1310f786b4c1dc6179651a5ef61e2bb095fdeb2d Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 23 Oct 2026 12:19:16 +0200 Subject: [PATCH 2/4] Keep the project cache store per V8 release branch Same cause as the Sass cache: Deno 2.9 writes V8 serializer format 16 and an older V8 rejects it, so a shared .quarto/project-cache/deno-kv-file written by a newer Quarto breaks every older Quarto on the project. Put the index and the content-addressed files under project-cache/v8-.. The untagged files stay untouched for older versions. --- src/core/cache/cache.ts | 34 ++++++++++------------- tests/unit/project-cache.test.ts | 46 ++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 20 deletions(-) create mode 100644 tests/unit/project-cache.test.ts diff --git a/src/core/cache/cache.ts b/src/core/cache/cache.ts index c32e6f42df0..528750c0ef4 100644 --- a/src/core/cache/cache.ts +++ b/src/core/cache/cache.ts @@ -9,6 +9,7 @@ import { assert } from "testing/asserts"; import { ensureDirSync } from "../../deno_ral/fs.ts"; import { join } from "../../deno_ral/path.ts"; +import { v8Branch } from "../../deno_ral/platform.ts"; import { md5HashBytes } from "../hash.ts"; import { satisfies } from "semver/mod.ts"; import { quartoConfig } from "../quarto.ts"; @@ -29,10 +30,16 @@ const requiredQuartoVersions: Record = { class ProjectCacheImpl implements Cloneable { projectScratchDir: string; + storeDir: string; index: Deno.Kv | null; constructor(_projectScratchDir: string) { this.projectScratchDir = _projectScratchDir; + this.storeDir = join( + _projectScratchDir, + "project-cache", + `v8-${v8Branch}`, + ); this.index = null; } @@ -53,9 +60,7 @@ class ProjectCacheImpl implements Cloneable { for await (const entry of entries) { const diskValue = entry.value; if (entry.key[0] !== "version") { - Deno.removeSync( - join(this.projectScratchDir, "project-cache", diskValue as string), - ); + Deno.removeSync(join(this.storeDir, diskValue as string)); } await this.index.delete(entry.key); } @@ -67,9 +72,8 @@ class ProjectCacheImpl implements Cloneable { } async init(): Promise { - const indexPath = join(this.projectScratchDir, "project-cache"); - ensureDirSync(indexPath); - this.index = await Deno.openKv(join(indexPath, "deno-kv-file")); + ensureDirSync(this.storeDir); + this.index = await Deno.openKv(join(this.storeDir, "deno-kv-file")); let version = await this.index.get(["version"]); if (version.value === null) { await this.createOnDisk(); @@ -93,10 +97,7 @@ class ProjectCacheImpl implements Cloneable { async addBuffer(key: string[], value: Uint8Array): Promise { assert(this.index); const hash = await md5HashBytes(value); - Deno.writeFileSync( - join(this.projectScratchDir, "project-cache", hash), - value, - ); + Deno.writeFileSync(join(this.storeDir, hash), value); const result = await this.index.set(key, { hash, type: "buffer", @@ -108,10 +109,7 @@ class ProjectCacheImpl implements Cloneable { assert(this.index); const buffer = new TextEncoder().encode(value); const hash = await md5HashBytes(buffer); - Deno.writeTextFileSync( - join(this.projectScratchDir, "project-cache", hash), - value, - ); + Deno.writeTextFileSync(join(this.storeDir, hash), value); const result = await this.index.set(key, { hash, type: "string", @@ -187,9 +185,7 @@ class ProjectCacheImpl implements Cloneable { return this._getAs(key, (result) => { assert(result.type === "buffer", "Expected buffer"); const { hash } = result as DiskCacheEntry; - return Deno.readFileSync( - join(this.projectScratchDir, "project-cache", hash), - ); + return Deno.readFileSync(join(this.storeDir, hash)); }); } @@ -197,9 +193,7 @@ class ProjectCacheImpl implements Cloneable { return this._getAs(key, (result) => { assert(result.type === "string", "Expected string"); const { hash } = result as DiskCacheEntry; - return Deno.readTextFileSync( - join(this.projectScratchDir, "project-cache", hash), - ); + return Deno.readTextFileSync(join(this.storeDir, hash)); }); } diff --git a/tests/unit/project-cache.test.ts b/tests/unit/project-cache.test.ts new file mode 100644 index 00000000000..c105874831c --- /dev/null +++ b/tests/unit/project-cache.test.ts @@ -0,0 +1,46 @@ +/* + * project-cache.test.ts + * + * Tests that the project cache keeps its store per V8 release branch. + * + * Copyright (C) 2026 Posit Software, PBC + */ + +import { unitTest } from "../test.ts"; +import { assert, assertEquals } from "../asserts.ts"; +import { join } from "../../src/deno_ral/path.ts"; +import { ensureDirSync, existsSync } from "../../src/deno_ral/fs.ts"; +import { v8Branch } from "../../src/deno_ral/platform.ts"; +import { md5HashBytes } from "../../src/core/hash.ts"; +import { createProjectCache } from "../../src/core/cache/cache.ts"; +import { withTempDir } from "../utils.ts"; + +const LEGACY_SENTINEL = "legacy deno-kv-file owned by another Quarto version"; + +unitTest( + "projectCache - stores index and content under v8- and never opens a legacy deno-kv-file", + async () => { + await withTempDir(async (scratch) => { + const legacyDir = join(scratch, "project-cache"); + ensureDirSync(legacyDir); + const legacy = join(legacyDir, "deno-kv-file"); + Deno.writeTextFileSync(legacy, LEGACY_SENTINEL); + + const cache = await createProjectCache(scratch); + try { + await cache.addString(["k"], "cached value"); + assertEquals(await cache.getString(["k"]), "cached value"); + const storeDir = join(legacyDir, `v8-${v8Branch}`); + const hash = await md5HashBytes( + new TextEncoder().encode("cached value"), + ); + assert(existsSync(join(storeDir, "deno-kv-file"))); + assert(existsSync(join(storeDir, hash))); + assert(!existsSync(join(legacyDir, hash))); + } finally { + cache.close(); + } + assertEquals(Deno.readTextFileSync(legacy), LEGACY_SENTINEL); + }, "quarto-project-cache-v8"); + }, +); From cdddb4a0a6e2b058d6c2d283e9017f29f28b1daa Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 23 Oct 2026 12:20:07 +0200 Subject: [PATCH 3/4] Accept the cache tests' Deno file calls in the runtime boundary baseline The new cache tests write and read legacy sentinel files and open a KV store to prove the untagged stores stay untouched. --- tools/runtime-boundary-baseline.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/runtime-boundary-baseline.json b/tools/runtime-boundary-baseline.json index 315f1c2eb47..ddee720fb9a 100644 --- a/tools/runtime-boundary-baseline.json +++ b/tools/runtime-boundary-baseline.json @@ -16,7 +16,7 @@ "stdImportFiles": 3 }, "tests": { - "denoCalls": 832, + "denoCalls": 838, "stdImportFiles": 177 } } From fcf607856800c45a9a11d5afd3b9494af5785044 Mon Sep 17 00:00:00 2001 From: Christophe Dervieux Date: Fri, 23 Oct 2026 12:39:26 +0200 Subject: [PATCH 4/4] Clear populated project caches without throwing clear() passed each index entry's value to join() as if it were a hash string, but disk entries store {hash, type} and immediate entries store {value, type}. join() threw "Path must be a string", so any populated cache made clear() fail. The only caller is init() when the stored cache version is unknown, i.e. a cache written by a newer Quarto. That is the cross-version case this branch is about: an older Quarto would crash there instead of clearing the cache. The bug dates from the cache's introduction in 60294d07f. clear() now removes the hash file only for disk entries, and drops immediate entries from the index alone. It uses safeRemoveSync because entries with identical content share one hash file, so the second removal finds it gone. That replaces a raw Deno call, so the src count in the runtime boundary baseline drops by one. --- src/core/cache/cache.ts | 14 ++++++++---- tests/unit/project-cache.test.ts | 33 ++++++++++++++++++++++++++++ tools/runtime-boundary-baseline.json | 2 +- 3 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/core/cache/cache.ts b/src/core/cache/cache.ts index 528750c0ef4..3d3ffc7a46e 100644 --- a/src/core/cache/cache.ts +++ b/src/core/cache/cache.ts @@ -7,7 +7,7 @@ */ import { assert } from "testing/asserts"; -import { ensureDirSync } from "../../deno_ral/fs.ts"; +import { ensureDirSync, safeRemoveSync } from "../../deno_ral/fs.ts"; import { join } from "../../deno_ral/path.ts"; import { v8Branch } from "../../deno_ral/platform.ts"; import { md5HashBytes } from "../hash.ts"; @@ -58,9 +58,15 @@ class ProjectCacheImpl implements Cloneable { assert(this.index); const entries = this.index.list({ prefix: [] }); for await (const entry of entries) { - const diskValue = entry.value; - if (entry.key[0] !== "version") { - Deno.removeSync(join(this.storeDir, diskValue as string)); + const value = entry.value as CacheIndexEntry; + // immediate entries live in the index only; disk entries with + // identical content share one hash file + if ( + entry.key[0] !== "version" && + (value.type === "buffer" || value.type === "string") + ) { + const { hash } = value as DiskCacheEntry; + safeRemoveSync(join(this.storeDir, hash)); } await this.index.delete(entry.key); } diff --git a/tests/unit/project-cache.test.ts b/tests/unit/project-cache.test.ts index c105874831c..7ed72847b50 100644 --- a/tests/unit/project-cache.test.ts +++ b/tests/unit/project-cache.test.ts @@ -44,3 +44,36 @@ unitTest( }, "quarto-project-cache-v8"); }, ); + +unitTest( + "projectCache - clear() removes disk and immediate entries of a populated cache", + async () => { + await withTempDir(async (scratch) => { + const cache = await createProjectCache(scratch); + try { + await cache.addString(["string"], "shared content"); + await cache.addString(["same-content"], "shared content"); + await cache.addBuffer(["buffer"], new Uint8Array([1, 2, 3])); + await cache.addSmallString(["small-string"], "small"); + await cache.addSmallBuffer(["small-buffer"], new Uint8Array([4])); + const storeDir = join(scratch, "project-cache", `v8-${v8Branch}`); + const stringHash = await md5HashBytes( + new TextEncoder().encode("shared content"), + ); + const bufferHash = await md5HashBytes(new Uint8Array([1, 2, 3])); + + await cache.clear(); + + assertEquals(await cache.get(["string"]), null); + assertEquals(await cache.get(["same-content"]), null); + assertEquals(await cache.get(["buffer"]), null); + assertEquals(await cache.get(["small-string"]), null); + assertEquals(await cache.get(["small-buffer"]), null); + assert(!existsSync(join(storeDir, stringHash))); + assert(!existsSync(join(storeDir, bufferHash))); + } finally { + cache.close(); + } + }, "quarto-project-cache-clear"); + }, +); diff --git a/tools/runtime-boundary-baseline.json b/tools/runtime-boundary-baseline.json index ddee720fb9a..2810b910744 100644 --- a/tools/runtime-boundary-baseline.json +++ b/tools/runtime-boundary-baseline.json @@ -1,6 +1,6 @@ { "src": { - "denoCalls": 994, + "denoCalls": 993, "stdImportFiles": 49 }, "src-js": {