Skip to content
agentty-orgPublic

About

A typed Elm runtime for C++26, plus the platform layer it runs on. Effects as typed rows, deep Sendable/Frozen checks, one reactor per OS.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

jaal

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.

The idea in one example

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 update for 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 Cmd type says which effects update can 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.

Background work, Elm style

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.

Input reads the live model

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.

Finding races with a seed

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"

Randomness you can replay

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 it

In 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.

Building out a real app

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 stop

A 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 step

Not 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).

Building

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 dev

Other 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 want

It 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.

What's tested where

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.

Using it from CMake

add_subdirectory(jaal)                  # or: find_package(jaal)
target_link_libraries(app PRIVATE jaal::jaal)

License

MIT

About

A typed Elm runtime for C++26, plus the platform layer it runs on. Effects as typed rows, deep Sendable/Frozen checks, one reactor per OS.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages