Skip to content

Releases: benoitc/erlang-python

3.1.1

31 May 20:22
d56181c

Choose a tag to compare

Changed

  • Lower minimum OTP to 27 - minimum_otp_vsn is now 27. The OTP 28/29 support work was source-compatible with 27 (the try ... catch cleanups build fine there), so the floor was raised further than needed. CI now also builds and runs the full Common Test suite on OTP 27 across Python 3.12/3.13/3.14.

Full Changelog: 3.1.0...v3.1.1

3.1.0

30 May 21:36

Choose a tag to compare

Fixed

  • NIF robustness hardening - make_py_error no longer passes a NULL message/type
    to enif_make_string/enif_make_atom when a Python exception's text isn't
    UTF-8-encodable; binary_to_string rejects names/code containing an embedded NUL
    (which would silently truncate a module/function/attr/code string) rather than
    truncating; a leaked split method object in the reactor buffer is released; and a
    stray debug fprintf on the normal worker send path is removed.

Security

  • No shell for venv/installer commands - py:ensure_venv and dependency
    installation now run the executables via open_port({spawn_executable, ...}) with an
    argument list instead of building a shell string for os:cmd. Venv paths, requirement
    files, and extras are passed literally, so shell metacharacters can't be injected. For
    uv, VIRTUAL_ENV is passed via the port {env, ...} option rather than a shell prefix.
  • Bounded shared state + safe stream/log builders - py_state gained an optional
    max_state_entries cap (default infinity, unchanged behavior) enforced with atomic
    admission so Python-driven state_set can't exhaust node memory, and its size counter
    is protected from corruption. The py:stream and logging helpers that build Python
    source now strictly validate module/function/kwarg names as identifiers (rejecting
    injection at positions where quoting is meaningless) and escape string-literal values
    including control characters.
  • Validated event-loop fd handles - The asyncio reader/writer integration no longer
    hands Python a raw fd_resource pointer as an integer key. Each handle is an opaque id
    validated against a registry on every use, so a stale, duplicate, or fabricated id is a
    safe no-op (or clean error) instead of a double-free or arbitrary-pointer dereference
    that crashed the node. fd_read/fd_write also moved to dirty IO schedulers.
  • OWN_GIL worker robustness (Python 3.14+) - A per-request allocation failure in
    a subinterpreter worker no longer breaks (and permanently kills) the worker command
    loop; it returns an error and keeps serving. The owngil_* dispatch NIFs now run on
    dirty IO schedulers and use non-blocking, deadline-bounded pipe reads and writes, so a
    stalled or dead worker can't wedge a scheduler forever. The internal SuspensionRequired
    exception is now looked up per-interpreter (like ProcessError), avoiding cross-
    interpreter object use under OWN_GIL.
  • Callback suspend/resume lifetime hardening - The worker resource is now kept
    alive for the lifetime of a suspended callback (it could previously be GC'd mid-
    suspension, causing a use-after-free on resume). A resume frees any prior result
    before storing a new one (no leak/double-replay on a duplicate resume), the
    pending-callback thread-local is cleared at the worker request boundary, and the
    callback-response pipe writes run on dirty schedulers with non-blocking, deadline-
    bounded writes so a stalled reader or large payload can't wedge a scheduler or
    desync the framed protocol.
  • Zero-copy buffer pinning - py_buffer no longer relocates (and frees) its
    storage while a Python memoryview points into it. A write that would grow the
    buffer while a view is held now returns an error instead of dangling the view into
    freed memory (a use-after-free that crashed the whole node).
  • Bounded recursion in type conversion - The Erlang<->Python converters now cap
    nesting depth, so a deeply nested term (or Python structure) returns a clean error
    instead of overflowing the C stack and crashing the whole node.
  • NULL-checked tuple allocation - Argument-tuple allocations in the call/eval paths
    are checked before use, and the Python->Erlang map conversion is bounded against
    mid-iteration dict mutation, closing two ways an allocation failure or re-entrant
    __str__ could corrupt memory.
  • Safe term decoding at the NIF boundary - All enif_binary_to_term calls now
    pass ERL_NIF_BIN2TERM_SAFE, preventing attacker-influenced data (notably a Python
    "__etf__:<base64>" callback result) from minting new, non-GC'd atoms and exhausting
    the atom table. Local-node pids/refs and already-existing atoms still round-trip
    unchanged; only brand-new atoms, remote-node pids/refs, and external funs in
    Python-supplied payloads are now rejected.

