# Sandbox Architecture

This document describes the `@coda/sandbox` package: an isolated code execution engine built on V8 isolates via `isolated-vm`.

The sandbox is built around a single invariant: **user code never shares memory with the host process**. It runs inside a V8 isolate — an independent heap managed by `isolated-vm` — and communicates exclusively through a narrow bridge of serialized JSON strings and `ivm.Reference` function handles.

---

## The 3 Execution Planes

The system is decomposed into three execution planes.

**1. Host plane** — the Node.js process. All privileged code lives here:

- `SandboxEngine` — public API surface; orchestrates the other components
- `IsolateManager` — maintains the warm pool and the active-isolate set
- `ContextBuilder` — injects bridge references and WASM binding scripts into a fresh isolate context
- `CodeExecutor` — compiles and runs user code inside the prepared context, enforcing dual timeout
- `WasmLoader` — reads WASM binaries and binding scripts from disk; caches compiled `ArrayBuffer` instances

**2. Bridge plane** — the membrane between host and isolate. Nothing crosses this boundary except:

- Primitive values (strings, numbers, booleans)
- JSON-encoded strings (the only way to pass structured data)
- `ivm.Reference` handles (opaque pointers to host functions, invocable from the isolate)

The bridge is implemented in `bridge/session.ts` and the context-building step of `ContextBuilder`. Each bridge function has a defined calling convention (`apply` for async, `applySync` for sync) and validates its arguments before acting.

**3. Isolate plane** — the V8 isolate context. This is where user-supplied code runs. The isolate sees only:

- `dataProxy(request)` — async function; suspends until the host fulfills a data request
- `reportStatus(payload)` — sync function; fires a status event to the session queue
- `console.log` / `console.warn` / `console.error` — sync; forwarded to the host as console events
- WASM module bindings injected by `ContextBuilder` at context-build time
- Standard JavaScript globals (ECMAScript built-ins only — no `process`, no `require`, no `fetch`)

```
┌─────────────────────────────────────────────────────────────────────────┐
│  HOST PLANE (Node.js process)                                           │
│                                                                         │
│   SandboxEngine / IsolateManager / ContextBuilder / CodeExecutor        │
│   SandboxSessionImpl (event queue, pending request map)                 │
│   WasmLoader (ArrayBuffer cache)                                        │
│                                                                         │
│                     │                    ▲                              │
│        ivm.Reference│invocation          │JSON string return            │
│        (JSON string │argument)           │(user code return value)      │
│                     ▼                    │                              │
│  ═══════════════ ISOLATE BOUNDARY ════════════════════════════════════  │
│                                                                         │
│  ISOLATE PLANE (V8 isolate context)                                     │
│                                                                         │
│   dataProxy(req)       → JSON.stringify(req) → __dataRequestFn ref     │
│   reportStatus(status) → JSON.stringify(st)  → __reportStatusFn ref    │
│   console.log(...)     → JSON.stringify(args) → __consoleLogFn ref     │
│   return result        → JSON.stringify(result)                         │
│                                                                         │
│   [NO access to: process, require, fetch, fs, net, Buffer, globals]    │
│                                                                         │
└─────────────────────────────────────────────────────────────────────────┘

Crossing rules:
  ALLOWED   ✓  Primitive values (string, number, boolean, null)
  ALLOWED   ✓  JSON strings (the only structured-data transport)
  ALLOWED   ✓  ivm.Reference handles (opaque; invoked, never inspected)
  ALLOWED   ✓  ArrayBuffer (for WASM binary transfer — copy, not shared)
  BLOCKED   ✗  JavaScript objects (including arrays, class instances)
  BLOCKED   ✗  Promises (cannot cross; async bridge uses JSON + callback)
  BLOCKED   ✗  Node.js APIs, modules, or any host-side references
  BLOCKED   ✗  Prototype chains, closures, or any mutable shared state
```

---

## Component Diagram

