dev log ยท 31 October 2026

A working PoC with Hot Code Reloading and Incremental Compilation

A running Nim process receives new cells without restarting, and earlier state survives.

proof of concept ยท Nim 2.4.0 built from source, gcc, Linux ยท rendered with nimib

Result: a running Nim process can receive new "cells" (small Nim modules) without restarting. Each new cell runs its top-level code once, can use everything earlier cells defined, and the values of earlier variables survive. Adding a cell costs about 1 s of compilation with nim c, and about 0.4 s with incremental compilation (nim ic), measured separately.

This answers the question of the previous post, How could a Nim REPL work?, by testing its core mechanism: each REPL input becomes a module, and the long-running process is the session. It is a mechanism test, not a REPL yet: the cells are written by hand and there is no prompt.

The pieces

Hot code reloading (HCR, --hotCodeReloading:on) compiles every module to its own shared library. The main module is the one part that can't be reloaded, so it is a fixed driver: it waits for a go file to appear, then calls performCodeReload(), twice.

../experiments/hcr-cells/main.nim
import std/hotcodereloading
import cells
import std/[os]
var i = 0
while i < 2:
  sleep 200
  if fileExists("go"):
    removeFile("go")
    echo "reloading"
    performCodeReload()
    inc i

main imports cells, a module whose only job is to import every cell so far. At the start it imports just c1; the run script rewrites it to import c1, c2 and then import c1, c2, c3. This is the file a REPL would regenerate after each input.

The cells are ordinary modules. Their exported (*) symbols are what later cells can see, so a REPL would add the * when it turns an input into a module.

../experiments/hcr-cells/c1.nim
var x* = 41
echo "c1 init, x=", x
../experiments/hcr-cells/c2.nim
import c1
var y* = x + 1
echo "c2 init, y=", y
proc twice*(a: int): int = 2*a
echo "twice(y)=", twice(y)
../experiments/hcr-cells/c3.nim
import c1, c2
x = 100
echo "c3: x=", x, " y=", y, " twice(x)=", twice(x)

c3 assigns to x, a variable owned by c1, and reads y, owned by c2.

Running it

run.nim, a small Nim program run with nim r, builds the two runtime libraries HCR needs (libnimrtl.so and libnimhcr.so), compiles the host with only c1, and starts it in the background. Then it adds c2, recompiles, and creates go; then the same for c3. It fails if any step fails, or if the host has not finished 30 s after the last reload.

../experiments/hcr-cells/run.nim
## Cells as modules with hot code reloading: start a host with one cell,
## then add c2 and c3 to the running process.
## Run with `nim r experiments/hcr-cells/run.nim` (needs Nim >= 2.4).
import std/[exitprocs, os, osproc, times]

const
  flags = "--hints:off --warnings:off"
  hostTimeout = initDuration(seconds = 30) # the host should exit after its two reloads

proc compile(args: string) =
  ## Runs `nim c` and stops at the first failure.
  let cmd = "nim c " & flags & " " & args
  if execShellCmd(cmd) != 0:
    quit "failed: " & cmd

proc setCells(cells: string) =
  ## `cells.nim` imports every cell so far; a REPL would rewrite it after each input.
  writeFile("cells.nim", "import " & cells & "\n")

setCurrentDir(currentSourcePath().parentDir)
addExitProc(proc () = setCells "c1") # leave cells.nim as committed, also on failure
let nimLib = findExe("nim").parentDir.parentDir / "lib"

# the two runtime libraries HCR needs
compile "--app:lib -d:createNimRtl --out:libnimrtl.so " & nimLib / "nimrtl.nim"
compile "--app:lib -d:createNimHcr --out:libnimhcr.so " & nimLib / "nimhcr.nim"

# the host starts with only c1, in the background
setCells "c1"
removeFile "go"
compile "--hotCodeReloading:on --out:host main.nim"
putEnv("LD_LIBRARY_PATH", getCurrentDir())
let host = startProcess(getCurrentDir() / "host", options = {poParentStreams})
addExitProc(proc () = (if host.running: host.kill))

sleep 2000
for cells in ["c1, c2", "c1, c2, c3"]:
  setCells cells
  # the running host's executable can't be overwritten; the cell libraries are what matter
  compile "--hotCodeReloading:on --out:host.next main.nim"
  writeFile("go", "") # the host reloads when it sees this file
  sleep 2000

let deadline = getTime() + hostTimeout
while host.running and getTime() < deadline:
  sleep 100
if host.running:
  quit "the host did not exit within " & $hostTimeout
if host.peekExitCode != 0:
  quit "the host failed with exit code " & $host.peekExitCode

The output below was captured when this page was built, by running the program above.

echo runExperiment("../experiments/hcr-cells/run.nim")
c1 init, x=41
reloading
c2 init, y=42
twice(y)=84
reloading
c3: x=100 y=42 twice(x)=200

What the output shows:

That is the behaviour a REPL session needs: earlier inputs never re-run, and their state stays. INim, by contrast, re-runs every earlier input each time.

Cost of adding a cell

Recompiling the host after adding a cell took about 1.05 s with nim c (gcc, warm cache). experiments/ic-chain measures the same step with nim ic on a chain of 20 cell modules, each importing and re-exporting the previous one. It hasn't been combined with HCR yet, so this is a separate number. Its program:

../experiments/ic-chain/run.nim
## Time `nim ic` on a chain of 20 cell modules, then on adding a 21st cell.
## Run with `nim r experiments/ic-chain/run.nim`
## (needs Nim >= 2.4 built with nifler and nifmake).
import std/[os, osproc, monotimes, times, strformat, tempfiles]

proc run(cmd: string) =
  ## Runs a command, shows its output and stops at the first failure.
  let (output, exitCode) = execCmdEx(cmd)
  if exitCode != 0:
    quit output & "\nfailed: " & cmd
  stdout.write output

proc timed(cmd: string) =
  ## Runs a command quietly and prints how long it took.
  let start = getMonoTime()
  let (output, exitCode) = execCmdEx(cmd)
  if exitCode != 0:
    quit output & "\nfailed: " & cmd
  let seconds = (getMonoTime() - start).inMilliseconds.float / 1000
  echo &"{cmd} took {seconds:.2f} s"

# a fresh directory, so the IC cache starts empty
let dir = createTempDir("ic-chain-", "")
setCurrentDir dir

# c1 defines x; each next cell imports and re-exports the previous one
writeFile("c1.nim", "import std/[strutils, tables]\nvar x* = 1\n")
for i in 2..20:
  let prev = "c" & $(i - 1)
  writeFile(&"c{i}.nim", &"import {prev}\nexport {prev}\nvar x{i}* = x + {i}\n")
writeFile("main.nim", "import c20\necho x20\n")
timed "nim ic main.nim"
run "./main"

# add a 21st cell
writeFile("c21.nim", "import c20\nexport c20\nvar x21* = x20 * 2\n")
writeFile("main.nim", "import c21\necho x21\n")
timed "nim ic main.nim"
run "./main"

setCurrentDir getTempDir()
removeDir dir

Its output, captured when this page was built:

echo runExperiment("../experiments/ic-chain/run.nim")
nim ic main.nim took 4.19 s
21
nim ic main.nim took 0.30 s
42

The first nim ic builds the stdlib and all 20 cells into the IC cache. After that, adding a 21st cell only checks and compiles the new module.

What happened on Nim 2.2.6

The same host crashed at startup with a segfault on Nim 2.2.6 (the version the nimlang package currently ships): the generated main module never initialised its HCR function pointers. This happened with orc and refc, threads on and off. On 2.4.0 it works with the defaults.

Not covered yet