DEV Community

Cover image for Designed to survive: how Zeri's core assumes everything can die
Luca
Luca

Posted on

Designed to survive: how Zeri's core assumes everything can die

The value that belongs to no one

The project structure is like a kitchen in mid-service. Four stations, four cooks, and none of them speak the same language. The one on the grill thinks in Python. Pasta speaks JavaScript. Garde-manger works in Ruby, and the dessert chef only knows Lua. An order comes in that needs all four to touch the same plate. Now: how do you pass a half-finished dish between people who can't read each other's tickets?

That's the problem Zeri spends most of its complexity solving. It's a terminal REPL where you can start a value in one language and finish it in another, set a dictionary in Python, read it back in JavaScript, and change it in Lua. Sounds small. It isn't, because a value in one language simply is not the same object in another. A Python int and a JavaScript number are different things in memory. None, null, and nil are three different ways of saying nothing, and they don't agree on much beyond the sentiment. Somebody in the middle has to own a neutral version of that value, one that belongs to no language, so it can be handed to any of them.

The companion piece to this article argues about why I built a thing like this before I had a user who asked for it. This one is about the how, and the how turned out to have a single idea running under every part of it, so I'll say it once up front and then let it resurface: every boundary in this system is built assuming the other side can die. Two processes, two protocols, four sidecars, one shared hub, and each seam is designed around the possibility that whatever's on the far end goes away mid-sentence. Not as a failure case bolted on afterward. As the thing that decides the shape.

None of what follows is theory. Every claim is a line I can point at, and a couple of them are lines I had to fix while writing this, which, honestly, is the best argument I know of for writing the article at all.

Two processes that agree to die together

Zeri runs as two processes. There's a headless engine written in C++ that does evaluation, holds state, and supervises the language runtimes. And there's a terminal UI written in Go, built on the Charm stack, that does rendering and input. They talk over a socket. That's the whole topology.

The split isn't decoration. The engine has no idea what renders its output; the UI has no idea how code gets evaluated. That ignorance is the point; it means a crash on one side can't corrupt the memory of the other, because there's no shared memory to corrupt. Two address spaces, one pipe between them.

Why C++ for the engine specifically? Because the engine is the thing that owns other processes. It forks runtimes, wires up their file descriptors, sets death signals, and builds job objects. That's memory-and-process work, and it's the job C++ is actually good at. Go got the frontend for the opposite reason: an event-driven TUI wants cheap concurrency and good tooling, and the Charm stack is built for exactly that shape.

Now the interesting part, and the first place the "assume death" rule shows up, with a twist I didn't expect when I started. The two sides do not survive each other symmetrically. The frontend survives a dead engine: if the engine process crashes, the UI catches it and transitions to a disconnected state through two independent paths (one watching the process, one watching the transport). But the engine does not survive a frontend that walks away. Any I/O error on the bridge triggers a shutdown:

bridge.on_error([bridgeTerminal](yuumi::Error) {
    bridgeTerminal->RequestShutdown();
});
Enter fullscreen mode Exit fullscreen mode

That call unblocks the REPL read loop with an empty value; the loop breaks, and the process exits. This is deliberate. An engine with no frontend attached is a headless process nobody can see, and nobody can kill cleanly. So the engine chooses to die with the thing it serves rather than outlive it. The rule isn't "nothing ever dies." The rule is "process isolation stops a crash from spreading corruption, and past that, dying on purpose beats staying." Symmetry would have been easier to write and worse to run.

Diagram: the Go TUI and the C++ engine as two separate processes joined by one socket. The UI watches the engine through two paths that both lead to a disconnected state; the engine, on any bridge error, calls RequestShutdown and exits on purpose. Death is observed on one side, chosen on the other.

Two protocols, because there are two kinds of "other side"

Here's a design decision that looks like duplication until you see why it isn't: Zeri has two internal protocols, one for the UI-to-engine link, one for the engine-to-runtime link. I could have reused one for both. I didn't, and the reason is that the two boundaries are talking to two different kinds of unreliable.

The first protocol carries the UI-to-engine conversation. Its frame is a fixed six-byte header, four bytes of payload length in little-endian, one channel byte, one flags byte, followed by a JSON payload.

frame := make([]byte, frameHeaderSize+len(payload))
binary.LittleEndian.PutUint32(frame[0:4], uint32(len(payload)))
frame[4] = byte(ch)
frame[5] = 0
copy(frame[frameHeaderSize:], payload)
Enter fullscreen mode Exit fullscreen mode

The header is binary because binary is cheap and unambiguous: read the first four bytes, you know exactly how many bytes the rest of the message is, and you take that many without ever inspecting a single character of the payload to find where it ends. The payload is JSON because JSON is easier to debug: when something goes wrong, you can read the message. Binary where it has to be fast, text where it can afford to be simple. That split runs through both protocols.

