docs/migration.md

# Migration Guide: v1.8.x to v2.0+

This guide covers breaking changes and migration steps when upgrading from erlang_python v1.8.x to v2.0 and later.

## Quick Checklist

- [ ] Rename `py:call_async` → `py:spawn_call` (with await) or `py:cast` (fire-and-forget)
- [ ] Replace `py:bind`/`py:unbind` with `py_context_router`
- [ ] Replace `py:ctx_*` functions with `py_context:*`
- [ ] Replace `erlang_asyncio` imports with `erlang`
- [ ] Replace `erlang_asyncio.run()` with `erlang.run()`
- [ ] Replace subprocess calls with Erlang ports
- [ ] Move signal handlers to Erlang level
- [ ] Review any `os.fork`/`os.exec` usage
- [ ] Update code relying on shared state between contexts (now isolated)

## Python Version Compatibility

| Python Version | GIL Mode | Notes |
|---------------|----------|-------|
| 3.9 - 3.11 | Shared GIL | Multi-executor mode, `py:execution_mode()` returns `multi_executor` |
| 3.12 - 3.13 | OWN_GIL subinterpreters | True parallelism, `py:execution_mode()` returns `subinterp` |
| 3.13t | Free-threaded | No GIL, `py:execution_mode()` returns `free_threaded` |
| 3.14+ | SHARED_GIL subinterpreters | Subinterpreters with shared GIL for C extension compatibility |

**Python 3.14 Support**: Full support for Python 3.14 including:
- SHARED_GIL subinterpreter mode for C extension compatibility
- Proper `sys.path` initialization in subinterpreters
- All asyncio features work correctly

**FreeBSD Support**: Improved fd handling on FreeBSD/kqueue platforms:
- Automatic fd duplication in `py_reactor_context` to prevent fd stealing errors
- `py:dup_fd/1` for explicit fd duplication when needed

## Architecture Changes

### OWN_GIL Subinterpreter Thread Pool (Python 3.12+)

The most significant change in v2.0 is the new execution model. On Python 3.12+, erlang_python now uses **OWN_GIL subinterpreters** for true parallelism:

**v1.8.x Architecture:**
- Single Python interpreter with shared GIL
- Worker pool with round-robin dispatch
- All workers share global state

**v2.0 Architecture:**
- N subinterpreters, each in its own thread with its own GIL
- Each subinterpreter has isolated state (no shared globals)
- True parallel execution without GIL contention
- 25-30% faster cast operations

**Impact on your code:**

1. **Isolated namespaces**: Variables defined in one context are not visible in others
   ```erlang
   %% v1.8.x - this worked (shared namespace)
   py:exec(<<"x = 42">>),
   {ok, 42} = py:eval(<<"x">>).  %% Might go to different worker

   %% v2.0 - use explicit context for shared state
   Ctx = py:context(1),
   py:exec(Ctx, <<"x = 42">>),
   {ok, 42} = py:eval(Ctx, <<"x">>).  %% Same context
   ```

2. **Module imports are per-context**: Each subinterpreter loads modules independently
   ```erlang
   %% Import in one context doesn't affect others
   Ctx1 = py:context(1),
   Ctx2 = py:context(2),
   py:exec(Ctx1, <<"import mymodule">>),
   %% Ctx2 does NOT have mymodule imported
   ```

3. **Use Shared State API for cross-context data**:
   ```python
   from erlang import state_set, state_get
   state_set("config", {"key": "value"})  # Available to all contexts
   ```

### Execution Mode Detection

Check which mode is active:

```erlang
%% Check execution mode
py:execution_mode().
%% => subinterp     (Python 3.12+ with OWN_GIL)
%% => free_threaded (Python 3.13t with --disable-gil)
%% => multi_executor (Python < 3.12)

%% Check if subinterpreters are supported
py:subinterp_supported().
%% => true | false
```

## New APIs

### `py:context/1` - Explicit Context Selection

Get a specific context by index for operations that need shared state:

```erlang
%% Get context 1 (1-based indexing)
Ctx = py:context(1),

%% All operations on Ctx share state
ok = py:exec(Ctx, <<"counter = 0">>),
ok = py:exec(Ctx, <<"counter += 1">>),
{ok, 1} = py:eval(Ctx, <<"counter">>).
```

### `py:start_contexts/0` - Initialize Context Pool

Explicitly start the context pool (usually done automatically):

```erlang
{ok, Contexts} = py:start_contexts().
%% Returns list of context PIDs
```

### `py_context_router` - Context Routing

The context router manages context distribution:

```erlang
%% Start with default contexts (one per scheduler)
{ok, Contexts} = py_context_router:start().

%% Start with custom count
{ok, Contexts} = py_context_router:start(#{contexts => 8}).

%% Get context for current scheduler
Ctx = py_context_router:get_context().

%% Get specific context
Ctx = py_context_router:get_context(1).

%% Bind current process to a context
ok = py_context_router:bind_context(Ctx).

%% Unbind
ok = py_context_router:unbind_context().

%% Get pool size
N = py_context_router:num_contexts().
```

## API Changes

### `py:call_async` renamed to `py:spawn_call`

The function for non-blocking Python calls has been renamed to follow gen_server conventions:

**Before (v1.8.x):**
```erlang
Ref = py:call_async(math, factorial, [100]),
{ok, Result} = py:await(Ref).
```

**After (v2.0):**
```erlang
Ref = py:spawn_call(math, factorial, [100]),
{ok, Result} = py:await(Ref).
```

The semantics are identical - `spawn_call` replaces `async_call`.

Note: `py:cast/3,4` is now fire-and-forget (returns `ok`, no await).

### `erlang_asyncio` module removed

The separate `erlang_asyncio` Python module has been consolidated into the main `erlang` module. Use `erlang.run()` with standard asyncio functions.

**Before (v1.8.x):**
```python
import erlang_asyncio

async def handler():
    await erlang_asyncio.sleep(0.1)
    return "done"

result = erlang_asyncio.run(handler())
```

**After (v2.0):**
```python
import erlang
import asyncio

async def handler():
    await asyncio.sleep(0.1)  # Standard asyncio, uses Erlang timers
    return "done"

result = erlang.run(handler())  # Run with Erlang event loop
```

**Function mapping:**

| v1.8.x | v2.0 |
|--------|------|
| `erlang_asyncio.run(coro)` | `erlang.run(coro)` |
| `erlang_asyncio.sleep(delay)` | `asyncio.sleep(delay)` inside `erlang.run()` |
| `erlang_asyncio.gather(*coros)` | `asyncio.gather(*coros)` inside `erlang.run()` |
| `erlang_asyncio.wait_for(coro, timeout)` | `asyncio.wait_for(coro, timeout)` inside `erlang.run()` |
| `erlang_asyncio.create_task(coro)` | `asyncio.create_task(coro)` inside `erlang.run()` |
| `erlang_asyncio.new_event_loop()` | `erlang.new_event_loop()` |

## Removed APIs

### ASGI/WSGI Modules (Removed)

The `py_asgi` and `py_wsgi` modules have been removed.

**Removed modules:**
- `py_asgi` - ASGI application runner
- `py_wsgi` - WSGI application runner

**Migration:** Use `py:call` with an event loop context or the [Channel API](channel.md) for web framework integration.

#### ASGI Alternative using py:call

```erlang
%% Instead of py_asgi:run/4, use py:call with an event loop context
{ok, Ctx} = py_context:start_link(1, auto),
Scope = #{
    type => <<"http">>,
    method => <<"GET">>,
    path => <<"/api/users">>
},
{ok, Response} = py:call(Ctx, myapp, handle_request, [Scope]).
```

#### WSGI Alternative using py:call

```erlang
%% Instead of py_wsgi:run/3, use py:call
{ok, Ctx} = py_context:start_link(1, auto),
Environ = #{
    <<"REQUEST_METHOD">> => <<"GET">>,
    <<"PATH_INFO">> => <<"/api/users">>,
    <<"SERVER_NAME">> => <<"localhost">>,
    <<"SERVER_PORT">> => <<"8080">>
},
{ok, Response} = py:call(Ctx, myapp, wsgi_app, [Environ]).
```

For more sophisticated web framework integration, consider the [Reactor API](reactor.md) or [Channel API](channel.md).

## Removed Features

### Context Affinity Functions (`bind`/`unbind`)

The process-binding functions have been removed. The new architecture uses `py_context_router` for automatic scheduler-affinity routing.

