PtcRunner.Sandbox (PtcRunner v0.13.0)

Copy Markdown View Source

Executes programs in isolated BEAM processes with resource limits.

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

Resource Limits

ResourceDefaultOption
Timeout1,000 ms:timeout
Max Heap~10 MB (1,250,000 words):max_heap
Worker Max Heap= :max_heap:worker_max_heap
Max Parallel Workers8: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

Summary

Types

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

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

Execution metrics for a program run.

Functions

Executes an AST in an isolated sandbox process.

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

Types

eval_fn()

@type eval_fn() :: (any(), PtcRunner.Context.t() ->
                {: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()

@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()

@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).

Functions

execute(ast, context, opts \\ [])

@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(fun, opts \\ [])

@spec run_bounded(
  (-> 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}}