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:
c1 init, x=41is printed once, at startup. Neither reload runsc1again.- After the first reload,
c2runs for the first time and seesxfromc1(y = 42). - After the second reload,
c3changesxto 100.yis still 42:c2was not re-run, so its value is the one computed earlier, andtwicefromc2is callable fromc3.
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
- Generating cells from input: adding
*to top-level declarations, writingcells.nim, andimport cell_K except xwhen a later cell redefines a name. - Showing values of bare expressions (
>>> xprinting value and type). - HCR together with IC: the 1 s above is
nim c; whethernim ic --hotCodeReloading:onworks hasn't been tried. - Other C compilers: only gcc. nimlang ships zig cc.
- Limits of HCR: changing a type defined in an earlier cell can't be reloaded, and under orc each reload keeps the old library loaded, so memory grows over a long session.
- The host executable: the running
hostcan't be overwritten while it runs ("Text file busy"), so the run program writes the recompiled one tohost.next. Only the cell libraries matter for the reload.