Experimental BEAM-like JavaScript runtime on QuickJS (via rquickjs).
Many isolated JS processes are multiplexed onto a small, fixed pool of OS worker threads — one system thread can manage thousands of engines. Each process owns its own QuickJS runtime (isolated heap, like a BEAM process) and a mailbox; processes communicate only by message passing.
Model
- Process = one
Runtime+Context+ mailbox. Never shares JS state. - Scheduler = M worker threads pulling processes off a shared run queue. Each slice executes one pending QuickJS job, then the process is re-queued, parked, or finished. Processes migrate freely between worker threads.
- Suspension =
await recv()on an empty mailbox parks the process until a message arrives. Nothing blocks an OS thread. - Isolation = an uncaught error kills only its process; the exit code is non-zero if any process failed.
Install
Build & run
cargo build --release otter [--workers N] script.js [more_scripts.js ...]
Every file on the command line starts as its own process (pids 0..n); the
runtime exits when all processes (including spawned ones) have finished.
JSON-RPC control (RPC)
otter can expose a JSON-RPC 2.0 control socket over TCP, letting an external
tool drive the scheduler exactly like the in-JS API: spawn, list, info,
kill, send, rename, count, and shutdown. Enable it with
--rpc-port (the runtime then stays alive until you call shutdown); frames
are line-delimited, one request per line, so any CLI that speaks TCP works:
# start a runtime with a control socket on port 9000 otter --rpc-port 9000 script.js & # drive it with netcat (or any line-based TCP client) echo '{"jsonrpc":"2.0","id":1,"method":"list"}' | nc 127.0.0.1 9000 echo '{"jsonrpc":"2.0","id":2,"method":"spawn","params":{"code":"await recv();"}}' | nc 127.0.0.1 9000 echo '{"jsonrpc":"2.0","id":3,"method":"send","params":{"pid":1,"value":"hi"}}' | nc 127.0.0.1 9000 echo '{"jsonrpc":"2.0","id":4,"method":"shutdown"}' | nc 127.0.0.1 9000
Each request carries a numeric/string id and receives a matching response;
notifications (no id) get none, per the JSON-RPC 2.0 spec.
JS API
| API | Description |
|---|---|
spawn(code, opts?) |
Start a new process from a source string, returns its pid. Optional { sandbox: { canSpawnAndKill: false } } narrows the child's sandbox at birth. |
send(pid, value) |
Serialize value to JSON and deliver it to pid's mailbox. Messages to dead pids are dropped silently. |
await recv(timeoutMs?) |
Suspend until a message arrives; resolves with the parsed value. With a timeoutMs it rejects with a TimeoutError if no message arrives in time (a message that races the deadline stays in the mailbox). |
await sleep(ms) |
Suspend the process for at least ms milliseconds; does not touch the mailbox. |
await yieldNow() |
Voluntarily give up the current slice and rejoin the back of the run queue. (Not named yield because that word is reserved inside async-function bodies, which is how top-level-await scripts are parsed.) |
self() |
The current process's pid. |
killProcess(pid) |
Request termination of a live process; returns true if pid is live. Best-effort: the process is reaped at its next scheduling boundary (parked/sleeping processes immediately). Killing an unknown pid returns false. |
listProcesses() |
Array of {pid, name, status} for every live process, sorted by pid. |
isProcessAlive(pid) |
true while pid is live. |
processInfo(pid) |
{pid, name, status} for a live pid, or null. |
processCount() |
Number of live processes. |
setName(name) |
Rename the current process; visible in listProcesses()/processInfo(). |
selfSandbox() |
Snapshot {canSpawnAndKill} of the current process's sandbox policy. |
restrictSandbox(policy?, opts?) |
Narrow a sandbox at runtime (self by default, or {pid}). Monotonic/irrevocable; returns the post-state. |
console.log/error |
Line-oriented output prefixed with the pid. |
Scripts support top-level await. Full API docs live in docs/.
Examples
cargo run --release -- examples/ping_pong.js # two processes volleying messages cargo run --release -- --workers 1 examples/ring.js # 1000 processes on ONE thread cargo run --release -- examples/coop.js # yieldNow()-driven interleaving cargo run --release -- examples/timer.js # sleep() and recv(timeoutMs) watchdog cargo run --release -- examples/process_mgmt.js # list, inspect, rename, and kill processes cargo run --release -- examples/sandbox.js # spawn confined children, self-restrict, no-escalation
Limitations (v1)
- Cooperative scheduling: a long synchronous loop without
await/yieldNow()monopolizes its worker thread until it suspends (no preemption yet). - Killing is cooperative too:
killProcess()takes effect at the target's next scheduling boundary, so a process stuck in a long synchronous loop won't die until it yields. - Timers are serviced by idle workers on a ~5 ms tick, so
sleep()/recv()deadlines are accurate to within a tick or two, not to the millisecond. - At most one outstanding suspension per process: calling
sleep()while arecv()is pending (or vice versa) raises aTypeError.recv()/yieldNow()after asleep()is fine once the sleep has completed. - Messages must be JSON-serializable; functions, symbols and
undefinedare rejected. - Sandboxing is a single toggle today (
canSpawnAndKill); it gates onlyspawnandkillProcess(other).send/recv/sleep/yieldNoware unrestricted, and there is no CPU/memory isolation. Seedocs/api.md.
