Expensive init -- loading config, parsing schemas, populating caches, building indices, registering handlers -- is paid on every program start. For long-running daemons it amortizes; for CLIs and short-lived workers it dominates wall-clock latency.
Lisp (save-lisp-and-die), Smalltalk (image saves), and Emacs (pdumper)
solved this decades ago: dump the heap after init, load it back on later
starts, skip init. Turmeric's tur/image does the same with a serializable
continuation captured at a quiescent post-init point.
(with-image-cache-after-init PATH init loop) runs init then loop on a
cold start, and -- crucially -- writes the continuation that runs loop to an
image file in between. On a later run, if a valid image exists, it restores
that continuation and jumps straight into loop, skipping init entirely.
The image file is a fixed 72-byte header (src/runtime/image.h) followed by
the Phase 21 TSER payload bytes of the captured continuation. No new wire
format -- it frames Phase 21's serial codec.
STAG_PTR opaque and
rejected by TUR-E0018 at the serial-shift site; re-acquire them after
load (see "Resources" below).eval (see "Security").(load "stdlib/image.tur")
;; Primary combinator: split init from loop.
(with-image-cache-after-init "/var/cache/app.img"
do-init ; a (fn [] int) -- runs only on cold start
main-loop) ; a (fn [] int) -- runs on both cold and warm starts
;; Single-body wrapper (capture happens *after* body returns).
(with-image-cache "/var/cache/app.img" do-everything)
;; Low-level building blocks.
(save-image! "/var/cache/app.img") ; capture here, write to disk
(load-image! "/var/cache/app.img") ; restore + resume
with-image-cache-after-init is a macro, not a function: init and loop
are spliced into call position so the captured tail (loop) is a call to a
named top-level function. This is required -- the serial-shift collector
reconstructs frames by stable name, so the resumed tail must be a named
(serializable) continuation, never a heap closure. Pass top-level defn
names, not anonymous fns, for init and loop.
load-image! returns 0 both on a validation failure and when the resumed
continuation legitimately evaluates to 0. When the cold/warm decision matters,
gate on image/loadable? first (which with-image-cache-after-init does
internally) rather than on the return value.
def isn't in the imageThe image captures the continuation, which means only values transitively
closed over by the captured frames are in it. A top-level def that the
captured continuation doesn't reference is not in the image -- a foot-gun
if you expect whole-heap Lisp-image semantics.
Status: A first-class
defimage-globalregistry (plan AI3) that serialises declared mutable globals alongside the continuation, and aTUR-W0706lint for unregistered mutation reachable from a cache body, are not yet implemented. Until then, thread any post-init mutable state you need through the captured continuation (as a frame env), so it rides in the image. Seedocs/upcoming/application-image-dumps-plan.mdAI3.
OS-handle-backed values (files, sockets, GPU contexts) cannot be in the image. The application must re-acquire them after load. Two hook kinds bracket the image lifecycle:
Both are registered with image/register-reload-hook! /
image/register-finalize-hook! and run in registration order. The
with-image-cache combinators and save-image! / load-image! invoke them
for you at the right point.
(defimage-reload-hook reopen-log
(do (reopen-the-log!) 0)) ; defines (defn reopen-log [] :int ...)
(defimage-finalize-hook flush-log
(do (flush-the-log!) 0))
(defn main [] :int
(image/register-reload-hook! reopen-log) ; install BOTH hooks at the top
(image/register-finalize-hook! flush-log) ; of main -- see the note below
(with-image-cache-after-init "/var/cache/app.img" expensive-init main-loop))
Register at the top of
main, not insideinit. A compiled Turmeric program runs onlymain(top-level forms do not execute), so hooks cannot self-register at load time --defimage-reload-hookis sugar for a nameddefn, not a registration. And a warm start skipsinitentirely, so a hook registered there would be missing on exactly the run that needs the reacquisition. Install hooks before the with-image-cache call so they are present on both paths.
stdlib/image_hooks.tur packages the common cases so you do not hand-roll the
reopen logic. The tracked-file table is the workhorse: declare files during
init, install the reopen hook once, and read fresh handles back after a warm
resume.
(load "stdlib/image.tur")
(load "stdlib/image_hooks.tur")
(defn do-init [] :int
(image-hooks/track-file! "/var/run/app.sock" "r+") ; declare what to reopen
0)
(defn do-loop [] :int
(let [h (image-hooks/slot-handle 0)] ; live FILE* after resume
(read-through h)))
(defn main [] :int
(image-hooks/use-reopen-tracked!) ; reload hook: reopen every tracked file
(image-hooks/use-flush-stdio!) ; finalize hook: fflush stdout/stderr
(with-image-cache-after-init "/var/cache/app.img" do-init do-loop))
On a cold start the slot handle is 0 (the reload hook does not run); on a warm
start the hook reopens each tracked path and image-hooks/slot-handle returns
the fresh FILE* (as an int handle to cast in inline-C).
Loading an image written by a different binary is undefined behavior (frame reconstruction maps names to this binary's functions). The build stamp catches this cheaply at load time.
The stamp is a SHA-256 of the running executable, computed at load time
(via /proc/self/exe on Linux). Same binary -> same stamp; a different binary
-> a different file -> a different stamp -> the image is rejected cleanly (a
0 return / cold-start fallback), never resumed as garbage.
This differs from the plan's AI4.1 linker-section stamp (a two-pass build embedding the digest in a
.tur_build_stampsection). Hashing the running executable delivers the same safety contract without platform-specific linker magic. It does not guarantee a byte-identical stamp across two independent builds of the same source (a reproducible-build nicety, not a safety property); two builds produce two binaries, so an image from build A will not load under build B -- which is exactly the safety behavior we want.
Inspect and validate images from the CLI without resuming them:
tur image-info app.img # print magic/version/stamp/payload-len/...
tur image-verify app.img # structural check (magic/version/CRC)
tur image-verify app.img ./myapp # also require the stamp to match ./myapp
tur image-verify exits 0 on success, 1 on a build-stamp mismatch, and
2 on a structural failure (bad magic/version/CRC or unreadable file).
Dynamic libraries (AI4.4): the stamp covers the main executable only. If the program
dlopens spice plugins or other.sos, the stamp does not see them; dynamic-spice + image-dumps is a follow-up. Treat images from adlopen-ing binary with extra caution.The
--unsafe-image-skip-build-checkescape hatch (AI4.3): the stdlib path supports skipping the stamp check by passingstamp = 0toimage/load-resume-file!. Do not use this in production -- it removes the only guard against resuming a foreign image. The matchingtur run --imageCLI flag (plan AI6.1/AI6.2) is not yet implemented.
Loading an image is equivalent to eval -- it resumes captured code. The
build-stamp check prevents accidental mismatch; it does not make a
malicious-but-stamp-matching image safe. Treat image files as trusted
executables:
/var/cache/app/
owned root:root; ~/.cache/app/ owned by the user)./tmp).Signed images (an Ed25519 signature in the header) are possible future work
if fleet warm-start becomes a use case.
| System | Mechanism | Cross-binary? |
|---|---|---|
SBCL save-lisp-and-die |
dump whole heap | pinned to the runtime |
CCL save-application |
dump whole heap | pinned |
| Smalltalk image save | dump object memory | pinned to the VM |
Emacs pdumper |
portable dump of post-init heap | pinned to the binary |
| Java CRaC | checkpoint/restore via CRIU | pinned |
| Linux CRIU | whole-process checkpoint | pinned |
Turmeric tur/image |
serializable continuation + framed header | pinned via build stamp |
Turmeric's twist: instead of dumping the whole heap, it captures a single
typed, Serializable-checked continuation at a quiescent point the application
chooses -- so the captured state is exactly what the typechecker allows to
cross the boundary.
docs/guides/serializable-continuations-guide.md -- the Phase 21 substrate.docs/upcoming/application-image-dumps-plan.md -- the full plan (AI0--AI8),
including the phases not yet implemented (AI3 globals, AI5 reload hooks,
AI6.1 tur run --image, perf benchmarks).src/runtime/image.h -- the canonical header layout; tests/image_unit.c
pins it.