Skip to content

Fix lost pool timers and the py:call reentrancy deadlock - #86

Merged
benoitc merged 2 commits into
mainfrom
fix/pool-timers-reentrant-call
Sep 22, 2026
Merged

benoitc merged 2 commits into
mainfrom
fix/pool-timers-reentrant-call

Conversation

@benoitc

@benoitc benoitc commented Sep 22, 2026

Copy link
Copy Markdown
Owner

Hornbeam hit two bugs in the embedded contexts.

Tasks on the event loop pool that awaited asyncio.sleep for more than a few milliseconds never completed when several ran at once: with 24 concurrent 50 ms sleeps, 23 timed out. Every pool loop drove a Python loop attached to the default loop's capsule, so its timers went to the default worker and its callback ids collided in the default queue. Each pool loop now drives a loop bound to its own resource, a timer expiry is dispatched to the loop that set it, and a loop with no tasks leaves its pending events to whoever polls it.

A Python function called with py:call that called erlang.call, where the callback called py:call again, hung until the request timeout and then failed with "callback synchronisation lost; retry". The context thread blocked on the thread worker pipe and the nested call waited on that context. The thread now serves its own request queue while it waits for the callback, so the nested py:call runs inline on the same context at any depth, and the Python code still runs exactly once. Suspension with replay stays a py:eval mechanism, since the shared buffer flow control and the documented erlang.call contract depend on exactly-once execution from py:call.

Checking the README reentrant example turned up two more defects on that path: a resumed py:eval replayed against the context globals instead of the request's namespace, and the callback process could not see the caller's __main__ functions. Both are fixed, and the README example is now a test.

Every event loop pool loop drove a Python loop attached to the default
loop's capsule, so its timers went to the default worker, its pending
queue was the default loop's, and the per-loop callback ids collided
there. With several tasks sleeping at once only one ever woke up.

Give each pool loop a capsule over its own resource, send a timer to
the loop that set it and let the worker dispatch the expiry there, key
timer refs process-wide, and release the Python loop before a pool
loop is destroyed. A worker no longer builds a Python loop for a loop
that has no tasks, so raw pending events stay pollable.
A function called with py:call that called erlang.call blocked the
context thread on the thread worker pipe, and the callback's nested
py:call waited on a busy context until the request timeout. The
context thread now sends the callback to its py_context process and
serves its own request queue while it waits for the reply, so the
callback's py:call runs on the same thread at any depth and the
Python code still runs exactly once. Inside a running asyncio loop
the thread path stays.

The callback process is bound to that context and inherits the
caller's process-local env, so the README example runs as written.
A resume of a suspended py:eval now replays in the namespace of the
original request instead of the context globals. The shared buffer
flow control and the sync erlang.sleep use a blocking call that never
suspends, so a py:eval around them is not replayed.
@benoitc
benoitc merged commit 2980156 into main Sep 22, 2026
20 checks passed
@benoitc
benoitc deleted the fix/pool-timers-reentrant-call branch September 22, 2026 17:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant