Skip to content

Commit 5cf256c

Browse files
authored
Add PID serialization, erlang.send(), and SuspensionRequired base class change (#9)
Add erlang.send() and PID serialization
1 parent d188ca5 commit 5cf256c

File tree

9 files changed

+557
-2
lines changed

9 files changed

+557
-2
lines changed

CHANGELOG.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,27 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
### Added
6+
7+
- **PID serialization** - Erlang PIDs now convert to `erlang.Pid` objects in Python
8+
and back to real PIDs when returned to Erlang. Previously, PIDs fell through to
9+
`None` (Erlang→Python) or string representation (Python→Erlang).
10+
11+
- **`erlang.send(pid, term)`** - Fire-and-forget message passing from Python to
12+
Erlang processes. Uses `enif_send()` directly with no suspension or blocking.
13+
Raises `erlang.ProcessError` if the target process is dead.
14+
15+
- **`erlang.ProcessError`** - New exception for dead/unreachable process errors.
16+
Subclass of `Exception`, so it's catchable with `except Exception` or
17+
`except erlang.ProcessError`.
18+
19+
### Changed
20+
21+
- **`SuspensionRequired` base class** - Now inherits from `BaseException` instead
22+
of `Exception`. This prevents ASGI/WSGI middleware `except Exception` handlers
23+
from intercepting the suspension control flow used by `erlang.call()`.
24+
325
## 1.8.1 (2026-02-25)
426

527
### Fixed

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ Key features:
2525
- **Dirty NIF execution** - Python runs on dirty schedulers, never blocking the BEAM
2626
- **Elixir support** - Works seamlessly from Elixir via the `:py` module
2727
- **Bidirectional calls** - Python can call back into registered Erlang/Elixir functions
28-
- **Type conversion** - Automatic conversion between Erlang and Python types
28+
- **Message passing** - Python can send messages directly to Erlang processes via `erlang.send()`
29+
- **Type conversion** - Automatic conversion between Erlang and Python types (including PIDs)
2930
- **Streaming** - Iterate over Python generators chunk-by-chunk
3031
- **Virtual environments** - Activate venvs for dependency isolation
3132
- **AI/ML ready** - Examples for embeddings, semantic search, RAG, and LLMs

c_src/py_callback.c

Lines changed: 138 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -694,6 +694,50 @@ static PyObject *ErlangFunction_New(PyObject *name) {
694694
return (PyObject *)self;
695695
}
696696

697+
/* ============================================================================
698+
* ErlangPid - opaque wrapper for Erlang process identifiers
699+
*
700+
* ErlangPidObject is defined in py_nif.h for use by py_convert.c
701+
* ============================================================================ */
702+
703+
static PyObject *ErlangPid_repr(ErlangPidObject *self) {
704+
/* Show the raw term value for debugging — not a stable external format,
705+
but distinguishes different PIDs in logs and repls. */
706+
return PyUnicode_FromFormat("<erlang.Pid 0x%lx>",
707+
(unsigned long)self->pid.pid);
708+
}
709+
710+
static PyObject *ErlangPid_richcompare(PyObject *a, PyObject *b, int op) {
711+
if (!Py_IS_TYPE(b, &ErlangPidType)) {
712+
Py_RETURN_NOTIMPLEMENTED;
713+
}
714+
ErlangPidObject *pa = (ErlangPidObject *)a;
715+
ErlangPidObject *pb = (ErlangPidObject *)b;
716+
int eq = enif_is_identical(pa->pid.pid, pb->pid.pid);
717+
switch (op) {
718+
case Py_EQ: return PyBool_FromLong(eq);
719+
case Py_NE: return PyBool_FromLong(!eq);
720+
default: Py_RETURN_NOTIMPLEMENTED;
721+
}
722+
}
723+
724+
static Py_hash_t ErlangPid_hash(ErlangPidObject *self) {
725+
Py_hash_t h = (Py_hash_t)enif_hash(ERL_NIF_PHASH2, self->pid.pid, 0);
726+
if (h == -1) h = -2; /* -1 is reserved for errors in Python */
727+
return h;
728+
}
729+
730+
PyTypeObject ErlangPidType = {
731+
PyVarObject_HEAD_INIT(NULL, 0)
732+
.tp_name = "erlang.Pid",
733+
.tp_basicsize = sizeof(ErlangPidObject),
734+
.tp_flags = Py_TPFLAGS_DEFAULT,
735+
.tp_repr = (reprfunc)ErlangPid_repr,
736+
.tp_richcompare = ErlangPid_richcompare,
737+
.tp_hash = (hashfunc)ErlangPid_hash,
738+
.tp_doc = "Opaque Erlang process identifier",
739+
};
740+
697741
/**
698742
* Python implementation of erlang.call(name, *args)
699743
*
@@ -911,6 +955,68 @@ static PyObject *erlang_call_impl(PyObject *self, PyObject *args) {
911955
return NULL;
912956
}
913957

958+
/* ============================================================================
959+
* erlang.send() - Fire-and-forget message passing
960+
*
961+
* Sends a message directly to an Erlang process mailbox via enif_send().
962+
* No suspension, no blocking, no reply needed.
963+
* ============================================================================ */
964+
965+
/**
966+
* @brief Python: erlang.send(pid, term) -> None
967+
*
968+
* Fire-and-forget message send to an Erlang process.
969+
*
970+
* @param self Module reference (unused)
971+
* @param args Tuple: (pid:erlang.Pid, term:any)
972+
* @return None on success, NULL with exception on failure
973+
*/
974+
static PyObject *erlang_send_impl(PyObject *self, PyObject *args) {
975+
(void)self;
976+
977+
if (PyTuple_Size(args) != 2) {
978+
PyErr_SetString(PyExc_TypeError,
979+
"erlang.send requires exactly 2 arguments: (pid, term)");
980+
return NULL;
981+
}
982+
983+
PyObject *pid_obj = PyTuple_GetItem(args, 0);
984+
PyObject *term_obj = PyTuple_GetItem(args, 1);
985+
986+
/* Validate PID type */
987+
if (!Py_IS_TYPE(pid_obj, &ErlangPidType)) {
988+
PyErr_SetString(PyExc_TypeError, "First argument must be an erlang.Pid");
989+
return NULL;
990+
}
991+
992+
ErlangPidObject *pid = (ErlangPidObject *)pid_obj;
993+
994+
/* Allocate a message environment and convert the term */
995+
ErlNifEnv *msg_env = enif_alloc_env();
996+
if (msg_env == NULL) {
997+
PyErr_SetString(PyExc_MemoryError, "Failed to allocate message environment");
998+
return NULL;
999+
}
1000+
1001+
ERL_NIF_TERM msg = py_to_term(msg_env, term_obj);
1002+
1003+
if (PyErr_Occurred()) {
1004+
enif_free_env(msg_env);
1005+
return NULL;
1006+
}
1007+
1008+
/* Fire-and-forget send */
1009+
if (!enif_send(NULL, &pid->pid, msg_env, msg)) {
1010+
enif_free_env(msg_env);
1011+
PyErr_SetString(ProcessErrorException,
1012+
"Failed to send message: process may not exist");
1013+
return NULL;
1014+
}
1015+
1016+
enif_free_env(msg_env);
1017+
Py_RETURN_NONE;
1018+
}
1019+
9141020
/* ============================================================================
9151021
* Async callback support for asyncio integration
9161022
*
@@ -1288,6 +1394,10 @@ static PyMethodDef ErlangModuleMethods[] = {
12881394
"Call a registered Erlang function.\n\n"
12891395
"Usage: erlang.call('func_name', arg1, arg2, ...)\n"
12901396
"Returns: The result from the Erlang function."},
1397+
{"send", erlang_send_impl, METH_VARARGS,
1398+
"Send a message to an Erlang process (fire-and-forget).\n\n"
1399+
"Usage: erlang.send(pid, term)\n"
1400+
"The pid must be an erlang.Pid object."},
12911401
{"_get_async_callback_fd", get_async_callback_fd, METH_NOARGS,
12921402
"Get the file descriptor for async callback responses.\n"
12931403
"Used internally by async_call() to register with asyncio."},
@@ -1344,6 +1454,11 @@ static int create_erlang_module(void) {
13441454
return -1;
13451455
}
13461456

1457+
/* Initialize ErlangPid type */
1458+
if (PyType_Ready(&ErlangPidType) < 0) {
1459+
return -1;
1460+
}
1461+
13471462
PyObject *module = PyModule_Create(&ErlangModuleDef);
13481463
if (module == NULL) {
13491464
return -1;
@@ -1353,7 +1468,7 @@ static int create_erlang_module(void) {
13531468
* This exception is raised internally when erlang.call() needs to suspend.
13541469
* It carries callback info in args: (callback_id, func_name, args_tuple) */
13551470
SuspensionRequiredException = PyErr_NewException(
1356-
"erlang.SuspensionRequired", NULL, NULL);
1471+
"erlang.SuspensionRequired", PyExc_BaseException, NULL);
13571472
if (SuspensionRequiredException == NULL) {
13581473
Py_DECREF(module);
13591474
return -1;
@@ -1365,6 +1480,20 @@ static int create_erlang_module(void) {
13651480
return -1;
13661481
}
13671482

1483+
/* Create erlang.ProcessError for dead/unreachable processes */
1484+
ProcessErrorException = PyErr_NewException(
1485+
"erlang.ProcessError", NULL, NULL);
1486+
if (ProcessErrorException == NULL) {
1487+
Py_DECREF(module);
1488+
return -1;
1489+
}
1490+
Py_INCREF(ProcessErrorException);
1491+
if (PyModule_AddObject(module, "ProcessError", ProcessErrorException) < 0) {
1492+
Py_DECREF(ProcessErrorException);
1493+
Py_DECREF(module);
1494+
return -1;
1495+
}
1496+
13681497
/* Add ErlangFunction type to module (for introspection) */
13691498
Py_INCREF(&ErlangFunctionType);
13701499
if (PyModule_AddObject(module, "Function", (PyObject *)&ErlangFunctionType) < 0) {
@@ -1373,6 +1502,14 @@ static int create_erlang_module(void) {
13731502
return -1;
13741503
}
13751504

1505+
/* Add ErlangPid type to module */
1506+
Py_INCREF(&ErlangPidType);
1507+
if (PyModule_AddObject(module, "Pid", (PyObject *)&ErlangPidType) < 0) {
1508+
Py_DECREF(&ErlangPidType);
1509+
Py_DECREF(module);
1510+
return -1;
1511+
}
1512+
13761513
/* Add __getattr__ to enable "from erlang import name" and "erlang.name()" syntax
13771514
* Module __getattr__ (PEP 562) needs to be set as an attribute on the module dict */
13781515
PyObject *getattr_func = PyCFunction_New(&getattr_method, module);

c_src/py_convert.c

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,12 @@ static ERL_NIF_TERM py_to_term(ErlNifEnv *env, PyObject *obj) {
317317
return result;
318318
}
319319

320+
/* Handle ErlangPid → Erlang PID */
321+
if (Py_IS_TYPE(obj, &ErlangPidType)) {
322+
ErlangPidObject *pid_obj = (ErlangPidObject *)obj;
323+
return enif_make_pid(env, &pid_obj->pid);
324+
}
325+
320326
/* Handle NumPy arrays by converting to Python list first */
321327
if (is_numpy_ndarray(obj)) {
322328
PyObject *tolist = PyObject_CallMethod(obj, "tolist", NULL);
@@ -528,6 +534,17 @@ static PyObject *term_to_py(ErlNifEnv *env, ERL_NIF_TERM term) {
528534
return dict;
529535
}
530536

537+
/* Check for PID */
538+
{
539+
ErlNifPid pid;
540+
if (enif_get_local_pid(env, term, &pid)) {
541+
ErlangPidObject *obj = PyObject_New(ErlangPidObject, &ErlangPidType);
542+
if (obj == NULL) return NULL;
543+
obj->pid = pid;
544+
return (PyObject *)obj;
545+
}
546+
}
547+
531548
/* Check for wrapped Python object resource */
532549
py_object_t *wrapper;
533550
if (enif_get_resource(env, term, PYOBJ_RESOURCE_TYPE, (void **)&wrapper)) {

c_src/py_nif.c

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,9 @@ _Atomic uint64_t g_callback_id_counter = 1;
7979
/* Custom exception for suspension */
8080
PyObject *SuspensionRequiredException = NULL;
8181

82+
/* Custom exception for dead/unreachable processes */
83+
PyObject *ProcessErrorException = NULL;
84+
8285
/* Cached numpy.ndarray type for fast isinstance checks (NULL if numpy not available) */
8386
PyObject *g_numpy_ndarray_type = NULL;
8487

c_src/py_nif.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -707,6 +707,13 @@ extern _Atomic uint64_t g_callback_id_counter;
707707
/** @brief Python exception class for suspension */
708708
extern PyObject *SuspensionRequiredException;
709709

710+
/** @brief Python exception for dead/unreachable process */
711+
extern PyObject *ProcessErrorException;
712+
713+
/** @brief Python type for opaque Erlang PIDs */
714+
typedef struct { PyObject_HEAD; ErlNifPid pid; } ErlangPidObject;
715+
extern PyTypeObject ErlangPidType;
716+
710717
/** @brief Cached numpy.ndarray type for fast isinstance checks (NULL if numpy unavailable) */
711718
extern PyObject *g_numpy_ndarray_type;
712719

docs/type-conversion.md

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ When calling Python functions or evaluating expressions, Erlang values are autom
2020
| `list()` | `list` | Recursively converted |
2121
| `tuple()` | `tuple` | Recursively converted |
2222
| `map()` | `dict` | Keys and values recursively converted |
23+
| `pid()` | `erlang.Pid` | Opaque wrapper, round-trips back to Erlang PID |
2324

2425
### Examples
2526

@@ -74,6 +75,7 @@ Return values from Python are converted back to Erlang:
7475
| `list` | `list()` | Recursively converted |
7576
| `tuple` | `tuple()` | Recursively converted |
7677
| `dict` | `map()` | Keys and values recursively converted |
78+
| `erlang.Pid` | `pid()` | Round-trips back to the original Erlang PID |
7779
| generator | internal | Used with streaming functions |
7880

7981
### Examples
@@ -114,6 +116,43 @@ Return values from Python are converted back to Erlang:
114116
{ok, #{<<"a">> := 1, <<"b">> := 2}} = py:eval(<<"{'a': 1, 'b': 2}">>).
115117
```
116118

119+
### Process Identifiers (PIDs)
120+
121+
Erlang PIDs are converted to opaque `erlang.Pid` objects in Python. These can be
122+
passed back to Erlang (where they become real PIDs again) or used with `erlang.send()`:
123+
124+
```erlang
125+
%% Pass self() to Python - arrives as erlang.Pid
126+
{ok, Pid} = py:call(mymod, round_trip_pid, [self()]).
127+
%% Pid =:= self()
128+
129+
%% Python can send messages directly to Erlang processes
130+
ok = py:exec(<<"
131+
import erlang
132+
def notify(pid, data):
133+
erlang.send(pid, ('notification', data))
134+
">>).
135+
```
136+
137+
```python
138+
import erlang
139+
140+
def forward_to(pid, message):
141+
"""Send a message to an Erlang process."""
142+
erlang.send(pid, message)
143+
```
144+
145+
`erlang.Pid` objects support equality and hashing, so they can be compared and
146+
used as dict keys or in sets:
147+
148+
```python
149+
pid_a == pid_b # True if both wrap the same Erlang PID
150+
{pid: "value"} # Works as a dict key
151+
pid in seen_pids # Works in sets
152+
```
153+
154+
Sending to a process that has already exited raises `erlang.ProcessError`.
155+
117156
## Special Cases
118157

119158
### NumPy Arrays

0 commit comments

Comments
 (0)