Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 93 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ Beam.demonitor(ref);

## Supervision

Runtimes are OTP children with crash recovery:
Runtimes and context pools are OTP children with crash recovery:

```elixir
children = [
Expand All @@ -82,6 +82,9 @@ children = [
"db.query" => fn [sql, params] -> Repo.query!(sql, params).rows end,
}},
{QuickBEAM, name: :worker, id: :worker},

# Context pool for high-concurrency use cases
{QuickBEAM.ContextPool, name: MyApp.JSPool, size: 4},
]

Supervisor.start_link(children, strategy: :one_for_one)
Expand All @@ -92,6 +95,77 @@ Supervisor.start_link(children, strategy: :one_for_one)
The `:script` option loads a JS file at startup. If the runtime crashes,
the supervisor restarts it with a fresh context and re-evaluates the script.

Individual `Context` processes are typically started dynamically (e.g.
from a LiveView `mount`) and linked to the connection process.

## Context Pool

For high-concurrency scenarios (thousands of connections), use
`ContextPool` instead of individual runtimes. Many lightweight JS
contexts share a small number of runtime threads:

```elixir
# Start a pool with N runtime threads (defaults to scheduler count)
{:ok, pool} = QuickBEAM.ContextPool.start_link(name: MyApp.JSPool, size: 4)

# Each context is a GenServer with its own JS global scope
{:ok, ctx} = QuickBEAM.Context.start_link(pool: MyApp.JSPool)
{:ok, 3} = QuickBEAM.Context.eval(ctx, "1 + 2")
{:ok, "HELLO"} = QuickBEAM.Context.eval(ctx, "'hello'.toUpperCase()")
QuickBEAM.Context.stop(ctx)
```

Contexts support the full API — `eval`, `call`, `Beam.call`/`callSync`,
DOM, messaging, browser/node APIs, handlers, and supervision:

```elixir
# In a Phoenix LiveView
def mount(_params, _session, socket) do
{:ok, ctx} = QuickBEAM.Context.start_link(
pool: MyApp.JSPool,
handlers: %{"db.query" => &MyApp.query/1}
)
{:ok, assign(socket, js: ctx)}
end

```

The context is linked to the LiveView process — it terminates and
cleans up automatically when the connection closes. No explicit
`terminate` callback needed.

### Granular API groups

Contexts can load individual API groups instead of the full browser bundle:

```elixir
QuickBEAM.Context.start_link(pool: pool, apis: [:beam, :fetch]) # 231 KB
QuickBEAM.Context.start_link(pool: pool, apis: [:beam, :url]) # 108 KB
QuickBEAM.Context.start_link(pool: pool, apis: false) # 58 KB
QuickBEAM.Context.start_link(pool: pool) # 429 KB (all browser APIs)
```

Available groups: `:fetch`, `:websocket`, `:worker`, `:channel`,
`:eventsource`, `:url`, `:crypto`, `:compression`, `:buffer`, `:dom`,
`:console`, `:storage`, `:locks`. Dependencies auto-resolve.

### Per-context resource limits

```elixir
{:ok, ctx} = QuickBEAM.Context.start_link(
pool: pool,
memory_limit: 512_000, # per-context allocation limit (bytes)
max_reductions: 100_000 # opcode budget per eval/call
)