```
Caller
  │
  │  submit(ExecuteMessage)
  ▼
SandboxEngine
  ├── IsolateManager ─────────────────────────────────────────────────┐
  │     ├── warmPool: IsolateHandle[]  (pre-created, ready to use)    │
  │     └── active: Set<IsolateHandle> (currently executing)          │
  │           └── IsolateHandle { isolate, context, createdAt }       │
  │                                                                    │
  ├── ContextBuilder ──────────────────────────────────────────────── │ ─┐
  │     ├── injects: __dataRequestFn  (ivm.Reference, async)          │  │
  │     ├── injects: __reportStatusFn (ivm.Reference, applySync)      │  │
  │     ├── injects: __consoleLogFn   (ivm.Reference, applySync)      │  │
  │     ├── injects: __consoleWarnFn  (ivm.Reference, applySync)      │  │
  │     ├── injects: __consoleErrorFn (ivm.Reference, applySync)      │  │
  │     ├── runs: bootstrap script (wraps refs into JS API surface)   │  │
  │     └── runs: WASM binding scripts (per LoadedModule)             │  │
  │                                                                    │  │
  ├── CodeExecutor ────────────────────────────────────────────────── │ ─┘
  │     ├── validates: code size <= limits.codeSizeBytes              │
  │     ├── wraps: user code in double IIFE                           │
  │     ├── compiles: ivm.Script (syntax check on host)               │
  │     ├── runs: script.run({ timeout: timeoutMs }) — V8 CPU timeout  │
  │     └── races: external setTimeout(timeoutMs) — wall-clock timeout │
  │                                                                    │
  └── WasmLoader ──────────────────────────────────────────────────── ┘
        ├── configs: WasmModuleConfig[]
        ├── cache: Map<name, LoadedModule>
        └── LoadedModule { name, binary: ArrayBuffer, bindings: string }


SandboxSession  (returned to caller, per-execution)
  ├── events: AsyncIterable<OutboundMessage>   ← caller iterates this
  ├── send(DataResponseMessage)                ← caller pushes data responses
  └── abort()                                  ← caller cancels execution


Bridge (inside each isolate context after ContextBuilder.build())
  ├── __dataRequestFn   — ivm.Reference; apply({result:{promise:true}}) from isolate
  │                       JSON-string in → host resolves → JSON-string out
  ├── __reportStatusFn  — ivm.Reference; applySync from isolate
  │                       JSON-string payload → pushed to session queue as 'status' event
  ├── __consoleLogFn    — ivm.Reference; applySync; forwarded as 'console' event
  ├── __consoleWarnFn   — ivm.Reference; applySync; forwarded as 'console' event
  ├── __consoleErrorFn  — ivm.Reference; applySync; forwarded as 'console' event
  └── WASM bindings     — compiled ivm.Script objects run in context at build time
```

---

## The ivm Bridge — How Values Cross the Isolate Boundary

V8 isolates are fully memory-isolated. Each isolate has its own heap, and objects allocated in one isolate cannot be referenced from another. This is the fundamental constraint that shapes the entire bridge design.

**What crosses automatically:** Only primitives — `string`, `number`, `boolean`, and `null`. Everything else requires explicit transfer.

**ivm.Reference — wrapping host functions:** `ivm.Reference` wraps a host-side function as a transferable reference. When passed into an isolate's context, the isolate receives a proxy object. The isolate calls the proxy's `.apply()` or `.applySync()` method, which invokes the original host function. The `{ result: { promise: true } }` option is critical for async host functions: when the host function returns a Promise, ivm unwraps it inside the isolate so the isolate can `await` the result naturally.

**The JSON string trick:** Complex objects (arrays, nested objects) cannot be passed directly across the boundary. The pattern used throughout the bridge is:

- **Host → isolate**: The host serializes the return value with `JSON.stringify()` before returning it. The isolate receives a plain string, then calls `JSON.parse()` on it.
- **Isolate → host**: The isolate serializes arguments with `JSON.stringify()` before passing them. The host receives a plain string, then calls `JSON.parse()` on it.

This is why `dataProxy.request` returns a fully parsed object in user code despite the raw transfer being a string, and why `onDataRequest` receives `paramsJson: string` and immediately does `JSON.parse(paramsJson)`.

`ContextBuilder.build()` instruments each isolate context by:

1. Creating `ivm.Reference` for `__dataRequestFn` — wraps the `onDataRequest` callback from `engine.ts`. Deserializes `paramsJson`, calls `onDataRequest(method, params)`, returns `JSON.stringify(result)` back across the boundary.
2. Creating `ivm.Reference` for `__reportStatusFn` — wraps the `onStatus` callback; pushes `StatusEvent` to the session queue.
3. Creating `ivm.Reference` for each console fn — sync, pushes `ConsoleEvent`.
4. Injecting all references into the context global via `context.global.set()`.
5. Compiling and running the bootstrap script — wraps the raw refs into the clean `dataProxy` / `reportStatus` / `console` API surface visible to user code.
6. For each `LoadedModule` from `WasmLoader`: compiling and running the `bindings` string in the context.

---

## Code Wrapping and Compilation

User code is never executed raw. Before compilation, it is wrapped in a double IIFE:

```javascript
(async () => {
  const __r = await (async () => {
    // USER CODE HERE
  })();
  return JSON.stringify(__r !== undefined ? __r : null);
})();
```

Each layer has a specific purpose:

- **Outer async IIFE**: Allows the entire execution to be returned as a Promise, which the host unwraps via `script.run(context, { promise: true })`.
- **Inner async IIFE**: Enables top-level `await` in user code. Critically, `return` statements in user code return from the inner function — not the outer one — so the outer wrapper always has control over what crosses the boundary.
- **`JSON.stringify(__r)`**: Serializes the return value to a string inside the isolate before it crosses the boundary. This is the isolate's side of the JSON string trick.
- **`__r !== undefined ? __r : null`**: Guards against `JSON.stringify(undefined)`, which returns the JavaScript value `undefined` (not the string `"null"`), which would cause `ivm` to throw.

The compilation flow in `CodeExecutor.run()`:

1. Measure the UTF-8 byte length of the user's source code; reject if it exceeds `maxCodeBytes` (`SandboxValidationError`).
2. Wrap the code in the double IIFE.
3. `isolate.compileScript(wrappedCode)` compiles to V8 bytecode. Syntax errors surface here as `SandboxValidationError`.
4. Execute with dual timeout (see below).
5. Parse the return value: `JSON.parse(returnValue)` on the host.
6. Gather stats: `performance.now()` delta for wall time, `isolate.cpuTime` (BigInt nanoseconds), `isolate.getHeapStatistics()` for peak memory.
7. Validate `JSON.stringify(result).length <= limits.outputSizeBytes`; throw `SandboxValidationError` if exceeded.

---

## The Dual Timeout Implementation

Two independent timeout mechanisms run in parallel:

```
script.run({ timeout: timeoutMs })   — V8 built-in, interrupts synchronous loops
        ↕ Promise.race
setTimeout(() => isolate.dispose(), timeoutMs)  — external, disposes for async hangs
```

**Why both are needed**: The V8 built-in timeout fires between event loop ticks. Synchronous infinite loops (e.g., `while(true) {}`) are interrupted correctly. However, async code that does `while (condition) { await something(); }` keeps the event loop alive on every tick, so V8 never gets the opportunity to fire its interrupt. The external timeout bypasses this by disposing the isolate unconditionally after the deadline.

After `isolate.dispose()`, any pending `await` inside the isolate throws "Isolate is disposed". `CodeExecutor` catches this state: it checks `handle.isolate.isDisposed` and throws `SandboxTimeoutError`.

Both paths — V8 interrupt and external dispose — produce the same `SandboxTimeoutError` to callers. The distinction is internal.

---

## The Data Request Flow

The following describes what happens when user code calls `dataProxy.request(method, params)`:

1. **User code** calls `await dataProxy.request('getTracks', { albumId: 123 })` inside the isolate.
2. **Inside the isolate bootstrap**, the `dataProxy.request` function calls:
   ```javascript
   __dataRequestFn.apply(undefined, [method, JSON.stringify(params)], {
     result: { promise: true },
   });
   ```
3. **Host `dataRequestRef` function** is invoked. It calls `JSON.parse(paramsJson)` to recover the params object, then calls `onDataRequest(method, params)`.
4. **`onDataRequest` in `engine.ts`** increments `dataRequestCount`, checks it against the configured limit, then calls `session.createDataRequest(dataRequestId, method, params)`.
5. **`session.createDataRequest`** pushes a `data_request` event onto the async queue and creates a Promise stored in the `pendingRequests` map, keyed by `dataRequestId`.
6. **The caller's `for await (const event of session.events)`** receives the `data_request` event and reads the method and params.
7. **The caller** calls `session.send({ type: 'data_response', dataRequestId, data: result })` with the resolved data.
8. **`session.send()`** looks up the pending Promise by `dataRequestId` and resolves it with the provided data.
9. **`onDataRequest`** receives the resolved value, checks `dataBytesReceived` against the limit, then returns the data to the bridge.
10. **`dataRequestRef`** returns `JSON.stringify(result)` across the ivm boundary.
11. **ivm** delivers the string to the isolate's `.apply()` call result.
12. **Inside the isolate bootstrap**, `JSON.parse(json)` is called — user code receives the fully reconstituted object.
13. Execution continues in user code.

---

## Session and Async Queue

`SandboxSession` is the interface returned to callers. `SandboxSessionImpl` is the concrete implementation.

The session exposes `session.events` as an `AsyncIterableIterator`. Internally, it uses a buffer-and-resolver pattern to bridge the push-based event producer and pull-based async iterator consumer.

- **`buffer: OutboundMessage[]`**: Holds events that have been pushed but not yet consumed.
- **`waitingResolver`**: Holds the `resolve` function from a pending `next()` call when the consumer is waiting for the next event.

The mechanics:

- When an event arrives (`pushEvent`): if `waitingResolver` is set, resolve it immediately with the new event (consumer was waiting). Otherwise, push to buffer.
- When the consumer calls `next()`: if buffer has items, return and shift the first item immediately. If the iterator is closed, return `{ done: true }`. Otherwise, store a new Promise's `resolve` as `waitingResolver` and return the Promise.

**Backpressure note**: `pushEvent` never blocks. If the consumer is slow and not calling `next()`, events accumulate in `buffer` without bound.

**Pending data request map:** `pendingRequests: Map<requestId, (payload: string) => void>` — each in-flight `dataProxy` call registers a callback here. `session.send(DataResponseMessage)` looks up the request by ID and resolves it. If `abort()` is called, all pending requests are rejected with `SandboxAbortedError`.

**Lifecycle:** `created → executing → closed`. `close()` is called internally by `execute()` in its `finally` block. Callers cannot call it directly.

---

## Console Capture

The isolate context is bootstrapped with three `ivm.Reference` functions that replace `console.log`, `console.warn`, and `console.error` inside the isolate. Each accepts a string message argument and calls `onStatus('console', '[log] ...')`, `'[warn] ...'`, or `'[error] ...'` on the host.

**Two-level cap on console output**:

1. **`ContextBuilder` cap**: A `consoleCallCount` counter shared across all three console refs. Once it reaches 1000, all three refs become no-ops.
2. **Engine cap**: `onStatus` in `engine.ts` has its own `statusCount` counter. Once `statusCount >= maxStatusEvents`, the `onStatus` handler becomes a no-op regardless of source.

Console output is subject to the lower of the two limits: 1000 calls per execution (ContextBuilder), or the engine's `maxStatusEvents` across all status event types combined.

---

## Stats Collection

Execution statistics are collected and returned with every result:

- **`wallMs`**: Measured using `performance.now()` before and after the `script.run()` call. Rounded with `Math.max(1, Math.round(...))` — the minimum is 1ms to avoid reporting 0 for very fast executions.
- **`cpuMs`**: `isolate.cpuTime` returns a BigInt in nanoseconds. Converted via `Number(cpuNs) / 1_000_000`.
- **`heapMb`**: `isolate.getHeapStatistics()` is an async call that returns heap stats including `used_heap_size`. Converted via `used_heap_size / (1024 * 1024)`.
- **`dataRequestCount` / `dataBytesReceived`**: Tracked in the `engine.ts` execute closure. `CodeExecutor` always returns 0 for these fields; the engine overrides them with the values accumulated during execution.

---

## WASM Module Loading and Injection

Large analytical libraries cannot be `require()`d inside an isolate. Instead, their compiled binary and a JavaScript binding layer are injected at context-build time.

