-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathboot.lua
More file actions
1619 lines (1559 loc) · 72.4 KB
/
Copy pathboot.lua
File metadata and controls
1619 lines (1559 loc) · 72.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
-- boot.lua : load the full Shen KLambda kernel into the Lua runtime and
-- initialise it. Returns the prims module P with everything live. (On the
-- S41.2 2026-07-11 kernel the kernel self-initialises at load time; see FILES
-- and initialise() below.)
local R = require("runtime")
local C = require("compiler")
local P = require("prims")
-- LuaJIT trace/mcode tuning, plus an optional switch to disable the JIT.
--
-- SHEN_JIT=off disables the LuaJIT compiler for the whole process (the
-- in-library equivalent of `luajit -j off`). This is a mitigation for issue
-- #43: on aarch64 an OLD LuaJIT (2.1.0-beta3 era) mis-compiles one of the
-- ~1500 traces generated while loading the kernel, and the bad trace branches
-- into unmapped memory (SIGSEGV during boot; stochastic, ~0.3%/boot on a late
-- beta3 rolling build, 50/50 on the genuine 2017 tag). It is a LuaJIT backend
-- bug, ALREADY FIXED upstream: the real fix is a current 2.1 rolling release
-- (0 crashes on the same boot). SHEN_JIT=off is the fallback for hosts pinned
-- to an old LuaJIT. NOTE it is distinct from SHEN_JIT_OPT=off, which only
-- restores the host's default jit.opt limits and leaves the JIT ON — that does
-- not prevent the crash.
--
-- With the JIT on, LuaJIT's default mcode area (512KB, 1000 traces) is far too
-- small for the compiled kernel: on arm64 the suite triggers dozens of full
-- trace-cache flushes per run ("failed to allocate mcode memory"), costing
-- ~10-16% of total wall time re-JITting the same code. Raise the limits once
-- at boot. SHEN_JIT_OPT=off restores the host's defaults (embedders that
-- manage jit.opt themselves should set it).
local function disable_jit()
local jit_ok, jit = pcall(require, "jit")
if jit_ok and jit and jit.off then pcall(jit.off) end
end
P.disable_jit = disable_jit -- so shen.boot{jit=false} can drive it too
do
if os.getenv("SHEN_JIT") == "off" then
disable_jit()
else
local jit_ok, jit = pcall(require, "jit")
if jit_ok and jit then
-- The beta arm64 backend is known to mis-compile a boot trace and can
-- SIGSEGV (issue #43). Disable it by default on that exact combination;
-- this keeps old distro/OpenResty LuaJIT builds crash-free while leaving
-- current rolling builds unchanged. SHEN_JIT=on is an explicit opt-in
-- for embedders that have verified their LuaJIT build.
if jit.arch == "arm64" and tostring(jit.version):find("beta", 1, true)
and os.getenv("SHEN_JIT") ~= "on" then
disable_jit()
io.stderr:write("shen-lua: disabling JIT for " .. tostring(jit.version)
.. " on arm64 (known boot SIGSEGV; issue #43). Upgrade LuaJIT or"
.. " set SHEN_JIT=on to override.\n")
end
if jit.opt and os.getenv("SHEN_JIT_OPT") ~= "off" then
pcall(jit.opt.start,
"sizemcode=2048", "maxmcode=131072", "maxtrace=8000", "maxside=400")
end
-- Some hosts leave the JIT nominally ON but deny the process executable
-- trace memory (issue #55): macOS hardened-runtime binaries embedding
-- LuaJIT (e.g. Envoy's Lua filter — no JIT entitlement) report
-- jit.status() == true, yet every trace attempt aborts with "failed to
-- allocate mcode memory" and is re-attempted on the next hot path.
-- Measured in Envoy 1.39 on an arm64 Mac: a hot loop ran ~550x slower
-- than plain interpretation and kernel boot took 40-66 s (vs ~3 s
-- interpreted). Detect it directly — compile one throwaway hot loop and
-- watch for a trace "stop" event — and fall back to the interpreter.
-- SHEN_JIT=on skips the probe (explicit opt-in for verified hosts).
if os.getenv("SHEN_JIT") ~= "on" and jit.status and jit.status()
and jit.attach then
local compiled = false
local watcher = function(what)
if what == "stop" then compiled = true end
end
if pcall(jit.attach, watcher, "trace") then
local mk = loadstring or load
local probe = mk("local s = 0 for i = 1, 400 do s = s + i end return s")
if probe then for _ = 1, 3 do probe() end end
pcall(jit.attach, watcher) -- detach
if not compiled then
disable_jit()
io.stderr:write("shen-lua: the JIT reports enabled but compiled no"
.. " trace (executable memory denied? hardened host?); running"
.. " interpreted. Set SHEN_JIT=on to skip this probe.\n")
end
end
end
end
end
-- GC tuning. Compiled-KL workloads are cons-churn-heavy (jit.p on urdr's
-- software SHA-256 suite: ~27% of wall time in the GC at LuaJIT's default
-- pause=200), and most of that churn is short-lived list cells. Raising
-- the pause to 400 (heap may grow to 4x live before a full cycle) cuts
-- suite CPU ~15-20% for about 2x peak RSS (30MB -> 60MB on that suite).
-- SHEN_GC=off keeps the host's defaults (embedders that manage the GC
-- themselves should set it); SHEN_GC="pause[,stepmul]" sets explicit
-- values (e.g. SHEN_GC=800,100 buys another ~10% on batch runs at ~110MB;
-- SHEN_GC=200 is LuaJIT's default pause).
local gc = os.getenv("SHEN_GC")
if gc ~= "off" then
local pause, stepmul
if gc and gc ~= "" then pause, stepmul = gc:match("^(%d+),?(%d*)$") end
collectgarbage("setpause", tonumber(pause) or 400)
if stepmul and stepmul ~= "" then collectgarbage("setstepmul", tonumber(stepmul)) end
end
end
local function find_kldir()
local env = os.getenv("SHEN_KL_DIR")
if env and env ~= "" then return env end
-- 1. Vendored kernel inside this repo (preferred, makes the clone self-contained)
if io.open("klambda/toplevel.kl", "r") then
return "klambda"
end
-- 2. Common external locations (useful when developing against a full
-- ShenOSKernel checkout or the legacy shen-c reference implementation)
local candidates = {
"../cl-source/ShenOSKernel-41.2/klambda",
"../ShenOSKernel-41.2/klambda",
-- legacy shen-c (22.4) clone for comparison / older certification
"../shen-c/shen/src/kl",
"../shen-c/klambda",
}
-- 3. Relative to this module's own location, so requiring shen-lua from
-- another directory (LUA_PATH into a checkout, or a luarocks install)
-- works without chdir. For a luarocks install boot.lua lives at
-- <tree>/share/lua/5.1/boot.lua and copy_directories puts klambda at
-- <tree>/lib/luarocks/rocks-5.1/shen/<version>/klambda.
local src = debug.getinfo(1, "S").source
local here = src:match("^@(.*)[/\\][^/\\]*$")
if here then
candidates[#candidates+1] = here .. "/klambda"
local tree = here:match("^(.*)/share/lua/[%d.]+$")
if tree then
-- any installed version of the rock (scm-1, 0.9.0-1, ...): glob the
-- rock directory rather than hardcoding a version string.
local rocksdir = tree .. "/lib/luarocks/rocks-5.1/shen"
local ls = io.popen('ls -1 "' .. rocksdir .. '" 2>/dev/null')
if ls then
for ver in ls:lines() do
candidates[#candidates+1] = rocksdir .. "/" .. ver .. "/klambda"
end
ls:close()
end
end
end
for _,c in ipairs(candidates) do
local f = io.open(c .. "/toplevel.kl", "r")
if f then f:close(); return c end
end
-- Last resort: assume the vendored location (will produce a clear error)
return "klambda"
end
local KLDIR = find_kldir() .. "/"
P.KLDIR = KLDIR -- resolved .kl directory (trailing /), for typecheck_native
-- Boot order for the S41.2 (2026-07-11 refresh) kernel. The first 15 entries
-- are the refreshed KLambda modules. The refreshed kernel initialises itself
-- at LOAD time: declarations.kl runs top-level forms — (set *property-vector*
-- (vector 20000)), the environment `set`s, (shen.initialise-arity-table ...),
-- (put shen shen.external-symbols ...) and (shen.build-lambda-table ...) — that
-- the removed init.kl used to run from shen.initialise (see initialise()).
--
-- Order is NOT upstream Sources/make.shen order. make.shen relies on the
-- factorise pass + a macros bootstrap that runs last; shen-lua compiles KL
-- directly, so what matters is that a module's LOAD-TIME side effects see
-- their dependencies already defined:
-- * declarations' top-level init calls put/vector/hash/shen.lambda-entry
-- (sys), so sys precedes declarations;
-- * types.kl's 161 top-level (declare ...) forms actually RUN the type
-- checker at load (each declare infers the signature's variance), so every
-- function `declare` reaches transitively must already be defined:
-- shen.prolog-vector (macros.kl), shen.*sigf* + the arity table
-- (declarations.kl), and — new in the refresh — shen.rectify-type and the
-- rest of the inference machinery (t-star.kl). Pre-refresh t-star trailed
-- types; the refresh moved shen.rectify-type into t-star, so t-star must
-- now precede types. Hence the tail: macros declarations t-star types.
--
-- The trailing three are the community ShenOSKernel extensions, which Tarver's
-- refresh no longer ships as KLambda. shen-lua keeps vendoring them on top so
-- the CLI launcher etc. stay available; they are pure defuns/defmacros
-- referencing only public kernel functions, so they load unchanged.
--
-- NOTE: stlib is NOT here. The standard library is no longer a precompiled
-- klambda/stlib.kl; it is loaded from the S-lineage Shen sources under
-- lib/StLib/ by load_stdlib() (below), which the refresh's own install.shen
-- drives. See lib/StLib/PROVENANCE.md and klambda/PROVENANCE.md.
local FILES = {
"yacc","core","load","prolog","reader","sequent","sys","toplevel",
"track","writer","backend","macros","declarations","t-star","types",
"extension-features","extension-expand-dynamic","extension-launcher"
}
-- ---- standard streams ----------------------------------------------------
-- *stoutput*/*sterror* write to stdout/stderr; *stinput* reads stdin bytes.
local out_stream = P.mk_out_stream(function(s) io.stdout:write(s) end, function() io.stdout:flush() end, "stdout")
local err_stream = P.mk_out_stream(function(s) io.stderr:write(s) end, function() io.stderr:flush() end, "stderr")
local in_stream = P.mk_in_stream(function() local c = io.stdin:read(1); return c and string.byte(c) or nil end,
function() end, "stdin")
P.GLOBALS["*stoutput*"] = out_stream
P.GLOBALS["*sterror*"] = err_stream
P.GLOBALS["*stinput*"] = in_stream
P.GLOBALS["*home-directory*"] = ""
-- ---- platform metadata (required by 41.2+ kernel) -------------------------
P.GLOBALS["*language*"] = "Lua"
P.GLOBALS["*implementation*"] = rawget(_G, "jit") and "LuaJIT" or _VERSION
P.GLOBALS["*port*"] = "shen-lua"
P.GLOBALS["*porters*"] = "shen-lua contributors"
P.GLOBALS["*os*"] = (package.config and package.config:sub(1,1) == "\\") and "Windows" or "Unix"
P.GLOBALS["*release*"] = "0.1" -- port release; kernel *version* comes from declarations.kl ("41.2")
-- ---- kernel bytecode cache -------------------------------------------------
-- Loading the kernel from .kl sources costs ~0.8s (read + parse + KL->Lua
-- compile + Lua parse). The generated chunks are deterministic, so we cache
-- string.dump'd bytecode of one concatenated chunk per kernel file, keyed on
-- an FNV-1a hash of everything that determines codegen: the .kl sources, the
-- compiler/reader/prims sources (prims registers primitive arities, which
-- select direct-call vs APP codegen), the file list, and the LuaJIT version/
-- arch (bytecode is not portable across either). SHEN_KERNEL_CACHE=off
-- disables; any other value overrides the cache path.
local CACHE_FORMAT = "SHENKC3" -- 3: per-chunk hoisted (declare ...) block +
-- gensym/inference counters
-- LuaJIT's `bit` library drives the FNV-1a hashing behind both the kernel
-- bytecode cache and the user fasl cache. PUC Lua has no `bit` (5.3+ has
-- native bitwise operators, but this file must stay parseable by 5.1/LuaJIT),
-- so when it is absent both caches self-disable: cache_path()/fasl_dir()
-- return nil, which makes every hashing path (fnv1a/cache_key/fasl_key)
-- unreachable. Pure perf features — correctness is unaffected.
local has_bit, bit = pcall(require, "bit")
if not has_bit then bit = nil end
local function fnv1a(s, h)
h = h or 2166136261
local bxor, lshift, tobit, byte = bit.bxor, bit.lshift, bit.tobit, string.byte
for i = 1, #s do
h = bxor(h, byte(s, i))
-- h = h * 16777619 in 32-bit (2^24 + 2^8 + 2^7 + 2^4 + 2^1 + 1):
-- a direct multiply overflows the double-exact range under bit.band.
h = tobit(h + lshift(h, 1) + lshift(h, 4) + lshift(h, 7) + lshift(h, 8) + lshift(h, 24))
end
return h
end
-- Each Lua build gets its own default cache FILE, not just its own key:
-- bytecode is only portable within the exact build, and two hosts sharing one
-- path — your `luajit` and an embedded LuaJIT with a different jit.version
-- (OpenResty's, Envoy's) — would see a key mismatch on every alternation and
-- invalidate + rewrite each other's cache, recompiling the kernel every time.
-- The filename suffix is a hash of the same version/arch fingerprint that
-- cache_key() folds into the content key.
local function cache_path()
if not bit then return nil end -- PUC Lua: no `bit` -> no cache keys
local p = os.getenv("SHEN_KERNEL_CACHE")
if p == "off" or p == "0" then return nil end
if p and p ~= "" then return p end
return ".shen-kernel-cache."
.. bit.tohex(fnv1a(jit and (jit.version .. jit.arch) or _VERSION))
.. ".bin"
end
local function read_file(path)
local fh = io.open(path, "rb")
if not fh then return nil end
local s = fh:read("*a"); fh:close()
return s
end
local function module_source(name)
local path = package.searchpath and package.searchpath(name, package.path)
return read_file(path or (name .. ".lua")) or ""
end
-- key over kl sources + codegen-relevant module sources; returns hex string,
-- plus the kl sources themselves (the compile path needs them anyway).
local function cache_key()
local h = fnv1a(jit and (jit.version .. jit.arch) or _VERSION)
h = fnv1a(CACHE_FORMAT .. table.concat(FILES, ","), h)
for _, m in ipairs({ "compiler", "runtime", "prims" }) do
h = fnv1a(module_source(m), h)
end
local sources = {}
for _, nm in ipairs(FILES) do
local s = assert(read_file(KLDIR..nm..".kl"), "cannot open "..nm)
sources[nm] = s
h = fnv1a(s, h)
end
return bit.tohex(h), sources
end
-- The compiler hoists big literal (cons ...) trees into the C.KDATA side
-- table at COMPILE time; the emitted bytecode only carries KDATA[i] reads
-- (compiler.lua try_const/try_lit_const). Cached chunks therefore need KDATA
-- rebuilt before they run. Entries are pure literal data — numbers, strings,
-- booleans, interned symbols, NIL, cons cells — so they serialize exactly.
-- Tags: N<num>\n S<len>\n<bytes> Y<len>\n<name> B1\n/B0\n L\n(=NIL) C\n<car><cdr>
local function kdata_ser(v, out)
while R.is_cons(v) do -- cdr spine iteratively: it's the long axis
out[#out+1] = "C\n"
kdata_ser(v[1], out)
v = v[2]
end
local t = type(v)
if v == R.NIL then out[#out+1] = "L\n"
elseif t == "number" then out[#out+1] = "N" .. string.format("%.17g", v) .. "\n"
elseif t == "string" then out[#out+1] = "S" .. #v .. "\n" .. v
elseif t == "boolean" then out[#out+1] = v and "B1\n" or "B0\n"
elseif R.is_symbol(v) then out[#out+1] = "Y" .. #v.name .. "\n" .. v.name
else error("unserializable KDATA value: " .. t) end
end
local function kdata_de(data, pos)
local tag = data:sub(pos, pos)
local e = data:find("\n", pos, true)
if not e then error("truncated KDATA") end
local arg = data:sub(pos + 1, e - 1)
pos = e + 1
if tag == "C" then
local hd, tl
hd, pos = kdata_de(data, pos)
tl, pos = kdata_de(data, pos)
return R.cons(hd, tl), pos
elseif tag == "L" then return R.NIL, pos
elseif tag == "N" then return tonumber(arg), pos
elseif tag == "B" then return arg == "1", pos
elseif tag == "S" or tag == "Y" then
local len = tonumber(arg)
local s = data:sub(pos, pos + len - 1)
pos = pos + len
if tag == "S" then return s, pos end
return R.intern(s), pos
end
error("bad KDATA tag: " .. tostring(tag))
end
-- format: CACHE_FORMAT\n key\n nchunks\n
-- { name\n #dump\n dump ndecl\n { dname\n #ddump\n ddump }* }*
-- narities\n { arity SP fname\n }* nkdata\n { entry }*
-- gensym\n infs\n
-- The per-chunk decl list is that kernel file's hoisted (declare ...) block:
-- one dumped prolog abstraction per signature, in file order. See
-- hoist_tail / record_declares / replay_declares below.
local function write_cache(path, key, chunks, arity, counters)
local parts = { CACHE_FORMAT, "\n", key, "\n", tostring(#chunks), "\n" }
for _, ch in ipairs(chunks) do
parts[#parts+1] = ch.name .. "\n" .. #ch.dump .. "\n" .. ch.dump
local d = ch.decl or {}
parts[#parts+1] = #d .. "\n"
for _, e in ipairs(d) do
parts[#parts+1] = e.name .. "\n" .. #e.dump .. "\n" .. e.dump
end
end
local an = 0
for _ in pairs(arity) do an = an + 1 end
parts[#parts+1] = an .. "\n"
for name, ar in pairs(arity) do
parts[#parts+1] = ar .. " " .. name .. "\n"
end
parts[#parts+1] = #C.KDATA .. "\n"
for i = 1, #C.KDATA do
kdata_ser(C.KDATA[i], parts)
end
parts[#parts+1] = tostring(counters.gensym) .. "\n" .. tostring(counters.infs) .. "\n"
local tmp = path .. ".tmp"
local fh = io.open(tmp, "wb")
if not fh then return end -- read-only dir: silently skip caching
fh:write(table.concat(parts)); fh:close()
os.remove(path)
os.rename(tmp, path)
-- Writing a per-build default cache obsoletes the old shared-path file from
-- pre-per-build versions (it would sit stale forever otherwise). Only the
-- default path triggers this — an explicit SHEN_KERNEL_CACHE never does.
if path:match("^%.shen%-kernel%-cache%.%x+%.bin$") then
os.remove(".shen-kernel-cache.bin")
os.remove(".shen-kernel-cache.bin.tmp")
end
end
-- Parse a write_cache blob. key == nil skips the key check (used for the
-- embedded-kernel payload baked into a single-file bundle, where the build
-- pins the blob and per-chunk load failures fall back to a full compile).
local function parse_cache(data, key)
local pos = 1
local function line()
local e = data:find("\n", pos, true)
if not e then return nil end
local s = data:sub(pos, e - 1); pos = e + 1
return s
end
if line() ~= CACHE_FORMAT then return nil end
local k = line()
if key ~= nil and k ~= key then return nil end
local n = tonumber(line() or ""); if not n then return nil end
local chunks = {}
for i = 1, n do
local nm = line()
local len = tonumber(line() or "")
if not nm or not len or pos + len - 1 > #data then return nil end
chunks[i] = { name = nm, dump = data:sub(pos, pos + len - 1) }
pos = pos + len
local nd = tonumber(line() or ""); if not nd then return nil end
local decl = {}
for j = 1, nd do
local dn = line()
local dl = tonumber(line() or "")
if not dn or not dl or pos + dl - 1 > #data then return nil end
decl[j] = { name = dn, dump = data:sub(pos, pos + dl - 1) }
pos = pos + dl
end
chunks[i].decl = decl
end
local na = tonumber(line() or ""); if not na then return nil end
local arity = {}
for i = 1, na do
local ln = line(); if not ln then return nil end
local ar, name = ln:match("^(%-?%d+) (.*)$")
if not ar then return nil end
arity[name] = tonumber(ar)
end
local nk = tonumber(line() or ""); if not nk then return nil end
local kdata = {}
local kok, kerr = pcall(function()
for i = 1, nk do
kdata[i], pos = kdata_de(data, pos)
end
end)
if not kok then return nil end
local gensym = tonumber(line() or ""); if not gensym then return nil end
local infs = tonumber(line() or ""); if not infs then return nil end
return { chunks = chunks, arity = arity, kdata = kdata,
gensym = gensym, infs = infs }
end
local function read_cache(path, key)
local data = read_file(path)
if not data then return nil end
return parse_cache(data, key)
end
-- ---- load the kernel -----------------------------------------------------
-- Loads the 19 .kl modules in FILES (see above): the 15 refreshed S41.2
-- (2026-07-11) KLambda modules plus the vendored community stlib + 3 booted
-- extensions. The opt-in extension-programmable-pattern-matching.kl is
-- vendored but not booted.
-- The KLambda sources are vendored under `klambda/` so the repository
-- is self-contained. You can still override with SHEN_KL_DIR (e.g. to point
-- at a full ShenOSKernel checkout during development).
-- Native overrides, installed after the compiled KL defuns are all in F.
local function install_native_overrides()
-- Hottest Prolog deref primitives (see prims.install_native_prolog).
P.install_native_prolog()
-- Hottest general-purpose kernel functions with native Lua
-- (element?, assoc, map, reverse, fail, ...; see prims.install_native_stdlib).
P.install_native_stdlib()
-- Native soa32 Prolog/typecheck engine (prolog_engine.lua). Default on once
-- the module ships; SHEN_PROLOG_ENGINE=legacy falls back to the compiled-KL
-- CPS engine. Module absence is tolerated (pre-engine checkouts); any other
-- load error is real and must propagate.
if os.getenv("SHEN_PROLOG_ENGINE") ~= "legacy" then
local ok, eng = pcall(require, "prolog_engine")
if ok then
eng.install(P)
elseif not tostring(eng):find("module 'prolog_engine' not found", 1, true) then
error(eng)
end
end
end
-- memoized: the fasl layer reuses the codegen key even when the kernel
-- cache is disabled (first call reads ~850KB and hashes it, ~ms).
local KERNEL_KEY
local function kernel_key()
local k, sources = KERNEL_KEY, nil
if not k then
if P.KERNEL_CACHE_DATA then
-- single-file bundle: no .kl files on disk; the embedded blob captures
-- everything that determines codegen, so its hash is the kernel key.
k = bit.tohex(fnv1a(P.KERNEL_CACHE_DATA))
else
k, sources = cache_key()
end
KERNEL_KEY = k
end
return k, sources
end
-- ---- hoisted kernel type signatures ---------------------------------------
-- klambda/types.kl ends with 161 top-level `(declare Name Type)` forms, and
-- they are not cheap annotations: `declare` (types.kl) runs the type theory for
-- real on every one of them —
-- (a) shen.variancy over the signature under the Prolog machine,
-- (b) (eval-kl (shen.prolog-abstraction Type)) — a full KL->Lua compile plus
-- loadstring per signature, producing the closure stored in shen.*sigf*,
-- (c) (set shen.*sigf* (shen.assoc-> Name <closure> ...)).
-- Measured on arm64 LuaJIT that block is ~43 ms: ~30% of a warm boot and its
-- largest single item, recomputed on every start even though the kernel
-- bytecode cache was hit and nothing changed.
--
-- (a) is a static check of the kernel's own signatures against the kernel's own
-- sources, and the cache key already covers every input to it; (b) is
-- deterministic given the signature. So a cached boot can skip both and keep
-- only (c), replaying the abstraction from dumped bytecode:
--
-- hoist_tail pulls the trailing (declare ...) block out of a kernel
-- file's forms so boot.lua — not the opaque concatenated
-- chunk — is what runs it. Only a CONTIGUOUS TRAILING run is
-- hoisted, so hoisting can never reorder a file's effects; a
-- file that interleaves declares with other top-level forms
-- keeps them inline and caches nothing for them.
-- record_declares the cold path: runs the real `declare`, capturing each
-- eval-kl chunk through the ordinary FASL_REC recorder
-- (C.NO_KDATA keeps the dumps relocatable, exactly as for
-- the user fasl cache).
-- replay_declares the warm path: load dump, run it, assoc-> into
-- shen.*sigf* — the same final state, no type theory.
--
-- The gensym and inference counters `declare` advances are recorded and
-- restored (load_cached below), so a cached boot's shen.*gensym* and
-- (inferences) match an uncached one exactly instead of lagging by the ~1000
-- gensyms the skipped abstractions would have consumed.
local function is_declare_form(f)
return R.is_cons(f) and R.is_symbol(f[1]) and f[1].name == "declare"
and R.is_cons(f[2]) and R.is_cons(f[2][2]) and f[2][2][2] == R.NIL
end
-- Split a kernel file's forms into (body, init forms, declares). Only a
-- TRAILING run of non-defun top-level forms is ever moved, and it is run
-- immediately after the body chunk, so hoisting cannot reorder anything. In
-- the 41.2 kernel exactly two files have such a run: types.kl (161 declares)
-- and declarations.kl (one form, (shen.build-lambda-table (external shen))).
-- Anything less tidy than [defuns...][inits...][declares...] is left inline.
local function hoist_tail(forms)
local last = #forms
local function is_defun(f)
return R.is_cons(f) and R.is_symbol(f[1]) and f[1].name == "defun"
end
while last > 0 and not is_defun(forms[last]) do last = last - 1 end
if last == #forms then return forms, nil, nil end -- no trailing run
-- the trailing run is [init forms][declare forms]; find the split
local d0 = #forms + 1
while d0 > last + 1 and is_declare_form(forms[d0 - 1]) do d0 = d0 - 1 end
for i = last + 1, d0 - 1 do
if is_declare_form(forms[i]) then return forms, nil, nil end -- interleaved
end
local kept, inits, decls = {}, {}, {}
for i = 1, last do kept[i] = forms[i] end
for i = last + 1, d0 - 1 do inits[#inits + 1] = forms[i] end
for i = d0, #forms do
decls[#decls + 1] = { name = forms[i][2][1], typ = forms[i][2][2][1] }
end
return kept, (#inits > 0 and inits or nil), (#decls > 0 and decls or nil)
end
local function record_declares(decls)
local rec = { n = 0, in_chunk = false }
local saved_rec, saved_nokdata = P.FASL_REC, C.NO_KDATA
P.FASL_REC = rec
C.NO_KDATA = true -- recorded chunks must be relocatable
local out, done = {}, 0
local ok, err = pcall(function()
for _, d in ipairs(decls) do
local n0 = rec.n
P.F["declare"](d.name, d.typ)
done = done + 1
-- `declare` must have produced exactly one top-level eval-kl chunk (the
-- prolog abstraction). Anything else and we do not understand what was
-- just recorded: cache nothing rather than cache a half-truth.
if rec.n ~= n0 + 1 or rec[rec.n].k ~= "c" then
error("shen-lua: unexpected declare recording", 0)
end
out[#out + 1] = { name = d.name.name, dump = rec[rec.n].dump }
end
end)
P.FASL_REC = saved_rec
C.NO_KDATA = saved_nokdata
if not ok then
-- Not a fatal condition: the declares run either way, they just cannot be
-- cached. Finish the block uncached and record nothing for it.
if err == "shen-lua: unexpected declare recording" then
for i = done + 1, #decls do P.F["declare"](decls[i].name, decls[i].typ) end
return nil
end
error(err, 0)
end
return out
end
local function replay_declares(decl)
local sigf = R.intern("shen.*sigf*")
local assoc, set = P.F["shen.assoc->"], P.F["set"]
for _, e in ipairs(decl) do
local fn = P.load_chunk(e.dump, "declare:" .. e.name)
set(sigf, assoc(R.intern(e.name), fn(), P.GLOBALS["shen.*sigf*"]))
end
end
-- Load (don't run) every cached dump first, so a corrupt/foreign-arch cache
-- falls back to the full compile before any chunk has executed. Returns true
-- on success, false if any dump refused to load.
local function load_cached(cached, verbose, tag)
local fns = {}
for i, ch in ipairs(cached.chunks) do
local lok, fn = pcall(P.load_chunk, ch.dump, ch.name)
if not lok then return false end
fns[i] = fn
end
-- Rebuild the compile-time literal pool FIRST: the cached bytecode
-- reads KDATA[i]. Mutate C.KDATA in place — ENV.KDATA aliases it.
for i, v in ipairs(cached.kdata) do C.KDATA[i] = v end
for i, fn in ipairs(fns) do
-- A hoisted ":init" chunk is a kernel file's trailing top-level block. The
-- only reason it is a separate chunk is so the native shen.lambda-entry can
-- be in place before declarations.kl's (shen.build-lambda-table (external
-- shen)) runs — otherwise that one form runs the whole compiler ~280 times,
-- once per external symbol, and it is 2/3 of what is left of a cached
-- kernel load. See prims.install_native_lambda_entry.
if cached.chunks[i].name:find(":init", 1, true) then
P.install_native_lambda_entry()
end
local rok, err = pcall(fn)
if not rok then
error("load error in "..cached.chunks[i].name..tag..": "..tostring(err))
end
-- the file's hoisted signature block, in its original position
local decl = cached.chunks[i].decl
if decl and #decl > 0 then replay_declares(decl) end
if verbose then io.stderr:write(" loaded "..cached.chunks[i].name..tag.."\n") end
end
-- Fast-forward the counters `declare` would have advanced had the type theory
-- actually run (see replay_declares), so a cached boot is indistinguishable
-- from an uncached one at the Shen level.
if cached.gensym and type(P.GLOBALS["shen.*gensym*"]) == "number"
and cached.gensym > P.GLOBALS["shen.*gensym*"] then
P.GLOBALS["shen.*gensym*"] = cached.gensym
end
if cached.infs and type(P.GLOBALS["shen.*infs*"]) == "number"
and cached.infs > P.GLOBALS["shen.*infs*"] then
P.GLOBALS["shen.*infs*"] = cached.infs
end
-- Restore defun arities harvested at compile time (prescan + cdefun);
-- runtime compilation of user code needs them for direct-call codegen.
for name, ar in pairs(cached.arity) do C.ARITY[name] = ar end
return true
end
-- Full compile from .kl sources. extsources (name -> source string), when
-- given, overrides the on-disk KLDIR files (the single-file bundle embeds
-- them as P.KL_SOURCES). path/key, when given, write the bytecode cache.
local function compile_kernel(extsources, path, key, verbose)
local all = {}
for _,nm in ipairs(FILES) do
local s = (extsources and extsources[nm]) or assert(read_file(KLDIR..nm..".kl"), "cannot open "..nm)
local fs = R.read_all(s)
all[nm] = fs
C.prescan(fs)
end
local chunks = {}
for _,nm in ipairs(FILES) do
-- One concatenated chunk per kernel file: same statements in the same
-- order as per-form loading (every top-level form compiles to a single
-- self-contained `do ... end` statement), but loadstring'd once and
-- dumpable for the bytecode cache.
-- The file's trailing top-level block is hoisted out (hoist_tail) into a
-- separate ":init" chunk and, for signatures, into a recorded declare
-- block; both run right after the body, in file order.
local forms, inits, decls = hoist_tail(all[nm])
local function emit(name, fs)
local parts = {}
for i,f in ipairs(fs) do parts[i] = C.compile_top(f) end
local fn = P.load_chunk(table.concat(parts, "\n"), name)
if name:find(":init", 1, true) then P.install_native_lambda_entry() end
local ok, err = pcall(fn)
if not ok then error("load error in "..name..": "..tostring(err)) end
chunks[#chunks+1] = { name = name, dump = string.dump(fn) }
return chunks[#chunks]
end
emit(nm, forms)
if inits then emit(nm .. ":init", inits) end
if decls then chunks[#chunks].decl = record_declares(decls) end
if verbose then io.stderr:write(" loaded "..nm.."\n") end
end
if path then
write_cache(path, key, chunks, C.ARITY, {
gensym = type(P.GLOBALS["shen.*gensym*"]) == "number" and P.GLOBALS["shen.*gensym*"] or 0,
infs = type(P.GLOBALS["shen.*infs*"]) == "number" and P.GLOBALS["shen.*infs*"] or 0,
})
end
end
local function load_kernel(verbose)
-- Embedded kernel (single-file bundle, see build/make-bundle.lua):
-- P.KERNEL_CACHE_DATA holds a write_cache-format blob baked in at bundle
-- build time, P.KL_SOURCES the .kl sources as a name -> string table.
-- The blob is trusted as-is (no key check — the build pins it); if its
-- bytecode refuses to load (a different LuaJIT version/arch than the
-- build machine) we fall back to compiling the embedded sources. The
-- on-disk cache file is bypassed entirely in this mode.
if P.KERNEL_CACHE_DATA then
local cached = parse_cache(P.KERNEL_CACHE_DATA, nil)
if not (cached and load_cached(cached, verbose, " (embedded)")) then
compile_kernel(assert(P.KL_SOURCES, "embedded kernel bytecode unusable and no embedded .kl sources"),
nil, nil, verbose)
end
install_native_overrides()
return
end
local path = cache_path()
local key, sources
if path then
key, sources = kernel_key()
local cached = read_cache(path, key)
if cached then
if load_cached(cached, verbose, " (cached)") then
install_native_overrides()
return
end
os.remove(path) -- corrupt/stale dump: recompile below and rewrite
end
end
compile_kernel(sources, path, key, verbose)
install_native_overrides()
end
-- ---- user-program fasl cache ----------------------------------------------
-- (load "x.shen") is dominated by the reader, macroexpansion, and — with tc
-- on — typechecking, all deterministic given the file content and the
-- session's load history. The persistent effects of a load are exactly
-- (load.kl): the top-level eval-kl chunks (eval-and-print / work-through),
-- the (declare Name Type) calls from shen.assumetypes on the tc path, and
-- the compile-time side state those compiles created (C.ARITY, C.KDATA,
-- shen.*gensym*). We record those during a load and replay them on a key
-- hit, skipping reader+macro+typecheck entirely — SBCL fasl semantics: "it
-- typechecked when compiled".
--
-- Key = codegen key + file content + tc flag + a ROLLING hash of all
-- previously loaded files (editing file A invalidates everything loaded
-- after it, make-style) + the names of live datatypes and macros (catches
-- most REPL-defined state). Replay requires #C.KDATA to equal the recorded
-- base (compiled bytecode hard-codes KDATA indices); a mismatch is a miss,
-- never an error.
--
-- A replayed load reproduces the per-form value/type echo: the bytes
-- shen.eval-and-print / shen.work-through write to (stoutput) are captured as
-- "e" records during the miss and re-pr'd (in stream order, after each form's
-- chunk) on replay, so warm-hit stdout matches cold. Still dropped by design:
-- the cosmetic "run time"/"typechecked in N inferences" banners (they are
-- emitted by `load` OUTSIDE shen.load-help and would add per-run timing noise).
-- 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
-- "dt" (re-run shen.process-datatype);
-- 5: "lt" (shen.*lambdatable* delta by name);
-- 4: "e" (per-form value/type echo)
-- records; 3: "pc" (shen.compile-prolog)
local FASL_STACK = {}
local FASL_ROLL = 2166136261
local FASL_DEBUG = os.getenv("SHEN_FASL_DEBUG") == "1"
local FASL_INSTALLED = false -- install_fasl() ran: the recorder is live
local function fasl_dir()
if not bit then return nil end -- PUC Lua: no `bit` -> no fasl keys
local p = os.getenv("SHEN_FASL")
if p == "off" or p == "0" then return nil end
local d = os.getenv("SHEN_FASL_DIR")
if d and d ~= "" then return d end
local home = os.getenv("HOME")
if not home or home == "" then return nil end
return home .. "/.cache/shen-lua-fasl"
end
local function fasl_log(msg)
if FASL_DEBUG then io.stderr:write("[fasl] " .. msg .. "\n") end
end
-- names of a KL list of symbols or (symbol . x) pairs (datatypes, *macros*)
local function kl_names(l)
local parts = {}
while R.is_cons(l) do
local e = l[1]
if R.is_symbol(e) then parts[#parts+1] = e.name
elseif R.is_cons(e) and R.is_symbol(e[1]) then parts[#parts+1] = e[1].name
else parts[#parts+1] = "?" end
l = l[2]
end
return table.concat(parts, ",")
end
local function fasl_key(content)
local env = (P.GLOBALS["shen.*tc*"] and "tc" or "raw")
.. "|" .. (os.getenv("SHEN_PROLOG_ENGINE") or "native")
.. "|" .. bit.tohex(FASL_ROLL)
.. "|" .. kl_names(P.GLOBALS["shen.*datatypes*"])
.. "|" .. kl_names(P.GLOBALS["*macros*"])
return bit.tohex(fnv1a(content, fnv1a(env, fnv1a((kernel_key())))))
end
-- format: SHENFASL1\n nrec\n
-- { C\n name\n #dump\n dump top-level eval-kl chunk
-- | D\n <ser name><ser type> (declare ...) from assumetypes
-- | M\n <ser name> shen.record-macro (fn rebuilt by name)
-- | P\n <ser x><ser ptr><ser y> (put ... *property-vector*)
-- | E\n #bytes\n bytes per-form value/type echo (stoutput)
-- | A\n <ser names> shen.*lambdatable* delta (entries
-- rebuilt by name via shen.lambda-entry)
-- | G\n <ser name><ser val> }* (set ...) outside any chunk
-- narity\n {ar SP name\n}* kbase\n nkdata\n entries gensym\n
local function fasl_serialize(rec, arity0)
if rec.uncacheable then error(rec.uncacheable) end
local parts = { FASL_FORMAT, "\n", tostring(rec.n), "\n" }
for i = 1, rec.n do
local r = rec[i]
if r.k == "c" then
parts[#parts+1] = "C\n" .. r.name .. "\n" .. #r.dump .. "\n" .. r.dump
elseif r.k == "d" then
parts[#parts+1] = "D\n"
kdata_ser(r.name, parts)
kdata_ser(r.typ, parts)
elseif r.k == "m" then
parts[#parts+1] = "M\n"
kdata_ser(r.name, parts)
elseif r.k == "lf" then
parts[#parts+1] = "L\n"
kdata_ser(r.name, parts)
elseif r.k == "lt" then
parts[#parts+1] = "A\n"
kdata_ser(r.names, parts)
elseif r.k == "dv" then
parts[#parts+1] = "T\n"
kdata_ser(r.global, parts)
kdata_ser(r.names, parts)
elseif r.k == "pc" then
parts[#parts+1] = "Q\n"
kdata_ser(r.name, parts)
kdata_ser(r.rules, parts)
elseif r.k == "sy" then
parts[#parts+1] = "Z\n"
kdata_ser(r.syns, parts)
elseif r.k == "p" then
parts[#parts+1] = "P\n"
kdata_ser(r.x, parts)
kdata_ser(r.pointer, parts)
kdata_ser(r.y, parts)
elseif r.k == "e" then
-- raw echo bytes, length-prefixed (may contain newlines / non-ASCII);
-- mirrors the "C" chunk framing — no trailing separator, the next
-- record's kind letter starts immediately after the bytes.
parts[#parts+1] = "E\n" .. #r.bytes .. "\n" .. r.bytes
else -- "g"
parts[#parts+1] = "G\n"
kdata_ser(r.name, parts)
kdata_ser(r.val, parts)
end
end
local delta = {}
for name, ar in pairs(C.ARITY) do
if arity0[name] ~= ar then delta[#delta+1] = ar .. " " .. name end
end
parts[#parts+1] = #delta .. "\n"
for _, d in ipairs(delta) do parts[#parts+1] = d .. "\n" end
local g = P.GLOBALS["shen.*gensym*"]
parts[#parts+1] = tostring(type(g) == "number" and g or 0) .. "\n"
return table.concat(parts)
end
local function atomic_write(path, blob)
local tmp = path .. ".tmp"
local fh = io.open(tmp, "wb")
if not fh then return end -- read-only dir: silently skip caching
fh:write(blob); fh:close()
os.remove(path)
os.rename(tmp, path)
end
local function fasl_write(path, rec, arity0)
atomic_write(path, fasl_serialize(rec, arity0))
end
-- Parse a fasl record stream out of `data` starting at `pos`. Returns the
-- record table and the position just past the stream, or nil on any
-- malformation (a miss, never an error). The stdlib boot image (below) embeds
-- one of these after its own header, which is why this takes a position.
local function fasl_parse(data, pos)
local function line()
local e = data:find("\n", pos, true)
if not e then return nil end
local s = data:sub(pos, e - 1); pos = e + 1
return s
end
if line() ~= FASL_FORMAT then return nil end
local n = tonumber(line() or ""); if not n then return nil end
local function de_n(count)
local ok, vals = pcall(function()
local out = {}
for j = 1, count do out[j], pos = kdata_de(data, pos) end
return out
end)
if ok then return vals end
return nil
end
local recs = {}
for i = 1, n do
local k = line()
if k == "C" then
local nm = line()
local len = tonumber(line() or "")
if not nm or not len or pos + len - 1 > #data then return nil end
recs[i] = { k = "c", name = nm, dump = data:sub(pos, pos + len - 1) }
pos = pos + len
elseif k == "D" then
local v = de_n(2); if not v then return nil end
recs[i] = { k = "d", name = v[1], typ = v[2] }
elseif k == "M" then
local v = de_n(1); if not v then return nil end
recs[i] = { k = "m", name = v[1] }
elseif k == "L" then
local v = de_n(1); if not v then return nil end
recs[i] = { k = "lf", name = v[1] }
elseif k == "A" then
local v = de_n(1); if not v then return nil end
recs[i] = { k = "lt", names = v[1] }
elseif k == "T" then
local v = de_n(2); if not v then return nil end
recs[i] = { k = "dv", global = v[1], names = v[2] }
elseif k == "Q" then
local v = de_n(2); if not v then return nil end
recs[i] = { k = "pc", name = v[1], rules = v[2] }
elseif k == "Z" then
local v = de_n(1); if not v then return nil end
recs[i] = { k = "sy", syns = v[1] }
elseif k == "P" then
local v = de_n(3); if not v then return nil end
recs[i] = { k = "p", x = v[1], pointer = v[2], y = v[3] }
elseif k == "E" then
local len = tonumber(line() or "")
if not len or pos + len - 1 > #data then return nil end
recs[i] = { k = "e", bytes = data:sub(pos, pos + len - 1) }
pos = pos + len
elseif k == "G" then
local v = de_n(2); if not v then return nil end
recs[i] = { k = "g", name = v[1], val = v[2] }
else return nil end
end
local na = tonumber(line() or ""); if not na then return nil end
local arity = {}
for i = 1, na do
local ln = line(); if not ln then return nil end
local ar, name = ln:match("^(%-?%d+) (.*)$")
if not ar then return nil end
arity[name] = tonumber(ar)
end
local gensym = tonumber(line() or ""); if not gensym then return nil end
return { recs = recs, arity = arity, gensym = gensym }, pos
end
local function fasl_read(path)
local data = read_file(path)
if not data then return nil end
return (fasl_parse(data, 1))
end
local function fasl_replay(cached)
-- Recorded chunks are relocatable (compiled under C.NO_KDATA — literals
-- ride inside the chunk via MKTREE/MKLIST, never the KDATA side table),
-- so replay has no positional coupling to this session's compile state.
for name, ar in pairs(cached.arity) do C.ARITY[name] = ar end
for _, r in ipairs(cached.recs) do
if r.k == "c" then
P.load_chunk(r.dump, r.name)()
elseif r.k == "d" then
-- through the live F["declare"] so engine sig-table wrappers see it
P.F["declare"](r.name, r.typ)
elseif r.k == "m" then
-- the macro's defun chunk replayed above; rebuild the (name . fn) pair
local fn = P.F[r.name.name]
if not fn then error("fasl: macro function missing: " .. r.name.name) end
P.F["shen.record-macro"](r.name, fn)
elseif r.k == "lf" then
-- shen.lambda-entry returns the complete (name . curried-fn) entry
-- (or () for arity 0/-1); the recorded put stored its tl. Rebuild and
-- put the same shape. The arity property it reads was applied by the
-- preceding "p" record (stream order).
local entry = P.F["shen.lambda-entry"](r.name)
local val = R.is_cons(entry) and entry[2] or entry
P.F["put"](r.name, R.intern("shen.lambda-form"), val,
P.GLOBALS["*property-vector*"])
elseif r.k == "lt" then
-- 41.2 kernel path: (set shen.*lambdatable* ...) carries live curried
-- lambdas, so the recording stored only the NAMES whose entries the set
-- added/replaced. Rebuild each entry from the live defun exactly the way
-- shen.update-lambdatable does (shen.lambda-entry reads the arity
-- property, replayed by the preceding "p" record in stream order).
local names = r.names
while R.is_cons(names) do
local name = names[1]
local entry = P.F["shen.lambda-entry"](name)
if R.is_cons(entry) then
P.F["set"](R.intern("shen.*lambdatable*"),
P.F["shen.assoc->"](name, entry[2],