Skip to content

Commit 78eba7a

Browse files
committed
Add isolated context mode: CPython in a child OS process
A mode where Python code cannot take the node down or run forever: one child process per context over a Unix socket, signals with SIGKILL as backstop, rlimits and cgroups, restart on crash. Same public API; the context process is a gen_statem serving one caller at a time. Validated on macOS and FreeBSD. Version 4.2.0.
1 parent e4f3b61 commit 78eba7a

22 files changed

Lines changed: 5920 additions & 8 deletions

‎CHANGELOG.md‎

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,44 @@
11
# Changelog
22

3+
## 4.2.0 (2026-08-29)
4+
5+
### Added
6+
7+
- **`isolated` context mode** - `py_context:new(#{mode => isolated})` runs
8+
CPython in a child OS process per context, with the same `call/eval/exec`,
9+
callback, `erlang.send`/`whereis`, worker-loop and pool API as the embedded
10+
modes. It is the first mode with a hard bound: `py_context:interrupt/1`
11+
stops a blocking C call (a signal in the child) and `SIGKILL` is the
12+
backstop after `kill_after` ms; `py_context:kill/1` kills at once. `rlimits`
13+
(`as`, `cpu`, `nofile`) and a cgroup v2 directory bound the child; a
14+
segfault in a C extension returns `{error, {child_exited, {signal, 11}}}`
15+
and the node survives. The child restarts on crash within a budget
16+
(`restart`, `max_restarts`, `restart_period`); `py_context:child_info/1`
17+
reports its OS pid. Children are reaped by the VM and exit when the BEAM
18+
dies (socket EOF watchdog, `PR_SET_PDEATHSIG` on Linux, `PROC_PDEATHSIG_CTL`
19+
on FreeBSD). `cgroup` is refused outside Linux; rlimits apply everywhere:
20+
`as` is kernel-enforced on Linux and FreeBSD and enforced by an RSS
21+
watchdog in the child on macOS (`{child_exited, {memory_limit, Bytes}}`).
22+
Validated on macOS (arm64) and FreeBSD 14.3 (OTP 28, Python 3.11).
23+
- **`py_context:pass_fd/2`** - hands a file descriptor to an isolated child
24+
over the control socket (`SCM_RIGHTS`), so `erlang.server.serve` works out
25+
of process: Erlang binds once, N killable children accept.
26+
- **Pure-Python ETF codec** (`priv/_erlang_impl/_etf.py`) with the type
27+
mapping of `py_convert.c`; the child needs no C extension. Integers beyond
28+
64 bits round-trip exactly in isolated mode.
29+
- `py:python_executable/0`, `py:kill/1`, `py_nif:os_kill/2`.
30+
- `py_isolated` is a `gen_statem` (states `idle`, `{busy, Id}`, `looping`,
31+
`stopping_loop`, `{restarting, Reason}`): `sys:get_state/1` and
32+
`sys:trace/2` work on isolated contexts, requests arriving during a
33+
restart are served by the new child, and `py_context:kill/1` returns once
34+
the new child is up.
35+
- Timeouts on an isolated context cancel their own request only (queued
36+
requests are dropped, the executing one is interrupted); the kill backstop
37+
is bound to that request, so a busy shared context is never killed because
38+
another caller gave up. Soak-tested: callback storms, interrupt/kill
39+
storms, loop churn, 60 s mixed workload with resource counters checked.
40+
- Guide: `docs/isolated.md`, with what each of the three modes guarantees.
41+
342
## 4.1.0 (2026-08-15)
443

544
### Added

‎README.md‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -603,6 +603,7 @@ When creating Python contexts, you can choose the execution mode:
603603
|------|----------------|-------------|
604604
| `worker` | Any | Dedicated pthread per context, main interpreter namespace (default) |
605605
| `owngil` | 3.14+ | Dedicated pthread + subinterpreter with its own GIL, true parallelism |
606+
| `isolated` | Any | CPython in a child OS process: killable, rlimit-bounded, crash-contained |
606607

607608
```erlang
608609
%% Default: worker mode (recommended)
@@ -612,8 +613,18 @@ When creating Python contexts, you can choose the execution mode:
612613
%% OWN_GIL mode for true parallelism (Python 3.14+ required)
613614
%% Each context runs in its own pthread with independent GIL
614615
{ok, Ctx} = py_context:new(#{mode => owngil}).
616+
617+
%% Isolated mode: a child process per context. A stuck call is killed, a
618+
%% segfault only takes the child down, rlimits bound memory and CPU.
619+
{ok, Ctx} = py_context:new(#{mode => isolated, kill_after => 1000,
620+
rlimits => #{as => 512 * 1024 * 1024}}).
615621
```
616622

623+
**Isolated mode** is the only mode with a hard bound: `py_context:interrupt/1`
624+
stops a blocking C call, and `SIGKILL` is the backstop. It costs a process per
625+
context (about 16 MB and 40 ms to start) and roughly twice the call latency.
626+
See [Isolated Contexts](docs/isolated.md).
627+
617628
**Worker mode is recommended** because it works with any Python version and automatically benefits from free-threaded Python (3.13t+) when available. Each context owns a dedicated pthread, providing stable thread affinity for libraries with thread-local state (numpy, torch, tensorflow).
618629