The frame carries a length, a channel, and a flags byte, and nothing else: no sequence counter, no framing metadata beyond what it takes to find the next boundary. The channel byte does the multiplexing, so control traffic and data traffic share one socket without stepping on each other. The flags byte is reserved: there's an enum declaring Compressed and Encrypted bits, but nothing sets them yet. It's a byte I designed room for and haven't spent, which is cheaper than a protocol change I'd have to make later to claw the room back.

The read side is where the "assume death" rule gets concrete:

std::vector<std::byte> header(6);
asio::read(_transport.socket(), asio::buffer(header), ec);
if (ec) break;

std::memcpy(&length, header.data(), 4);
if (length > MAX_MESSAGE_SIZE) break;

std::vector<std::byte> body(length);
asio::read(_transport.socket(), asio::buffer(body), ec);
if (ec) break;
Enter fullscreen mode Exit fullscreen mode

Every failure is a break. A length that exceeds the cap is a break; the engine does not try to resynchronize because a corrupt length means the stream is no longer interpretable, and the only honest move is to stop. A truncated header is indistinguishable from an error and also breaks. There's no "half a frame" state here. When the loop exits, one line decides whether that exit was a death or a shutdown I asked for:

if (_connected.exchange(false)) {
    notify_error(Error::ConnectionLost);
}
Enter fullscreen mode Exit fullscreen mode

The exchange is the anti-double-notify. If a clean stop() already set the flag to false, the exchange returns false, and the error handler never fires. It's the difference between "it died" and "I killed it," encoded in a single atomic swap.

The second protocol, I call it ZeriWire, carries the engine-to-runtime conversation, and it's a different animal because the far end is a different kind of fragile. It's a five-byte header (length plus a message-type byte, no channel, no flags) over the child process's stdin and stdout. And unlike the first protocol, its decoder is an incremental state machine:

case State::READ_PAYLOAD: {
    const size_t need = m_payloadLen - m_payloadPos;
    const size_t take = std::min(need, len - pos);
    std::memcpy(m_payload.data() + m_payloadPos, ptr + pos, take);
    m_payloadPos += take;
    pos += take;

    if (m_payloadPos < m_payloadLen) break;    // payload split across two reads

    ZeriFrame frame{ m_frameType, std::move(m_payload) };
    ResetState();
    SavePending(ptr, len, pos);                // bytes belonging to the next frame
    return frame;
}
Enter fullscreen mode Exit fullscreen mode

Why the extra machinery? Because the first protocol reads from a socket where a blocking read fills the whole buffer or fails. ZeriWire reads from an anonymous pipe, where a read hands you an arbitrary 4096-byte chunk that has no respect for message boundaries. A frame can be split across two reads; two frames can arrive in one. The state machine is the price of that. And it needs no channels (the message type is the discipline), no version handshake, no PID check, because the engine launched this child itself. The first protocol exchanges a magic number and a version because its peer connected from outside and has to prove it's the right kind of stranger. ZeriWire's only handshake is a READY event that means "the bootstrap loaded," not "you are who you claim to be."

That's the whole reason for two protocols. A UI that connects is one contract. A child who can die mid-sentence is another. The protocols are shaped like the things they distrust.

Diagram comparing two frame headers byte by byte. Protocol 1 (UI to engine) uses 6 bytes: a 4-byte little-endian length, one channel byte, one reserved flags byte, then a JSON payload. ZeriWire (engine to runtime) uses 5 bytes: the same 4-byte length, one type byte, then the payload. No channel, no flags, because the engine launched the child itself.

Sidecars: every runtime is a process that can burn down alone

Each language runs as its own sidecar process. Python, JavaScript and TypeScript through Bun, Ruby, and Lua through LuaJIT, four isolated processes, each with its own memory and its own lifecycle. The engine is the pass: it calls the orders, times them, and handles the failures. Every station is a process, not a thread, and that distinction is the entire safety model.

The runtimes aren't bundled. They're discovered on PATH at startup, four lines, one per language:

addRuntime("lua", { "luajit" }, false, "luajit");
addRuntime("python", { "python3", "python" }, false, "python3");
addRuntime("js", { "bun" });
addRuntime("ruby", { "ruby" }, true);
Enter fullscreen mode Exit fullscreen mode

No hardcoded paths, no vendored binaries — just names to look up. If a runtime isn't there, that language is marked unavailable and the rest carry on.

The "each station burns down alone" guarantee isn't a comment in the code; it's a system call. On POSIX, the engine forks and execs:

