diff --git a/_extensions/quarto-ext/shinylive/shinylive.lua b/_extensions/quarto-ext/shinylive/shinylive.lua index a8ce817..1e8f970 100644 --- a/_extensions/quarto-ext/shinylive/shinylive.lua +++ b/_extensions/quarto-ext/shinylive/shinylive.lua @@ -40,16 +40,153 @@ function throw_quarto_error(err_msg, ...) assert(false, err_msg .. "\n") end +-- ===== Shinylive subprocess result cache ===================================== +-- Memoizes subprocess results to disk so they are shared across documents +-- within a full-project render. Without this, every document re-runs several +-- identical subprocesses (`extension info`, `extension base-htmldeps`, +-- `extension language-resources`, plus two per code block), and their +-- interpreter startup cost dominates large-site render times. +-- +-- Results are only cached once the live version of the tool that produced +-- them is known (see the `keySuffix` parameter of `cachedPipe`): every cache +-- key embeds that version, so upgrading the shinylive package (or quarto) +-- naturally invalidates the cache and stale reuse is impossible. +-- +-- The cache is strictly an optimization: on any cache failure, the real +-- subprocess is run. + +local pipeCacheState = nil -- nil = uninitialized, false = disabled, table = ready + +local function initPipeCache() + if pipeCacheState ~= nil then + return pipeCacheState + end + local ok, result = pcall(function() + local projDir = quarto.project.directory or os.getenv("QUARTO_PROJECT_DIR") + if projDir == nil then + return false + end + local dir = tostring(projDir) .. "/.quarto/shinylive-cache" + pandoc.system.make_directory(dir, true) + return { dir = dir } + end) + if ok and result then + pipeCacheState = result + else + pipeCacheState = false + end + return pipeCacheState +end + +local function pipeCacheKey(command, args, input, keySuffix) + return pandoc.utils.sha1( + command .. "\1" .. table.concat(args, "\1") .. "\1" .. input .. "\1" .. keySuffix + ) +end + +local function pipeCacheRead(cache, key) + local f = io.open(cache.dir .. "/" .. key, "rb") + if f == nil then + return nil + end + local contents = f:read("*a") + f:close() + return contents +end + +-- Some subprocess results embed absolute paths to files elsewhere on disk, +-- and running the subprocess is what creates those files: notably +-- `extension base-htmldeps` downloads the shinylive web assets to the user +-- cache dir as a side effect. Only serve a hit whose referenced files all +-- still exist; otherwise re-run the subprocess so it can re-create them. +-- (Matches POSIX absolute paths in "source"/"path" JSON fields; on Windows +-- nothing matches, so hits are served unvalidated — same as no check.) +local function pipeCacheHitUsable(hit) + for _, field in ipairs({ "source", "path" }) do + for path in hit:gmatch('"' .. field .. '":%s*"(/[^"]+)"') do + local f = io.open(path, "r") + if f == nil then + return false + end + f:close() + end + end + return true +end + +local function pipeCacheWrite(cache, key, value) + -- Write to a temp file then rename: atomic on POSIX, so a concurrent + -- render (e.g. quarto preview) can never observe a partial entry. + local path = cache.dir .. "/" .. key + local tmpPath = path .. ".tmp" .. tostring(math.random(1000000000)) + local f = io.open(tmpPath, "wb") + if f == nil then + return + end + f:write(value) + f:close() + os.rename(tmpPath, path) +end + +local pipeMemo = {} -- in-process memo: repeat calls skip hashing and disk I/O + +-- Drop-in replacement for `pandoc.pipe()` that memoizes successful results +-- to disk. `keySuffix` must encode the live version of the tool being run +-- (and anything else the result depends on besides command/args/input); pass +-- nil to skip caching entirely — used for the version-discovery calls +-- themselves, which therefore still run once per document and keep the cache +-- honest. Failed subprocess calls are never cached (pandoc.pipe raises, so +-- the error propagates to the caller exactly as before). +local function cachedPipe(command, args, input, keySuffix) + if keySuffix == nil then + return pandoc.pipe(command, args, input) + end + local cache = initPipeCache() + if cache == false then + return pandoc.pipe(command, args, input) + end + + local keyOk, key = pcall(pipeCacheKey, command, args, input, keySuffix) + if not keyOk then + return pandoc.pipe(command, args, input) + end + + if pipeMemo[key] ~= nil then + return pipeMemo[key] + end + + local hitOk, hit = pcall(pipeCacheRead, cache, key) + if hitOk and hit ~= nil then + local usableOk, usable = pcall(pipeCacheHitUsable, hit) + if usableOk and usable then + pipeMemo[key] = hit + return hit + end + end + + local res = pandoc.pipe(command, args, input) + pipeMemo[key] = res + pcall(pipeCacheWrite, cache, key, res) + return res +end +-- ===== End shinylive subprocess result cache ================================= + -- Python specific method to call py-shinylive -- @param args: list of string arguments to pass to py-shinylive -- @param input: string to pipe into to py-shinylive function callPythonShinylive(args, input) -- Try calling `pandoc.pipe('shinylive', ...)` and if it fails, print a message -- about installing shinylive python package. + -- Cache results once the live package version is known; the `--version` + -- discovery call itself runs before that and is never cached. + local keySuffix = nil + if pyShinyliveVersion ~= nil then + keySuffix = "py-shinylive-" .. pyShinyliveVersion + end local res local status, err = pcall( function() - res = pandoc.pipe("shinylive", args, input) + res = cachedPipe("shinylive", args, input, keySuffix) end ) @@ -75,10 +212,16 @@ function callRShinylive(args, input) -- Try calling `pandoc.pipe('Rscript', ...)` and if it fails, print a message -- about installing shinylive R package. + -- Cache results once the live package version is known; the initial + -- `extension info` call that discovers it is never cached. + local keySuffix = nil + if versions.r ~= nil then + keySuffix = "r-shinylive-" .. versions.r.version + end local res local status, err = pcall( function() - res = pandoc.pipe("Rscript", args, input) + res = cachedPipe("Rscript", args, input, keySuffix) end ) @@ -424,10 +567,19 @@ return { ensureLanguageSetup(language) -- Convert code block to JSON string in the same format as app.json. - local parsedCodeblockJson = pandoc.pipe( + -- The result depends on the codeblock-to-json script (which ships with + -- the language's shinylive package) and the quarto CLI that runs it, so + -- key the cache on both of their versions. + local codeblockKeySuffix = nil + if versions[language] ~= nil then + codeblockKeySuffix = language .. "-shinylive-" .. versions[language].version .. + "\1quarto-" .. tostring(quarto.version) + end + local parsedCodeblockJson = cachedPipe( quarto_cli_path, { "run", codeblockScript, language }, - el.text + el.text, + codeblockKeySuffix ) -- This contains "files" and "quartoArgs" keys.