Conversation
The socket listen/accept sequence, frames, executable lookup and port options move to py_child, so another process can drive a Python child the same way. No change in behaviour.
clear_env => true gives the child only the variables named in env, and hash_seed pins PYTHONHASHSEED, so two children built the same way hash strings and order sets the same way. Bad values are refused when the context starts.
A context started with session => true runs in its own scratch directory, is killed rather than asked to stop, and is never restarted: once its child is gone it answers every request with the exit reason until it is closed. Its child can be forked by a session template instead of spawned; the template reports the exit by message. The child runtime can be built before it is connected, and the serve loop is shared by both kinds of child.
py_session:template/1 prepares an interpreter once: imports, preload, environment and hash seed. py_session:new/1 then gives a fresh child process per session, forked from the template's zygote or spawned, and close/1 kills it. A session is an isolated context, so calls, callbacks back into the same session, interrupts and loops work unchanged. A forked session detaches from the zygote's stdio and logs its output through its own context, so a zygote that dies is seen and rebuilt while its sessions keep running.
The stress suite logs what a fresh session costs next to a plain isolated context, where a fork's time goes, sessions per second and call overhead. The soak suite churns sessions from fork and spawn templates with crashes, kills and timeouts, then checks that processes, ports, fds, children, zombies and scratch directories are back to baseline. The examples compare the isolation step with Temporal's and Restate's Python SDKs on the same workflow.
A task-oriented guide (template, new, run, fork or spawn, what a session sees, limits, costs), the decision record for forking sessions from a zygote, the exited state of a session context, and the changelog.
Every file:make_dir, del_dir_r, delete and change_mode call goes through the node's single file server, so sessions opened in parallel queued behind each other: on macOS eight callers got fewer sessions a second than one. The socket directory is now created once and cached, and a session's socket file and scratch directory are handled with prim_file from its own process. close/1 replies before the directory is removed.
An owngil context thread waited for erlang.call on its callback pipe, so a callback calling back into the same context queued behind the request that was waiting for it, until the timeout. Requests with a caller now wait inline and serve the nested call, as on a worker context.
The NIF read sys.modules to find the erlang module when it extends it. When sys.modules is replaced by a mapping (a re-import template does that), PyDict_GetItemString on it returns nothing and the extension code ran without erlang. PyImport_GetModuleDict() is the table the import system itself uses.
A template with start => reimport keeps worker or owngil contexts, and py_session:run/5 imports the function's module again in a new module dictionary on one of them, the way Temporal's Python SDK isolates a workflow run. The standard library, imports and passthrough modules are shared; the swap is per thread, so worker contexts run at once without seeing each other's modules. It isolates module state only, and the guide says what stays shared.
The zygote reaps a child and its template reports how it died; on a busy machine the pid disappears before that report arrives, and the session concluded it was killed. It now waits for the report while the template is alive, and falls back to the pid only when it is gone.
Figures from examples/bench_sessions.erl on an unloaded machine, with throughput per start mode and the isolation step of Temporal's and Restate's SDKs on the same workflow.
benoitc
force-pushed
the
feature/isolated-sessions
branch
from
September 26, 2026 19:33
15521fe to
5810f47
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Some callers need each run of a Python function to start from the same state and leave nothing behind for the next one. A durable-execution engine replaying workflow steps is the case that prompted this. An isolated context keeps its interpreter between calls, and a new one costs an interpreter start plus every import. Temporal's Python SDK isolates a workflow run by importing its module again in a fresh
sys.modules, inside a shared process. Restate's SDK does not isolate invocations at all.py_session:template/1prepares an interpreter once: paths, imports, preload, environment, hash seed and limits.py_session:new/1then gives a new child process per session that starts from it. By default the child is forked from a zygote that already ran the imports and preload, so a session is ready in a few milliseconds instead of the 40 to 60 ms of a new interpreter.start => spawnstarts a new interpreter per session, optionally from a warm pool, for code that cannot be forked. A session is an isolated context, so calls, callbacks back into the same session, interrupts, loops andpass_fdwork unchanged.close/1kills it. A session whose process dies answers with the reason until it is closed, and is never restarted.Measured on the same machine and workflow module: a forked session costs about 3.5 ms to start, use and close, the same order as a Temporal workflow sandbox (0.5 ms to 4.6 ms depending on what the workflow imports), and gives a separate process instead of a separate module dictionary. Restate's SDK costs almost nothing per invocation because it isolates nothing. One zygote serves about 650 sessions a second on macOS and 1,000 on Linux;
zygotes => Nadds more. A call into a session crosses a local socket (30 to 70 µs), which the in-process SDKs avoid.When fresh module state per run is enough,
start => reimportruns each call on a worker or owngil context instead: the function's module is imported again in a new module dictionary, swapped per thread, the way Temporal's Python SDK isolates a workflow run. The standard library and the modules the template lists are shared, and so are the process, its environment and C-extension state. A run costs about 0.25 ms, and calls inside it stay in-process.It also fixes a hang on owngil contexts: a callback calling back into the same owngil context waited until the request timeout, because the context thread waited for
erlang.callon its pipe while the nested call sat in its queue. It now waits inline and serves the nested call, as worker contexts do.Isolated contexts also gain
clear_envandhash_seed, which sessions use so that each one sees only the template's environment and orders sets the same way.A session is a process boundary, not a security boundary. Confining what a session can reach (Landlock, seccomp, Seatbelt) and freezing time and randomness for deterministic replay are the next steps.