const pid_t pid = fork();
if (pid == 0) {
#ifdef __linux__
    prctl(PR_SET_PDEATHSIG, SIGTERM);
    if (getppid() == 1) _exit(1);
#endif
    dup2(outWrite.Get(), STDOUT_FILENO);
    dup2(inRead.Get(), STDIN_FILENO);
    // ... (omitted: close inherited fds, build argv)
    execvp(executable.c_str(), argv.data());
    _exit(127);
}
Enter fullscreen mode Exit fullscreen mode

A SIGSEGV in Python3 is a SIGSEGV in Python3, full stop, the engine's address space isn't reachable from the sidecar, because the only channel between them is a pair of anonymous pipes. There's no dlopen("libpython") anywhere; nothing is loaded in-process. And the runtime can't outlive its parent: PR_SET_PDEATHSIG asks the OS to send the child a signal when the parent dies, while the getppid() == 1 check closes the race window where the parent dies between the fork and the death-signal setup. On macOS, the same result comes from a small daemon watching the parent for exit; on Windows, a job object with kill-on-close is created, where the child is created suspended and assigned to the job before it's ever allowed to run. Three platforms, one promise, no orphaned runtimes, ever.

Supervision is layered, and the timeouts are ordered on purpose:

inline constexpr std::chrono::seconds kLaunchHandshakeTimeout{ 5 };
inline constexpr std::chrono::seconds kWatchdogTimeout{ 30 };
inline constexpr std::chrono::seconds kExecuteCompletionTimeout{ 35 };
Enter fullscreen mode Exit fullscreen mode

The outer timeout (35s) is strictly larger than the watchdog (30s). That ordering is the design; the outer layer only wakes up if the inner safety net has already failed. It's the safety net for the safety net. And when the watchdog does fire, it doesn't report an absence; it manufactures a synthetic result and hands it to the caller, so the layer above never has to know the answer simply never came. Death gets translated into a value.

There's an honest wrinkle here I'll own rather than hide. When a sidecar dies, it actually tries to say so; the Python bootstrap catches its own top-level exception and sends a CRASHED event on the way out. The engine, in this version, doesn't listen; the dispatch for that event only looks for READY, and everything else is dropped, which a comment in the code cheerfully admits. So the sidecar shouts, nobody's listening, and the watchdog quietly cleans up thirty seconds later. It works. It's not elegant. It's on the list.

And when a launch fails entirely, the engine degrades instead of erroring out; it falls back to one-shot execution, running the code in a fresh, short-lived process. You lose the persistent namespace for that runtime, not the functionality. A dead sidecar gets recreated on the next command, transparently, and the only cost to you is the variables that lived in that one process. Only that one.

Diagram: the C++ engine as a central hub holding the canonical value store, connected by a separate pipe pair to each of four isolated sidecar processes, Python, JavaScript/TypeScript on Bun, Ruby, and Lua on LuaJIT. There are no connections between the sidecars; every value crosses through the engine.

SharedScope: the hub that owns the neutral copy

Back to the four cooks. The value that has to move between them can't live in any one language, so it lives in the engine, in a single canonical form that belongs to none of them. The engine is the hub. Each runtime is a spoke with its own namespace, and the spokes never touch each other; the only way a value gets from Python to Bun is through the engine. The store itself is unremarkable to look at:

std::map<std::string, AnyValue> m_sharedVariables;
mutable std::shared_mutex m_sharedMutex;
Enter fullscreen mode Exit fullscreen mode

The interesting work is the translation at the boundary, and it happens in the engine, not in each sidecar. When a value comes in as JSON, it's normalized into a neutral representation, and the rules are readable line by line:

if (value.is_boolean())        return AnyValue(value.get<bool>());
if (value.is_number_integer()) return AnyValue(value.get<std::int64_t>());
if (value.is_number_float())   return AnyValue(value.get<double>());
if (value.is_string())         return AnyValue(value.get<std::string>());

if (value.is_array()) {
    std::vector<AnyValue> out;
    for (const auto& item : value) {
        const auto converted = JsonToAnyValue(item);   // recursion
        if (!converted.has_value()) {
            return std::nullopt;                        // one bad leaf
        }                                               // invalidates the whole container
        out.push_back(*converted);
    }
    return AnyValue(std::move(out));
}
// ... (omitted: object branch, same recursive shape)
Enter fullscreen mode Exit fullscreen mode

Every integer collapses to a 64-bit int, every float to a double, strings stay strings, and arrays and maps are rebuilt recursively. The canonical form is a value that can hold exactly seven things: null, bool, int64, double, string, array-of-those, map-of-those. Nothing else can be constructed. And that recursive return std::nullopt is the property worth staring at: a dictionary with a nested array survives the trip if and only if every leaf inside it is representable. One member that isn't collapses the whole container to nothing. There's no partial success, no "I saved the parts I could." Either the value crosses the whole, or it doesn't cross.

