@@ -365,7 +365,8 @@ static suspended_context_state_t *create_suspended_context_state_for_call(
365365 ErlNifBinary * module_bin ,
366366 ErlNifBinary * func_bin ,
367367 ERL_NIF_TERM args_term ,
368- ERL_NIF_TERM kwargs_term ) {
368+ ERL_NIF_TERM kwargs_term ,
369+ py_env_resource_t * penv ) {
369370
370371 /* Allocate the suspended context state resource */
371372 suspended_context_state_t * state = enif_alloc_resource (
@@ -379,6 +380,10 @@ static suspended_context_state_t *create_suspended_context_state_for_call(
379380
380381 state -> ctx = ctx ;
381382 enif_keep_resource (ctx ); /* Keep ctx alive while suspended state exists */
383+ state -> penv = penv ;
384+ if (penv != NULL ) {
385+ enif_keep_resource (penv ); /* The replay runs in the request's namespace */
386+ }
382387 state -> callback_id = tl_pending_callback_id ;
383388 state -> request_type = PY_REQ_CALL ;
384389
@@ -450,7 +455,8 @@ static suspended_context_state_t *create_suspended_context_state_for_eval(
450455 ErlNifEnv * env ,
451456 py_context_t * ctx ,
452457 ErlNifBinary * code_bin ,
453- ERL_NIF_TERM locals_term ) {
458+ ERL_NIF_TERM locals_term ,
459+ py_env_resource_t * penv ) {
454460
455461 (void )env ;
456462
@@ -466,6 +472,10 @@ static suspended_context_state_t *create_suspended_context_state_for_eval(
466472
467473 state -> ctx = ctx ;
468474 enif_keep_resource (ctx ); /* Keep ctx alive while suspended state exists */
475+ state -> penv = penv ;
476+ if (penv != NULL ) {
477+ enif_keep_resource (penv ); /* The replay runs in the request's namespace */
478+ }
469479 state -> callback_id = tl_pending_callback_id ;
470480 state -> request_type = PY_REQ_EVAL ;
471481
@@ -1541,6 +1551,79 @@ static PyObject *py_consume_time_slice(PyObject *self, PyObject *args) {
15411551 *
15421552 * This allows the dirty scheduler to be freed while waiting for the callback.
15431553 */
1554+ /**
1555+ * True when an asyncio loop is running on this thread.
1556+ *
1557+ * A suspension unwinds the whole Python stack of the request, which cannot
1558+ * be done from inside a running loop: erlang._run_loop_forever (the
1559+ * context worker loop) and asyncio.run() inside a called function keep the
1560+ * blocking thread path for their erlang.call.
1561+ */
1562+ static bool asyncio_loop_running_here (void ) {
1563+ PyObject * modules = PyImport_GetModuleDict ();
1564+ if (modules == NULL ) {
1565+ return false;
1566+ }
1567+ PyObject * events = PyDict_GetItemString (modules , "asyncio.events" ); /* Borrowed */
1568+ if (events == NULL ) {
1569+ return false; /* asyncio never imported: no loop can be running */
1570+ }
1571+ PyObject * get_running = PyObject_GetAttrString (events , "_get_running_loop" );
1572+ if (get_running == NULL ) {
1573+ PyErr_Clear ();
1574+ return false;
1575+ }
1576+ PyObject * loop = PyObject_CallNoArgs (get_running );
1577+ Py_DECREF (get_running );
1578+ if (loop == NULL ) {
1579+ PyErr_Clear ();
1580+ return false;
1581+ }
1582+ bool running = (loop != Py_None );
1583+ Py_DECREF (loop );
1584+ return running ;
1585+ }
1586+
1587+ /**
1588+ * erlang._call_blocking(name, *args): the thread worker path, always.
1589+ *
1590+ * Blocks the calling Python thread on the thread worker pipe until the
1591+ * Erlang function returns; the Python frame is never suspended or
1592+ * replayed. For the library's own flow-control callbacks (shared buffer
1593+ * waits, erlang.sleep), whose callers keep state across the call, and for
1594+ * any Python thread.
1595+ */
1596+ static PyObject * erlang_call_blocking_impl (PyObject * self , PyObject * args ) {
1597+ (void )self ;
1598+
1599+ Py_ssize_t nargs = PyTuple_Size (args );
1600+ if (nargs < 1 ) {
1601+ PyErr_SetString (PyExc_TypeError , "erlang.call requires at least a function name" );
1602+ return NULL ;
1603+ }
1604+
1605+ PyObject * name_obj = PyTuple_GetItem (args , 0 );
1606+ if (!PyUnicode_Check (name_obj )) {
1607+ PyErr_SetString (PyExc_TypeError , "Function name must be a string" );
1608+ return NULL ;
1609+ }
1610+ const char * func_name = PyUnicode_AsUTF8 (name_obj );
1611+ if (func_name == NULL ) {
1612+ return NULL ;
1613+ }
1614+ size_t func_name_len = strlen (func_name );
1615+
1616+ /* Build args list (remaining args) */
1617+ PyObject * call_args = PyTuple_GetSlice (args , 1 , nargs );
1618+ if (call_args == NULL ) {
1619+ return NULL ;
1620+ }
1621+
1622+ PyObject * result = thread_worker_call (func_name , func_name_len , call_args );
1623+ Py_DECREF (call_args );
1624+ return result ;
1625+ }
1626+
15441627static PyObject * erlang_call_impl (PyObject * self , PyObject * args ) {
15451628 (void )self ;
15461629
@@ -1560,58 +1643,62 @@ static PyObject *erlang_call_impl(PyObject *self, PyObject *args) {
15601643 /*
15611644 * Check if we have a callback handler available.
15621645 * Priority:
1563- * 1. tl_current_context with suspension enabled (new process-per-context API)
1564- * 2. tl_current_context with callback_handler (old blocking pipe mode)
1565- * 3. thread_worker_call (spawned threads)
1646+ * 1. tl_current_context with suspension enabled: eval requests and
1647+ * resume replays (ctx_execute_eval_with_env, nif_context_resume)
1648+ * 2. tl_current_context with a request in flight: call requests wait
1649+ * inline, serving the context's queue (ctx_call_erlang_inline)
1650+ * 3. tl_current_context with callback_handler (old blocking pipe mode)
1651+ * 4. thread_worker_call (spawned threads)
15661652 *
1567- * NOTE: In OWN_GIL mode, erlang.call() goes through thread_worker_call()
1568- * rather than using suspension/resume. This is because OWN_GIL contexts
1569- * bypass the suspension protocol - the dedicated pthread that owns the GIL
1570- * cannot be suspended. As a result, the call executes on a different
1571- * context/interpreter (the thread worker), not the calling OWN_GIL context.
1572- * Re-entrant calls back to the same OWN_GIL context are not supported.
1653+ * Inside a running asyncio loop the request stack cannot be unwound or
1654+ * blocked without stalling the loop: those calls take path 4.
15731655 */
1574- bool has_context_suspension = (tl_current_context != NULL && tl_allow_suspension );
1656+ bool loop_running = asyncio_loop_running_here ();
1657+ bool has_context_suspension = (tl_current_context != NULL && tl_allow_suspension &&
1658+ !loop_running );
15751659 bool has_context_handler = (tl_current_context != NULL && tl_current_context -> has_callback_handler );
1660+ bool has_context_inline = (tl_current_context != NULL && !tl_allow_suspension &&
1661+ tl_current_context -> has_current_caller &&
1662+ !has_context_handler && !loop_running );
15761663
1577- if (!has_context_suspension && !has_context_handler ) {
1578- /*
1579- * Not an executor thread - use thread worker path.
1580- * This enables any spawned Python thread to call erlang.call():
1581- * - threading.Thread instances
1582- * - concurrent.futures.ThreadPoolExecutor workers
1583- * - Any other Python threads
1584- * - OWN_GIL contexts (which don't support suspension)
1585- */
1664+ if (has_context_inline ) {
15861665 Py_ssize_t nargs = PyTuple_Size (args );
15871666 if (nargs < 1 ) {
15881667 PyErr_SetString (PyExc_TypeError , "erlang.call requires at least a function name" );
15891668 return NULL ;
15901669 }
1591-
15921670 PyObject * name_obj = PyTuple_GetItem (args , 0 );
15931671 if (!PyUnicode_Check (name_obj )) {
15941672 PyErr_SetString (PyExc_TypeError , "Function name must be a string" );
15951673 return NULL ;
15961674 }
1597- const char * func_name = PyUnicode_AsUTF8 (name_obj );
1675+ Py_ssize_t func_name_len = 0 ;
1676+ const char * func_name = PyUnicode_AsUTF8AndSize (name_obj , & func_name_len );
15981677 if (func_name == NULL ) {
15991678 return NULL ;
16001679 }
1601- size_t func_name_len = strlen (func_name );
1602-
1603- /* Build args list (remaining args) */
16041680 PyObject * call_args = PyTuple_GetSlice (args , 1 , nargs );
16051681 if (call_args == NULL ) {
16061682 return NULL ;
16071683 }
1608-
1609- /* Use thread worker call */
1610- PyObject * result = thread_worker_call (func_name , func_name_len , call_args );
1684+ PyObject * result = ctx_call_erlang_inline (tl_current_context , func_name ,
1685+ (size_t )func_name_len , call_args );
16111686 Py_DECREF (call_args );
16121687 return result ;
16131688 }
16141689
1690+ if (!has_context_suspension && !has_context_handler ) {
1691+ /*
1692+ * Not an executor thread - use thread worker path.
1693+ * This enables any spawned Python thread to call erlang.call():
1694+ * - threading.Thread instances
1695+ * - concurrent.futures.ThreadPoolExecutor workers
1696+ * - Any other Python threads
1697+ * - code running inside an asyncio loop on a context thread
1698+ */
1699+ return erlang_call_blocking_impl (self , args );
1700+ }
1701+
16151702 Py_ssize_t nargs = PyTuple_Size (args );
16161703 if (nargs < 1 ) {
16171704 PyErr_SetString (PyExc_TypeError , "erlang.call requires at least a function name" );
@@ -3189,6 +3276,8 @@ static PyObject *erlang_whereis_impl(PyObject *self, PyObject *args) {
31893276
31903277/* Python method definitions for erlang module */
31913278static PyMethodDef ErlangModuleMethods [] = {
3279+ {"_call_blocking" , erlang_call_blocking_impl , METH_VARARGS ,
3280+ "Call an Erlang function on the blocking thread path (no suspension, no replay)" },
31923281 {"call" , erlang_call_impl , METH_VARARGS ,
31933282 "Call a registered Erlang function.\n\n"
31943283 "Usage: erlang.call('func_name', arg1, arg2, ...)\n"
0 commit comments