State machines
View SourceThe states each long-lived thing in erlang_python moves through, what moves it, and what is allowed in each state. Read this before changing a loop, a shutdown path or a restart policy; the invariants at the end are the ones a change must keep. Messages and frames are in protocols.
The Python runtime (C)
g_runtime_state in c_src/py_nif.h, moved with compare-and-swap so only
one thread wins each transition.
UNINIT --init--> INITING --ok--> RUNNING --finalize--> SHUTTING_DOWN --> STOPPED
| ^
+--------------------- failure ------------------------+runtime_is_running()gates every NIF that touches Python; a NIF that findsSHUTTING_DOWNorSTOPPEDreturnsnot_runningor an error.STOPPEDmay re-enterINITING: the runtime can be finalized and initialized again in one VM (the suites do this).
An embedded context (py_context process + context thread)
Two cooperating machines: the Erlang process and the pthread in C.
Context thread (ctx_thread_main_worker, ctx_thread_main_owngil
in c_src/py_nif.c):
starting --namespaces created--> waiting --dequeue--> executing --reply--> waiting
| |
+-- init_error -----> exited +-- shutdown_requested ----> exitedwaitingblocks onqueue_not_emptyunderqueue_mutex.executingis bracketed bypy_context_exec_enter/exec_leave(interrupt bookkeeping) around the GIL; the request mirror onpy_context_tis valid only here.exitedsetsthread_running = false;nif_context_destroyjoins with a timeout and, if the join fails, marks the contextleakedand pins the resource instead of freeing it.
Erlang process (loop/1 in py_context_embedded):
idle --{call|eval|exec|submit}--> in_request --{py_result}--> idle
| |
| +--{suspended, ...}--> in_callback --resume--> in_request
|
+--{start_loop}--> loop_running --{py_result, LoopReq}--> idle
|
+--{stop_loop, GraceMs}--> stopping --grace--> interrupt --deadline--> idlein_requestis a blocking receive for the reply; nested callbacks arrive as{erlang_callback, ...}(pipe) or{suspended, ...}and are served inline, so the process never deadlocks with its own thread.- In
loop_runningthe loop requestLoopReqoccupies the thread:call,eval,execandcall_methodanswer{error, loop_running};stopfirst stops the loop. The owner is monitored and itsDOWNstops the loop. stoppingarmsloop_stop_deadline(cooperative) thenloop_interrupt_deadline; the loop exit is thepy_resultforLoopReq, and the owner gets{py_loop_exit, Ctx, Result}.
A request (ctx_request_t)
created (refcount 1) --enqueue--> queued (2) --dequeue--> running --done--> completed
| |
+-- cancelled (destroy, timeout) -------+
v
freed when refcount hits 0The queue and the caller each hold one reference; whoever releases last
frees. cancelled is checked by the thread before running, so a request
cancelled while queued is answered {error, cancelled} without touching
Python.
An isolated context (py_isolated, gen_statem)
States are the state() type in src/py_isolated.erl; sys:get_state/1
shows the current one and sys:trace/2 prints transitions.
start_child + handshake
|
v
+--------------> idle <----------------------------------+
| | |
| main request | start_loop |
| v |
| {busy, Id} ---reply Id---> idle |
| | |
| | looping --stop_loop-----> stopping_loop
| | | (grace: interrupt, then kill)
| | +--loop_exit event--------+
| |
+-- child exit / kill / socket error --> {restarting, Reason} --new child--> idle
|
+-- budget exhausted --> stopPer state:
| State | Main requests (call, eval, exec, start_loop) | Other requests | Timers armed |
|---|---|---|---|
idle | dispatched, go to {busy, Id} (start_loop goes to looping) | dispatched | none |
{busy, Id} | postponed, unless from a process running a callback for this context (nested, dispatched) | dispatched | {timeout, kill} bound to Id once an interrupt was sent |
looping | {error, loop_running} | dispatched (submit, pass_fd, ...) | none |
stopping_loop | postponed | dispatched | state_timeout for the interrupt, then {timeout, kill} bound to loop |
{restarting, R} | postponed | postponed | state_timeout waiting for the port's exit_status |
Transitions and their triggers:
{busy, Id}toidle: a status-1/2 frame withId. Frames for other ids (nested requests) do not change state.- Anything to
{restarting, Reason}:{Port, {exit_status, S}}, a socketabort, a{memory_limit, Rss}event,kill/1, or a request the state machine cannot deliver. In-flight requests, submitted tasks and a running loop fail withReason(fail_pending/2); postponed requests are kept and served by the next child. {restarting, _}toidle: the port reported the exit and a new child passed the handshake.restart_allowed/1counts restarts inrestart_period; overmax_restarts(or withrestart => false) the process stops with{child_exited, Reason}.loopingtostopping_loop:stop_loop/2or the owner'sDOWN.stopping_looptoidle: the{loop_exit, R}event. The interruptstate_timeoutand the kill backstop escalate if the loop does not exit.
Interrupt timing: interrupt/1 in {busy, Id} sends {interrupt, Id} and
arms {{timeout, kill}, KillAfter, Id}. The reply for Id cancels the
timer; if it fires, the child gets SIGKILL and the machine goes to
{restarting, killed}. An interrupt in any other state answers
not_running.
The child (_isolated.py)
The main thread runs one request at a time but can nest:
idle --request--> executing [stack: Id1]
|
+-- erlang.call --> waiting for reply, serving nested requests [Id1, Id2] ...
|
+-- SIGUSR1 while running --> _Interrupted raised in the request on top_exec_stackholds the ids being executed, innermost last. An{interrupt, Target}control is honoured only ifTargetis the top of the stack; otherwise it is stale and dropped. The signal handler raises only whilerunningis true, so an interrupt between requests cannot leak into the next one.- The reader thread never runs Python code: it parses frames, resolves waiters, and pushes requests and interrupts to the main thread's inbox.
broken(EOF or a hard error on the socket) is terminal: the reader callsos._exit, since nothing useful can happen in the process any more and a main thread stuck in a C call must not keep it alive. Erlang sees the port'sexit_statusand runs the restart policy above.- With a loop:
start_looprunsrun_foreveron the main thread; requests that need the main thread are refused withloop_running,submitgoes throughcall_soon_threadsafe, andstop_loopcallsloop.stop()from the reader thread.
A shared region (py_shm)
new --> open --close/1 or owner DOWN--> closed (file unlinked, handle closed)Mappings in Python outlive closed until the wrapper is closed or
collected; a later access raises ValueError, never a fault. A shared
buffer adds closed = true in its header at py_buffer:close/1, and
readers waiting in _py_buffer_wait are answered with the closed flag.
Invariants
- A context executes one top-level request at a time, in every mode.
Embedded: one thread, one dequeue. Isolated:
{busy, Id}pluspostpone. - Nested requests only come from a process serving a callback of the same context. Anything else waits.
- A restart never loses a queued request, only the ones in flight, and callers of those get an error naming the cause.
- Interrupts target the request executing now; a stale interrupt is
dropped on both sides (kill timer bound to the id in Erlang, stack check
in the child,
interrupt_pendingcleared inexec_leavefor embedded contexts). - Shutdown never frees memory a thread may still use: embedded contexts leak on a failed join, the child is reaped through the port.