Skip to content

Commit 2980156

Browse files
authored
Merge pull request #86 from benoitc/fix/pool-timers-reentrant-call
Fix lost pool timers and the py:call reentrancy deadlock
2 parents 581f3fa + d39e53d commit 2980156

21 files changed

Lines changed: 1111 additions & 491 deletions

‎CHANGELOG.md‎

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

3+
## Unreleased
4+
5+
### Fixed
6+
7+
- Tasks on the event loop pool that awaited `asyncio.sleep` for more than a
8+
few milliseconds never completed when several ran at once: with 24
9+
concurrent 50 ms sleeps, 23 timed out, while the same tasks run one after
10+
another always finished (in Hornbeam, concurrent ASGI requests hung until
11+
the request timeout). Every pool loop scheduled its timers on the default
12+
loop and polled that loop's queue, where the per-loop callback ids
13+
collided. Each pool loop now drives a Python loop bound to its own
14+
resource, a timer expiry is dispatched to the loop that set it, and a loop
15+
with no tasks leaves its pending events to whoever polls it.
16+
- A Python function called with `py:call` that called `erlang.call`, where
17+
the Erlang callback called `py:call` again, hung until the request timeout
18+
and then failed with "callback synchronisation lost; retry" or
19+
`{error, timeout}`, even one level deep; the same chain started with
20+
`py:eval` worked. `erlang.call` blocked the context thread on the thread
21+
worker pipe and the nested `py:call` waited on that context. The context
22+
thread now serves its own requests while it waits for the callback, so the
23+
nested `py:call` runs inline on the same context, at any depth and with
24+
several sequential `erlang.call`s in one function; the Python code still
25+
runs exactly once. Inside a running asyncio loop (`py_context:start_loop/2`,
26+
`asyncio.run` in a called function) the thread path stays.
27+
- The shared buffer's flow control and the sync `erlang.sleep` call Erlang
28+
through a blocking path that never suspends the request, so a `py:eval`
29+
that reads a shared buffer or sleeps is not replayed around them.
30+
- After a callback resumed, a `py:eval` or `py:call` that used a function
31+
defined with `py:exec` failed with `NameError: name ... is not defined`:
32+
the replay ran in the context globals instead of the caller's namespace.
33+
The replay now runs in the namespace of the original request.
34+
- An Erlang callback could not see the caller's `__main__` functions and
35+
was routed to another context: `py:call('__main__', double, [X])` from a
36+
callback failed with "module '__main__' has no attribute 'double'". The
37+
callback process is now bound to the suspended context and shares the
38+
caller's namespace, so the README reentrant example passes as written.
39+
340
## 5.0.0 (2026-08-29)
441

542
### Added

‎README.md‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -197,9 +197,10 @@ def process(x):
197197
%% 10 → double_via_python → double(10)=20 → +1 = 21
198198
```
199199

200-
The implementation uses a suspension/resume mechanism that frees the dirty
201-
scheduler while the Erlang callback executes, preventing deadlocks even with
202-
multiple levels of nesting.
200+
While the Erlang callback runs, the context thread keeps serving requests
201+
for its context, so the callback's own `py:call` runs there and nesting
202+
works at any depth. The callback shares the caller's Python namespace,
203+
which is how it can see `double`. No dirty scheduler is held meanwhile.
203204

204205
## Shared State Between Workers
205206

‎c_src/py_callback.c‎

Lines changed: 118 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
15441627
static 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 */
31913278
static 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

Comments
 (0)