### Types

```typescript
interface WasmModuleConfig {
  name: string; // e.g. 'duckdb', 'glpk', 'simple-statistics'
  wasmPath: string; // absolute path to .wasm file on disk
  jsBindingPath: string; // absolute path to .js bindings file on disk
}

interface LoadedModule {
  name: string;
  binary: ArrayBuffer; // NOT a Node Buffer — raw ArrayBuffer for ivm transfer
  bindings: string; // JS source string, run in isolate context at build time
}
```

### Load phase (once, at engine startup)

`WasmLoader.loadAll(configs)` uses `Promise.all` over all configured modules. For each module:

- The `.wasm` binary is read with `await fs.readFile(path)`, producing a Node `Buffer`. It is then converted to an `ArrayBuffer` via `buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength)`.
- The JS binding file is read with `await fs.readFile(path, 'utf-8')`, producing a string.

Both are stored in a `Map<name, LoadedModule>`. Disk reads happen only once per engine lifetime.

### Injection phase (per execution, in ContextBuilder.build())

For each module, the JS binding string is compiled as an `ivm.Script` and run in the new isolate context. This makes the WASM module available as a global variable inside user code (e.g., `duckdb.connect()` or `glpk.solve()`). The binding script itself handles any internal WASM initialization (e.g., DuckDB-WASM bootstrapping itself from a bundled binary).

### Supported modules

| Module            | Use case                                              | Size             |
| ----------------- | ----------------------------------------------------- | ---------------- |
| DuckDB-WASM       | Columnar SQL queries over in-memory data              | ~6 MB binary     |
| GLPK.js           | Linear and integer programming (royalty optimization) | ~2 MB binary     |
| simple-statistics | Descriptive stats, regression, distributions          | ~80 KB (JS only) |

---

## Protocol and Error Layers

### Protocol layer — `bridge/protocol.ts`

Defines the full message vocabulary and limit types shared between the host and any consumer of the package.

**Key types:**

- `SandboxMessage` — discriminated union of all messages that can flow in either direction:
  - Inbound (host → session): `ExecuteMessage`, `DataResponseMessage`, `AbortMessage`
  - Outbound (session → caller): `StatusEvent`, `ResultEvent`, `ErrorEvent`, `ConsoleEvent`
- `IsolateLimits` — per-execution V8 resource caps: `memoryMb`, `timeoutMs`, `maxCodeBytes`
- `BridgeLimits` — limits enforced at the bridge level: `maxDataRequests`, `maxDataBytes`, `maxResultBytes`, `maxStatusEvents`
- `ErrorCode` — union type of all structured error codes: `'TIMEOUT' | 'OOM' | 'RUNTIME' | 'VALIDATION' | 'ABORTED'`
- `DEFAULT_ISOLATE_LIMITS` / `DEFAULT_BRIDGE_LIMITS` — sensible baseline defaults exported for callers

This layer has no runtime dependencies — it is pure type definitions and constants.

### Error layer — `bridge/errors.ts`

Defines the `SandboxError` class hierarchy. All errors emitted through `ErrorEvent` are instances of these classes, giving callers a structured way to discriminate failures.

```
SandboxError (base, has readonly .code: ErrorCode)
  ├── SandboxTimeoutError    — code: 'TIMEOUT'
  ├── SandboxOOMError        — code: 'OOM'
  ├── SandboxRuntimeError    — code: 'RUNTIME'  (user code threw)
  ├── SandboxValidationError — code: 'VALIDATION' (code too large, bad input, result too large)
  └── SandboxAbortedError    — code: 'ABORTED'  (session.abort() called)
```

---

## Execution Lifecycle (Step by Step)

**Step 1 — `SandboxEngine.submit(msg: ExecuteMessage): SandboxSession`**

- Validates the `ExecuteMessage` (required fields, limits within engine-wide caps)
- Creates `SandboxSessionImpl` — allocates the async queue and pending-request map
- Calls `this.execute(msg, session)` **without awaiting it** — execution runs in the background
- Returns `session` immediately to the caller

**Step 2 — `execute()` acquires an isolate**

