From 0f5d70e4ef74648f1db2aedd7ca68fa4d2c18f8d Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Sat, 15 Aug 2026 19:37:47 +0200 Subject: [PATCH 01/10] Add worker loops: run and drive an ErlangEventLoop inside a context py_context:start_loop/stop_loop/loop_ref/submit/submit_await and the erlang.server helper let Erlang run N owngil workers that serve TCP/UDP on a socket Erlang bound (py:dup_fd per worker) or adopt accepted fds, and inject coroutines into the running loop. Fixes behind it: owngil contexts had no ErlangEventLoop and returned the main loop from context_get_event_loop; owngil dispatch was blocking on a dirty scheduler with a 30 s cap; transports closed fds still in the poll set (erts_poll reports under churn); READ re-arm from the loop thread crashed in enif_select; task start failures were dropped. Includes CT suite, Python unit tests, gated stress suite, bench script and docs/workers.md. --- CHANGELOG.md | 56 +++ README.md | 1 + c_src/py_callback.c | 5 + c_src/py_event_loop.c | 363 ++++++++++++++++-- c_src/py_event_loop.h | 36 +- c_src/py_nif.c | 121 ++++-- c_src/py_nif.h | 4 + docs/asyncio.md | 1 + docs/interrupts.md | 6 + docs/owngil_internals.md | 9 +- docs/workers.md | 192 ++++++++++ examples/bench_worker_loop.erl | 264 +++++++++++++ priv/_erlang_impl/__init__.py | 38 ++ priv/_erlang_impl/_loop.py | 97 ++++- priv/_erlang_impl/_server.py | 82 ++++ priv/_erlang_impl/_transport.py | 57 ++- priv/tests/test_loop_helpers.py | 119 ++++++ priv/tests/test_server.py | 168 +++++++++ priv/tests/test_transport_close.py | 236 ++++++++++++ rebar.config | 2 + src/erlang_python.app.src | 2 +- src/py_context.erl | 325 +++++++++++++++- src/py_event_worker.erl | 5 + src/py_nif.erl | 8 + test/py_asyncio_compat_SUITE.erl | 46 ++- test/py_test_workerloop.py | 147 ++++++++ test/py_worker_loop_SUITE.erl | 537 +++++++++++++++++++++++++++ test/py_worker_loop_stress_SUITE.erl | 350 +++++++++++++++++ 28 files changed, 3174 insertions(+), 103 deletions(-) create mode 100644 docs/workers.md create mode 100644 examples/bench_worker_loop.erl create mode 100644 priv/_erlang_impl/_server.py create mode 100644 priv/tests/test_loop_helpers.py create mode 100644 priv/tests/test_server.py create mode 100644 priv/tests/test_transport_close.py create mode 100644 test/py_test_workerloop.py create mode 100644 test/py_worker_loop_SUITE.erl create mode 100644 test/py_worker_loop_stress_SUITE.erl diff --git a/CHANGELOG.md b/CHANGELOG.md index f8e1be0..6799b79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,61 @@ # Changelog +## 4.1.0 (2026-08-15) + +### Added + +- **Worker loops** - `py_context:start_loop/1,2` runs an `ErlangEventLoop` + forever on the context thread and returns at once; `py_context:submit/4,5` + and `submit_await/4,5,6` schedule a coroutine or function on it from Erlang + (results as `{async_result, TaskRef, _}`, `py_event_loop:await/1,2`); + `stop_loop/1,2` stops it cooperatively, then interrupts after a grace + period; `loop_ref/1` exposes the loop for `py_nif:submit_task/7`. The owner + receives `{py_loop_exit, Ctx, Result}` when the loop ends. While a loop + runs, `call/eval/exec/call_method` on that context return + `{error, loop_running}`. `py_context:new/1` takes `preload => Code`, run once + in the context before anything else. See `docs/workers.md`. +- **`erlang.server`** - `serve(listen_fd, protocol_factory, udp=False)`, + `adopt(fd, protocol_factory)` and `stop_serving(server)`: serve TCP or UDP + on a socket Erlang bound (`py:dup_fd/1` per worker) or take over one + accepted connection, from a coroutine scheduled with `submit`. This is the + gunicorn shape inside the VM: Erlang binds once, N owngil contexts accept + on their copy of the fd, Erlang supervises, scales and reloads. +- **Injection into subinterpreter loops** - `py_nif:process_ready_tasks/1` + attaches a thread state to the loop's subinterpreter, so `submit_task` works + for owngil loops, idle or running; scheduling into a running loop now wakes + it instead of waiting for the next poll timeout (about 25 us round trip + instead of up to 1 s). Tasks that fail to start (missing module or function, + argument conversion, the call itself raising) are reported to the caller as + `{async_result, Ref, {error, Reason}}` instead of being dropped. + +### Fixed + +- **owngil contexts had no event loop** - `owngil_context_thread_main` created + the `py_event_loop` module without a default loop, so `erlang.run()`, + `create_server` and channels raised "Erlang event loop not initialized" in + owngil contexts. Each owngil context now owns an `ErlangEventLoop` served by + its own `py_event_worker`. `py_nif:context_get_event_loop/1` returned the + main interpreter's loop for owngil contexts, which made every owngil start + re-point the main loop's worker to a process that died with the context. +- **owngil dispatch** - calls into owngil contexts went through a blocking + dispatch on a dirty CPU scheduler with a 30 s cap + (`OWNGIL_DISPATCH_TIMEOUT_SECS`); they now use the same async queue as + worker mode: no dirty scheduler held during the call, no cap, and a lower + round trip (about 11.6 us against 15.6 us before on the bench machine). +- **fd closed while still in the poll set** - transports closed their socket + right after `ERL_NIF_SELECT_STOP` was issued, which under connection churn + produced `Bad input fd in erts_poll()` and `enif_select ... stealing + control of fd` reports and could deliver events to the wrong resource once + the number was reused. Transports now detach the fd and hand it to the NIF + (`_release_fd_resource(fd_key, take_ownership)`), which closes it from the + select stop callback; the reselect path and the close path serialise on the + loop mutex. 10k connections across four workers now log nothing. +- **Re-arming a read select from the Python thread** - re-selecting READ on + an fd the BEAM had moved into a scheduler poll set crashed inside + `enif_select` when done from the loop thread (transport `resume_reading`, + `add_reader` on an fd with an active writer). Read re-arms now go through + the loop's `py_event_worker` (`py_nif:fd_arm/2`). + ## 4.0.0 (2026-08-15) ### Breaking Changes diff --git a/README.md b/README.md index 6b7b201..d98c347 100644 --- a/README.md +++ b/README.md @@ -650,6 +650,7 @@ py:execution_mode(). %% => worker | owngil - [Threading](docs/threading.md) - [Logging and Tracing](docs/logging.md) - [Asyncio Event Loop](docs/asyncio.md) - Erlang-native asyncio with TCP/UDP support +- [Worker Loops](docs/workers.md) - Long-lived loops in owngil contexts, serving on sockets Erlang owns - [Reactor](docs/reactor.md) - FD-based protocol handling - [Security](docs/security.md) - Sandbox and blocked operations - [Changelog](https://github.com/benoitc/erlang-python/releases) diff --git a/c_src/py_callback.c b/c_src/py_callback.c index 69d822b..ba630a2 100644 --- a/c_src/py_callback.c +++ b/c_src/py_callback.c @@ -4138,11 +4138,16 @@ static int create_erlang_module(void) { " erlang.byte_channel = _erlang_impl.byte_channel\n" " erlang.ByteChannel = _erlang_impl.ByteChannel\n" " erlang.ByteChannelClosed = _erlang_impl.ByteChannelClosed\n" + " # Worker loops (py_context:start_loop/submit) and fd serving\n" + " erlang.server = _erlang_impl.server\n" + " erlang._run_loop_forever = _erlang_impl._run_loop_forever\n" + " erlang._stop_loop = _erlang_impl._stop_loop\n" " # Make erlang behave as a package for 'import erlang.reactor' syntax\n" " erlang.__path__ = [priv_dir]\n" " sys.modules['erlang.reactor'] = erlang.reactor\n" " sys.modules['erlang.channel'] = erlang.channel\n" " sys.modules['erlang.byte_channel'] = erlang.byte_channel\n" + " sys.modules['erlang.server'] = erlang.server\n" " return True\n" " except ImportError as e:\n" " import sys\n" diff --git a/c_src/py_event_loop.c b/c_src/py_event_loop.c index 5ed6573..9c1a1b2 100644 --- a/c_src/py_event_loop.c +++ b/c_src/py_event_loop.c @@ -35,6 +35,7 @@ */ #include "py_nif.h" +#include #include "py_event_loop.h" #include "py_reactor_buffer.h" @@ -338,6 +339,10 @@ static int set_interpreter_event_loop(erlang_event_loop_t *loop) { return 0; } +erlang_event_loop_t *get_current_interpreter_event_loop(void) { + return get_interpreter_event_loop(); +} + /* ============================================================================ * Resource Callbacks * ============================================================================ */ @@ -1950,11 +1955,6 @@ ERL_NIF_TERM nif_handle_fd_event_and_reselect(ErlNifEnv *env, int argc, return make_error(env, "invalid_fd_ref"); } - /* Check if FD is still open */ - if (atomic_load(&fd_res->closing_state) != FD_STATE_OPEN) { - return ATOM_OK; /* Silently ignore events on closing FDs */ - } - erlang_event_loop_t *loop = fd_res->loop; if (loop == NULL) { return make_error(env, "no_loop"); @@ -1965,6 +1965,18 @@ ERL_NIF_TERM nif_handle_fd_event_and_reselect(ErlNifEnv *env, int argc, uint64_t callback_id; bool is_active; + /* The state check and the reselect must be one step: the Python thread + * closes fds through py_release_fd_resource, which moves the state to + * CLOSING under this same mutex before it issues ERL_NIF_SELECT_STOP. + * Without the lock we could pass the check, lose the race, and reselect + * a closed (or already reused) fd number. */ + pthread_mutex_lock(&loop->mutex); + + if (atomic_load(&fd_res->closing_state) != FD_STATE_OPEN) { + pthread_mutex_unlock(&loop->mutex); + return ATOM_OK; /* Silently ignore events on closing FDs */ + } + if (is_read) { callback_id = fd_res->read_callback_id; is_active = fd_res->reader_active; @@ -1974,13 +1986,10 @@ ERL_NIF_TERM nif_handle_fd_event_and_reselect(ErlNifEnv *env, int argc, } if (!is_active || callback_id == 0) { + pthread_mutex_unlock(&loop->mutex); return ATOM_OK; /* Watcher was stopped, ignore */ } - /* Add to pending queue (has duplicate detection) */ - event_type_t event_type = is_read ? EVENT_TYPE_READ : EVENT_TYPE_WRITE; - event_loop_add_pending(loop, event_type, callback_id, fd_res->fd); - /* Immediately reselect for next event. * Use ATOM_UNDEFINED instead of enif_make_ref to avoid per-event allocation. * The ref is ignored by the worker anyway. */ @@ -1989,6 +1998,49 @@ ERL_NIF_TERM nif_handle_fd_event_and_reselect(ErlNifEnv *env, int argc, enif_select(env, (ErlNifEvent)fd_res->fd, select_flags, fd_res, target_pid, ATOM_UNDEFINED); + pthread_mutex_unlock(&loop->mutex); + + /* Add to pending queue (has duplicate detection; takes loop->mutex itself) */ + event_type_t event_type = is_read ? EVENT_TYPE_READ : EVENT_TYPE_WRITE; + event_loop_add_pending(loop, event_type, callback_id, fd_res->fd); + + return ATOM_OK; +} + +/** + * fd_arm(FdRef, read | write) -> ok + * + * Re-arm a read or write select for an fd whose resource already exists. + * Called by the loop's py_event_worker on behalf of ErlangEventLoop + * (_update_fd_read/_update_fd_write): re-selecting READ on an fd that a + * scheduler thread already polled must itself run on a scheduler thread, + * since erts keeps such fds in the scheduler's own poll set. Doing it from + * the Python thread crashes inside enif_select. Fresh selects, CANCEL and + * STOP are fine from any thread, and only re-arms go through here. + */ +ERL_NIF_TERM nif_fd_arm(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]) { + (void)argc; + + fd_resource_t *fd_res; + if (!enif_get_resource(env, argv[0], FD_RESOURCE_TYPE, (void **)&fd_res)) { + return make_error(env, "invalid_fd_ref"); + } + erlang_event_loop_t *loop = fd_res->loop; + if (loop == NULL) { + return make_error(env, "no_loop"); + } + bool is_read = enif_compare(argv[1], ATOM_READ) == 0; + + pthread_mutex_lock(&loop->mutex); + if (atomic_load(&fd_res->closing_state) != FD_STATE_OPEN || + (is_read ? !fd_res->reader_active : !fd_res->writer_active)) { + pthread_mutex_unlock(&loop->mutex); + return ATOM_OK; /* Closed or disarmed again in the meantime */ + } + int select_flags = is_read ? ERL_NIF_SELECT_READ : ERL_NIF_SELECT_WRITE; + enif_select(env, (ErlNifEvent)fd_res->fd, select_flags, + fd_res, &loop->worker_pid, ATOM_UNDEFINED); + pthread_mutex_unlock(&loop->mutex); return ATOM_OK; } @@ -2753,6 +2805,102 @@ static inline void return_pooled_env(erlang_event_loop_t *loop, ErlNifEnv *term_ } } +/* ============================================================================ + * GIL handling for process_ready_tasks + * + * Main-interpreter loops use PyGILState_Ensure. Subinterpreter loops (OWN_GIL + * contexts) cannot: PyGILState_* only knows the main interpreter, so a fresh + * thread state is created for loop->interp and bound to this scheduler thread + * for the duration of the call. The attach count lets the interpreter thread + * wait for us before Py_EndInterpreter (event_loop_detach_interpreter). + * ============================================================================ */ + +typedef struct { + PyGILState_STATE gstate; + PyThreadState *tstate; /* non-NULL when attached to a subinterpreter */ +} loop_gil_t; + +static bool loop_gil_acquire(erlang_event_loop_t *loop, loop_gil_t *g) { + g->tstate = NULL; +#ifdef HAVE_SUBINTERPRETERS + if (loop->interp_id != 0) { + pthread_mutex_lock(&loop->mutex); + if (loop->interp == NULL) { + pthread_mutex_unlock(&loop->mutex); + return false; + } + g->tstate = PyThreadState_New(loop->interp); + if (g->tstate == NULL) { + pthread_mutex_unlock(&loop->mutex); + return false; + } + loop->external_attached++; + pthread_mutex_unlock(&loop->mutex); + /* Take the subinterpreter GIL outside loop->mutex: the loop thread + * may hold the GIL while waiting for loop->mutex. */ + PyEval_RestoreThread(g->tstate); + return true; + } +#endif + g->gstate = PyGILState_Ensure(); + return true; +} + +static void loop_gil_release(erlang_event_loop_t *loop, loop_gil_t *g) { +#ifdef HAVE_SUBINTERPRETERS + if (g->tstate != NULL) { + PyThreadState_Clear(g->tstate); + PyThreadState_DeleteCurrent(); /* releases the subinterpreter GIL */ + g->tstate = NULL; + pthread_mutex_lock(&loop->mutex); + loop->external_attached--; + pthread_mutex_unlock(&loop->mutex); + return; + } +#endif + (void)loop; + PyGILState_Release(g->gstate); +} + +void event_loop_detach_interpreter(erlang_event_loop_t *loop) { + if (loop == NULL) { + return; + } + pthread_mutex_lock(&loop->mutex); + loop->interp = NULL; + /* Teardown is rare: poll rather than add a condvar to the loop struct. */ + while (loop->external_attached > 0) { + pthread_mutex_unlock(&loop->mutex); + usleep(1000); + pthread_mutex_lock(&loop->mutex); + } + pthread_mutex_unlock(&loop->mutex); +} + +/** + * Report a task that could not be started to its caller as + * {async_result, Ref, {error, Reason}} instead of dropping it silently. + * Reason is the pending Python exception when one is set (cleared here), + * otherwise the given atom. Runs with the GIL held. + */ +static void send_task_failure(ErlNifEnv *term_env, ErlNifPid *caller_pid, + ERL_NIF_TERM ref, const char *reason) { + ErlNifEnv *msg_env = enif_alloc_env(); + if (msg_env == NULL) { + PyErr_Clear(); + return; + } + ERL_NIF_TERM err = PyErr_Occurred() ? make_py_error(msg_env) + : make_error(msg_env, reason); + ERL_NIF_TERM msg = enif_make_tuple3(msg_env, + enif_make_atom(msg_env, "async_result"), + enif_make_copy(msg_env, ref), + err); + (void)term_env; + enif_send(NULL, caller_pid, msg_env, msg); + enif_free_env(msg_env); +} + /** * process_ready_tasks(LoopRef) -> ok | {error, Reason} * @@ -2872,7 +3020,10 @@ ERL_NIF_TERM nif_process_ready_tasks(ErlNifEnv *env, int argc, * PHASE 2: Process all tasks WITH GIL (Python operations) * ======================================================================== */ - PyGILState_STATE gstate = PyGILState_Ensure(); + loop_gil_t gil; + if (!loop_gil_acquire(loop, &gil)) { + return make_error(env, "interpreter_gone"); + } /* OPTIMIZATION: Use cached Python imports (uvloop-style) * Avoids PyImport_ImportModule on every call */ @@ -2897,7 +3048,7 @@ ERL_NIF_TERM nif_process_ready_tasks(ErlNifEnv *env, int argc, for (int i = 0; i < num_tasks; i++) { return_pooled_env(loop, tasks[i].term_env); } - PyGILState_Release(gstate); + loop_gil_release(loop, &gil); return make_error(env, "asyncio_import_failed"); } @@ -2914,7 +3065,7 @@ ERL_NIF_TERM nif_process_ready_tasks(ErlNifEnv *env, int argc, for (int i = 0; i < num_tasks; i++) { return_pooled_env(loop, tasks[i].term_env); } - PyGILState_Release(gstate); + loop_gil_release(loop, &gil); return make_error(env, "erlang_loop_import_failed"); } @@ -2925,7 +3076,7 @@ ERL_NIF_TERM nif_process_ready_tasks(ErlNifEnv *env, int argc, for (int i = 0; i < num_tasks; i++) { return_pooled_env(loop, tasks[i].term_env); } - PyGILState_Release(gstate); + loop_gil_release(loop, &gil); return make_error(env, "run_and_send_not_found"); } @@ -2938,7 +3089,7 @@ ERL_NIF_TERM nif_process_ready_tasks(ErlNifEnv *env, int argc, for (int i = 0; i < num_tasks; i++) { return_pooled_env(loop, tasks[i].term_env); } - PyGILState_Release(gstate); + loop_gil_release(loop, &gil); return make_error(env, "events_import_failed"); } @@ -2965,7 +3116,7 @@ ERL_NIF_TERM nif_process_ready_tasks(ErlNifEnv *env, int argc, for (int i = 0; i < num_tasks; i++) { return_pooled_env(loop, tasks[i].term_env); } - PyGILState_Release(gstate); + loop_gil_release(loop, &gil); return make_error(env, "loop_module_import_failed"); } @@ -2976,7 +3127,7 @@ ERL_NIF_TERM nif_process_ready_tasks(ErlNifEnv *env, int argc, for (int i = 0; i < num_tasks; i++) { return_pooled_env(loop, tasks[i].term_env); } - PyGILState_Release(gstate); + loop_gil_release(loop, &gil); return make_error(env, "loop_class_not_found"); } @@ -2987,7 +3138,7 @@ ERL_NIF_TERM nif_process_ready_tasks(ErlNifEnv *env, int argc, for (int i = 0; i < num_tasks; i++) { return_pooled_env(loop, tasks[i].term_env); } - PyGILState_Release(gstate); + loop_gil_release(loop, &gil); return make_error(env, "loop_creation_failed"); } @@ -3101,6 +3252,7 @@ ERL_NIF_TERM nif_process_ready_tasks(ErlNifEnv *env, int argc, } if (func == NULL) { + send_task_failure(term_env, &caller_pid, tuple_elems[1], "function_not_found"); return_pooled_env(loop, term_env); continue; } @@ -3135,6 +3287,7 @@ ERL_NIF_TERM nif_process_ready_tasks(ErlNifEnv *env, int argc, if (!args_ok) { Py_DECREF(args); Py_DECREF(func); + send_task_failure(term_env, &caller_pid, tuple_elems[1], "args_conversion_failed"); return_pooled_env(loop, term_env); continue; } @@ -3160,7 +3313,8 @@ ERL_NIF_TERM nif_process_ready_tasks(ErlNifEnv *env, int argc, Py_XDECREF(kwargs); if (coro == NULL) { - PyErr_Clear(); + /* The call itself raised: report the Python exception */ + send_task_failure(term_env, &caller_pid, tuple_elems[1], "call_failed"); return_pooled_env(loop, term_env); continue; } @@ -3248,13 +3402,18 @@ ERL_NIF_TERM nif_process_ready_tasks(ErlNifEnv *env, int argc, int running = PyObject_IsTrue(is_running); Py_DECREF(is_running); if (running) { - /* Loop is already running - just signal it and clean up. - * The pending events were already added by dispatch_timer/handle_fd_event, - * and the condition variable was signaled. The running loop will wake up - * and process them. + /* Loop is already running (run_forever on another thread): the + * coroutines were scheduled on its ready queue, wake it so they + * start now instead of at the next poll timeout. Same broadcast + * as _wakeup_for (call_soon_threadsafe). * Note: events_module is cached, so we don't DECREF it. */ Py_XDECREF(old_running_loop); - PyGILState_Release(gstate); + loop_gil_release(loop, &gil); + if (!loop->shutdown) { + pthread_mutex_lock(&loop->mutex); + pthread_cond_broadcast(&loop->event_cond); + pthread_mutex_unlock(&loop->mutex); + } return ATOM_OK; } } else { @@ -3312,7 +3471,7 @@ ERL_NIF_TERM nif_process_ready_tasks(ErlNifEnv *env, int argc, Py_XDECREF(restore); Py_XDECREF(old_running_loop); - PyGILState_Release(gstate); + loop_gil_release(loop, &gil); /* * Check if there are more tasks remaining (we hit MAX_TASK_BATCH limit). @@ -5317,6 +5476,16 @@ ERL_NIF_TERM nif_context_get_event_loop(ErlNifEnv *env, int argc, return make_error(env, "not_subinterp"); } + /* OWN_GIL contexts: the loop was created by the context thread inside its + * subinterpreter and recorded on the context. Read it without touching + * the main GIL, which would resolve the main interpreter's loop instead. */ + if (ctx->uses_own_gil) { + if (ctx->event_loop == NULL) { + return make_error(env, "no_event_loop"); + } + return enif_make_tuple2(env, ATOM_OK, enif_make_resource(env, ctx->event_loop)); + } + /* With shared-GIL pool model, event loop operations work on dirty schedulers. * py_context_acquire handles PyThreadState_Swap to the subinterpreter. */ @@ -7131,6 +7300,7 @@ static PyObject *py_loop_new(PyObject *self, PyObject *args) { } else { loop->interp_id = 0; /* Main interpreter */ } + loop->interp = current_interp; #else loop->interp_id = 0; /* Main interpreter */ #endif @@ -7643,15 +7813,42 @@ static PyObject *py_update_fd_read(PyObject *self, PyObject *args) { PyErr_SetString(PyExc_ValueError, "Invalid fd resource"); return NULL; } + if (fd_res->fd < 0 || atomic_load(&fd_res->closing_state) != FD_STATE_OPEN) { + enif_release_resource(fd_res); + PyErr_SetString(PyExc_ValueError, "fd resource is closed"); + return NULL; + } + + if (!event_loop_ensure_worker(fd_res->loop)) { + enif_release_resource(fd_res); + PyErr_SetString(PyExc_RuntimeError, "Event loop has no router or worker"); + return NULL; + } fd_res->read_callback_id = callback_id; fd_res->reader_active = true; - /* Re-register for read events (may already be registered, that's OK) */ - ErlNifPid *target_pid = fd_res->loop->has_worker ? - &fd_res->loop->worker_pid : &fd_res->loop->router_pid; - enif_select(fd_res->loop->msg_env, (ErlNifEvent)fd_res->fd, - ERL_NIF_SELECT_READ, fd_res, target_pid, ATOM_UNDEFINED); + /* Re-arm from the worker process, not from this Python thread: see + * nif_fd_arm for why a READ re-select must run on a scheduler thread. */ + ErlNifEnv *arm_env = enif_alloc_env(); + if (arm_env == NULL) { + fd_res->reader_active = false; + enif_release_resource(fd_res); + PyErr_SetString(PyExc_MemoryError, "Failed to allocate env"); + return NULL; + } + ERL_NIF_TERM arm_msg = enif_make_tuple3(arm_env, + enif_make_atom(arm_env, "fd_arm"), + enif_make_resource(arm_env, fd_res), + ATOM_READ); + if (!enif_send(NULL, &fd_res->loop->worker_pid, arm_env, arm_msg)) { + enif_free_env(arm_env); + fd_res->reader_active = false; + enif_release_resource(fd_res); + PyErr_SetString(PyExc_RuntimeError, "Event loop worker is gone"); + return NULL; + } + enif_free_env(arm_env); enif_release_resource(fd_res); Py_RETURN_NONE; @@ -7676,15 +7873,31 @@ static PyObject *py_update_fd_write(PyObject *self, PyObject *args) { PyErr_SetString(PyExc_ValueError, "Invalid fd resource"); return NULL; } + if (fd_res->fd < 0 || atomic_load(&fd_res->closing_state) != FD_STATE_OPEN) { + enif_release_resource(fd_res); + PyErr_SetString(PyExc_ValueError, "fd resource is closed"); + return NULL; + } + + if (!event_loop_ensure_worker(fd_res->loop)) { + enif_release_resource(fd_res); + PyErr_SetString(PyExc_RuntimeError, "Event loop has no router or worker"); + return NULL; + } fd_res->write_callback_id = callback_id; fd_res->writer_active = true; - /* Re-register for write events */ - ErlNifPid *target_pid = fd_res->loop->has_worker ? - &fd_res->loop->worker_pid : &fd_res->loop->router_pid; - enif_select(fd_res->loop->msg_env, (ErlNifEvent)fd_res->fd, - ERL_NIF_SELECT_WRITE, fd_res, target_pid, ATOM_UNDEFINED); + /* Same target as _add_writer_for: the loop's worker process. */ + ErlNifPid *target_pid = &fd_res->loop->worker_pid; + int ret = enif_select(fd_res->loop->msg_env, (ErlNifEvent)fd_res->fd, + ERL_NIF_SELECT_WRITE, fd_res, target_pid, ATOM_UNDEFINED); + if (ret < 0) { + fd_res->writer_active = false; + enif_release_resource(fd_res); + PyErr_SetString(PyExc_RuntimeError, "Failed to register fd for writing"); + return NULL; + } enif_release_resource(fd_res); Py_RETURN_NONE; @@ -7752,21 +7965,53 @@ static PyObject *py_clear_fd_write(PyObject *self, PyObject *args) { /** * Release fd_resource (stop all monitoring and release). - * Python function: _release_fd_resource(fd_key) -> None + * Python function: _release_fd_resource(fd_key, take_ownership=False) -> None + * + * With take_ownership the fd is closed by us, from the enif_select stop + * callback once the poll set has dropped it (or right here when it is not + * selected). Closing an fd from Python while ERL_NIF_SELECT_STOP is still + * scheduled leaves a stale entry in the poll set and lets the number be + * reused by the next accept(): that is the "Bad input fd in erts_poll" / + * "stealing control of fd" report class. Transports hand their socket over + * this way (see ErlangEventLoop._close_socket). */ static PyObject *py_release_fd_resource(PyObject *self, PyObject *args) { (void)self; unsigned long long fd_key; + int take_ownership = 0; - if (!PyArg_ParseTuple(args, "K", &fd_key)) { + if (!PyArg_ParseTuple(args, "K|p", &fd_key, &take_ownership)) { return NULL; } fd_resource_t *fd_res = fd_reg_take(fd_key); if (fd_res != NULL) { - if (fd_res->loop != NULL) { - enif_select(fd_res->loop->msg_env, (ErlNifEvent)fd_res->fd, - ERL_NIF_SELECT_STOP, fd_res, NULL, ATOM_UNDEFINED); + erlang_event_loop_t *loop = fd_res->loop; + if (take_ownership) { + fd_res->owns_fd = true; + /* Move to CLOSING under loop->mutex so a concurrent + * handle_fd_event_and_reselect cannot reselect this fd. */ + if (loop != NULL) pthread_mutex_lock(&loop->mutex); + int expected = FD_STATE_OPEN; + atomic_compare_exchange_strong(&fd_res->closing_state, + &expected, FD_STATE_CLOSING); + fd_res->reader_active = false; + fd_res->writer_active = false; + if (loop != NULL) pthread_mutex_unlock(&loop->mutex); + } + int rc = -1; + if (loop != NULL) { + rc = enif_select(loop->msg_env, (ErlNifEvent)fd_res->fd, + ERL_NIF_SELECT_STOP, fd_res, NULL, ATOM_UNDEFINED); + } + if (rc < 0 && take_ownership && fd_res->fd >= 0) { + /* Never selected (or no loop): nothing pending in the poll set */ + int expected = FD_STATE_CLOSING; + if (atomic_compare_exchange_strong(&fd_res->closing_state, + &expected, FD_STATE_CLOSED)) { + close(fd_res->fd); + fd_res->fd = -1; + } } enif_release_resource(fd_res); } @@ -8149,6 +8394,45 @@ int create_default_event_loop(ErlNifEnv *env) { loop->has_router = false; loop->has_self = false; + /* Async task queue, env pool and namespace registry, as in + * nif_event_loop_new: a default loop must accept submit_task too, since + * OWN_GIL contexts only ever have this loop. */ + loop->task_queue = enif_ioq_create(ERL_NIF_IOQ_NORMAL); + if (loop->task_queue == NULL || + pthread_mutex_init(&loop->task_queue_mutex, NULL) != 0) { + if (loop->task_queue != NULL) { + enif_ioq_destroy(loop->task_queue); + loop->task_queue = NULL; + } + enif_free_env(loop->msg_env); + pthread_cond_destroy(&loop->event_cond); + pthread_mutex_destroy(&loop->mutex); + enif_release_resource(loop); + return -1; + } + loop->task_queue_initialized = true; + atomic_store(&loop->task_count, 0); + atomic_store(&loop->task_wake_pending, false); + loop->py_loop = NULL; + loop->py_loop_valid = false; + loop->py_cache_valid = false; + loop->callable_cache_count = 0; + loop->env_pool_count = 0; + if (pthread_mutex_init(&loop->env_pool_mutex, NULL) != 0 || + pthread_mutex_init(&loop->namespaces_mutex, NULL) != 0) { + pthread_mutex_destroy(&loop->task_queue_mutex); + loop->task_queue_initialized = false; + enif_ioq_destroy(loop->task_queue); + loop->task_queue = NULL; + enif_free_env(loop->msg_env); + pthread_cond_destroy(&loop->event_cond); + pthread_mutex_destroy(&loop->mutex); + enif_release_resource(loop); + return -1; + } + loop->namespaces_head = NULL; + loop->pid_env_head = NULL; + #ifdef HAVE_SUBINTERPRETERS /* Check if this is a subinterpreter by comparing to main interpreter */ PyInterpreterState *current_interp = PyInterpreterState_Get(); @@ -8159,6 +8443,7 @@ int create_default_event_loop(ErlNifEnv *env) { } else { loop->interp_id = 0; /* Main interpreter */ } + loop->interp = current_interp; #else loop->interp_id = 0; /* Main interpreter */ #endif diff --git a/c_src/py_event_loop.h b/c_src/py_event_loop.h index 419b777..4cc457e 100644 --- a/c_src/py_event_loop.h +++ b/c_src/py_event_loop.h @@ -39,8 +39,9 @@ #include #include -/* Forward declaration for Python object (avoids including Python.h in header) */ +/* Forward declarations for Python objects (avoids including Python.h in header) */ typedef struct _object PyObject; +typedef struct _is PyInterpreterState; /* ============================================================================ * Constants @@ -347,6 +348,15 @@ typedef struct erlang_event_loop { /** @brief Interpreter ID: 0 = main interpreter, >0 = subinterpreter */ uint32_t interp_id; + /** @brief Owning interpreter, needed to attach a thread state to a + * subinterpreter loop from an Erlang scheduler (see process_ready_tasks). + * NULL once the interpreter is being torn down. Guarded by mutex. */ + PyInterpreterState *interp; + + /** @brief Number of scheduler threads currently attached to interp + * through loop_gil_acquire(). Guarded by mutex. */ + int external_attached; + /* ========== Async Task Queue (uvloop-inspired) ========== */ /* * Future optimization: Replace serialized task queue with native MPSC @@ -864,6 +874,13 @@ ERL_NIF_TERM nif_handle_fd_event(ErlNifEnv *env, int argc, ERL_NIF_TERM nif_handle_fd_event_and_reselect(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); +/** + * @brief Re-arm a read/write select from a scheduler thread + * + * NIF: fd_arm(FdRef, read | write) -> ok | {error, Reason} + */ +ERL_NIF_TERM nif_fd_arm(ErlNifEnv *env, int argc, const ERL_NIF_TERM argv[]); + /** * @brief Stop read monitoring without closing the FD * @@ -1092,6 +1109,23 @@ int create_default_event_loop(ErlNifEnv *env); */ int init_subinterpreter_event_loop(ErlNifEnv *env); +/** + * @brief Event loop of the interpreter bound to the calling thread + * + * Must be called with that interpreter's GIL held. Returns NULL when no + * default loop exists yet. + */ +erlang_event_loop_t *get_current_interpreter_event_loop(void); + +/** + * @brief Detach a subinterpreter loop from its interpreter before teardown + * + * Call from the interpreter's own thread with its GIL released, right before + * Py_EndInterpreter. Blocks until every scheduler thread attached through + * process_ready_tasks has detached, and refuses new attachments. + */ +void event_loop_detach_interpreter(erlang_event_loop_t *loop); + /* ============================================================================ * Reactor NIF Functions (Erlang-as-Reactor architecture) * ============================================================================ */ diff --git a/c_src/py_nif.c b/c_src/py_nif.c index f7443ba..527b738 100644 --- a/c_src/py_nif.c +++ b/c_src/py_nif.c @@ -3542,6 +3542,20 @@ static ERL_NIF_TERM dispatch_to_worker_thread( * @param local_env Optional local environment (NULL for default) * @return {enqueued, RequestId} on success, {error, Reason} on failure */ +/** + * @brief Whether requests to this context go through the shared request queue + * of a dedicated thread (worker or OWN_GIL), which is what the async + * dispatch NIFs need. + */ +static inline bool ctx_uses_async_thread(const py_context_t *ctx) { +#ifdef HAVE_SUBINTERPRETERS + if (ctx->uses_own_gil) { + return true; + } +#endif + return ctx->uses_worker_thread; +} + static ERL_NIF_TERM dispatch_to_worker_thread_async( ErlNifEnv *env, py_context_t *ctx, @@ -3638,15 +3652,22 @@ static void *owngil_context_thread_main(void *arg) { return NULL; } - /* Register py_event_loop module for reactor support */ - if (create_py_event_loop_module() < 0) { - fprintf(stderr, "OWN_GIL: create_py_event_loop_module failed\n"); + /* Register py_event_loop module and create this interpreter's default + * ErlangEventLoop, so asyncio I/O (create_server, channels, timers) works + * inside the context. Keep a reference on the context so Erlang can wire a + * dedicated py_event_worker without acquiring the main GIL. */ + if (init_subinterpreter_event_loop(NULL) < 0) { + fprintf(stderr, "OWN_GIL: init_subinterpreter_event_loop failed\n"); PyErr_Print(); Py_EndInterpreter(ctx->own_gil_tstate); atomic_store(&ctx->init_error, true); atomic_store(&ctx->worker_running, false); return NULL; } + ctx->event_loop = get_current_interpreter_event_loop(); + if (ctx->event_loop != NULL) { + enif_keep_resource(ctx->event_loop); + } /* Create namespace dictionaries */ ctx->globals = PyDict_New(); @@ -3706,19 +3727,30 @@ static void *owngil_context_thread_main(void *arg) { /* Check if request was cancelled while queued */ if (atomic_load(&req->cancelled)) { - /* Request cancelled - signal completion without processing */ - req->result_env = enif_alloc_env(); - if (req->result_env) { - req->result = enif_make_tuple2(req->result_env, - enif_make_atom(req->result_env, "error"), - enif_make_atom(req->result_env, "cancelled")); - } - req->success = false; + /* Request cancelled - deliver error without processing */ + if (req->async_mode) { + enif_clear_env(ctx->msg_env); + ERL_NIF_TERM cancel_msg = enif_make_tuple3(ctx->msg_env, + enif_make_atom(ctx->msg_env, "py_result"), + enif_make_copy(ctx->msg_env, req->request_id), + enif_make_tuple2(ctx->msg_env, + enif_make_atom(ctx->msg_env, "error"), + enif_make_atom(ctx->msg_env, "cancelled"))); + enif_send(NULL, &req->caller_pid, ctx->msg_env, cancel_msg); + } else { + req->result_env = enif_alloc_env(); + if (req->result_env) { + req->result = enif_make_tuple2(req->result_env, + enif_make_atom(req->result_env, "error"), + enif_make_atom(req->result_env, "cancelled")); + } + req->success = false; - pthread_mutex_lock(&req->mutex); - atomic_store(&req->completed, true); - pthread_cond_signal(&req->cond); - pthread_mutex_unlock(&req->mutex); + pthread_mutex_lock(&req->mutex); + atomic_store(&req->completed, true); + pthread_cond_signal(&req->cond); + pthread_mutex_unlock(&req->mutex); + } ctx_request_release(req); continue; @@ -3760,16 +3792,35 @@ static void *owngil_context_thread_main(void *arg) { ctx->reactor_buffer_ptr = NULL; ctx->local_env_ptr = NULL; - /* Signal completion */ - pthread_mutex_lock(&req->mutex); - atomic_store(&req->completed, true); - pthread_cond_signal(&req->cond); - pthread_mutex_unlock(&req->mutex); + /* Deliver result - async (message to caller) or blocking (condvar) */ + if (req->async_mode) { + enif_clear_env(ctx->msg_env); + ERL_NIF_TERM result_msg = enif_make_tuple3(ctx->msg_env, + enif_make_atom(ctx->msg_env, "py_result"), + enif_make_copy(ctx->msg_env, req->request_id), + req->result_env ? enif_make_copy(ctx->msg_env, req->result) + : enif_make_tuple2(ctx->msg_env, + enif_make_atom(ctx->msg_env, "error"), + enif_make_atom(ctx->msg_env, "no_result"))); + enif_send(NULL, &req->caller_pid, ctx->msg_env, result_msg); + } else { + pthread_mutex_lock(&req->mutex); + atomic_store(&req->completed, true); + pthread_cond_signal(&req->cond); + pthread_mutex_unlock(&req->mutex); + } /* Release queue's reference to request */ ctx_request_release(req); } + /* Refuse new scheduler attachments to our event loop and wait for the + * ones in flight (process_ready_tasks). Must run with our GIL released, + * since an attached thread needs it to finish. */ + if (ctx->event_loop != NULL) { + event_loop_detach_interpreter((erlang_event_loop_t *)ctx->event_loop); + } + /* Cleanup: acquire our OWN_GIL and destroy interpreter */ PyEval_RestoreThread(ctx->own_gil_tstate); Py_XDECREF(ctx->module_cache); @@ -3779,6 +3830,15 @@ static void *owngil_context_thread_main(void *arg) { ctx->locals = NULL; ctx->module_cache = NULL; + /* Drop our reference on the interpreter's event loop before the + * interpreter goes away (the loop destructor skips Python cleanup for + * subinterpreter loops). Detaching was done above with the GIL released. */ + if (ctx->event_loop != NULL) { + void *loop = ctx->event_loop; + ctx->event_loop = NULL; + enif_release_resource(loop); + } + /* End interpreter - this releases our GIL and cleans up */ PyInterpreterState *ended_interp = ctx->own_gil_interp; Py_EndInterpreter(ctx->own_gil_tstate); @@ -4539,6 +4599,7 @@ static int owngil_context_init(py_context_t *ctx) { ctx->uses_own_gil = true; ctx->own_gil_tstate = NULL; ctx->own_gil_interp = NULL; + ctx->event_loop = NULL; /* Initialize worker thread state */ atomic_store(&ctx->worker_running, false); @@ -4777,6 +4838,7 @@ static ERL_NIF_TERM nif_context_create(ErlNifEnv *env, int argc, const ERL_NIF_T ctx->uses_own_gil = false; ctx->own_gil_tstate = NULL; ctx->own_gil_interp = NULL; + ctx->event_loop = NULL; if (use_owngil) { /* OWN_GIL mode: create dedicated pthread with OWN_GIL subinterpreter */ @@ -5347,8 +5409,8 @@ static ERL_NIF_TERM nif_context_call_async(ErlNifEnv *env, int argc, const ERL_N /* RequestId is argv[2] - can be any term */ ERL_NIF_TERM request_id = argv[2]; - /* Worker thread mode: dispatch async */ - if (ctx->uses_worker_thread) { + /* Dedicated thread (worker or OWN_GIL): dispatch async */ + if (ctx_uses_async_thread(ctx)) { /* Build request tuple: {Module, Func, Args, Kwargs} */ ERL_NIF_TERM kwargs = (argc > 6 && enif_is_map(env, argv[6])) ? argv[6] : enif_make_new_map(env); @@ -5397,8 +5459,8 @@ static ERL_NIF_TERM nif_context_eval_async(ErlNifEnv *env, int argc, const ERL_N /* RequestId is argv[2] - can be any term */ ERL_NIF_TERM request_id = argv[2]; - /* Worker thread mode: dispatch async */ - if (ctx->uses_worker_thread) { + /* Dedicated thread (worker or OWN_GIL): dispatch async */ + if (ctx_uses_async_thread(ctx)) { /* Build request tuple: {Code, Locals} */ ERL_NIF_TERM locals = (argc > 4 && enif_is_map(env, argv[4])) ? argv[4] : enif_make_new_map(env); @@ -5443,8 +5505,8 @@ static ERL_NIF_TERM nif_context_exec_async(ErlNifEnv *env, int argc, const ERL_N /* RequestId is argv[2] - can be any term */ ERL_NIF_TERM request_id = argv[2]; - /* Worker thread mode: dispatch async */ - if (ctx->uses_worker_thread) { + /* Dedicated thread (worker or OWN_GIL): dispatch async */ + if (ctx_uses_async_thread(ctx)) { return dispatch_to_worker_thread_async(env, ctx, CTX_REQ_EXEC, argv[3], caller_pid, request_id, NULL); } @@ -5487,7 +5549,7 @@ static ERL_NIF_TERM nif_context_call_with_env_async(ErlNifEnv *env, int argc, return make_error(env, "invalid_env"); } - if (!ctx->uses_worker_thread) { + if (!ctx_uses_async_thread(ctx)) { return make_error(env, "async_requires_worker_thread"); } @@ -5532,7 +5594,7 @@ static ERL_NIF_TERM nif_context_eval_with_env_async(ErlNifEnv *env, int argc, return make_error(env, "invalid_env"); } - if (!ctx->uses_worker_thread) { + if (!ctx_uses_async_thread(ctx)) { return make_error(env, "async_requires_worker_thread"); } @@ -5573,7 +5635,7 @@ static ERL_NIF_TERM nif_context_exec_with_env_async(ErlNifEnv *env, int argc, return make_error(env, "invalid_env"); } - if (!ctx->uses_worker_thread) { + if (!ctx_uses_async_thread(ctx)) { return make_error(env, "async_requires_worker_thread"); } @@ -8146,6 +8208,7 @@ static ErlNifFunc nif_funcs[] = { /* FD lifecycle management (uvloop-like API) */ {"handle_fd_event", 2, nif_handle_fd_event, 0}, {"handle_fd_event_and_reselect", 2, nif_handle_fd_event_and_reselect, 0}, + {"fd_arm", 2, nif_fd_arm, 0}, {"stop_reader", 1, nif_stop_reader, 0}, {"start_reader", 1, nif_start_reader, 0}, {"stop_writer", 1, nif_stop_writer, 0}, diff --git a/c_src/py_nif.h b/c_src/py_nif.h index 002418a..4f2e37c 100644 --- a/c_src/py_nif.h +++ b/c_src/py_nif.h @@ -1012,6 +1012,10 @@ struct py_context { /** @brief Interpreter state for OWN_GIL subinterpreter */ PyInterpreterState *own_gil_interp; + + /** @brief Default ErlangEventLoop of the subinterpreter (kept resource, + * set by the context thread, read by nif_context_get_event_loop) */ + void *event_loop; #else /** @brief Worker thread state (non-subinterp mode, kept for compatibility) */ PyThreadState *thread_state; diff --git a/docs/asyncio.md b/docs/asyncio.md index 28b30e2..b4f2990 100644 --- a/docs/asyncio.md +++ b/docs/asyncio.md @@ -1479,6 +1479,7 @@ process_batch(Items) -> ## See Also +- [Worker Loops](workers.md) - Run the loop forever in a context, inject coroutines from Erlang, serve on sockets Erlang owns - [Reactor](reactor.md) - Low-level FD-based protocol handling - [Security](security.md) - Sandbox and blocked operations - [Threading](threading.md) - For `erlang.async_call()` in asyncio contexts diff --git a/docs/interrupts.md b/docs/interrupts.md index 63ecb65..84d4465 100644 --- a/docs/interrupts.md +++ b/docs/interrupts.md @@ -75,3 +75,9 @@ ok = py_context:destroy(Ctx). context that just finished one call and started another stops the new one. - `py:call/3,4` and `py:eval/1,2` use `infinity` by default. Pass an explicit timeout, or use `py:interrupt/1`, if you need a bound. +- A context running a worker loop (`py_context:start_loop/1`) refuses + `call/eval/exec` with `{error, loop_running}` for this reason: a timed-out + call would interrupt the loop. `py_context:interrupt/1` on such a context + ends the loop with `{py_loop_exit, Ctx, {error, interrupted}}`; use + `py_context:stop_loop/1,2` for a cooperative stop. See [Worker + Loops](workers.md). diff --git a/docs/owngil_internals.md b/docs/owngil_internals.md index 03ee606..8d3b2e5 100644 --- a/docs/owngil_internals.md +++ b/docs/owngil_internals.md @@ -46,8 +46,9 @@ All major erlang_python features work with OWN_GIL mode: | PIDs (`erlang.Pid`) | Full | Round-trip serialization | | Send (`erlang.send`) | Full | Fire-and-forget messaging | | Reactor (`erlang.reactor`) | Full | FD-based protocols | -| Async Tasks | Full | `py_event_loop:create_task` | -| Asyncio | Full | `asyncio.sleep`, `gather`, etc. | +| Async Tasks | Full | `py_event_loop:create_task`, `py_context:submit` | +| Asyncio | Full | Own `ErlangEventLoop` per context: `erlang.run`, `create_server`, channels | +| Worker loops | Full | `py_context:start_loop`, see [Worker Loops](workers.md) | | Process-local envs | Full | Namespace isolation | ## Architecture @@ -302,7 +303,9 @@ py_env_resource_dtor(env, res) { ## Reactor / Event Loop Integration -OWN_GIL contexts support the reactor pattern for I/O-driven protocols. The `py_event_loop` module is registered in each OWN_GIL subinterpreter during startup. +OWN_GIL contexts support the reactor pattern for I/O-driven protocols. The `py_event_loop` module is registered in each OWN_GIL subinterpreter during startup, together with a default `ErlangEventLoop` for that interpreter. `py_context` starts a dedicated `py_event_worker` per OWN_GIL context and points that loop at it, so fd readiness and timers of one context never go through another context's process or through the main loop's worker. + +Requests to the OWN_GIL thread go through the same async queue as worker mode (`context_*_async` NIFs): the calling Erlang process waits in a `receive`, no dirty scheduler is held and there is no 30 s cap on a call. From an Erlang scheduler, `process_ready_tasks` attaches a temporary thread state to the subinterpreter to inject coroutines into its loop (`py_context:submit`); the context thread waits for those to detach before `Py_EndInterpreter`. ### Why Event Loop Registration Matters diff --git a/docs/workers.md b/docs/workers.md new file mode 100644 index 0000000..85fea63 --- /dev/null +++ b/docs/workers.md @@ -0,0 +1,192 @@ +# Worker Loops + +This guide covers running a long-lived asyncio event loop inside a Python +context and driving it from Erlang: starting and stopping the loop, injecting +coroutines into it, and serving TCP or UDP on sockets that Erlang owns. You +need it when you want Python servers or background async work to run as +supervised workers inside the VM, the way gunicorn runs worker processes, with +Erlang as the arbiter. + +## What a worker loop is + +An owngil `py_context` has its own interpreter, its own GIL, its own thread and +its own `ErlangEventLoop`. `py_context:start_loop/1` runs that loop forever on +the context thread. From then on: + +- `py_context:submit/4,5` schedules a coroutine or a plain function on the loop + and returns a task reference; the result arrives as `{async_result, TaskRef, + {ok, Value} | {error, Reason}}` (use `py_event_loop:await/1,2` or + `submit_await/4,5,6`). +- fds registered by the loop (servers, connections, channels) are served by + the loop thread; readiness comes through the context's own `py_event_worker` + process. +- `py_context:stop_loop/1,2` stops it from inside, or interrupts the thread if + it does not exit within the grace period. The owner receives + `{py_loop_exit, Ctx, Result}` when the loop ends, for any reason. + +Worker contexts get the same API on the shared main interpreter loop, which +allows one running `ErlangEventLoop` per interpreter: use owngil (Python +3.14+) for several workers. + +## Serve TCP on a socket Erlang owns + +Bind once in Erlang, duplicate the listen fd for each worker with +`py:dup_fd/1`, and let each worker accept on its copy. + +```python +# myapp.py, importable by the workers +import asyncio +import erlang + +class Echo(asyncio.Protocol): + def connection_made(self, transport): + self.transport = transport + + def data_received(self, data): + self.transport.write(data) + +async def serve(listen_fd): + server = await erlang.server.serve(listen_fd, Echo) + return 'serving' +``` + +```erlang +{ok, LSock} = gen_tcp:listen(8000, [binary, {reuseaddr, true}, {backlog, 1024}]), +{ok, LFd} = inet:getfd(LSock), + +Workers = [begin + {ok, W} = py_context:new(#{mode => owngil, preload => <<"import myapp">>}), + ok = py_context:start_loop(W), + {ok, Dup} = py:dup_fd(LFd), + {ok, <<"serving">>} = py_context:submit_await(W, myapp, serve, [Dup]), + W +end || _ <- lists:seq(1, 4)]. +``` + +Every worker now accepts on the same socket; the kernel hands each connection +to one of them. Erlang keeps the listen socket open across worker restarts, so +replacing a worker never closes the port. + +For UDP, open the socket with `gen_udp`, dup its fd the same way and call +`erlang.server.serve(fd, MyDatagramProtocol, udp=True)`; on Linux use one +`SO_REUSEPORT` socket per worker instead of one shared fd, so the kernel +spreads flows. + +## Hand over one connection at a time + +When Erlang wants to decide per connection (routing, tenancy, or keeping some +connections in Erlang), accept in Erlang and adopt the fd in the worker. + +```python +async def adopt(fd): + await erlang.server.adopt(fd, Echo) + return 'adopted' +``` + +```erlang +{ok, Conn} = gen_tcp:accept(LSock), +{ok, Fd} = inet:getfd(Conn), +{ok, Dup} = py:dup_fd(Fd), +{ok, <<"adopted">>} = py_context:submit_await(Worker, myapp, adopt, [Dup]), +gen_tcp:close(Conn). %% Python owns the dup, Erlang drops its copy +``` + +## Inject work into a running loop + +`submit` targets module level functions (`Module:Func`), imported in the +worker (put entry points in a module, or register one in `sys.modules` from +`preload`). Coroutine functions are awaited, plain functions are called. + +```erlang +{ok, Ref} = py_context:submit(W, myapp, refresh_cache, [Key]), +%% ... other work ... +{ok, Result} = py_event_loop:await(Ref, 5000). + +%% or in one step +{ok, Result} = py_context:submit_await(W, myapp, refresh_cache, [Key], #{}, 5000). +``` + +Task start failures come back as errors, not silence: `{error, +function_not_found}` for a missing module or function, `{error, +args_conversion_failed}`, or the Python exception if the call itself raised. + +## Control from Erlang without submit + +A `py_channel` awaited inside the loop delivers Erlang messages to the running +loop with no polling; use it as the control plane of a worker (adopt this fd, +drain, report stats): + +```python +async def control(channel_ref): + ch = erlang.Channel(channel_ref) + async for msg in ch: + match msg: + case ('adopt', fd): + await erlang.server.adopt(fd, Echo) + case ('stop',): + return 'stopped' +``` + +```erlang +{ok, Ch} = py_channel:new(), +{ok, _} = py_context:submit(W, myapp, control, [Ch]), +ok = py_channel:send(Ch, {adopt, Dup}). +``` + +## Stop, restart, supervise + +```erlang +ok = py_context:stop_loop(W, 5000), %% cooperative, interrupt after 5 s +receive {py_loop_exit, W, Result} -> Result end, +ok = py_context:start_loop(W). %% same context, fresh loop +``` + +- `stop_loop` returns `ok` once the loop has exited, `{error, no_loop}` when + none runs, `{error, timeout}` if it survived the interrupt. +- `py_context:interrupt/1` ends the loop at once with `{py_loop_exit, W, + {error, interrupted}}`; a loop blocked in a C call (`time.sleep`, a numpy + kernel) exits when that call returns. +- `py_context:stop/1` on a looping context interrupts the loop first, then + destroys the context. +- If the owner process (the caller of `start_loop`, or `#{owner => Pid}`) dies, + the loop is stopped. +- Put workers under your own supervisor. `py_context` processes are + `temporary` under `py_context_sup`; a restart is `py_context:new/1` again, + `start_loop`, and a new dup of the listen fd. Use `memory_limit` in + `py_context:new/1` to cap a worker, and `py_nif:context_memory_usage/1` to + watch it. + +## Rules + +- While a loop runs, `py_context:call/eval/exec/call_method` on that context + return `{error, loop_running}`. The thread is busy in the loop, and a call + that timed out would interrupt it. Use `submit`. +- Pass fds you may close: `py:dup_fd/1` copies, `serve` and `adopt` wrap the + fd in a socket that owns it and close it when done. Never hand the original + fd of a live `gen_tcp` socket. +- One running `ErlangEventLoop` per interpreter: worker mode supports one + worker loop, owngil one per context. +- Modules used by `submit` must be importable in the worker; the `exec` + namespace of the context is not searched. + +## Numbers + +`examples/bench_worker_loop.erl` (Apple M4 Pro, OTP 29, Python 3.14, gen_tcp +clients in the same VM): + +| Measure | Result | +|---|---| +| connect + echo + close, one worker | about 15 000 conn/s | +| keep-alive echo, one worker, 50 connections | about 70 000 req/s | +| connect + echo + close, 2 to 8 workers on one listen fd | about 23 000 conn/s (client bound) | +| `submit_await` coroutine, one caller | about 25 us round trip | +| `submit_await` coroutine, 100 callers | about 4 us per op, 250 000 ops/s | +| `py_context:call` on an idle owngil context | about 12 us | +| Erlang accept + adopt vs Python accept | 13 500 vs 14 500 conn/s | + +## See also + +- [Asyncio](asyncio.md) for the ErlangEventLoop itself +- [Channels](channel.md) for the control plane +- [Interrupts](interrupts.md) for what an interrupt does to a loop +- [OWN_GIL Internals](owngil_internals.md) for the thread and interpreter model diff --git a/examples/bench_worker_loop.erl b/examples/bench_worker_loop.erl new file mode 100644 index 0000000..ae51dda --- /dev/null +++ b/examples/bench_worker_loop.erl @@ -0,0 +1,264 @@ +#!/usr/bin/env escript +%% -*- erlang -*- +%%! -pa _build/default/lib/erlang_python/ebin + +%%% @doc Benchmark for worker loops (py_context:start_loop/submit, erlang.server). +%%% +%%% Measures, on OWN_GIL contexts: +%%% 1. connections/s and requests/s of an echo server on one worker loop +%%% 2. scaling with 1, 2, 4, 8 workers accepting on one listen fd +%%% 3. submit round trip latency into a running loop, 1/10/100 callers, +%%% against py_context:call on an idle context +%%% 4. adopt (Erlang accepts, hands the fd over) vs Python side accept +%%% +%%% Clients are plain gen_tcp sockets in this VM, so the numbers include the +%%% client cost; use them to compare shapes, not as absolute server figures. +%%% +%%% Run with: +%%% rebar3 compile && escript examples/bench_worker_loop.erl + +-mode(compile). + +-define(HOST, {127, 0, 0, 1}). + +-define(PY, <<" +import asyncio, erlang, sys, types +m = types.ModuleType('bench_wl'); sys.modules['bench_wl'] = m + +class Echo(asyncio.Protocol): + def connection_made(self, t): self.t = t + def data_received(self, d): self.t.write(d) + +class EchoClose(asyncio.Protocol): + def connection_made(self, t): self.t = t + def data_received(self, d): self.t.write(d); self.t.close() + +_servers = {} +async def serve(fd, keepalive): + srv = await erlang.server.serve(fd, Echo if keepalive else EchoClose) + _servers[fd] = srv + return 'ok' +async def adopt(fd): + await erlang.server.adopt(fd, EchoClose) + return 'ok' +async def noop(): + return 1 +def sync_noop(): + return 1 +m.serve = serve; m.adopt = adopt; m.noop = noop; m.sync_noop = sync_noop +">>). + +main(_Args) -> + io:format("~n"), + io:format("========================================================~n"), + io:format(" Worker loop benchmark~n"), + io:format("========================================================~n~n"), + {ok, _} = application:ensure_all_started(erlang_python), + print_system_info(), + case py_nif:owngil_supported() of + true -> + bench_single_worker(), + bench_scaling(), + bench_submit_latency(), + bench_adopt_vs_accept(); + false -> + io:format("~n[ERROR] worker loops need OWN_GIL (Python 3.14+)~n~n") + end, + halt(0). + +print_system_info() -> + io:format("System Information~n"), + io:format("------------------~n"), + io:format(" Erlang/OTP: ~s~n", [erlang:system_info(otp_release)]), + io:format(" Schedulers: ~p~n", [erlang:system_info(schedulers)]), + {ok, PyVer} = py:version(), + io:format(" Python: ~s~n", [PyVer]), + io:format("~n"). + +%% ============================================================================ +%% 1. Single worker: connections/s (connect, echo, close) and requests/s on +%% keep-alive connections +%% ============================================================================ + +bench_single_worker() -> + io:format("1. Single worker echo server~n"), + W = worker(), + {LSock, Port, LFd} = listen(), + {ok, Dup} = py:dup_fd(LFd), + {ok, <<"ok">>} = py_context:submit_await(W, bench_wl, serve, [Dup, false]), + N = 5000, + {Ms, Fails} = timed(fun() -> parallel_conns(Port, N, 50) end), + io:format(" connect+echo+close: ~p conns in ~p ms = ~p conn/s (failed ~p)~n", + [N, Ms, N * 1000 div max(1, Ms), Fails]), + ok = py_context:stop_loop(W), + py_context:stop(W), + gen_tcp:close(LSock), + + W2 = worker(), + {LSock2, Port2, LFd2} = listen(), + {ok, Dup2} = py:dup_fd(LFd2), + {ok, <<"ok">>} = py_context:submit_await(W2, bench_wl, serve, [Dup2, true]), + Conns = 50, + Reqs = 20000, + {Ms2, _} = timed(fun() -> keepalive_requests(Port2, Conns, Reqs div Conns) end), + io:format(" keep-alive echo: ~p reqs on ~p conns in ~p ms = ~p req/s~n", + [Reqs, Conns, Ms2, Reqs * 1000 div max(1, Ms2)]), + ok = py_context:stop_loop(W2), + py_context:stop(W2), + gen_tcp:close(LSock2), + io:format("~n"). + +%% ============================================================================ +%% 2. Scaling: N workers accepting on one listen fd +%% ============================================================================ + +bench_scaling() -> + io:format("2. Scaling: connect+echo+close, workers accepting on one listen fd~n"), + io:format(" ~-8s ~12s ~12s~n", ["workers", "conn/s", "ms"]), + N = 8000, + lists:foreach(fun(Workers) -> + Ws = [worker() || _ <- lists:seq(1, Workers)], + {LSock, Port, LFd} = listen(), + [begin + {ok, Dup} = py:dup_fd(LFd), + {ok, <<"ok">>} = py_context:submit_await(W, bench_wl, serve, [Dup, false]) + end || W <- Ws], + {Ms, _} = timed(fun() -> parallel_conns(Port, N, 100) end), + io:format(" ~-8w ~12w ~12w~n", [Workers, N * 1000 div max(1, Ms), Ms]), + [ok = py_context:stop_loop(W) || W <- Ws], + [py_context:stop(W) || W <- Ws], + gen_tcp:close(LSock) + end, [1, 2, 4, 8]), + io:format("~n"). + +%% ============================================================================ +%% 3. submit latency vs py_context:call +%% ============================================================================ + +bench_submit_latency() -> + io:format("3. Round trip latency~n"), + io:format(" ~-52s ~10s ~12s~n", ["path", "us/op", "ops/s"]), + {ok, Idle} = py_context:new(#{mode => owngil, preload => ?PY}), + N = 5000, + {MsCall, _} = timed(fun() -> + [{ok, 1} = py_context:call(Idle, bench_wl, sync_noop, []) || _ <- lists:seq(1, N)] + end), + row("py_context:call, idle owngil ctx", N, MsCall), + {MsSubIdle, _} = timed(fun() -> + [{ok, 1} = py_context:submit_await(Idle, bench_wl, sync_noop, []) || _ <- lists:seq(1, N)] + end), + row("submit_await sync fn, idle ctx", N, MsSubIdle), + py_context:stop(Idle), + + W = worker(), + lists:foreach(fun(Callers) -> + Per = N div Callers, + {Ms, _} = timed(fun() -> + Self = self(), + [spawn_link(fun() -> + [{ok, 1} = py_context:submit_await(W, bench_wl, noop, []) || _ <- lists:seq(1, Per)], + Self ! done + end) || _ <- lists:seq(1, Callers)], + [receive done -> ok end || _ <- lists:seq(1, Callers)] + end), + row(io_lib:format("submit_await coroutine, running loop, ~p callers", [Callers]), N, Ms) + end, [1, 10, 100]), + ok = py_context:stop_loop(W), + py_context:stop(W), + io:format("~n"). + +row(Label, N, Ms) -> + Us = Ms * 1000 / max(1, N), + io:format(" ~-52s ~10.1f ~12w~n", [Label, Us, N * 1000 div max(1, Ms)]). + +%% ============================================================================ +%% 4. adopt (Erlang accepts) vs Python accept +%% ============================================================================ + +bench_adopt_vs_accept() -> + io:format("4. Per connection: Erlang accept + adopt vs Python accept~n"), + N = 3000, + W = worker(), + {LSock, Port, LFd} = listen(), + {ok, Dup} = py:dup_fd(LFd), + {ok, <<"ok">>} = py_context:submit_await(W, bench_wl, serve, [Dup, false]), + {MsPy, _} = timed(fun() -> parallel_conns(Port, N, 50) end), + io:format(" Python accept: ~p conn/s~n", [N * 1000 div max(1, MsPy)]), + ok = py_context:stop_loop(W), + py_context:stop(W), + gen_tcp:close(LSock), + + W2 = worker(), + {LSock2, Port2, _} = listen(), + Acceptor = spawn_link(fun() -> acceptor(LSock2, W2) end), + {MsAd, _} = timed(fun() -> parallel_conns(Port2, N, 50) end), + io:format(" Erlang accept+adopt: ~p conn/s~n", [N * 1000 div max(1, MsAd)]), + unlink(Acceptor), exit(Acceptor, kill), + ok = py_context:stop_loop(W2), + py_context:stop(W2), + gen_tcp:close(LSock2), + io:format("~n"). + +acceptor(LSock, W) -> + case gen_tcp:accept(LSock) of + {ok, Conn} -> + {ok, Fd} = inet:getfd(Conn), + {ok, Dup} = py:dup_fd(Fd), + _ = py_context:submit(W, bench_wl, adopt, [Dup]), + gen_tcp:close(Conn), + acceptor(LSock, W); + _ -> + ok + end. + +%% ============================================================================ +%% Helpers +%% ============================================================================ + +worker() -> + {ok, W} = py_context:new(#{mode => owngil, preload => ?PY}), + ok = py_context:start_loop(W), + W. + +listen() -> + {ok, LSock} = gen_tcp:listen(0, [binary, {ip, ?HOST}, {active, false}, {backlog, 1024}]), + {ok, Port} = inet:port(LSock), + {ok, LFd} = inet:getfd(LSock), + {LSock, Port, LFd}. + +timed(Fun) -> + T0 = erlang:monotonic_time(millisecond), + R = Fun(), + {erlang:monotonic_time(millisecond) - T0, R}. + +parallel_conns(Port, N, Clients) -> + Self = self(), + Per = N div Clients, + [spawn_link(fun() -> + Fails = length([bad || _ <- lists:seq(1, Per), roundtrip(Port) =/= ok]), + Self ! {done, Fails} + end) || _ <- lists:seq(1, Clients)], + lists:sum([receive {done, F} -> F after 120000 -> Per end || _ <- lists:seq(1, Clients)]). + +roundtrip(Port) -> + case gen_tcp:connect(?HOST, Port, [binary, {active, false}], 5000) of + {ok, S} -> + ok = gen_tcp:send(S, <<"x">>), + R = gen_tcp:recv(S, 0, 5000), + gen_tcp:close(S), + case R of {ok, <<"x">>} -> ok; _ -> bad end; + _ -> + bad + end. + +keepalive_requests(Port, Conns, PerConn) -> + Self = self(), + [spawn_link(fun() -> + {ok, S} = gen_tcp:connect(?HOST, Port, [binary, {active, false}], 5000), + [begin ok = gen_tcp:send(S, <<"ping">>), {ok, <<"ping">>} = gen_tcp:recv(S, 4, 5000) end + || _ <- lists:seq(1, PerConn)], + gen_tcp:close(S), + Self ! done + end) || _ <- lists:seq(1, Conns)], + [receive done -> ok after 120000 -> ok end || _ <- lists:seq(1, Conns)], + ok. diff --git a/priv/_erlang_impl/__init__.py b/priv/_erlang_impl/__init__.py index 05abea5..0b69096 100644 --- a/priv/_erlang_impl/__init__.py +++ b/priv/_erlang_impl/__init__.py @@ -65,6 +65,7 @@ from . import _reactor as reactor from . import _channel as channel from . import _byte_channel as byte_channel +from . import _server as server from ._channel import Channel, reply, ChannelClosed from ._byte_channel import ByteChannel, ByteChannelClosed @@ -88,6 +89,7 @@ 'byte_channel', 'ByteChannel', 'ByteChannelClosed', + 'server', 'atom', ] @@ -165,6 +167,42 @@ def new_event_loop() -> ErlangEventLoop: return ErlangEventLoop() +def _run_loop_forever(notify_pid=None): + """Run an ErlangEventLoop on this thread until it is stopped. + + Entry point of py_context:start_loop/1: the context thread stays here + while Erlang injects coroutines with py_context:submit/4 (or the fd + events fire). notify_pid receives {py_loop_started} once the loop is + running. Returns 'stopped' when _stop_loop() ran, propagates + KeyboardInterrupt when py_context:interrupt/1 was used. + """ + loop = new_event_loop() + asyncio.set_event_loop(loop) + if notify_pid is not None: + import erlang as _erlang + + def _started(): + try: + _erlang.send(notify_pid, (atom('py_loop_started'),)) + except Exception: + pass + loop.call_soon(_started) + try: + loop.run_forever() + finally: + try: + asyncio.set_event_loop(None) + finally: + loop.close() + return 'stopped' + + +async def _stop_loop(): + """Stop the loop running _run_loop_forever() from inside it.""" + asyncio.get_running_loop().stop() + return 'stopping' + + def run(main, *, debug=None, **run_kwargs): """Run a coroutine using Erlang event loop. diff --git a/priv/_erlang_impl/_loop.py b/priv/_erlang_impl/_loop.py index 8be028b..c43ab30 100644 --- a/priv/_erlang_impl/_loop.py +++ b/priv/_erlang_impl/_loop.py @@ -314,11 +314,18 @@ def close(self): self._timer_refs.clear() self._handle_to_callback_id.clear() - # Remove all readers/writers + # Remove all readers/writers, then release fd resources kept alive by + # _stop_reading/_stop_writing for transports that were never closed for fd in list(self._readers.keys()): self.remove_reader(fd) for fd in list(self._writers.keys()): self.remove_writer(fd) + for fd, fd_key in list(self._fd_resources.items()): + try: + self._pel._release_fd_resource(fd_key) + except Exception: + pass + self._fd_resources.clear() # Clear signal handlers self._signal_handlers.clear() @@ -590,6 +597,76 @@ def remove_writer(self, fd): return True + # ------------------------------------------------------------------------ + # Transport helpers: stop reading/writing without giving up the fd + # resource, and hand the socket to the NIF for closing. + # + # remove_reader/remove_writer release the fd resource as soon as neither + # side is active, which issues ERL_NIF_SELECT_STOP. If the socket is then + # closed from Python before the stop completes, the fd sits in the poll + # set while its number gets reused. Transports therefore only clear the + # callbacks here and let _close_socket transfer the fd to the NIF, which + # closes it from the stop callback. + # ------------------------------------------------------------------------ + + def _stop_reading(self, fd): + entry = self._readers.pop(fd, None) + if entry is None: + return False + self._callbacks_by_cid.pop(entry[2], None) + fd_key = self._fd_resources.get(fd) + if fd_key is not None: + try: + self._pel._clear_fd_read(fd_key) + except Exception: + pass + return True + + def _stop_writing(self, fd): + entry = self._writers.pop(fd, None) + if entry is None: + return False + self._callbacks_by_cid.pop(entry[2], None) + fd_key = self._fd_resources.get(fd) + if fd_key is not None: + try: + self._pel._clear_fd_write(fd_key) + except Exception: + pass + return True + + def _close_socket(self, sock): + """Close a socket that may still be registered with enif_select. + + Clears any reader/writer, detaches the fd from the socket object and + lets the NIF close it once the select stop has completed. Sockets the + loop never registered are closed directly. + """ + try: + fd = sock.fileno() + except (OSError, ValueError): + return + if fd is None or fd < 0: + return + self._stop_reading(fd) + self._stop_writing(fd) + fd_key = self._fd_resources.pop(fd, None) + if fd_key is None: + sock.close() + return + try: + sock.detach() + except OSError: + pass + try: + self._pel._release_fd_resource(fd_key, True) + except Exception: + # NIF unavailable (mock module or shutdown): close it ourselves + try: + os.close(fd) + except OSError: + pass + # ======================================================================== # Socket operations # ======================================================================== @@ -1230,6 +1307,9 @@ def __init__(self): class _MockNifModule: """Mock NIF module for testing without actual Erlang integration.""" + def __init__(self): + self._fd_by_key = {} + def _is_initialized(self): return True @@ -1260,6 +1340,7 @@ def _wakeup_for(self, capsule): def _add_reader_for(self, capsule, fd, callback_id): capsule._counter += 1 capsule.readers[fd] = (callback_id, capsule._counter) + self._fd_by_key[capsule._counter] = fd return capsule._counter def _remove_reader_for(self, capsule, fd_key): @@ -1271,6 +1352,7 @@ def _remove_reader_for(self, capsule, fd_key): def _add_writer_for(self, capsule, fd, callback_id): capsule._counter += 1 capsule.writers[fd] = (callback_id, capsule._counter) + self._fd_by_key[capsule._counter] = fd return capsule._counter def _remove_writer_for(self, capsule, fd_key): @@ -1295,9 +1377,16 @@ def _clear_fd_write(self, fd_key): """Clear write monitoring on fd_resource.""" pass - def _release_fd_resource(self, fd_key): - """Release fd_resource.""" - pass + def _release_fd_resource(self, fd_key, take_ownership=False): + """Release fd_resource. With take_ownership the fd is closed here, + since there is no NIF to close it from a select stop callback.""" + if take_ownership: + fd = self._fd_by_key.pop(fd_key, None) + if fd is not None: + try: + os.close(fd) + except OSError: + pass def _schedule_timer_for(self, capsule, delay_ms, callback_id): return callback_id diff --git a/priv/_erlang_impl/_server.py b/priv/_erlang_impl/_server.py new file mode 100644 index 0000000..2a6ccf5 --- /dev/null +++ b/priv/_erlang_impl/_server.py @@ -0,0 +1,82 @@ +# Copyright 2026 Benoit Chesneau +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Serve on fds handed over by Erlang. + +Erlang owns the listen socket (gen_tcp or the socket module), duplicates its +fd with py:dup_fd/1 for each worker context, and each worker calls serve() +on its copy from a coroutine scheduled with py_context:submit/4. Accepted +connections can also be handed over one by one with adopt(). + + async def main(listen_fd): + server = await erlang.server.serve(listen_fd, EchoProtocol) + await server.serve_forever() + +The fd passed in must be one this interpreter may close: serve() and adopt() +wrap it in a socket object that owns it. +""" + +import asyncio +import socket + +__all__ = ['serve', 'adopt', 'stop_serving'] + + +def _socket_from_fd(fd, *, udp=False): + """Wrap an fd owned by Python in a non-blocking socket object.""" + if not isinstance(fd, int) or fd < 0: + raise ValueError(f"invalid fd: {fd!r}") + kind = socket.SOCK_DGRAM if udp else socket.SOCK_STREAM + try: + sock = socket.socket(fileno=fd) + except OSError as exc: + raise OSError(exc.errno, f"cannot adopt fd {fd}: {exc.strerror}") from exc + if sock.type != kind: + sock.detach() + raise ValueError(f"fd {fd} is not a {'datagram' if udp else 'stream'} socket") + sock.setblocking(False) + return sock + + +async def serve(listen_fd, protocol_factory, *, udp=False, backlog=100): + """Serve on a listen fd (TCP/Unix) or a bound datagram fd (UDP). + + Returns an asyncio Server for stream sockets, or the datagram transport + for udp=True. The caller keeps the loop alive (serve_forever, or the + loop started with py_context:start_loop/1). + """ + loop = asyncio.get_running_loop() + sock = _socket_from_fd(listen_fd, udp=udp) + if udp: + transport, _protocol = await loop.create_datagram_endpoint( + protocol_factory, sock=sock) + return transport + return await loop.create_server(protocol_factory, sock=sock, backlog=backlog) + + +async def adopt(fd, protocol_factory): + """Take over an accepted connection whose fd Erlang handed to us. + + Returns (transport, protocol) like loop.create_connection. + """ + loop = asyncio.get_running_loop() + sock = _socket_from_fd(fd) + return await loop.create_connection(protocol_factory, sock=sock) + + +async def stop_serving(server, *, wait_closed=True): + """Stop accepting on a Server or close a datagram transport.""" + server.close() + if wait_closed and hasattr(server, 'wait_closed'): + await server.wait_closed() diff --git a/priv/_erlang_impl/_transport.py b/priv/_erlang_impl/_transport.py index 8f61000..6bac533 100644 --- a/priv/_erlang_impl/_transport.py +++ b/priv/_erlang_impl/_transport.py @@ -69,6 +69,16 @@ async def _start(self): self._loop.call_soon(self._protocol.connection_made, self) self._loop.add_reader(self._fileno, self._read_ready) + def __del__(self): + # An abandoned transport must still hand its fd back to the loop, so + # the enif_select registration is stopped before the number is reused. + sock = getattr(self, '_sock', None) + if sock is not None and sock.fileno() >= 0: + try: + self._loop._close_socket(sock) + except Exception: + pass + # Maximum reads per callback to avoid starving other events _max_reads_per_call = 16 @@ -105,7 +115,7 @@ def _read_ready(self): self._protocol.data_received(data) else: # Connection closed (EOF received) - self._loop.remove_reader(self._fileno) + self._loop._stop_reading(self._fileno) keep_open = self._protocol.eof_received() # If eof_received returns False/None, close the transport if not keep_open: @@ -159,7 +169,7 @@ def _write_ready_cb(self): for _ in range(self._max_writes_per_call): remaining = len(self._buffer) - self._buffer_offset if remaining <= 0: - self._loop.remove_writer(self._fileno) + self._loop._stop_writing(self._fileno) if self._closing: self._call_connection_lost(None) return @@ -176,11 +186,11 @@ def _write_ready_cb(self): if exc.errno == errno.EBADF: self._conn_lost += 1 return - self._loop.remove_writer(self._fileno) + self._loop._stop_writing(self._fileno) self._fatal_error(exc, 'Fatal write error') return except Exception as exc: - self._loop.remove_writer(self._fileno) + self._loop._stop_writing(self._fileno) self._fatal_error(exc, 'Fatal write error') return @@ -192,7 +202,7 @@ def _write_ready_cb(self): # Reset buffer when fully consumed self._buffer = self._buffer_factory() self._buffer_offset = 0 - self._loop.remove_writer(self._fileno) + self._loop._stop_writing(self._fileno) if self._closing: self._call_connection_lost(None) @@ -203,7 +213,7 @@ def write_eof(self): self._closing = True # Check if no pending data (buffer fully consumed) if self._buffer_offset >= len(self._buffer): - self._loop.remove_reader(self._fileno) + self._loop._stop_reading(self._fileno) self._call_connection_lost(None) def can_write_eof(self): @@ -214,7 +224,7 @@ def close(self): if self._closing: return self._closing = True - self._loop.remove_reader(self._fileno) + self._loop._stop_reading(self._fileno) # Check if no pending data (buffer fully consumed) if self._buffer_offset >= len(self._buffer): self._conn_lost += 1 @@ -229,8 +239,10 @@ def _call_connection_lost(self, exc): try: self._protocol.connection_lost(exc) finally: + # The fd may still be in the BEAM poll set: let the loop hand it + # to the NIF, which closes it once the select stop completes. try: - self._sock.close() + self._loop._close_socket(self._sock) except OSError: pass @@ -274,8 +286,8 @@ def abort(self): """Close immediately.""" self._closing = True self._conn_lost += 1 - self._loop.remove_reader(self._fileno) - self._loop.remove_writer(self._fileno) + self._loop._stop_reading(self._fileno) + self._loop._stop_writing(self._fileno) self._call_connection_lost(None) def pause_reading(self): @@ -283,7 +295,7 @@ def pause_reading(self): if self._closing or self._paused: return self._paused = True - self._loop.remove_reader(self._fileno) + self._loop._stop_reading(self._fileno) def resume_reading(self): """Resume reading from the transport.""" @@ -333,6 +345,14 @@ async def _start(self): self._protocol.connection_made(self) self._loop.add_reader(self._fileno, self._read_ready) + def __del__(self): + sock = getattr(self, '_sock', None) + if sock is not None and sock.fileno() >= 0: + try: + self._loop._close_socket(sock) + except Exception: + pass + def _read_ready(self): """Called when data is available to read. @@ -432,7 +452,7 @@ def _write_ready(self): self._buffer.popleft() - self._loop.remove_writer(self._fileno) + self._loop._stop_writing(self._fileno) if self._closing: self._call_connection_lost(None) @@ -441,7 +461,7 @@ def close(self): if self._closing: return self._closing = True - self._loop.remove_reader(self._fileno) + self._loop._stop_reading(self._fileno) if not self._buffer: self._conn_lost += 1 self._call_connection_lost(None) @@ -455,8 +475,10 @@ def _call_connection_lost(self, exc): try: self._protocol.connection_lost(exc) finally: + # The fd may still be in the BEAM poll set: let the loop hand it + # to the NIF, which closes it once the select stop completes. try: - self._sock.close() + self._loop._close_socket(self._sock) except OSError: pass @@ -487,8 +509,8 @@ def abort(self): """Close immediately.""" self._closing = True self._conn_lost += 1 - self._loop.remove_reader(self._fileno) - self._loop.remove_writer(self._fileno) + self._loop._stop_reading(self._fileno) + self._loop._stop_writing(self._fileno) self._buffer.clear() self._call_connection_lost(None) @@ -540,8 +562,7 @@ def close(self): return self._serving = False for sock in self._sockets: - self._loop.remove_reader(sock.fileno()) - sock.close() + self._loop._close_socket(sock) self._sockets.clear() # Wake up waiters diff --git a/priv/tests/test_loop_helpers.py b/priv/tests/test_loop_helpers.py new file mode 100644 index 0000000..e24b5bd --- /dev/null +++ b/priv/tests/test_loop_helpers.py @@ -0,0 +1,119 @@ +# Copyright 2026 Benoit Chesneau +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for the worker loop entry points behind py_context:start_loop/1. + +_run_loop_forever() runs an ErlangEventLoop on the calling thread until +_stop_loop() (a coroutine scheduled on it) stops it. Runs inside the BEAM +through tests.ct_runner in an owngil context (its own interpreter, no other +ErlangEventLoop alive), so it exercises the real py_event_loop module. +""" + +import asyncio +import threading +import unittest + + +def _impl(): + # _run_loop_forever looks up new_event_loop in the _erlang_impl namespace, + # so that is the module to hook + import _erlang_impl + return _erlang_impl + + +class TestLoopHelpers(unittest.TestCase): + + def test_run_forever_returns_after_stop(self): + impl = _impl() + started = threading.Event() + result = {} + + def runner(): + # The loop must be created on the running thread; grab it once + # it is running to schedule the stop + orig = impl.new_event_loop + + def new_loop_hook(): + loop = orig() + result['loop'] = loop + started.set() + return loop + impl.new_event_loop = new_loop_hook + try: + result['ret'] = impl._run_loop_forever() + finally: + impl.new_event_loop = orig + + t = threading.Thread(target=runner) + t.start() + self.assertTrue(started.wait(5)) + loop = result['loop'] + # give run_forever a moment to start + for _ in range(100): + if loop.is_running(): + break + threading.Event().wait(0.01) + self.assertTrue(loop.is_running()) + loop.call_soon_threadsafe(loop.create_task, impl._stop_loop()) + t.join(5) + self.assertFalse(t.is_alive()) + self.assertEqual(result['ret'], 'stopped') + self.assertTrue(loop.is_closed()) + # the current event loop is cleared, a new one can be created + with self.assertRaises(RuntimeError): + asyncio.get_running_loop() + loop2 = impl.new_event_loop() + loop2.close() + + def test_stop_loop_outside_loop_raises(self): + impl = _impl() + coro = impl._stop_loop() + with self.assertRaises(RuntimeError): + coro.send(None) # no running loop + coro.close() + + def test_second_loop_while_running_fails_cleanly(self): + """One running ErlangEventLoop per interpreter: a second creation + raises while the first runs, and works again once it has stopped.""" + impl = _impl() + loop = impl.new_event_loop() + stopped = threading.Event() + + def runner(): + try: + loop.run_forever() + finally: + stopped.set() + + t = threading.Thread(target=runner) + t.start() + for _ in range(100): + if loop.is_running(): + break + threading.Event().wait(0.01) + self.assertTrue(loop.is_running()) + try: + with self.assertRaises(RuntimeError): + impl.new_event_loop() + finally: + loop.call_soon_threadsafe(loop.stop) + t.join(5) + loop.close() + self.assertTrue(stopped.is_set()) + again = impl.new_event_loop() + again.close() + + +if __name__ == '__main__': + unittest.main() diff --git a/priv/tests/test_server.py b/priv/tests/test_server.py new file mode 100644 index 0000000..00d7ede --- /dev/null +++ b/priv/tests/test_server.py @@ -0,0 +1,168 @@ +# Copyright 2026 Benoit Chesneau +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for erlang.server (serve / adopt / stop_serving on handed-over fds). + +The fds come from sockets created here and detached, which is what +py:dup_fd/1 produces on the Erlang side: a descriptor Python may own. +""" + +import asyncio +import os +import socket +import unittest + +from . import _testbase as tb + + +def _server_module(): + try: + import erlang + if hasattr(erlang, 'server'): + return erlang.server + except ImportError: + pass + from _erlang_impl import _server + return _server + + +class Echo(asyncio.Protocol): + def connection_made(self, transport): + self.transport = transport + + def data_received(self, data): + self.transport.write(b'echo:' + data) + self.transport.close() + + +class _TestServe: + + def test_serve_tcp_on_listen_fd(self): + server_mod = _server_module() + lsock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + lsock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + lsock.bind(('127.0.0.1', 0)) + lsock.listen(16) + port = lsock.getsockname()[1] + fd = lsock.detach() # what py:dup_fd hands over + + async def main(): + server = await server_mod.serve(fd, Echo) + self.assertTrue(server.is_serving()) + client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + client.setblocking(False) + await self.loop.sock_connect(client, ('127.0.0.1', port)) + await self.loop.sock_sendall(client, b'hi') + data = await self.loop.sock_recv(client, 1024) + client.close() + await server_mod.stop_serving(server) + self.assertFalse(server.is_serving()) + return data + + self.assertEqual(self.loop.run_until_complete(main()), b'echo:hi') + + def test_serve_udp_on_bound_fd(self): + server_mod = _server_module() + usock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + usock.bind(('127.0.0.1', 0)) + port = usock.getsockname()[1] + fd = usock.detach() + got = [] + + class UDP(asyncio.DatagramProtocol): + def connection_made(self, transport): + self.transport = transport + + def datagram_received(self, data, addr): + got.append(data) + self.transport.sendto(b'udp:' + data, addr) + + async def main(): + transport = await server_mod.serve(fd, UDP, udp=True) + client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + client.setblocking(False) + client.sendto(b'ping', ('127.0.0.1', port)) + fut = self.loop.create_future() + + def on_ready(): + try: + fut.set_result(client.recv(1024)) + except BlockingIOError: + return + self.loop.remove_reader(client.fileno()) + + self.loop.add_reader(client.fileno(), on_ready) + data = await asyncio.wait_for(fut, 5) + client.close() + await server_mod.stop_serving(transport, wait_closed=False) + return data + + self.assertEqual(self.loop.run_until_complete(main()), b'udp:ping') + self.assertEqual(got, [b'ping']) + + def test_adopt_connected_fd(self): + server_mod = _server_module() + a, b = socket.socketpair() + fd = a.detach() + b.setblocking(False) + + async def main(): + transport, protocol = await server_mod.adopt(fd, Echo) + self.assertIsInstance(protocol, Echo) + await self.loop.sock_sendall(b, b'x') + data = await self.loop.sock_recv(b, 1024) + b.close() + return data + + self.assertEqual(self.loop.run_until_complete(main()), b'echo:x') + + def test_bad_fd_rejected(self): + server_mod = _server_module() + + async def main(): + with self.assertRaises(ValueError): + await server_mod.serve(-1, Echo) + with self.assertRaises(ValueError): + await server_mod.serve('nope', Echo) + with self.assertRaises(OSError): + await server_mod.serve(99999, Echo) + with self.assertRaises(ValueError): + await server_mod.adopt(-5, Echo) + return 'ok' + + self.assertEqual(self.loop.run_until_complete(main()), 'ok') + + def test_wrong_socket_type_rejected(self): + server_mod = _server_module() + usock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + usock.bind(('127.0.0.1', 0)) + fd = usock.detach() + + async def main(): + with self.assertRaises(ValueError): + await server_mod.serve(fd, Echo) # datagram fd, stream expected + return 'ok' + + try: + self.assertEqual(self.loop.run_until_complete(main()), 'ok') + finally: + os.close(fd) + + +class TestErlangServe(_TestServe, tb.ErlangTestCase): + """erlang.server on ErlangEventLoop.""" + + +class TestAIOServe(_TestServe, tb.AIOTestCase): + """erlang.server on the stdlib loop (portable helpers).""" diff --git a/priv/tests/test_transport_close.py b/priv/tests/test_transport_close.py new file mode 100644 index 0000000..471501a --- /dev/null +++ b/priv/tests/test_transport_close.py @@ -0,0 +1,236 @@ +# Copyright 2026 Benoit Chesneau +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for the transport close path of ErlangEventLoop. + +A transport must not close its socket while the fd may still be in the BEAM +poll set: it detaches the fd and hands it to the loop (_close_socket), which +releases the fd resource with ownership so the NIF closes it after the +select stop. These tests drive the loop against a recording NIF stub, so +they check the contract without a running BEAM. +""" + +import asyncio +import os +import socket +import unittest + +from _erlang_impl import _loop as loop_mod +from _erlang_impl._transport import ErlangSocketTransport, ErlangDatagramTransport + + +class _RecordingNif(loop_mod._MockNifModule): + """Mock NIF that records fd resource calls.""" + + def __init__(self): + super().__init__() + self.calls = [] + + def _add_reader_for(self, capsule, fd, callback_id): + self.calls.append(('add_reader', fd)) + return super()._add_reader_for(capsule, fd, callback_id) + + def _add_writer_for(self, capsule, fd, callback_id): + self.calls.append(('add_writer', fd)) + return super()._add_writer_for(capsule, fd, callback_id) + + def _clear_fd_read(self, fd_key): + self.calls.append(('clear_read', self._fd_by_key.get(fd_key))) + + def _clear_fd_write(self, fd_key): + self.calls.append(('clear_write', self._fd_by_key.get(fd_key))) + + def _release_fd_resource(self, fd_key, take_ownership=False): + self.calls.append(('release', self._fd_by_key.get(fd_key), take_ownership)) + return super()._release_fd_resource(fd_key, take_ownership) + + +def _make_loop(): + """ErlangEventLoop over the recording stub (no py_event_loop C module).""" + loop = loop_mod.ErlangEventLoop.__new__(loop_mod.ErlangEventLoop) + nif = _RecordingNif() + # Mirror the parts of __init__ the transports touch + loop._pel = nif + loop._loop_capsule = nif._loop_new() + loop._uses_global_capsule = False + loop._readers = {} + loop._writers = {} + loop._callbacks_by_cid = {} + loop._fd_resources = {} + loop._timers = {} + loop._timer_refs = {} + loop._handle_to_callback_id = {} + loop._ready = __import__('collections').deque() + loop._ready_append = loop._ready.append + loop._ready_popleft = loop._ready.popleft + loop._handle_pool = [] + loop._handle_pool_max = 150 + loop._cached_time = 0.0 + loop._wake_pending = False + loop._running = False + loop._stopping = False + loop._closed = False + loop._thread_id = None + loop._clock_resolution = 1e-9 + loop._exception_handler = None + loop._current_handle = None + loop._debug = False + loop._task_factory = None + loop._default_executor = None + loop._signal_handlers = {} + loop._execution_mode = None + loop._callback_id = 0 + return loop, nif + + +class _Proto(asyncio.Protocol): + def __init__(self): + self.lost = [] + + def connection_made(self, transport): + pass + + def connection_lost(self, exc): + self.lost.append(exc) + + +class TestTransportClose(unittest.TestCase): + + def setUp(self): + self.loop, self.nif = _make_loop() + self.a, self.b = socket.socketpair() + self.a.setblocking(False) + + def tearDown(self): + for s in (self.a, self.b): + try: + s.close() + except OSError: + pass + + def _fd_is_open(self, fd): + try: + os.fstat(fd) + return True + except OSError: + return False + + def test_close_hands_fd_to_nif(self): + fd = self.a.fileno() + proto = _Proto() + transport = ErlangSocketTransport(self.loop, self.a, proto) + self.loop.add_reader(fd, transport._read_ready) + self.assertEqual(self.nif.calls, [('add_reader', fd)]) + + transport.close() + # No pending writes: connection_lost ran and the socket was detached, + # the fd itself is closed by the (mock) NIF with ownership + self.assertEqual(proto.lost, [None]) + self.assertEqual(self.a.fileno(), -1) + self.assertIn(('release', fd, True), self.nif.calls) + self.assertFalse(self._fd_is_open(fd)) + # nothing left registered for that fd + self.assertNotIn(fd, self.loop._fd_resources) + self.assertNotIn(fd, self.loop._readers) + + def test_stop_reading_keeps_resource(self): + fd = self.a.fileno() + transport = ErlangSocketTransport(self.loop, self.a, _Proto()) + self.loop.add_reader(fd, transport._read_ready) + transport.pause_reading() + self.assertIn(('clear_read', fd), self.nif.calls) + # resource kept for resume, no release issued + self.assertNotIn(('release', fd, False), self.nif.calls) + self.assertIn(fd, self.loop._fd_resources) + transport.resume_reading() + self.assertIn(fd, self.loop._readers) + + def test_abort_closes_once(self): + fd = self.a.fileno() + proto = _Proto() + transport = ErlangSocketTransport(self.loop, self.a, proto) + self.loop.add_reader(fd, transport._read_ready) + transport.abort() + transport.abort() + transport.close() + self.assertEqual(proto.lost, [None]) + releases = [c for c in self.nif.calls if c[0] == 'release'] + self.assertEqual(releases, [('release', fd, True)]) + + def test_pending_write_defers_close(self): + fd = self.a.fileno() + proto = _Proto() + transport = ErlangSocketTransport(self.loop, self.a, proto) + self.loop.add_reader(fd, transport._read_ready) + # Fill the buffer so the write cannot complete synchronously + transport._buffer = bytearray(b'pending') + transport._buffer_offset = 0 + transport.close() + # reading stopped, connection not lost yet, socket still open + self.assertIn(('clear_read', fd), self.nif.calls) + self.assertEqual(proto.lost, []) + self.assertNotEqual(self.a.fileno(), -1) + # drain: the write callback finishes and closes + transport._write_ready_cb() + self.assertEqual(proto.lost, [None]) + self.assertIn(('release', fd, True), self.nif.calls) + + def test_datagram_close(self): + u = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + u.bind(('127.0.0.1', 0)) + u.setblocking(False) + fd = u.fileno() + proto = _Proto() + transport = ErlangDatagramTransport(self.loop, u, proto) + self.loop.add_reader(fd, transport._read_ready) + transport.close() + self.assertEqual(proto.lost, [None]) + self.assertIn(('release', fd, True), self.nif.calls) + self.assertFalse(self._fd_is_open(fd)) + + def test_close_socket_unregistered(self): + # A socket the loop never registered is closed directly + s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + fd = s.fileno() + self.loop._close_socket(s) + self.assertFalse(self._fd_is_open(fd)) + self.assertEqual([c for c in self.nif.calls if c[0] == 'release'], []) + + def test_loop_close_releases_kept_resources(self): + fd = self.a.fileno() + transport = ErlangSocketTransport(self.loop, self.a, _Proto()) + self.loop.add_reader(fd, transport._read_ready) + transport.pause_reading() # resource kept without reader + self.loop.close() + self.assertIn(('release', fd, False), self.nif.calls) + self.assertEqual(self.loop._fd_resources, {}) + + def test_del_abandoned_transport(self): + # A paused transport is not referenced by the loop's readers; when it + # is dropped its socket still goes through the loop close path + fd = self.a.fileno() + transport = ErlangSocketTransport(self.loop, self.a, _Proto()) + self.loop.add_reader(fd, transport._read_ready) + transport.pause_reading() + sock = self.a + self.a = socket.socket() # keep tearDown happy + del transport + import gc + gc.collect() + self.assertEqual(sock.fileno(), -1) + self.assertIn(('release', fd, True), self.nif.calls) + + +if __name__ == '__main__': + unittest.main() diff --git a/rebar.config b/rebar.config index a93fbe8..c36b4fc 100644 --- a/rebar.config +++ b/rebar.config @@ -62,6 +62,7 @@ <<"docs/scalability.md">>, <<"docs/threading.md">>, <<"docs/asyncio.md">>, + <<"docs/workers.md">>, <<"docs/reactor.md">>, <<"docs/process-bound-envs.md">>, <<"docs/security.md">>, @@ -93,6 +94,7 @@ <<"docs/scalability.md">>, <<"docs/threading.md">>, <<"docs/asyncio.md">>, + <<"docs/workers.md">>, <<"docs/reactor.md">>, <<"docs/process-bound-envs.md">>, <<"docs/security.md">>, diff --git a/src/erlang_python.app.src b/src/erlang_python.app.src index 94abb6c..251cf8f 100644 --- a/src/erlang_python.app.src +++ b/src/erlang_python.app.src @@ -1,6 +1,6 @@ {application, erlang_python, [ {description, "Execute Python applications from Erlang using dirty NIFs"}, - {vsn, "4.0.0"}, + {vsn, "4.1.0"}, {registered, []}, {mod, {erlang_python_app, []}}, {applications, [ diff --git a/src/py_context.erl b/src/py_context.erl index b79da74..fa1a72f 100644 --- a/src/py_context.erl +++ b/src/py_context.erl @@ -55,7 +55,17 @@ is_subinterp/1, create_local_env/1, get_nif_ref/1, - interrupt/1 + interrupt/1, + start_loop/1, + start_loop/2, + stop_loop/1, + stop_loop/2, + loop_ref/1, + submit/4, + submit/5, + submit_await/4, + submit_await/5, + submit_await/6 ]). %% Internal exports @@ -83,9 +93,19 @@ id :: pos_integer(), interp_id :: non_neg_integer(), event_state = #{} :: map(), %% #{loop_ref => ref(), worker_pid => pid()} - callback_handler :: pid() | undefined %% For thread-model callback handling + callback_handler :: pid() | undefined, %% For thread-model callback handling + %% Worker loop (start_loop/1): request id of the run_forever exec, the + %% owner that gets {py_loop_exit, Ctx, Result}, its monitor, and the + %% callers waiting in stop_loop/2 + loop_req :: reference() | undefined, + loop_owner :: pid() | undefined, + loop_owner_mon :: reference() | undefined, + loop_stop_waiters = [] :: [{pid(), reference()}] }). +%% Time given to a running loop to exit after py_context:interrupt/1 +-define(LOOP_INTERRUPT_GRACE_MS, 3000). + %% ============================================================================ %% API %% ============================================================================ @@ -422,6 +442,108 @@ init_ref_tab() -> ok end. +%% @doc Run an ErlangEventLoop forever on the context's thread. +%% +%% Returns as soon as the loop is started. The loop keeps running until +%% stop_loop/1,2 or interrupt/1; the owner (the caller by default) receives +%% `{py_loop_exit, Ctx, Result}' when it ends, where Result is the return of +%% the exec that ran it (`ok', `{error, interrupted}', or a Python error). +%% +%% While the loop runs, call/eval/exec/call_method on this context return +%% `{error, loop_running}': the thread is busy in the loop, and a timed-out +%% call would interrupt it. Use submit/4,5 and submit_await/4,5,6 instead; +%% they inject coroutines into the running loop. +%% +%% Options: +%% - `owner' - pid that receives `{py_loop_exit, Ctx, Result}' (default: caller). +%% If the owner dies the loop is stopped. +-spec start_loop(context()) -> ok | {error, term()}. +start_loop(Ctx) -> + start_loop(Ctx, #{}). + +-spec start_loop(context(), map()) -> ok | {error, term()}. +start_loop(Ctx, Opts) when is_pid(Ctx), is_map(Opts) -> + Owner = maps:get(owner, Opts, self()), + MRef = erlang:monitor(process, Ctx), + Ctx ! {start_loop, self(), MRef, Owner}, + await_ctrl_reply(Ctx, MRef, 15000). + +%% @doc Stop a loop started with start_loop/1,2. +%% +%% Asks the loop to stop from inside (a coroutine calling `loop.stop()'), +%% then interrupts the thread if it has not exited after Grace ms (default +%% 5000). Returns `ok' once the loop has exited, `{error, no_loop}' when none +%% is running, `{error, timeout}' if it survived the interrupt too. +-spec stop_loop(context()) -> ok | {error, term()}. +stop_loop(Ctx) -> + stop_loop(Ctx, 5000). + +-spec stop_loop(context(), non_neg_integer()) -> ok | {error, term()}. +stop_loop(Ctx, GraceMs) when is_pid(Ctx), is_integer(GraceMs), GraceMs >= 0 -> + MRef = erlang:monitor(process, Ctx), + Ctx ! {stop_loop, self(), MRef, GraceMs}, + await_ctrl_reply(Ctx, MRef, GraceMs + ?LOOP_INTERRUPT_GRACE_MS + 2000). + +%% @doc Event loop reference of this context, usable with py_nif:submit_task/7 +%% and py_event_loop:create_task/4. +%% +%% owngil contexts have their own loop; worker contexts share the main +%% interpreter's loop (py_event_loop:get_loop/0). +-spec loop_ref(context()) -> {ok, reference()} | {error, term()}. +loop_ref(Ctx) when is_pid(Ctx) -> + MRef = erlang:monitor(process, Ctx), + Ctx ! {loop_ref, self(), MRef}, + await_ctrl_reply(Ctx, MRef, 5000). + +%% @doc Schedule `Module:Func(Args...)' on the context's event loop and +%% return at once with `{ok, TaskRef}'. +%% +%% Works whether or not start_loop/1 is active: with a running loop the +%% coroutine is injected into it, otherwise the event worker steps the loop. +%% The result arrives as `{async_result, TaskRef, {ok, Value} | {error, R}}'; +%% use py_event_loop:await/1,2 or submit_await/4,5,6. Coroutine functions +%% are awaited, plain functions are called and their value returned. +%% Module must be importable in the context (sys.modules), so put entry +%% points in a module rather than in the exec namespace. +-spec submit(context(), atom() | binary(), atom() | binary(), list()) -> + {ok, reference()} | {error, term()}. +submit(Ctx, Module, Func, Args) -> + submit(Ctx, Module, Func, Args, #{}). + +-spec submit(context(), atom() | binary(), atom() | binary(), list(), map()) -> + {ok, reference()} | {error, term()}. +submit(Ctx, Module, Func, Args, Kwargs) when is_pid(Ctx), is_list(Args), is_map(Kwargs) -> + case loop_ref(Ctx) of + {ok, LoopRef} -> + TaskRef = make_ref(), + case py_nif:submit_task(LoopRef, self(), TaskRef, + to_binary(Module), to_binary(Func), Args, Kwargs) of + ok -> {ok, TaskRef}; + {error, _} = Error -> Error + end; + {error, _} = Error -> + Error + end. + +%% @doc submit/4 followed by py_event_loop:await/2 (default timeout 5000 ms). +-spec submit_await(context(), atom() | binary(), atom() | binary(), list()) -> + {ok, term()} | {error, term()}. +submit_await(Ctx, Module, Func, Args) -> + submit_await(Ctx, Module, Func, Args, #{}, 5000). + +-spec submit_await(context(), atom() | binary(), atom() | binary(), list(), map()) -> + {ok, term()} | {error, term()}. +submit_await(Ctx, Module, Func, Args, Kwargs) -> + submit_await(Ctx, Module, Func, Args, Kwargs, 5000). + +-spec submit_await(context(), atom() | binary(), atom() | binary(), list(), map(), + timeout()) -> {ok, term()} | {error, term()}. +submit_await(Ctx, Module, Func, Args, Kwargs, Timeout) -> + case submit(Ctx, Module, Func, Args, Kwargs) of + {ok, TaskRef} -> py_event_loop:await(TaskRef, Timeout); + {error, _} = Error -> Error + end. + %% ============================================================================ %% Internal functions %% ============================================================================ @@ -453,6 +575,21 @@ await_reply(Ctx, MRef, Timeout) -> {error, timeout} end. +%% @private +%% Reply wait for loop control messages: unlike await_reply/3 a timeout here +%% must not interrupt the context (it would kill the loop we are managing). +await_ctrl_reply(Ctx, MRef, Timeout) -> + receive + {MRef, Result} -> + erlang:demonitor(MRef, [flush]), + Result; + {'DOWN', MRef, process, Ctx, Reason} -> + {error, {context_died, Reason}} + after Timeout -> + erlang:demonitor(MRef, [flush]), + {error, timeout} + end. + %% @private register_nif_ref(Ref) -> try @@ -494,7 +631,7 @@ init(Parent, Id, Mode, Opts) -> register_nif_ref(Ref), case apply_memory_limit(Ref, Opts) of ok -> - init_started(Parent, Id, Ref, InterpId); + init_started(Parent, Id, Ref, InterpId, Opts); {error, LimitError} -> unregister_nif_ref(), try py_nif:context_destroy(Ref) catch _:_ -> ok end, @@ -516,12 +653,23 @@ apply_memory_limit(Ref, Opts) -> end. %% @private -init_started(Parent, Id, Ref, InterpId) -> +init_started(Parent, Id, Ref, InterpId, Opts) -> %% Apply all registered imports and paths to this interpreter apply_registered_imports(Ref), apply_registered_paths(Ref), %% Apply preload code (populates globals for process-local envs) apply_preload(Ref), + %% Per-context preload from new/1 (imports the app once per worker) + case maps:get(preload, Opts, undefined) of + undefined -> ok; + PreCode when is_binary(PreCode); is_list(PreCode) -> + case handle_exec_with_async(Ref, iolist_to_binary(PreCode)) of + ok -> ok; + {error, PreErr} -> + error_logger:warning_msg( + "py_context ~p: preload failed: ~p~n", [InterpId, PreErr]) + end + end, %% For subinterpreters, create a dedicated event worker EventState = setup_event_worker(Ref, InterpId), %% For thread-model subinterpreters, spawn a dedicated callback handler @@ -635,8 +783,86 @@ create_context(owngil) -> %% @private %% Main context loop. Handles requests and uses suspension-based callback support. -loop(#state{ref = Ref, interp_id = InterpId} = State) -> +loop(#state{ref = Ref, interp_id = InterpId, loop_req = LoopReq} = State) -> receive + %% ---- worker loop management (start_loop/stop_loop/loop_ref) ---- + {start_loop, From, MRef, _Owner} when LoopReq =/= undefined -> + From ! {MRef, {error, already_running}}, + loop(State); + + {start_loop, From, MRef, Owner} -> + {Reply, NewState} = do_start_loop(Owner, State), + From ! {MRef, Reply}, + loop(NewState); + + {stop_loop, From, MRef, _GraceMs} when LoopReq =:= undefined -> + From ! {MRef, {error, no_loop}}, + loop(State); + + {stop_loop, From, MRef, GraceMs} -> + loop(begin_stop_loop(From, MRef, GraceMs, State)); + + {loop_ref, From, MRef} -> + From ! {MRef, context_loop_ref(State)}, + loop(State); + + {py_result, LoopReq, Result} when LoopReq =/= undefined -> + loop(loop_exited(Result, State)); + + {loop_stop_deadline, LoopReq} when LoopReq =/= undefined -> + %% Cooperative stop did not land: interrupt the thread + _ = py_nif:context_interrupt(Ref), + erlang:send_after(?LOOP_INTERRUPT_GRACE_MS, self(), + {loop_interrupt_deadline, LoopReq}), + loop(State); + + {loop_interrupt_deadline, LoopReq} when LoopReq =/= undefined -> + [W ! {M, {error, timeout}} || {W, M} <- State#state.loop_stop_waiters], + loop(State#state{loop_stop_waiters = []}); + + {loop_stop_deadline, _} -> + loop(State); + {loop_interrupt_deadline, _} -> + loop(State); + + {'DOWN', Mon, process, _Owner, _Reason} + when Mon =:= State#state.loop_owner_mon, LoopReq =/= undefined -> + %% Owner is gone: nobody will hear the exit, stop the loop + loop(begin_stop_loop(undefined, undefined, 5000, + State#state{loop_owner_mon = undefined})); + + {async_result, _TaskRef, _} -> + %% Result of a coroutine this process submitted (loop stop) - drop + loop(State); + + %% ---- while a worker loop runs, the thread is not available ---- + {call, From, MRef, _, _, _, _} when LoopReq =/= undefined -> + From ! {MRef, {error, loop_running}}, loop(State); + {call, From, MRef, _, _, _, _, _} when LoopReq =/= undefined -> + From ! {MRef, {error, loop_running}}, loop(State); + {eval, From, MRef, _, _} when LoopReq =/= undefined -> + From ! {MRef, {error, loop_running}}, loop(State); + {eval, From, MRef, _, _, _} when LoopReq =/= undefined -> + From ! {MRef, {error, loop_running}}, loop(State); + {exec, From, MRef, _} when LoopReq =/= undefined -> + From ! {MRef, {error, loop_running}}, loop(State); + {exec, From, MRef, _, _} when LoopReq =/= undefined -> + From ! {MRef, {error, loop_running}}, loop(State); + {call_method, From, MRef, _, _, _} when LoopReq =/= undefined -> + From ! {MRef, {error, loop_running}}, loop(State); + + {stop, From, MRef} when LoopReq =/= undefined -> + %% Get the thread out of the loop before destroying the context, + %% otherwise context_destroy waits for a thread that never returns + terminate(normal, stop_running_loop(State)), + From ! {MRef, ok}; + + {'EXIT', _Pid, Reason} = Exit when LoopReq =/= undefined, + (Reason =:= shutdown orelse Reason =:= kill orelse + (is_tuple(Reason) andalso element(1, Reason) =:= shutdown)) -> + self() ! Exit, + loop(stop_running_loop(State)); + {call, From, MRef, Module, Func, Args, Kwargs} -> Result = handle_call_with_suspension(Ref, Module, Func, Args, Kwargs), From ! {MRef, Result}, @@ -736,6 +962,95 @@ loop(#state{ref = Ref, interp_id = InterpId} = State) -> end end. +%% ============================================================================ +%% Worker loop helpers +%% ============================================================================ + +%% @private Loop reference: the context's own loop (owngil) or the shared +%% main-interpreter loop (worker mode) +context_loop_ref(#state{event_state = #{loop_ref := LoopRef}}) -> + {ok, LoopRef}; +context_loop_ref(_State) -> + py_event_loop:get_loop(). + +%% @private Start run_forever on the context thread through the async exec +%% path, so this process stays free to serve loop_ref/stop_loop and the +%% dirty schedulers are not held. +do_start_loop(Owner, #state{ref = Ref} = State) -> + case context_loop_ref(State) of + {ok, _} -> + LoopReq = make_ref(), + case py_nif:context_call_async(Ref, self(), LoopReq, <<"erlang">>, + <<"_run_loop_forever">>, [self()], #{}) of + {enqueued, LoopReq} -> + %% Wait for the loop to actually run before answering, so + %% a submit right after start_loop finds it + receive + {py_loop_started} -> + Mon = case is_pid(Owner) of + true -> erlang:monitor(process, Owner); + false -> undefined + end, + {ok, State#state{loop_req = LoopReq, loop_owner = Owner, + loop_owner_mon = Mon, loop_stop_waiters = []}}; + {py_result, LoopReq, {error, Reason}} -> + {{error, Reason}, State}; + {py_result, LoopReq, Other} -> + {{error, {loop_exited, Other}}, State} + after 10000 -> + {{error, loop_start_timeout}, State} + end; + {error, Reason} -> + {{error, Reason}, State} + end; + {error, Reason} -> + {{error, Reason}, State} + end. + +%% @private Ask the running loop to stop from inside, arm the interrupt +%% deadline, and remember who to answer once it has exited. +begin_stop_loop(From, MRef, GraceMs, #state{loop_req = LoopReq} = State) -> + Waiters = case From of + undefined -> State#state.loop_stop_waiters; + _ -> [{From, MRef} | State#state.loop_stop_waiters] + end, + case context_loop_ref(State) of + {ok, LoopRef} -> + _ = py_nif:submit_task(LoopRef, self(), make_ref(), + <<"erlang">>, <<"_stop_loop">>, [], #{}); + _ -> + ok + end, + erlang:send_after(GraceMs, self(), {loop_stop_deadline, LoopReq}), + State#state{loop_stop_waiters = Waiters}. + +%% @private The exec running the loop returned: tell the owner and the +%% stop_loop callers, clear the loop state. +loop_exited(Result, #state{loop_owner = Owner, loop_owner_mon = Mon, + loop_stop_waiters = Waiters} = State) -> + case Mon of + undefined -> ok; + _ -> erlang:demonitor(Mon, [flush]) + end, + case is_pid(Owner) of + true -> Owner ! {py_loop_exit, self(), Result}; + false -> ok + end, + [W ! {M, ok} || {W, M} <- Waiters], + State#state{loop_req = undefined, loop_owner = undefined, + loop_owner_mon = undefined, loop_stop_waiters = []}. + +%% @private Synchronous stop used before terminate: interrupt and wait a +%% bounded time for the exec to return. +stop_running_loop(#state{ref = Ref, loop_req = LoopReq} = State) -> + _ = py_nif:context_interrupt(Ref), + receive + {py_result, LoopReq, Result} -> + loop_exited(Result, State) + after ?LOOP_INTERRUPT_GRACE_MS -> + loop_exited({error, timeout}, State) + end. + %% @private Clean up resources on termination terminate(_Reason, #state{ref = Ref, event_state = EventState, callback_handler = CallbackHandler}) -> unregister_nif_ref(), diff --git a/src/py_event_worker.erl b/src/py_event_worker.erl index 884465c..da8b98e 100644 --- a/src/py_event_worker.erl +++ b/src/py_event_worker.erl @@ -92,6 +92,11 @@ handle_info({timeout, TimerRef}, State) -> handle_info({select, _FdRes, _Ref, cancelled}, State) -> {noreply, State}; +%% Re-arm request from the Python loop (see py_nif:fd_arm/2) +handle_info({fd_arm, FdRes, Mode}, State) -> + _ = py_nif:fd_arm(FdRes, Mode), + {noreply, State}; + %% Handle task_ready wakeup from submit_task NIF. %% This is sent via enif_send when a new async task is submitted. %% Uses a drain-until-empty loop to handle tasks submitted during processing. diff --git a/src/py_nif.erl b/src/py_nif.erl index d59bde9..0357bc6 100644 --- a/src/py_nif.erl +++ b/src/py_nif.erl @@ -121,6 +121,7 @@ %% FD lifecycle management (uvloop-like API) handle_fd_event/2, handle_fd_event_and_reselect/2, + fd_arm/2, stop_reader/1, start_reader/1, stop_writer/1, @@ -925,6 +926,13 @@ handle_fd_event(_FdRef, _Type) -> handle_fd_event_and_reselect(_FdRef, _Type) -> ?NIF_STUB. +%% @doc Re-arm a read or write select for an existing fd resource. +%% Sent to the event worker by the Python loop, which cannot re-select a +%% scheduler-polled fd from its own thread. +-spec fd_arm(reference(), read | write) -> ok | {error, term()}. +fd_arm(_FdRef, _Type) -> + ?NIF_STUB. + %% @doc Stop/pause read monitoring without closing the FD. %% The watcher still exists and can be restarted with start_reader. -spec stop_reader(reference()) -> ok | {error, term()}. diff --git a/test/py_asyncio_compat_SUITE.erl b/test/py_asyncio_compat_SUITE.erl index 40d73a6..6de5c7e 100644 --- a/test/py_asyncio_compat_SUITE.erl +++ b/test/py_asyncio_compat_SUITE.erl @@ -50,7 +50,11 @@ test_executors_erlang/1, test_context_erlang/1, test_process_erlang/1, - test_erlang_api/1 + test_erlang_api/1, + test_server_erlang/1, + test_server_asyncio/1, + test_transport_close/1, + test_loop_helpers/1 ]). %% Asyncio comparison tests (standard asyncio) @@ -84,7 +88,10 @@ groups() -> test_executors_erlang, test_context_erlang, test_process_erlang, - test_erlang_api + test_erlang_api, + test_server_erlang, + test_transport_close, + test_loop_helpers ]}, {comparison_tests, [sequence], [ test_base_asyncio, @@ -94,7 +101,8 @@ groups() -> test_unix_asyncio, test_dns_asyncio, test_executors_asyncio, - test_context_asyncio + test_context_asyncio, + test_server_asyncio ]} ]. @@ -176,6 +184,35 @@ test_erlang_api(Config) -> %% test_erlang_api has only Erlang-specific tests, run all run_python_tests("tests.test_erlang_api", <<"*">>, Config). +%% erlang.server (serve/adopt on handed-over fds) on the Erlang loop +test_server_erlang(Config) -> + run_erlang_tests("tests.test_server", Config). + +%% Transport close path against a recording NIF stub (no loop needed) +test_transport_close(Config) -> + run_python_tests("tests.test_transport_close", <<"*">>, Config). + +%% _run_loop_forever/_stop_loop, the entry points of py_context:start_loop. +%% Needs an interpreter with no other ErlangEventLoop, so an owngil context. +test_loop_helpers(Config) -> + case py_nif:owngil_supported() of + false -> + {skip, "needs an owngil context (Python 3.14+)"}; + true -> + PrivDir = ?config(priv_dir, Config), + {ok, Ctx} = py_context:new(#{mode => owngil}), + ok = py_context:exec(Ctx, iolist_to_binary(io_lib:format( + "import sys\nif '~s' not in sys.path:\n sys.path.insert(0, '~s')\n", + [PrivDir, PrivDir]))), + Result = py_context:call(Ctx, 'tests.ct_runner', run_tests, + [<<"tests.test_loop_helpers">>, <<"*">>], #{}, 120000), + py_context:stop(Ctx), + case Result of + {ok, Results} -> handle_test_results("tests.test_loop_helpers", <<"*">>, Results); + {error, Reason} -> ct:fail({python_error, Reason}) + end + end. + %% ============================================================================ %% Asyncio Comparison Tests (standard asyncio) %% ============================================================================ @@ -209,6 +246,9 @@ test_executors_asyncio(Config) -> test_context_asyncio(Config) -> run_asyncio_tests("tests.test_context", Config). +test_server_asyncio(Config) -> + run_asyncio_tests("tests.test_server", Config). + %% ============================================================================ %% Internal Functions %% ============================================================================ diff --git a/test/py_test_workerloop.py b/test/py_test_workerloop.py new file mode 100644 index 0000000..94d2734 --- /dev/null +++ b/test/py_test_workerloop.py @@ -0,0 +1,147 @@ +# Helpers for py_worker_loop_SUITE: protocols and coroutines that +# py_context:submit/4 schedules on a context's worker loop. +import asyncio +import erlang + +served = 0 +_servers = {} +_datagram = {} + + +class Echo(asyncio.Protocol): + """Reply with 'ok:' + data, tagged with the worker id, then close.""" + + tag = b'' + + def connection_made(self, transport): + self.transport = transport + + def data_received(self, data): + global served + served += 1 + self.transport.write(self.tag + b'ok:' + data) + self.transport.close() + + +class KeepAlive(asyncio.Protocol): + """Answer every 'ping' with 'pong', keep the connection open.""" + + def connection_made(self, transport): + self.transport = transport + self.buf = b'' + + def data_received(self, data): + global served + self.buf += data + while len(self.buf) >= 4: + self.buf = self.buf[4:] + served += 1 + self.transport.write(b'pong') + + +class Greedy(asyncio.Protocol): + """'hog' allocates past any sane memory cap; anything else echoes.""" + + def connection_made(self, transport): + self.transport = transport + + def data_received(self, data): + if data == b'hog': + try: + hog = [[] for _ in range(3000000)] # about 170 MB of empty lists + self.transport.write(b'no-cap:%d' % len(hog)) + except MemoryError: + self.transport.write(b'memoryerror') + else: + self.transport.write(b'ok:' + data) + self.transport.close() + + +class EchoUDP(asyncio.DatagramProtocol): + def connection_made(self, transport): + self.transport = transport + + def datagram_received(self, data, addr): + global served + served += 1 + self.transport.sendto(b'udp:' + data, addr) + + +async def serve(fd, tag=b''): + """Serve TCP on a listen fd handed over by Erlang.""" + if isinstance(tag, str): + tag = tag.encode() + proto = type('TaggedEcho', (Echo,), {'tag': tag}) + server = await erlang.server.serve(fd, proto) + _servers[fd] = server + return 'serving' + + +async def serve_keepalive(fd): + server = await erlang.server.serve(fd, KeepAlive) + _servers[fd] = server + return 'serving' + + +async def serve_greedy(fd): + server = await erlang.server.serve(fd, Greedy) + _servers[fd] = server + return 'serving' + + +async def block_loop(seconds): + """Wedge the loop in a blocking C call (time.sleep).""" + import time + time.sleep(seconds) + return 'unblocked' + + +async def serve_udp(fd): + transport = await erlang.server.serve(fd, EchoUDP, udp=True) + _datagram[fd] = transport + return 'serving' + + +async def stop(fd): + server = _servers.pop(fd, None) + if server is not None: + await erlang.server.stop_serving(server) + transport = _datagram.pop(fd, None) + if transport is not None: + transport.close() + return 'stopped' + + +async def adopt(fd): + """Take over an accepted connection fd.""" + await erlang.server.adopt(fd, Echo) + return 'adopted' + + +async def add(a, b): + await asyncio.sleep(0.001) + return a + b + + +def sync_add(a, b): + return a + b + + +async def sleep_then(value, seconds): + await asyncio.sleep(seconds) + return value + + +async def raise_error(): + raise ValueError('boom') + + +async def wait_channel(ref): + """Await one message on a py_channel from inside the loop.""" + ch = erlang.Channel(ref) + msg = await ch.async_receive() + return msg + + +def served_count(): + return served diff --git a/test/py_worker_loop_SUITE.erl b/test/py_worker_loop_SUITE.erl new file mode 100644 index 0000000..e3666e2 --- /dev/null +++ b/test/py_worker_loop_SUITE.erl @@ -0,0 +1,537 @@ +%%% @doc Common Test suite for worker loops. +%%% +%%% Covers py_context:start_loop/1,2, stop_loop/1,2, loop_ref/1, submit/4,5, +%%% submit_await/4,5,6, the `preload' option, the erlang.server helper, and +%%% the owngil fixes behind them (per-context event loop, async dispatch, +%%% coroutine injection, fd close after select stop). +%%% +%%% Most cases need owngil (one loop per interpreter, and worker contexts +%%% share the main interpreter); those skip on Python < 3.14. +-module(py_worker_loop_SUITE). + +-include_lib("common_test/include/ct.hrl"). + +-export([ + all/0, + groups/0, + init_per_suite/1, + end_per_suite/1, + init_per_group/2, + end_per_group/2, + init_per_testcase/2, + end_per_testcase/2 +]). + +-export([ + %% owngil + test_loop_ref_per_context/1, + test_start_stop_loop/1, + test_start_twice/1, + test_stop_idle/1, + test_calls_rejected_while_running/1, + test_submit_idle_and_running/1, + test_submit_errors_reported/1, + test_submit_ordering/1, + test_tcp_serve_on_dup_fd/1, + test_udp_serve_on_dup_fd/1, + test_adopt_accepted_fd/1, + test_three_workers_one_listen_fd/1, + test_channel_awaited_in_loop/1, + test_owner_death_stops_loop/1, + test_stop_context_while_looping/1, + test_interrupt_ends_loop/1, + test_long_call_no_30s_cap/1, + test_main_pool_unaffected/1, + test_churn_no_poll_reports/1, + test_preload_option/1, + test_bad_fd_rejected/1, + %% worker mode + test_worker_mode_single_loop/1 +]). + +%% logger handler callback used by test_churn_no_poll_reports +-export([log/2]). + +-define(HOST, {127, 0, 0, 1}). + +all() -> + [{group, owngil}, {group, worker}]. + +groups() -> + [{owngil, [], [ + test_loop_ref_per_context, + test_start_stop_loop, + test_start_twice, + test_stop_idle, + test_calls_rejected_while_running, + test_submit_idle_and_running, + test_submit_errors_reported, + test_submit_ordering, + test_tcp_serve_on_dup_fd, + test_udp_serve_on_dup_fd, + test_adopt_accepted_fd, + test_three_workers_one_listen_fd, + test_channel_awaited_in_loop, + test_owner_death_stops_loop, + test_stop_context_while_looping, + test_interrupt_ends_loop, + test_long_call_no_30s_cap, + test_main_pool_unaffected, + test_churn_no_poll_reports, + test_preload_option, + test_bad_fd_rejected + ]}, + {worker, [], [test_worker_mode_single_loop]}]. + +init_per_suite(Config) -> + {ok, _} = application:ensure_all_started(erlang_python), + TestDir = filename:dirname(code:which(?MODULE)), + [{test_dir, TestDir} | Config]. + +end_per_suite(_Config) -> + ok = application:stop(erlang_python), + ok. + +init_per_group(owngil, Config) -> + case py_nif:owngil_supported() of + true -> [{mode, owngil} | Config]; + false -> {skip, "worker loops need OWN_GIL (Python 3.14+)"} + end; +init_per_group(worker, Config) -> + [{mode, worker} | Config]. + +end_per_group(_Group, _Config) -> + ok. + +init_per_testcase(_TestCase, Config) -> + flush(), + Config. + +end_per_testcase(_TestCase, _Config) -> + flush(), + ok. + +%%% ============================================================================ +%%% Loop lifecycle +%%% ============================================================================ + +%% @doc Every owngil context has its own loop, distinct from the main one. +test_loop_ref_per_context(Config) -> + C1 = new_ctx(Config), + C2 = new_ctx(Config), + {ok, L1} = py_context:loop_ref(C1), + {ok, L2} = py_context:loop_ref(C2), + {ok, LMain} = py_event_loop:get_loop(), + true = L1 =/= L2, + true = L1 =/= LMain, + true = L2 =/= LMain, + stop_ctx(C1), stop_ctx(C2), + ok. + +%% @doc start_loop returns once running; stop_loop returns once exited and +%% the owner hears about it; the context is usable again after. +test_start_stop_loop(Config) -> + C = new_ctx(Config), + ok = py_context:start_loop(C), + {ok, 3} = py_context:submit_await(C, py_test_workerloop, add, [1, 2]), + ok = py_context:stop_loop(C), + receive {py_loop_exit, C, {ok, <<"stopped">>}} -> ok + after 2000 -> ct:fail(no_loop_exit) + end, + {ok, 4} = py_context:eval(C, <<"2+2">>, #{}, 5000), + %% and again + ok = py_context:start_loop(C), + {ok, 7} = py_context:submit_await(C, py_test_workerloop, add, [3, 4]), + ok = py_context:stop_loop(C), + stop_ctx(C), + ok. + +test_start_twice(Config) -> + C = new_ctx(Config), + ok = py_context:start_loop(C), + {error, already_running} = py_context:start_loop(C), + ok = py_context:stop_loop(C), + stop_ctx(C), + ok. + +test_stop_idle(Config) -> + C = new_ctx(Config), + {error, no_loop} = py_context:stop_loop(C), + stop_ctx(C), + ok. + +%% @doc call/eval/exec/call_method are refused while the loop runs (a timed +%% out call would interrupt the loop) and the loop keeps working. +test_calls_rejected_while_running(Config) -> + C = new_ctx(Config), + ok = py_context:start_loop(C), + {error, loop_running} = py_context:eval(C, <<"1">>), + {error, loop_running} = py_context:exec(C, <<"x = 1">>), + {error, loop_running} = py_context:call(C, math, sqrt, [4.0]), + {ok, 3} = py_context:submit_await(C, py_test_workerloop, add, [1, 2]), + ok = py_context:stop_loop(C), + stop_ctx(C), + ok. + +%%% ============================================================================ +%%% submit +%%% ============================================================================ + +%% @doc submit works on an idle context (event worker steps the loop) and +%% into a running loop, for coroutines and plain functions. +test_submit_idle_and_running(Config) -> + C = new_ctx(Config), + {ok, 3} = py_context:submit_await(C, py_test_workerloop, add, [1, 2]), + {ok, 5} = py_context:submit_await(C, py_test_workerloop, sync_add, [2, 3]), + ok = py_context:start_loop(C), + T0 = erlang:monotonic_time(millisecond), + {ok, 9} = py_context:submit_await(C, py_test_workerloop, add, [4, 5]), + Latency = erlang:monotonic_time(millisecond) - T0, + %% injected coroutines wake the loop, no wait for the poll timeout + true = Latency < 500, + {ok, 6} = py_context:submit_await(C, py_test_workerloop, sync_add, [1, 5]), + {ok, TaskRef} = py_context:submit(C, py_test_workerloop, sleep_then, [<<"late">>, 0.05]), + {ok, <<"late">>} = py_event_loop:await(TaskRef, 2000), + ok = py_context:stop_loop(C), + stop_ctx(C), + ok. + +%% @doc Failures to start or run a task are reported, not dropped. +test_submit_errors_reported(Config) -> + C = new_ctx(Config), + ok = py_context:start_loop(C), + {error, function_not_found} = py_context:submit_await(C, py_test_workerloop, nope, []), + {error, function_not_found} = py_context:submit_await(C, no_such_module, f, []), + {error, {'TypeError', _}} = py_context:submit_await(C, math, sqrt, [<<"x">>]), + {error, _} = py_context:submit_await(C, py_test_workerloop, raise_error, []), + ok = py_context:stop_loop(C), + stop_ctx(C), + ok. + +%% @doc A burst of submits from one caller completes and comes back in order. +test_submit_ordering(Config) -> + C = new_ctx(Config), + ok = py_context:start_loop(C), + Refs = [begin + {ok, R} = py_context:submit(C, py_test_workerloop, add, [I, 0]), + {I, R} + end || I <- lists:seq(1, 500)], + [{ok, I} = py_event_loop:await(R, 5000) || {I, R} <- Refs], + ok = py_context:stop_loop(C), + stop_ctx(C), + ok. + +%%% ============================================================================ +%%% Serving on fds handed over by Erlang +%%% ============================================================================ + +test_tcp_serve_on_dup_fd(Config) -> + C = new_ctx(Config), + ok = py_context:start_loop(C), + {LSock, Port, Dup} = listen_dup(), + {ok, <<"serving">>} = py_context:submit_await(C, py_test_workerloop, serve, [Dup]), + [<<"ok:x">> = roundtrip(Port, <<"x">>) || _ <- lists:seq(1, 100)], + {ok, 100} = py_context:submit_await(C, py_test_workerloop, served_count, []), + {ok, <<"stopped">>} = py_context:submit_await(C, py_test_workerloop, stop, [Dup]), + ok = py_context:stop_loop(C), + gen_tcp:close(LSock), + stop_ctx(C), + ok. + +test_udp_serve_on_dup_fd(Config) -> + C = new_ctx(Config), + ok = py_context:start_loop(C), + {ok, USock} = gen_udp:open(0, [binary, {ip, ?HOST}, {active, false}]), + {ok, Port} = inet:port(USock), + {ok, Fd} = inet:getfd(USock), + {ok, Dup} = py:dup_fd(Fd), + {ok, <<"serving">>} = py_context:submit_await(C, py_test_workerloop, serve_udp, [Dup]), + {ok, Client} = gen_udp:open(0, [binary, {ip, ?HOST}, {active, false}]), + ok = gen_udp:send(Client, ?HOST, Port, <<"ping">>), + {ok, {_, _, <<"udp:ping">>}} = gen_udp:recv(Client, 0, 2000), + {ok, <<"stopped">>} = py_context:submit_await(C, py_test_workerloop, stop, [Dup]), + gen_udp:close(Client), + gen_udp:close(USock), + ok = py_context:stop_loop(C), + stop_ctx(C), + ok. + +%% @doc Erlang accepts, then hands the connection fd to the loop. +test_adopt_accepted_fd(Config) -> + C = new_ctx(Config), + ok = py_context:start_loop(C), + {ok, LSock} = gen_tcp:listen(0, [binary, {ip, ?HOST}, {active, false}]), + {ok, Port} = inet:port(LSock), + Self = self(), + spawn_link(fun() -> + {ok, S} = gen_tcp:connect(?HOST, Port, [binary, {active, false}], 2000), + ok = gen_tcp:send(S, <<"adopted?">>), + Self ! {client, gen_tcp:recv(S, 0, 3000)}, + gen_tcp:close(S) + end), + {ok, Conn} = gen_tcp:accept(LSock, 2000), + {ok, ConnFd} = inet:getfd(Conn), + {ok, Dup} = py:dup_fd(ConnFd), + {ok, <<"adopted">>} = py_context:submit_await(C, py_test_workerloop, adopt, [Dup]), + %% Erlang gives up its copy; Python owns the dup + gen_tcp:close(Conn), + receive {client, {ok, <<"ok:adopted?">>}} -> ok + after 3000 -> ct:fail(no_reply_through_adopted_fd) + end, + gen_tcp:close(LSock), + ok = py_context:stop_loop(C), + stop_ctx(C), + ok. + +%% @doc gunicorn shape: one listen socket, three workers accepting on dups. +test_three_workers_one_listen_fd(Config) -> + Ctxs = [new_ctx(Config) || _ <- lists:seq(1, 3)], + {ok, LSock} = gen_tcp:listen(0, [binary, {ip, ?HOST}, {active, false}, {backlog, 512}]), + {ok, Port} = inet:port(LSock), + {ok, LFd} = inet:getfd(LSock), + lists:foreach(fun({I, C}) -> + ok = py_context:start_loop(C), + {ok, Dup} = py:dup_fd(LFd), + Tag = list_to_binary("w" ++ integer_to_list(I) ++ ":"), + {ok, <<"serving">>} = py_context:submit_await(C, py_test_workerloop, serve, [Dup, Tag]) + end, lists:zip(lists:seq(1, 3), Ctxs)), + Replies = [roundtrip(Port, <<"x">>) || _ <- lists:seq(1, 300)], + 300 = length([R || R <- Replies, binary:part(R, byte_size(R) - 4, 4) =:= <<"ok:x">>]), + Tags = lists:usort([binary:part(R, 0, 3) || R <- Replies]), + ct:log("workers that served: ~p", [Tags]), + %% All three workers accept on the same socket + 3 = length(Tags), + [ok = py_context:stop_loop(C) || C <- Ctxs], + [stop_ctx(C) || C <- Ctxs], + gen_tcp:close(LSock), + ok. + +%% @doc A py_channel awaited inside the loop is the Erlang to loop control +%% plane: no polling, message delivered into the running loop. +test_channel_awaited_in_loop(Config) -> + C = new_ctx(Config), + ok = py_context:start_loop(C), + {ok, Ch} = py_channel:new(), + {ok, TaskRef} = py_context:submit(C, py_test_workerloop, wait_channel, [Ch]), + timer:sleep(100), + ok = py_channel:send(Ch, {adopt, 42}), + {ok, Msg} = py_event_loop:await(TaskRef, 3000), + ct:log("channel message seen by the loop: ~p", [Msg]), + ok = py_channel:close(Ch), + ok = py_context:stop_loop(C), + stop_ctx(C), + ok. + +%%% ============================================================================ +%%% Failure and shutdown paths +%%% ============================================================================ + +test_owner_death_stops_loop(Config) -> + C = new_ctx(Config), + Owner = spawn(fun() -> receive never -> ok end end), + ok = py_context:start_loop(C, #{owner => Owner}), + {ok, 3} = py_context:submit_await(C, py_test_workerloop, add, [1, 2]), + exit(Owner, kill), + ok = wait_until(fun() -> py_context:eval(C, <<"1">>, #{}, 1000) =:= {ok, 1} end, 5000), + stop_ctx(C), + ok. + +%% @doc Stopping the context while its loop runs interrupts the loop first, +%% so context_destroy does not wait 30 s for the thread. +test_stop_context_while_looping(Config) -> + C = new_ctx(Config), + ok = py_context:start_loop(C), + T0 = erlang:monotonic_time(millisecond), + ok = py_context:stop(C), + Elapsed = erlang:monotonic_time(millisecond) - T0, + ct:log("stop while looping took ~p ms", [Elapsed]), + true = Elapsed < 10000, + receive {py_loop_exit, C, {error, interrupted}} -> ok + after 1000 -> ct:fail(no_interrupted_exit) + end, + false = is_process_alive(C), + ok. + +test_interrupt_ends_loop(Config) -> + C = new_ctx(Config), + ok = py_context:start_loop(C), + ok = py_context:interrupt(C), + receive {py_loop_exit, C, {error, interrupted}} -> ok + after 3000 -> ct:fail(no_interrupted_exit) + end, + {ok, 4} = py_context:eval(C, <<"2+2">>, #{}, 5000), + stop_ctx(C), + ok. + +%% @doc owngil calls no longer go through the 30 s blocking dispatch, and do +%% not hold a dirty scheduler while they run. +test_long_call_no_30s_cap(Config) -> + C = new_ctx(Config), + Other = new_ctx(Config), + Self = self(), + spawn_link(fun() -> + Self ! {long, py_context:eval(C, <<"__import__('time').sleep(32) or 7">>, #{}, infinity)} + end), + timer:sleep(200), + T0 = erlang:monotonic_time(millisecond), + {ok, 2} = py_context:eval(Other, <<"1+1">>, #{}, 5000), + true = erlang:monotonic_time(millisecond) - T0 < 1000, + receive {long, {ok, 7}} -> ok + after 40000 -> ct:fail(long_call_did_not_return) + end, + stop_ctx(C), stop_ctx(Other), + ok. + +%% @doc Starting and stopping owngil contexts must not touch the main loop's +%% worker (they used to re-point it and leave it dead). +test_main_pool_unaffected(Config) -> + C = new_ctx(Config), + ok = py_context:start_loop(C), + ok = py_context:stop_loop(C), + stop_ctx(C), + {ok, 4.0} = py_event_loop:run(math, sqrt, [16.0]), + ok. + +%% @doc Connection churn leaves no fd in the BEAM poll set behind: no +%% "Bad input fd in erts_poll()" or "stealing control" reports. +test_churn_no_poll_reports(Config) -> + ok = logger:add_handler(?MODULE, ?MODULE, #{config => self()}), + C = new_ctx(Config), + ok = py_context:start_loop(C), + {LSock, Port, Dup} = listen_dup(), + {ok, <<"serving">>} = py_context:submit_await(C, py_test_workerloop, serve, [Dup]), + N = 2000, + Self = self(), + Workers = 20, + [spawn_link(fun() -> + Fails = length([bad || _ <- lists:seq(1, N div Workers), + roundtrip(Port, <<"x">>) =/= <<"ok:x">>]), + Self ! {churn_done, Fails} + end) || _ <- lists:seq(1, Workers)], + Fails = lists:sum([receive {churn_done, F} -> F after 60000 -> N end + || _ <- lists:seq(1, Workers)]), + 0 = Fails, + timer:sleep(300), + ok = py_context:stop_loop(C), + gen_tcp:close(LSock), + stop_ctx(C), + ok = logger:remove_handler(?MODULE), + Reports = collect_reports(), + ct:log("erts_poll reports: ~p", [Reports]), + [] = Reports, + ok. + +test_preload_option(Config) -> + Pre = <<"import sys, types\n" + "_m = types.ModuleType('preloaded_mod')\n" + "async def hello():\n" + " return 'hi'\n" + "_m.hello = hello\n" + "sys.modules['preloaded_mod'] = _m\n">>, + {ok, C} = py_context:new(#{mode => ?config(mode, Config), preload => Pre}), + ok = py_context:start_loop(C), + {ok, <<"hi">>} = py_context:submit_await(C, preloaded_mod, hello, []), + ok = py_context:stop_loop(C), + stop_ctx(C), + ok. + +test_bad_fd_rejected(Config) -> + C = new_ctx(Config), + ok = py_context:start_loop(C), + {error, _} = py_context:submit_await(C, py_test_workerloop, serve, [-1]), + {error, _} = py_context:submit_await(C, py_test_workerloop, serve, [99999]), + {error, _} = py_context:submit_await(C, py_test_workerloop, adopt, [<<"x">>]), + %% loop still fine + {ok, 3} = py_context:submit_await(C, py_test_workerloop, add, [1, 2]), + ok = py_context:stop_loop(C), + stop_ctx(C), + ok. + +%%% ============================================================================ +%%% Worker mode +%%% ============================================================================ + +%% @doc Worker contexts share the main interpreter, which allows one +%% ErlangEventLoop: the first start_loop works, a second context cannot. +test_worker_mode_single_loop(Config) -> + W1 = new_ctx(Config), + W2 = new_ctx(Config), + ok = py_context:start_loop(W1), + {ok, 4.0} = py_context:submit_await(W1, math, sqrt, [16.0]), + {ok, 3} = py_context:submit_await(W1, py_test_workerloop, add, [1, 2]), + {error, _} = py_context:start_loop(W2), + ok = py_context:stop_loop(W1), + stop_ctx(W1), stop_ctx(W2), + ok. + +%%% ============================================================================ +%%% Logger handler (churn test) +%%% ============================================================================ + +log(#{msg := Msg}, #{config := Pid}) -> + Text = case Msg of + {string, S} -> unicode:characters_to_binary(S); + {report, R} -> unicode:characters_to_binary(io_lib:format("~p", [R])); + {Fmt, Args} -> unicode:characters_to_binary(io_lib:format(Fmt, Args)) + end, + case binary:match(Text, [<<"erts_poll">>, <<"stealing control">>]) of + nomatch -> ok; + _ -> Pid ! {poll_report, Text} + end, + ok. + +collect_reports() -> + receive {poll_report, T} -> [T | collect_reports()] + after 200 -> [] + end. + +%%% ============================================================================ +%%% Helpers +%%% ============================================================================ + +new_ctx(Config) -> + {ok, C} = py_context:new(#{mode => ?config(mode, Config)}), + TestDir = ?config(test_dir, Config), + Code = iolist_to_binary(io_lib:format( + "import sys\nif '~s' not in sys.path:\n sys.path.insert(0, '~s')\n" + "import py_test_workerloop\n", [TestDir, TestDir])), + ok = py_context:exec(C, Code), + C. + +stop_ctx(C) -> + try py_context:stop(C) catch _:_ -> ok end, + ok. + +listen_dup() -> + {ok, LSock} = gen_tcp:listen(0, [binary, {ip, ?HOST}, {active, false}, {backlog, 512}]), + {ok, Port} = inet:port(LSock), + {ok, LFd} = inet:getfd(LSock), + {ok, Dup} = py:dup_fd(LFd), + {LSock, Port, Dup}. + +roundtrip(Port, Data) -> + {ok, S} = gen_tcp:connect(?HOST, Port, [binary, {active, false}], 5000), + ok = gen_tcp:send(S, Data), + R = case gen_tcp:recv(S, 0, 5000) of + {ok, Bin} -> Bin; + Other -> Other + end, + gen_tcp:close(S), + R. + +wait_until(Fun, TimeoutMs) -> + Deadline = erlang:monotonic_time(millisecond) + TimeoutMs, + wait_until_loop(Fun, Deadline). + +wait_until_loop(Fun, Deadline) -> + case Fun() of + true -> ok; + false -> + case erlang:monotonic_time(millisecond) > Deadline of + true -> {error, timeout}; + false -> timer:sleep(50), wait_until_loop(Fun, Deadline) + end + end. + +flush() -> + receive _ -> flush() after 0 -> ok end. diff --git a/test/py_worker_loop_stress_SUITE.erl b/test/py_worker_loop_stress_SUITE.erl new file mode 100644 index 0000000..b20cff6 --- /dev/null +++ b/test/py_worker_loop_stress_SUITE.erl @@ -0,0 +1,350 @@ +%%% @doc Stress tests for worker loops. +%%% +%%% Long running, high volume checks on py_context:start_loop/submit and +%%% erlang.server: connection churn on several workers with no fd left in the +%%% BEAM poll set, held keep-alive connections, start/stop cycling without +%%% leaks, submit storms during traffic, and the kill paths (memory cap, +%%% interrupt of a wedged loop). +%%% +%%% Skipped unless the environment variable STRESS is set (`STRESS=1 rebar3 +%%% ct --suite py_worker_loop_stress_SUITE`), and needs owngil (Python 3.14+). +-module(py_worker_loop_stress_SUITE). + +-include_lib("common_test/include/ct.hrl"). + +-export([ + all/0, + init_per_suite/1, + end_per_suite/1, + init_per_testcase/2, + end_per_testcase/2 +]). + +-export([ + test_churn_four_workers/1, + test_keepalive_connections_held/1, + test_start_stop_cycles/1, + test_submit_storm_under_traffic/1, + test_memory_cap_in_handler/1, + test_interrupt_wedged_loop/1 +]). + +%% logger handler callback +-export([log/2]). + +-define(HOST, {127, 0, 0, 1}). + +all() -> [ + test_churn_four_workers, + test_keepalive_connections_held, + test_start_stop_cycles, + test_submit_storm_under_traffic, + test_memory_cap_in_handler, + test_interrupt_wedged_loop +]. + +init_per_suite(Config) -> + case os:getenv("STRESS") of + false -> + {skip, "set STRESS=1 to run the worker loop stress suite"}; + _ -> + {ok, _} = application:ensure_all_started(erlang_python), + case py_nif:owngil_supported() of + false -> {skip, "worker loops need OWN_GIL (Python 3.14+)"}; + true -> + TestDir = filename:dirname(code:which(?MODULE)), + [{test_dir, TestDir} | Config] + end + end. + +end_per_suite(_Config) -> + ok = application:stop(erlang_python), + ok. + +init_per_testcase(_TestCase, Config) -> + ok = logger:add_handler(?MODULE, ?MODULE, #{config => self()}), + Config. + +end_per_testcase(_TestCase, _Config) -> + _ = logger:remove_handler(?MODULE), + flush(), + ok. + +%%% ============================================================================ +%%% Cases +%%% ============================================================================ + +%% @doc 10k short connections against four workers on one listen fd: no +%% failed connects, no erts_poll reports, fd count back to baseline. +test_churn_four_workers(Config) -> + Baseline = fd_count(), + Ctxs = [new_ctx(Config) || _ <- lists:seq(1, 4)], + {LSock, Port, LFd} = listen(), + lists:foreach(fun({I, C}) -> + ok = py_context:start_loop(C), + {ok, Dup} = py:dup_fd(LFd), + Tag = list_to_binary("w" ++ integer_to_list(I) ++ ":"), + {ok, <<"serving">>} = py_context:submit_await(C, py_test_workerloop, serve, [Dup, Tag]) + end, lists:zip(lists:seq(1, 4), Ctxs)), + N = 10000, + Clients = 50, + T0 = erlang:monotonic_time(millisecond), + Replies = parallel_roundtrips(Port, N, Clients), + Elapsed = erlang:monotonic_time(millisecond) - T0, + Failed = length([R || R <- Replies, not is_binary(R)]), + Tags = lists:usort([binary:part(R, 0, 3) || R <- Replies, is_binary(R)]), + ct:log("~p connections in ~p ms (~p conn/s), failed ~p, workers ~p", + [N, Elapsed, N * 1000 div max(1, Elapsed), Failed, Tags]), + 0 = Failed, + 4 = length(Tags), + [ok = py_context:stop_loop(C) || C <- Ctxs], + [stop_ctx(C) || C <- Ctxs], + gen_tcp:close(LSock), + timer:sleep(500), + [] = collect_reports(), + After = fd_count(), + ct:log("fds before ~p after ~p", [Baseline, After]), + true = After =< Baseline + 8, + ok. + +%% @doc 1000 keep-alive connections held open with periodic writes for 20 s; +%% the worker's memory stays bounded. +test_keepalive_connections_held(Config) -> + C = new_ctx(Config), + ok = py_context:start_loop(C), + {LSock, Port, LFd} = listen(), + {ok, Dup} = py:dup_fd(LFd), + {ok, <<"serving">>} = py_context:submit_await(C, py_test_workerloop, serve_keepalive, [Dup]), + Ref = py_context:get_nif_ref(C), + {ok, Mem0, _} = py_nif:context_memory_usage(Ref), + Socks = [begin + {ok, S} = gen_tcp:connect(?HOST, Port, [binary, {active, false}], 5000), + S + end || _ <- lists:seq(1, 1000)], + Rounds = 10, + lists:foreach(fun(_) -> + [ok = gen_tcp:send(S, <<"ping">>) || S <- Socks], + [{ok, <<"pong">>} = gen_tcp:recv(S, 4, 10000) || S <- Socks], + timer:sleep(2000) + end, lists:seq(1, Rounds)), + {ok, Mem1, _} = py_nif:context_memory_usage(Ref), + ct:log("memory ~p -> ~p bytes with 1000 held connections", [Mem0, Mem1]), + true = Mem1 < Mem0 + 256 * 1024 * 1024, + [gen_tcp:close(S) || S <- Socks], + timer:sleep(500), + ok = py_context:stop_loop(C), + gen_tcp:close(LSock), + stop_ctx(C), + [] = collect_reports(), + ok. + +%% @doc start_loop/stop_loop cycled 200 times: no leaked event workers, no +%% memory growth, context still fine. +test_start_stop_cycles(Config) -> + C = new_ctx(Config), + Ref = py_context:get_nif_ref(C), + Workers0 = length(supervisor:which_children(py_event_worker_sup)), + Procs0 = erlang:system_info(process_count), + {ok, Mem0, _} = py_nif:context_memory_usage(Ref), + lists:foreach(fun(I) -> + ok = py_context:start_loop(C), + {ok, I} = py_context:submit_await(C, py_test_workerloop, add, [I, 0]), + ok = py_context:stop_loop(C) + end, lists:seq(1, 200)), + {ok, 4} = py_context:eval(C, <<"2+2">>, #{}, 5000), + {ok, Mem1, _} = py_nif:context_memory_usage(Ref), + Workers1 = length(supervisor:which_children(py_event_worker_sup)), + Procs1 = erlang:system_info(process_count), + ct:log("workers ~p -> ~p, processes ~p -> ~p, memory ~p -> ~p", + [Workers0, Workers1, Procs0, Procs1, Mem0, Mem1]), + Workers0 = Workers1, + true = Procs1 =< Procs0 + 5, + true = Mem1 < Mem0 + 64 * 1024 * 1024, + stop_ctx(C), + ok. + +%% @doc 10k coroutine submits while 100 connections exchange data: every +%% result arrives, per caller ordering holds. +test_submit_storm_under_traffic(Config) -> + C = new_ctx(Config), + ok = py_context:start_loop(C), + {LSock, Port, LFd} = listen(), + {ok, Dup} = py:dup_fd(LFd), + {ok, <<"serving">>} = py_context:submit_await(C, py_test_workerloop, serve_keepalive, [Dup]), + Self = self(), + Traffic = spawn_link(fun() -> + Socks = [element(2, gen_tcp:connect(?HOST, Port, [binary, {active, false}], 5000)) + || _ <- lists:seq(1, 100)], + traffic_loop(Socks, Self) + end), + Callers = 10, + PerCaller = 1000, + [spawn_link(fun() -> + Refs = [element(2, py_context:submit(C, py_test_workerloop, add, [I, 0])) + || I <- lists:seq(1, PerCaller)], + Results = [py_event_loop:await(R, 30000) || R <- Refs], + Self ! {storm, Results} + end) || _ <- lists:seq(1, Callers)], + AllOk = lists:all(fun(Results) -> + Results =:= [{ok, I} || I <- lists:seq(1, PerCaller)] + end, [receive {storm, Rs} -> Rs after 120000 -> [] end || _ <- lists:seq(1, Callers)]), + Traffic ! stop, + receive {traffic_done, Exchanged} -> ct:log("traffic exchanged ~p messages", [Exchanged]) + after 30000 -> ct:fail(traffic_did_not_stop) + end, + true = AllOk, + ok = py_context:stop_loop(C), + gen_tcp:close(LSock), + stop_ctx(C), + ok. + +%% @doc A handler exceeding the context memory cap gets MemoryError there; +%% the loop keeps serving other connections. +test_memory_cap_in_handler(Config) -> + case probe_memory_limits(Config) of + false -> + {skip, "runtime started without enable_memory_limits"}; + true -> + {ok, C} = py_context:new(#{mode => owngil, memory_limit => 64 * 1024 * 1024}), + setup_ctx(C, Config), + ok = py_context:start_loop(C), + {LSock, Port, LFd} = listen(), + {ok, Dup} = py:dup_fd(LFd), + {ok, <<"serving">>} = py_context:submit_await(C, py_test_workerloop, serve_greedy, [Dup]), + %% greedy request: the handler tries to allocate past the cap + {ok, S1} = gen_tcp:connect(?HOST, Port, [binary, {active, false}], 5000), + ok = gen_tcp:send(S1, <<"hog">>), + R1 = gen_tcp:recv(S1, 0, 30000), + gen_tcp:close(S1), + ct:log("greedy handler replied ~p", [R1]), + {ok, <<"memoryerror">>} = R1, + %% normal request still served + {ok, S2} = gen_tcp:connect(?HOST, Port, [binary, {active, false}], 5000), + ok = gen_tcp:send(S2, <<"x">>), + {ok, <<"ok:x">>} = gen_tcp:recv(S2, 0, 5000), + gen_tcp:close(S2), + ok = py_context:stop_loop(C), + gen_tcp:close(LSock), + stop_ctx(C), + ok + end. + +%% @doc A loop wedged in a blocking C call is interrupted once the call +%% returns; the loop exits and the context recovers. +test_interrupt_wedged_loop(Config) -> + C = new_ctx(Config), + ok = py_context:start_loop(C), + {ok, _} = py_context:submit(C, py_test_workerloop, block_loop, [3]), + timer:sleep(200), + T0 = erlang:monotonic_time(millisecond), + ok = py_context:interrupt(C), + receive {py_loop_exit, C, {error, interrupted}} -> + Elapsed = erlang:monotonic_time(millisecond) - T0, + ct:log("wedged loop interrupted after ~p ms", [Elapsed]), + true = Elapsed < 6000 + after 10000 -> ct:fail(loop_not_interrupted) + end, + {ok, 4} = py_context:eval(C, <<"2+2">>, #{}, 5000), + stop_ctx(C), + ok. + +%%% ============================================================================ +%%% Helpers +%%% ============================================================================ + +new_ctx(Config) -> + {ok, C} = py_context:new(#{mode => owngil}), + setup_ctx(C, Config), + C. + +setup_ctx(C, Config) -> + TestDir = ?config(test_dir, Config), + Code = iolist_to_binary(io_lib:format( + "import sys\nif '~s' not in sys.path:\n sys.path.insert(0, '~s')\n" + "import py_test_workerloop\n", [TestDir, TestDir])), + ok = py_context:exec(C, Code). + +stop_ctx(C) -> + try py_context:stop(C) catch _:_ -> ok end, + ok. + +listen() -> + {ok, LSock} = gen_tcp:listen(0, [binary, {ip, ?HOST}, {active, false}, {backlog, 1024}]), + {ok, Port} = inet:port(LSock), + {ok, LFd} = inet:getfd(LSock), + {LSock, Port, LFd}. + +roundtrip(Port, Data) -> + case gen_tcp:connect(?HOST, Port, [binary, {active, false}], 5000) of + {ok, S} -> + ok = gen_tcp:send(S, Data), + R = case gen_tcp:recv(S, 0, 5000) of + {ok, Bin} -> Bin; + Other -> Other + end, + gen_tcp:close(S), + R; + Error -> + Error + end. + +parallel_roundtrips(Port, N, Clients) -> + Self = self(), + Per = N div Clients, + [spawn_link(fun() -> + Self ! {rt, [roundtrip(Port, <<"x">>) || _ <- lists:seq(1, Per)]} + end) || _ <- lists:seq(1, Clients)], + lists:append([receive {rt, L} -> L after 300000 -> [] end || _ <- lists:seq(1, Clients)]). + +traffic_loop(Socks, Parent) -> + traffic_loop(Socks, Parent, 0). + +traffic_loop(Socks, Parent, Count) -> + receive + stop -> + [gen_tcp:close(S) || S <- Socks], + Parent ! {traffic_done, Count} + after 0 -> + [ok = gen_tcp:send(S, <<"ping">>) || S <- Socks], + [{ok, <<"pong">>} = gen_tcp:recv(S, 4, 10000) || S <- Socks], + traffic_loop(Socks, Parent, Count + length(Socks)) + end. + +probe_memory_limits(Config) -> + case py_context:new(#{mode => owngil}) of + {ok, Ctx} -> + setup_ctx(Ctx, Config), + Ref = py_context:get_nif_ref(Ctx), + Result = py_nif:context_set_memory_limit(Ref, 0), + py_context:stop(Ctx), + Result =:= ok; + _ -> + false + end. + +fd_count() -> + case os:type() of + {unix, linux} -> length(element(2, file:list_dir("/proc/self/fd"))); + {unix, _} -> length(element(2, file:list_dir("/dev/fd"))); + _ -> 0 + end. + +log(#{msg := Msg}, #{config := Pid}) -> + Text = case Msg of + {string, S} -> unicode:characters_to_binary(S); + {report, R} -> unicode:characters_to_binary(io_lib:format("~p", [R])); + {Fmt, Args} -> unicode:characters_to_binary(io_lib:format(Fmt, Args)) + end, + case binary:match(Text, [<<"erts_poll">>, <<"stealing control">>]) of + nomatch -> ok; + _ -> Pid ! {poll_report, Text} + end, + ok. + +collect_reports() -> + receive {poll_report, T} -> [T | collect_reports()] + after 200 -> [] + end. + +flush() -> + receive _ -> flush() after 0 -> ok end. From 71ab9f58f633fefe45149488ad43023cc0909460 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Sat, 15 Aug 2026 19:53:11 +0200 Subject: [PATCH 02/10] Return more from process_ready_tasks when tasks remain behind a running loop Tasks beyond MAX_TASK_BATCH queued before the worker ran were stranded: submit_task sends no new wakeup while one is pending and the running-loop branch returned ok. Seen as test_submit_ordering timeouts on slow CI. --- c_src/py_event_loop.c | 7 +++++++ test/py_worker_loop_SUITE.erl | 8 ++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/c_src/py_event_loop.c b/c_src/py_event_loop.c index 9c1a1b2..c1ba2e3 100644 --- a/c_src/py_event_loop.c +++ b/c_src/py_event_loop.c @@ -3414,6 +3414,13 @@ ERL_NIF_TERM nif_process_ready_tasks(ErlNifEnv *env, int argc, pthread_cond_broadcast(&loop->event_cond); pthread_mutex_unlock(&loop->mutex); } + /* Same contract as the tail of this function: tasks beyond the + * batch limit are still queued, tell the worker to come back + * (submit_task will not send another wakeup while one is + * pending, so returning ok here would strand them). */ + if (atomic_load(&loop->task_count) > 0) { + return ATOM_MORE; + } return ATOM_OK; } } else { diff --git a/test/py_worker_loop_SUITE.erl b/test/py_worker_loop_SUITE.erl index e3666e2..e08a582 100644 --- a/test/py_worker_loop_SUITE.erl +++ b/test/py_worker_loop_SUITE.erl @@ -209,14 +209,18 @@ test_submit_errors_reported(Config) -> ok. %% @doc A burst of submits from one caller completes and comes back in order. +%% More than MAX_TASK_BATCH (64) tasks are queued before the worker gets to +%% them, so this also covers the running-loop branch returning `more'. test_submit_ordering(Config) -> C = new_ctx(Config), ok = py_context:start_loop(C), + {ok, LoopRef} = py_context:loop_ref(C), Refs = [begin - {ok, R} = py_context:submit(C, py_test_workerloop, add, [I, 0]), + R = make_ref(), + ok = py_nif:submit_task(LoopRef, self(), R, <<"py_test_workerloop">>, <<"add">>, [I, 0], #{}), {I, R} end || I <- lists:seq(1, 500)], - [{ok, I} = py_event_loop:await(R, 5000) || {I, R} <- Refs], + [{ok, I} = py_event_loop:await(R, 10000) || {I, R} <- Refs], ok = py_context:stop_loop(C), stop_ctx(C), ok. From aa07a70476f148278f9225406aee06fbdc30b899 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Sat, 15 Aug 2026 20:03:36 +0200 Subject: [PATCH 03/10] Report incomplete tasks in test_submit_ordering --- test/py_worker_loop_SUITE.erl | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/test/py_worker_loop_SUITE.erl b/test/py_worker_loop_SUITE.erl index e08a582..6b8063a 100644 --- a/test/py_worker_loop_SUITE.erl +++ b/test/py_worker_loop_SUITE.erl @@ -220,7 +220,15 @@ test_submit_ordering(Config) -> ok = py_nif:submit_task(LoopRef, self(), R, <<"py_test_workerloop">>, <<"add">>, [I, 0], #{}), {I, R} end || I <- lists:seq(1, 500)], - [{ok, I} = py_event_loop:await(R, 10000) || {I, R} <- Refs], + Results = [{I, py_event_loop:await(R, 30000)} || {I, R} <- Refs], + Bad = [X || {I, Res} = X <- Results, Res =/= {ok, I}], + case Bad of + [] -> ok; + _ -> ct:log("~p of 500 tasks did not complete: ~p", [length(Bad), lists:sublist(Bad, 10)]), + ct:log("loop alive: ~p", [py_context:submit_await(C, py_test_workerloop, sync_add, [1, 1])]), + ct:log("late results in mailbox: ~p", [erlang:process_info(self(), message_queue_len)]), + ct:fail({tasks_incomplete, length(Bad)}) + end, ok = py_context:stop_loop(C), stop_ctx(C), ok. From b7d06596dae6700c120e001c8bbe8bca8d0f6651 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Sat, 15 Aug 2026 20:40:37 +0200 Subject: [PATCH 04/10] Bound result collection in test_submit_ordering --- test/py_worker_loop_SUITE.erl | 27 +++++++++++++++++++++++---- 1 file changed, 23 insertions(+), 4 deletions(-) diff --git a/test/py_worker_loop_SUITE.erl b/test/py_worker_loop_SUITE.erl index 6b8063a..a2764e9 100644 --- a/test/py_worker_loop_SUITE.erl +++ b/test/py_worker_loop_SUITE.erl @@ -220,13 +220,18 @@ test_submit_ordering(Config) -> ok = py_nif:submit_task(LoopRef, self(), R, <<"py_test_workerloop">>, <<"add">>, [I, 0], #{}), {I, R} end || I <- lists:seq(1, 500)], - Results = [{I, py_event_loop:await(R, 30000)} || {I, R} <- Refs], - Bad = [X || {I, Res} = X <- Results, Res =/= {ok, I}], + %% Collect within one overall deadline so a strand shows up as a count, + %% not as a timetrap + Deadline = erlang:monotonic_time(millisecond) + 30000, + Results = collect_results(Refs, Deadline, #{}), + Bad = [{I, maps:get(R, Results, missing)} || {I, R} <- Refs, + maps:get(R, Results, missing) =/= {ok, I}], case Bad of [] -> ok; _ -> ct:log("~p of 500 tasks did not complete: ~p", [length(Bad), lists:sublist(Bad, 10)]), - ct:log("loop alive: ~p", [py_context:submit_await(C, py_test_workerloop, sync_add, [1, 1])]), - ct:log("late results in mailbox: ~p", [erlang:process_info(self(), message_queue_len)]), + ct:log("loop alive (sync): ~p", [py_context:submit_await(C, py_test_workerloop, sync_add, [1, 1])]), + ct:log("loop alive (coro): ~p", [py_context:submit_await(C, py_test_workerloop, add, [1, 1])]), + ct:log("mailbox: ~p", [erlang:process_info(self(), message_queue_len)]), ct:fail({tasks_incomplete, length(Bad)}) end, ok = py_context:stop_loop(C), @@ -531,6 +536,20 @@ roundtrip(Port, Data) -> gen_tcp:close(S), R. +collect_results([], _Deadline, Acc) -> + Acc; +collect_results(Refs, Deadline, Acc) -> + Wait = max(0, Deadline - erlang:monotonic_time(millisecond)), + receive + {async_result, R, Res} -> + case lists:keytake(R, 2, Refs) of + {value, _, Rest} -> collect_results(Rest, Deadline, Acc#{R => Res}); + false -> collect_results(Refs, Deadline, Acc) + end + after Wait -> + Acc + end. + wait_until(Fun, TimeoutMs) -> Deadline = erlang:monotonic_time(millisecond) + TimeoutMs, wait_until_loop(Fun, Deadline). From 7965359dfe60676d611b06ff83fc2b6c736bb195 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Sat, 15 Aug 2026 20:48:30 +0200 Subject: [PATCH 05/10] More detail in test_submit_ordering failure --- test/py_worker_loop_SUITE.erl | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/py_worker_loop_SUITE.erl b/test/py_worker_loop_SUITE.erl index a2764e9..c80d410 100644 --- a/test/py_worker_loop_SUITE.erl +++ b/test/py_worker_loop_SUITE.erl @@ -232,7 +232,9 @@ test_submit_ordering(Config) -> ct:log("loop alive (sync): ~p", [py_context:submit_await(C, py_test_workerloop, sync_add, [1, 1])]), ct:log("loop alive (coro): ~p", [py_context:submit_await(C, py_test_workerloop, add, [1, 1])]), ct:log("mailbox: ~p", [erlang:process_info(self(), message_queue_len)]), - ct:fail({tasks_incomplete, length(Bad)}) + Alive = py_context:submit_await(C, py_test_workerloop, add, [1, 1]), + ct:fail({tasks_incomplete, length(Bad), lists:sublist(Bad, 6), + {first_bad_index, element(1, hd(Bad))}, {loop_alive, Alive}}) end, ok = py_context:stop_loop(C), stop_ctx(C), From e3d1092fa9e4960d145c0d4462e8e07149bde0a0 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Sat, 15 Aug 2026 20:54:59 +0200 Subject: [PATCH 06/10] Dequeue exactly the consumed bytes per task in process_ready_tasks The io queue can store several small task binaries in one iovec element; dequeuing iov_len per task dropped every task after the first in that element (every second submit lost on slow CI runners). --- CHANGELOG.md | 8 ++++++++ c_src/py_event_loop.c | 12 ++++++++---- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6799b79..91147c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,14 @@ (`_release_fd_resource(fd_key, take_ownership)`), which closes it from the select stop callback; the reselect path and the close path serialise on the loop mutex. 10k connections across four workers now log nothing. +- **Queued tasks dropped in pairs** - `py_nif:process_ready_tasks/1` dequeued + one whole iovec element per task; when erts stored several small task + binaries in one element (tasks queued behind a busy worker), every task + after the first in that element was lost, seen as every second + `submit_task` never answering on slow machines. It now dequeues exactly the + bytes each term consumed. Tasks beyond the batch limit queued behind a + running loop were also left waiting for the next wakeup; the running-loop + path now returns `more` like the idle path. - **Re-arming a read select from the Python thread** - re-selecting READ on an fd the BEAM had moved into a scheduler poll set crashed inside `enif_select` when done from the loop thread (transport `resume_reading`, diff --git a/c_src/py_event_loop.c b/c_src/py_event_loop.c index c1ba2e3..4eb45c5 100644 --- a/c_src/py_event_loop.c +++ b/c_src/py_event_loop.c @@ -2990,8 +2990,9 @@ ERL_NIF_TERM nif_process_ready_tasks(ErlNifEnv *env, int argc, } ERL_NIF_TERM task_term; - if (enif_binary_to_term(term_env, task_bin.data, task_bin.size, - &task_term, ERL_NIF_BIN2TERM_SAFE) == 0) { + size_t consumed = enif_binary_to_term(term_env, task_bin.data, task_bin.size, + &task_term, ERL_NIF_BIN2TERM_SAFE); + if (consumed == 0) { return_pooled_env(loop, term_env); /* Dequeue and skip this malformed task */ enif_ioq_deq(loop->task_queue, iov[0].iov_len, NULL); @@ -3004,8 +3005,11 @@ ERL_NIF_TERM nif_process_ready_tasks(ErlNifEnv *env, int argc, tasks[num_tasks].task_term = task_term; num_tasks++; - /* Dequeue (we've copied the data) */ - enif_ioq_deq(loop->task_queue, iov[0].iov_len, NULL); + /* Dequeue exactly the bytes of this term. The io queue merges small + * binaries enqueued back to back into one iovec element, so a slow + * consumer can find several tasks in iov[0]; dequeuing iov_len would + * silently drop the ones after the first. */ + enif_ioq_deq(loop->task_queue, consumed, NULL); atomic_fetch_sub(&loop->task_count, 1); } From 54c4023e28fd08466cbfe82bc1d18e9ff9b496a3 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Sat, 15 Aug 2026 21:04:04 +0200 Subject: [PATCH 07/10] Diagnostics: report which tasks started --- test/py_test_workerloop.py | 20 ++++++++++++++++++++ test/py_worker_loop_SUITE.erl | 5 ++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/test/py_test_workerloop.py b/test/py_test_workerloop.py index 94d2734..e7a9026 100644 --- a/test/py_test_workerloop.py +++ b/test/py_test_workerloop.py @@ -118,11 +118,31 @@ async def adopt(fd): return 'adopted' +_started = [] + + async def add(a, b): + _started.append(a) await asyncio.sleep(0.001) return a + b +def started(): + """Which add() calls actually started (diagnostics).""" + return list(_started) + + +def scheduled_stats(): + """Loop side view: how many tasks were created and how many are pending.""" + loop = asyncio.get_event_loop_policy().get_event_loop() if False else None + try: + import asyncio as _a + tasks = _a.all_tasks(loop=None) if False else [] + except Exception: + tasks = [] + return len(_started), len(tasks) + + def sync_add(a, b): return a + b diff --git a/test/py_worker_loop_SUITE.erl b/test/py_worker_loop_SUITE.erl index c80d410..c9d8e5b 100644 --- a/test/py_worker_loop_SUITE.erl +++ b/test/py_worker_loop_SUITE.erl @@ -233,8 +233,11 @@ test_submit_ordering(Config) -> ct:log("loop alive (coro): ~p", [py_context:submit_await(C, py_test_workerloop, add, [1, 1])]), ct:log("mailbox: ~p", [erlang:process_info(self(), message_queue_len)]), Alive = py_context:submit_await(C, py_test_workerloop, add, [1, 1]), + Started = py_context:submit_await(C, py_test_workerloop, started, []), + StartedN = case Started of {ok, L} -> length(L); _ -> Started end, ct:fail({tasks_incomplete, length(Bad), lists:sublist(Bad, 6), - {first_bad_index, element(1, hd(Bad))}, {loop_alive, Alive}}) + {first_bad_index, element(1, hd(Bad))}, {loop_alive, Alive}, + {started, StartedN}, {started_head, case Started of {ok, L2} -> lists:sublist(L2, 12); _ -> Started end}}) end, ok = py_context:stop_loop(C), stop_ctx(C), From 2925d0f04225d2eb3ab31ed1051944909fe76624 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Sat, 15 Aug 2026 21:14:01 +0200 Subject: [PATCH 08/10] Diagnostics: loop state on failure --- test/py_test_workerloop.py | 33 ++++++++++++++++++++++++--------- test/py_worker_loop_SUITE.erl | 7 +++---- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/test/py_test_workerloop.py b/test/py_test_workerloop.py index e7a9026..a233ef5 100644 --- a/test/py_test_workerloop.py +++ b/test/py_test_workerloop.py @@ -132,15 +132,30 @@ def started(): return list(_started) -def scheduled_stats(): - """Loop side view: how many tasks were created and how many are pending.""" - loop = asyncio.get_event_loop_policy().get_event_loop() if False else None - try: - import asyncio as _a - tasks = _a.all_tasks(loop=None) if False else [] - except Exception: - tasks = [] - return len(_started), len(tasks) +_woke = [] + + +async def add_traced(a, b): + """add() that records wake up: which sleeps came back.""" + _started.append(a) + await asyncio.sleep(0.001) + _woke.append(a) + return a + b + + +async def loop_state(): + """Loop side view for diagnostics.""" + loop = asyncio.get_running_loop() + timers = getattr(loop, '_timers', {}) + return { + 'started': len(_started), + 'woke': len(_woke), + 'woke_head': _woke[:12], + 'timers_left': len(timers), + 'timer_ids': sorted(timers)[:12], + 'ready': len(getattr(loop, '_ready', [])), + 'tasks': len(asyncio.all_tasks()), + } def sync_add(a, b): diff --git a/test/py_worker_loop_SUITE.erl b/test/py_worker_loop_SUITE.erl index c9d8e5b..db41475 100644 --- a/test/py_worker_loop_SUITE.erl +++ b/test/py_worker_loop_SUITE.erl @@ -217,7 +217,7 @@ test_submit_ordering(Config) -> {ok, LoopRef} = py_context:loop_ref(C), Refs = [begin R = make_ref(), - ok = py_nif:submit_task(LoopRef, self(), R, <<"py_test_workerloop">>, <<"add">>, [I, 0], #{}), + ok = py_nif:submit_task(LoopRef, self(), R, <<"py_test_workerloop">>, <<"add_traced">>, [I, 0], #{}), {I, R} end || I <- lists:seq(1, 500)], %% Collect within one overall deadline so a strand shows up as a count, @@ -233,11 +233,10 @@ test_submit_ordering(Config) -> ct:log("loop alive (coro): ~p", [py_context:submit_await(C, py_test_workerloop, add, [1, 1])]), ct:log("mailbox: ~p", [erlang:process_info(self(), message_queue_len)]), Alive = py_context:submit_await(C, py_test_workerloop, add, [1, 1]), - Started = py_context:submit_await(C, py_test_workerloop, started, []), - StartedN = case Started of {ok, L} -> length(L); _ -> Started end, + LoopState = py_context:submit_await(C, py_test_workerloop, loop_state, []), ct:fail({tasks_incomplete, length(Bad), lists:sublist(Bad, 6), {first_bad_index, element(1, hd(Bad))}, {loop_alive, Alive}, - {started, StartedN}, {started_head, case Started of {ok, L2} -> lists:sublist(L2, 12); _ -> Started end}}) + {loop_state, LoopState}}) end, ok = py_context:stop_loop(C), stop_ctx(C), From c998d340a50607c99631c913d71c9c74ab5f03e1 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Sat, 15 Aug 2026 21:16:18 +0200 Subject: [PATCH 09/10] Skip the worker round trip for events while run_forever drives the loop A loop running on its own thread consumes pending events itself; sending task_ready for every timer or fd event made the worker attach to the subinterpreter and take its GIL for nothing. Track that state in the loop struct (_set_running_for) and only wake the loop thread. --- c_src/py_event_loop.c | 51 ++++++++++++++++++++++++++++++++++++-- c_src/py_event_loop.h | 5 ++++ priv/_erlang_impl/_loop.py | 13 ++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/c_src/py_event_loop.c b/c_src/py_event_loop.c index 4eb45c5..198b048 100644 --- a/c_src/py_event_loop.c +++ b/c_src/py_event_loop.c @@ -3018,7 +3018,19 @@ ERL_NIF_TERM nif_process_ready_tasks(ErlNifEnv *env, int argc, /* NOTE: We do NOT return early here even if num_tasks == 0. * We may have pending timer/FD events that need _run_once to process. * The first check (task_count == 0 && pending_count == 0) at the start - * of this function already handles the case where there's truly no work. */ + * of this function already handles the case where there's truly no work. + * + * Exception: a loop driven by run_forever on its own thread consumes + * pending events itself. With no tasks to schedule there is nothing for + * us to do under its GIL; wake it and leave. */ + if (num_tasks == 0 && atomic_load(&loop->py_running)) { + if (!loop->shutdown) { + pthread_mutex_lock(&loop->mutex); + pthread_cond_broadcast(&loop->event_cond); + pthread_mutex_unlock(&loop->mutex); + } + return ATOM_OK; + } /* ======================================================================== * PHASE 2: Process all tasks WITH GIL (Python operations) @@ -4435,7 +4447,7 @@ bool event_loop_add_pending(erlang_event_loop_t *loop, event_type_t type, * * Uses the same coalescing logic as submit_task to avoid message floods. */ - if (loop->has_worker) { + if (loop->has_worker && !atomic_load(&loop->py_running)) { if (!atomic_exchange(&loop->task_wake_pending, true)) { ErlNifEnv *msg_env = enif_alloc_env(); if (msg_env != NULL) { @@ -8137,6 +8149,40 @@ static PyObject *py_cancel_timer_for(PyObject *self, PyObject *args) { Py_RETURN_NONE; } +/* Python function: _set_running_for(capsule, running) -> None + * + * Marks the loop as driven by run_forever on the calling thread (or not). + * See erlang_event_loop_t.py_running. */ +static PyObject *py_set_running_for(PyObject *self, PyObject *args) { + (void)self; + PyObject *capsule; + int running; + + if (!PyArg_ParseTuple(args, "Op", &capsule, &running)) { + return NULL; + } + erlang_event_loop_t *loop = loop_from_capsule(capsule); + if (loop == NULL) { + PyErr_Clear(); + Py_RETURN_NONE; + } + atomic_store(&loop->py_running, running ? true : false); + if (!running && loop->has_worker && !loop->shutdown) { + /* Events queued while we were running but not yet consumed must + * now be handled by the worker path again */ + if (atomic_load(&loop->pending_count) > 0 && + !atomic_exchange(&loop->task_wake_pending, true)) { + ErlNifEnv *msg_env = enif_alloc_env(); + if (msg_env != NULL) { + enif_send(NULL, &loop->worker_pid, msg_env, + enif_make_atom(msg_env, "task_ready")); + enif_free_env(msg_env); + } + } + } + Py_RETURN_NONE; +} + /* Python function: _wakeup_for(capsule) -> None */ static PyObject *py_wakeup_for(PyObject *self, PyObject *args) { (void)self; @@ -8275,6 +8321,7 @@ static PyMethodDef PyEventLoopMethods[] = { {"_set_global_loop_ref", py_set_global_loop_ref, METH_VARARGS, "Store Python loop reference in global loop"}, {"_run_once_native_for", py_run_once_for, METH_VARARGS, "Combined poll + get_pending for specific loop"}, {"_get_pending_for", py_get_pending_for, METH_VARARGS, "Get and clear pending events for specific loop"}, + {"_set_running_for", py_set_running_for, METH_VARARGS, "Mark loop as running under run_forever"}, {"_wakeup_for", py_wakeup_for, METH_VARARGS, "Wake up specific event loop"}, {"_is_initialized_for", py_is_initialized_for, METH_VARARGS, "Check if specific loop is initialized"}, {"_add_reader_for", py_add_reader_for, METH_VARARGS, "Register fd for read monitoring on specific loop"}, diff --git a/c_src/py_event_loop.h b/c_src/py_event_loop.h index 4cc457e..2485e5b 100644 --- a/c_src/py_event_loop.h +++ b/c_src/py_event_loop.h @@ -357,6 +357,11 @@ typedef struct erlang_event_loop { * through loop_gil_acquire(). Guarded by mutex. */ int external_attached; + /** @brief True while ErlangEventLoop.run_forever() drives this loop on + * its own thread. Pending events then need no task_ready round trip + * through the worker: the loop thread picks them up itself. */ + _Atomic bool py_running; + /* ========== Async Task Queue (uvloop-inspired) ========== */ /* * Future optimization: Replace serialized task queue with native MPSC diff --git a/priv/_erlang_impl/_loop.py b/priv/_erlang_impl/_loop.py index c43ab30..7dfebe6 100644 --- a/priv/_erlang_impl/_loop.py +++ b/priv/_erlang_impl/_loop.py @@ -227,6 +227,14 @@ def run_forever(self): self._running = True # Don't reset _stopping here - honor stop() called before run_forever() + # Tell the NIF this loop consumes its own events from now on + set_running = getattr(self._pel, '_set_running_for', None) + if set_running is not None: + try: + set_running(self._loop_capsule, True) + except Exception: + set_running = None + # Register as the running loop old_running_loop = events._get_running_loop() events._set_running_loop(self) @@ -237,6 +245,11 @@ def run_forever(self): events._set_running_loop(old_running_loop) self._stopping = False self._running = False + if set_running is not None: + try: + set_running(self._loop_capsule, False) + except Exception: + pass self._thread_id = None self._set_coroutine_origin_tracking(False) From ab442ea470b9da1cc5e9181701896d605df8d4f6 Mon Sep 17 00:00:00 2001 From: Benoit Chesneau Date: Sat, 15 Aug 2026 21:22:18 +0200 Subject: [PATCH 10/10] Do not hand pooled handles to call_soon/call_at callers asyncio cancels a handle after it ran (sleep's finally); a recycled handle then cancels the callback that reused it. Only _dispatch fd handles are pooled now. Explains the every-second-task loss on CI. --- CHANGELOG.md | 8 +++++ priv/_erlang_impl/_loop.py | 34 ++++++++++++++---- priv/tests/test_loop_helpers.py | 64 +++++++++++++++++++++++++++++++++ 3 files changed, 100 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 91147c0..b5ddd25 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,14 @@ bytes each term consumed. Tasks beyond the batch limit queued behind a running loop were also left waiting for the next wakeup; the running-loop path now returns `more` like the idle path. +- **Recycled handles cancelled by their previous owner** - `ErlangEventLoop` + handed pooled `Handle` objects out of `call_soon` (and out of `call_at` + when the delay rounded to zero, which `asyncio.sleep(0.001)` does depending + on the clock value). asyncio cancels such handles after they ran + (`sleep` does in its `finally`), which cancelled whatever callback had been + given the recycled handle since: every second sleeper never woke. Only the + fd event handles created inside `_dispatch` are pooled now; `call_soon` + and `call_at` return fresh handles. - **Re-arming a read select from the Python thread** - re-selecting READ on an fd the BEAM had moved into a scheduler poll set crashed inside `enif_select` when done from the loop thread (transport `resume_reading`, diff --git a/priv/_erlang_impl/_loop.py b/priv/_erlang_impl/_loop.py index 7dfebe6..e9f0b23 100644 --- a/priv/_erlang_impl/_loop.py +++ b/priv/_erlang_impl/_loop.py @@ -49,6 +49,15 @@ EVENT_TYPE_TIMER = 3 +class _PooledHandle(events.Handle): + """Handle recycled through ErlangEventLoop._handle_pool. + + Only fd event dispatch creates these; they never leave the loop, so no + one can cancel one after it ran and hit its next occupant. + """ + __slots__ = () + + class ErlangEventLoop(asyncio.AbstractEventLoop): """asyncio event loop backed by Erlang's scheduler. @@ -392,7 +401,14 @@ def call_soon(self, callback, *args, context=None): Uses handle pooling (uvloop-style) to reduce allocations. """ self._check_closed() - handle = self._get_handle(callback, args, context) + # A handle handed to the caller must not come from the pool: asyncio + # code keeps call_soon/call_later handles and cancels them after they + # ran (asyncio.sleep does), which would cancel whatever callback the + # recycled handle carries by then. Pooling stays for the fd event + # handles created in _dispatch, which never leave the loop. + if context is None: + context = contextvars.copy_context() + handle = events.Handle(callback, args, self, context) self._ready_append(handle) return handle @@ -420,10 +436,15 @@ def call_at(self, when, callback, *args, context=None): """Schedule a callback to be called at a specific time.""" self._check_closed() - # For zero or past times, schedule immediately via call_soon + # For zero or past times, run at the next iteration. Return a real + # TimerHandle (never pooled): callers cancel it after it ran. delay_ms = int((when - self.time()) * 1000) if delay_ms <= 0: - return self.call_soon(callback, *args, context=context) + if context is None: + context = contextvars.copy_context() + handle = events.TimerHandle(when, callback, args, self, context) + self._ready_append(handle) + return handle callback_id = self._next_id() @@ -1244,7 +1265,7 @@ def _get_handle(self, callback, args, context=None): handle._context = context return handle except IndexError: - return events.Handle(callback, args, self, context) + return _PooledHandle(callback, args, self, context) def _return_handle(self, handle): """Return a Handle to the pool for reuse. @@ -1256,8 +1277,9 @@ def _return_handle(self, handle): If the TimerHandle is recycled and reused for another callback, the cancel() call will incorrectly cancel the new callback. """ - # Don't pool TimerHandle - asyncio.sleep holds a reference and cancels it - if isinstance(handle, events.TimerHandle): + # Only handles created by _dispatch are recycled (see _PooledHandle); + # call_soon/call_at handles are held by callers who may cancel them + if type(handle) is not _PooledHandle: return if len(self._handle_pool) < self._handle_pool_max: diff --git a/priv/tests/test_loop_helpers.py b/priv/tests/test_loop_helpers.py index e24b5bd..74e6150 100644 --- a/priv/tests/test_loop_helpers.py +++ b/priv/tests/test_loop_helpers.py @@ -117,3 +117,67 @@ def runner(): if __name__ == '__main__': unittest.main() + + +class TestHandleReuse(unittest.TestCase): + """call_soon/call_at handles are the caller's: cancelling one after it + ran must not touch a later callback (the handle pool used to recycle + them, so asyncio.sleep's cancel in its finally block killed whichever + callback had been given the recycled handle).""" + + def _loop(self): + impl = _impl() + return impl.new_event_loop() + + def test_cancel_after_run_does_not_kill_next_callback(self): + loop = self._loop() + seen = [] + try: + h1 = loop.call_soon(seen.append, 1) + loop.call_soon(loop.stop) + loop.run_forever() + self.assertEqual(seen, [1]) + # h1 ran; a stale cancel must be a no-op + h1.cancel() + loop.call_soon(seen.append, 2) + loop.call_soon(loop.stop) + loop.run_forever() + self.assertEqual(seen, [1, 2]) + finally: + loop.close() + + def test_zero_delay_call_later_returns_timer_handle(self): + loop = self._loop() + seen = [] + try: + h = loop.call_later(0, seen.append, 'x') + self.assertIsInstance(h, asyncio.TimerHandle) + loop.call_soon(loop.stop) + loop.run_forever() + self.assertEqual(seen, ['x']) + h.cancel() # after it ran, no effect on anything else + loop.call_soon(seen.append, 'y') + loop.call_soon(loop.stop) + loop.run_forever() + self.assertEqual(seen, ['x', 'y']) + finally: + loop.close() + + def test_many_sleeps_all_wake(self): + """500 concurrent sleep(0.001): every one comes back, whether the + delay rounds to a timer or to the next iteration.""" + loop = self._loop() + woke = [] + + async def one(i): + await asyncio.sleep(0.001) + woke.append(i) + + async def main(): + await asyncio.gather(*(one(i) for i in range(500))) + + try: + loop.run_until_complete(main()) + finally: + loop.close() + self.assertEqual(sorted(woke), list(range(500)))