diff --git a/CHANGELOG.md b/CHANGELOG.md index c19c77a..216f137 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,27 @@ # Changelog +## Unreleased + +### Added + +- **PID serialization** - Erlang PIDs now convert to `erlang.Pid` objects in Python + and back to real PIDs when returned to Erlang. Previously, PIDs fell through to + `None` (Erlang→Python) or string representation (Python→Erlang). + +- **`erlang.send(pid, term)`** - Fire-and-forget message passing from Python to + Erlang processes. Uses `enif_send()` directly with no suspension or blocking. + Raises `erlang.ProcessError` if the target process is dead. + +- **`erlang.ProcessError`** - New exception for dead/unreachable process errors. + Subclass of `Exception`, so it's catchable with `except Exception` or + `except erlang.ProcessError`. + +### Changed + +- **`SuspensionRequired` base class** - Now inherits from `BaseException` instead + of `Exception`. This prevents ASGI/WSGI middleware `except Exception` handlers + from intercepting the suspension control flow used by `erlang.call()`. + ## 1.8.1 (2026-02-25) ### Fixed diff --git a/README.md b/README.md index b54bb94..37282d8 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ Key features: - **Dirty NIF execution** - Python runs on dirty schedulers, never blocking the BEAM - **Elixir support** - Works seamlessly from Elixir via the `:py` module - **Bidirectional calls** - Python can call back into registered Erlang/Elixir functions -- **Type conversion** - Automatic conversion between Erlang and Python types +- **Message passing** - Python can send messages directly to Erlang processes via `erlang.send()` +- **Type conversion** - Automatic conversion between Erlang and Python types (including PIDs) - **Streaming** - Iterate over Python generators chunk-by-chunk - **Virtual environments** - Activate venvs for dependency isolation - **AI/ML ready** - Examples for embeddings, semantic search, RAG, and LLMs diff --git a/c_src/py_callback.c b/c_src/py_callback.c index aeaed55..b1179d5 100644 --- a/c_src/py_callback.c +++ b/c_src/py_callback.c @@ -694,6 +694,50 @@ static PyObject *ErlangFunction_New(PyObject *name) { return (PyObject *)self; } +/* ============================================================================ + * ErlangPid - opaque wrapper for Erlang process identifiers + * + * ErlangPidObject is defined in py_nif.h for use by py_convert.c + * ============================================================================ */ + +static PyObject *ErlangPid_repr(ErlangPidObject *self) { + /* Show the raw term value for debugging — not a stable external format, + but distinguishes different PIDs in logs and repls. */ + return PyUnicode_FromFormat("", + (unsigned long)self->pid.pid); +} + +static PyObject *ErlangPid_richcompare(PyObject *a, PyObject *b, int op) { + if (!Py_IS_TYPE(b, &ErlangPidType)) { + Py_RETURN_NOTIMPLEMENTED; + } + ErlangPidObject *pa = (ErlangPidObject *)a; + ErlangPidObject *pb = (ErlangPidObject *)b; + int eq = enif_is_identical(pa->pid.pid, pb->pid.pid); + switch (op) { + case Py_EQ: return PyBool_FromLong(eq); + case Py_NE: return PyBool_FromLong(!eq); + default: Py_RETURN_NOTIMPLEMENTED; + } +} + +static Py_hash_t ErlangPid_hash(ErlangPidObject *self) { + Py_hash_t h = (Py_hash_t)enif_hash(ERL_NIF_PHASH2, self->pid.pid, 0); + if (h == -1) h = -2; /* -1 is reserved for errors in Python */ + return h; +} + +PyTypeObject ErlangPidType = { + PyVarObject_HEAD_INIT(NULL, 0) + .tp_name = "erlang.Pid", + .tp_basicsize = sizeof(ErlangPidObject), + .tp_flags = Py_TPFLAGS_DEFAULT, + .tp_repr = (reprfunc)ErlangPid_repr, + .tp_richcompare = ErlangPid_richcompare, + .tp_hash = (hashfunc)ErlangPid_hash, + .tp_doc = "Opaque Erlang process identifier", +}; + /** * Python implementation of erlang.call(name, *args) * @@ -911,6 +955,68 @@ static PyObject *erlang_call_impl(PyObject *self, PyObject *args) { return NULL; } +/* ============================================================================ + * erlang.send() - Fire-and-forget message passing + * + * Sends a message directly to an Erlang process mailbox via enif_send(). + * No suspension, no blocking, no reply needed. + * ============================================================================ */ + +/** + * @brief Python: erlang.send(pid, term) -> None + * + * Fire-and-forget message send to an Erlang process. + * + * @param self Module reference (unused) + * @param args Tuple: (pid:erlang.Pid, term:any) + * @return None on success, NULL with exception on failure + */ +static PyObject *erlang_send_impl(PyObject *self, PyObject *args) { + (void)self; + + if (PyTuple_Size(args) != 2) { + PyErr_SetString(PyExc_TypeError, + "erlang.send requires exactly 2 arguments: (pid, term)"); + return NULL; + } + + PyObject *pid_obj = PyTuple_GetItem(args, 0); + PyObject *term_obj = PyTuple_GetItem(args, 1); + + /* Validate PID type */ + if (!Py_IS_TYPE(pid_obj, &ErlangPidType)) { + PyErr_SetString(PyExc_TypeError, "First argument must be an erlang.Pid"); + return NULL; + } + + ErlangPidObject *pid = (ErlangPidObject *)pid_obj; + + /* Allocate a message environment and convert the term */ + ErlNifEnv *msg_env = enif_alloc_env(); + if (msg_env == NULL) { + PyErr_SetString(PyExc_MemoryError, "Failed to allocate message environment"); + return NULL; + } + + ERL_NIF_TERM msg = py_to_term(msg_env, term_obj); + + if (PyErr_Occurred()) { + enif_free_env(msg_env); + return NULL; + } + + /* Fire-and-forget send */ + if (!enif_send(NULL, &pid->pid, msg_env, msg)) { + enif_free_env(msg_env); + PyErr_SetString(ProcessErrorException, + "Failed to send message: process may not exist"); + return NULL; + } + + enif_free_env(msg_env); + Py_RETURN_NONE; +} + /* ============================================================================ * Async callback support for asyncio integration * @@ -1288,6 +1394,10 @@ static PyMethodDef ErlangModuleMethods[] = { "Call a registered Erlang function.\n\n" "Usage: erlang.call('func_name', arg1, arg2, ...)\n" "Returns: The result from the Erlang function."}, + {"send", erlang_send_impl, METH_VARARGS, + "Send a message to an Erlang process (fire-and-forget).\n\n" + "Usage: erlang.send(pid, term)\n" + "The pid must be an erlang.Pid object."}, {"_get_async_callback_fd", get_async_callback_fd, METH_NOARGS, "Get the file descriptor for async callback responses.\n" "Used internally by async_call() to register with asyncio."}, @@ -1344,6 +1454,11 @@ static int create_erlang_module(void) { return -1; } + /* Initialize ErlangPid type */ + if (PyType_Ready(&ErlangPidType) < 0) { + return -1; + } + PyObject *module = PyModule_Create(&ErlangModuleDef); if (module == NULL) { return -1; @@ -1353,7 +1468,7 @@ static int create_erlang_module(void) { * This exception is raised internally when erlang.call() needs to suspend. * It carries callback info in args: (callback_id, func_name, args_tuple) */ SuspensionRequiredException = PyErr_NewException( - "erlang.SuspensionRequired", NULL, NULL); + "erlang.SuspensionRequired", PyExc_BaseException, NULL); if (SuspensionRequiredException == NULL) { Py_DECREF(module); return -1; @@ -1365,6 +1480,20 @@ static int create_erlang_module(void) { return -1; } + /* Create erlang.ProcessError for dead/unreachable processes */ + ProcessErrorException = PyErr_NewException( + "erlang.ProcessError", NULL, NULL); + if (ProcessErrorException == NULL) { + Py_DECREF(module); + return -1; + } + Py_INCREF(ProcessErrorException); + if (PyModule_AddObject(module, "ProcessError", ProcessErrorException) < 0) { + Py_DECREF(ProcessErrorException); + Py_DECREF(module); + return -1; + } + /* Add ErlangFunction type to module (for introspection) */ Py_INCREF(&ErlangFunctionType); if (PyModule_AddObject(module, "Function", (PyObject *)&ErlangFunctionType) < 0) { @@ -1373,6 +1502,14 @@ static int create_erlang_module(void) { return -1; } + /* Add ErlangPid type to module */ + Py_INCREF(&ErlangPidType); + if (PyModule_AddObject(module, "Pid", (PyObject *)&ErlangPidType) < 0) { + Py_DECREF(&ErlangPidType); + Py_DECREF(module); + return -1; + } + /* Add __getattr__ to enable "from erlang import name" and "erlang.name()" syntax * Module __getattr__ (PEP 562) needs to be set as an attribute on the module dict */ PyObject *getattr_func = PyCFunction_New(&getattr_method, module); diff --git a/c_src/py_convert.c b/c_src/py_convert.c index b9540b7..289ac49 100644 --- a/c_src/py_convert.c +++ b/c_src/py_convert.c @@ -317,6 +317,12 @@ static ERL_NIF_TERM py_to_term(ErlNifEnv *env, PyObject *obj) { return result; } + /* Handle ErlangPid → Erlang PID */ + if (Py_IS_TYPE(obj, &ErlangPidType)) { + ErlangPidObject *pid_obj = (ErlangPidObject *)obj; + return enif_make_pid(env, &pid_obj->pid); + } + /* Handle NumPy arrays by converting to Python list first */ if (is_numpy_ndarray(obj)) { PyObject *tolist = PyObject_CallMethod(obj, "tolist", NULL); @@ -528,6 +534,17 @@ static PyObject *term_to_py(ErlNifEnv *env, ERL_NIF_TERM term) { return dict; } + /* Check for PID */ + { + ErlNifPid pid; + if (enif_get_local_pid(env, term, &pid)) { + ErlangPidObject *obj = PyObject_New(ErlangPidObject, &ErlangPidType); + if (obj == NULL) return NULL; + obj->pid = pid; + return (PyObject *)obj; + } + } + /* Check for wrapped Python object resource */ py_object_t *wrapper; if (enif_get_resource(env, term, PYOBJ_RESOURCE_TYPE, (void **)&wrapper)) { diff --git a/c_src/py_nif.c b/c_src/py_nif.c index 0661cd7..5ec2502 100644 --- a/c_src/py_nif.c +++ b/c_src/py_nif.c @@ -79,6 +79,9 @@ _Atomic uint64_t g_callback_id_counter = 1; /* Custom exception for suspension */ PyObject *SuspensionRequiredException = NULL; +/* Custom exception for dead/unreachable processes */ +PyObject *ProcessErrorException = NULL; + /* Cached numpy.ndarray type for fast isinstance checks (NULL if numpy not available) */ PyObject *g_numpy_ndarray_type = NULL; diff --git a/c_src/py_nif.h b/c_src/py_nif.h index 321f000..cf77f55 100644 --- a/c_src/py_nif.h +++ b/c_src/py_nif.h @@ -707,6 +707,13 @@ extern _Atomic uint64_t g_callback_id_counter; /** @brief Python exception class for suspension */ extern PyObject *SuspensionRequiredException; +/** @brief Python exception for dead/unreachable process */ +extern PyObject *ProcessErrorException; + +/** @brief Python type for opaque Erlang PIDs */ +typedef struct { PyObject_HEAD; ErlNifPid pid; } ErlangPidObject; +extern PyTypeObject ErlangPidType; + /** @brief Cached numpy.ndarray type for fast isinstance checks (NULL if numpy unavailable) */ extern PyObject *g_numpy_ndarray_type; diff --git a/docs/type-conversion.md b/docs/type-conversion.md index db30e60..dd2e21e 100644 --- a/docs/type-conversion.md +++ b/docs/type-conversion.md @@ -20,6 +20,7 @@ When calling Python functions or evaluating expressions, Erlang values are autom | `list()` | `list` | Recursively converted | | `tuple()` | `tuple` | Recursively converted | | `map()` | `dict` | Keys and values recursively converted | +| `pid()` | `erlang.Pid` | Opaque wrapper, round-trips back to Erlang PID | ### Examples @@ -74,6 +75,7 @@ Return values from Python are converted back to Erlang: | `list` | `list()` | Recursively converted | | `tuple` | `tuple()` | Recursively converted | | `dict` | `map()` | Keys and values recursively converted | +| `erlang.Pid` | `pid()` | Round-trips back to the original Erlang PID | | generator | internal | Used with streaming functions | ### Examples @@ -114,6 +116,43 @@ Return values from Python are converted back to Erlang: {ok, #{<<"a">> := 1, <<"b">> := 2}} = py:eval(<<"{'a': 1, 'b': 2}">>). ``` +### Process Identifiers (PIDs) + +Erlang PIDs are converted to opaque `erlang.Pid` objects in Python. These can be +passed back to Erlang (where they become real PIDs again) or used with `erlang.send()`: + +```erlang +%% Pass self() to Python - arrives as erlang.Pid +{ok, Pid} = py:call(mymod, round_trip_pid, [self()]). +%% Pid =:= self() + +%% Python can send messages directly to Erlang processes +ok = py:exec(<<" +import erlang +def notify(pid, data): + erlang.send(pid, ('notification', data)) +">>). +``` + +```python +import erlang + +def forward_to(pid, message): + """Send a message to an Erlang process.""" + erlang.send(pid, message) +``` + +`erlang.Pid` objects support equality and hashing, so they can be compared and +used as dict keys or in sets: + +```python +pid_a == pid_b # True if both wrap the same Erlang PID +{pid: "value"} # Works as a dict key +pid in seen_pids # Works in sets +``` + +Sending to a process that has already exited raises `erlang.ProcessError`. + ## Special Cases ### NumPy Arrays diff --git a/test/py_pid_send_SUITE.erl b/test/py_pid_send_SUITE.erl new file mode 100644 index 0000000..1ae0b43 --- /dev/null +++ b/test/py_pid_send_SUITE.erl @@ -0,0 +1,219 @@ +%%% @doc Common Test suite for PID serialization, erlang.send(), and +%%% SuspensionRequired BaseException inheritance. +%%% +%%% Tests three new erlang_python features: +%%% 1. Erlang PIDs serialize to erlang.Pid objects in Python and back +%%% 2. erlang.send(pid, term) for fire-and-forget message passing +%%% 3. SuspensionRequired inherits from BaseException (not Exception) +-module(py_pid_send_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_pid_type_in_python/1, + test_pid_isinstance_check/1, + test_pid_round_trip/1, + test_pid_equality/1, + test_pid_hash/1, + test_pid_inequality/1, + test_send_simple_message/1, + test_send_multiple_messages/1, + test_send_complex_term/1, + test_send_bad_pid_raises/1, + test_suspension_not_caught_by_except/1, + test_suspension_is_base_exception/1, + test_suspension_not_subclass_of_exception/1, + test_send_to_dead_process/1, + test_send_dead_process_raises_process_error/1, + test_process_error_is_exception_subclass/1, + test_pid_in_complex_structure/1 +]). + +all() -> + [ + test_pid_type_in_python, + test_pid_isinstance_check, + test_pid_round_trip, + test_pid_equality, + test_pid_hash, + test_pid_inequality, + test_send_simple_message, + test_send_multiple_messages, + test_send_complex_term, + test_send_bad_pid_raises, + test_suspension_not_caught_by_except, + test_suspension_is_base_exception, + test_suspension_not_subclass_of_exception, + test_send_to_dead_process, + test_send_dead_process_raises_process_error, + test_process_error_is_exception_subclass, + test_pid_in_complex_structure + ]. + +init_per_suite(Config) -> + {ok, _} = application:ensure_all_started(erlang_python), + %% Add test directory to Python path + TestDir = code:lib_dir(erlang_python, test), + ok = py:exec(iolist_to_binary(io_lib:format( + "import sys; sys.path.insert(0, '~s')", [TestDir]))), + Config. + +end_per_suite(_Config) -> + ok = application:stop(erlang_python), + ok. + +init_per_testcase(_TestCase, Config) -> + Config. + +end_per_testcase(_TestCase, _Config) -> + catch py:unregister_function(test_pid_echo), + ok. + +%%% ============================================================================ +%%% PID Serialization Tests +%%% ============================================================================ + +%% @doc Verify that an Erlang PID arrives in Python as an erlang.Pid object. +test_pid_type_in_python(_Config) -> + {ok, <<"Pid">>} = py:call(py_test_pid_send, get_pid_type, [self()]), + ok. + +%% @doc Verify isinstance(pid, erlang.Pid) returns True. +test_pid_isinstance_check(_Config) -> + {ok, true} = py:call(py_test_pid_send, is_erlang_pid, [self()]), + ok. + +%% @doc Verify a PID round-trips: Erlang -> Python -> Erlang stays a real PID. +test_pid_round_trip(_Config) -> + Pid = self(), + {ok, ReturnedPid} = py:call(py_test_pid_send, round_trip_pid, [Pid]), + Pid = ReturnedPid, + ok. + +%% @doc Verify two erlang.Pid wrapping the same PID are equal. +test_pid_equality(_Config) -> + Pid = self(), + {ok, true} = py:call(py_test_pid_send, pid_equality, [Pid, Pid]), + ok. + +%% @doc Verify equal PIDs produce the same hash (usable in sets/dicts). +test_pid_hash(_Config) -> + Pid = self(), + {ok, true} = py:call(py_test_pid_send, pid_hash_equal, [Pid, Pid]), + ok. + +%% @doc Verify two different PIDs are not equal. +test_pid_inequality(_Config) -> + Pid1 = self(), + Pid2 = spawn(fun() -> receive stop -> ok end end), + {ok, true} = py:call(py_test_pid_send, pid_inequality, [Pid1, Pid2]), + Pid2 ! stop, + ok. + +%%% ============================================================================ +%%% erlang.send() Tests +%%% ============================================================================ + +%% @doc Test erlang.send(pid, term) delivers a simple message. +test_send_simple_message(_Config) -> + Pid = self(), + {ok, true} = py:call(py_test_pid_send, send_message, [Pid, <<"hello">>]), + receive + <<"hello">> -> ok + after 5000 -> + ct:fail(timeout_waiting_for_message) + end. + +%% @doc Test sending multiple messages in sequence. +test_send_multiple_messages(_Config) -> + Pid = self(), + {ok, 3} = py:call(py_test_pid_send, send_multiple, + [Pid, [<<"one">>, <<"two">>, <<"three">>]]), + receive <<"one">> -> ok after 5000 -> ct:fail(timeout_msg_1) end, + receive <<"two">> -> ok after 5000 -> ct:fail(timeout_msg_2) end, + receive <<"three">> -> ok after 5000 -> ct:fail(timeout_msg_3) end. + +%% @doc Test sending a complex compound term. +test_send_complex_term(_Config) -> + Pid = self(), + {ok, true} = py:call(py_test_pid_send, send_complex_term, [Pid]), + receive + {<<"hello">>, 42, [1, 2, 3], #{<<"key">> := <<"value">>}, true} -> ok + after 5000 -> + ct:fail(timeout_waiting_for_complex_message) + end. + +%% @doc Verify erlang.send() with a non-PID raises TypeError. +test_send_bad_pid_raises(_Config) -> + {ok, true} = py:call(py_test_pid_send, send_bad_pid, []), + ok. + +%% @doc Test sending to a process that has already exited. +test_send_to_dead_process(_Config) -> + %% Spawn a process that exits immediately + Pid = spawn(fun() -> ok end), + timer:sleep(100), %% ensure it's dead + %% erlang.send() to a dead PID should fail (enif_send returns 0) + {error, _} = py:call(py_test_pid_send, send_message, [Pid, <<"dead">>]), + ok. + +%% @doc Verify sending to a dead process raises erlang.ProcessError (not RuntimeError). +test_send_dead_process_raises_process_error(_Config) -> + Pid = spawn(fun() -> ok end), + timer:sleep(100), + {ok, true} = py:call(py_test_pid_send, send_dead_process_raises_process_error, [Pid]), + ok. + +%% @doc Verify erlang.ProcessError is a subclass of Exception. +test_process_error_is_exception_subclass(_Config) -> + {ok, true} = py:call(py_test_pid_send, process_error_is_exception_subclass, []), + ok. + +%%% ============================================================================ +%%% PID in Complex Structures +%%% ============================================================================ + +%% @doc Test PID embedded in lists and maps round-trips correctly. +test_pid_in_complex_structure(_Config) -> + Pid = self(), + %% Pass PID inside a list through Python and get it back + {ok, [ReturnedPid]} = py:call(py_test_pid_send, round_trip_pid, [[Pid]]), + Pid = ReturnedPid, + %% Pass PID inside a map + {ok, #{<<"pid">> := ReturnedPid2}} = py:call(py_test_pid_send, + round_trip_pid, [#{<<"pid">> => Pid}]), + Pid = ReturnedPid2, + ok. + +%%% ============================================================================ +%%% SuspensionRequired BaseException Tests +%%% ============================================================================ + +%% @doc Verify SuspensionRequired is NOT caught by `except Exception`. +%% This is the key behavior change: ASGI apps with `except Exception` +%% handlers will no longer intercept the suspension mechanism. +test_suspension_not_caught_by_except(_Config) -> + %% Register a simple callback + py:register_function(test_pid_echo, fun([X]) -> X end), + %% Call Python function that wraps erlang.call in try/except Exception + {ok, {<<"ok">>, 42}} = py:call(py_test_pid_send, + suspension_not_caught_by_except_exception, []), + ok. + +%% @doc Verify SuspensionRequired IS a subclass of BaseException. +test_suspension_is_base_exception(_Config) -> + {ok, true} = py:call(py_test_pid_send, suspension_caught_by_except_base, []), + ok. + +%% @doc Verify SuspensionRequired is NOT a subclass of Exception. +test_suspension_not_subclass_of_exception(_Config) -> + {ok, true} = py:call(py_test_pid_send, suspension_not_subclass_of_exception, []), + ok. diff --git a/test/py_test_pid_send.py b/test/py_test_pid_send.py new file mode 100644 index 0000000..3f2042b --- /dev/null +++ b/test/py_test_pid_send.py @@ -0,0 +1,110 @@ +"""Test module for PID serialization and erlang.send(). + +Tests that: +- Erlang PIDs arrive in Python as erlang.Pid objects +- erlang.Pid objects round-trip back to Erlang as real PIDs +- erlang.send(pid, term) delivers messages to Erlang processes +- SuspensionRequired is not caught by `except Exception` +""" + +import erlang + + +def get_pid_type(pid): + """Return the type name of the received argument.""" + return type(pid).__qualname__ + + +def is_erlang_pid(pid): + """Return True if the argument is an erlang.Pid.""" + return isinstance(pid, erlang.Pid) + + +def round_trip_pid(pid): + """Return the PID unchanged - tests that it converts back to Erlang PID.""" + return pid + + +def pid_equality(a, b): + """Return True if two erlang.Pid objects are equal.""" + return a == b + + +def pid_inequality(a, b): + """Return True if two different erlang.Pid objects are not equal.""" + return a != b + + +def pid_hash_equal(a, b): + """Return True if two equal erlang.Pid objects produce the same hash.""" + return hash(a) == hash(b) + + +def send_message(pid, msg): + """Send a message to an Erlang process using erlang.send().""" + erlang.send(pid, msg) + return True + + +def send_multiple(pid, messages): + """Send multiple messages to an Erlang process.""" + for msg in messages: + erlang.send(pid, msg) + return len(messages) + + +def send_complex_term(pid): + """Send a complex term (tuple of various types) to an Erlang process.""" + erlang.send(pid, ('hello', 42, [1, 2, 3], {'key': 'value'}, True)) + return True + + +def send_bad_pid(): + """Try to call erlang.send with a non-PID - should raise TypeError.""" + try: + erlang.send("not_a_pid", "msg") + return False # Should not reach here + except TypeError: + return True + + +def send_dead_process_raises_process_error(pid): + """Verify sending to a dead process raises erlang.ProcessError.""" + try: + erlang.send(pid, "msg") + return False + except erlang.ProcessError: + return True + + +def process_error_is_exception_subclass(): + """Verify erlang.ProcessError is a subclass of Exception (catchable).""" + return issubclass(erlang.ProcessError, Exception) + + +def suspension_not_caught_by_except_exception(): + """Verify SuspensionRequired is NOT caught by `except Exception`. + + Calls a registered Erlang function inside a try/except Exception block. + With BaseException inheritance, SuspensionRequired passes through. + """ + try: + result = erlang.call('test_pid_echo', 42) + return ('ok', result) + except Exception as e: + # SuspensionRequired should NOT land here anymore + return ('caught', str(type(e).__name__)) + + +def suspension_caught_by_except_base(): + """Verify SuspensionRequired IS caught by `except BaseException`. + + This confirms the exception still exists and propagates, just not + as a subclass of Exception. + """ + return issubclass(erlang.SuspensionRequired, BaseException) + + +def suspension_not_subclass_of_exception(): + """Verify SuspensionRequired is NOT a subclass of Exception.""" + return not issubclass(erlang.SuspensionRequired, Exception)