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 kernels forked before you need them.

Our post.tmd has a single {python} cell. This chapter is what happens when it runs, and, more to the point, the lengths Taliesin goes to so that it usually doesn’t have to.

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 per language, 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
    langs: HashMap<&'static str, LangState>,  // "python" → warm kernel + 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 very 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; what to do about a document you did not write is the CLI reference’s subject, not this chapter’s. Otherwise it groups the cells by language, preserving document order, runs each language’s cells, 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. Per language, it answers one question, which contiguous range of cells must actually 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: Each language’s 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, exactly the dependency a notebook needs. 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

The whole scheme rests on one tiny function. For a language’s 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 not by an empty string but 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: an unchanged doc builds instantly. The kernel starts only when a key is missing (a real edit), and only for the changed range.

post.tmd makes this vivid. Build it twice without touching it and the second build replays the plot from _freeze/ without ever 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.

That cumulative key is what 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): no mtime heuristics, nothing to clear by hand; the content is the key. 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 deliberately 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": 4,
  "packages": { "python": "5b5d92121322a910" },
  "entries": [
    { "k": "9f1c…", "v": "<pre class=\"tali-stream\">…</pre>" },
    { "k": "a37e…", "v": "<img class=\"tali-fig tali-fig-light\" …>" }
  ]
}

A few properties earn their place:

  • 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 restoring instantly.
  • 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 language over the interpreter’s 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 actually 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 says so on stderr, once per language. It deliberately does not change what hits. taliesin doctor --format json prints the same manifest in full, every package and version plus the digest, which is what turns “the digest moved” into “which pandas”.

4.3.2 What is never persisted

The planner is deliberately stingy about what it hands the cache, so a bad result can never bake in:

  • Error outputs (anything carrying class="tali-error": an exception, a timeout, or the mid-run “kernel died” marker), so a transient failure re-runs next time. The check matches the class, not a bare substring, so a cell that merely prints the text “tali-error” still caches.
  • Truncated outputs (a result clipped at the size cap, marked taliesin: output truncated), so a clipped result is never frozen as if complete.
  • #| 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.
  • The cached tail the kernel didn’t actually run. Only cells [shared, run_end), the cells that genuinely 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 { child, 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) 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 each language’s startup preambles once against the now-warm kernel: Python installs 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).

A preamble that fails is not fatal (the kernel runs cells perfectly well without the bridge) but it is not silent either, because its failure would otherwise surface cells later wearing a disguise: a NameError on define, or figures that quietly stop matching the page. 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 the casualty.

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.

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 the real runaway. The parent header is load-bearing, not a detail: iopub is a broadcast channel, so a background thread an earlier cell left running keeps publishing under its parent header, and counting that as this cell’s output would disarm the cap for the one cell it exists to govern. 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 megabyte-spewing cell stops flooding the reader rather than being read and discarded. That is why removing the wall-clock default costs no protection.

On any such hit execute SIGINTs the kernel (the interrupt_mode: signal path, which surfaces as 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 stops waiting and says so: the page gets an InterruptIgnored output (the cell is still running inside the warm kernel, and nothing short of a restart reclaims it) and the console gets that kernel’s pid, kept out of the page so two builds of one document cannot differ by a pid. 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, preferring text/html, then image/png (wrapped as an inline data: <img>), then SVG, then JPEG, then text/plain (as a <pre>). The cell’s text/plain fallback is only used when nothing richer exists.
  • error becomes a <pre class="tali-error"> holding the traceback with its ANSI colour codes stripped (IPython colourises tracebacks for a terminal). This is the one class the freeze cache refuses to store, so an error never sticks.

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, the first time a cell touches matplotlib) 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 baked straight into the cell’s output HTML, so they ship inside the page and need no sidecar copy at all. 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. 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 does not scan HTML at all. mirror_assets walks the whole source tree once and mirrors every non-source file into _site/ at the same relative path, skipping .tmd/.bib/.Rproj sources, _-prefixed and dot entries, and build-cache residue (*_cache/*_files). Since this mirror runs before the pages render, a figure a cell generates this build is not in the mirror unless it was already on disk (for instance restored from _freeze/ on a prior run); a clean rebuild of a doc that writes a new sidecar file is the one case to keep in mind.

4.7 Resilience

ensure_kernel makes a missing or crashed kernel a recoverable, visible condition rather than a latch:

  • 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 fixing it self-heals within a few saves.
  • The interpreter’s own start error (last_error) is surfaced as a diagnostic, so “kernel unavailable” tells you why.

With no kernel at all, cells render as highlighted source and the preview shows a quiet “kernel unavailable” note: the document still builds.