jolt/test/integration/ctx-image-test.janet
Yogthos 0e3584884f core: AOT context image — init-cached recovers the bootstrap cost across processes
init in compile mode is ~2.4 s (tier loading, analyzer self-compile, macro
recompilation), paid by every process that builds a ctx from source — each
jpm-test file, embedders, workers. init-cached marshals the built ctx to a
disk image (same root-env dicts as snapshot/fork) and later processes
unmarshal it in ~5 ms, any process: nothing from the baking process is
needed at load.

The cache key fingerprints the embedded .clj stdlib (which covers jolt-core:
analyzer, IR, core tiers), the .janet seed sources next to the module, the
janet version, the init opts, and the env knobs that shape a ctx (JOLT_PATH/
MUTABLE/AOT_CORE/FEATURES) — any change rebuilds. Corrupt or non-ctx images
fall back to a rebuild (unmarshal of garbage can 'succeed' with a scalar, so
the shape is checked, not just the throw). Writes are atomic (tmp + rename)
so racing cold starts never publish a torn image. JOLT_NO_IMAGE_CACHE=1
opts out; JOLT_IMAGE_CACHE_DIR overrides the location (default TMPDIR).

Test consumers switch to init-cached (harness, suite-worker, conformance,
the behavioral unit/integration tests); tests that validate the bootstrap
itself (bootstrap-fixpoint, staged-bootstrap, aot round-trip, direct-linking)
and the deps tests (tmp-dir :paths would fragment the key) keep real init.
Full jpm test: 2:46 -> 1:58 (~29%). New ctx-image-test covers cold/warm,
cross-process load (subprocess runs defn/redef/macros/protocols/multimethods
off the baked image), per-opts keying, and corrupt-image fallback.
2026-06-10 13:57:37 -04:00

78 lines
4.3 KiB
Text

# init-cached: disk-cached AOT image of the fully-built context.
#
# init in compile mode costs ~2.4 s (tier loading, analyzer self-compile, macro
# recompilation). init-cached pays that once, marshals the built ctx to an image
# file (the same machinery as api/snapshot), and every later process unmarshals
# it instead of rebuilding. The cache key fingerprints the embedded .clj stdlib,
# the .janet seed sources, and the init opts, so any source change invalidates.
#
# The cross-process case is the one that matters (each `jpm test` file is its
# own janet process), so the warm-load checks run in a SUBPROCESS against the
# image this process bakes.
(use ../../src/jolt/api)
(print "ctx image cache...")
(def dir (string (or (os/getenv "TMPDIR") "/tmp") "/jolt-img-test-" (os/getpid)))
(os/mkdir dir)
(os/setenv "JOLT_IMAGE_CACHE_DIR" dir)
# The cache is the test subject — undo an ambient opt-out (and for the subprocess).
(os/setenv "JOLT_NO_IMAGE_CACHE" nil)
(defer (do (each f (os/dir dir) (os/rm (string dir "/" f))) (os/rmdir dir))
# 1. Cold init: builds the ctx, writes an image, and is fully functional.
(def t0 (os/clock))
(def ctx1 (init-cached {:compile? true}))
(def cold-s (- (os/clock) t0))
(assert (= 3 (eval-string ctx1 "(+ 1 2)")) "cold ctx evaluates")
(def files (filter |(string/has-suffix? ".jimg" $) (os/dir dir)))
(assert (= 1 (length files)) (string "one image written, got " (length files)))
# 2. Warm init in THIS process: loads the image, functional, and much faster.
(def t1 (os/clock))
(def ctx2 (init-cached {:compile? true}))
(def warm-s (- (os/clock) t1))
(assert (= 10 (eval-string ctx2 "(do (defn f [x] (* 2 x)) (f 5))")) "warm ctx compiles defns")
(assert (< warm-s (/ cold-s 4))
(string/format "warm load (%.0f ms) at least 4x faster than cold (%.0f ms)"
(* 1000 warm-s) (* 1000 cold-s)))
# 3. Different opts get a different image (no false sharing between modes).
(init-cached {})
(def files2 (filter |(string/has-suffix? ".jimg" $) (os/dir dir)))
(assert (= 2 (length files2)) "interpret-mode image is keyed separately")
# 4. Cross-process warm load: a fresh janet process loads the image this
# process baked and runs real work — compiled defns, redefinition,
# macros, lazy seqs, protocols, multimethods, stdlib require.
(def checks
``(use ./src/jolt/api)
(def t0 (os/clock))
(def ctx (init-cached {:compile? true}))
(def warm-ms (* 1000 (- (os/clock) t0)))
(def img-files (filter |(string/has-suffix? ".jimg" $)
(os/dir (os/getenv "JOLT_IMAGE_CACHE_DIR"))))
(assert (= 2 (length img-files)) "subprocess hit the cache (no new image)")
(assert (= 3 (eval-string ctx "(+ 1 2)")) "arith")
(assert (= 120 (eval-string ctx "(do (defn fact [n] (if (zero? n) 1 (* n (fact (dec n))))) (fact 5))")) "compiled defn")
(assert (= 7 (eval-string ctx "(do (def a 3) (def a 7) a)")) "redefinition")
(assert (= 6 (eval-string ctx "(-> 1 inc (* 3))")) "macros expand")
(assert (= 9 (eval-string ctx "(do (defmacro tw [x] `(* 3 ~x)) (tw 3))")) "defmacro works post-load")
(assert (= [2 4 6] (normalize-pvecs (eval-string ctx "(vec (map #(* 2 %) [1 2 3]))"))) "lazy/HOF")
(assert (= "a-b" (eval-string ctx "(do (require '[clojure.string :as str]) (str/join \"-\" [\"a\" \"b\"]))")) "stdlib require")
(assert (= 42 (eval-string ctx "(do (defprotocol P (pf [x])) (defrecord R [] P (pf [x] 42)) (pf (->R)))")) "protocols")
(assert (= :big (eval-string ctx "(do (defmulti m (fn [x] (if (> x 5) :big :small))) (defmethod m :big [_] :big) (m 10))")) "multimethods")
(print "subprocess warm load ok in " (math/round warm-ms) " ms")``)
(def code (os/execute ["janet" "-e" checks] :p))
(assert (= 0 code) "cross-process warm load passes")
# 5. A source change invalidates: poisoning the fingerprint env knob is not
# possible from here, but a corrupted image must fall back to a rebuild
# rather than crash.
(each f (os/dir dir)
(when (string/has-suffix? ".jimg" f) (spit (string dir "/" f) "garbage")))
(def ctx3 (init-cached {:compile? true}))
(assert (= 3 (eval-string ctx3 "(+ 1 2)")) "corrupted image falls back to rebuild")
(printf "ctx image cache passed! (cold %.0f ms, warm %.0f ms)"
(* 1000 cold-s) (* 1000 warm-s)))