
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.
Runtime + Context + mailbox. Never shares JS state.await recv() on an empty mailbox parks the process until
a message arrives. Nothing blocks an OS thread.cargo install otter-rt
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.
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.
| 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/.
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
await/yieldNow()
monopolizes its worker thread until it suspends (no preemption yet).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.sleep()/recv()
deadlines are accurate to within a tick or two, not to the millisecond.sleep() while a
recv() is pending (or vice versa) raises a TypeError. recv()/yieldNow()
after a sleep() is fine once the sleep has completed.undefined are
rejected.canSpawnAndKill); it gates only spawn
and killProcess(other). send/recv/sleep/yieldNow are unrestricted,
and there is no CPU/memory isolation. See docs/api.md.cargo test
2 commits
Rust
100.0%

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.
Runtime + Context + mailbox. Never shares JS state.await recv() on an empty mailbox parks the process until
a message arrives. Nothing blocks an OS thread.cargo install otter-rt
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.
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.
| 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/.
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
await/yieldNow()
monopolizes its worker thread until it suspends (no preemption yet).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.sleep()/recv()
deadlines are accurate to within a tick or two, not to the millisecond.sleep() while a
recv() is pending (or vice versa) raises a TypeError. recv()/yieldNow()
after a sleep() is fine once the sleep has completed.undefined are
rejected.canSpawnAndKill); it gates only spawn
and killProcess(other). send/recv/sleep/yieldNow are unrestricted,
and there is no CPU/memory isolation. See docs/api.md.cargo test
2 commits
Rust
100.0%