- Calls `IsolateManager.acquire(msg.limits)`
- If the warm pool is non-empty: pops the top handle (O(1))
- Otherwise: creates a fresh `new ivm.Isolate({ memoryLimit: msg.limits.memoryMb })` (takes ~2-5 ms)
- Adds the handle to `active` set

**Step 3 — `ContextBuilder.build()` instruments the context**

- Creates `ivm.Context` from the isolate, injects all bridge references and WASM bindings (see [The ivm Bridge](#the-ivm-bridge--how-values-cross-the-isolate-boundary) above)

**Step 4 — `CodeExecutor.run()` executes user code**

- Wraps, compiles, and races dual timeouts (see [Code Wrapping and Compilation](#code-wrapping-and-compilation) and [Dual Timeout](#the-dual-timeout-implementation) above)
- On OOM (ivm memory limit reached): throws `SandboxOOMError` (code: `'OOM'`)
- On any other thrown value from the isolate: wraps in `SandboxRuntimeError` with sanitized message

**Step 5 — result processing**

- The script's resolved value is a JSON string. `JSON.parse(returnValue)` on the host.
- Stats gathered, output size validated (see [Code Wrapping and Compilation](#code-wrapping-and-compilation) steps 5-7)

**Step 6 — event emission**

- On success: `session.pushEvent({ type: 'result', payload: returnValue, stats })`
- On any error: `session.pushEvent({ type: 'error', error: sandboxError })`
- The async iterator in the caller unblocks and yields the event

**Step 7 — cleanup (`finally` block, always runs)**

- `IsolateManager.release(handle)` — calls `handle.isolate.dispose()` unconditionally
- `session.close()` — marks the session done; the async iterator returns (loop ends for caller)
- All `pendingRequests` callbacks that are still registered are rejected with `SandboxAbortedError` (defensive — should be empty by this point under normal flow)

---

## Warm Pool

The warm pool pre-creates isolates to eliminate V8 isolate creation latency (~10-50 ms) from the hot path.

```
IsolateManager.warm()
  └── for i in 0..warmPoolSize:
        create new ivm.Isolate({ memoryLimit: defaultIsolateLimits.memoryMb })
        create context via isolate.createContext()
        push IsolateHandle to pool[]
```

**Note**: `warm()` is a method on `IsolateManager`, not on `SandboxEngine`. The current `SandboxEngine` does not expose a public `warm()` method.

**Acquire logic:**

```
acquire(requestedLimits):
  if pool.length > 0:
    handle = pool.pop()               // O(1) — warm hit (no limit matching)
  else:
    handle = createFresh(requestedLimits)  // cold creation
  active.add(handle)
  return handle
```

Pool isolates are returned regardless of whether the requested limits match the pool's creation limits. The pool is opportunistic: if it has anything, it returns it. The only way to get a cold start is an empty pool.

**Release logic:**

```
release(handle):
  active.delete(handle)
  handle.isolate.dispose()    // ALWAYS — never returns to pool
```

Isolates are always disposed after execution. This is the **ephemeral guarantee**: no heap state, no cached objects, no prototype contamination carries over between executions. The pool only holds _pre-warmed_ isolates, not _recycled_ ones.

---

## Scalability

### Performance Characteristics

| Operation                     | Time        | Notes                                      |
| ----------------------------- | ----------- | ------------------------------------------ |
| Isolate creation              | ~10-50 ms   | Amortized by warm pool                     |
| Context setup (bridge + WASM) | ~1-5 ms     | Per execution                              |
| DuckDB-WASM initialization    | ~100-500 ms | Per execution (most expensive cost)        |
| JSON serialization overhead   | ~1-10 ms    | For large result objects; negligible small |

### Concurrency Model

All executions share the Node.js event loop. ivm runs each V8 isolate on a separate worker thread for CPU work, but the async/await coordination, data request callbacks, and stats collection all funnel through the host event loop. `maxConcurrentIsolates` is a hard ceiling on simultaneous isolates, preventing memory exhaustion. `engine.submit()` returns immediately; submissions beyond the ceiling queue naturally with no back-pressure mechanism. Each executing isolate can have at most one outstanding data request at a time — multiple concurrent executions do not block each other.

### Memory Footprint

| Component                                  | Approximate size                        |
| ------------------------------------------ | --------------------------------------- |
| Each V8 isolate (configured at `memoryMb`) | Up to 256 MB heap (default)             |
| Warm pool of N idle isolates               | N x ~few MB (near zero until code runs) |
| Active isolates at peak                    | `maxConcurrentIsolates` x `memoryMb`    |
| DuckDB-WASM binary (ArrayBuffer, shared)   | ~10 MB                                  |
| GLPK.js binary (ArrayBuffer, shared)       | ~2 MB                                   |
| simple-statistics (ArrayBuffer, shared)    | ~300 KB                                 |

WASM binaries are loaded once and held in the host process heap for the engine lifetime. They are not duplicated per isolate.

### Recommended Configuration

| Scenario          | maxConcurrentIsolates | warmPoolSize | memoryMb | timeoutMs |
| ----------------- | --------------------- | ------------ | -------- | --------- |
| Development       | 5                     | 2            | 256      | 30000     |
| Small production  | 10                    | 3            | 256      | 30000     |
| High concurrency  | 50                    | 5            | 128      | 15000     |
| Heavy computation | 5                     | 2            | 512      | 60000     |

### Bottlenecks and Mitigations

| Bottleneck                          | Problem                                                           | Mitigation                                                                     |
| ----------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------ |
| **WASM init per execution**         | DuckDB-WASM initializes from scratch every time (100-500ms)       | Future: pre-initialize WASM in pool isolates                                   |
| **Pool exhaustion under burst**     | Burst traffic empties pool; cold starts add ~50ms                 | Size `warmPoolSize` to match expected peak concurrency                         |
| **Single event loop for callbacks** | All isolates compete for the host event loop for data request I/O | Future: `worker_threads` for per-isolate event loops                           |
| **No horizontal scaling**           | Single-process, single-instance                                   | Deploy multiple instances behind a load balancer (stateless, no coordination)  |
| **Memory fragmentation**            | Host heap accumulates JSON `String` allocations over time         | Node.js GC handles this automatically; periodic restart if growth is unbounded |

### Scalability Ceiling

The current single-instance design supports approximately 10-50 concurrent executions depending on available RAM (primary constraint), CPU core count, and WASM initialization cost. Beyond this range, deploy multiple `SandboxEngine` instances across containers — execution is stateless and ephemeral, so no coordination is required.

---

## File Structure

```
sandbox/
├── package.json                              # @coda/sandbox; isolated-vm ^6.1.2
├── tsconfig.json
├── jest.config.json
└── src/
    ├── index.ts                              # Public barrel export
    ├── engine.ts                             # SandboxEngine class + SandboxEngineConfig type
    ├── bridge/
    │   ├── protocol.ts                       # SandboxMessage union, IsolateLimits,
    │   │                                     #   BridgeLimits, ErrorCode, defaults
    │   ├── errors.ts                         # SandboxError hierarchy (5 subclasses)
    │   └── session.ts                        # SandboxSession interface,
    │                                         #   SandboxSessionImpl (async queue + callbacks)
    └── execution/
        ├── isolate/
        │   ├── isolate-manager.ts            # IsolateManager: warm pool + active set
        │   ├── context-builder.ts            # ContextBuilder: bridge injection + WASM setup
        │   └── code-executor.ts              # CodeExecutor: compile + run + dual timeout
        └── wasm/
            ├── module-config.ts              # WasmModuleConfig + LoadedModule types
            └── wasm-loader.ts                # WasmLoader: disk reads + ArrayBuffer cache
```

**Public exports from `src/index.ts`:**

- `SandboxEngine`, `SandboxEngineConfig`
- `SandboxSession` (interface)
- `ExecuteMessage`, `DataResponseMessage` (inbound message types)
- `OutboundMessage`, `StatusEvent`, `ResultEvent`, `ErrorEvent`, `ConsoleEvent` (outbound)
- `IsolateLimits`, `BridgeLimits`, `ErrorCode`
- `SandboxError` and all subclasses
- `DEFAULT_ISOLATE_LIMITS`, `DEFAULT_BRIDGE_LIMITS`
