A typed Elm runtime for C++, plus the platform layer it runs on.
You write update. jaal runs the loop, the timers, the background tasks and
the OS waiting, on Linux, macOS and Windows. It doesn't draw anything: a
renderer (a terminal UI, a GUI, a web socket) plugs in as a host.
Status: core, kernel and platform layers are built and tested on all three platforms (Windows natively via MSYS2 clang64; historically verified under wine).
Docs live in docs/:
- manual.md: start here — builds one app step by step, from the first program to testing it under every thread order
- reference.md: every public type, and what it refuses — the page to look things up in
- scope.md: what jaal offers, what it doesn't, what's planned
- hosts.md: how to write a host (a terminal, a GUI, a server)
- decisions.md: the design decisions and why
- design.md: the full technical design
- concurrency.md: the threading and memory safety model
Headers come one per layer: <jaal/jaal.hpp> is everything, and
<jaal/core.hpp>, <jaal/kernel.hpp>, <jaal/platform.hpp>,
<jaal/host.hpp>, <jaal/meta.hpp> are the individual layers. A library of
effect descriptors needs only core, and never pulls in a thread or an OS
handle.
struct Ticker {
struct Model { int ticks = 0; };
struct Tick {};
struct Interrupted {};
using Msg = std::variant<Tick, Interrupted>;
using Cmd = jaal::Cmd<Msg>; // core effects
using Sub = jaal::Sub<Msg>; // timers and streams
static Cmd update(Model& m, Tick) {
++m.ticks;
return m.ticks == 5 ? Cmd::quit(0) : Cmd{};
}
static Cmd update(Model&, Interrupted) { return Cmd::quit(0); }
static Sub subscribe(const Model&) { return Sub::every(500ms, Tick{}); }
// input, signals included: the event and the model as it is right now
static std::optional<Msg> on(const Model&, jaal::sig s) {
if (s == jaal::sig::interrupt) return Interrupted{};
return std::nullopt;
}
};
int main() { return jaal::run<Ticker>(); }That's the whole shape, and there's only one way to write it:
| member | what it is |
|---|---|
Model |
the state; default-constructible |
Msg |
a std::variant of message structs |
Cmd |
jaal::Cmd<Msg, extra effects...>: the core effects are always in |
update(Model&, Case) |
one per message case; changes the model in place, returns a Cmd ({} for none) |
init(Model&) |
optional: set up the model, return the first Cmd |
Sub, subscribe(const Model&) |
optional: timers and streams to keep running |
on(const Model&, const Event&) |
optional: one per input kind (keys, clicks, signals); returns the Msg it means, or nullopt |
- Forget an
updatefor a case and it won't compile, with a message that names the case and the line to add:'Counter' has no update for message 'Counter::Reset'; add: static Cmd update(Model&, Counter::Reset). - The
Cmdtype says which effectsupdatecan ask for. Returning one it doesn't list, or running on a host that can't do one, won't compile. - Signals arrive as input, through
on(const Model&, jaal::sig). A program that doesn't handle Ctrl+C still stops on it (exit code 130), like any process. - The same program runs on the real platform (
jaal::run), or on a simulated one with a fake clock for tests (jaal::headless).
One behaviour to know: a signal the process inherited as ignored stays
ignored, so nohup keeps working. That includes a program started in the
background by a non-interactive shell (cmd & in a script), which the
shell starts with SIGINT ignored. In that case Ctrl+C won't reach it, the
same as for any other program.
Full example: examples/ticker.cpp.
Most background work computes one thing and hands it back. perform runs
the body off the loop and delivers its return value as a message. No Sink,
no send, no threads in your code:
static Cmd update(Model& m, Open) {
return Cmd::attempt<Loaded, LoadFailed>([](const std::string& url) {
return fetch(url); // may throw: arrives as LoadFailed
}, m.url);
}Give it a key and the newest one wins. Search-as-you-type is one line, with no token to check by hand:
static Cmd update(Model& m, Typed t) {
m.query = t.text;
return Cmd::perform<Results>("search", &search, m.query); // replaces the last one
}
static Cmd update(Model&, Esc) { return Cmd::cancel("search"); }A replaced task's stop_token fires, and nothing it sends reaches update,
even a result it had already queued. That's decided on the loop thread, so
no thread timing can let a stale result through. The body can't capture
anything and its arguments must be safe to move to another thread; both
are compile errors with a plain message, not a template dump.
Keys, clicks and signals become messages through on overloads, one per
kind of input, the same shape as update:
static std::optional<Msg> on(const Model& m, const Key& k) {
if (m.picker_open) return PickerKey{k.c}; // the model as it is NOW
if (k.c == '\x14') return OpenPicker{}; // ^T
return Typed{k.c};
}A fast terminal can deliver ^T m o in one read. Each key is judged
against the model the previous one left, so m and o go to the picker
that ^T just opened. Routing a key costs about 14 ns.
jaal::sim runs a program with task latency, same-instant ordering and
injected crashes all picked by one seed. explore tries many seeds and
hands back the first one that breaks an invariant:
auto r = jaal::explore<Search>(1, 500, {}, [](jaal::sim<Search>& s) {
s.at(0ms, Type{1});
s.at(2ms, Type{2}); // fetch results come back in any order
s.check("never show stale results", [last = 0](const Model& m) mutable {
bool ok = m.shown >= last; last = m.shown; return ok;
});
});
if (!r.ok()) puts(r.failure->describe().c_str());
// seed 1 broke "never show stale results" at step 7 (t=9.629ms) after 6 message(s)The same seed breaks the same way every time, on every platform, and
r.failure->messages replays through jaal::replay. A run takes about a
microsecond, so hundreds of thousands of seeds a second.
Then walk the failing run step by step:
jaal::timeline<Search> t(r.failure->messages);
auto step = t.first_bad([](const Model& m) { return m.shown >= m.query || m.shown == 0; });
puts(jaal::to_string(t.changes(*step)).c_str()); // ".1: 2 -> 1"A program that reads its own generator can't be replayed: the bug you're
chasing never comes back. So drawing is an effect, and the kernel owns the
stream (Elm's Random.generate):
static Cmd update(Model&, Roll) {
return Cmd::random([](jaal::rng& r) -> Msg {
return Rolled{r.in(1, 6)}; // or shuffle a deck
});
}jaal::rng is splitmix64: 8 bytes of state and the same sequence on every
platform and standard library (the distributions in <random> are not
portable, which would break replay). It has next, below, in,
uniform, between, chance, pick and split.
A real run picks its own seed and reports it, so a crash is reproducible:
jaal::run_options o;
o.on_seed = [](std::uint64_t s) { log("seed %llu", s); }; // log it
o.kernel.random_seed = from_the_log; // replay itIn tests the seed is fixed instead: headless and given default to one,
and sim derives it from the sim seed, so one seed still controls the whole
run.
Three things every interactive app needs, that jaal has so you don't write them again.
A list of sub-programs. Tabs, panes, sessions, open buffers: each with its own model, timers and streams, and messages routed back to the right one.
struct ToTab { int id; Tab::Msg msg; };
using Tabs = jaal::children<Tab, App, ToTab>; // App is the parent program
struct Model { Tabs::map tabs; }; // id -> Tab::Model
static Cmd update(Model& m, ToTab t) { return Tabs::update(m.tabs, t); } // routed to that tab
static Cmd update(Model& m, NewTab) { return Tabs::add(m.tabs).second; } // runs its init()
static Cmd update(Model& m, Close c) { Tabs::remove(m.tabs, c.id); return {}; } // its streams stopA child's messages come back as an ordinary case of the parent's Msg, so
routing them is just another update overload.
The part that's easy to get wrong: two tabs both subscribing to stream key
"fetch" would reconcile to one subscription, so one tab's feed would
drive the other. children<> prefixes each child's keys with its id, so they
stay separate and stop independently. For a single child in a fixed slot,
jaal::child<> is the simpler one.
Waiting for typing to settle. Keyed tasks already throw away stale
results. When you also want to wait until the user stops typing before
starting at all, debounce holds the newest value and a token:
struct Model { jaal::debounce<std::string> query; };
static Cmd update(Model& m, Typed t) { // each keystroke: newest wins
return Cmd::after(200ms, Fire{m.query.set(t.text)});
}
static Cmd update(Model& m, Fire f) { // the timer arrives
if (!m.query.ready(f.token)) return {}; // superseded: drop it
return Cmd::perform<Results>("search", &search, m.query.value());
}The token is what makes it correct. Guarding on the text instead looks
equivalent and isn't: type ab, delete to a, and the stale timer for a
matches again. jaal::throttle is the by-time sibling, for redraws.
A message back into the loop, so one update path can reuse another:
return Cmd::send(Refresh{}); // folded in this stepNot after(0ms, ...): that goes through the timer heap and arrives a step
late, which costs a frame and makes tests wait on a clock.
Surviving a crash. update is pure, so the messages it folded ARE the
state. Journal them, and on restart rebuild the model and resume from it:
jaal::durable<Ledger> d;
auto log = load_journal(); // yours: file, WAL, db
if (!log.empty()) d.resume = jaal::replay<Ledger>(log); // or replay_from(snapshot, tail)
d.journal = [&](const Ledger::Msg& m) { append_and_fsync(m); };
return jaal::run<Ledger>(host, {}, std::move(d));Resuming skips init() and its effects (they already happened) but runs
subscribe(model), so timers and streams the model wants come back live.
Put one-shot restart work (reconnect, re-announce) in d.resume_cmd.
Test it without a disk: headless<Ledger> h(jaal::resume_from, model).
Needs a C++26 compiler with structured binding packs: GCC 16, clang 22, or
Apple clang 21 (Xcode 26). CMake 3.29+, Ninja. On macOS, jaal passes
-std=c++2c itself when CMake is older than the compiler and doesn't know
the flag yet.
cmake --preset dev # debug; uses ccache and mold/lld when installed
cmake --build --preset dev
ctest --preset devOther presets: clang, asan, tsan, release, mingw (Windows cross
build with llvm-mingw; tests run under wine).
To run the whole matrix:
scripts/check.sh # Linux: gcc, clang, asan, tsan, windows-under-wine
# macOS: clang, asan, tsan, release
scripts/check.sh asan tsan # or name the presets you wantIt prints one line per preset and exits non-zero if any fails. About 2.5 minutes on a 12-core machine. There's no hosted CI; this is the check.
| Linux | macOS | Windows | |
|---|---|---|---|
| core, kernel | runs | runs | runs (native, clang64; also under wine) |
| reactor | epoll + poll, run | kqueue + poll, run | wait_reactor, native + under wine |
| process | posix_process (fork/exec) | posix_process | windows_process (CreateProcessW + job object, adopt for foreign children) |
| signals | run | run (real SIGINT/TERM/HUP) | handler run directly; not yet via a real console |
| sanitizers | asan, tsan | asan, tsan | — |
Linux and macOS run the whole suite on real hardware, under the debug,
clang, asan, tsan and release presets. Windows builds natively with MSYS2
clang64 and runs the core/kernel/reactor suite (maya + agentty execute
their full suites against it); the wine matrix remains as the historical
verification, and a native Windows preset for scripts/check.sh is the
remaining gap.
add_subdirectory(jaal) # or: find_package(jaal)
target_link_libraries(app PRIVATE jaal::jaal)MIT