https://github.com/leostera/zart
zig actor runtime
https://github.com/leostera/zart
Last synced: 2 months ago
JSON representation
zig actor runtime
- Host: GitHub
- URL: https://github.com/leostera/zart
- Owner: leostera
- License: mit
- Created: 2026-04-24T09:40:23.000Z (2 months ago)
- Default Branch: main
- Last Pushed: 2026-04-25T21:29:57.000Z (2 months ago)
- Last Synced: 2026-04-27T19:32:27.203Z (2 months ago)
- Language: Zig
- Size: 318 KB
- Stars: 13
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- Contributing: CONTRIBUTING.md
- License: LICENSE.md
- Agents: AGENTS.md
Awesome Lists containing this project
README
# zart
`zart` is a small typed actor runtime for Zig.
The core idea is simple: spawning an actor returns an `Actor(Msg)` handle, and that handle is the typed capability used to send messages. Messages are structurally copied into the target actor's inbox; pointer fields remain references and keep their normal Zig ownership/lifetime rules.
## Status
`zart` is pre-release and under active development.
Implemented today:
- Typed `Actor(Msg)` handles.
- Function actors and struct actors.
- Message sends through `actor.send(msg)`.
- Cooperative scheduling on stackful fibers.
- `ctx.recv()` parking and `ctx.yield()` CPU checkpoints.
- Configurable execution and I/O budgets.
- Non-blocking actor I/O facade through runtime-provided `std.Io` drivers.
- Default multicore scheduler with worker parking and work stealing.
- kqueue-backed file and stream socket I/O on Apple/BSD targets through `zart.io.Default`.
- POSIX `poll(2)` I/O backend available as `zart.io.PosixPoll`.
- Runtime tracing hooks.
- Stack slab pooling for actor fibers.
- Explicit ABI-preserving fiber context switching on supported native targets.
- Guard-page owning fiber stacks and opt-in stack high-water measurement.
- Runnable examples under `examples/`.
- Runtime tests and actor/fiber benchmarks.
Planned:
- Monitors and links.
- Lock-free SMP-ready mailboxes.
- `spawn_blocking`.
- Linux `io_uring` I/O backend.
- Sanitizer/Valgrind fiber hooks.
- WebAssembly backend via Asyncify/continuations.
## Example
```zig
const std = @import("std");
const zart = @import("zart");
const Reply = union(enum) {
value: u64,
};
const CounterMsg = union(enum) {
inc: u64,
get: zart.Actor(Reply),
stop,
};
const Counter = struct {
pub const Msg = CounterMsg;
initial: u64 = 0,
pub fn run(self: *@This(), ctx: *zart.Ctx(Msg)) !void {
var value = self.initial;
while (true) {
switch (try ctx.recv()) {
.inc => |n| value += n,
.get => |reply_to| try reply_to.send(.{ .value = value }),
.stop => return,
}
}
}
};
const Collector = struct {
pub const Msg = Reply;
slot: *u64,
pub fn run(self: *@This(), ctx: *zart.Ctx(Msg)) !void {
switch (try ctx.recv()) {
.value => |n| self.slot.* = n,
}
}
};
test "counter actor" {
var rt = try zart.Runtime.init(std.testing.allocator, .{});
defer rt.deinit();
var observed: u64 = 0;
const collector = try rt.spawn(Collector{ .slot = &observed });
const counter = try rt.spawn(Counter{ .initial = 10 });
try counter.send(.{ .inc = 32 });
try counter.send(.{ .get = collector });
try counter.send(.stop);
try rt.run();
try std.testing.expectEqual(@as(u64, 42), observed);
}
```
Function actors are also supported when the parameter is typed as `*zart.Ctx(Msg)`:
```zig
fn worker(ctx: *zart.Ctx(CounterMsg)) !void {
while (true) {
switch (try ctx.recv()) {
.inc => {},
.get => {},
.stop => return,
}
}
}
```
## Runtime Configuration
`Runtime.Options` controls runtime policy and testability:
```zig
var rt = try zart.Runtime.init(allocator, .{
.stack_size = 64 * 1024,
.stack_slab_size = 4 * 1024 * 1024,
.preallocate_stack_slab = true,
.preallocate_registry_slab = true,
.execution_budget = 64,
.io_budget = 64,
.worker_count = 0,
.internal_allocator = std.heap.smp_allocator,
.tracer = null,
.io = null,
});
```
Actors receive `ctx.allocator()` from the allocator passed to `Runtime.init`. Runtime internals use `internal_allocator`, defaulting to `std.heap.smp_allocator`.
`worker_count = 0` uses the host logical CPU count. Set `worker_count = 1` for deterministic single-worker tests; the public execution API is still `rt.run()`.
The detailed runtime contract is documented in `docs/runtime-semantics.md`.
## I/O
Actor I/O is non-blocking by runtime contract. `ctx.io()` returns a `std.Io` facade that preserves standard I/O call shapes while routing operations through a user-provided `zart.IoDriver`.
The driver must not block the scheduler. It can complete requests immediately or retain them and complete them later from a poller/event callback.
The default backend is selected at comptime through `zart.io.Default`:
```zig
var actor_io = try zart.io.Default.init();
defer actor_io.deinit();
var rt = try zart.Runtime.init(allocator, .{
.io = actor_io.driver(),
});
```
Implemented POSIX-style drivers retry non-blocking file and stream socket reads/writes internally. If an operation returns `WouldBlock`, the actor is parked until readiness is reported and then resumed with the completed result.
Backend status:
- Apple/BSD targets: `zart.io.Default` uses `kqueue`.
- Linux: `zart.io.Uring` is reserved for the production backend but is not implemented yet.
- Portable POSIX fallback: `zart.io.PosixPoll` is available explicitly.
Do not route blocking filesystem or network calls through `ctx.io()`. `spawn_blocking` is planned for that class of work.
## Fibers
Actors run on stackful fibers. `Fiber` is intentionally a low-level primitive: the fast path accepts caller-provided stack bytes and does not allocate. `Fiber.init` initializes a fiber in-place, and the `Fiber` object must stay at a stable address until `deinit`. This pinning requirement does not prevent scheduler-thread migration; it only means the fiber handle itself cannot be copied or moved after initialization because the prepared stack stores its address.
Supported native backends are explicit:
- `x86_64_sysv`: Linux, macOS, BSD-family, DragonFly.
- `aarch64_aapcs64_basic`: Linux, macOS, BSD-family.
The context switch saves ABI-preserved state directly in an `extern struct`, through global assembly with a C ABI boundary. On x86_64 SysV it preserves `rsp`, `rip`, `rbx`, `rbp`, `r12`-`r15`, MXCSR control state, and the x87 control word. On AArch64 it preserves `x19`-`x29`, `sp`, `pc`, the switch function's `lr`, and the low 64-bit lanes of `d8`-`d15`; optional SVE/SME state is not supported.
For standalone fibers, prefer the guarded owning stack helper:
```zig
var stack = try zart.Fiber.Stack.alloc(64 * 1024);
defer stack.deinit();
var fiber: zart.Fiber = undefined;
try stack.initFiber(&fiber, entry, arg); // also enables stackHighWaterMark()
defer fiber.deinit();
_ = try fiber.run();
```
Current Fiber limitations:
- Raw caller-provided stack slices have no guard page. Use `Fiber.Stack.alloc` when a standalone fiber should trap on downward stack overflow.
- No ASan/TSan fiber annotations or Valgrind stack registration yet.
- No reliable unwinding/backtraces across fiber boundaries.
- No CET shadow-stack integration on x86_64, and no explicit IBT/BTI/PAC landing-pad policy yet.
- No C++ exception or `longjmp` crossing fiber boundaries.
- No WebAssembly backend yet; Wasm needs an Asyncify/continuation backend, not native stack-pointer switching.
`Fiber.deinit()` rejects suspended fibers and transitions valid fibers to `.deinitialized`; later `run()` returns `error.Deinitialized`. If a runtime intentionally discards a suspended stack without unwinding, it must call `abandonWithoutUnwind()` explicitly first; that transitions the fiber to `.abandoned`, and later `run()` returns `error.Abandoned`.
## Tracing
Pass `Runtime.Options.tracer` to observe runtime events without changing actor code. When no tracer is configured, trace event construction is skipped at the call sites.
Events include actor spawned/resumed/waiting/yielded/completed/failed, message sent/received, and I/O submitted/completed.
## Examples
Examples live under `examples/` and are wired into the build:
```sh
zig build examples
zig build example-counter
zig build example-ping_pong
zig build example-fan_out
zig build example-cooperative_yield
zig build example-tracing
zig build example-file_io
zig build example-http_server
```
They cover typed request/reply actors, ping-pong messaging, fan-out aggregation, explicit `ctx.yield()` checkpoints, runtime tracing, `ctx.io()` file reads, and one-actor-per-request HTTP socket handling.
The HTTP server runs until interrupted by default:
```sh
zig build example-http_server -- --port 8080 --acceptors 4
curl http://127.0.0.1:8080/hello
```
For smoke tests, limit the accept loop:
```sh
zig build example-http_server -- --port 8080 --max-requests 1
```
## Build
```sh
zig build
zig build test
zig build examples
zig build bench -- --quick
zig build bench -- --quick --warmup 5 --iterations 1000
zig build bench -Doptimize=ReleaseFast -- --quick
```
The benchmark runner reports min/max/avg/mean/median/stddev for each case using adaptive time units.
## Package
Import the module as `zart` from `src/root.zig`.
```zig
const zart = @import("zart");
```