Changed

  • Support Erlang/OTP 28 and 29 - Validated builds and the full Common Test
    suite on OTP 28 and 29. Minimum supported OTP is now 28 (minimum_otp_vsn).
    CI tests OTP 28 and 29 across Python 3.12/3.13/3.14.
  • Replaced deprecated catch Expr cleanup calls with try ... catch ... end
    to silence the new OTP 29 default warning; behavior is unchanged.

3.0.0

03 May 13:47

Choose a tag to compare

3.0.0 (2026-05-03)

Breaking Changes

  • Simplified execution model - Only two public execution modes: worker and owngil

    • worker: Dedicated pthread per context with stable thread affinity (default)
    • owngil: Dedicated pthread + subinterpreter with own GIL (Python 3.14+)
    • Removed multi_executor and free_threaded from public API
    • Internal capability detection still tracks Python features
  • Removed py:num_executors/0 - Contexts now use per-context worker threads
    instead of a shared executor pool. This function is no longer needed.

  • py:execution_mode/0 returns worker | owngil - Based on the context_mode
    application configuration. Previously returned internal capabilities like
    free_threaded, subinterp, or multi_executor.

  • Removed py:async_stream/3,4 - Streaming async generators was never
    implemented behind the API and always returned {error, stream_not_implemented}.
    Use py:stream_start/3,4 for sync generators; async-generator support may
    return in a later release.

  • Removed num_executors / num_async_workers configuration - Both keys
    were no-ops after the v3.0 worker rework. Configure context count via
    num_contexts and the rate-limit ceiling via max_concurrent.

  • Strict context-mode validation at the NIF boundary - py_nif:context_create/1
    now returns {error, {invalid_mode, Atom}} for anything other than worker | owngil.
    Previously, callers that bypassed py_context (notably py_reactor_context)
    silently mapped any unknown atom — including legacy auto and subinterp
    to worker mode. Code that relied on that loophole must pass worker (or
    owngil) explicitly.

Fixed

  • py:async_call/3,4 + py:async_await/1,2 round-trip - Previously the
    await receive matched {py_response, _, _} while the event loop sent
    {async_result, _, _}, causing every async call to silently time out.
    Async calls now go directly through py_event_loop:create_task and
    py_event_loop:await.

  • py:async_gather/1,2 actually executes - Reimplemented as concurrent
    async_call submission with sequential async_await. Returns
    {ok, [Result1, ...]} on success or {error, {gather_failed, [{Idx, Reason}, ...]}}
    if any call fails. The previous implementation returned gather_not_implemented.

  • Thread-callback flakes (issue #63) - Six layered defects in the
    erlang.call/erlang.async_call plumbing could deliver wrong values to
    the wrong caller under load. Reads now loop on partial/EINTR with a
    monotonic deadline; sync writes use a single length-prefixed frame on a
    dirty I/O scheduler with deadlined non-blocking writes; the sync wire
    carries the originating callback id and the receiver discards mismatched
    frames; the async pipe has one writer process per fd with an
    atomics-bounded mailbox (?ASYNC_WRITER_MAX_QUEUE = 10000) and a
    resumable nonblocking parser on the read end; workers that fail to
    resync are unlinked from the pool, freed, and bounded by
    MAX_POISONED_WORKERS = 64.

Documentation

  • Audited every fenced code block in README.md and docs/*.md for
    current-API references. Fixed Py_GIL_OWN to PyInterpreterConfig_OWN_GIL
    in docs/scalability.md, corrected the multi_executor fallback claim
    in docs/migration.md, and repaired a broken SharedDict example in
    docs/shared-dict.md.
  • New test/coverage_audit.md maps every public py:* and erlang.* API
    to its test suite. Added cases for py:cast/4, py:async_gather/2, and
    py:dup_fd/1 so each documented API has a regression test.
  • New scripts/lint_doc_snippets.escript (driven by make lint-docs and
    CI) statically validates every Erlang py:Fn(/N) call and parses every
    Python block in the docs. Snippets that intentionally show removed APIs
    or REPL output opt out via <!-- skip-lint -->.

Changed

  • Per-context worker threads - Each context now gets its own dedicated pthread
    that handles all Python operations. This provides stable thread affinity for
    numpy/torch/tensorflow compatibility without needing a shared executor pool.

  • Async NIF dispatch - Context operations use async NIFs with message passing
    instead of blocking dirty schedulers. This improves concurrency under load.

  • Request queue per context - Replaced single-slot request pattern with proper
    request queues that support multiple concurrent callers.

  • No global asyncio policy install on Python 3.14+. asyncio.set_event_loop_policy
    was deprecated in 3.14 and is removed in 3.16. The Erlang integration's run path
    already uses loop_factory= (erlang.run/1, asyncio.Runner) so the global
    policy was only a convenience for bare asyncio.run() inside py:exec. We now
    skip the install on 3.14+ to avoid the deprecation warning. On 3.14+ use
    erlang.run(main) or asyncio.Runner(loop_factory=erlang.new_event_loop)
    explicitly. Behavior on Python 3.9–3.13 is unchanged. erlang.install() raises
    RuntimeError on 3.14+ (still emits a DeprecationWarning and works on 3.12–3.13).

Removed

  • Multi-executor pool (g_executors[], multi_executor_start/stop)
  • context_dispatch_call/eval/exec functions (dead code)
  • References to PY_MODE_MULTI_EXECUTOR in context operations
  • py_async_pool legacy gen_server (unused after async API rewire)
  • priv/_erlang_impl/_ssl.py (SSLTransport, create_ssl_transport) had no
    importer and was never wired into the asyncio event loop. Removed.
  • Internal py_util exports send_response/3, normalize_timeout/1, and
    normalize_timeout/2 had no callers anywhere. Removed. The module is
    marked @private; no external API changes.
  • Explicit py:subinterp_* handle API removed. py:subinterp_create/0,
    subinterp_destroy/1, subinterp_call/4,5, subinterp_eval/2,3,
    subinterp_exec/2, subinterp_cast/4, subinterp_async_call/4,
    subinterp_await/1,2, and subinterp_pool_* are all gone. Use
    py_context:new(#{mode => owngil}) instead — it gives the same
    parallelism with OTP supervision and automatic cleanup.
    py:subinterp_supported/0 (capability probe) and py:parallel/1
    (which routes through the context API) stay.
  • Internal py_execution_mode_t collapsed from 3 values to 2 (free_threaded
    / gil); py_nif:execution_mode/0 returns free_threaded | gil instead
    of the old free_threaded | subinterp | multi_executor.
  • examples/reactor_owngil_example.erl deleted (called nonexistent
    py:subinterp_reactor_* functions; pre-existing breakage).

v2.3.1

31 Mar 22:36

Choose a tag to compare

Changes

  • Add executor affinity for numpy/torch thread safety
  • Add shared-dict guide to hex docs

2.3.0

29 Mar 18:42

Choose a tag to compare

Removed

  • ASGI/WSGI Support - The py_asgi and py_wsgi modules have been removed
    • py_asgi:run/4,5 - ASGI application runner
    • py_wsgi:run/3,4 - WSGI application runner
    • For web framework integration, use py:call with event loop contexts or the Channel API
    • See Migration Guide for alternatives

Added

  • SharedDict - Process-scoped shared dictionaries for cross-process state
    • py:shared_dict_new/0 - Create a new SharedDict
    • py:shared_dict_get/2,3 - Get value with optional default
    • py:shared_dict_set/3 - Set key-value pair
    • py:shared_dict_del/2 - Delete a key
    • py:shared_dict_keys/1 - List all keys
    • py:shared_dict_destroy/1 - Explicit cleanup
    • Python access via erlang.SharedDict with dict-like interface
    • Mutex-protected for concurrent access (~300k ops/sec)
    • Pickle serialization for complex types
    • See SharedDict documentation for details

v2.2.0

24 Mar 01:36

Choose a tag to compare

Added

  • OWN_GIL Mode - True parallel Python execution with Python 3.14+ subinterpreters. Each subinterpreter runs with its own GIL in a dedicated thread, enabling true parallelism for CPU-bound workloads.

  • Process-Bound Python Environments - Per-Erlang-process Python namespaces with isolated globals/locals that persist across calls.

  • Event Loop Pool - py_event_loop_pool distributes async tasks with scheduler-affinity routing.

  • ByteChannel API - Raw byte streaming without term serialization. Ideal for HTTP bodies, file streaming, binary protocols.

  • PyBuffer API - Zero-copy buffer for WSGI input streams with file-like interface.

  • True streaming API - py:stream_start/3,4 and py:stream_cancel/1 for event-driven streaming from Python generators.

  • erlang.whereis(name) - Lookup registered Erlang PIDs from Python.

  • erlang.schedule_inline(callback) - Inline continuation scheduling.

  • py:spawn_call/3,4,5 - Fire-and-forget with result delivery.

  • Explicit bytes conversion - {bytes, Binary} tuple for round-trip safety.

  • Import caching API - py:import/1,2, py:add_import/1,2, py:add_path/1.

  • Per-interpreter preload code - Execute code in new interpreters with inherited globals.

Fixed

  • Channel notification for create_task
  • Channel waiter race condition
  • Event loop isolation and resource safety
  • Python 3.14 venv activation
  • OWN_GIL safety fixes (mutex leak, deadlock prevention, env validation)

Changed

  • py:cast is now fire-and-forget (use py:spawn_call for results)
  • OWN_GIL requires Python 3.14+
  • Removed auto-started io pool
  • Removed py_event_router
  • Config-based initialization for imports/paths

Performance

  • Direct NIF channel operations (up to 1760x speedup)
  • nif_process_ready_tasks optimization (~15% improvement)

See CHANGELOG.md for full details.

v2.1.0 - Async Task API

12 Mar 12:28

Choose a tag to compare

Added

  • Async Task API - uvloop-inspired task submission from Erlang

    • py_event_loop:run/3,4 - Blocking run of async Python functions
    • py_event_loop:create_task/3,4 - Non-blocking task submission with reference
    • py_event_loop:await/1,2 - Wait for task result with timeout
    • py_event_loop:spawn_task/3,4 - Fire-and-forget task execution
    • Thread-safe submission via enif_send (works from dirty schedulers)
    • See Async Task API docs
  • erlang.spawn_task(coro) - Spawn async tasks from sync and async contexts

    • Works where asyncio.get_running_loop() fails
    • Returns asyncio.Task for optional await/cancel
  • Explicit Scheduling API - Control dirty scheduler release from Python

    • erlang.schedule(callback, *args) - Release scheduler, continue via Erlang callback
    • erlang.schedule_py(module, func, args, kwargs) - Release scheduler, continue in Python
    • erlang.consume_time_slice(percent) - Check if NIF time slice exhausted
    • ScheduleMarker type for cooperative long-running tasks
  • Distributed Python Execution - Run Python across Erlang nodes

Changed

  • Event Loop Performance
    • Growable pending queue (256 to 16384)
    • Snapshot-detach pattern to reduce mutex contention
    • Callable cache (64 slots) avoids PyImport/GetAttr per task
    • Task wakeup coalescing

Fixed

  • ensure_venv always installs deps, even if venv exists
  • erlang.sleep() timing in sync context
  • time() returns fresh value when loop not running
  • Handle pooling bugs in ErlangEventLoop
  • Task wakeup race causing batch task stalls

v2.0.0

09 Mar 14:28

Choose a tag to compare

Highlights

  • Dual Pool Support - Separate pools for CPU-bound and I/O-bound operations with registration-based routing
  • Channel API - Bidirectional message passing between Erlang and Python (8x faster than Reactor for small messages)
  • OWN_GIL Subinterpreter Thread Pool - True parallelism with Python 3.12+ subinterpreters
  • Reactor API - FD-based protocol handling for building custom servers
  • Virtual Environment Management - Automatic venv creation with py:ensure_venv/2,3

Added

  • py:ensure_venv/2,3 - Automatic venv creation and activation
  • py:dup_fd/1 - Safe socket handoff from Erlang to Python
  • Dual pool support (default and io pools) with registration-based routing
  • Channel API (py_channel) for bidirectional message passing
  • OWN_GIL subinterpreter thread pool for true parallelism
  • erlang.reactor module for FD-based protocol handling
  • ETF encoding for PIDs and References
  • erlang.send(pid, term) for fire-and-forget message passing
  • Audit hook sandbox blocking fork/exec operations
  • Process-per-context architecture

Changed

  • py:call_async renamed to py:cast
  • Unified erlang Python module (removed separate erlang_asyncio)
  • Async worker backend replaced with event loop model
  • SuspensionRequired now inherits from BaseException

Deprecated

  • py_asgi module - use Channel API or Reactor API instead
  • py_wsgi module - use Channel API or Reactor API instead

Removed

  • Context affinity functions (py:bind, py:unbind, py:is_bound, py:with_context, py:ctx_*)
  • Signal handling support in ErlangEventLoop
  • Subprocess support in ErlangEventLoop

Fixed

  • Reactor context extending erlang module in subinterpreters
  • FD stealing and UDP connected socket issues
  • Timer scheduling for standalone ErlangEventLoop
  • Subinterpreter cleanup and thread worker re-registration
  • ProcessError exception class identity in subinterpreters

See CHANGELOG.md for full details.

v1.8.1

25 Feb 02:56

Choose a tag to compare

Fixed

  • ASGI scope caching bug - HTTP method was not treated as a dynamic field in the scope template cache. This caused incorrect method values when the same path was accessed with different HTTP methods (e.g., GET /path followed by POST /path would return method="GET" for both requests).

v1.8.0

25 Feb 01:23

Choose a tag to compare

Added

  • ASGI NIF Optimizations - Six optimizations for high-performance ASGI request handling

    • Direct Response Tuple Extraction - Extract (status, headers, body) directly without generic conversion
    • Pre-Interned Header Names - 16 common HTTP headers cached as PyBytes objects
    • Cached Status Code Integers - 14 common HTTP status codes cached as PyLong objects
    • Zero-Copy Request Body - Large bodies (≥1KB) use buffer protocol for zero-copy access
    • Scope Template Caching - Thread-local cache of 64 scope templates keyed by path hash
    • Lazy Header Conversion - Headers converted on-demand for requests with ≥4 headers
  • erlang_asyncio Module - Asyncio-compatible primitives using Erlang's native scheduler

    • erlang_asyncio.sleep(delay, result=None) - Sleep using Erlang's erlang:send_after/3
    • erlang_asyncio.run(coro) - Run coroutine with ErlangEventLoop
    • erlang_asyncio.gather(*coros) - Run coroutines concurrently
    • erlang_asyncio.wait_for(coro, timeout) - Wait with timeout
    • erlang_asyncio.wait(fs, timeout, return_when) - Wait for multiple futures
    • erlang_asyncio.create_task(coro) - Create background task
    • Event loop functions: get_event_loop(), new_event_loop(), set_event_loop(), get_running_loop()
  • Erlang Sleep NIF - Synchronous sleep primitive for Python

    • py_event_loop._erlang_sleep(delay_ms) - Sleep using Erlang timer
    • Releases GIL during sleep, no Python event loop overhead
  • Scalable I/O Model - Worker-per-context architecture

    • py_event_worker - Dedicated worker process per Python context
    • Combined FD event dispatch and reselect via handle_fd_event_and_reselect NIF
  • New Test Suite - test/py_erlang_sleep_SUITE.erl with 8 tests

Performance

  • ASGI marshalling optimizations - 40-60% improvement for typical ASGI workloads
  • Eliminates event loop overhead for sleep operations (~0.5-1ms saved per call)
  • Sub-millisecond timer precision via BEAM scheduler (vs 10ms asyncio polling)
  • Zero CPU when idle - event-driven, no polling

See CHANGELOG.md for full details.