From b32aba389998ff6a00fee0f51a58551c1186e95a Mon Sep 17 00:00:00 2001 From: pyrex41 Date: Mon, 19 Oct 2026 06:38:59 -0500 Subject: [PATCH 1/2] fix(fasl): replay a nested (load) once on a warm hit (#92) When a recorded load ran another load from inside one of its chunks (a top-level (load ...) form, or a function that loads), splice_into_outer also copied the inner load's records into the outer fasl. On a hit the outer chunk re-ran the load and the spliced copy replayed it again, so the inner file's output printed twice on every warm run. Splice only when the outer span is not in a chunk, which is the case the splice exists for (a load whose effects no outer record reproduces). Deps are still propagated either way. Fixes #92 Co-Authored-By: Claude Opus 5.5 --- boot.lua | 13 ++++++++++--- test/cli_spec.lua | 49 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/boot.lua b/boot.lua index 65b0c8f..a2d1df1 100644 --- a/boot.lua +++ b/boot.lua @@ -1320,17 +1320,24 @@ local function install_fasl() -- on a miss — so the outer stream stays a complete description of everything -- the outer span did, in order. An inner recording that could not be -- serialized poisons the outer one for the same reason. + -- + -- Only a load made OUTSIDE the outer span's chunks has a hole to fill (one + -- run at macroexpansion time, say). A load made while one of the outer + -- chunks executes — a top-level (load ...) form, or a function that loads — + -- is already in the outer stream as that chunk, and replaying the chunk + -- runs the load again; splicing it as well replays it twice (issue #92). local function splice_into_outer(recs, uncacheable, deps) local outer = FASL_STACK[#FASL_STACK] if not outer then return end + if outer.deps and deps then + for _, d in ipairs(deps) do outer.deps[#outer.deps + 1] = d end + end + if outer.in_chunk then return end for _, r in ipairs(recs) do outer.n = outer.n + 1 outer[outer.n] = r end if uncacheable then outer.uncacheable = outer.uncacheable or uncacheable end - if outer.deps and deps then - for _, d in ipairs(deps) do outer.deps[#outer.deps + 1] = d end - end end local orig_load = F["load"] diff --git a/test/cli_spec.lua b/test/cli_spec.lua index 7c456c5..1660ef5 100644 --- a/test/cli_spec.lua +++ b/test/cli_spec.lua @@ -20,6 +20,7 @@ -- itself on 42), --hush-load / SHEN_HUSH_LOAD=1 (load's echo dropped, -- the program's own (output ...) kept), including that --hush-load output -- is byte-identical cold and on a warm fasl hit; +-- * a warm fasl hit runs a nested (load) once, not twice (issue #92); -- * shen.boot{hush_load = true}, the embedder spelling of that mode. -- -- Every subprocess is wrapped in `timeout` when available, so an EOF-loop @@ -370,6 +371,54 @@ do for _, d in ipairs{dq, dh, dd, de} do os.execute("rm -rf " .. sh_quote(d)) end end +-- --------------------------------------------------------------------------- +-- A warm fasl hit runs a nested (load) once (pyrex41/shen-lua#92). +-- A file that loads another file — from a top-level (load ...) form or from a +-- function that loads — used to replay the inner file's chunks twice on a hit: +-- once by re-running the outer chunk that calls load, and once more from a +-- copy of the inner records spliced into the outer fasl. Its output therefore +-- doubled on every warm run. The output must be the same cold, warm and with +-- SHEN_FASL=off. +-- --------------------------------------------------------------------------- +do + local fdir = os.tmpname(); os.remove(fdir) + local lib = os.tmpname() .. ".shen" + local h = io.open(lib, "w"); h:write('(output "LIB~%")\n'); h:close() + local function shen_file(body) + local p = os.tmpname() .. ".shen" + local fh = io.open(p, "w"); fh:write(body); fh:close() + return p + end + local ql = '"' .. lib .. '"' + local mains = { + ["top-level load"] = shen_file('(output "BEGIN~%")\n(load ' .. ql .. ')\n' + .. '(load ' .. ql .. ')\n(output "END~%")\n'), + ["load from a function"] = shen_file('(output "BEGIN~%")\n(load ' .. ql .. ')\n' + .. '(define fasl92-reload X -> (load X))\n(fasl92-reload ' .. ql .. ')\n' + .. '(output "END~%")\n'), + } + local want = "BEGIN\nLIB\nLIB\nEND\n" + for what, main in pairs(mains) do + os.execute("rm -rf " .. sh_quote(fdir)) + local cmd = SHEN .. " --hush-load " .. sh_quote(main) + local envp = "env SHEN_FASL_DIR=" .. sh_quote(fdir) .. " " + local cold, ccode = run(envp .. cmd) + local warm, wcode = run(envp .. "SHEN_FASL_DEBUG=1 " .. cmd) + local warm2 = run(envp .. cmd) + local off = run("env SHEN_FASL=off " .. cmd) + check(ccode == 0 and wcode == 0, "#92 " .. what .. ": cold and warm runs exit 0") + check(warm:find("[fasl] hit", 1, true) ~= nil, + "#92 " .. what .. ": second run is a fasl HIT") + warm = warm:gsub("%[fasl%][^\n]*\n", "") + check(off == want, "#92 " .. what .. ": SHEN_FASL=off prints LIB twice") + check(cold == want, "#92 " .. what .. ": cold run prints LIB twice") + check(warm == want, "#92 " .. what .. ": warm hit prints LIB twice, not four times") + check(warm2 == want, "#92 " .. what .. ": second warm hit is unchanged") + os.remove(main) + end + os.remove(lib); os.execute("rm -rf " .. sh_quote(fdir)) +end + -- --------------------------------------------------------------------------- -- shen.boot{hush_load = true} — the embedder spelling of the same switch. -- Drives a fresh LuaJIT process that boots in-library and (load)s a file, so From bf3fd90cf0743783a85e8575acdad40a3cdfd5db Mon Sep 17 00:00:00 2001 From: pyrex41 Date: Mon, 19 Oct 2026 06:41:02 -0500 Subject: [PATCH 2/2] fix(fasl): bump the format to SHENFASL7 so #92-poisoned caches miss A fasl written before the previous commit already holds the spliced copy of an in-chunk nested load, and the cache key does not cover boot.lua, so an upgraded build still hit it and printed the nested output twice. A new header makes fasl_parse reject those files: they are recompiled once, then cached correctly. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +++++ boot.lua | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43ab962..958d037 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -39,6 +39,11 @@ Notable changes to shen-lua. Release notes for earlier versions are under ### Fixed +- A warm fasl hit no longer runs a nested `load` twice. When a loaded file + loaded another file, from a top-level `(load ...)` form or from a function + that loads, the nested file's output printed twice on every cached run. + The fasl format is now `SHENFASL7`, so caches written by older builds are + recompiled once rather than replayed (pyrex41/shen-lua#92). - The kernel bytecode cache and its stdlib-signature sidecar default to `${XDG_CACHE_HOME:-$HOME/.cache}/shen-lua/kernel-.bin` (directory created on first write; an unwritable location just skips caching). They diff --git a/boot.lua b/boot.lua index a2d1df1..8f4e1ce 100644 --- a/boot.lua +++ b/boot.lua @@ -800,7 +800,9 @@ end -- Known: (destroy ...) at the REPL between loads is not in the key. -- SHEN_FASL=off disables; SHEN_FASL_DIR overrides ~/.cache/shen-lua-fasl; -- SHEN_FASL_DEBUG=1 logs hits/misses to stderr. -local FASL_FORMAT = "SHENFASL6" -- 6: "dv" (shen.*datatypes* by name) replaces +local FASL_FORMAT = "SHENFASL7" -- 7: a nested load run inside a chunk is no + -- longer spliced into the outer fasl (#92); + -- 6: "dv" (shen.*datatypes* by name) replaces -- "dt" (re-run shen.process-datatype); -- 5: "lt" (shen.*lambdatable* delta by name); -- 4: "e" (per-form value/type echo)