# `PtcRunner.Sandbox`
[🔗](https://github.com/andreasronge/ptc_runner/blob/main/lib/ptc_runner/sandbox.ex#L1)

Executes programs in isolated BEAM processes with resource limits.

Spawns isolated processes with configurable timeout and memory limits,
ensuring safe program execution.

## Resource Limits

| Resource | Default | Option |
|----------|---------|--------|
| Timeout | 1,000 ms | `:timeout` |
| Max Heap | ~10 MB (1,250,000 words) | `:max_heap` |
| Worker Max Heap | = `:max_heap` | `:worker_max_heap` |
| Max Parallel Workers | 8 | `:max_parallel_workers` |

`:max_heap` is the **program's allocation headroom above the granted
environment**, not the process's absolute size. `execute/3` spawns the
sandbox under a hard `:setup_max_heap` ceiling (default `4 × max_heap`)
while the host-provided environment (context, `memory:`, tool closures,
the parsed program) is copied in, then garbage-collects, measures that
**pre-eval sandbox baseline**, and re-arms the `max_heap_size` flag at
`baseline + max_heap`. Host-granted data is therefore excluded from the
program's bill; memory the *program* acquires stays fail-closed. Two
caveats (see `docs/plans/sandbox-heap-rebaseline.md`):

- the baseline is a *sandbox* baseline — it includes the parsed user
  program (bounded by `:max_program_bytes`) and eval plumbing, not just
  grants;
- per OTP, the `max_heap_size` check runs only when a GC triggers and
  counts transient garbage plus GC workspace, so `:max_heap` is
  allocation headroom, not a live-data quota.

Callers granting data larger than the default setup ceiling must raise
`:setup_max_heap` explicitly; otherwise the forced post-copy GC kills
the sandbox deterministically with a distinguishable setup-phase error
(the boundedness precondition, enforced).

The `max_heap_size` flag is per-process and is *not* inherited by child
processes, so the PTC-Lisp `pmap`/`pcalls` builtins spawn each worker
(via `PtcRunner.Lisp.Eval.ParallelRunner`) with its OWN fixed
`max_heap_size` of `:worker_max_heap` words, armed at spawn with **no
re-baseline** — a worker's captured closure environment is
program-created, which is exactly what `worker_max_heap` exists to
bill. The number of parallel workers alive at once — across the whole
run, at every nesting depth — is capped by a shared slot semaphore of
`:max_parallel_workers` (`PtcRunner.Lisp.Eval.ParallelBudget`).
Aggregate live parallel heap is therefore bounded by:

    max_parallel_workers × worker_max_heap

A pmap/pcalls worker that cannot obtain a slot fails the run with
`:parallel_capacity_exceeded` (no sequential fallback). The top-level
sandbox process is not counted as a parallel slot.

The `:max_heap` sandbox limit and each `:worker_max_heap` parallel-worker
limit are enforced via BEAM's `:max_heap_size` process flag with
`include_shared_binaries: true`, so they account for both process-local heap
terms and shared (refc) binaries referenced by the process. This prevents
binary-heavy programs from exceeding the memory budget via off-heap
allocations.

Note that this is a per-process BEAM budget, not a whole-node or container
memory limit. For adversarial multi-tenant deployments, back this with an
OS/container memory limit around the VM or an isolated worker process.

## Configuration

Limits can be set per-call:

    PtcRunner.Lisp.run(program, timeout: 5000, max_heap: 5_000_000)

Or as application-level defaults in `config.exs`:

    config :ptc_runner,
      default_timeout: 2000,
      default_max_heap: 2_500_000

# `eval_fn`

```elixir
@type eval_fn() :: (any(), PtcRunner.Context.t() -&gt;
                {:ok, any(), map()}
                | {:error, {atom(), String.t()} | {atom(), String.t(), any()}})
```

Evaluator function that takes AST and context and returns result with memory.

# `memory_exceeded_info`

```elixir
@type memory_exceeded_info() :: %{
  phase: :eval | :setup,
  limit_bytes: non_neg_integer(),
  baseline_bytes: non_neg_integer() | nil,
  budget_bytes: non_neg_integer()
}
```

Diagnostic payload for a `:memory_exceeded` kill from `execute/3`.

`phase: :eval` — the program exceeded its budget above the measured
baseline. `phase: :setup` — the host environment itself blew the
`:setup_max_heap` ceiling before eval started (`baseline_bytes` is `nil`;
raise the ceiling or shrink the grant).

# `metrics`

```elixir
@type metrics() :: %{
  duration_ms: integer(),
  memory_bytes: integer(),
  eval_reductions: non_neg_integer(),
  baseline_bytes: non_neg_integer() | nil
}
```

Execution metrics for a program run.

`baseline_bytes` is the pre-eval sandbox baseline (granted environment +
parsed program) measured after the post-copy GC; the program's effective
heap limit was `baseline_bytes + max_heap × word_size`. `nil` when the
heap limit is disabled (`max_heap: 0`).

# `execute`

```elixir
@spec execute(any(), PtcRunner.Context.t(), keyword()) ::
  {:ok, any(), metrics(), map()}
  | {:error,
     {atom(), memory_exceeded_info()}
     | {atom(), non_neg_integer()}
     | {atom(), String.t()}
     | {atom(), String.t(), any()}}
```

Executes an AST in an isolated sandbox process.

## Arguments
  - ast: The AST to execute
  - context: The execution context
  - opts: Options (timeout, max_heap, eval_fn)
    - `:eval_fn` - Evaluator function (required)
    - `:timeout` - Timeout in milliseconds (default: 1000, configurable via `:default_timeout`)
    - `:max_heap` - Program heap budget in words above the measured
      baseline (default: 1_250_000, configurable via `:default_max_heap`;
      `0` disables the limit entirely)
    - `:setup_max_heap` - Hard ceiling in words while the host
      environment is copied in, before the re-baseline (default:
      `4 × max_heap`)

## Returns
  - `{:ok, result, metrics, memory}` on success
  - `{:error, reason}` on failure; a heap kill is
    `{:memory_exceeded, memory_exceeded_info()}`

# `run_bounded`

```elixir
@spec run_bounded(
  (-&gt; term()),
  keyword()
) ::
  {:ok, term()}
  | {:error,
     {:timeout, non_neg_integer()}
     | {:memory_exceeded, non_neg_integer()}
     | {:execution_error, String.t()}}
```

Runs an arbitrary function in an isolated process with resource limits.

Unlike `execute/3` which is specialized for Lisp evaluation, this function
runs any zero-arity function under the same process isolation primitives
(timeout, `max_heap_size`, monitored child).

## Options

  * `:timeout` - Timeout in milliseconds (default: 1000)
  * `:max_heap` - Max heap size in words (default: 1_250_000)

## Returns

  * `{:ok, result}` — the function returned `result`
  * `{:error, {:timeout, ms}}` — killed after timeout
  * `{:error, {:memory_exceeded, bytes}}` — heap limit hit
  * `{:error, {:execution_error, message}}` — process crashed

## Examples

    iex> PtcRunner.Sandbox.run_bounded(fn -> 1 + 1 end)
    {:ok, 2}

    iex> PtcRunner.Sandbox.run_bounded(fn -> :timer.sleep(:infinity) end, timeout: 50)
    {:error, {:timeout, 50}}

---

*Consult [api-reference.md](api-reference.md) for complete listing*
