Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,77 @@
# 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.
- **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.
- **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`,
`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
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
5 changes: 5 additions & 0 deletions c_src/py_callback.c
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading