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_sizecheck runs only when a GC triggers and counts transient garbage plus GC workspace, so:max_heapis 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_heapA 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
@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.
@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).
@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
@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;0disables 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()}
@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 returnedresult{: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}}