**Before (v1.8.x):**
```erlang
ok = py:bind(),
ok = py:exec(<<"x = 42">>),
{ok, 42} = py:eval(<<"x">>),
ok = py:unbind().

%% Or with explicit contexts
{ok, Ctx} = py:bind(new),
ok = py:ctx_exec(Ctx, <<"y = 100">>),
{ok, 100} = py:ctx_eval(Ctx, <<"y">>),
ok = py:unbind(Ctx).
```

**After (v2.0) - Use context router:**
```erlang
%% Automatic scheduler-affinity routing (recommended)
{ok, _} = py:call(math, sqrt, [16]).

%% Or explicit context binding via router
Ctx = py_context_router:get_context(),
py_context_router:bind_context(Ctx),
{ok, _} = py:call(math, sqrt, [16]),  %% Uses bound context
py_context_router:unbind_context().

%% For isolated state, use py_context directly
{ok, Contexts} = py_context_router:start(),
Ctx = py_context_router:get_context(1),
ok = py_context:exec(Ctx, <<"x = 42">>),
{ok, 42} = py_context:eval(Ctx, <<"x">>, #{}).
```

**Removed functions:**
- `bind/0`, `bind/1` - process binding
- `unbind/0`, `unbind/1` - process unbinding
- `is_bound/0` - check if process is bound
- `with_context/1` - scoped context execution
- `ctx_call/4,5,6` - context-specific call
- `ctx_eval/2,3,4` - context-specific eval
- `ctx_exec/2` - context-specific exec

### Subprocess Support

Python subprocess operations (`subprocess.Popen`, `asyncio.create_subprocess_*`, etc.) are no longer available. They are blocked by the audit hook sandbox because `fork()` would corrupt the Erlang VM.

**Before (v1.8.x):**
```python
import subprocess
result = subprocess.run(["ls", "-la"], capture_output=True)
```

**After (v2.0) - Use Erlang ports:**
```erlang
%% Register a shell command helper
py:register_function(run_command, fun([Cmd, Args]) ->
    Port = open_port({spawn_executable, Cmd},
                     [{args, Args}, binary, exit_status, stderr_to_stdout]),
    collect_output(Port, [])
end).

collect_output(Port, Acc) ->
    receive
        {Port, {data, Data}} -> collect_output(Port, [Data | Acc]);
        {Port, {exit_status, Status}} ->
            {Status, iolist_to_binary(lists:reverse(Acc))}
    end.
```

```python
from erlang import run_command
status, output = run_command("/bin/ls", ["-la"])
```

See [Security](security.md) for details on blocked operations.

### Signal Handling

Signal handlers can no longer be registered from Python. The ErlangEventLoop raises `NotImplementedError` for `add_signal_handler` and `remove_signal_handler`.

**Before (v1.8.x):**
```python
import signal
import asyncio

loop = asyncio.get_event_loop()
loop.add_signal_handler(signal.SIGTERM, shutdown_handler)
```

**After (v2.0) - Handle at Erlang level:**
```erlang
%% In your application supervisor or main module
os:set_signal(sigterm, handle),

%% Then in a process that handles system messages
receive
    {signal, sigterm} ->
        %% Graceful shutdown
        application:stop(my_app)
end.
```

## New Features to Consider

### Scheduler-Affinity Context Router

The new `py_context_router` automatically routes Python calls based on scheduler ID, providing better cache locality:

```erlang
%% Automatically uses scheduler-based routing
{ok, Result} = py:call(math, sqrt, [16]).

%% Or explicitly bind a context to a process
Ctx = py_context_router:get_context(),
py_context_router:bind_context(Ctx),
%% All calls from this process now go to Ctx
```

### `erlang.reactor` for Protocol Handling

For building custom servers, the new reactor module provides FD-based protocol handling:

```python
from erlang.reactor import Protocol, serve

class EchoProtocol(Protocol):
    def data_received(self, data):
        self.write(data)
        return "continue"

serve(sock, EchoProtocol)
```

See [Reactor](reactor.md) for full documentation.

### Socket FD Handoff

Pass socket file descriptors directly from Erlang to Python for high-performance I/O.

Use `py:dup_fd/1` to duplicate the fd before handoff. This lets Erlang close its
socket while Python keeps its own copy:

```erlang
%% Erlang: Accept and hand off to reactor
{ok, ClientSock} = gen_tcp:accept(ListenSock),
{ok, Fd} = inet:getfd(ClientSock),

%% Duplicate fd so both sides have independent copies
{ok, DupFd} = py:dup_fd(Fd),
py_reactor_context:handoff(DupFd, #{type => tcp}),

%% Safe to close Erlang's socket
gen_tcp:close(ClientSock).
```

For direct asyncio usage:

```erlang
{ok, Fd} = inet:getfd(ClientSock),
{ok, DupFd} = py:dup_fd(Fd),
Ctx = py:context(1),
py:call(Ctx, my_handler, handle_fd, [DupFd]).
```

```python
# Python: Use fd with asyncio
import socket
sock = socket.socket(fileno=fd)
sock.setblocking(False)
# ... use with asyncio
```

See [Reactor](reactor.md#socket-ownership) for details.

### `erlang.send()` for Fire-and-Forget Messages

Send messages directly to Erlang processes without waiting:

```python
import erlang

# Send to a registered process
erlang.send(("my_server", "node@host"), {"event": "user_login", "user": 123})

# Send to a PID
erlang.send(pid, "hello")
```

### `erlang.sleep()` with Dirty Scheduler Release

Synchronous sleep that releases the Erlang dirty scheduler thread:

```python
import erlang

def slow_handler():
    # Sleep without blocking Erlang scheduler
    erlang.sleep(1.0)  # Releases dirty scheduler during sleep
    return "done"
```

Unlike `time.sleep()`, `erlang.sleep()` releases the dirty NIF thread while waiting, allowing other Python calls to use the scheduler slot.

### `erlang.call()` Blocking with Explicit Scheduling

The `erlang.call()` function now supports explicit scheduling for long-running operations:

```python
import erlang

def handler():
    # Blocking call to Erlang
    result = erlang.call('my_callback', arg1, arg2)

    # For async contexts, use schedule to yield control
    erlang.schedule()  # Yield to event loop

    return result
```

### `channel.receive()` Blocking Receive

Channels now support blocking receive that suspends Python and yields to Erlang:

```python
from erlang.channel import Channel

def processor(channel):
    # Blocking receive - suspends Python, releases scheduler
    msg = channel.receive()

    # Non-blocking alternative
    msg = channel.try_receive()  # Returns None if empty

    # Async alternative
    # msg = await channel.async_receive()
```

### `erlang.spawn_task()` for Async Task Spawning

Spawn async tasks from both sync and async contexts:

```python
import erlang
import asyncio

async def background_work():
    await asyncio.sleep(1)
    print("Background done")

def sync_handler():
    # Works even without running event loop
    task = erlang.spawn_task(background_work())
    # Fire-and-forget, task runs in background
    return "submitted"

async def async_handler():
    # Also works in async context
    task = erlang.spawn_task(background_work())
    # Optionally await
    await task
```

### Async Task API (Erlang Side)

Submit and manage async Python tasks from Erlang:

```erlang
%% Blocking run
{ok, Result} = py_event_loop:run(Ctx, my_module, my_async_func, [Arg1]).

%% Non-blocking with reference
Ref = py_event_loop:create_task(Ctx, my_module, my_async_func, [Arg1]),
{ok, Result} = py_event_loop:await(Ref, 5000).

%% Fire-and-forget
py_event_loop:spawn_task(Ctx, my_module, my_async_func, [Arg1]).

%% Message-based result delivery
Ref = py_event_loop:create_task(Ctx, my_module, my_async_func, [Arg1]),
receive
    {async_result, Ref, {ok, Result}} -> handle(Result);
    {async_result, Ref, {error, Reason}} -> handle_error(Reason)
end.
```

### Virtual Environment Management

Automatic venv creation and activation with dependency installation:

```erlang
%% Create venv if missing, install deps, activate
ok = py:ensure_venv("/path/to/venv", "/path/to/requirements.txt").

%% With options
ok = py:ensure_venv("/path/to/venv", "/path/to/requirements.txt", [
    {installer, pip},  % or uv
    force              % Recreate even if exists
]).

%% Manual activation
ok = py:activate_venv("/path/to/venv").

%% Deactivation
ok = py:deactivate_venv().

%% Check venv status
{ok, #{<<"active">> := true, <<"venv_path">> := Path}} = py:venv_info().
```

### Custom Pool Support

Create pools on demand for CPU-bound and I/O-bound operations:

```erlang
%% Default pool - CPU-bound operations (sized to schedulers)
{ok, Result} = py:call(math, sqrt, [16]).

%% Create io pool for I/O-bound operations
{ok, _} = py_context_router:start_pool(io, 10, worker).
{ok, Response} = py:call(io, requests, get, [Url]).

%% Registration-based routing (no call site changes)
py:register_pool(io, requests),              % Route all requests.* to io pool
py:register_pool(io, {aiohttp, get}),        % Route specific function

%% After registration, calls auto-route
{ok, Response} = py:call(requests, get, [Url]).  % Goes to io pool
```

## Performance Improvements

The v2.0 release includes significant performance improvements:

| Operation | v1.8.1 | v2.0 | Improvement |
|-----------|--------|------|-------------|
| Sync py:call | 0.011 ms | 0.004 ms | 2.9x faster |
| Sync py:eval | 0.014 ms | 0.007 ms | 2.0x faster |
| Cast (async) | 0.011 ms | 0.004 ms | 2.8x faster |
| Throughput | ~90K/s | ~250K/s | 2.8x higher |

These improvements come from:
- Event-driven async model (no pthread polling)
- Scheduler-affinity routing
- Per-interpreter isolation
- Optimized NIF paths

## Troubleshooting

### "RuntimeError: fork() blocked by sandbox"

You're trying to use subprocess or os.fork(). Use Erlang ports instead. See [Security](security.md).

### "NotImplementedError: Signal handlers not supported"

Signal handling must be done at the Erlang level. See the Signal Handling section above.

### "AttributeError: module 'erlang_asyncio' has no attribute..."

The `erlang_asyncio` module has been removed. Update imports to use `erlang` directly.

### Module not found: `_erlang_impl._loop`

If you see this error with `py:async_call`, ensure the application is fully started:
```erlang
{ok, _} = application:ensure_all_started(erlang_python).
```

### Variables not found across py:exec/py:eval calls

In v2.0 with subinterpreters, each call may go to a different context. Use explicit contexts:

```erlang
%% Wrong - may use different contexts
py:exec(<<"x = 42">>),
{error, _} = py:eval(<<"x">>).  %% x not defined in this context!

%% Right - use explicit context
Ctx = py:context(1),
py:exec(Ctx, <<"x = 42">>),
{ok, 42} = py:eval(Ctx, <<"x">>).
```

### asyncio tests failing with subinterpreters

Some asyncio operations don't work correctly across subinterpreters because each has isolated event loop state. For asyncio-heavy code, either:

1. Use explicit context to keep operations in one subinterpreter
2. Use `erlang.run()` within a single context
3. Check `py:execution_mode()` and adapt accordingly

### C extension not compatible with subinterpreters

Some C extensions don't support subinterpreters. Check for errors like:
```
ImportError: module does not support subinterpreters
```

Options:
1. Use Python < 3.12 (falls back to multi_executor mode)
2. Check if the library has a subinterpreter-compatible version
3. Isolate the library usage to a single context

### Python 3.14: `erlang_loop_import_failed`

If you see `erlang_loop_import_failed` errors with Python 3.14:

```erlang
{error, {erlang_loop_import_failed, ...}}
```

This indicates the `priv` directory is not in `sys.path` for the subinterpreter. Ensure:
1. Application is fully started: `application:ensure_all_started(erlang_python)`
2. You're using the latest version with the Python 3.14 fixes

### FreeBSD: fd stealing error

If you see `driver_select(...) stealing control of fd=N` on FreeBSD:

```
driver_select(py_reactor_context) stealing control of fd=61 from resource py_nif:fd_resource
```

This occurs when both Erlang's tcp_inet driver and py_reactor try to register the same fd with kqueue. Solutions:
1. Use `py:dup_fd/1` to duplicate the fd before handoff
2. Update to the latest version where `py_reactor_context` auto-duplicates fds

## Configuration

### Pool Size

Configure the number of contexts in `sys.config`:

```erlang
{erlang_python, [
    {num_contexts, 8}  %% Default: erlang:system_info(schedulers)
]}
```

Or at runtime:
```erlang
py_context_router:start(#{contexts => 8}).
```

The C-level pool supports up to 64 subinterpreters (default pre-allocated: 32).