4 Code execution & the kernel
Why an edit re-runs one cell and not the whole notebook: cumulative hashing, the freeze cache on disk, and the warm kernel.
Our post.tmd has a single {python} cell. Taliesin usually avoids running it by
reusing an earlier run’s output, held in memory or on disk.
Executing {python} cells is the most involved subsystem and lives
entirely in the server (crates/server), in two layers: the executor
(exec.rs) decides what to run and splices outputs back into the block list;
the kernel (kernel.rs) is one warm Jupyter process it talks to over ZMQ.
The core knows nothing about execution: it emits code cells as blocks, and the
server fills in their outputs.
4.1 The executor (exec.rs)
Executor holds one warm kernel, created lazily, plus the two output caches that let
it skip work, one in memory and one on disk:
Which Python runs is decided in one place, interpreter.rs, in precedence order:
_site.yml’s python:, a project-local .venv/bin/python, TALIESIN_PYTHON, a .venv
found by walking up to the nearest .git or pyproject.toml, then bare python3. The
Resolved it returns carries the provenance and the whole search trail, which is what
the kernel-start log line and doctor’s Environment section print when the venv you
expected was not the one picked.
run(blocks) is the entry point each rebuild calls. Its first check is the
no_exec flag: with TALIESIN_NO_EXEC (or --no-exec) set, it returns the blocks
untouched, so every code cell previews as highlighted source with no kernel started and
no “kernel unavailable” notice. The render pass reads the same flag for {js} cells
(taliesin_core::render::no_exec_in_force), so a browser-side cell is left as source too.
It is a code-cell switch, not a sandbox: raw <script> in the document body still
reaches the page. The CLI reference
covers what to do about a document you did not write. When the flag is not set, run
collects the {python} cells in document order, runs them, and splices an output
block after each cell.
An output block’s id is derived from its cell’s id, so when a cell re-runs only its output block changes: the diff swaps it in place (see the block model).
4.2 What runs on a rebuild
compute_outputs gives the document notebook semantics without re-running
everything. It decides which contiguous range of cells must
execute, by sorting the cells into three zones (Figure 4.1):
flowchart LR A["warm prefix<br/>the live kernel<br/>already ran it"] --> B["run range<br/>changed cell +<br/>everything downstream"] --> C["cached tail<br/>restored from<br/>_freeze/"]
The boundaries come from one key per cell: a cumulative content hash
(freeze::cumulative_hashes) that folds in the interpreter identity plus every
same-language cell’s code up to and including this one. Because it is cumulative,
editing a cell moves its key and every downstream key, so an upstream edit
invalidates everything after it automatically, as notebook semantics require.
plan then finds the warm prefix the live kernel already holds and the run
range; any suffix whose keys are already on disk is restored without running.
So editing the last cell re-runs one cell; editing the first re-runs all. The
kernel stays warm across all of it: no per-edit cold start (goal #3). A
#| include: false cell still runs (for its kernel-state side effects) but emits
no output block. A #| cache: false cell always re-runs, is never cached, and forces
every cell after it to re-run and stay uncached too: the cumulative key folds in that
cell’s code but never its value, so an entry written below it would assert an upstream
that does not hold.
4.2.1 The cumulative-hash chain in detail
For the cells in document order, cumulative_hashes(interp, codes) folds
each cell’s code into the running digest of everything before it:
acc₀ = FNV1a( seed ) (the seed digest, not a cell key)
accᵢ = keyᵢ = FNV1a( accᵢ₋₁ + "\n" + codeᵢ ) (one key per cell, document order)
The chain is seeded by the interpreter identity, a stable id of the form
python::/path/to/python3::Python 3.11.9
built by interp_id: the language, the resolved interpreter path, and the first
line of <program> --version, run once and memoised. Because the seed flows into
every key, swapping the interpreter (a Python upgrade, a different venv) shifts the
whole chain at once, so the old interpreter’s cached outputs are never served by a new
one. Edit an upstream cell and every downstream key moves with it, even where the
downstream code is byte-for-byte unchanged, so the planner gets its upstream
invalidation with no separate dependency graph to keep correct. (FNV-1a is the
same 64-bit hash the core uses for block ids, chosen because it is small,
deterministic, and stable across runs and tool versions, so a cache written today
still hits tomorrow.)
4.3 The persistent cache (_freeze/)
The warm prefix only exists while a kernel is alive. To skip work across build
runs and preview restarts (when the kernel holds nothing), outputs are also
written to _freeze/<page>.json, keyed by the same cumulative hash
(crates/server/src/freeze.rs). On a cold start the in-memory prefix is empty,
so if every cell’s key hits the disk cache the whole document replays from disk
and never boots the kernel. The kernel starts only when a key is missing (a real
edit), and only for the changed range.
Build post.tmd twice without touching it and the second build replays the plot
from _freeze/ without starting Python. Bump tau to 12 and only that one cell’s
key misses, so only it re-runs; had there been cells after it, their keys would
have moved too, and they would re-run as well.
The cumulative key makes this safe where a naive per-cell cache would be
fragile. It encodes every byte of code the kernel ran to reach this output, so:
- a stale hit is impossible for the axes the key can see (cell code, its
upstream, interpreter identity): the key is computed from content, so there are no
mtime heuristics and nothing to clear by hand. What it cannot see is what a cell
reads (a data file, an environment variable, a fetched URL, the clock, a library
upgraded in place), which is what
#| cache: falseis for; - a different interpreter (say a Python upgrade) busts every key, since it can’t serve outputs the old one computed;
- kernel variable state is never cached (that is the fragile part of per-cell caching), which is why a cold start can only skip a cell when its whole upstream is unchanged, never an arbitrary one in the middle.
What’s stored is just the cell’s output HTML (kernel images are already inline data URIs, so there are no sidecar files).
4.3.1 The on-disk file
_freeze/<stem>.json mirrors the source tree (posts/x.tmd caches to
_freeze/posts/x.json) so it’s easy to inspect, and holds a flat, versioned list of
key/output pairs:
The format has these properties:
versiongating. The loader comparesversionagainst the binary’sFORMAT_VERSIONand, on a mismatch, treats the file as empty rather than replaying entries it might no longer be able to interpret. The version is bumped not only when the file shape changes but when the bundled output format of a cached cell changes (the cell code is identical, so the cumulative key wouldn’t move on its own); the next save rewrites the file fresh. A missing or corrupt file is tolerated the same way: start empty, rewrite on the next save.- Oldest-first order.
entriesis written oldest-first so eviction can drop from the front. The cache keeps up toMAX_ENTRIES(1024) entries orMAX_BYTES(16 MB) per page, whichever binds first, evicting the least-recently-touched. The byte budget is the bound the entry cap cannot provide: a page whose outputs are inline data-URI figures would otherwise dwarf the 80 to 150 MB kernels the warm-page pool already keeps resident, while for text output the entry cap still binds first. Either way it is deep enough that toggling an edit back and forth keeps hitting the cache. - Atomic write. A save serialises to a sibling
*.json.tmpand renames it into place, so a crash mid-write can’t corrupt an existing cache;_freeze/(and any sub-dirs) are created on demand, and a write failure is logged but non-fatal (the build still works, just uncached). packages: what these outputs were produced under. One digest per interpreter identity (the id that seeds the keys, so two interpreters’ entries and digests never mix) over its installedname==versionset (packages.rs). This is the axis the cumulative key structurally cannot see: an in-placepip install --upgrade pandasis the same interpreter reporting the same--version, so every key is unchanged and the old numbers restore. It is recorded only by a run that executed something (stamping a pure replay would relabel yesterday’s outputs as today’s) and compared when a restore comes off disk, so a replay that crossed a package change reports it on stderr, once. The set is probed once per process and again after “Restart kernel”, since installing something is the usual reason to restart. It deliberately does not change what hits.taliesin doctor --format jsonprints the same manifest in full, every package and version plus the digest, so you can find which package changed when the digest moves.
4.3.2 What is never persisted
The planner keeps the following out of the cache, so a bad result is never restored on a later run:
- Failed cells: an exception, a timeout, a cell that never ran or whose kernel died
under it, and an output cut at a size cap. Whether a cell failed travels from the
executor as data (
exec::Failure) beside its HTML and is never read back out of the markup, so a cell that merely prints<pre class="tali-error">or the truncation notice still caches. A transient failure re-runs next time, and a clipped result is never frozen as if complete. The same record is what a build counts as a cell error. #| cache: falsecells, which always re-execute by contract, and everything downstream of one (first_uncacheable, the single definitionplan‘s run range and the persist loop share): the key below such a cell records its upstream’s code, never the value that cell just produced afresh.- Everything downstream of a cell that errored or was interrupted. It left the kernel half-mutated, so every cell after it ran against state its key does not describe.
- Anything a warm kernel re-ran. A kernel that has already executed an earlier
version of a cell (or a cell since deleted) still holds the names it defined, so after
renaming
thresholdtolimita leftoverprint(threshold)keeps printing the old value in that kernel, while a fresh one raisesNameError.LangState::executedcounts every cell the kernel has been sent since it booted, and a run persists only when that count equals the warm prefix it reuses, meaning the kernel ran exactly that prefix and nothing else. In practice the preview persists what a cold kernel computes (first open, after “Restart kernel”, after the page left the warm pool), and the first build after an editing session re-runs from the earliest cell edited in it. - The cached tail the kernel didn’t run. Only cells
[shared, run_end), the cells that executed this rebuild, are recorded as the kernel’s “ran” state; the disk-restored tail is left out, because the live kernel never produced it and recording it would falsely claim warm state the kernel doesn’t hold.
TALIESIN_NO_CACHE disables the cache outright (every lookup misses, nothing is
written), and “Restart kernel” forces a fresh run rather than a replay.
4.4 The warm kernel (kernel.rs)
Kernel { proc, shell, iopub, conn_dir, … } is a spawned interpreter plus its ZMQ
client connections. start():
- picks free ports and writes a Jupyter
connection.json, - spawns the interpreter (
python -m ipykernel_launcher) with stderr piped, and with its working directory set to the document’s own directory (eachExecutoris created.in_dir(...)of its page) so a cell’s relative file writes (audio fromscipy.io.wavfile, asavefigfigure) land beside the source rather than in whatever directory the server was launched from, - connects the
shell+iopubZMQ channels, and races the connect against the process exiting, so a missingipykernelfails in ~1s with its real stderr (“No module named ipykernel”) instead of hanging the 30s connect timeout and reporting something opaque, - reads one iopub message to confirm the SUB subscription is live (sidestepping the ZMQ slow-joiner problem before the first run), and
- runs the startup preambles once against the now-warm kernel: they install the
definePython-to-JS bridge and a matplotlib theme hook that restyles inline figures to follow the page (lazily, the first time a cell uses matplotlib), and make every thread start in a copy of the context of the code that started it (see the parent-header check below).
A preamble that fails is not fatal (the kernel runs cells without the bridge) but it is
not silent either, because otherwise its failure would show up later in another cell as
a different symptom: a NameError on define, or figures that stop matching the page
with no error. So each preamble declares what is lost if it does not run, and one that
raises (or reports on stderr, which is how the matplotlib hook flags a failure it has to
catch itself) prints one console line naming the interpreter, the error and what was
lost.
execute(code) sends an execute_request on the shell channel and collects iopub
messages parented by that request, namely stream (stdout/stderr), execute_result,
display_data, and error, until the kernel returns to idle. It applies clear_output
the way a notebook does, dropping what the cell has shown so far (with wait=True, when
the next output arrives), so an animation that clears before each frame publishes only its
last frame and holds one frame against the output caps.
A silence cap (TALIESIN_CELL_SILENCE, default 600s) guards a wedged cell. It
measures the gap since the last iopub message this cell’s request is the parent
of, not total runtime, so the budget resets on every line the cell prints: a
forty-minute training run that logs an epoch line is alive, while a cell that has
said nothing for ten minutes is treated as a runaway. The parent-header check matters
because iopub is a broadcast channel: a background thread an earlier cell left
running keeps publishing under its own parent header, and counting that as this
cell’s output would disarm the cap for the cell it exists to govern. A thread only has
“its own” header because of the thread preamble: ipykernel reads the header from a
context variable, and a plain threading.Thread that did not inherit the cell’s context
gets whichever cell is running when it prints, which put its output into, and froze it
with, an unrelated later cell. Output a thread prints after its cell has finished is
dropped, since that cell’s output is final. An optional
total wall-clock cap (TALIESIN_CELL_TIMEOUT) is available but off by default,
since bounding total runtime is a cost decision, not a safety one.
A streaming runaway (a while True: print(...) that keeps the channel busy, and so
never trips the silence cap) is caught instead by the output caps: once
MAX_STREAM_BYTES (512 KB of stream text) or MAX_OUTPUTS (4096 items) trips, execute
interrupts immediately, so a cell printing megabytes of output is stopped instead of
having all of it read and discarded. The caps count what the page would keep, after
consecutive chunks of one stream are merged and carriage returns applied (Outputs in
kernel.rs), not the messages that arrived: a progress bar redrawn ten thousand times
is one line of it, so it runs to completion, while a loop that keeps printing new lines
fills the byte cap. A loop that only ever redraws one line with \r is therefore not
stopped by these caps, which is the same case as a long job reporting progress.
On any such hit execute SIGINTs the kernel (the interrupt_mode: signal path, which
raises a KeyboardInterrupt inside the cell) and drains a short grace window
(5s) so the interrupt error and the trailing idle arrive and the channels resync,
leaving the warm kernel usable for the next cell. SIGINT is only a request: a cell that
installs its own handler, or sits in a C extension that never checks signals, outlives
it. When the window runs out with the cell still not idle, execute kills the kernel,
since nothing short of that stops the cell and every later cell would otherwise queue
behind it and wait out its own caps. The page gets an InterruptIgnored output, the
console gets that kernel’s pid (kept out of the page so two builds of one document cannot
differ by a pid), and the executor’s dead-kernel path marks the rest of the run as not
run; the next run starts a fresh kernel. “Restart kernel” kills the requesting page’s
running kernel for the same reason (it is being discarded anyway), and only interrupts a
cell another page is running.
Setting either cap to 0 disables it, and with both off a silent cell is bounded by
nothing but the kernel’s own life. Drop kills the child and removes the connection dir.
4.5 From a kernel message to HTML
Each collected iopub message is normalised into one Output value, and
render_outputs turns the list into the HTML that becomes the cell’s output block. The
mapping is small and explicit:
streambecomes a<pre class="tali-stream">(withtali-stderradded for the stderr channel), HTML-escaped.execute_resultanddisplay_dataare both “rich” outputs; rendering picks the richest representation the kernel offered, in a notebook’s order:text/html, thentext/markdown(through the document’s own renderer, so$x$is KaTeX), thentext/latex(KaTeX in display mode), thenimage/png(wrapped as an inlinedata:<img>, sized by thewidth/heightanImage(...)puts in its metadata), then SVG, then JPEG, thenapplication/json(pretty-printed), thentext/plain(as a<pre>). Thetext/plainfallback is only used when nothing richer exists, since fordisplay(Markdown(...))it is the object’s repr.update_display_datareplaces, in place, every output the cell displayed under the samedisplay_id(a display handle’supdate()); one that names another cell’s display is ignored, since that output is already final.clear_outputdrops what the cell has shown (see the kernel section above).errorbecomes a<pre class="tali-error">holding the traceback with its ANSI colour codes stripped (IPython colourises tracebacks for a terminal). A cell with one is never stored in the freeze cache, so an error is never restored from disk.
In a traceback and on stderr, a path inside the project prints relative to it and any
other path under $HOME as ~/, so a library warning does not publish the author’s home
directory (PathScrub, set per executor by in_project).
4.5.1 Why matplotlib figures render twice
A plain inline matplotlib PNG would bake one foreground colour into the image, so it
would look wrong against the other theme. The Python startup preamble installs (lazily:
before a cell whose source names matplotlib, and after any cell once matplotlib is
loaded, which covers a library drawing with it, such as pandas’ .plot()) a text/html
figure formatter that renders
each figure twice, once recoloured for the light theme and once for the dark, and
emits them together as <img class="tali-fig tali-fig-light"> + <img class="tali-fig tali-fig-dark">. The standalone image/png representation is suppressed so only the
dual-theme HTML survives. The page then swaps which image shows on a data-theme
change, so a plot’s axes and text always match the surrounding page with no
re-execution. Only data colours are left untouched. The recolouring is applied to the inline
representation alone and never to global rcParams, so a cell’s own savefig still
writes a clean print-ready figure (black on white).
4.6 How generated outputs reach the build
Inline kernel images (a matplotlib figure, an image/png, an SVG) are emitted as data:
URIs in the cell’s output HTML, so they ship inside the page and need no sidecar copy.
A file a cell writes to disk (a savefig PNG, audio from scipy.io.wavfile) is
different: it lands in the document’s own directory, because each Executor runs
.in_dir(<doc dir>) and the kernel inherits that working directory (see
the warm kernel). The cell then references it with a
relative src=, e.g. <img src="figures/plot.png">.
The build subcommand collects those references so the output is portable, but the two
build shapes do it differently:
- A single-document
build(build doc.tmd --out <dir>, orbuild doc.tmd out.htmlinto another directory) runscopy_local_assetsover the rendered HTML after execution.local_refsreads every element’ssrc/href/posterover the sharedrender::tagswalker, never a substring scan, so a code sample that merely shows an attribute stays text;is_local_refthen drops external URLs,data:URIs and in-page anchors, any?query/#fragmentis stripped, and each existing file is copied to the same relative path beside the page. The publication rule (includes::publishable, with the page’s folder as the boundary) decides what may be copied: a file above that folder has nowhere to go and is reported as an error, and a different file already at the destination is never overwritten. Because the scan runs after the cells ran, a figure a cell freshly wrote is picked up like any hand-placed image. A separate pass (copy_js_imports) follows a{js}cell’s relativeimport()/fetch()specifiers, which the attribute scan cannot see. An in-place build (output beside the source) skips every self-copy. - A site
buildmirrors, then ships what the pages reference.mirror_assetswalks the whole source tree once and mirrors every non-source file into_site/at the same relative path, skipping.tmd/.bib/.mdsources,_-prefixed and dot entries, editor residue and build-cache residue (*_cache/*_files).deploy_referenced_sourcesthen reads each built page through the samelocal_refswalk and ships a file a page references that the mirror left out, such as an_images/picture or a linked.md. Both judge a file by the one publication rule,includes::publishable, so a dot-path or a symlink out of the repository never ships. Both run after the pages are built, so a figure a cell writes in this build is published with it.
_freeze/ restores a cell’s rendered output, never the files the cell wrote: a cell that
replays from the cache does not run, so a generated file deleted since (a git clean, a
fresh clone) stays missing until the cell runs again. Commit a file a cell writes, or
force a run with Restart kernel or TALIESIN_NO_CACHE=1. The check that a page’s
local files exist runs after its cells when they run, so a figure a cell writes on the
first build is not reported missing.
4.7 Resilience
ensure_kernel makes a missing or crashed kernel a recoverable, visible condition:
- Crashes mid-run: the cells after the crash fail fast with a “kernel died” notice instead of each waiting out the full cell timeout on a kernel that will never reply; the next rebuild detects the dead kernel, respawns it, and re-runs from the top.
- Failed to start: back off for
KERNEL_RETRY_AFTER(20s) before retrying, so a badTALIESIN_PYTHONdoesn’t re-hang every save, but once it is fixed the kernel recovers on its own within a few saves. - The interpreter’s own start error (
last_error) is reported as a diagnostic, so “kernel unavailable” tells you why.
With no kernel, cells render as highlighted source and the preview shows a quiet “kernel unavailable” note: the document still builds.