gi-dellav/otter

Experimental JS runtime with ability to run 1k+ threads

4

stars

2

commits

Rust

primary language

Aug 26, 2026

updated

README

otter

Sea Otter

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

cargo install otter-rt

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

APIDescription
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/errorLine-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 a recv() is pending (or vice versa) raises a TypeError. recv()/yieldNow() after a sleep() is fine once the sleep has completed.
  • Messages must be JSON-serializable; functions, symbols and undefined are rejected.
  • Sandboxing is a single toggle today (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.

Tests

cargo test

Contributors

gi-dellav

2 commits

gi-dellav/otter

Experimental JS runtime with ability to run 1k+ threads

4

stars

2

commits

Rust

primary language

Aug 26, 2026

updated

README

otter

Sea Otter

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

cargo install otter-rt

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

APIDescription
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/errorLine-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 a recv() is pending (or vice versa) raises a TypeError. recv()/yieldNow() after a sleep() is fine once the sleep has completed.
  • Messages must be JSON-serializable; functions, symbols and undefined are rejected.
  • Sandboxing is a single toggle today (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.

Tests

cargo test

Contributors

gi-dellav

2 commits

Languages

Rust

100.0%