# Track per-context memory
{:ok, %{context_malloc_size: 92_000}} = QuickBEAM.Context.memory_usage(ctx)
```

Exceeding `memory_limit` triggers OOM. Exceeding `max_reductions`
interrupts the current eval but keeps the context usable for
subsequent calls.

## API surfaces

QuickBEAM can load browser APIs, Node.js APIs, or both:
Expand Down Expand Up @@ -327,8 +401,26 @@ vs QuickJSEx 0.3.1 (Rust/Rustler, JSON serialization):
| `Beam.callSync` (JS→BEAM) | 5 μs overhead (unique to QuickBEAM) |
| Startup | ~600 μs (parity) |

Context pool vs individual runtimes at scale:

| | Runtime (1:1 thread) | Context (pooled) |
|---|---|---|
| JS heap per instance | ~530 KB | ~429 KB (full) / ~58 KB (bare) |
| OS thread stack | ~2.5 MB each | shared (4 threads total) |
| OS threads at 10K | 10,000 | 4 (configurable) |
| Total RAM at 10K | ~30 GB | ~4.2 GB (full) / ~570 MB (bare) |

See [`bench/`](https://github.com/elixir-volt/quickbeam/tree/master/bench) for details.

## When to use what

| Use case | Module | Why |
|---|---|---|
| One-off eval, scripting | `QuickBEAM` (Runtime) | Simple, full isolation |
| SSR request pool | `QuickBEAM.Pool` | Checkout/checkin with reset |
| Per-connection state (LiveView) | `QuickBEAM.Context` | Lightweight, thousands concurrent |
| Sandboxed user code | `QuickBEAM` or `Context` with `apis: false` | Memory limits, reduction limits, timeouts |

## Examples

- [`examples/ssr/`](https://github.com/elixir-volt/quickbeam/tree/master/examples/ssr) — Preact SSR with a pool of runtimes
Expand Down
123 changes: 123 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,129 @@ Each checkout gets a runtime, each checkin resets it and re-runs the
init function. This gives a clean JS context per request while
amortizing startup cost.

## Context Pool

`QuickBEAM.ContextPool` is a different approach to concurrency —
lightweight JS contexts that share runtime threads, rather than
whole runtimes in a checkout pool.

### The problem

A full `QuickBEAM.Runtime` dedicates an OS thread and `JSRuntime` per
GenServer (~2MB+ each). At 10K concurrent connections (e.g. Phoenix
LiveView), that's 10K threads and ~25GB of memory.

### The solution

QuickJS natively supports multiple `JSContext` instances per
`JSRuntime`. Each context has its own global object, prototypes, and
execution state, but shares the runtime's GC heap and parser. A
`ContextPool` exploits this:

```
┌─────────────────────────────────────────────────────┐
│ ContextPool (GenServer) │
│ Round-robin assignment: context → thread │
├──────────┬──────────┬──────────┬───────────────────┐│
│ Thread 0 │ Thread 1 │ Thread 2 │ Thread N-1 ││
│ JSRuntime│ JSRuntime│ JSRuntime│ JSRuntime ││
│ ┌──────┐ │ ┌──────┐ │ ┌──────┐ │ ┌──────┐ ││
│ │Ctx 1 │ │ │Ctx 2 │ │ │Ctx 3 │ │ │Ctx N │ ││
│ │Ctx 5 │ │ │Ctx 6 │ │ │Ctx 7 │ │ │Ctx ..│ ││
│ │Ctx 9 │ │ │... │ │ │... │ │ │ │ ││
│ └──────┘ │ └──────┘ │ └──────┘ │ └──────┘ ││
└──────────┴──────────┴──────────┴───────────────────┘│
└─────────────────────────────────────────────────────┘
```

Marginal memory per context depends on API surface: ~58 KB bare,
~71 KB with Beam API, ~108 KB beam+url, ~231 KB beam+fetch,
~429 KB with full browser APIs. Individual runtimes cost ~530 KB
JS heap plus a ~2.5 MB OS thread stack each.

### How it works

Each pool thread has a lock-free message queue and a `HashMap` of
`ContextId → ContextEntry` (QuickJS context + `RuntimeData`). The
worker loop dequeues messages, looks up the target context by ID,
and dispatches operations (eval, call, reset, destroy, DOM queries,
message delivery, resolve/reject for `Beam.call`).

`Beam.callSync` uses per-context `SyncCallSlot`s stored in a
`RuntimeData` referenced by both the JS thread and NIF layer. The
NIF writes the result and signals the slot directly — no round-trip
through the pool queue — so the blocked JS thread wakes immediately.

`Beam.call` (async) works through a drain callback: when the JS
thread is in `await_promise` waiting for a Promise to resolve, it
periodically calls `drain_fn` which pulls messages from the pool queue
and routes resolve/reject messages to the correct context.

### Context lifecycle

Each `QuickBEAM.Context` is a lightweight GenServer that:
1. On `init`: asks the pool to create a `JSContext` on one of its
threads, installs polyfills (browser/node/beam), snapshots builtins
2. On `eval`/`call`: enqueues work to the pool thread via NIF,
receives the result as a message
3. On `terminate`: sends a destroy command to free the `JSContext`

Contexts are isolated — separate globals, separate prototypes — but
share the runtime's GC and parser. Prototype pollution in one context
does not affect another.

### Granular API groups

Instead of loading all browser APIs, contexts can request individual
groups to minimize memory:

```elixir
QuickBEAM.Context.start_link(pool: pool, apis: [:beam, :fetch]) # 231 KB
QuickBEAM.Context.start_link(pool: pool, apis: [:beam, :url]) # 108 KB
```

Available groups: `:fetch`, `:websocket`, `:worker`, `:channel`,
`:eventsource`, `:url`, `:crypto`, `:compression`, `:buffer`, `:dom`,
`:console`, `:storage`, `:locks`. Dependencies auto-resolve (e.g.
`:fetch` includes EventTarget/AbortController, `:websocket` includes
the message dispatcher).

The `:browser` atom expands to all groups but uses a monolithic bundle
for better code sharing.

### Precompiled bytecode

Polyfill JS is compiled to QuickJS bytecode once (on first use) and
cached in `persistent_term`. New contexts load bytecodes via
`JS_ReadObject` + `JS_EvalFunction` instead of parsing JS text —
~3.2x faster context creation.

### QuickJS patches

QuickBEAM patches QuickJS-NG with per-context resource controls:

**Per-context memory tracking** — All context-level allocators
(`js_malloc`, `js_calloc`, `js_realloc`, `js_free`) track a
`malloc_size` counter on the `JSContext`. When `malloc_limit` is set,
allocations exceeding the limit trigger OOM. The runtime-level memory
tracking remains unchanged (cumulative across all contexts).

```elixir
{:ok, ctx} = QuickBEAM.Context.start_link(pool: pool, memory_limit: 512_000)
{:ok, %{context_malloc_size: 92_000}} = QuickBEAM.Context.memory_usage(ctx)
```

**Per-context reduction limits** — Each interrupt check (~10K opcodes)
increments a `reduction_count` on the `JSContext`. When it exceeds
`reduction_limit`, an uncatchable error terminates the current eval.
The count resets before each eval/call, so the limit is per-operation.
The context remains usable after hitting the limit.

```elixir
{:ok, ctx} = QuickBEAM.Context.start_link(pool: pool, max_reductions: 100_000)
# A 10M-iteration loop gets interrupted; next eval works fine
```

## Supervision

Runtimes are GenServers — they fit naturally into OTP supervision
Expand Down
17 changes: 5 additions & 12 deletions lib/quickbeam.ex
Original file line number Diff line number Diff line change
Expand Up @@ -247,13 +247,6 @@ defmodule QuickBEAM do
QuickBEAM.Runtime.send_message(runtime, message)
end

@user_globals_js """
(() => {
const names = Object.getOwnPropertyNames(globalThis).sort();
return names.filter(k => !k.startsWith("__qb_") && !(k in globalThis.__qb_builtins));
})()
"""

@doc """
List global names defined in the JS context.

Expand All @@ -271,10 +264,10 @@ defmodule QuickBEAM do
"""
@spec globals(runtime(), keyword()) :: {:ok, [String.t()]} | {:error, QuickBEAM.JSError.t()}
def globals(runtime, opts \\ []) do
if Keyword.get(opts, :user_only, false) do
eval(runtime, @user_globals_js)
else
eval(runtime, "Object.getOwnPropertyNames(globalThis).sort()")
user_only = Keyword.get(opts, :user_only, false)

with {:ok, names} <- GenServer.call(runtime, {:list_globals, user_only}, :infinity) do
{:ok, Enum.sort(names)}
end
end

Expand All @@ -298,7 +291,7 @@ defmodule QuickBEAM do
"""
@spec get_global(runtime(), String.t()) :: js_result()
def get_global(runtime, name) when is_binary(name) do
eval(runtime, "globalThis[#{inspect(name)}]")
GenServer.call(runtime, {:get_global, name}, :infinity)
end

@doc """
Expand Down
5 changes: 4 additions & 1 deletion lib/quickbeam/beam_call.zig
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,10 @@ fn beam_call_sync_impl(
self.rd.sync_slots_mutex.unlock();
return qjs.JS_ThrowInternalError(ctx, "runtime shutting down");
}
slot.done.timedWait(10_000_000) catch {};
if (self.drain_fn) |drain| {
drain(self);
}
slot.done.timedWait(1_000_000) catch {};
}

self.rd.sync_slots_mutex.lock();
Expand Down
Loading
Loading