No description
Find a file
zacheryasc de0f3960f4 feat: docs and README (#13)
Update the docs and readme with all the changes we've made
2026-02-06 17:12:04 +00:00
benches feat: worker thread api (#6) 2026-02-06 21:45:19 +07:00
docs feat: docs and README (#13) 2026-02-06 17:12:04 +00:00
examples feat: jupyter example (#10) 2026-02-06 14:04:48 +00:00
src feat: worker thread api (#6) 2026-02-06 21:45:19 +07:00
tests feat: worker thread api (#6) 2026-02-06 21:45:19 +07:00
tools/depgraph feat: code DAG visualizer (#11) 2026-02-06 14:54:56 +00:00
wasm feat: basic wasm (#12) 2026-02-06 16:47:43 +00:00
.gitignore feat: basic wasm (#12) 2026-02-06 16:47:43 +00:00
Cargo.lock feat: python bindings (#7) 2026-02-06 12:47:51 +00:00
Cargo.toml feat: worker thread api (#6) 2026-02-06 21:45:19 +07:00
deps.dot feat: code DAG visualizer (#11) 2026-02-06 14:54:56 +00:00
deps.html feat: code DAG visualizer (#11) 2026-02-06 14:54:56 +00:00
package-lock.json feat: code DAG visualizer (#11) 2026-02-06 14:54:56 +00:00
package.json feat: code DAG visualizer (#11) 2026-02-06 14:54:56 +00:00
pyproject.toml feat: docs and README (#13) 2026-02-06 17:12:04 +00:00
README.md feat: docs and README (#13) 2026-02-06 17:12:04 +00:00
uv.lock feat: jupyter example (#10) 2026-02-06 14:04:48 +00:00

swactor

Small, WASM-compatible actor runtime for Rust, with Python and WebAssembly bindings.

Quick Start (Rust)

use swactor::actor::{ActorAddress, ActorInterface};
use swactor::runtime::{Ctx, Runtime, RuntimeConfig};

#[derive(Clone)]
struct Greet { name: String, reply_to: ActorAddress }

#[derive(Clone)]
struct Greeting(String);

struct Greeter;

impl ActorInterface for Greeter {
    type Incoming = Greet;
    type Response = Greeting;

    fn handle(&mut self, ctx: &Ctx, msg: Greet) {
        let _ = ctx.send(msg.reply_to, Greeting(format!("Hello, {}!", msg.name)));
    }
}

fn main() {
    let rt = Runtime::new(RuntimeConfig::default());
    let addr = rt.spawn(Greeter).unwrap();
    let inbox = rt.new_inbox::<Greeting>().unwrap();

    rt.send_to(addr, Greet { name: "world".into(), reply_to: *inbox.addr() }).unwrap();
    rt.tick();
    rt.tick();

    println!("{}", inbox.try_recv().unwrap().0); // "Hello, world!"
}

Quick Start (Python)

uv pip install .    # builds the Rust extension automatically

Single-threaded — caller drives each tick:

from swactor import Runtime

def echo(ctx, msg):
    ctx.send(msg["reply_to"], f"hello, {msg['name']}!")

rt = Runtime()
addr = rt.spawn(echo)
inbox = rt.inbox()
rt.send(addr, {"name": "world", "reply_to": inbox.addr})
rt.tick()
print(inbox.try_recv())  # "hello, world!"

Multi-threaded — workers run on background threads:

import asyncio
from swactor import Runtime, RuntimeConfig

async def main():
    rt = Runtime(RuntimeConfig(num_threads=2))

    def echo(ctx, msg):
        ctx.send(msg["reply_to"], f"hello, {msg['name']}!")

    addr = rt.spawn(echo)
    inbox = rt.inbox()
    handle = rt.run()  # spawns worker threads, consumes rt

    for name in ["alice", "bob", "charlie"]:
        handle.send(addr, {"name": name, "reply_to": inbox.addr})
        while (reply := inbox.try_recv()) is None:
            await asyncio.sleep(0.01)
        print(reply)

    handle.shutdown()
    handle.join()

asyncio.run(main())

Quick Start (WASM)

The wasm/ crate wraps swactor for use from JavaScript via wasm-bindgen. It runs single-threaded with the caller driving tick() — a natural fit for game loops, simulations, or any frame-based update cycle.

cd wasm && wasm-pack build --target nodejs    # or --target web
import { SwactorRuntime } from "./wasm/pkg/swactor_wasm.js";

const rt = new SwactorRuntime();

// spawn a counter actor — accumulates values sent to it
const counter = rt.spawn_counter();

// spawn a relay that forwards messages to the counter
const relay = rt.spawn_relay(counter);

// send through the relay
rt.send(relay, 5);
rt.send(relay, 7);

rt.tick();  // relay receives and forwards
rt.tick();  // counter receives forwarded messages

// drain results from the inbox
let v;
while ((v = rt.try_recv()) !== undefined) {
    console.log(v);  // 5, then 12
}

rt.free();

The WASM crate uses the no_random feature (deterministic address generation) so there's no dependency on system RNG.

Running the Examples

cargo run --example hello    # single actor, request/response
cargo run --example ring     # 500 actors in a ring topology

Multi-threaded Mode

Pass num_threads in the config. The runtime spawns OS threads and runs workers autonomously — no tick() calls needed.

let mut config = RuntimeConfig::default();
config.num_threads = 4;
let rt = Runtime::new(config);

let addr = rt.spawn(MyActor::default()).unwrap();
let handle = rt.run().unwrap();  // consumes rt, spawns 4 threads

// use handle.runtime to spawn/send while workers run
handle.runtime.send_to(addr, MyMsg).unwrap();

handle.shutdown();
handle.join();

Architecture

The runtime is layered: Runtime → Workers → ActorPool → Actors.

┌─ Runtime (Arc, shared) ──────────────────────────────────────┐
│                                                               │
│  AddressMap    Placement    InboxRegistry    is_running        │
│  (addr→worker) (round-robin) (external inboxes) (AtomicBool)  │
│                                                               │
│  transfer_txs[]              spawn_txs[]                      │
│  (one Sender per worker)     (one Sender per worker)          │
│                                                               │
└───────┬───────────────┬───────────────┬───────────────────────┘
        │               │               │
        v               v               v
   ┌─ Worker 0 ──┐ ┌─ Worker 1 ──┐ ┌─ Worker 2 ──┐
   │  ActorPool   │ │  ActorPool   │ │  ActorPool   │
   │  ┌────────┐  │ │  ┌────────┐  │ │  ┌────────┐  │
   │  │mailbox │  │ │  │mailbox │  │ │  │mailbox │  │
   │  │ actor  │  │ │  │ actor  │  │ │  │ actor  │  │
   │  └────────┘  │ │  └────────┘  │ │  └────────┘  │
   │  ┌────────┐  │ │  ┌────────┐  │ │              │
   │  │mailbox │  │ │  │mailbox │  │ └──────────────┘
   │  │ actor  │  │ │  │ actor  │  │
   │  └────────┘  │ │  └────────┘  │
   └──────────────┘ └──────────────┘

Each worker runs a four-phase tick loop:

  1. Drain spawn queue — add newly spawned actors to the pool
  2. Drain transfer queue — deliver cross-worker messages to mailboxes
  3. Tick all actors — pop messages, call handlers, buffer outgoing sends
  4. Drain pending local — deliver same-worker messages for the next tick

Messages are type-erased (Box<dyn Any + Send>) in transit and downcast back to the concrete type at delivery. Mismatched types are silently dropped.

Detailed architecture docs live in docs/:

Document Covers
Worker Thread Tick phases, backoff, message routing, full system topology
Runtime Runtime, Ctx, Inbox, RuntimeHandle, stats
Actor Model Traits, type erasure, addresses
Channels & Shared State HybridChannel, AddressMap, Placement

Source Layout

src/
├── lib.rs           module root, feature gates, get_random()
├── actor.rs         Message, ActorInterface, ActorAddress, type erasure
├── runtime.rs       Runtime, Ctx, Inbox, RuntimeHandle, InboxRegistry
├── worker/
│   ├── mod.rs       Worker, WorkerContext, ActorPool, tick loop
│   └── tests.rs     worker unit tests with step-based DSL
├── channel.rs       HybridChannel (ArrayQueue + SegQueue), Sender/Receiver
├── config.rs        RuntimeConfig, BackoffPolicy
├── address_map.rs   AddressMap (RwLock<HashMap>), Placement (round-robin)
├── error.rs         Error type
└── python.rs        PyO3 bindings (feature = "python")

wasm/
├── Cargo.toml       separate crate, depends on swactor with no_random
├── src/lib.rs       wasm-bindgen wrapper (SwactorRuntime)
└── test.mjs         Node.js test suite

examples/
├── hello.rs                    echo actor
├── ring.rs                     ring topology
└── python/
    ├── hello_single_thread.py  minimal Python example
    ├── hello_async.py          multi-threaded + asyncio
    └── getting_started.ipynb   Jupyter notebook

tests/
├── runtime_api.rs   single + multi-thread integration tests
├── stats_demo.rs    stats snapshot tests
└── test_python.py   Python binding tests

Building & Testing

# Rust
cargo test                              # run all tests
cargo run --example hello               # run an example
cargo bench                             # benchmarks (criterion)

# Python bindings (requires Rust toolchain on PATH)
uv pip install .                        # build + install
uv run python3 tests/test_python.py     # run Python tests

# WASM bindings
cd wasm && wasm-pack build --target nodejs
node test.mjs                           # run WASM tests

Feature Flags

Flag Default What it does
getrandom yes System RNG for actor addresses
no_random no Deterministic counter (for WASM / reproducible tests)
python no PyO3 bindings, builds cdylib wheel