Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

clojurust is a Rust-hosted dialect of the Clojure programming language. It reads and executes .cljrs and .cljc source files, provides an interactive REPL, and can AOT-compile programs to standalone native binaries.

Goals

  • Interpreter — read and execute .cljrs (native) and .cljc (cross-platform) source files.
  • Reader conditionals.cljc files use #?(:rust ... :clj ... :default ...) to branch on platform; the platform key for clojurust is :rust.
  • Rust interop — Clojure code can call Rust functions through a defined set of conventions and type-marshalling primitives.
  • Garbage collector — a tracing GC manages all Clojure values; an optional region-based allocator is available for allocation-heavy code paths.
  • AOT compilationcljrs compile produces a standalone native binary via Cranelift, or a WebAssembly module with --target wasm. See the WebAssembly chapter.
  • Async & I/Oclojure.core.async channels and non-blocking file I/O ship as optional crates layered over the interpreter. See the Async & I/O chapter.
  • Embeddable — the same runtime the CLI drives can be built inside your own Rust program as a scripting, rules, or plugin layer. See the Embedding chapter.

Source file extensions

ExtensionMeaning
.cljrsNative clojurust source. Always evaluated under the :rust platform.
.cljcCross-platform source. Reader conditionals select the active branch; clojurust evaluates :rust branches.

Quick start

cljrs run hello.cljrs
cljrs repl
cljrs eval '(+ 1 2)'
cljrs compile app.cljrs -o app
cljrs test --src-path test

Detailed documentation for each subcommand is in the CLI chapter.

Command-line tool

cljrs is the command-line entry point for clojurust. It provides subcommands for running files, starting a REPL, evaluating expressions, compiling to native binaries, running tests, and managing dependencies.

cljrs [GLOBAL OPTIONS] <SUBCOMMAND> [SUBCOMMAND OPTIONS]

Subcommands

SubcommandDescription
runInterpret a .cljrs or .cljc source file
replStart an interactive REPL
evalEvaluate a single expression and print the result
compileAOT-compile a source file to a native binary
testRun clojure.test namespaces
depsManage project dependencies declared in cljrs.edn
irInspect, pre-lower, and visualize the IR (build/dump/viz)

Global options

These options are accepted by every subcommand.

--stack-size-mb <MB>

Set the main thread’s stack size in megabytes. Defaults to 64 MB. Increase this value if you encounter stack overflows in deeply recursive code.

cljrs --stack-size-mb 128 run my-program.cljrs

--debug

Enable debug-level logging. Prints internal diagnostics to stderr.

--trace

Enable trace-level logging (implies --debug). Much more verbose than --debug.

-X <LEVEL:FEATURES>

Feature-scoped logging. Enables logging at LEVEL for the named comma-separated FEATURES only.

cljrs -X debug:gc,jit run my-program.cljrs
cljrs -X trace:jit run my-program.cljrs

Available levels: debug, trace. Available features: gc, reader, jit, and others.

--gc-stats [FILE]

Print garbage-collector statistics on exit. Pass a file path to write the report there; omit the path to write to stdout.

Only honoured by run, eval, and test.

cljrs --gc-stats run my-program.cljrs       # stats to stdout
cljrs --gc-stats gc.log run my-program.cljrs # stats to file

--verify-commit-signatures

Require valid PGP or SSH signatures on every versioned commit before executing historical code. Verification is native (no git/gpg subprocess) and checks the signature against the keys listed in :trusted-signers. Off by default. Can also be enabled per-project in cljrs.edn via :verify-commit-signatures true.

Project configuration: cljrs.edn

When any of run, repl, compile, or test starts, clojurust walks up the directory tree from the current working directory and loads the nearest cljrs.edn it finds. The :paths declared in that file are appended to the source-path list (CLI --src-path values come first).

See deps for the full format of cljrs.edn.

cljrs run

Interpret a .cljrs or .cljc source file.

cljrs run [OPTIONS] <FILE>

All top-level forms in FILE are evaluated in order. The return value of the last form is discarded; side effects (output, file writes, etc.) are the intended mechanism for a run program to produce observable results.

Arguments

ArgumentDescription
<FILE>Path to the source file (.cljrs or .cljc)

Options

--src-path <DIR>

Add DIR to the list of directories searched when resolving require. May be repeated to add multiple directories.

cljrs run --src-path src --src-path lib my-program.cljrs

Paths declared in :paths of the nearest cljrs.edn are appended automatically after CLI paths.

--gc-soft-limit-mb <MB>

Soft memory limit for the GC in megabytes. When live heap exceeds this value, a collection is triggered at the next safepoint.

--gc-hard-limit-mb <MB>

Hard memory limit for the GC in megabytes. When live heap exceeds this value, a collection is forced immediately.

Examples

# Run a file in the current directory
cljrs run hello.cljrs

# Run with a source path for namespace resolution
cljrs run --src-path src src/myapp/core.cljrs

# Run and write GC stats to stderr on exit
cljrs --gc-stats run my-program.cljrs

cljrs repl

Start an interactive read–eval–print loop.

cljrs repl [OPTIONS]

The REPL reads one expression at a time, evaluates it, and prints the result. Multi-line input is supported: the REPL continues reading until all open brackets are closed before evaluating.

Type :quit or press Ctrl-D to exit.

Options

--src-path <DIR>

Add DIR to the source-path list for require. May be repeated.

--gc-soft-limit-mb <MB> / --gc-hard-limit-mb <MB>

GC memory limits. See run for details.

REPL behaviour

  • The initial namespace is user.
  • The standard library (clojure.core and friends) is pre-loaded.
  • *1, *2, *3 hold the last three non-nil results.
  • *e holds the last exception.
  • nil results are printed as nil.

Line editing

When clojurust is built with the enable-rustyline feature, the REPL uses rustyline for line editing, including history and readline-style key bindings.

Without that feature, a simpler line reader is used that still supports multi-line input but has no history or key bindings.

Example session

$ cljrs repl
clojurust REPL (type :quit to exit)

=> (+ 1 2)
3
=> (def x 42)
=> x
42
=> (map inc [1 2 3])
(2 3 4)
=> :quit
Bye.

cljrs eval

Evaluate a single Clojure expression and print the result.

cljrs eval '<EXPR>'

The expression is evaluated in a fresh environment with the standard library loaded. If the result is non-nil, it is printed to stdout. A nil result produces no output.

Arguments

ArgumentDescription
<EXPR>The expression to evaluate, as a string

Examples

cljrs eval '(+ 1 2)'
# → 3

cljrs eval '(map str (range 5))'
# → ("0" "1" "2" "3" "4")

cljrs eval '(println "hello")'
# prints: hello
# (println returns nil so no value line is printed)

Notes

eval does not accept --src-path. If you need to require namespaces from a source tree, use run with a small script file instead.

cljrs compile

AOT-compile a source file to a standalone native binary.

cljrs compile [OPTIONS] <FILE> --out <OUT>

The compiler lowers the source file through the IR pipeline and emits a native binary via Cranelift. The resulting binary statically links the clojurust runtime, GC, and standard library; it has no runtime dependency on the cljrs tool.

Arguments

ArgumentDescription
<FILE>Source file to compile (or a directory when --test is used)

Required options

-o, --out <OUT>

Output path for the compiled binary.

Optional options

--src-path <DIR>

Add DIR to the source path for require resolution. May be repeated.

--test

Compile a test harness instead of a regular program. When this flag is set, FILE should be a directory; the compiler discovers all .cljrs/.cljc files in that directory and builds a binary that runs all clojure.test tests found in them.

--target <TARGET>

Select the code-generation backend. Defaults to native (a Cranelift native binary). wasm emits a WebAssembly module instead — see Targeting WebAssembly.

--gc-soft-limit-mb <MB> / --gc-hard-limit-mb <MB>

GC memory limits baked into the compiled binary, not the compilation process itself.

Examples

# Compile a single file
cljrs compile src/myapp/core.cljrs --out myapp

# Compile and run
cljrs compile src/myapp/core.cljrs --out myapp && ./myapp

# Compile a test binary
cljrs compile --test test/ --out run-tests && ./run-tests

Targeting WebAssembly

cljrs compile src/myapp/core.cljrs --target wasm -o myapp.wasm

With --target wasm the compiler runs the same IR pipeline but emits a .wasm module — the entry namespace and every lowerable required namespace bundled together — instead of a native binary. The emitted module is validated with wasmparser. --target wasm cannot be combined with --test (there is no wasm test harness yet).

Status: code generation complete; runtime linking in progress. The emitted module imports its runtime bridge, linear memory, and function table from a "rt" module that the wasm runtime must satisfy — that linking step, and wiring the IR interpreter in as the dynamic-code tier, is not yet done. See the WebAssembly chapter.

Notes

AOT compilation is based on Cranelift (native) or wasm-encoder (wasm). Not all language features are yet supported in AOT mode; in particular, features that rely on dynamic dispatch or late binding may fall back to interpreted execution within the compiled program.

Native Rust code

If cljrs.edn contains a :rust key, cljrs compile links the declared Rust crate into the binary and calls its cljrs_init function before any Clojure code runs. See AOT mode for details.

cljrs test

Run clojure.test test namespaces.

cljrs test [OPTIONS] [NAMESPACES...]

Loads each namespace, calls clojure.test/run-tests on it, and prints a summary of passes, failures, and errors. Exits with code 0 if all tests pass, 1 if any fail or error, and 2 if no test namespaces are found.

Arguments

ArgumentDescription
[NAMESPACES...]Namespace names to test (e.g. myapp.core-test). If omitted, all namespaces in --src-path directories are discovered automatically.

Options

--src-path <DIR>

Source directory to search for test namespaces. May be repeated. Namespace discovery translates file paths to namespace names by replacing path separators with . and underscores with -.

cljrs test --src-path test

-v, --verbose

Print each passing assertion as well as failures. Useful for identifying which test is hanging.

--gc-soft-limit-mb <MB> / --gc-hard-limit-mb <MB>

GC memory limits. See run for details.

Namespace discovery

When no explicit namespaces are given, cljrs test walks all --src-path directories and converts every .cljrs and .cljc file to a namespace name:

test/myapp/core_test.cljrs  →  myapp.core-test
test/myapp/util_test.cljc   →  myapp.util-test

Output format

Ran 12 tests containing 48 assertions across 2 namespace(s) in 0.3s.
48 passed, 0 failed, 0 errors.

All tests passed.
══════════════════════════════════════════════════════════════

On failure, a breakdown by namespace is printed before the summary line.

Examples

# Run all tests discovered under test/
cljrs test --src-path test

# Run specific namespaces
cljrs test --src-path test myapp.core-test myapp.util-test

# Verbose output
cljrs test --src-path test --verbose

cljrs deps

Manage project dependencies declared in cljrs.edn.

cljrs deps <SUBCOMMAND>

Subcommands

SubcommandDescription
fetchClone or update git dependencies
statusShow which dependencies are cached and which are missing

fetch

cljrs deps fetch [NAME]

Clone or update git dependencies from cljrs.edn. Without a NAME, fetches every git dependency declared in the nearest cljrs.edn. With a NAME, fetches only that one dependency.

Git repositories are cached in ~/.cljrs/cache/git/. Network access only occurs when this command is run explicitly — the runtime never fetches dependencies automatically.

If a versioned symbol or namespace requires a git dependency that is not in the local cache, the runtime raises a clear error:

error: dependency 'my.lib' is not cached locally.
       run `cljrs deps fetch` to download it.

Examples

cljrs deps fetch           # fetch all git deps
cljrs deps fetch my.lib    # fetch only 'my.lib'

status

cljrs deps status

Print the cache status of every dependency declared in the nearest cljrs.edn.

my.lib:    cached (sha: abc1234ef, url: https://github.com/user/my-lib)
dev-tools: NOT cached — run `cljrs deps fetch` (sha: 9f3a112b, url: ...)
vendor:    local dep at ../vendor/utils — ok

Exits with code 0 if all dependencies are satisfied, 1 otherwise.


cljrs.edn format

clojurust discovers project configuration by walking up the directory tree from the current working directory until it finds a cljrs.edn file. The file is valid clojurust EDN:

{:paths ["src" "resources"]

 :deps
 {my.lib    {:git/url "https://github.com/user/my-lib"
              :git/sha "abc1234ef"}
  dev-tools {:git/url "https://github.com/user/dev-tools"
              :git/sha "9f3a112b"}
  vendor    {:local/root "../vendor/utils"}}

 :aliases
 {:dev  {:extra-paths ["dev"]}
  :test {:extra-paths ["test"]
         :extra-deps  {test-tools {:git/url "..."
                                   :git/sha "..."}}}}

 :verify-commit-signatures true

 ; Keys allowed to sign versioned commits (inline key or path to a key file).
 :trusted-signers ["ssh-ed25519 AAAAC3NzaC1lZDI1NTE5... maintainer@example.com"
                   "keys/release-signing.asc"]

 ; Optional: embed a Rust crate for native interop
 :rust {:crate "."
        :init  "my_project::cljrs_init"}}

Keys

KeyTypeDescription
:pathsvector of stringsDirectories to add to the source path. Equivalent to --src-path on the CLI.
:depsmapMap from dependency name (symbol) to dependency descriptor.
:aliasesmapNamed alias maps with :extra-paths and :extra-deps.
:verify-commit-signaturesbooleanIf true, require valid PGP/SSH signatures (verified natively) on all versioned commits.
:trusted-signersvector of stringsPublic keys allowed to sign versioned commits. Each entry is an inline key (armored PGP or OpenSSH) or a path to a key file relative to cljrs.edn.
:rustmapEmbedded Rust crate for native interop. See Rust Interop.

Git dependency URLs

:git/url accepts https:// URLs and local paths (and file://), all fetched in-process with pure-Rust gitoxide — no git binary required. The cljrs binary additionally fetches ssh:// and scp-like git@host:path URLs natively over SSH: host keys are verified against ~/.ssh/known_hosts, and authentication uses a running ssh-agent ($SSH_AUTH_SOCK). Other schemes (git://, http://) are rejected.

:rust key

:rust {:crate "."                       ; path to Cargo.toml directory
       :init  "my_project::cljrs_init"} ; Rust path to the init function
Sub-keyDescription
:crateDirectory containing the user’s Cargo.toml, relative to cljrs.edn.
:initFully-qualified Rust path to the init function. The first :: segment is treated as the crate name.

When :rust is present, cljrs run and cljrs repl automatically load the compiled shared library from <crate>/target/debug/lib<name>.so (or equivalent). Build it first with cljrs build-native.

Dependency descriptors

Git dependency:

my.lib {:git/url "https://github.com/user/my-lib"
        :git/sha "abc1234ef"}

:git/sha must be at least a 7-character commit prefix. The full commit hash is recommended for reproducibility.

Local dependency:

vendor {:local/root "../vendor/utils"}

:local/root is a path relative to the cljrs.edn file’s directory.

cljrs build-native

Compile the project’s embedded Rust crate to a shared library so that cljrs run and cljrs repl can load native functions at startup.

cljrs build-native [--release]

This command reads the :rust key from the nearest cljrs.edn, runs cargo build inside the declared crate directory, and prints the path of the resulting library to stdout.

Options

--release

Build in release mode (cargo build --release) instead of the default debug mode. Use this when profiling or shipping.

What it does

  1. Locates cljrs.edn by walking up the directory tree.
  2. Reads :rust :crate (the directory containing the user’s Cargo.toml) and :rust :init (the fully-qualified Rust path to the init function).
  3. Derives the crate name from the first :: segment of the init path — e.g. "my_project::cljrs_init"my_project.
  4. Runs cargo build [--release] in the crate directory.
  5. Prints the output library path on success:
    • Linux: target/debug/libmy_project.so
    • macOS: target/debug/libmy_project.dylib
    • Windows: target/debug/my_project.dll

Auto-loading

Once the library exists, cljrs run and cljrs repl load it automatically on startup — no flags required. If the library is absent (not yet built or deleted), a warning is printed but the interpreter continues; calls to unregistered native functions will produce a runtime error.

Example

# Build the native library
cljrs build-native

# Now run Clojure code that calls native functions
cljrs run src/main.cljrs

See Rust Interop for a complete walkthrough.

cljrs ir

Inspect, pre-lower, and visualize clojurust’s intermediate representation (IR). Groups three subcommands:

cljrs ir <build|dump|viz> [OPTIONS]
SubcommandDescription
buildPre-lower namespaces to IR and write a serialized bundle
dumpPrint a human-readable dump of a serialized IR bundle
vizRender the optimised IR for a source file to a self-contained HTML page

cljrs ir build

Boots a standard environment, lowers every function in the requested namespaces to IR, and serializes the result to a bundle file.

cljrs ir build [OPTIONS]

A bundle produced by build is loaded back at startup with the public cljrs_runtime::tiered::load_prebuilt_ir API, which matches bundle entries to the live ir_arity_ids assigned when the target functions are defined and populates the IR cache directly — the functions execute at Tier 1 (the IR interpreter) from their very first call, skipping the warmup that background lowering normally needs. This is most useful for cutting cold-start latency on targets that can’t run the background lowering worker, such as an embedder built for wasm32.

Options

-n, --ns <NS>

Namespace to lower. May be repeated. Defaults to clojure.core if omitted. Non-clojure.core namespaces are required from --src-path before lowering.

-o, --output <PATH>

Output file path for the serialized IR bundle. Defaults to ir_bundle.bin.

--src-path <DIR>

Add DIR to the source path used to resolve --ns namespaces other than clojure.core. May be repeated.

-v, --verbose

Print per-arity lowering progress to stderr.

Example

cljrs ir build --ns clojure.core -o core.ir.bin
cljrs ir build --ns my.app.core --src-path src -o app.ir.bin -v

cljrs ir dump

Print a human-readable dump of every function in a serialized IR bundle.

cljrs ir dump <INPUT>

Arguments

ArgumentDescription
<INPUT>Path to a bundle written by cljrs ir build

Example

cljrs ir dump app.ir.bin

cljrs ir viz

Render the optimised IR for a source file to a self-contained HTML page.

cljrs ir viz [OPTIONS] <FILE>

The HTML output shows the source side-by-side with the IR, with regions colour-coded by the bump-allocation optimiser’s results. Allocations that did not make it into a region are annotated with their escape verdict and the blamed use site.

This subcommand is primarily a debugging aid for the IR optimisation pipeline.

Arguments

ArgumentDescription
<FILE>Source file to lower to IR

Options

-o, --out <FILE>

Output path for the HTML file. If omitted, the output is written alongside the source file with an .ir.html extension:

src/myapp/core.cljrs  →  src/myapp/core.cljrs.ir.html

--src-path <DIR>

Add DIR to the source path for require resolution. May be repeated.

--quiet

Suppress the [ir viz] wrote … progress line on stderr.

Example

cljrs ir viz src/myapp/core.cljrs
# writes: src/myapp/core.cljrs.ir.html

cljrs ir viz src/myapp/core.cljrs --out /tmp/core.html --quiet

Open the resulting HTML file in a browser to explore the IR.

Interpreting the output

  • Green regions — allocations placed in a bump-allocation region; they do not incur GC heap pressure.
  • Red / yellow annotations — allocations that escaped the region, labelled with the reason (returned, captured by closure, stored in heap object, etc.).
  • Clicking a source line highlights the corresponding IR instructions and vice versa.

Language overview

clojurust is a dialect of Clojure. Its syntax, data model, and core library are designed to be as compatible with Clojure as possible, with a small number of deliberate extensions and a small number of features that are not yet implemented.

What is the same

  • All Clojure literal syntax: symbols, keywords, numbers (long, double, ratio, BigInt, BigDecimal), strings, characters, booleans, nil.
  • All collection literals: list (...), vector [...], map {...}, set #{...}.
  • Reader dispatch macros: ', `, ~, ~@, ^, @, #', #(...), #"...", ##Inf, ##-Inf, ##NaN, tagged literals.
  • The full set of special forms: def, fn*, if, do, let*, loop*, recur, quote, var, set!, throw, try/catch/finally, letfn, binding.
  • Persistent collections (HAMT-backed maps and sets, RRB vectors, linked lists, queues) with Clojure-compatible equality and hashing.
  • clojure.core — arithmetic, comparison, collection operations, lazy sequences, transducers, I/O, concurrency primitives.
  • Standard library namespaces: clojure.string, clojure.set, clojure.test, clojure.walk, clojure.edn, clojure.zip, clojure.data, clojure.spec.alpha (no generators — see Differences from Clojure).
  • Protocols (defprotocol, extend-type, extend-protocol), multimethods (defmulti, defmethod), records (defrecord), and reify.
  • Concurrency: atom, future, promise, delay, volatile!, agent.
  • Dynamic variables (binding, with-bindings, *ns*, *out*, etc.).
  • Metadata on vars and some values (with-meta, meta, ^:dynamic, etc.).

Extensions

Known differences and missing features

See Differences from Clojure for the full list.

Reader conditionals

Reader conditionals allow a single source file to contain code for multiple Clojure platforms. clojurust evaluates the :rust branch.

Syntax

Non-splicing form

#?(:rust   expr-rust
   :clj    expr-jvm
   :cljs   expr-clojurescript
   :default expr-fallback)

Exactly one branch is selected at read time based on the current platform. For clojurust, :rust is matched first. If no :rust key is present, :default is used. If neither is present, the entire form is skipped (reads as nothing).

The selected branch is a single expression; the whole #?(...) form evaluates to that expression.

(def platform #?(:rust   "clojurust"
                 :clj    "JVM Clojure"
                 :cljs   "ClojureScript"
                 :default "unknown"))

Splicing form

#?@(:rust   [a b c]
    :clj    [x y z]
    :default [])

The splicing form #?@(...) selects a vector from the active platform and splices its elements into the surrounding form. It is only valid inside a list, vector, map, or set literal.

;; Adds platform-specific items to a vector
(def features [#?@(:rust   [:gc :cranelift]
                   :clj    [:jvm :hotspot]
                   :default [])])
; => [:gc :cranelift]  (on clojurust)
;; Platform-specific require in an ns form
(ns myapp.core
  (:require [clojure.string :as str]
            #?@(:rust [[:clojurust.system :as sys]]
                :clj  [[:java.lang.System :as sys]])))

File-extension behaviour

ExtensionPlatform dispatch
.cljrsAlways :rust. Reader conditionals are still supported but :rust is always the active branch.
.cljcCross-platform. Reader conditional branches are stored as-is; the evaluator selects :rust.

Notes

  • The reader stores all branches of a #?(...) form; only the evaluator discards non-matching branches. This means reader-conditional forms can be inspected programmatically without losing the other branches.
  • Order within a reader conditional matters: keys are checked left-to-right. :default should come last.
  • Unlike Clojure, there is no :cljr (ClojureCLR) platform; the clojurust key is :rust.

Versioned symbols

clojurust lets you pin a symbol or namespace to a specific git commit by appending @<commit> to its name. This lets callers use a historical implementation without requiring the defining library to keep the old code alongside the new.

Syntax

my-fn@abc1234           ; unqualified versioned symbol
my.ns/my-fn@abc1234     ; namespace-qualified versioned symbol

The commit suffix must be a valid hex prefix of at least 7 characters (the full 40-character hash is recommended for reproducibility).

Versioned require

Whole namespaces can be loaded at a specific commit:

(require '[my.lib@abc1234 :as lib-v1])
(require '[my.lib         :as lib])      ; HEAD

(lib/some-fn x)        ; current version
(lib-v1/some-fn x)     ; pinned to abc1234

Both aliases coexist in the same namespace; calls through lib-v1/ always resolve against commit abc1234.

Propagation semantics

When a function body is evaluated in a versioned context — because it was loaded via a versioned require or called through a versioned symbol — the following resolution rules apply:

Symbol formResolved at
Unqualified or same-namespace, no @The inherited commit (propagated from the caller)
Qualified self-reference (my.ns/x written inside my.ns)The inherited commit
Explicitly versioned foo@DCommit D
External / cross-namespace, no @HEAD (current)

This means a versioned call behaves like a logical snapshot: internal helpers in the same namespace are drawn from the same commit automatically, but cross-namespace dependencies and the standard library use their current values unless explicitly pinned.

Resolving any pinned symbol loads the whole namespace at that commit into an immutable namespace named my.ns@<commit>: top-level side effects of the pinned file run once (when the snapshot is first loaded), historical definitions never overwrite the live (HEAD) bindings, and the snapshot is cached for the rest of the session.

Execution tiers

Versioned symbols behave identically everywhere code runs:

  • Interpreter — symbols resolve through the shared versioned resolver.
  • JIT — hot functions keep their pins: compiled code resolves each pinned reference once through a per-call-site inline cache (versioned bindings are immutable, so the cache never needs invalidation).
  • AOT (cljrs compile) — pins are resolved at compile time: every versioned require and bare versioned symbol is fetched from git during compilation and embedded in the binary. The produced binary is self-contained — no git repository, source tree, or ~/.cljrs cache is needed where it runs. A pin pointing at a missing commit fails the compile, and a versioned namespace that was not embedded fails at runtime with a clear “was not embedded at compile time” error.

Native (Rust) functions

Native functions live in the running binary, so there is no historical Clojure source to re-evaluate. By default a pinned lookup of a native function resolves to the current binary’s implementation — a verified HEAD binding: the runtime compares the pin against the package’s recorded provenance (the commit it was built from, declared with cljrs_interop::register_provenance!). A match is silent; a mismatch or missing provenance warns once per pin, or errors when --enforce-native-versions (or :enforce-native-versions true in cljrs.edn) is set.

For true pinned native code, opt in per dependency (experimental; requires a Rust toolchain at runtime):

{:deps
 {my.native.lib {:git/url   "https://github.com/user/my-native-lib"
                 :git/sha   "abc1234ef"
                 :rust/init "my_native_lib::cljrs_init"
                 :rust/load :dylib}}}

The runtime then builds the dependency’s crate at the pinned commit as a shared library (cached under ~/.cljrs/cache/dylibs/), verifies an ABI fingerprint (cljrs version, rustc version, build profile must match the host exactly), and registers the pinned implementations into the immutable my.native.lib@<commit> namespace.

Dependency setup

Versioned symbols require the referenced git repository to be cached locally. Declare the dependency in cljrs.edn and run cljrs deps fetch before using versioned symbols:

; cljrs.edn
{:deps
 {my.lib {:git/url "https://github.com/user/my-lib"
           :git/sha "abc1234ef"}}}
cljrs deps fetch my.lib

If the required commit is not cached, clojurust raises a descriptive error rather than attempting a network fetch:

error: dependency 'my.lib' is not cached locally.
       run `cljrs deps fetch` to download it.

Signature verification

When --verify-commit-signatures is passed on the CLI (or :verify-commit-signatures true is set in cljrs.edn), clojurust verifies that every accessed versioned commit carries a valid PGP or SSH signature before executing its code. Verification is native (no git/gpg/ssh-keygen subprocess): the signature must be made by a key listed in the project’s :trusted-signers. With verification on but no trusted signers configured, no commit can be verified and resolution fails closed.

Notes

  • Versioned symbols are resolved lazily at call time, not at load time, so the dependency only needs to be cached the first time the code path is actually executed.
  • The version cache is per-GlobalEnv and is keyed on "<ns>/<name>@<commit>", so the same commit of the same namespace is loaded at most once per interpreter session.

Differences from Clojure

This page documents intentional differences, missing features, and behavioural variations between clojurust and Clojure (JVM). The goal is to be a useful reference for porting .cljc code and for understanding what to expect when running Clojure code under clojurust.

No JVM, no Java interop

clojurust runs on Rust, not the JVM. There is no Java class hierarchy, no java.lang.*, and no Java method calls. The . (dot) special form and new have limited implementations:

  • (new Exception "msg") and (Exception. "msg") produce clojurust exception values, not Java objects.
  • (.method obj args...) is not yet implemented. Use protocols or the built-in equivalents instead.

Code that uses (System/nanoTime), (Thread/sleep n), or any other Java static method must use reader conditionals to supply a :rust alternative, or use the clojurust built-ins nanotime and sleep respectively.

Platform key

The reader-conditional platform key is :rust, not :clj. See Reader conditionals.

Missing concurrency features

FeatureStatus
ref / STM (dosync, alter, ref-set, commute, ensure)Not implemented
locking macroNot implemented
monitor-enter / monitor-exitNot implemented

atom, agent, future, promise, delay, and volatile! are fully implemented.

deftype

deftype is not implemented. Use defrecord (which is fully supported) or reify for most cases.

Metadata on collections

Metadata is supported on vars and some values, but not yet propagated through collection operations such as assoc. The with-meta and meta functions work on any value that carries metadata.

Sorted collections

sorted-map and sorted-set are implemented. sorted-map-by and sorted-set-by (custom comparators) are not yet implemented.

Hierarchies

make-hierarchy, ancestors, descendants, and parents have stub implementations. derive, underive, and a full isa? hierarchy are not yet implemented. defmulti / defmethod dispatch works but does not consult a custom hierarchy.

amap and areduce

These macros are registered as stubs but not fully implemented.

clojure.pprint

Not implemented.

clojure.zip

Stub — the namespace exists but most functions are not yet implemented.

Numeric tower

The numeric tower (Long → BigInt → Ratio → BigDecimal → Double) follows Clojure conventions. A few differences to be aware of:

  • Integer overflow in +, -, * automatically promotes to BigInt (same as Clojure’s checked arithmetic). The promoting variants +', -', *' are also available.
  • (/ 1 3) returns a Ratio (1/3), not a Double, same as Clojure.
  • BigDecimal precision is controlled by with-precision (the Clojure macro) or the lower-level push-precision! / pop-precision! built-ins.

*clojure-version* / *cljrs-version*

These vars are not yet defined. clojure-version as a function returns a map describing the current runtime.

with-open and close

clojurust provides RAII-style resource management via with-open (a macro) and close (a built-in function). These follow the same protocol as Clojure’s with-open; any value that implements the Resource protocol can be used.

Source file namespace mapping

The namespace→file mapping converts . to / and - to _, the same as Clojure:

myapp.core    →  myapp/core.cljrs  (or .cljc)
my-app.utils  →  my_app/utils.cljrs

Standard library

The following clojure.* namespaces are available: clojure.string, clojure.set, clojure.test, clojure.walk, clojure.edn, clojure.data, clojure.spec.alpha, clojure.spec.test.alpha, and clojure.spec.gen.alpha. clojure.core.async is also available (as the cljrs-async crate — see the Async & I/O chapter).

clojure.zip and clojure.pprint exist as stubs. clojure.spec.alpha has no generators: clojure.spec.gen.alpha and every generative-testing entry point (s/gen, s/exercise, s/exercise-fn, stest/check) throw a clear ex-info instead of generating values. clojure.core.match is not available.

New built-in functions

clojurust adds a small number of built-in functions that have no direct equivalent in standard Clojure. Most exist because Clojure code would normally reach these capabilities through Java interop, which is not available in clojurust.


System / time

(sleep ms)

Pause the current thread for ms milliseconds.

(sleep 100)   ; sleep 100 ms

Clojure equivalent: (Thread/sleep ms).


(nanotime)

Return the number of nanoseconds since the Unix epoch as a Long.

(let [start (nanotime)
      _     (do-work)
      end   (nanotime)]
  (println "elapsed ns:" (- end start)))

Clojure equivalent: (System/nanoTime) (note: Clojure’s version is relative to an arbitrary origin; clojurust’s is Unix epoch-relative).


String utilities

(char-code c)

Return the Unicode code point of character c as a Long.

(char-code \A)    ; => 65
(char-code \λ)    ; => 955

Clojure equivalent: (int c).


(char-at s i)

Return the character at index i in string s.

(char-at "hello" 1)   ; => \e

Clojure equivalent: (.charAt s i).


(string->list s)

Convert string s to a list of its characters.

(string->list "abc")   ; => (\a \b \c)

Clojure equivalent: (seq s) (returns a seq, not a list, but behaves the same in most contexts).


(number->string n)

Convert number n to its string representation.

(number->string 42)     ; => "42"
(number->string 3.14)   ; => "3.14"

Clojure equivalent: (str n).


(string->number s) / (string->number s base)

Parse string s as a number, returning nil if the string is not a valid number. The optional base argument specifies the radix (default 10).

(string->number "42")      ; => 42
(string->number "3.14")    ; => 3.14
(string->number "ff" 16)   ; => 255
(string->number "nope")    ; => nil

Clojure equivalent: no single equivalent; typically (Integer/parseInt s), (Double/parseDouble s), or a try/catch around those.


BigDecimal precision

(push-precision! n) / (push-precision! n mode)

Push a BigDecimal precision context onto the thread-local precision stack. Subsequent BigDecimal operations are rounded to n significant digits using mode (default HALF_UP).

Available rounding modes: CEILING, FLOOR, HALF_UP, HALF_DOWN, HALF_EVEN, UP, DOWN, UNNECESSARY.

(push-precision! 4)
(/ 1M 3M)    ; => 0.3333 (4 significant digits)
(pop-precision!)

This is the lower-level mechanism underlying the with-precision macro (which is preferred in normal code). Use push-precision! / pop-precision! only when you need to manage the precision stack manually across multiple calls.

Clojure equivalent: with-precision (macro).


(pop-precision!)

Pop the most recently pushed BigDecimal precision context.


Persistent queue

(queue) / (queue capacity)

Create a new empty persistent queue. The optional capacity argument is a size hint for the initial allocation.

(def q (queue))
(def q2 (conj q :a :b :c))
(peek q2)   ; => :a
(pop  q2)   ; => queue with [:b :c]

Clojure equivalent: clojure.lang.PersistentQueue/EMPTY (a Java static field).


Mutable ArrayList

These functions provide a mutable, GC-managed resizable array backed by a Rust Vec. They are intended for performance-sensitive code that builds up a collection before converting it to an immutable value, or for interoperating with Rust code that expects a mutable sequence.

An ArrayList value is a NativeObject; it is not a Clojure collection and cannot be used with seq, conj, map, etc. directly. Convert it with array-list-to-array first.

(array-list) / (array-list capacity)

Create a new empty ArrayList. With a Long argument, pre-allocates storage for capacity elements.

(def al (array-list 16))

(array-list-push al v)

Append value v to the end of al. Returns al (mutates in place).

(array-list-push al :x)
(array-list-push al :y)

(array-list-remove al i)

Remove and return the element at index i. Later elements shift left.

(array-list-remove al 0)   ; removes and returns first element

(array-list-length al)

Return the number of elements in al as a Long.

(array-list-length al)   ; => 2

(array-list-to-array al)

Convert al to an immutable object array (Value::ObjectArray). The ArrayList is unaffected.

(def arr (array-list-to-array al))
(alength arr)   ; => 2

(array-list-clear al)

Remove all elements from al. Returns al.

(array-list-clear al)

Rust interop

(native-object? x)

Return true if x is a NativeObject (a Rust value wrapped for use in clojurust), false otherwise.

(native-object? (array-list))   ; => true
(native-object? [1 2 3])        ; => false

(native-type x)

Return the type-tag string of NativeObject x, or nil if x is not a NativeObject.

(native-type (array-list))   ; => "ArrayList"
(native-type 42)             ; => nil

The type tag is set by the Rust code that implements the NativeObject trait and is the primary mechanism for dispatching on native types from clojurust code.

Async & I/O

clojurust ships asynchronous concurrency and non-blocking file I/O as two optional crates that layer on top of the interpreter:

CrateNamespaceProvides
cljrs-asyncclojure.core.asyncCSP channels, go blocks, ^:async/await, timeout, alts/alt, pipelines
cljrs-ioclojure.rust.io.asyncnon-blocking file reads and writes delivered over core.async channels

Both are wired into the cljrs CLI by default (via its async feature) and can be embedded by Rust programs that link the crates directly.

Design at a glance

Async support is delivered as a separate library, mirroring how clojure.core.async ships as its own JAR on the JVM. The core interpreter crates contain no Tokio dependency and no #[cfg(feature = "async")] guards; they expose a single hook trait (AsyncRuntime) that cljrs-async registers into the environment at startup. The only conditional compilation lives in the CLI binary.

The whole async tier runs on a Tokio current_thread runtime driving a LocalSet. Every Clojure value stays on one thread, which keeps the garbage collector’s !Send pointers (GcPtr<T>) sound — no value ever crosses a thread boundary inside async code. CPU-bound parallelism continues to use the existing thread-based future, pmap, and agent primitives, which are unchanged.

Two ways to wait

clojurust distinguishes async waiting from blocking waiting:

  • await is a special form. Inside an ^:async function (or a go block), it yields the single executor thread to other tasks until the value it is given — a Future, a promise, or a channel operation — resolves.
  • deref / @ blocks the calling OS thread. Using deref on a future from within an ^:async body is a runtime error that steers you to await.

Without cljrs-async loaded, (await x) falls back to a blocking deref, so the form is still meaningful in purely synchronous code.

To scale Clojure work across CPU cores, that single-threaded executor is instantiated multiple times as independent isolates, each with its own heap and collector. Values move between isolates by an explicit copy rather than a shared pointer. See the Worker isolation chapter for the model and its rationale.

See the core.async chapter for the concurrency model, the Worker isolation chapter for scaling across cores, and the Asynchronous I/O chapter for the channel-oriented filesystem API.

core.async

The clojure.core.async namespace provides CSP-style concurrency — channels, go blocks, and the ^:async/await function model — implemented on a Tokio current_thread runtime and LocalSet. It is provided by the cljrs-async crate and loaded automatically by the cljrs CLI.

(require '[clojure.core.async :refer [chan go take! put! close! timeout alts]])

The execution model

All async tasks cooperate on a single executor thread. This is a deliberate choice: Clojure values are managed by a tracing GC whose pointers are !Send, so they cannot be moved between threads. Running every async task on one thread via LocalSet keeps those pointers sound and keeps garbage collection simple — “stop the world” just means “finish the current poll, collect, resume.”

Two tiers coexist:

TierRuns onUsed by
Async (single thread)Tokio LocalSet^:async fns, go, channels, timeout, alts
Parallel (thread pool)OS threadsfuture, pmap, agent (unchanged core primitives)

Both tiers produce Value::Future, so a caller can await or deref either.

To run Clojure work on more than one core, the single-threaded executor is instantiated multiple times as independent isolates; see the Worker isolation chapter.

^:async functions and await

A function tagged ^:async runs its body as an async task. Calling it returns a Future immediately; the body runs cooperatively, yielding the executor at every await.

(defn ^:async fetch [url]
  (let [resp (await (http-get url))]   ; yields until the request resolves
    (:body resp)))

(fetch "https://example.com")          ; returns a Future right away
(await (fetch "https://example.com"))  ; yields until the body is ready

await is a special form, not a function — it is syntactically detectable so the compiler can recognise yield points. Its behaviour depends on context:

  • Inside an ^:async body (driven by the async evaluator), (await x) yields the executor thread until x — a Future, a promise, or a channel op — resolves, then returns the resolved value.
  • Outside any async context, (await x) falls back to a blocking deref of the value, so the form still works in synchronous code.

^:async is viral: a function that uses await should itself be ^:async.

await vs. deref

deref / @ always blocks the calling OS thread. Calling deref on a future inside an ^:async body is a runtime error:

(defn ^:async bad [f]
  @f)            ; error: use (await ...) instead of deref inside an ^:async function

This enforcement (via the interpreter’s async-context flag) prevents the classic deadlock where a task blocks the only executor thread waiting on a future that can only be resolved by that same thread.

Channels

Channels are CSP conduits between tasks. They are implemented as NativeObjects (CljChannel) rather than a dedicated Value variant, keeping the core value model free of async concerns.

(chan)      ; unbuffered — a rendezvous channel
(chan 0)    ; same as (chan)
(chan 10)   ; buffered, capacity 10
  • An unbuffered (rendezvous) channel hands a value directly from a put! to a take!: the put! resolves true only once a taker consumes the value.
  • A buffered channel accepts put!s while it has room and serves take!s while it is non-empty.
  • A closed channel drains any buffered values, then take! yields nil and put! resolves false.

Channel operations

Operations that can block return a Value::Future, so they are used with await inside an async context:

OperationMeaning
(take! ch)await the next value (or nil once closed and drained)
(put! ch v)await acceptance of v; resolves true, or false if closed
(close! ch)close the channel (idempotent)

Non-blocking variants act synchronously and return immediately:

OperationMeaning
(poll! ch)take a buffered value now, or nil if none is ready
(offer! ch v)put v now if there is buffer room → true/false

Blocking variants park the OS thread and are meant for the REPL, tests, and other synchronous contexts — not for use inside an ^:async body or from the single-threaded executor thread (they deadlock there):

OperationMeaning
(<!! ch)blocking take
(>!! ch v)blocking put

go blocks

go spawns its body as an anonymous async task and returns a Future:

(def in  (chan 1))
(def out (chan 1))

(go (let [v (await (take! in))]
      (await (put! out (* v 2)))))

(await (put! in 21))
(await (take! out))    ; => 42

Selection: timeout, alts, alt

timeout returns a future that delivers nil after a delay:

(timeout 5000)   ; => Future resolving to nil after 5 s

alts waits on a vector of futures (or channel ops) and returns [value index] for whichever resolves first:

(let [[v i] (await (alts [(take! ch) (timeout 1000)]))]
  (if (= i 1) (println "timed out") (println "got" v)))

alt is a macro that pairs each future with a handler, awaits alts, and dispatches to the matching handler:

(alt
  (take! ch1)   (fn [v] (println "ch1:" v))
  (take! ch2)   (fn [v] (println "ch2:" v))
  (timeout 500) (fn [_] (println "timed out")))

Higher-level utilities

FunctionDescription
(join-all futs)await a seq of futures, returning a vector of results (like Promise.all)
(async-pmap f coll)spawn f over coll concurrently and await all results
(thread f) / (thread-call f)run f on a real OS thread (spawn_blocking); deliver its result over a channel
(onto-chan! ch coll)put every element of coll onto ch, then close it
(to-chan! coll)return a channel and seed it from coll in the background
(merge chs)fan several channels into one
(mult ch) + (tap! m ch) / (untap! m ch) / (untap-all! m)broadcast one source channel to many taps
(reduce f init ch)fold over a channel until it closes
(into coll ch)drain a channel into a collection

Garbage collection

Because the async tier is single-threaded, GC safepoints are cooperative: the runtime collects between poll cycles, and await invokes a safepoint before each yield. A background GC-service task (spawned by the crate’s init) services collection requests, and explicit root guards keep spawned task futures and their captured environments reachable while they are in flight.

Embedding from Rust

cljrs-async is a standalone crate. Call init from inside a LocalSet context, then evaluate code as usual:

#![allow(unused)]
fn main() {
let rt = tokio::runtime::Builder::new_current_thread()
    .enable_all()
    .build()
    .unwrap();
let local = tokio::task::LocalSet::new();
rt.block_on(local.run_until(async {
    let runtime = cljrs_runtime::Runtime::builder().build()?;
    cljrs_stdlib::install(&runtime);
    let globals = runtime.into_globals();
    cljrs_async::init(&globals);
    // ... evaluate code ...
}));
}

The CLI links this crate when built with its default async feature; a minimal binary can be produced with cargo build -p cljrs --no-default-features.

WASM note. In the browser REPL, init may run before a LocalSet exists; the GC-service spawn no-ops in that case and should be re-invoked from inside a LocalSet::run_until block. timeout uses the browser’s setTimeout (via gloo-timers) on wasm32 instead of tokio::time::sleep.

Worker isolation

The async tier described in core.async runs on a single executor thread. That keeps the garbage collector simple, but one interpreter thread can only use one CPU core. To scale Clojure work across cores, clojurust uses an isolate model: each worker is an independent execution context with its own heap, its own collector, and its own current_thread + LocalSet executor. Isolates share no GC pointers. A value moves between them by being copied, never by being aliased.

This chapter explains how that differs from Clojure, why the boundary is an explicit copy instead of transparent sharing, and how to work with it.

What an isolate is

An isolate is just “the single-threaded async runtime from the previous chapter, instantiated N times.” Each one:

  • owns a private GC heap (a thread-local) and collects it independently — no global stop-the-world, no cross-isolate coordination;
  • runs its own Tokio current_thread runtime and LocalSet, so go blocks, channels, and ^:async functions behave exactly as they do in a single-isolate program;
  • references the shared static arena (compiled code, interned keywords/symbols) with no copy, so isolates run the same program without duplicating it.

Because nothing is shared between heaps, two isolates collect garbage in parallel and throughput scales with the number of isolates rather than bottlenecking on one collector.

Differences from Clojure

JVM Clojure assumes one shared, globally-visible heap. Several of its concurrency features lean on that assumption; the isolate model deliberately does not reproduce them bit-for-bit.

Clojure (JVM)clojurust
future runs the body on another OS thread, sharing the heapfuture is loop-async on the same isolate — concurrent, not parallel. Cross-core parallelism is a separate, explicit step.
atom, ref, and var roots are globally shared mutable cells any thread can seeatom is isolate-local and fast. Genuinely shared mutable state is a distinct primitive, shared-atom.
Passing a value to another thread shares a pointer — zero copy, but also zero isolationPassing a value to another isolate deep-copies it. The two sides are fully independent afterward.
Any value (closures, refs, mutable objects) can be handed to another threadOnly plain, immutable, acyclic data can cross an isolate boundary; everything else is rejected at the send site.

The guiding stance (from docs/async-worker-pool-plan.md) is that pure-Clojure compatibility is not a hard constraint: where an honest separation of concerns conflicts with reproducing JVM semantics exactly, separation wins, and clojurust adds new primitives on top where they make sense. shared-atom and the isolate channels below are those primitives.

Why explicit copying instead of transparent sharing

The decision to copy at the boundary — rather than share pointers like the JVM — falls out of one technical fact and one design value.

The technical fact: GcPtr is !Send. Every Clojure value lives behind a GcPtr, which is a raw heap pointer into one isolate’s heap. It is honestly not safe to send across threads, and the Rust type system enforces that. You cannot accidentally leak a pointer from one isolate into another — it is a compile error, not a runtime hazard. This is the same property that lets transients and the single-threaded collector stay unsynchronized and fast.

So if a value is going to cross from one heap to another, it has to be reconstructed on the far side. That reconstruction is the copy. There is no “share it instead” option to fall back to — the alternative was an unsafe shared-heap discipline, which was rejected because it forced serialized global GC pauses and a single allocation bottleneck.

The design value: no invisible surprises. Given that crossings cost a copy, the remaining question is whether the programmer can see the cost. The plan (docs/isolate-boundary-plan.md) commits to four guarantees so the boundary is honest:

  1. Crossing only happens through an operation you typed. You send through a distinct construct (an isolate channel), so a copy never hides inside an ordinary (chan) or function call. You know a copy is coming because of the target you are holding, not because you annotated the message — the same way Erlang’s Pid ! Msg tells you a copy happens because you are sending to a process.
  2. The parallel primitive is distinct from future. The same source must not be a cheap loop-async task in one place and a silent deep copy onto another isolate in another. Parallel-across-isolates is its own primitive, never a re-interpretation of future.
  3. The copy is metered. Every accepted crossing records bytes copied and time into GC_STATS, visible via --gc-stats. A fan-out that deep-copies a 2 MB map to eight workers shows up as a number, not as mystery latency.
  4. Can’t-cross failures are located. A value that cannot cross raises an error at the send site, naming the offending type — not deep inside the scheduler.

The trap this avoids is the one where identical-looking code is sometimes free and sometimes an expensive deep copy, decided by scheduling you cannot see. The explicit boundary trades a little ceremony for the property that cost and failure are always attached to something you wrote.

Isolate channels — the copy boundary

An isolate channel is the sanctioned way to move a value between isolates. It is a distinct constructor from (chan), precisely so the crossing is visible in source.

(require '[clojure.core.async :refer [isolate-chan isolate-put! isolate-poll! isolate-take!]])

(let [[tx rx] (isolate-chan)]
  (isolate-put! tx {:a 1 :b [2 3]})  ; deep-copies the map across the boundary → true
  (isolate-poll! rx))                ; => {:a 1 :b [2 3]}  (an independent copy)
OperationMeaning
(isolate-chan)create a channel, returning [tx rx]. tx is multi-producer (cloneable); rx is single-consumer.
(isolate-put! tx v)deep-copy v across the boundary and enqueue it. true on success, false if the receiver is gone, throws if v can’t cross.
(isolate-poll! rx)non-blocking take: the next value (copied into this isolate’s heap), or nil if empty/closed.
(isolate-take! rx)a Future resolving to the next value, or nil once closed and drained. Use with await in a go/^:async body.
;; park until a value arrives, inside an async body:
(go (let [msg (await (isolate-take! rx))]
      (handle msg)))

The receiver deserializes into the heap of whichever isolate holds it, so keep rx on the isolate that will consume from it. The sender can be cloned and used from anywhere.

Current scope. The Clojure-level primitive that spawns a worker isolate (pfuture / spawn) is deferred — it needs the shared code arena so a worker can see the running program without copying it. Today, isolate spawning is a Rust-level facility (cljrs_async::isolate::Isolate), and from Clojure both ends of an isolate-chan usually live on one isolate. The channel still pays — and still meters — the honest deep copy, so the boundary is observable now and your code is already written against the API it will keep.

What can and cannot cross

Only plain, immutable, acyclic data can be copied across the boundary. isolate-put! (and shared-atom) accept:

  • all scalars: nil, booleans, longs, doubles, chars, bigints, ratios, UUIDs;
  • strings, symbols, keywords, and compiled regex patterns (by source);
  • all persistent collections — lists, vectors, maps, sets, queues, cons cells — and records, recursively;
  • primitive and object arrays (snapshotted), and error values (message + data + cause chain);
  • realized lazy sequences (they are forced first, then the result is copied).

The following hold isolate-local state and are rejected at the send site:

  • mutable cells — atom, volatile, var, promise, future, agent;
  • functions and macros of every kind — a closure captures GcPtrs from its home isolate, so it cannot be reconstructed elsewhere;
  • native Resources and NativeObjects (OS handles, channels — bound to one isolate);
  • transients and an unforced delay (deliberately thread-confined).

The error is located and names the type, e.g.:

(isolate-put! tx (fn [] 1))
;; => throws: isolate-put!: value of type `fn` cannot cross an isolate boundary;
;;    the value holds isolate-local state and cannot cross an isolate boundary

If you need to hand work to another isolate, send it data describing the work (a keyword tag, a vector of arguments) and have the receiving isolate dispatch to code it already has — not a closure.

shared-atom — cross-isolate mutable state

For genuinely shared mutable state, use shared-atom. It is the second tier of a deliberate two-tier design: atom stays local and fast; shared-atom is the explicit, opt-in tool for sharing.

(def counter (shared-atom 0))

(swap! counter inc)
(swap! counter + 10)
@counter                      ; => 11

(compare-and-set! counter 11 0)  ; lock-free CAS, like a normal atom
(shared-atom? counter)           ; => true
(atom? counter)                  ; => false — it is a distinct type

It supports the full atom surface — deref/@, reset!, swap!, compare-and-set! — with lock-free atomic updates that are safe across isolates. Under the hood the cell is an Arc<ArcSwap<…>> and its contents are a Send + Sync value representation, so the same reference can be handed to another isolate (it crosses an isolate channel by a refcount bump, not a deep copy) and mutated concurrently from both.

The cost is paid on write: each value stored is promoted into the shared representation. The same shareability rule applies — you can only publish plain, immutable, acyclic data. Storing a closure or other isolate-local value into a shared-atom fails, and a failed swap! leaves the atom unchanged:

(def a (shared-atom 0))
(swap! a (fn [_] (fn [] 1)))   ; throws — the new value is a closure
@a                              ; => 0  (swap did not take effect)

Use shared-atom only where you actually need cross-isolate sharing; keep the common case on a local atom, which avoids all promotion and refcount traffic.

The Send worker pool

Not every parallel task needs a whole isolate. Byte-level work that touches no Clojure values — socket reads and writes, TLS handshakes and bulk crypto, compression, hashing — runs on a multi-threaded Send-only worker pool instead. The heap thread offloads such work and awaits the result, which comes back as plain Send data (Vec<u8>, a string) that the heap thread turns into a Clojure value.

This is the seam cljrs-net uses: a socket lives in the pool as byte traffic, and the isolate’s interpreter only ever sees byte-arrays it constructed itself. The pool is a Rust-level facility (WorkerPool), used by native crates rather than called directly from Clojure; it lets I/O-bound servers scale across cores while Clojure logic stays on its isolate.

Using isolation effectively

  • Default to local. Plain atom, future, channels, and collections are bump-allocated and fast. Reach for isolates and shared-atom only when you need real multicore execution or cross-isolate sharing.
  • Send data, not behavior. A closure cannot cross. Pass a tag plus arguments and let the receiving isolate dispatch to code it already holds.
  • Keep the receiver pinned. An isolate-channel rx deserializes into its own heap; consume from it on the isolate that owns it.
  • Make crossings coarse. Each crossing is a deep copy. Prefer sending one larger message over chattering many small ones, and don’t fan a large value out to many workers without expecting the copy cost.
  • Watch the meter. Run with --gc-stats to see bytes copied and time spent at the boundary. If one value dominates, that is the value to restructure (or, later, to make zero-copy).
  • Let failures guide you. A located “cannot cross” error usually means a closure or a stateful object slipped into your message. Replace it with plain data.

Looking ahead

The boundary that ships today is deep-copy-on-send with the four visibility guarantees above. A future phase adds a zero-copy fast path — explicitly constructed shared-vec/shared-map values that are born in the Arc-backed shared representation and cross by refcount instead of by copy, demoting back to ordinary GC-backed collections the moment they hold something non-shareable. The boundary itself does not move; the telemetry from the metered seam is what will tell you which values are worth promoting to that form. See docs/isolate-boundary-plan.md for the full design.

Asynchronous I/O

The clojure.rust.io.async namespace exposes the host filesystem to Clojure as a non-blocking, channel-oriented API. Every operation runs on the same Tokio LocalSet executor that drives core.async and returns a clojure.core.async channel, so file I/O composes with go, take!, alts, and the rest of the async toolkit instead of blocking the interpreter thread.

It is provided by the cljrs-io crate and loaded automatically by the cljrs CLI (with its default-on async feature).

(require '[clojure.rust.io.async :as aio]
         '[clojure.core.async :refer [take!]])

The channels it returns are ordinary core.async channels — cljrs-io reuses cljrs-async’s CljChannel rather than defining its own type.

Two channel shapes

The API deliberately uses two channel shapes, chosen per operation:

  • Streaming reads return a raw channel. The call returns immediately and a background producer task reads the file and puts a sequence of values onto the channel, closing it at EOF. A small buffer (cap, default 8) bounds how far the producer reads ahead of the consumer, so even a multi-gigabyte file is streamed with backpressure rather than slurped into memory.

  • Discrete request/response ops return a promise channel. The call returns a capacity-1 channel onto which the producer delivers exactly one result before closing it. A single take! yields the value; a second yields nil. This signals “resolves exactly once” while staying uniformly takeable alongside everything else.

Streaming reads

Each returns a channel immediately and closes it at EOF:

FunctionYields
(chunk-chan path [buf-size [cap]])byte-array chunks of up to buf-size bytes (default 8192)
(byte-chan path [cap])individual bytes as signed longs (-128..127)
(char-chan path [charset [cap]])characters decoded with charset (default :utf-8)
(line-chan path [charset [cap]])lines, without the trailing \n / \r\n
(go (loop []
      (when-let [line (await (take! (line-chan "big.log")))]
        (println line)
        (recur))))

Discrete operations

Each returns a one-shot promise channel carrying a single result:

FunctionDelivers
(slurp path [charset])the whole file as a decoded string
(slurp-bytes path)the whole file as a byte-array
(read-bytes path n)a byte-array of up to the first n bytes
(spit path data [charset])the number of bytes written (data is a string or byte-array)
(go (let [text (await (take! (slurp "config.edn")))]
      (println text)))

Error handling

Failures are delivered in band: the producer puts an error value (an exception) onto the channel and then closes it. Consumers distinguish results from failures with the error? / ok? helpers:

(require '[clojure.core.async :refer [<!!]]
         '[clojure.rust.io.async :as aio])

(let [result (<!! (aio/slurp "config.edn"))]
  (if (aio/error? result)
    (println "read failed:" (ex-message result))
    (println result)))

Top-level consumption. From cljrs repl, cljrs run, or cljrs eval, consume results with (await (take! ...)). The blocking <!! / >!! ops deadlock the single-threaded executor at the top level — they are for use off the executor thread (separate test threads, embedders), not for top-level CLI forms.

Charsets

The charset argument is a keyword or string label resolved by encoding_rs, defaulting to UTF-8:

(aio/slurp "data.txt" :utf-8)
(aio/char-chan "legacy.txt" :windows-1252)
(aio/line-chan "jp.txt" :shift_jis)

Supported labels include :utf-8, :utf-16le, :iso-8859-1, :windows-1252, :shift_jis, and the rest of the encoding_rs set.

Status and scope

The eight builtins above plus the error? / ok? helpers are implemented. Candidate follow-ups not yet covered: a stateful AsyncReader handle with a cursor (open / read-chunk! / seek), append/options maps for spit, directory streaming, and transducer-equipped channels.

Embedding from Rust

cljrs-io::init registers the namespace; it is idempotent and requires cljrs_async::init and a running LocalSet:

#![allow(unused)]
fn main() {
rt.block_on(local.run_until(async {
    let runtime = cljrs_runtime::Runtime::builder().build()?;
    cljrs_stdlib::install(&runtime);
    let globals = runtime.into_globals();
    cljrs_async::init(&globals);   // required first
    cljrs_io::init(&globals);
    // ... evaluate code ...
}));
}

Charset encoding and decoding

The clojure.rust.charset namespace provides streaming charset encoding and decoding backed by encoding_rs, which implements the WHATWG Encoding Standard. The companion namespace clojure.rust.charset.async wraps the same codecs as channel-to-channel transformers that compose naturally with the rest of the async I/O stack.

Both are provided by the cljrs-charset crate and loaded automatically by the cljrs CLI.

(require '[clojure.rust.charset :as charset])
(require '[clojure.rust.charset.async :as ca])

Charset labels

Any label recognized by encoding_rs is accepted as a keyword or string:

:utf-8   :utf-16le   :shift-jis   :windows-1252   :iso-8859-1   ...

nil or an omitted argument defaults to UTF-8. The full list of accepted labels is defined by the WHATWG Encoding Standard.

Streaming codecs

Streaming codecs handle input that arrives in pieces — TCP segments, buffered reads — without buffering the whole input first.

Creating a codec

(charset/decoder)             ; => CljDecoder — UTF-8
(charset/decoder :shift-jis)  ; => CljDecoder — Shift-JIS

(charset/encoder)              ; => CljEncoder — UTF-8
(charset/encoder :windows-1252) ; => CljEncoder — Windows-1252

Feeding chunks

(charset/update! dec chunk)  ; => string  — decoded output so far
(charset/update! enc string) ; => byte-blob — encoded bytes so far

update! may return an empty string or empty byte-blob if the chunk ended on a multibyte boundary; the partial state is held inside the codec.

Flushing

(charset/finish! dec)  ; => string  — any remaining decoded characters
(charset/finish! enc)  ; => byte-blob — any remaining encoded bytes

After finish!, the codec is closed and further calls return an error.

Full example

;; Decode a Shift-JIS file streamed in two chunks
(let [dec (charset/decoder :shift-jis)]
  (println (charset/update! dec chunk1))
  (println (charset/update! dec chunk2))
  (println (charset/finish! dec)))

One-shot helpers

When the entire input is available at once, the one-shot helpers are more convenient:

FunctionSignatureReturns
decode(decode bytes) / (decode bytes charset)decoded string
encode(encode string) / (encode string charset)byte-blob
(charset/decode my-bytes :windows-1252)  ; => "Hello World"
(charset/encode "こんにちは" :shift-jis) ; => #bytes[...]

Unmappable characters

When encoding to a non-Unicode charset, characters that have no representation are replaced with HTML numeric character references:

😀  →  &#128512;

Async channel transformers

clojure.rust.charset.async wraps the streaming codecs as channel-to-channel transformers. Each function returns an output channel immediately; a background producer task drives the conversion.

FunctionSignatureInputOutput
decode-chan(decode-chan bytes-chan [charset [buf]])ByteBlob valuesstring values
encode-chan(encode-chan strings-chan [charset [buf]])string valuesByteBlob values

The third argument sets the output channel buffer depth (default 8). The producer yields whenever the consumer has not drained the buffer, applying natural backpressure back to the upstream source.

Closure and errors

  • Closing the input channel (or putting nil) signals the end of the stream. The producer flushes any partial codec state — for example a split multibyte sequence — and then closes the output channel.
  • Non-blob / non-string values, including Value::Error, are forwarded to the output channel unchanged so consumers can detect upstream failures.

Example: network decode pipeline

(require '[clojure.rust.net :as net])
(require '[clojure.rust.charset.async :as ca])
(require '[clojure.core.async :refer [go <! close!]])

;; byte-chan from a TCP connection → decode UTF-8 → process strings
(let [conn      (await (take! (net/connect {:host "example.com" :port 80})))
      strings-ch (ca/decode-chan (:in conn) :utf-8)]
  (go (loop []
        (when-let [s (<! strings-ch)]
          (process! s)
          (recur)))
      (close! (:out conn))))

Example: encode and send

;; Encode strings as Shift-JIS and write to a connection
(let [in-ch  (async/chan 8)
      out-ch (ca/encode-chan in-ch :shift-jis)]
  (async/onto-chan! in-ch ["Hello" " " "世界"])
  ;; out-ch delivers ByteBlob values ready to put on (:out conn)
  )

Embedding from Rust

cljrs_charset::init registers the sync namespace; init_async registers the channel-based namespace. Both are idempotent. init_async requires cljrs_async::init and a running Tokio LocalSet.

#![allow(unused)]
fn main() {
cljrs_async::init(&globals);        // required before init_async
cljrs_charset::init(&globals);
cljrs_charset::init_async(&globals);
}

The namespace name constants are also exported for use in attribute maps or require calls from Rust:

#![allow(unused)]
fn main() {
cljrs_charset::NS        // "clojure.rust.charset"
cljrs_charset::NS_ASYNC  // "clojure.rust.charset.async"
}

Networking

The clojure.rust.net family of namespaces provides channel-oriented TCP, TLS, Unix-domain, and UDP sockets. Every socket is modelled as a pair of clojure.core.async channels — bytes arrive on :in, bytes leave through :out. This is the same duplex-channel model used by libraries such as aleph on the JVM, so protocols can be written as ordinary channel operations: go loops, take!, put!, alts, and the framing helpers.

The stack is provided by the cljrs-net crate and loaded automatically by the cljrs CLI.

(require '[clojure.rust.net :as net])
NamespaceContents
clojure.rust.net.tcpPlain TCP client and server
clojure.rust.net.tlsTLS client and server (rustls)
clojure.rust.net.unixUnix-domain stream sockets (#[cfg(unix)])
clojure.rust.net.udpUDP datagrams
clojure.rust.net.frameStateful framers and encode helpers
clojure.rust.netUmbrella namespace — dispatches on :transport

Connection model

connect returns a capacity-1 promise channel (the discrete-op shape used throughout clojurust I/O). Taking from it yields either a connection map or an error:

{:in          <chan>     ; byte-array chunks, closed at EOF/error
 :out         <chan>     ; put byte-arrays or strings here; close to half-close
 :remote-addr "ip:port"
 :local-addr  "ip:port"
 :resource    <handle>} ; call (net/close conn) to release the FD

Half-close: (close! (:out conn)) sends a TCP FIN while leaving :in open, so you can finish reading any in-flight data before the peer closes its side. (net/close conn) tears down both halves immediately.

Server model

listen binds synchronously and returns a server map immediately:

{:conns      <chan>     ; yields a connection map per accepted socket; closed at shutdown
 :local-addr "ip:port"
 :resource   <handle>} ; call (net/close server) to stop accepting

Taking from :conns blocks until the next accepted connection or the listener closes. Backpressure is propagated: when :conns is full the accept loop parks until the application drains the channel.

start-server is sugar that spawns a go-loop accepting from :conns and calling a handler for each connection:

(net/start-server
  (fn [conn]
    (go (loop []
          (when-let [chunk (<! (:in conn))]
            (>! (:out conn) chunk)
            (recur)))
        (close! (:out conn))))
  {:port 8080})

Umbrella dispatch

clojure.rust.net delegates every call to the right namespace based on the :transport key (defaulting to :tcp):

(net/connect {:host "example.com" :port 443 :transport :tls})
(net/connect {:path "/tmp/app.sock" :transport :unix})
(net/listen  {:port 8080})                   ; :tcp by default

The umbrella close inspects the map shape to decide whether the argument is a server, a stream connection, or a UDP socket:

(net/close conn)    ; :remote-addr present → stream connection
(net/close server)  ; :conns present → listener
(net/close udp)     ; everything else → UDP socket

Lifecycle helpers

These are provided by clojure.rust.net and work with any connection or server:

;; Ensure a connection is closed even if the body throws
(with-open [c (await (take! (net/connect opts)))]
  body...)

;; Separate a stream of values from the first error
(let [{:keys [out err]} (net/split-err (:in conn))]
  ;; out  — channel of non-error values
  ;; err  — promise of the first error, or nil at clean EOF
  )

;; Consume an entire channel, collecting results
(let [{:keys [values error]} (await (net/drain-to (:in conn)))]
  ...)

Pool-based I/O (Phase A2)

TCP and TLS byte-level I/O runs on a WorkerPool multi-thread runtime. The LocalSet executor — which owns all GcPtr<Value> — interacts with it through lightweight bridge tasks that convert between Rust bytes and Clojure values. This keeps the heap thread responsive under sustained byte traffic while preserving the single-thread invariant that the garbage collector requires.

UDP and Unix sockets use a simpler single-task model; their I/O volume does not justify the bridge overhead.

TCP

(require '[clojure.rust.net.tcp :as tcp])

;; Client
(let [conn (await (take! (tcp/connect {:host "example.com" :port 80})))]
  ...)

;; Server — manual accept loop
(let [server (tcp/listen {:port 8080})]
  (go (loop []
        (when-let [conn (<! (:conns server))]
          (handle conn)
          (recur)))))

;; Server — with sugar
(tcp/start-server handle-fn {:port 8080})

connect accepts :in-buf and :out-buf keyword options to set channel buffer depths (default 8 each). listen additionally accepts :host (default "0.0.0.0") and :conns-buf.

TLS

(require '[clojure.rust.net.tls :as tls])

;; Client — uses WebPKI roots by default
(tls/connect {:host "example.com" :port 443})

;; Client — system roots or custom CA bundle
(tls/connect {:host "internal.example.com" :port 8443 :roots :system})
(tls/connect {:host "dev.local" :port 8443 :roots "ca.pem"})

;; Client — disable cert verification (testing only)
(tls/connect {:host "localhost" :port 8443 :insecure-skip-verify true})

;; Client — ALPN negotiation
(tls/connect {:host "example.com" :port 443 :alpn ["h2" "http/1.1"]})

;; Server — PEM cert and key required
(tls/listen {:port 8443 :cert "cert.pem" :key "key.pem"})
(tls/start-server handle-fn {:port 8443 :cert "cert.pem" :key "key.pem"})

The returned connection and server maps have the same shape as TCP.

Unix-domain sockets

Unix-domain sockets are only available on Unix targets. On other platforms the functions are registered but throw "not supported on this platform".

(require '[clojure.rust.net.unix :as unix])

;; Client
(unix/connect {:path "/tmp/app.sock"})

;; Server — automatically unlinks the path on close
(unix/listen {:path "/tmp/app.sock"})
(unix/start-server handle-fn {:path "/tmp/app.sock"})

listen pre-unlinks any stale socket file at the path before binding, so restarting a server after a crash does not require manual cleanup. close also unlinks the path.

UDP

UDP sockets use a datagram map on both :in and :out:

(require '[clojure.rust.net.udp :as udp])

(let [sock (udp/socket {:port 9000})]
  ;; Receive: {:data <byte-array> :addr "ip:port"}
  (go (loop []
        (when-let [pkt (<! (:in sock))]
          (println (:addr pkt) "->" (count (:data pkt)) "bytes")
          (recur))))

  ;; Send
  (>! (:out sock) {:data my-bytes :addr "10.0.0.1:9000"})

  (udp/close sock))

socket accepts :host (default "0.0.0.0") and :in-buf / :out-buf channel buffer options.

Framing

Raw TCP connections deliver bytes in arbitrary-sized chunks; protocols typically need message boundaries. clojure.rust.net.frame/frame pipes a raw :in channel through a stateful framer and returns a new channel of complete messages:

(require '[clojure.rust.net.frame :as frame])

;; Line-delimited protocol
(let [lines (frame/frame (:in conn) (frame/lines))]
  (go (loop []
        (when-let [line (<! lines)]
          (println line)
          (>! (:out conn) (frame/lines-encode line))
          (recur)))))

;; Length-prefixed protocol (4-byte big-endian header)
(let [msgs (frame/frame (:in conn) (frame/length-prefixed {:bytes 4}))]
  (go (loop []
        (when-let [msg (<! msgs)]
          (>! (:out conn) (frame/length-prefixed-encode msg {:bytes 4}))
          (recur)))))

Framer specs

ConstructorOutput typeNotes
(frame/lines)string per linestrips \r; emits partial final line at EOF
(frame/by-delimiter b)byte-array per framedelimiter byte excluded from output
(frame/length-prefixed {:bytes n})byte-array per frameN-byte header (big-endian by default); partial frames at EOF are discarded

Pass :endian :little to length-prefixed for little-endian headers.

Encode helpers

(frame/lines-encode str)                     ; => byte-array (UTF-8 + \n)
(frame/length-prefixed-encode ba {:bytes 4}) ; => byte-array (4-byte header prepended)

Async map over a framed channel

pipe-map covers the common case of applying a function to every message on a channel:

(let [msgs   (frame/frame (:in conn) (frame/lines))
      parsed (frame/pipe-map msgs parse-json)]
  ;; parsed is a channel of parse-json results
  )

Embedding from Rust

cljrs_net::init registers all namespaces. It is idempotent and calls cljrs_async::init internally, so you do not need to call it separately:

#![allow(unused)]
fn main() {
rt.block_on(local.run_until(async {
    let runtime = cljrs_runtime::Runtime::builder().build()?;
    cljrs_stdlib::install(&runtime);
    let globals = runtime.into_globals();
    cljrs_net::init(&globals);
    // ... evaluate code ...
}));
}

Lower-level functions are also callable directly from Rust if you need to open sockets outside Clojure code:

#![allow(unused)]
fn main() {
use cljrs_net::{tcp, tls, udp, frame};

tcp::connect_to("example.com", 80, 8, 8);
tcp::listen_on("0.0.0.0", 8080, 16, 8, 8)?;
udp::socket_on("0.0.0.0", 9000, 8, 8)?;
tls::tls_connect_to("example.com", 443, client_cfg, 8, 8);
tls::tls_listen_on("0.0.0.0", 8443, server_cfg, 16, 8, 8)?;
frame::frame_channel(in_chan, FramerSpec::Lines, 8);
}

Rust Interop

clojurust lets Clojure code call Rust functions with full type safety and GC integration. The interop layer has two modes that work together:

  • Interpreter mode — the Rust crate is compiled to a shared library (.so/.dylib/.dll) and loaded by cljrs run/cljrs repl at startup via cljrs build-native.
  • AOT modecljrs compile statically links the Rust crate into the generated binary; the native init function is called before any Clojure code runs.

Both modes use the same API: a Registry object that maps Clojure-visible names to Rust functions.

A third arrangement inverts the relationship: instead of cljrs loading your Rust, your program builds a runtime and registers its own functions into it. That is the Embedding chapter, and it uses the same Registry described here.

When to use Rust interop

  • Wrapping an existing Rust library (e.g. a database driver, image codec, or systems API) for use from Clojure.
  • Hot paths where Clojure performance is insufficient.
  • Exposing mutable or OS-level state (file descriptors, sockets, GPU buffers) as opaque NativeObject values that participate in protocol dispatch.

Chapter overview

PageContents
Project setupcljrs.edn config, Cargo setup, crate layout
Registry APIRegistry, wrap_fn*, type marshalling, NativeObject
The #[export] macroZero-boilerplate function registration
Interpreter modecljrs build-native, auto-loading, hot-reload workflow
AOT modeHow cljrs compile wires native init into the binary

Quick example

cljrs.edn:

{:paths ["src"]
 :rust  {:crate "."
         :init  "my_project::cljrs_init"}}

Cargo.toml (user crate):

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
cljrs-interop = { path = "/path/to/cljrs/crates/cljrs-interop" }

src/lib.rs:

#![allow(unused)]
fn main() {
use cljrs_interop::{Registry, wrap_fn2};

#[no_mangle]
pub extern "C" fn cljrs_init(registry: *mut Registry) {
    let r = unsafe { &mut *registry };
    r.define("my.project/add",
        wrap_fn2("add", |a: i64, b: i64| Ok::<i64, String>(a + b)));
}
}

src/main.cljrs:

(ns my.project.core
  (:require [my.project :as native]))

(println (native/add 3 4))   ; => 7

Workflow:

cljrs build-native            # compile lib → target/debug/libmy_project.so
cljrs run src/main.cljrs      # auto-loads the .so, then runs Clojure

Project Setup

A mixed Rust/Clojure project needs three things: a cljrs.edn that points to the Rust crate, a Cargo.toml with the right crate type, and a cljrs_init entry point.

Directory layout

my-project/
├── cljrs.edn          # Clojure project config (source paths, :rust key)
├── Cargo.toml         # Rust crate manifest
├── src/
│   ├── lib.rs         # Rust source — defines cljrs_init and native fns
│   └── main.cljrs     # Clojure entry point

The Rust crate and the cljrs.edn file can live in the same directory (:crate ".") or in a subdirectory (:crate "native").

cljrs.edn

Add a :rust map to the top-level config:

{:paths ["src"]

 :rust {:crate "."                       ; path to Cargo.toml directory
        :init  "my_project::cljrs_init"} ; Rust path to the init function
}
KeyRequiredDescription
:crateyesPath to the directory containing the user’s Cargo.toml. Relative to cljrs.edn.
:inityesFully-qualified Rust path to the init function, e.g. "my_crate::cljrs_init". The first :: segment is used as the crate name.

Cargo.toml

The user crate must be a library with cdylib output (for interpreter-mode dynamic loading) and, optionally, rlib output (for AOT static linking):

[package]
name    = "my_project"
version = "0.1.0"
edition = "2024"

[lib]
crate-type = ["cdylib", "rlib"]

[dependencies]
cljrs-interop = { path = "/path/to/cljrs/crates/cljrs-interop" }

Note: cdylib produces the .so/.dylib/.dll loaded by cljrs run. rlib allows cljrs compile to link the crate statically into the AOT binary. Both can coexist in crate-type.

The cljrs_init entry point

The init function receives a *mut Registry pointer and registers all native functions. It must have C linkage so the dynamic linker can find it by name:

#![allow(unused)]
fn main() {
use cljrs_interop::{Registry, wrap_fn1, wrap_fn2};

#[no_mangle]
pub extern "C" fn cljrs_init(registry: *mut Registry) {
    let r = unsafe { &mut *registry };

    r.define("my.project/greet",
        wrap_fn1("greet", |name: String| {
            Ok::<String, String>(format!("Hello, {name}!"))
        }));

    r.define("my.project/add",
        wrap_fn2("add", |a: i64, b: i64| Ok::<i64, String>(a + b)));
}
}

The function name in :rust :init ("my_project::cljrs_init") must match the Rust function name used in #[no_mangle] (cljrs_init). The crate prefix (my_project) is used when generating the AOT harness; it must match the [package] name in Cargo.toml with hyphens replaced by underscores.

Calling native functions from Clojure

Native functions registered under "my.project/greet" are visible in Clojure as my.project/greet. No require is needed unless you want a namespace alias:

; Direct qualified call
(my.project/greet "world")       ; => "Hello, world!"

; With a require alias
(ns my.app
  (:require [my.project :as native]))

(native/add 3 4)                 ; => 7

The namespace my.project is created automatically when cljrs_init is called; you do not need to create or load a Clojure file for it.

Registry API

The Registry type (from cljrs_interop) is the handle passed to cljrs_init for registering Rust functions as Clojure-visible values.

Registry methods

#![allow(unused)]
fn main() {
// Register f under "my.ns/my-fn".
// Panics if `qualified` contains no '/'.
pub fn define(&self, qualified: &str, f: NativeFn);

// Register f into an explicit namespace with a plain name.
// Equivalent to define("ns/name", f).
pub fn define_in(&self, ns: &str, name: &str, f: NativeFn);

// Access the underlying GlobalEnv for advanced operations
// (registering builtin sources, setting namespace aliases, etc.).
pub fn env(&self) -> &Arc<GlobalEnv>;
}

Wrapping Rust functions

The wrap_fn* family converts idiomatic Rust signatures into NativeFn values. Arguments and return values are marshalled automatically via the FromValue and IntoValue traits.

#![allow(unused)]
fn main() {
use cljrs_interop::{wrap_fn0, wrap_fn1, wrap_fn2, wrap_fn3, wrap_fn_variadic};

// Zero arguments
wrap_fn0("my.ns/timestamp", || Ok::<i64, String>(epoch_millis()))

// One argument
wrap_fn1("my.ns/double", |x: i64| Ok::<i64, String>(x * 2))

// Two arguments
wrap_fn2("my.ns/add", |a: i64, b: i64| Ok::<i64, String>(a + b))

// Three arguments
wrap_fn3("my.ns/clamp", |lo: i64, hi: i64, x: i64| Ok::<i64, String>(x.clamp(lo, hi)))

// Variadic — receives &[Value] directly; minimum argument count enforced
wrap_fn_variadic("my.ns/sum", 0, |args: &[Value]| {
    let total: i64 = args.iter().filter_map(|v| i64::from_value(v).ok()).sum();
    Ok::<i64, String>(total)
})
}

All wrappers accept closures (not just bare fn pointers), so they can capture Rust state:

#![allow(unused)]
fn main() {
let multiplier = Arc::new(AtomicI64::new(3));
let m = multiplier.clone();
r.define("my.ns/scale",
    wrap_fn1("scale", move |x: i64| {
        Ok::<i64, String>(x * m.load(Ordering::Relaxed))
    }));
}

Type marshalling

Built-in conversions

Clojure typeRust type
nil() or Option<T> (None)
true / falsebool
Longi64
Doublef64 (also accepts Long)
StringString
Any valueValue (pass-through)
Nil or anyOption<T>
BigIntnum_bigint::BigInt

Implement FromValue and/or IntoValue on your own types to add new conversions:

#![allow(unused)]
fn main() {
use cljrs_interop::{FromValue, IntoValue};
use cljrs_value::{Value, ValueResult};

struct Point { x: f64, y: f64 }

impl IntoValue for Point {
    fn into_value(self) -> Value {
        // encode as a two-element vector
        Value::vector(vec![self.x.into_value(), self.y.into_value()])
    }
}
}

Error bridging

All wrap_fn* closures return Result<R, E> where E: Display. Any Err value is converted to a Clojure exception and re-thrown; Ok values are marshalled via IntoValue.

For explicit control, use wrap_result:

#![allow(unused)]
fn main() {
use cljrs_interop::wrap_result;

fn my_fn(args: &[Value]) -> ValueResult<Value> {
    let n = i64::from_value(&args[0])?;
    wrap_result(std::fs::read_to_string(format!("/tmp/{n}.txt")))
}
}

Opaque Rust objects (NativeObject)

Arbitrary Rust structs can be wrapped as Clojure values using the NativeObject trait. The value appears in Clojure as an opaque object that can be passed around, stored in collections, and dispatched on via protocols.

#![allow(unused)]
fn main() {
use cljrs_interop::{NativeObject, gc_native_object};
use cljrs_gc::{MarkVisitor, Trace};
use cljrs_value::Value;

#[derive(Debug)]
struct Connection { /* ... */ }

impl NativeObject for Connection {
    fn type_tag(&self) -> &str { "Connection" }
    fn as_any(&self) -> &dyn std::any::Any { self }
}

// Connection holds no GcPtr fields, so Trace is a no-op.
impl Trace for Connection {
    fn trace(&self, _: &mut MarkVisitor) {}
}

// Create a Value::NativeObject wrapping a Connection.
fn make_conn(_args: &[Value]) -> ValueResult<Value> {
    let conn = Connection { /* ... */ };
    Ok(Value::NativeObject(gc_native_object(conn)))
}
}

To downcast back to the concrete type in a Rust function:

#![allow(unused)]
fn main() {
fn use_conn(args: &[Value]) -> ValueResult<Value> {
    let Value::NativeObject(obj) = &args[0] else {
        return Err(ValueError::WrongType { expected: "Connection", got: "…".into() });
    };
    let conn = obj.get().downcast_ref::<Connection>()
        .ok_or_else(|| ValueError::WrongType { expected: "Connection", got: obj.get().type_tag().into() })?;
    // use conn…
    Ok(Value::Nil)
}
}

Protocol dispatch on native objects

extend-type can be used in Clojure to implement protocols for native objects. The type tag (the string returned by type_tag()) is used for dispatch:

(defprotocol IConn
  (query [conn sql])
  (close! [conn]))

(extend-type Connection IConn
  (query  [conn sql]  (native/db-query conn sql))
  (close! [conn]      (native/db-close conn)))

GC integration

If your NativeObject contains GcPtr<T> fields, implement Trace properly so the GC can follow references:

#![allow(unused)]
fn main() {
use cljrs_gc::{GcPtr, MarkVisitor, Trace};
use cljrs_value::Value;

struct Cache { entries: Vec<GcPtr<Value>> }

impl Trace for Cache {
    fn trace(&self, visitor: &mut MarkVisitor) {
        for entry in &self.entries {
            entry.trace(visitor);
        }
    }
}
}

If your struct holds no GcPtr fields (only plain Rust data), a no-op Trace impl is sufficient.


For simpler cases that don’t need closures or custom NativeObject wiring, the #[export] macro provides a zero-boilerplate alternative to manual define calls.

The #[export] Macro

The #[export] attribute (from cljrs_interop) is the zero-boilerplate way to expose Rust functions to Clojure. Annotating a function with it causes the function to be registered automatically when a Registry is created — no explicit call required.

Basic usage

#![allow(unused)]
fn main() {
use cljrs_interop::export;

#[export(ns = "math")]
pub fn add(a: i64, b: i64) -> Result<i64, String> {
    Ok(a + b)
}
}

add is now visible in Clojure as math/add as soon as the shared library is loaded or the AOT binary starts. No cljrs_init is required unless you have other setup to perform (see When cljrs_init is still needed).

Attribute options

KeyRequiredDescription
nsyesClojure namespace, e.g. "math" or "my.project".
namenoOverride the Clojure symbol name. Default: Rust name with _ replaced by -.
variadic_minnoMinimum arity for variadic functions (default 0). Only valid when the function takes a single &[Value] parameter.

Name mapping

Rust function names are converted to Clojure-style kebab-case: every _ becomes -. Use name = "..." to override:

#![allow(unused)]
fn main() {
#[export(ns = "str.util")]
fn to_upper_case(s: String) -> String {        // → str.util/to-upper-case
    s.to_uppercase()
}

#[export(ns = "str.util", name = "upper")]     // → str.util/upper
fn to_upper_case_v2(s: String) -> String {
    s.to_uppercase()
}
}

Supported signatures

Fixed arity

Each parameter must implement FromValue. The parameter count sets the arity enforced by the runtime. There is no upper limit.

Plain return value — any type implementing IntoValue; wrapped in Ok automatically:

#![allow(unused)]
fn main() {
#[export(ns = "math")]
pub fn pi() -> f64 {
    std::f64::consts::PI
}
}

Result<T, E> returnErr becomes a Clojure exception via E::to_string():

#![allow(unused)]
fn main() {
#[export(ns = "math")]
pub fn safe_sqrt(x: f64) -> Result<f64, String> {
    if x < 0.0 {
        Err(format!("cannot take sqrt of {x}"))
    } else {
        Ok(x.sqrt())
    }
}
}

No return value — maps to nil:

#![allow(unused)]
fn main() {
#[export(ns = "log")]
pub fn log_info(msg: String) {
    eprintln!("[info] {msg}");
}
}

Four or more parameters work identically to two or three:

#![allow(unused)]
fn main() {
#[export(ns = "geom")]
pub fn rect_contains(rx: f64, ry: f64, rw: f64, rh: f64, px: f64, py: f64) -> bool {
    px >= rx && px <= rx + rw && py >= ry && py <= ry + rh
}
}

Variadic

For functions that take a variable number of arguments, use a single &[Value] parameter. Set variadic_min to enforce a minimum argument count:

#![allow(unused)]
fn main() {
use cljrs_interop::{FromValue, IntoValue, export};
use cljrs_value::Value;

#[export(ns = "math", variadic_min = 1)]
pub fn sum(args: &[Value]) -> Result<Value, String> {
    let total: i64 = args
        .iter()
        .map(|v| i64::from_value(v).map_err(|e| e.to_string()))
        .collect::<Result<Vec<_>, _>>()?
        .into_iter()
        .sum();
    Ok(total.into_value())
}
}
(math/sum 1 2 3 4 5)   ; => 15

When cljrs_init is still needed

#[export] handles function registration. A cljrs_init is still required when you need to:

  • Call mark_loaded so require treats a namespace as built-in rather than looking for a file on the source path.
  • Set namespace aliases or refer bindings via Registry::env().
  • Perform any other startup work beyond defining Clojure-visible functions.
#![allow(unused)]
fn main() {
use cljrs_interop::Registry;

#[no_mangle]
pub extern "C" fn cljrs_init(registry: *mut Registry) {
    let r = unsafe { &mut *registry };
    // #[export] functions are already registered — Registry::new ran first.
    r.env().mark_loaded("math");
    r.env().mark_loaded("log");
}
}

Note: The *mut Registry passed to cljrs_init is the same Registry created by the runtime before calling your function. All #[export] entries are already interned when cljrs_init is invoked.

Mixing #[export] with manual define

Both styles can coexist freely. Use #[export] for straightforward functions and r.define / wrap_fn* for cases that capture runtime state or need custom arity logic:

#![allow(unused)]
fn main() {
use cljrs_interop::{Registry, wrap_fn1, export};
use std::sync::{Arc, Mutex};

// Simple stateless function — use #[export].
#[export(ns = "counter")]
pub fn increment(n: i64) -> i64 {
    n + 1
}

#[no_mangle]
pub extern "C" fn cljrs_init(registry: *mut Registry) {
    let r = unsafe { &mut *registry };

    // Stateful closure that captures a value created at init time.
    let total: Arc<Mutex<i64>> = Arc::new(Mutex::new(0));
    let t = total.clone();
    r.define(
        "counter/running-total",
        wrap_fn1("running-total", move |n: i64| {
            let mut guard = t.lock().unwrap();
            *guard += n;
            Ok::<i64, String>(*guard)
        }),
    );
}
}

How it works

#[export] is a proc-macro (in cljrs-export-macro) that leaves the original function intact and emits an inventory submission:

#![allow(unused)]
fn main() {
// What #[export(ns = "math")] generates alongside your function:
::cljrs_interop::inventory::submit!(::cljrs_interop::ExportEntry {
    qualified: "math/add",
    make_fn: || {
        ::cljrs_interop::NativeFn::with_closure(
            "math/add",
            ::cljrs_interop::Arity::Fixed(2),
            move |args| {
                let __a0 = <i64 as ::cljrs_interop::FromValue>::from_value(&args[0])?;
                let __a1 = <i64 as ::cljrs_interop::FromValue>::from_value(&args[1])?;
                add(__a0, __a1)
                    .map(<i64 as ::cljrs_interop::IntoValue>::into_value)
                    .map_err(|e| ::cljrs_interop::ValueError::Other(e.to_string()))
            },
        )
    },
});
}

inventory uses linker constructors to collect all submissions across the binary at link time. Registry::new then iterates them and calls Registry::define for each one.

If you need to register exports into a Registry you constructed outside the normal runtime flow (e.g. in tests), call register_exports directly:

#![allow(unused)]
fn main() {
use cljrs_interop::{register_exports, Registry};

let r = Registry::new(env.clone());  // already auto-registered
// …or, if you have a registry from elsewhere:
register_exports(&r);
}

Interpreter Mode

In interpreter mode (cljrs run / cljrs repl), the Rust crate is compiled to a shared library and loaded at startup via dlopen. No recompilation of cljrs itself is required.

Workflow

# 1. Build the shared library (once, or after Rust changes)
cljrs build-native

# 2. Run Clojure code — the .so is loaded automatically
cljrs run src/main.cljrs

# 3. Start the REPL — also auto-loads
cljrs repl

cljrs build-native is just cargo build run inside the crate directory declared by :rust :crate in cljrs.edn. It inherits your normal Rust toolchain, RUSTFLAGS, and cargo configuration.

How auto-loading works

When cljrs run (or repl) starts:

  1. It locates cljrs.edn by walking up the directory tree.
  2. If :rust is present, it derives the library path from the crate name and profile (debug by default):
    • Linux: <crate_dir>/target/debug/lib<crate_name>.so
    • macOS: <crate_dir>/target/debug/lib<crate_name>.dylib
    • Windows: <crate_dir>/target/debug/<crate_name>.dll
  3. It opens the library with dlopen and looks up the symbol named after the last :: segment of :rust :init (e.g. "cljrs_init").
  4. It calls the symbol with a *mut Registry, which registers all native functions into the global namespace table.
  5. The library is kept loaded for the lifetime of the process.

All of this happens before any Clojure source is evaluated, so native functions are visible to ns/require forms and to top-level code.

Missing library

If the shared library does not exist yet, cljrs prints a warning and continues:

cljrs: native library not found at target/debug/libmy_project.so
       — run `cljrs build-native` first

Clojure code that calls unregistered native functions will get a runtime error; code that doesn’t call them runs normally. This is intentional: you can develop pure-Clojure parts of a mixed project without running cljrs build-native on every edit.

Release builds

Pass --release to build an optimised library:

cljrs build-native --release

The release library is placed at target/release/libmy_project.so (and so on). Note that cljrs run always looks for the debug build; to use the release library you currently need to copy it to the debug path or symlink it. (A future --release flag on run/repl will automate this.)

Development tips

  • Fast iteration: cljrs build-native compiles only the Rust crate, not the whole clojurust workspace. On a warm build cache it typically takes a few seconds.
  • Separate processes: Because the library is opened once at startup, changes to Rust code require restarting cljrs run. There is no hot-reload of native code within a single process.
  • Cargo features: Pass CARGO_FLAGS or set [profile.*] in your Cargo.toml as usual; cljrs build-native inherits the environment.
  • Debugging: Use RUST_LOG, RUST_BACKTRACE=1, and your normal Rust debugging tools. The library is a standard shared object; gdb/lldb can attach to the cljrs process and set breakpoints in native code.

AOT Mode

When cljrs compile detects a :rust key in cljrs.edn, it statically links the user’s Rust crate into the generated binary. The compiled binary is fully self-contained: no shared library or cljrs installation is needed at runtime.

How it works

cljrs compile generates a temporary Cargo harness project that:

  1. Depends on the clojurust runtime crates (cljrs-runtime, cljrs-stdlib, etc.)
  2. Also depends on the user’s Rust crate (via path = "<crate_dir>")
  3. Also depends on cljrs-interop (for Registry)
  4. Contains a generated main.rs that: a. Initialises the standard environment b. Creates a Registry and calls the user’s init function c. Evaluates the Clojure preamble (interpreted forms: ns, require, defmacro, …) d. Calls the AOT-compiled __cljrs_main function

The harness is compiled with cargo build --release and the resulting binary is copied to the output path.

Generated main.rs (simplified)

fn main() {
    cljrs_compiler::rt_abi::anchor_rt_symbols();

    let runtime = cljrs_runtime::Runtime::builder().build()?;
    cljrs_stdlib::install(&runtime);
    let globals = runtime.into_globals();

    // Native init — registered before any Clojure code runs
    let mut registry = cljrs_interop::Registry::new(globals.clone());
    my_project::cljrs_init(&mut registry);

    let mut env = cljrs_runtime::tiered::Env::new(globals, "user");
    cljrs_runtime::env::callback::push_eval_context(&env);

    // Interpreted preamble (ns, require, defmacro, …)
    // …

    // AOT-compiled body
    unsafe { __cljrs_main() };
}

Crate type requirement

For AOT static linking the user crate must produce an rlib (the default for library crates). If you also need interpreter-mode dynamic loading, declare both:

[lib]
crate-type = ["cdylib", "rlib"]

If you only need AOT (no cljrs run native loading), crate-type = ["rlib"] is sufficient.

Building an AOT binary

# Build once (debug or release)
cljrs build-native          # optional — only needed for cljrs run

# Compile to a native binary
cljrs compile src/main.cljrs --out myapp

# Run anywhere — no cljrs required
./myapp

The cljrs compile step does not require a pre-built .so; it statically links the Rust crate directly. You do not need to run cljrs build-native before cljrs compile.

Native functions in the preamble

Because the native init call happens before the interpreted preamble is evaluated, native functions are visible to ns, require, macros, and defprotocol/extend-type forms that run at startup:

(ns my.app
  (:require [my.project]))   ; my.project namespace already populated

(defprotocol IWidget
  (render [w]))

(extend-type my_project.Widget IWidget   ; native type tag
  (render [w] (my.project/widget-render w)))

Offline builds

The AOT harness uses cargo build --release --offline by default. All dependencies (the clojurust crates and the user crate) must be resolvable without network access. Point to local paths in Cargo.toml as shown in Project setup.

Memory management

AOT-compiled binaries use the bump allocator fast path: escape analysis promotes short-lived, non-escaping objects into bump-allocated regions, leaving the tracing GC to handle everything else. (Since JIT phase 10.5 the same machinery also runs under cljrs run/repl/eval; AOT still sees the most opportunities because the whole program is analyzed as one unit.) See Memory Management and The bump allocator for details.

Embedding clojurust

Everything the cljrs CLI does — run, repl, eval, test, nrepl — it does through a public Rust API that your own program can call. Embedding means building a clojurust runtime inside a larger Rust application and evaluating Clojure in it: a scripting layer for a game or an editor, a rules engine whose policies ship separately from the binary, a plugin host, or a service that exposes an nREPL port so operators can inspect it live.

The API is small. Four steps cover every embedding:

  1. Build a runtime with Runtime::builder() — this is where you pick the execution mode, source paths, and GC limits.
  2. Attach the tiers and extensions you want: the JIT, the standard library, async/IO/networking, your own native functions.
  3. Evaluate Clojure forms in an Env and move values across the boundary.
  4. Bound what the guest code may do, if it is not fully trusted — see Limits & sandboxing.

A minimal host

use cljrs_reader::Parser;
use cljrs_runtime::tiered::{Env, EvalError, eval};
use cljrs_runtime::{ExecutionMode, Runtime};
use cljrs_value::Value;

fn main() {
    // 1. Build.
    let runtime = Runtime::builder()
        .execution_mode(ExecutionMode::Tiered)
        .source_paths(vec!["src".into()])
        .build()
        .expect("bootstrap clojure.core");

    // 2. Attach the standard library.
    cljrs_stdlib::install(&runtime);

    // 3. Evaluate.
    let mut env = runtime.env("user");
    let value = eval_str(&mut env, "(+ 1 2)").expect("eval");
    println!("{value}");   // => 3
}

/// Parse `src` and evaluate every form in it, returning the last value.
fn eval_str(env: &mut Env, src: &str) -> Result<Value, EvalError> {
    let mut parser = Parser::new(src.to_string(), "<host>".to_string());
    let forms = parser.parse_all().map_err(EvalError::Read)?;
    let mut result = Value::Nil;
    for form in &forms {
        // One allocation frame per top-level form: it roots everything the
        // form allocates, and releases it when the form is done.
        let _frame = cljrs_gc::push_alloc_frame();
        result = eval(form, env)?;
    }
    Ok(result)
}

That is a complete embedding. build() registers native clojure.core, evaluates the Clojure bootstrap, creates a user namespace that refers it, and raises the runtime to its target tier; install adds clojure.string, clojure.set, clojure.test, and the rest.

Which crates you depend on

CrateYou need it for
cljrs-runtimeRuntime, RuntimeBuilder, Env, eval — always
cljrs-valuethe Value enum you pass in and get back — always
cljrs-readerParser, turning source text into forms — always
cljrs-gcGcConfig, push_alloc_frame — almost always
cljrs-stdlibclojure.string, clojure.set, clojure.edn, clojure.test, …
cljrs-compilerthe JIT tier (jit::install), and AOT compilation
cljrs-async, cljrs-io, cljrs-net, cljrs-charset, cljrs-base64core.async, file I/O, sockets, codecs
cljrs-interopexposing your Rust functions to Clojure
cljrs-nreplletting editors connect to your embedded runtime (cljrs-lsp is syntactic only and needs no runtime)
cljrs-txrunning untrusted pure functions under hard limits

None of these pull in clap, rustyline, or the rest of the CLI — the cljrs binary crate is one consumer of this API, not a layer under it.

Two rules that shape every embedding

A runtime is confined to one thread. GcPtr — and therefore Value, Env, GlobalEnv, and Runtime — is !Send. This is enforced by the compiler, not by convention: fn assert_send<T: Send>() fails to compile for Runtime. Build the runtime on the thread that will evaluate in it, and keep it there. Each thread that hosts a runtime gets its own GC heap and collects independently, so several runtimes on several threads scale without coordinating; values move between them by copying, never by aliasing. See Worker isolation for that boundary, and Evaluating for the host-side rules.

Values are garbage-collected, and the collector needs to see yours. A Value sitting in a host struct is not a root by itself. Anything reachable from a namespace is safe; anything else needs an allocation frame or an explicit root guard for as long as you hold it. Evaluating spells out the three cases.

Chapter overview

PageContents
The runtime builderEvery builder option, what build() does, the Runtime handle
Execution modesTreeWalk, Tiered, TieredNoJit, NoGcTransaction; tier promotion; JIT thresholds
Extensions & native codeInstalling the stdlib, async/IO/net, and your own Rust functions
Evaluating codeParsing, Env, calling Clojure from Rust, marshalling, errors, GC discipline
Limits & sandboxingMemory limits, gas metering, depth caps, and what is not sandboxed

The runtime builder

Runtime::builder() is the only way to construct a runtime. There is no standard_env, no minimal_env, no _with_paths variant: execution mode, source paths, GC configuration, embedded sources, and tier enablement are all inputs to one builder, and extensions install themselves into the finished runtime afterwards.

#![allow(unused)]
fn main() {
use cljrs_runtime::{ExecutionMode, Runtime};

let runtime = Runtime::builder()
    .execution_mode(ExecutionMode::Tiered)
    .source_paths(vec!["src".into(), "resources".into()])
    .gc_config(config)
    .build()?;
}

Every option has a default, so Runtime::builder().build()? is a valid runtime: tiered execution, no source paths, GC limits taken from the environment, namespace roots registered.

Options

MethodDefaultEffect
execution_mode(ExecutionMode)TieredWhich call path the runtime uses, and which tier it is promoted to. See Execution modes.
source_paths(Vec<PathBuf>)emptyDirectories searched when require resolves a namespace to a file on disk.
gc_config(Arc<GcConfig>)noneExplicit soft/hard heap limits. Applied after the environment defaults, so it wins.
gc_config_from_env(bool)trueWhether to apply CLJRS_GC_SOFT_LIMIT_MB / CLJRS_GC_HARD_LIMIT_MB (and their system-derived defaults) to the heap.
register_gc_roots(bool)trueWhether this runtime’s namespace table is registered as a GC root set.
builtin_source(ns, &'static str)noneEmbed a namespace’s Clojure source in the binary so require resolves it without a file. Repeatable.
eager_clojure_test(bool)falseEvaluate clojure.test during construction instead of on first require.
build()Bootstrap and return Result<Runtime, BuildError>.

execution_mode

The one option worth deciding deliberately. It fixes how function calls dispatch for the life of the runtime and cannot be changed afterwards — Execution modes covers the four choices and when each one is right.

source_paths

These are the roots require searches, in order, when a namespace is not already loaded and is not registered as an embedded source. A host that never wants guest code reading from disk can leave them empty — though note that an empty source path is not by itself a sandbox, since clojure.core still has slurp and spit. See Limits & sandboxing.

Paths can also be appended after construction through globals.source_paths (an RwLock<Vec<PathBuf>>); that is how the CLI merges :paths from cljrs.edn on top of its command-line flags.

gc_config and gc_config_from_env

GcConfig carries two numbers: a soft limit that triggers a collection when exceeded, and a hard limit that forces one.

#![allow(unused)]
fn main() {
use std::sync::Arc;
use cljrs_gc::GcConfig;

let runtime = Runtime::builder()
    .gc_config_from_env(false)                       // ignore CLJRS_GC_* entirely
    .gc_config(Arc::new(GcConfig::with_limits(
        64 * 1024 * 1024,                            // soft: 64 MB
        128 * 1024 * 1024,                           // hard: 128 MB
    )))
    .build()?;
}

Constructors: GcConfig::new() (hard limit derived from system memory, soft at 75% of it), GcConfig::with_hard_limit(bytes) (soft at 75% of it), and GcConfig::with_limits(soft, hard).

The two options compose in a fixed order: environment settings are applied first, then any explicit gc_config overwrites them. Leave gc_config_from_env on if you want operators to be able to tune the heap without a rebuild; turn it off if your host must be the only thing that decides its own memory budget.

The heap those limits configure is per thread, not per process — each thread that runs a runtime owns an independent heap and collects it independently.

register_gc_roots

On by default, and you almost certainly want it: it registers the runtime’s namespace table with the collector, so vars, their values, and everything reachable from them stay alive. The tracer holds a weak handle to the environment, so dropping the last Runtime handle stops it from being a root rather than pinning it forever through the heap’s tracer list — which is what makes several sequential runtimes in one process safe.

Turn it off only if you are constructing an environment you will root yourself.

builtin_source

Embeds Clojure source in your binary and makes it resolvable by name:

#![allow(unused)]
fn main() {
const RULES: &str = include_str!("../clj/acme/rules.cljc");

let runtime = Runtime::builder()
    .builtin_source("acme.rules", RULES)
    .build()?;
}

Guest code can now (require '[acme.rules :as rules]) with no file on disk. Sources registered this way are parsed and evaluated on their first require, not at build time, so registering a dozen namespaces costs nothing until they are used. This is exactly how cljrs-stdlib ships clojure.string and friends.

eager_clojure_test

Evaluates the embedded clojure.test during construction. Only useful when you are not installing an extension that already registers it lazily (cljrs-stdlib does), so most hosts leave it alone.

What build() does, in order

  1. Creates a GlobalEnv carrying the chosen execution mode.
  2. Registers the native clojure.core functions.
  3. Creates the user namespace and refers clojure.core into it.
  4. Parses and evaluates the Clojure bootstrap source (the higher-order functions that are defined in Clojure rather than Rust), then re-refers clojure.core so the new definitions are visible, and marks it loaded.
  5. Registers any builtin_source namespaces, and evaluates clojure.test if eager_clojure_test was set.
  6. Applies source paths, then GC configuration (environment first, explicit config second), then registers namespace GC roots.
  7. Resynchronises *ns* to user.
  8. Raises the tier state to what the execution mode targets.

Step 8 is last for a reason: the bootstrap itself always tree-walks, because nothing can be lowered to IR before clojure.core exists. Functions defined before that point are marked as bootstrap and stay excluded from background lowering.

BuildError

#![allow(unused)]
fn main() {
pub enum BuildError {
    EmbeddedSource { origin: String, message: String },
}
}

The only failure mode, and it means the binary’s own embedded text failed to parse — a broken bootstrap or a malformed builtin_source, not anything a user did. An individual bootstrap form that fails to evaluate is reported on stderr and skipped rather than failing the build.

The Runtime handle

#![allow(unused)]
fn main() {
impl Runtime {
    pub fn builder() -> RuntimeBuilder;
    pub fn from_globals(globals: Arc<GlobalEnv>) -> Runtime;
    pub fn globals(&self) -> &Arc<GlobalEnv>;
    pub fn into_globals(self) -> Arc<GlobalEnv>;
    pub fn env(&self, ns: &str) -> Env;
    pub fn execution_mode(&self) -> ExecutionMode;
    pub fn tier_state(&self) -> TierState;
}
}

Runtime is a cheap, cloneable handle: all state lives in the shared GlobalEnv, so a clone names the same runtime rather than creating a new one. Clone it freely to hand to extension install functions.

  • env(ns) gives you a fresh evaluation context in namespace ns — this is what you evaluate against. Creating one is cheap; make one per request, per REPL session, or per task as suits your host.
  • globals() / into_globals() reach the Arc<GlobalEnv> underneath, which is what extension init functions and the nREPL/LSP servers take.
  • from_globals(...) goes the other way, for code that is handed an Arc<GlobalEnv> (a native package loader, an AOT harness) and needs a Runtime to pass to an install.

Environment variables that affect construction

VariableRead byEffect
CLJRS_GC_SOFT_LIMIT_MBbuild(), when gc_config_from_env is onSoft heap limit in MB (default: a third of system memory).
CLJRS_GC_HARD_LIMIT_MBsameHard heap limit in MB (defaults to the soft limit).
CLJRS_NO_IRbuild()Pins the runtime at TierState::TreeWalk regardless of the execution mode.
CLJRS_GC_STATScljrs_gc::dump_stats_from_env()Where to write a GC_STATS snapshot — unset does nothing, empty or - means stdout, anything else is a file path. Nothing reads it on its own; call dump_stats_from_env() at exit if you want the behaviour the CLI’s --gc-stats flag gives.

Runtime-tuning variables that are read later (IR/JIT thresholds, eager lowering) are listed under Execution modes.

Several runtimes in one process

Two situations, with different answers:

  • Sequentially on one thread — build, use, drop, build again. Runtime identity is a counter-allocated GlobalEnv::id, not a memory address, so a fresh runtime can never inherit a dropped one’s IR cache. Combined with the weak root tracer, dropping a runtime really does release it.
  • Concurrently — one runtime per thread, each built on the thread that uses it. Since Runtime is !Send there is no other option, and the heaps stay independent. Move data between them with isolate channels or shared-atom; see Worker isolation.

Execution modes

An ExecutionMode is chosen once, when the runtime is built, and never changes. It selects which path a Clojure function call takes:

#![allow(unused)]
fn main() {
pub enum ExecutionMode {
    TreeWalk,
    Tiered,          // default
    TieredNoJit,
    NoGcTransaction,
}
}
ModeCalls dispatch throughPromoted toUse it for
TreeWalkthe tree-walking interpreterTreeWalkshort-lived runtimes: one-shot evaluation, tests, config loading, per-request sandboxes
Tieredthe IR-aware dispatcherJitlong-running hosts — the default, and what cljrs run/repl use
TieredNoJitthe IR-aware dispatcherIrlong-running hosts that must not generate native code, or that want reproducible timing
NoGcTransactionthe tree walker, behind a call-depth capTreeWalkbounded evaluation of untrusted pure functions (this is what cljrs-tx builds)

Tier state: what is live right now

ExecutionMode is the target; TierState is the current reality.

#![allow(unused)]
fn main() {
pub enum TierState { TreeWalk = 0, Ir = 1, Jit = 2 }
}

Every runtime starts at TreeWalk — nothing can be lowered to IR before clojure.core exists — and build() raises it once, at the very end of bootstrap, to the mode’s target tier. It only ever moves up, and only that once; runtime.tier_state() reports where it landed.

The distinction matters because “not tree-walking” is not one thing: TieredNoJit stops at Tier 1 and stays there even if a JIT backend is linked into your binary, which a single “compiler ready” flag could not express.

#![allow(unused)]
fn main() {
let runtime = Runtime::builder()
    .execution_mode(ExecutionMode::TieredNoJit)
    .build()?;
assert_eq!(runtime.tier_state(), TierState::Ir);   // after bootstrap
}

How a function heats up in Tiered mode

  1. Tree walk. Every function starts here. Calls are counted.
  2. Tier 1 — IR. After roughly 50 calls the arity is queued for background lowering on the cljrs-ir-lower worker thread; once its IR is published, calls run on the register-based IR interpreter instead.
  3. Tier 2 — native. After roughly 1,000 calls the arity is queued for JIT compilation, and subsequent calls jump straight to native code. Long-running loops can transfer mid-execution through on-stack replacement.

Lowering and compilation happen off the evaluation thread, so a call that is “hot enough” does not block while its faster version is built — it keeps running at the current tier until the new one is published.

Attaching the JIT

Tiered mode only targets native dispatch. Tier 2 requires a JIT backend, which lives in cljrs-compiler and is not linked in unless you install it:

#![allow(unused)]
fn main() {
let runtime = Runtime::builder()
    .execution_mode(ExecutionMode::Tiered)
    .build()?;

cljrs_compiler::jit::install(&runtime);   // install BEFORE any guest code runs
cljrs_stdlib::install(&runtime);
}

Without that call, Tiered behaves like TieredNoJit.

Install it before evaluating anything. An arity that reaches the JIT threshold with no backend attached is marked as queued and never re-enqueued — that flag is what stops hot dispatch from re-queueing the same arity on every call — so a host that evaluates first and installs afterwards silently gets no JIT for whatever ran in between. cljrs_compiler::jit::install_on(&globals) is the same call for a host holding the Arc<GlobalEnv> rather than the Runtime.

Tuning

Thresholds are process-global, not per runtime. Set them before building:

#![allow(unused)]
fn main() {
use cljrs_runtime::tiered::{set_ir_threshold, set_jit_threshold, set_osr_threshold};

set_ir_threshold(10);      // bring IR lowering in sooner
set_jit_threshold(500);    // and native compilation with it
set_osr_threshold(5_000);  // loop iterations before on-stack replacement
}
VariableEffect
CLJRS_NO_IRPins any runtime built afterwards at TierState::TreeWalk. The escape hatch when you suspect a lowering bug.
CLJRS_IR_THRESHOLDCalls before background IR lowering (default 50). u32::MAX disables lowering.
CLJRS_JIT_THRESHOLDCalls before JIT compilation (default 1,000).
CLJRS_OSR_THRESHOLDLoop iterations before on-stack replacement.
CLJRS_EAGER_LOWER=1Lower every function at definition time instead of when it gets hot. Expensive; useful for reproducing lowering bugs.
CLJRS_IR_CACHE_TTLSeconds an idle lowered arity survives before the cold-IR sweep drops it (default 600).
CLJRS_NO_ASYNC_JITDisables JIT compilation of ^:async function bodies.

The programmatic setters take precedence over the environment variables, which in turn override the defaults. Note that CLJRS_NO_JIT is read by the CLI, not by the runtime: in a host program, whether a JIT is attached is decided by whether you call jit::install, so check the variable yourself if you want to honour it.

Pre-lowered IR

cljrs ir build can serialise lowered IR to a bundle, and a host can replay it into a runtime’s cache:

#![allow(unused)]
fn main() {
let loaded = cljrs_runtime::tiered::load_prebuilt_ir(&globals, &bundle);
}

It walks the namespaces, matches each function arity against a bundle key, and returns how many arities it installed — skipping the warm-up for those functions. No cljrs runtime path loads a bundle today; the command exists as a lowerer diagnostic, and this entry point is what an embedding host would use if it wanted to ship one.

NoGcTransaction and the depth cap

NoGcTransaction routes calls through a depth-capped tree walker. Interpreted recursion consumes real Rust stack, so an unbounded recursion in guest code would overflow the host thread’s stack and abort the process — not something a host can catch. The cap turns that into an ordinary evaluation error.

#![allow(unused)]
fn main() {
use cljrs_runtime::env::depth::DepthGuard;

let runtime = Runtime::builder()
    .execution_mode(ExecutionMode::NoGcTransaction)
    .build()?;

let _depth = DepthGuard::install(1_000);   // scoped to this invocation
let result = eval(&form, &mut env);        // deeper nesting → EvalError::Runtime
}

The budget is thread-local and clears when the guard drops, including on unwind. With no guard installed the mode is exactly the plain tree walker.

The name comes from its other half: built with the workspace’s no-gc feature, this mode runs inside a region-allocated arena that is discarded wholesale when the invocation ends, with no collector and no write barriers. cljrs-tx packages that whole profile — see Limits & sandboxing.

Choosing

  • Long-running host, trusted codeTiered plus cljrs_compiler::jit::install. Pay the warm-up once, run fast forever.
  • Long-running host, no native codegen (a policy forbidding W^X pages, a platform without a JIT, or a need for predictable timing) → TieredNoJit.
  • Short-lived or one-shot (evaluate a config file, run a test, handle one request and drop the runtime) → TreeWalk. Populating an IR cache you will throw away is pure overhead.
  • Untrusted inputNoGcTransaction with a depth cap and a gas meter, or cljrs-tx for the packaged version.

Extensions & native code

A freshly built runtime knows clojure.core and nothing else. Everything beyond it — the standard library, core.async, file I/O, sockets, codecs, your own Rust functions — installs itself into the finished runtime. Nothing is implicit, so a host that only wants pure data transformation never links a socket API into its binary.

#![allow(unused)]
fn main() {
let runtime = Runtime::builder().build()?;

cljrs_compiler::jit::install(&runtime);   // Tier 2 (before any guest code)
cljrs_stdlib::install(&runtime);          // clojure.string, clojure.set, …
}

Every install/init in this chapter is idempotent, so calling one twice is harmless.

The standard library

cljrs_stdlib::install(&runtime) registers these namespaces:

NamespaceNotes
clojure.stringnative helpers plus Clojure source
clojure.setnative helpers plus Clojure source
clojure.walk, clojure.data, clojure.zip, clojure.templatepure Clojure
clojure.testpure Clojure
clojure.spec.alpha, clojure.spec.gen.alpha, clojure.spec.test.alphagenerators are throwing stubs
clojure.ednnot available on wasm32 (uses std::fs)
clojure.rust.iosynchronous file I/O; not available on wasm32

They are registered as embedded sources, so each is parsed and evaluated on its first require rather than at install time. Installing the whole stdlib therefore costs almost nothing until guest code asks for a namespace.

cljrs_stdlib::register(&globals) is the same call for a host holding the Arc<GlobalEnv>.

Note that this is all-or-nothing: there is no “stdlib without clojure.rust.io” switch. If your concern is what guest code can reach, see Limits & sandboxing — the answer is not to skip the stdlib, because clojure.core itself has slurp and spit.

Async, I/O, and networking

These extensions need a Tokio LocalSet — they spawn tasks with spawn_local, and cljrs_async::init starts a background GC-service task immediately. Two consequences for a host:

  1. Call init from inside the LocalSet, not before it.
  2. Drive each top-level form on that same LocalSet, or spawned tasks (channel producers, ^:async bodies, I/O readers) never make progress.
#![allow(unused)]
fn main() {
let rt = tokio::runtime::Builder::new_current_thread().enable_all().build()?;
let local = tokio::task::LocalSet::new();

let globals = runtime.globals();
local.block_on(&rt, async {
    cljrs_async::init(globals);     // clojure.core.async, clojure.rust.error
    cljrs_io::init(globals);        // clojure.rust.io.async
    cljrs_net::init(globals);       // clojure.rust.net.* (calls cljrs_async::init itself)
    cljrs_charset::init(globals);   // clojure.rust.charset
});
cljrs_base64::init(globals);        // cljrs.base64 — no async requirement
}
CrateNamespacesRequires
cljrs-asyncclojure.core.async, clojure.rust.errora LocalSet
cljrs-ioclojure.rust.io.asynccljrs_async::init + a LocalSet
cljrs-netclojure.rust.net, .tcp, .udp, .tls, .unix, .frame, .quic, .h3, .http2a LocalSet (initialises async itself)
cljrs-charsetclojure.rust.charset; init_async adds clojure.rust.charset.asyncinit is synchronous; init_async needs async + a LocalSet
cljrs-base64cljrs.base64nothing

The CLI’s own evaluation loop is the reference implementation: it builds the runtime and the LocalSet once, then drives every top-level form with LocalSet::block_on, so tasks that outlive one form stay queued and continue on the next form’s drive. Deliberately, it does not wrap the whole session in a single outer block_on — that would panic when a per-form block_on nests inside it.

Without a driver installed, evaluation still works; it is simply synchronous, and anything that depends on a spawned task will not complete.

Exposing your own Rust functions

The Registry is the same object described in Rust interop, but an embedding host constructs it directly rather than exporting a cljrs_init symbol for a dylib to be loaded through:

#![allow(unused)]
fn main() {
use cljrs_interop::{Registry, wrap_fn1, wrap_fn2};

let registry = Registry::new(runtime.globals().clone());

registry.define(
    "acme.native/add",
    wrap_fn2("add", |a: i64, b: i64| Ok::<i64, String>(a + b)),
);
registry.define(
    "acme.native/config-value",
    wrap_fn1("config-value", move |key: String| {
        host_config.get(&key).cloned().ok_or_else(|| format!("no such key: {key}"))
    }),
);
}

Guest code then calls (acme.native/add 3 4). Constructing a Registry also registers every #[export]-annotated function in the binary via inventory, so the #[export] macro works in a host program exactly as it does in a dylib.

The wrappers marshal arguments and return values automatically for the types implementing FromValue/IntoValuebool, i64, f64, String, BigInt, Option<T>, Vec<Value>, and Value itself. Anything else crosses as an opaque NativeObject. A closure passed to wrap_fn* may capture host state, as above; that is the usual way to give guest code a keyhole onto the host rather than a general capability.

A native function that needs to call back into Clojure — a comparator, a callback, a hook — uses cljrs_runtime::tiered::invoke, which is covered in Evaluating code.

Making namespaces resolvable

Guest code reaches a namespace through require, which resolves in this order:

  1. Already loaded — anything an extension registered, or a previous require evaluated.
  2. A registered compiled-namespace loader — how an AOT-produced binary supplies its own namespaces.
  3. Embedded sources — namespaces registered with builtin_source, evaluated on first use.
  4. Source paths — the first <path>/<rel>.cljrs or <path>/<rel>.cljc that exists, where <rel> is the namespace with dots turned into slashes and dashes into underscores, searched across the configured source paths in order.
  5. A native-require hook, if the host installed one (the CLI does, for :rust/load :dylib dependencies).

A host that ships its Clojure inside the binary uses (3) and can leave the source paths empty; a host that wants operators to drop .cljc files into a plugins directory uses (4). A namespace found nowhere raises Could not find namespace <ns> on source path.

Serving an editor

An embedded runtime can expose the same tooling surface the CLI does. The nREPL server takes the Arc<GlobalEnv> and runs its network side on its own thread, handing jobs back to your interpreter thread as plain Send data:

#![allow(unused)]
fn main() {
let server = cljrs_nrepl::start(cljrs_nrepl::Config::default(), globals.clone())?;
println!("nREPL listening on port {}", server.port());
server.serve()?;   // blocks this thread, processing evals
}

That gives editors (CIDER, Calva, Conjure) a live connection into your running application — the same runtime, the same namespaces, your native functions included. cljrs-lsp is a separate, purely syntactic server (parse diagnostics and a document-symbol outline); it never invokes the evaluator, so it does not need your runtime at all.

Evaluating code

Evaluation is two steps the host drives itself: parse source text into forms, then evaluate each form in an Env. There is no single eval_string entry point, because hosts differ in how they want to handle allocation frames, errors, and partial results — but the loop is a dozen lines.

Parse and evaluate

#![allow(unused)]
fn main() {
use cljrs_reader::Parser;
use cljrs_runtime::tiered::{Env, EvalError, eval};
use cljrs_value::Value;

fn eval_str(env: &mut Env, src: &str, origin: &str) -> Result<Value, EvalError> {
    let mut parser = Parser::new(src.to_string(), origin.to_string());
    let forms = parser.parse_all().map_err(EvalError::Read)?;

    let mut result = Value::Nil;
    for form in &forms {
        let _frame = cljrs_gc::push_alloc_frame();
        result = eval(form, env)?;
    }
    Ok(result)
}
}

origin is the filename the reader attaches to source spans — it shows up in error messages, so give it something meaningful ("<config>", the real path, a REPL input counter).

parse_all reads every form up front, so a syntax error anywhere means nothing is evaluated. To get REPL-style behaviour (evaluate what parses, report the rest), parse and evaluate one form at a time.

An Env is a lexical scope chained to the shared GlobalEnv. Make one with runtime.env("user") or Env::new(globals.clone(), "user"). It is cheap: one per request, per session, or per task is normal. Re-using an Env across calls preserves whatever defs the guest code made — those live in the namespace, not the Env — while giving you a fresh set of local bindings.

Getting values out

Value is the runtime’s tagged union. Three ways to read one:

#![allow(unused)]
fn main() {
// 1. Display — prints readably, the way `pr-str` does.
assert_eq!(value.to_string(), "\"hello, world\"");   // note the quotes

// 2. Marshalling, for the common scalar types.
use cljrs_interop::FromValue;
let s = String::from_value(&value)?;                  // "hello, world"
let n = i64::from_value(&count)?;

// 3. Matching, when you need the exact shape.
match &value {
    Value::Str(s) => println!("{}", s.get()),
    Value::Long(n) => println!("{n}"),
    Value::Nil => println!("nothing"),
    other => return Err(format!("unexpected: {other}")),
}
}

FromValue/IntoValue are implemented for (), bool, i64, f64, String, &str, BigInt, Option<T>, Vec<Value>, and Value. Collections come back as Value::Vector / Value::Map / Value::Set and are read through their persistent-collection APIs.

Remember that Display is the readable representation: strings keep their quotes, characters print as \a. Use FromValue or a match when you want the underlying data rather than something to show a user.

Calling Clojure from Rust

Look the function up in its namespace, then apply it:

#![allow(unused)]
fn main() {
use cljrs_runtime::env::apply::apply_value;
use cljrs_runtime::env::gc_roots::root_value;

eval_str(&mut env, "(defn greet [who] (str \"hello, \" who))", "<host>")?;

let f = globals.lookup_in_ns("user", "greet").expect("greet is defined");
let _root = root_value(&f);                    // f is on the Rust stack — root it

let arg = Value::Str(cljrs_gc::GcPtr::new("world".to_string()));
let out = apply_value(&f, vec![arg], &mut env)?;
}

apply_value handles every callee shape Clojure allows — functions, keywords, maps, sets, vars, protocol methods, multimethods — and roots the callee and arguments across the safepoint it takes on entry.

Inside a native function you usually do not have an Env in hand. Use invoke instead, which picks up the eval context that the interpreter pushes around every native call:

#![allow(unused)]
fn main() {
use cljrs_runtime::tiered::invoke;

// e.g. a comparator handed to your native sort
let ordering = invoke(&user_fn, vec![a.clone(), b.clone()])?;
}

invoke fails with “invoke called outside eval context” if there is no active evaluation on the thread. If you spawn a thread and want to call Clojure from it — which only makes sense for a runtime confined to that thread — install a context first with cljrs_runtime::env::callback::install_eval_context_guard(globals, ns).

Errors

#![allow(unused)]
fn main() {
pub enum EvalError {
    Thrown(Value),               // (throw ...) — the thrown value, often an error map
    UnboundSymbol(String),
    Arity { name: String, expected: String, got: usize },
    NotCallable(String),
    Runtime(String),
    Read(CljxError),             // reader/parse failure, carries file/line/col
    GasExhausted,                // the execution-credit budget ran out
    ForbiddenEffect(String),     // a capability denied by the transaction policy
    Recur(Vec<Value>),           // `recur` outside a loop — never escapes normal code
    CommitSignatureVerificationFailed { commit: String, reason: String },
}
}

Guest exceptions arrive as Thrown, carrying the thrown value; the rest are the evaluator’s own failures. A host that reports to users will want to map these to its own error type — EvalError::to_error_value converts one into a Clojure error value if you would rather hand the failure back to guest code than propagate it into Rust.

Reader errors keep their source spans, so miette renders them with the offending line highlighted if your host uses it.

GC discipline

The collector traces from a fixed set of roots. Three cases cover a host:

Values reachable from a namespace are safe. Anything def’d, and anything you intern yourself, is traced through the namespace table (assuming register_gc_roots is on, which is the default). This is the right home for anything the host holds for a long time:

#![allow(unused)]
fn main() {
globals.intern("acme.host", "session-id".into(), session_id_value);
}

Values you allocate while evaluating need an allocation frame. That is what push_alloc_frame() in the eval loop is for: everything allocated inside the frame is rooted until the returned guard drops. One frame per top-level form is the convention the CLI and the bootstrap both use — it keeps a long-running session from accumulating roots while still protecting the form being evaluated.

Values living on the Rust stack across a safepoint need an explicit root.

#![allow(unused)]
fn main() {
use cljrs_runtime::env::gc_roots::{root_value, root_values};

let _r1 = root_value(&callee);     // one Value
let _r2 = root_values(&args);      // a slice
}

Both return RAII guards that unregister on drop. Forgetting one is the classic embedding bug: it works until a collection happens to land inside the call.

cljrs_runtime::tiered::force_collect(&env) triggers an immediate collection, which is worth doing after you tear down namespaces so their closures and form trees are released before the next load.

Threads and runtimes

Runtime, GlobalEnv, Env, and Value are all !Send, and the compiler enforces it. The rules that fall out:

  • Build the runtime on the thread that will use it, and evaluate only on that thread. Each such thread owns an independent GC heap.
  • To use several cores, run several runtimes — one per thread — and move data between them with isolate channels (a deep copy) or shared-atom (a refcounted shared cell). Closures and stateful objects cannot cross. See Worker isolation.
  • Send work to the interpreter thread as plain data. This is exactly what the nREPL server does: its network thread packages each request as Send strings plus a reply channel and hands it to the interpreter thread over an mpsc channel.
  • Byte-level work does not need a runtime. Compression, hashing, TLS, socket traffic — anything touching no Clojure values — can run on an ordinary thread pool and hand results back as Vec<u8>.

Limits & sandboxing

An embedded interpreter runs code that came from somewhere else. How much you need to bound it depends on where that code came from — a config file in your own repository is not the same problem as a rule a customer typed into a form.

This page covers the three resource bounds a host can apply to an ordinary runtime, then the capability question, which is where the honest answer matters most: an ordinary clojurust runtime is not a security sandbox. Bounding it takes a different execution profile.

Memory

Each thread’s heap is bounded by a GcConfig: a soft limit that triggers collection and a hard limit that forces one.

#![allow(unused)]
fn main() {
use cljrs_gc::GcConfig;

Runtime::builder()
    .gc_config_from_env(false)                    // operators can't widen it
    .gc_config(Arc::new(GcConfig::with_limits(32 << 20, 64 << 20)))
    .build()?
}

This bounds the managed heap — GC-allocated Clojure values. It is not an RSS ceiling: ordinary Rust allocations made by native functions are not charged against it. If you need a hard address-space limit against adversarial code, it has to come from outside the process (a container limit, setrlimit, a WASM linear-memory bound).

CPU: gas metering

A gas meter is a cooperative execution-credit budget. Every tier charges against it — the tree walker at each evaluation step, the IR interpreter per basic block, and JIT-compiled native code through the runtime ABI — so a hot loop cannot outrun its budget by getting compiled.

#![allow(unused)]
fn main() {
use cljrs_runtime::env::gas::{GasGuard, GasMeter};

let meter = GasMeter::new(10_000);
let guard = GasGuard::install(meter.clone());
let result = eval_str(&mut env, untrusted_src, "<guest>");
drop(guard);

match result {
    Err(EvalError::GasExhausted) => { /* over budget — meter.remaining() == 0-ish */ }
    other => other?,
}
}

cljrs_runtime::tiered::eval_with_gas(&form, &mut env, credits) is the one-liner version: it installs a fresh meter for one form and drops it afterwards.

Properties worth knowing:

  • Installation is thread-local and scoped. The guard uninstalls on drop, including on unwind.
  • Nested meters are charged together, so an inner evaluation cannot escape an outer budget by installing a smaller one of its own.
  • Exhaustion does not poison an outer scope. An inner budget running out leaves the enclosing evaluation able to continue.
  • Async tasks carry the meter with them — compiled state machines capture the active meter stack when spawned and reinstall it when polled.
  • Unmetered code is free. With no meter installed, charge always succeeds; metering costs a thread-local check.

Because charging is per evaluation step rather than per wall-clock tick, a budget is reproducible across machines — but it is not a timeout. A native function that blocks (a slow syscall in a host-provided builtin) consumes no gas. Keep host builtins non-blocking, or enforce time separately.

Stack: the call-depth cap

Interpreted recursion consumes real Rust stack, and an overflow aborts the process rather than raising an error your host can catch. A runtime built in ExecutionMode::NoGcTransaction routes calls through a depth-capped path:

#![allow(unused)]
fn main() {
use cljrs_runtime::env::depth::DepthGuard;

let _depth = DepthGuard::install(1_000);
}

Exceeding it returns EvalError::Runtime with the depth-exceeded message. Size the evaluating thread’s stack to comfortably cover the cap you set — interpreted frames can run to a few kilobytes each. (cljrs --stack-size-mb is the CLI’s version of that decision; a host does it with std::thread::Builder::stack_size.)

Capabilities: what guest code can reach

clojure.core — which every runtime has, before any extension is installed — includes slurp, spit, println, rand, clock access, and new for Rust object construction. So:

  • Leaving source_paths empty does not prevent file access.
  • Skipping cljrs_stdlib::install does not prevent file access either. It only removes clojure.string, clojure.set, clojure.edn, clojure.rust.io, and friends.
  • Not installing cljrs-net/cljrs-io does keep sockets and async file I/O out of the binary — extensions are genuinely opt-in, and that is a real reduction in reachable surface.

For code you wrote or reviewed, that is usually fine: resource limits are the thing you actually want, and the guest is not adversarial. For code you did not write, it is not enough.

Running untrusted code

The isolation boundary that does exist is the transaction profile: a fresh environment, in a bounded arena, tree-walking only, under a capability denylist, with pure data crossing in and out. cljrs-tx packages it:

#![allow(unused)]
fn main() {
use cljrs_tx::{TxLimits, TxProgram, execute};

let program = TxProgram::parse("(fn [x] (* x 2))")?;
let result = execute(
    &program,
    vec![arg],                                   // SerializedValue in
    TxLimits { memory_bytes: 8 << 20, gas: 100_000, call_depth: 256 },
)?;                                              // SerializedValue out
}

execute builds and bootstraps a new interpreter environment inside one bounded arena, structure-clones the arguments in, invokes the function under a gas meter and the transaction capability policy, clones a pure-data result out, and destroys the whole environment. Nothing survives the call.

The policy that makes it a boundary is TransactionPolicyGuard, in cljrs_runtime::env::policy. While it is active, a denylist is enforced at the final call boundary of every native builtin and special form:

DeniedExamples
Filesystemslurp, spit, close
Outputprint, println, pr, prn, printf, newline, flush
Clocks & randomnessnanotime, sleep, rand, rand-int, random-sample, shuffle, random-uuid
Process-global stategensym, add-tap, remove-tap, tap>, shared-atom
Concurrencypromise, deliver, send, send-off
Rust interopnew, Exception.
Loading & namespaces., ns, require, in-ns, alias, load-file, with-out-str, await
Versioned lookupresolution of sym@commit

Violations surface as EvalError::ForbiddenEffect(operation), naming what was attempted. A host can install the guard directly around its own evaluation if it wants that denylist without the rest of the cljrs-tx profile — but note that the guard alone is one layer, not the whole boundary; the fresh environment and the arena are the other two.

Where guest code genuinely needs to read host state, execute_with_host interns each HostApi entry as a namespaced native function (my.host/lookup). That is the only seam through the boundary: arguments are serialized out and validated as pure data, results are validated and cloned back in, and no live handle ever enters the arena. Keep those functions deterministic, side-effect-free reads.

Build cljrs-tx with its no-gc feature (cargo test -p cljrs-tx --features no-gc); it is off by default so a normal workspace build does not unify every dependency into arena mode.

What the transaction profile still does not give you

  • Not a hard RSS limit. memory_bytes covers arena object boxes and out-of-line memory reported by Trace::gc_size_extra. Ordinary Rust allocations outside traced values are not fully charged. An adversarial address-space ceiling still needs a process or WASM sandbox around it.
  • Not a wall-clock timeout. Gas bounds work, not time.
  • Not a stack guarantee by itself. call_depth caps interpreted frames; the thread’s stack still has to be big enough for the depth you allow.
  • No I/O, no async, no JIT, no Rust interop inside it, by construction — that is the point, but it means transaction functions are pure computations over data, not general programs.

A checklist

Guest code is…Do this
Yours, in your repoTiered + JIT, GC limits from the environment. Nothing else.
Yours, but a runaway loop would be embarrassingAdd a gas meter around each top-level evaluation.
Written by users who can already run code on the boxGas meter, explicit GcConfig, empty source paths, install only the extensions you need.
Genuinely untrustedcljrs-tx with TxLimits, host reads through HostApi, and an OS-level limit around the process.

Memory Management

Every Clojure value in clojurust lives behind a GcPtr<T> — a raw pointer into managed memory. clojurust uses two allocators that cooperate behind that pointer:

AllocatorWhen it runsReclaims memory
Tracing GC (mark-and-sweep)always, in the default buildduring collect (stop-the-world)
Bump allocator (regions)AOT-compiled code onlyin bulk, when a region’s scope ends

The garbage collector is the backstop: it manages the full object graph and is the only allocator the interpreter uses. The bump allocator is a compile-time optimization layered on top — when the AOT compiler can prove that an object does not outlive the function (or loop) that created it, it allocates that object in a region that is freed all at once, with no tracing and no GC pause.

You don’t manage either allocator by hand. You write ordinary Clojure; the compiler decides what is region-eligible and the GC handles everything else.

Tracing GC

The default collector is a non-moving, stop-the-world mark-and-sweep GC. Key properties:

  • GcPtr<T> stores a stable address — objects never move, so a pointer stays valid for the lifetime of the object.
  • clone is an O(1) pointer copy; drop is a no-op. Reference cycles are collected, because liveness is determined by reachability from roots, not reference counts.
  • Collection is triggered by a memory threshold. The default hard limit is 1/4 of system RAM (a fixed 64 MB soft limit on wasm32, which cannot query system memory).
  • Each OS thread (isolate) owns an independent heap and collects on its own, with no cross-thread coordination.

Bump allocator (regions)

The bump allocator is a region-based fast path for short-lived, non-escaping allocations. It is selected automatically by the AOT compiler’s escape analysis and is described in detail in the next chapter.

AOT only. The bump allocator currently runs only in AOT-compiled binaries (cljrs compile). The interpreter (cljrs run, cljrs repl, cljrs eval) allocates everything on the GC heap. See The bump allocator for why.

Inspecting allocation behaviour

Both allocators feed a single set of process-global counters. Set the CLJRS_GC_STATS environment variable to dump them at program exit:

CLJRS_GC_STATS=- ./myapp        # write a summary to stdout
CLJRS_GC_STATS=stats.txt ./myapp # write the summary to a file

The summary reports GC allocations and bytes, region (bump) allocations and bytes, GC collection count, total pause time, and objects/bytes freed — so you can see how much work the bump allocator is taking off the GC. The interpreter exposes the same counters through the cljrs --gc-stats [FILE] flag.

The Bump Allocator

The bump allocator — called a region in the code — is the fastest way clojurust can hand out memory. Instead of allocating each object individually on the GC heap and later tracing it, a region carves objects out of a contiguous block by advancing (“bumping”) a single pointer, and frees them all at once when the region’s scope ends.

It is roughly 2.6× faster than a GC-heap allocation: there is no mutex, no per-object Box::new, and no collection pause.

The bump allocator runs in both AOT and JIT/interpreted modes. Binaries produced by cljrs compile have always used it; since JIT phase 10.5, cljrs run, cljrs repl, and cljrs eval use it too: eager IR lowering runs the same escape-optimization pass per defn (consulting previously-lowered defns through a cross-defn registry), the Tier-1 IR interpreter executes the region instructions, and JIT-compiled code threads the active region into callees as a hidden argument. See Which tiers use regions?.

How it works

A region owns one or more chunks of raw memory (the default chunk is 4 KiB). It tracks a single bump pointer into the active chunk:

chunk:  [ obj A ][ obj B ][ obj C ][ free ............ ]
                                    ^
                                    bump pointer

Allocating an object is:

  1. Align the bump pointer up to the object’s alignment.
  2. If the object fits in the current chunk, write it there and advance the pointer past it. This is the common, near-instant path.
  3. If it doesn’t fit, allocate a fresh chunk (sized max(4 KiB, 2 × object size)), chain it on, and allocate from it.

There is no per-object bookkeeping for freeing — a region does not free objects one at a time. When the region’s scope ends, it:

  1. Runs any registered destructors in reverse (LIFO) order, so objects that may reference earlier ones are torn down first.
  2. Releases its chunks back to the system allocator (keeping the first chunk to reuse) and rewinds the bump pointer.

That bulk reset is what makes the allocator cheap: the cost of freeing a thousand short-lived objects is one chunk free, not a thousand.

Scopes and the region stack

Regions live on a thread-local region stack. AOT-compiled code brackets a region-eligible scope with two runtime calls:

  • rt_region_start pushes a fresh region onto the stack at scope entry.
  • rt_region_end pops it at scope exit, running destructors and freeing chunks.

Only the top region receives allocations. While a region is active, the runtime’s allocation helpers route region-eligible collections into it and fall back to the GC heap when no region is active:

#![allow(unused)]
fn main() {
fn box_coll_val(v: Value) -> *const Value {
    if region_is_active() {
        // bump-allocate into the active region
        try_alloc_in_region(v).unwrap().get() as *const Value
    } else {
        box_val(v) // fall back to the GC heap
    }
}
}

This fallback is why region promotion is always safe: if escape analysis is conservative, or no region happens to be active, the object simply lands on the GC heap with identical semantics — just a little slower.

How the compiler decides what to bump-allocate

You never mark an allocation as region-eligible yourself. During AOT compilation, an escape analysis pass classifies every allocation on a four-level lattice:

StateMeaningAllocator
NoEscapenever leaves the functionregion
ArgEscapestored into an argument that escapesGC heap
Returnsreturned to the callerregion if the caller doesn’t let it escape
Escapesstored in the heap, captured by a closure, returned to the worldGC heap

An allocation is promoted to a region only when it provably does not escape the scope that created it — it is not returned, not stored in a longer-lived container, not captured by a closure, and not passed to a call that could retain it. The analysis understands many built-ins precisely (for example (first coll) and (count coll) don’t cause their argument to escape, while (conj coll x) lets coll escape but not x), and it follows recur into loop headers so loop-local intermediates can be region-allocated too.

Escape analysis also reaches across function boundaries: small non-capturing callees are inlined so their allocations become local again, and larger callees can be specialized to inherit the caller’s region, so a helper that builds and returns a short-lived vector can still be bump-allocated at a call site that immediately discards it.

Which tiers use regions?

The bump allocator depends on compile-time escape analysis. The decision of what may be region-allocated, and where the rt_region_start / rt_region_end brackets go, is decided when code is lowered and optimized:

  • cljrs compile (AOT): the whole program is one IR tree; escape analysis and region promotion see every callee.
  • cljrs run / repl / eval with eager lowering (the JIT default): each top-level defn is lowered and optimized at definition time. A cross-defn registry makes previously-lowered defns visible, so calls into other defns can be region-promoted too (the callee variant receives the caller’s region as a hidden trailing argument once JIT-compiled). The Tier-1 IR interpreter executes the same region instructions before native code is published.
  • Pure tree-walking (no IR): no escape information, everything on the GC heap — the always-correct default.

Because the analysis can be wrong in principle, the GC build carries a runtime safety net: storing a value into a program-lifetime cell (def, atoms, volatiles, promises, channel puts) passes a publish barrier that promotes any region-allocated parts to the GC heap with a deep copy — and when a value is opaque to that scan (a closure, an unrealized lazy seq), the active regions are retired (kept alive forever and traced as GC roots) instead of being reset. Correctness never depends on the analysis being perfect.

Relationship to the GC

In the default build the two allocators run side by side, and the bump allocator never hides memory from the collector:

  • Region-allocated pointers carry a low-bit tag. The GC’s mark phase checks the tag without dereferencing the pointer and skips region objects — their chunk memory may already have been freed and reused once the region’s scope ended, so following them would be unsafe.
  • Instead, every live region on the thread’s region stack is treated as a GC root. The collector walks the objects inside active regions, so any GC-heap object reachable only through a region stays alive during collection.

So the bump allocator is a fast path for provably short-lived objects, and the tracing GC remains the backstop for everything with a longer or unknown lifetime.

The no-gc build

clojurust can also be built with the no-gc Cargo feature, which removes the tracing GC entirely and makes regions the only allocator. In that mode every function call and every loop iteration pushes a scratch region that is freed when the scope exits, return values are evaluated in the caller’s region, and program-lifetime values (from def, defn, atom, and friends) live in a global static arena. This trades the GC’s generality for zero collection pauses and is documented separately; the default distribution ships with the GC enabled.

See also

  • Memory management overview — how the GC and bump allocator fit together, and the CLJRS_GC_STATS counters.
  • AOT mode — how cljrs compile builds the native binary the bump allocator runs in.

JIT & tiered execution

Status: planned (Phase 10). This page describes work that is designed but not yet implemented. The full architecture and roadmap live in docs/archive/jit-plan.md.

clojurust runs code through a series of tiers, each faster than the last and selected automatically based on how hot the code is:

TierEngineWhen it runs
0Tree-walking interpreter (cljrs-runtime::interp)Always available; the universal fallback
1IR register interpreter (cljrs-runtime::tiered)When a function’s ANF/SSA IR is cached
2AOT native code (cljrs-compiler::aot)After an explicit cljrs compile
JITIn-process native code (cljrs-compiler::jit)Planned — when a function or loop gets hot at runtime

All tiers meet at one seam — the call_cljrs_fn dispatch hook — and a function transparently moves up the tiers as it warms up, falling back when a form isn’t yet supported.

The JIT tier brings native speed to ad-hoc code — scripts run with cljrs run and expressions typed at the REPL — without any explicit compile step. Its design covers:

  • Hot-path detection via per-arity invocation counters and loop back-edge counters, with on-stack replacement (OSR) so a single long-running loop promotes to native mid-execution.
  • Background compilation on a worker thread, with the finished code swapped in atomically so a hot call never stalls.
  • Code unloading that reclaims native code when the REPL redefines a function, tied to the garbage collector’s stop-the-world safepoints so there is no unload-vs-execute race.
  • Context-driven bump allocation: the JIT specializes a function’s allocation strategy to the context it is called from, threading the caller’s region through proven-non-escaping calls — extending the bump allocator beyond AOT code into the default GC build.

See docs/archive/jit-plan.md for the complete design and the phased Phase 10 roadmap.

WebAssembly

clojurust can compile Clojure to WebAssembly for native-fast, sandbox-safe deployment in the browser. cljrs compile --target wasm runs the same IR pipeline as the native backend, but emits a .wasm module instead of a native binary.

Status: code generation complete; runtime linking in progress. The compiler emits validated wasm modules for most of the language; making a module runnable in the browser (linking it against the wasm runtime) is the remaining step. See Status below and the full design in docs/archive/wasm-aot-plan.md.

Why a separate backend

clojurust’s native execution tiers up at runtime, and its top tiers generate machine code while the program runs (the JIT). A WebAssembly sandbox forbids exactly that: there is no mmap(PROT_EXEC) inside a module, so it cannot generate and then execute fresh machine code.

The browser story is therefore ahead-of-time: compile each Clojure function to wasm bytecode at build time and ship it; the browser’s own engine JITs that to native. The execution tiers invert relative to native:

Bottom (dynamic)Top (peak)
nativetree-walk → IR-interpJIT/OSR, reached at runtime
browsertree-walk → IR-interpAOT-wasm, frozen at build time

The IR interpreter stays on board the wasm bundle as the dynamic-code tier — for eval, the REPL, freshly-required namespaces, and macros — while AOT-wasm is the frozen top tier. No in-sandbox JIT or on-stack-replacement hooks are installed.

What is shared

Everything upstream of code generation is backend-agnostic and reused unchanged from the native path: ANF/SSA lowering, escape analysis + region inference, scalar representation inference, and the runtime-bridge contract. The only genuinely wasm-specific work is relooping (recovering structured control flow, since wasm has no goto) and the bytecode emitter. How those work is covered in The AOT backend.

Because regions are a property of the IR, bump allocation comes along for free in wasm: a region is a linear-memory arena, a region handle is an i32 offset. See Memory Management.

Status

Working (every emitted module is validated with wasmparser):

  • Scalar, string, keyword, and symbol constants; all control flow (if/cond/loop/recur via the relooper).
  • Boxed and unboxed arithmetic and comparison; collection and region allocation.
  • Calls (direct, region-threaded, and dynamic), closures via a shared function table, and cross-function tail calls.
  • Globals/vars and exceptions (throw/try/catch).
  • The typed parameter ABI^long/^double params passed unboxed, with a boxed-entry trampoline for dynamic callers.
  • Whole-program bundling — the entry namespace and every lowerable required namespace compile into one module.

Remaining — linking the module against the wasm runtime (so its imported rt_* bridges, memory, and function table are satisfied) and wiring the IR interpreter in as the dynamic-code tier. Until then the module is the AOT artifact, not yet a running program.

Not yet supported — the async poll-function ABI; the per-call-site inline cache. Deferred indefinitely — WasmGC (the linear-memory GC stays) and an in-browser JIT.

See also

The AOT backend

This page explains how the WebAssembly code generator works. It lives entirely in crates/cljrs-compiler/src/wasm/ and is a second consumer of the same cljrs-ir IR that the native (Cranelift) backend consumes.

crates/cljrs-compiler/src/wasm/
  mod.rs    — public API: compile_function, compile_bundle, WasmBackend, WasmError
  abi.rs    — the ABI contract: Value→i32, the rt_abi import table, regions, WasmLayout
  reloop.rs — the relooper: IR CFG → structured control flow (wasm-private)
  emit.rs   — the emitter: IrFunction(s) → a validated wasm module

The pipeline is always reloopemit.

The value model

Under wasm32, every pointer is an i32 — a linear-memory offset. The GC heap and all regions live in the module’s single linear memory.

IR representationwasm typeMeaning
boxed *const Valuei32offset of the boxed value
region handlei32offset of the region arena
unboxed Longi64raw long payload
unboxed Doublef64raw double payload
Bool / tagsi32small integers

The default model is boxed-only: every IR value is, by default, a wasm i32 holding a boxed pointer. This is always correct; unboxing is layered on top as an optimization. Because every pointer is an i32, the entire runtime-bridge surface (rt_abi, ~165 extern "C" functions) is expressible as wasm imports from the module named "rt", with no marshalling beyond width changes.

Relooping

wasm has only block/loop/if + labelled br — no goto. The relooper recovers structured control flow from the IR’s CFG using dominator-tree structuring (Ramsey’s “Beyond Relooper”), specialized to two facts true of every CFG this backend sees:

  • Back edges are exactly recur. Clojure has no goto; the only cyclic control flow is loop/recur, so loop headers are precisely the recur targets and every other edge is forward.
  • The CFG is reducible. Structured source can’t produce irreducible control flow, so the relooper needs no node-splitting or dispatch variable.

The output is a Structured tree (Simple/Labeled/Loop/If/Br/Return) that the emitter walks directly: Labeledblock, Looploop, Ifif/else, Brbr N. Each block is emitted exactly once. This pass is wasm-private — Cranelift wants the raw CFG and would be pessimized by re-structuring.

The emitter

The emitter lowers the structured tree and each Inst to bytecode. Highlights:

  • SSA φ resolution — no phi instruction is emitted. On each edge, each φ’s incoming value is copied into its local using the operand stack for parallel-move semantics (all reads before any writes), so a swapping (recur b a) cannot clobber.
  • Allocation — element pointers are marshalled into a runtime scratch buffer (rt_scratch_ptr), then a slice-taking rt_alloc_* bridge is called. Regions reuse the same machinery with the handle threaded as a leading argument.
  • Calls — direct calls resolve to a wasm function index; region-threaded calls pass the handle as a hidden trailing argument; dynamic calls dispatch through rt_call. Because imported functions occupy the low index space, the emitter runs two passes — discover imports, then encode with the import count settled.
  • Closures use a shared imported function table (a closure’s function pointer is a table index); tail calls become return_call when the tail-call proposal is enabled.
  • Constants/globals intern their bytes into a deduplicated read-only data pool emitted as one data segment; exceptions use the boxed thread-local error path (rt_throw/rt_try).
  • Unboxed scalars — representation inference assigns i64/f64/i32 to intermediates wherever the boxed bridge’s semantics survive on the raw value, so hot arithmetic compiles to native wasm ops; values box on demand only at boxed-context boundaries.

A GC rt_safepoint is emitted at function entry and at each loop header.

The native (Cranelift) backend is the semantic reference: the wasm emitter mirrors codegen.rs arm for arm. The one structural difference is control flow — Cranelift consumes the raw CFG; the wasm backend reloops it first.

The typed parameter ABI

By default, parameters stay boxed (the signature is all-i32), because the always-boxed dispatchers — dynamic rt_call, the indirect function table, cross-function direct calls — cannot supply unboxed arguments.

A function with static ^long/^double parameter hints compiles to two wasm functions:

  • a typed body whose hinted params arrive unboxed (i64/f64), so the body reads them with no per-use unbox;
  • a boxed-entry trampoline with the all-i32 signature every dispatcher expects. The trampoline is the function’s primary entry — exported, installed in the table, and the target of every direct call — so all the boxed dispatch paths reach a typed function unchanged. It coerces each boxed argument (rt_coerce_long/rt_coerce_double) and tail-calls the typed body.

The native backend’s specialized prologue deoptimizes on a tag mismatch; the wasm sandbox has no deopt seam, so a violated static hint coerces or throws instead (Clojure’s longCast/doubleCast semantics).

Whole-program bundling

cljrs compile --target wasm lowers the entry namespace and every transitively-required user namespace the backend can lower into one module (each as a __cljrs_ns_init_N initializer, mirroring the native path’s per-namespace discovery). A namespace the backend can’t lower is skipped, left for the runtime’s IR-interpreter tier — the same graceful degradation native AOT uses. The module’s read-only data and function-table base addresses are configurable (WasmLayout) so the linking step can place them at the addresses the runtime reserves.

Testing

Every new shape is validated with wasmparser in a unit test (cargo test -p cljrs-compiler wasm::) — a module that validates is structurally correct wasm even with no JS runtime to execute it. End-to-end tests drive real .cljrs source through compile_file_to_wasm, including a cross-namespace require.

For the complete design, the increment-by-increment build log, and the open-task list, see docs/archive/wasm-aot-plan.md.