Two decisions shaped this, and both are the "assume death" rule wearing different clothes.

The first is copy-by-value, not shared memory. GetShared returns by value; no overload hands back a reference, no GetSharedRef, nothing that could be mistaken for a live pointer into the hub. This is deliberate to the point of stubbornness. Four processes, four different garbage collectors, or in LuaJIT's case, its own, and a live reference across that boundary would be a promise none of the four could keep. The copy is the discipline. Pretending four separate processes share memory would be a lie engineered to crash later, so the system doesn't pretend.

The second is loud failure. If a value doesn't fit the supported set, the engine doesn't mangle it into something that looks fine; it refuses, and the set fails on the spot:

const auto converted = DeserializeAnyValue(message["value"]);
if (converted.has_value()) {
    runtimeState.SetShared(key, *converted);
} else {
    response["error"] = "Unsupported value type.";
}
Enter fullscreen mode Exit fullscreen mode

That error rides back to the user's code and becomes an exception on the exact line that called set, not three steps later, wearing a disguise, in some unrelated function. Break loudly beats deliver-corrupt-quietly.

Except, and this is one of the lines I had to fix while writing this article, the rule had a hole in it, and the hole said the opposite of what the rule promised. The integer branch did value.get<std::int64_t>() with no range check. An unsigned integer above int64 max would get silently truncated, and one bigger still would quietly degrade to a float. It was the single place in the whole system that would hand you the wrong value without a word, the exact betrayal the loud-failure rule exists to prevent, sitting right in the middle of the mechanism that's supposed to enforce it. I found it setting a big number in Python and reading a different number back in JavaScript. The fix is boring: an out-of-range integer is now refused the same way a BigInt is, returns nothing, and raises on the line. The canonical set is still seven types. The rule now actually holds.

One last detail I like, because it shows the same data can warrant two different policies. When a shared value can't be serialized, the machine-facing list skips it silently, a lost entry, no trace. But the human-facing /shared list command shows it, marked <unsupported>. Same underlying value, two audiences, two honest answers: the machine gets a clean list it can consume, the human gets the truth that something's wrong.

The AI layer, and where it deliberately isn't

Zeri has a local-LLM feature; you can talk to a model from inside the REPL. It's worth thirty seconds here purely because of where it lives, which is a clean example of the engine/frontend boundary holding.

The whole thing lives in the Go layer. The TUI builds an OpenAI-compatible request, streams the response back as UI events, and the C++ engine never sees a token, a prompt, or a reply.

req, err := http.NewRequestWithContext(ctx, http.MethodPost,
    target+"/v1/chat/completions", bytes.NewReader(body))
Enter fullscreen mode Exit fullscreen mode

There's one honest caveat. The engine isn't totally ignorant of the feature: it stores and serves the AI settings, endpoint, model and key, because the engine owns persistence and the UI has no store of its own. So the division is precise, the UI knows how to talk to the model, and the engine knows where the keys are. The engine holds the configuration and never once uses it. It's the tidiest possible version of "not my job."

Yuumi, and why the protocol got its own name

The UI-to-engine protocol, the framing, the transport, the handshake, the bridge, is factored out into its own thing, called Yuumi. Inside the repo, it's a separate build target on the C++ side and a separate package on the Go side, with one property that matters, the dependencies only point one way. Nothing under Yuumi includes anything from Zeri. Yuumi does not know Zeri exists; it transports and it doesn't understand. The bridge hands up a raw, uninterpreted map and lets the layer above decide what any of it means.

That discipline, the seam being real, not aspirational, is what later made it possible to spin the idea out into a project of its own. I'm not going to oversell that here. The point for this article is narrower and more useful: drawing a boundary strictly enough that one side genuinely can't reach into the other is what lets you eventually lift that side out. The isolation wasn't for show. It was the thing that made the piece separable.

The one idea, again

If you've read this far, you've seen the same decision made in five different places. The two-process split contains a crash. The two protocols each distrust a different kind of peer. The sidecars burn down alone. Copy-by-value refuses to fake a shared memory that four processes couldn't honor. Loud failure refuses to hand you a value it isn't sure about, and where it quietly did anyway, that was a bug, and now it doesn't.

None of this makes Zeri important. It's still a REPL that went looking for a problem architecture-first, and the companion article is honest about what that cost. But the architecture itself has one spine, and the spine is a single sentence: assume the other side can die, and design the seam so that when it does, nothing lies to you about it. Everything else in this system is a consequence of taking that seriously.

If you want the why, why build this before anyone asked, that's the other article. This was how. If you want more information about the project or some aspects of the code in particular, I am open to further explanations.

Top comments (0)