Skip to content
Taliesin Internals

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:

struct Executor {
    python: Resolved,           // interpreter.rs: path + provenance + search trail
    state: LangState,           // the warm kernel + the cells it has run (ran-list)
    freeze: FreezeCache,        // persistent _freeze/ output cache (on disk)
    no_exec: bool,              // TALIESIN_NO_EXEC / --no-exec: cells as source
    force_next: bool,           // "Restart kernel" forces a full re-run
}

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/"]
Figure 4.1: The cells split into three zones; only the run range boots the kernel.

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: false is 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:

{
  "version": 5,
  "packages": { "python::/proj/.venv/bin/python::Python 3.12.3": "5b5d92121322a910" },
  "entries": [
    { "k": "9f1c…", "v": "<pre class=\"tali-stream\">…</pre>" },
    { "k": "a37e…", "v": "<img class=\"tali-fig tali-fig-light\" …>" }
  ]
}

The format has these properties:

  • version gating. The loader compares version against the binary’s FORMAT_VERSION and, 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. entries is written oldest-first so eviction can drop from the front. The cache keeps up to MAX_ENTRIES (1024) entries or MAX_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.tmp and 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 installed name==version set (packages.rs). This is the axis the cumulative key structurally cannot see: an in-place pip install --upgrade pandas is 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 json prints 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: false cells, which always re-execute by contract, and everything downstream of one (first_uncacheable, the single definition plan‘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 threshold to limit a leftover print(threshold) keeps printing the old value in that kernel, while a fresh one raises NameError. LangState::executed counts 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():

  1. picks free ports and writes a Jupyter connection.json,
  2. spawns the interpreter (python -m ipykernel_launcher) with stderr piped, and with its working directory set to the document’s own directory (each Executor is created .in_dir(...) of its page) so a cell’s relative file writes (audio from scipy.io.wavfile, a savefig figure) land beside the source rather than in whatever directory the server was launched from,
  3. connects the shell + iopub ZMQ channels, and races the connect against the process exiting, so a missing ipykernel fails in ~1s with its real stderr (“No module named ipykernel”) instead of hanging the 30s connect timeout and reporting something opaque,
  4. reads one iopub message to confirm the SUB subscription is live (sidestepping the ZMQ slow-joiner problem before the first run), and
  5. runs the startup preambles once against the now-warm kernel: they install the define Python-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:

  • stream becomes a <pre class="tali-stream"> (with tali-stderr added for the stderr channel), HTML-escaped.
  • execute_result and display_data are both “rich” outputs; rendering picks the richest representation the kernel offered, in a notebook’s order: text/html, then text/markdown (through the document’s own renderer, so $x$ is KaTeX), then text/latex (KaTeX in display mode), then image/png (wrapped as an inline data: <img>, sized by the width/height an Image(...) puts in its metadata), then SVG, then JPEG, then application/json (pretty-printed), then text/plain (as a <pre>). The text/plain fallback is only used when nothing richer exists, since for display(Markdown(...)) it is the object’s repr.
  • update_display_data replaces, in place, every output the cell displayed under the same display_id (a display handle’s update()); one that names another cell’s display is ignored, since that output is already final.
  • clear_output drops what the cell has shown (see the kernel section above).
  • error becomes 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>, or build doc.tmd out.html into another directory) runs copy_local_assets over the rendered HTML after execution. local_refs reads every element’s src/href/poster over the shared render::tags walker, never a substring scan, so a code sample that merely shows an attribute stays text; is_local_ref then drops external URLs, data: URIs and in-page anchors, any ?query/#fragment is 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 relative import()/fetch() specifiers, which the attribute scan cannot see. An in-place build (output beside the source) skips every self-copy.
  • A site build mirrors, then ships what the pages reference. mirror_assets walks the whole source tree once and mirrors every non-source file into _site/ at the same relative path, skipping .tmd/.bib/.md sources, _-prefixed and dot entries, editor residue and build-cache residue (*_cache/*_files). deploy_referenced_sources then reads each built page through the same local_refs walk 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 bad TALIESIN_PYTHON doesn’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.