This guide covers isolated mode, where a context's CPython interpreter runs
in a child OS process instead of inside the BEAM. You need it when Python
code must not be able to take the node down or run forever: user-supplied
scripts, C extensions you do not control, work that needs a hard time or
memory bound. The public API is the one you already use with worker and
owngil; you switch by configuration.
worker |
owngil |
isolated |
|
|---|---|---|---|
| Where Python runs | BEAM process, one pthread per context | BEAM process, one pthread and one interpreter per context | Child OS process per context |
| Interrupt a Python loop | yes (KeyboardInterrupt at the next bytecode) |
yes | yes |
Interrupt a blocking C call (time.sleep, socket read, numpy kernel) |
no, only when the call returns | no | yes (signal), then SIGKILL |
| Hard bound on a call | no: a stuck call keeps its thread until it returns | no | yes: kill_after then SIGKILL |
| Memory cap | no | obmalloc accounting, C extensions not counted | RLIMIT_AS (Linux, FreeBSD), RSS watchdog (macOS), cgroups v2 (Linux) |
| CPU bound | no | no | RLIMIT_CPU |
| Segfault in a C extension | kills the node | kills the node | kills the child, caller gets {error, {child_exited, {signal, 11}}} |
| Python state after a crash | n/a | n/a | lost; the child restarts and the context stays usable |
Call latency (eval("1+1"), p50, same machine) |
16 us | ~20 us | 25 us |
| Memory per context | shared interpreter | one interpreter | one process, ~16 MB RSS bare |
| Startup | microseconds | milliseconds | ~40 ms |
Zero-copy py_buffer, channels, erlang.schedule, object refs |
yes | yes | no (see Limits) |
{ok, Ctx} = py_context:new(#{mode => isolated}),
{ok, 4} = py_context:eval(Ctx, <<"2+2">>),
{ok, 4.0} = py_context:call(Ctx, math, sqrt, [16]),
ok = py_context:exec(Ctx, <<"x = 41">>),
{ok, 42} = py_context:eval(Ctx, <<"x + 1">>),
ok = py_context:stop(Ctx).A pool works the same way, and py:call/4 routes to it:
{ok, _} = py_context_router:start_pool(sandbox, 4, isolated),
{ok, 4.0} = py:call(sandbox, math, sqrt, [16]).Options of py_context:new/1 specific to this mode:
| Option | Default | Meaning |
|---|---|---|
python |
interpreter matching the embedded runtime (py:python_executable/0), or isolated_python app env |
Executable to run |
rlimits |
#{} |
#{as => Bytes, cpu => Seconds, nofile => N}, applied with setrlimit before any user code |
cgroup |
none | Path of a cgroup v2 directory the child joins (limits written by you: memory.max, cpu.max, pids.max) |
env |
#{} |
Extra environment variables for the child |
paths |
[] |
Extra sys.path entries (registered py_import paths and imports are applied too) |
preload |
none | Code run once in the child before anything else |
kill_after |
1000 |
Milliseconds between a soft interrupt and SIGKILL |
restart |
true |
Start a fresh child when the current one dies |
max_restarts, restart_period |
5, 10000 |
Restart budget; past it the context process exits with the child's reason |
start_timeout |
10000 |
Milliseconds allowed for the child to connect |
py_context:interrupt/1 sends the child a signal that raises
KeyboardInterrupt in the running request, inside a blocking C call too. If
the request has not returned after kill_after, the child is killed:
{ok, Ctx} = py_context:new(#{mode => isolated, kill_after => 500}),
Self = self(),
spawn(fun() -> Self ! {done, py_context:eval(Ctx, <<"__import__('time').sleep(60)">>)} end),
timer:sleep(100),
ok = py_context:interrupt(Ctx),
receive {done, {error, interrupted}} -> ok end.A timeout targets only the request that timed out: if the child is
executing it, the child is interrupted (and killed after kill_after if the
interrupt is not honoured); if it is still queued behind other callers'
requests, it is dropped from the queue and nobody else is interrupted. This
differs from the embedded modes, where an interrupt can only hit whatever
runs. py_context:interrupt/1 remains context-wide: it interrupts the
request executing now. py_context:kill/1 skips the soft step:
ok = py_context:kill(Ctx),
%% A fresh child is already serving; the Python state is gone
{ok, 4} = py_context:eval(Ctx, <<"2+2">>).In-flight calls return {error, interrupted} (soft) or {error, killed}
(hard).
{ok, Ctx} = py_context:new(#{mode => isolated,
rlimits => #{as => 512 * 1024 * 1024,
cpu => 30,
nofile => 256}}).Past as, allocations fail with MemoryError in the child (or the child
dies if it cannot cope); past cpu, the child dies with SIGXCPU and the
caller gets {error, {child_exited, {signal, 24}}}. rlimits are POSIX and
are the portable bound: Linux and FreeBSD enforce all three in the kernel.
macOS ignores RLIMIT_AS, so there the child enforces as itself: a
watchdog thread polls its resident set every 50 ms and exits when it passes
the limit, and the caller gets {error, {child_exited, {memory_limit, Bytes}}}.
cpu and nofile are kernel-enforced on all three. A free-threaded
CPython build reserves a large virtual range at startup, so its as limit
must be well above what a regular build needs (several GB).
With cgroups v2 (Linux only), create the group and write the limits, then
hand the directory to the context. On any other platform the option is
refused before a child is spawned, with {error, {cgroup_unsupported, Os}}:
mkdir /sys/fs/cgroup/py_sandbox
echo 268435456 > /sys/fs/cgroup/py_sandbox/memory.max
echo "50000 100000" > /sys/fs/cgroup/py_sandbox/cpu.max
echo 64 > /sys/fs/cgroup/py_sandbox/pids.max{ok, Ctx} = py_context:new(#{mode => isolated, cgroup => "/sys/fs/cgroup/py_sandbox"}).The child joins the group before running any user code; if it cannot, the
start fails with {error, {startup_error, [{cgroup, Reason}]}}.
{ok, Ctx} = py_context:new(#{mode => isolated}),
{error, {child_exited, {signal, 11}}} =
py_context:eval(Ctx, <<"__import__('ctypes').memset(0, 0, 1)">>),
{ok, 4} = py_context:eval(Ctx, <<"2+2">>).The node, every other context and the context process itself are unaffected.
With restart => false the context process exits with
{child_exited, Reason} instead, so a supervisor of yours decides.
erlang.call, erlang.send, erlang.whereis, erlang.Pid, erlang.Atom
work as in the embedded modes, from any Python thread, and callbacks nest: an
Erlang function called from Python may call back into the same context, from
the process running the callback. The context serves one caller at a time,
in order; a nested call from the callback process goes through at once,
while a call from any other process waits for the current request to finish
(so a callback that hands the nested call to another process and waits for
it would wait until its own timeout).
py:register_function(double, fun([X]) -> X * 2 end),
{ok, 84} = py_context:eval(Ctx, <<"__import__('erlang').call('double', 42)">>).import erlang
def notify(pid):
erlang.send(pid, ('progress', 50)) # raises erlang.ProcessError if pid is deadRound trips cross a Unix socket as external term format; the type mapping is the one in Type Conversion. One difference: integers beyond 64 bits arrive intact (the NIF converter has no bignum path).
The child runs a plain asyncio loop. A call that returns a coroutine is
awaited and its value returned:
async def fetch(n):
await asyncio.sleep(0.1)
return n * 2{ok, 4} = py_context:call(Ctx, myapp, fetch, [2]).The worker loop API of Worker Loops works unchanged
(start_loop, submit, submit_await, stop_loop), and a wedged loop is
killed by stop_loop/2 after its grace period. To serve on a socket Erlang
owns, hand the fd over with py_context:pass_fd/2; it crosses the control
socket with SCM_RIGHTS:
{ok, LSock} = gen_tcp:listen(8080, [binary, {active, false}]),
{ok, Fd} = inet:getfd(LSock),
[begin
{ok, Ctx} = py_context:new(#{mode => isolated}),
ok = py_context:start_loop(Ctx),
{ok, ChildFd} = py_context:pass_fd(Ctx, Fd),
{ok, _} = py_context:submit_await(Ctx, myapp, serve, [ChildFd])
end || _ <- lists:seq(1, 4)].Inside a coroutine, await erlang.async_call(name, *args) keeps the loop
running while Erlang answers.
Arguments and results cross the socket as a copy. For large payloads use a
shared region: a file mapped MAP_SHARED on both sides through
iommap. Add it to your deps:
{deps, [{iommap, "1.1.3"}]}.A region is a fixed-size handle you pass like any other argument, in any context mode:
{ok, Shm} = py_shm:new(64 * 1024 * 1024),
ok = py_shm:write(Shm, 0, Floats), %% one copy
{ok, Sum} = py_context:call(Ctx, myapp, sum_floats, [Shm]),
Out = py_shm:binary(Shm, 0, 1024), %% no copy
ok = py_shm:close(Shm).import numpy
def sum_floats(shm): # erlang.SharedMemory
a = numpy.frombuffer(shm.buffer, dtype=numpy.float32) # no copy
a[:1024] = 0 # Erlang sees it
return float(a.sum())Python-produced data is zero-copy in both directions (write into the
region, read it in Erlang with py_shm:binary/3); Erlang-produced data
costs one write/3 copy instead of encode, socket, decode and copy. A
region is mapped once per interpreter and reused across calls; it is
closed by close/1 or when its owner process exits.
Streaming bodies use the same mechanism through py_buffer:
{ok, Buf} = py_buffer:new(#{shared => true}), %% 4 MB ring
ok = py_buffer:write(Buf, Chunk), %% blocks when full
ok = py_buffer:close(Buf),
py_context:call(Ctx, myapp, handle, [#{<<"wsgi.input">> => Buf}]).The Python side gets erlang.SharedBuffer with the read, readline,
readlines, iteration and read_nonblock of the native buffer. Flow
control is a callback round trip per blocking read, so in an embedded
context the native py_buffer:new/0,1 is still the cheaper choice; use
shared => true when the buffer may reach an isolated context or a pool
mixing modes. A native buffer cannot cross into a child.
Rules: regions are never resized (a truncated file would be a SIGBUS, so
the size is checked when mapping); mapped pages count against the child's
as limit and its resident set; /dev/shm is used when present, else a
private directory under TMPDIR; while a call holds a handle the child
owns the region, and a concurrent write/3 is a caller error.
What sharing changes about isolation: the child can still only crash or
exhaust itself, but it can write anything into a region it holds, at any
time, and py_shm:binary/3 sees those bytes (a binary that changes under
you; take it once the callee is done, or copy with read/3). Hand the child
a read-only handle when it only needs to read: py_shm:new(Size, #{writable => false})
or py_shm:read_only(Shm) map it PROT_READ in Python, and Erlang keeps
writing. A child that runs as your user could still truncate the region
file on purpose; sealing and syscall filtering are separate hardening work.
- One child per context, started with
open_portso the VM reaps it and reports its exit status: no zombies, and a child that dies before connecting is reported with its output ({error, {child_exited_at_start, Reason, Output}}). - The context process is a
gen_statem(py_isolated) with statesidle,{busy, RequestId},looping,stopping_loopand{restarting, Reason};sys:get_state(Ctx)shows what it is doing andsys:trace(Ctx, true)prints its events. It serves one caller at a time, in order; a request that arrives while the child restarts waits for the new child instead of failing. It outlives the process that created it, and stops if that process crashes. - The child and the context process talk over a Unix socket in a private
directory, framed exactly like the embedded callback pipe
(
<<Id:64/native, Len:32/native, Body>>, body<<Status, ETF>>). - A reader thread in the child owns the socket. It delivers requests to the
main thread, routes replies to whichever thread is waiting, handles
interruptby signalling the main thread, and exits the process on EOF. So when the BEAM dies, every child exits, even one stuck in a C call; on Linux (prctl(PR_SET_PDEATHSIG)) and FreeBSD (procctl(PROC_PDEATHSIG_CTL)) the kernel deliversSIGKILLfor the same case. - When the socket breaks or the child exits, pending calls fail with
{error, {child_exited, Reason}}, new calls fail the same way until the restart has happened (which takes about 100 ms), and nothing hangs. - Child stdout and stderr are forwarded to the Erlang logger, one line per
message, tagged with the context id and OS pid.
py_context:child_info/1returnsos_pid,python_version,executableandplatform.
- Python object references cannot cross a process boundary:
py_context:call_method/4returns{error, not_supported_in_isolated}, results are always converted to terms, and process-local environments (py:call(Ctx, ...)per Erlang process) map to the child's single namespace. erlang.schedule*, channels (py_channel,py_byte_channel),py_buffer, shared dicts and the reactor need the embedded interpreter and raiseRuntimeError("... not available in isolated mode").py_context:loop_ref/1returns{error, not_supported_in_isolated};submit/4without a running loop returns{error, no_loop}(there is no event worker to step an idle loop).erlang.callfrom inside a coroutine blocks the loop, as in the embedded modes; useerlang.async_call.- The child decodes terms with the same rules as the NIF, so atoms sent from Python are created in the VM's atom table. Do not let untrusted code mint unbounded distinct atoms.
- No syscall filtering: process isolation plus rlimits is the boundary. A seccomp (Linux) or Capsicum (FreeBSD) sandbox is a separate hardening step.
- Each call copies its arguments and result through the socket: a 1 MB binary round-trips in about 1.3 ms, 16 MB in about 27 ms (worker mode: 0.2 ms and 3 ms). For bulk data use shared memory (below).
- Interrupts for the embedded-mode interrupt semantics
- Worker Loops for the loop API and serving on Erlang sockets
- Memory for the owngil memory caps
- Security for the audit-hook sandbox of the embedded modes