619630
**Why OWN_GIL requires Python 3.14+**: Some C extensions (e.g., `_decimal`, `numpy`) have global state bugs in sub-interpreters on Python 3.12/3.13. These are fixed in Python 3.14.
@@ -629,6 +640,7 @@ py:execution_mode(). %% => worker | owngil
629640
|------|----------------|-------------|
630641
| `worker` (default) | Any | One pthread per context; true parallelism on free-threaded 3.13t+ |
631642
| `owngil` | 3.14+ | Per-interpreter GIL, true parallelism across contexts |
643+
| `isolated` | Any | One OS process per context, parallel and failure-isolated |
632644

633645
## Error Handling
634646

@@ -651,6 +663,7 @@ py:execution_mode(). %% => worker | owngil
651663
- [Logging and Tracing](docs/logging.md)
652664
- [Asyncio Event Loop](docs/asyncio.md) - Erlang-native asyncio with TCP/UDP support
653665
- [Worker Loops](docs/workers.md) - Long-lived loops in owngil contexts, serving on sockets Erlang owns
666+
- [Isolated Contexts](docs/isolated.md) - Python in a child process: kill, rlimits, crash containment
654667
- [Reactor](docs/reactor.md) - FD-based protocol handling
655668
- [Security](docs/security.md) - Sandbox and blocked operations
656669
- [Changelog](https://github.com/benoitc/erlang-python/releases)

‎c_src/py_nif.c‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,14 @@
3636
* - py_callback.c: Callback system and asyncio support
3737
*/
3838

39+
/* pthread_timedjoin_np (used to bound the owngil worker join on Linux)
40+
* is declared by <pthread.h> only under _GNU_SOURCE. */
41+
#ifndef _GNU_SOURCE
42+
#define _GNU_SOURCE
43+
#endif
44+
45+
#include <signal.h>
46+
#include <errno.h>
3947
#include "py_nif.h"
4048
#include "py_util.h"
4149
#include "py_event_loop.h"
@@ -8079,6 +8087,26 @@ static void unload(ErlNifEnv *env, void *priv_data) {
80798087
/* Other cleanup handled by finalize */
80808088
}
80818089

8090+
/**
8091+
* @brief Send a signal to an OS process (kill(2)).
8092+
*
8093+
* Used by isolated contexts to SIGKILL their child. The caller holds the
8094+
* child's port open until exit_status arrives, so the pid cannot have been
8095+
* recycled.
8096+
*/
8097+
static ERL_NIF_TERM nif_os_kill(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) {
8098+
(void)argc;
8099+
int pid, sig;
8100+
if (!enif_get_int(env, argv[0], &pid) || !enif_get_int(env, argv[1], &sig) || pid <= 0) {
8101+
return enif_make_badarg(env);
8102+
}
8103+
if (kill((pid_t)pid, sig) == 0) {
8104+
return ATOM_OK;
8105+
}
8106+
return enif_make_tuple2(env, ATOM_ERROR,
8107+
enif_make_atom(env, errno == ESRCH ? "esrch" : errno == EPERM ? "eperm" : "einval"));
8108+
}
8109+
80828110
static ErlNifFunc nif_funcs[] = {
80838111
/* Initialization */
80848112
{"init", 0, nif_py_init, 0},
@@ -8220,6 +8248,7 @@ static ErlNifFunc nif_funcs[] = {
82208248
{"create_test_pipe", 0, nif_create_test_pipe, 0},
82218249
{"close_test_fd", 1, nif_close_test_fd, 0},
82228250
{"dup_fd", 1, nif_dup_fd, 0},
8251+
{"os_kill", 2, nif_os_kill, 0},
82238252
{"write_test_fd", 2, nif_write_test_fd, 0},
82248253
{"read_test_fd", 2, nif_read_test_fd, 0},
82258254
/* TCP test helpers */

‎docs/interrupts.md‎

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,29 @@ context to deal with that:
6565
ok = py_context:destroy(Ctx).
6666
```
6767

68+
## Interrupting a blocking C call: isolated mode
69+
70+
The limits below apply to the embedded modes. An `isolated` context runs
71+
Python in a child process, where an interrupt is a signal that lands inside
72+
`time.sleep`, a socket read or any other blocking call, and `SIGKILL` is the
73+
backstop if the signal is ignored:
74+
75+
```erlang
76+
{ok, Ctx} = py_context:new(#{mode => isolated, kill_after => 1000}),
77+
{error, timeout} = py_context:eval(Ctx, <<"__import__('time').sleep(60)">>, #{}, 200),
78+
%% Usable at once, no 60 s wait
79+
{ok, 4} = py_context:eval(Ctx, <<"2+2">>, #{}, 5000).
80+
```
81+
82+
`py_context:kill/1` kills at once. See [Isolated Contexts](isolated.md).
83+
6884
## Limits
6985

7086
- CPython delivers an async exception at the next bytecode boundary. Code
7187
blocked inside a C call (`time.sleep`, a numpy kernel, a socket read) is
7288
not interrupted until that call returns. The call still times out on the
73-
Erlang side; the context becomes usable once the C call finishes.
89+
Erlang side; the context becomes usable once the C call finishes. Only
90+
`isolated` mode interrupts such a call.
7491
- An interrupt targets the context, not an individual request. Interrupting a
7592
context that just finished one call and started another stops the new one.
7693
- `py:call/3,4` and `py:eval/1,2` use `infinity` by default. Pass an explicit

0 commit comments

Comments
 (0)