Skip to content

Commit 45a7fcc

Browse files
committed
Refresh erlang.sleep docs + add silent= flag to erlang.install
The asyncio.md and migration.md tables claimed sync erlang.sleep() always releases the dirty scheduler. In v3.0 the dirty scheduler isn't held during sync calls anyway (async NIF dispatch returns immediately); what blocks is the worker pthread for py:call, the Erlang process for py:exec/py:eval, or no thread at all for awaited async sleep. Update the table and the sleep() docstring accordingly. erlang.install() emits a DeprecationWarning on 3.12-3.13 by design, but users who knowingly use the legacy pattern had no clean local opt-out. Add a keyword-only silent=False; passing silent=True suppresses the warning without disabling DeprecationWarning globally. The 3.14+ RuntimeError stays unconditional.
1 parent d6bb934 commit 45a7fcc

4 files changed

Lines changed: 65 additions & 30 deletions

File tree

docs/asyncio.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -707,14 +707,15 @@ def sync_handler():
707707
return "done"
708708
```
709709

710-
**Behavior by Context:**
710+
**Behavior by context (v3.0 worker-pthread architecture):**
711711

712-
| Context | Mechanism | Effect |
713-
|---------|-----------|--------|
714-
| Async (`await erlang.sleep()`) | `asyncio.sleep()` via `call_later()` | Yields to event loop, dirty scheduler released |
715-
| Sync (`erlang.sleep()`) | `erlang.call('_py_sleep')` with `receive/after` | Blocks Python, Erlang process suspends, dirty scheduler released |
712+
| Context | Mechanism | What blocks |
713+
|---------|-----------|-------------|
714+
| Async (`await erlang.sleep()`) | `asyncio.sleep()` via Erlang `send_after` | Yields to the event loop. The worker pthread is free to handle other tasks. |
715+
| Sync from `py:exec` / `py:eval` | `erlang.call('_py_sleep', secs)` triggers suspension; the dirty scheduler is released and an Erlang `receive ... after` parks the caller | Caller's Erlang process. Dirty scheduler free for other work. |
716+
| Sync from `py:call` (worker mode) | Falls back to `time.sleep`; replaying the Python frame around a suspension would change time-measurement semantics | The context's worker pthread for the sleep duration. Async NIF dispatch returns immediately so the BEAM dirty scheduler is **not** held; other Erlang processes and other contexts run normally. |
716717

717-
Both modes allow other Erlang processes and Python contexts to run during the sleep.
718+
In every case the BEAM dirty scheduler is freed during the sleep — the difference is which thread blocks (Erlang process, dirty scheduler, or worker pthread).
718719

719720
#### asyncio.sleep(delay)
720721

docs/migration.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -541,20 +541,25 @@ erlang.send(("my_server", "node@host"), {"event": "user_login", "user": 123})
541541
erlang.send(pid, "hello")
542542
```
543543

544-
### `erlang.sleep()` with Dirty Scheduler Release
544+
### `erlang.sleep()` cooperates with the BEAM scheduler
545545

546-
Synchronous sleep that releases the Erlang dirty scheduler thread:
546+
Synchronous sleep that lets other Erlang processes and Python
547+
contexts make progress during the wait:
547548

548549
```python
549550
import erlang
550551

551552
def slow_handler():
552-
# Sleep without blocking Erlang scheduler
553-
erlang.sleep(1.0) # Releases dirty scheduler during sleep
553+
erlang.sleep(1.0)
554554
return "done"
555555
```
556556

557-
Unlike `time.sleep()`, `erlang.sleep()` releases the dirty NIF thread while waiting, allowing other Python calls to use the scheduler slot.
557+
The BEAM dirty scheduler is never held during the sleep. The exact
558+
thread that blocks depends on context — the Erlang process for
559+
`py:exec` / `py:eval`, or the context's private worker pthread for
560+
`py:call`. See the [behavior-by-context table in the asyncio
561+
guide](asyncio.md#erlangsleepseconds) for the full breakdown. In all
562+
cases, other contexts and other Erlang processes continue running.
558563

559564
### `erlang.call()` Blocking with Explicit Scheduling
560565

priv/_erlang_impl/__init__.py

Lines changed: 37 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -226,17 +226,26 @@ def sleep(seconds):
226226
- Async context: Returns an awaitable (use with await)
227227
- Sync context: Blocks synchronously
228228
229-
**Dirty Scheduler Release:**
230-
231-
In async context, uses asyncio.sleep() which routes through the Erlang
232-
timer system via erlang:send_after. The dirty scheduler is released
233-
because the Python code yields back to the event loop.
234-
235-
In sync context (when called from py:exec or py:eval), the sleep uses
236-
Erlang's receive/after via erlang.call('_py_sleep', seconds), which
237-
releases the dirty NIF scheduler thread. When called from py:call
238-
contexts, falls back to Python's time.sleep() which blocks the dirty
239-
scheduler but ensures correct time measurement behavior.
229+
**Behavior by context (v3.0 worker-pthread architecture)**:
230+
231+
The BEAM dirty scheduler is never held during the sleep — the
232+
difference is which thread blocks.
233+
234+
- Async (``await erlang.sleep()``) uses ``asyncio.sleep()``, which
235+
routes through Erlang's ``send_after`` timer. The coroutine
236+
yields to the event loop; the worker pthread handles other tasks.
237+
- Sync from ``py:exec`` / ``py:eval`` calls
238+
``erlang.call('_py_sleep', seconds)``. The suspension machinery
239+
releases the dirty scheduler and parks the caller's Erlang
240+
process in a ``receive ... after``.
241+
- Sync from ``py:call`` falls back to ``time.sleep`` — the worker
242+
pthread blocks for the sleep duration. The BEAM dirty scheduler
243+
is *not* held here either: the NIF dispatch returned immediately
244+
and the caller is waiting in an Erlang ``receive`` on the
245+
context process. Other Erlang processes and other contexts run
246+
normally during the sleep. (Replaying a suspended Python frame
247+
around ``time.time()`` would change time-measurement semantics,
248+
which is why ``py:call`` doesn't take the suspension path.)
240249
241250
Args:
242251
seconds: Duration to sleep in seconds (float or int).
@@ -246,13 +255,13 @@ def sleep(seconds):
246255
In sync context: None (blocks until sleep completes).
247256
248257
Example:
249-
# Async context - releases dirty scheduler via event loop yield
258+
# Async context
250259
async def main():
251-
await erlang.sleep(0.5) # Uses Erlang timer system
260+
await erlang.sleep(0.5)
252261
253262
# Sync context
254263
def handler():
255-
erlang.sleep(0.5) # Blocks for 0.5 seconds
264+
erlang.sleep(0.5)
256265
"""
257266
try:
258267
asyncio.get_running_loop()
@@ -396,7 +405,7 @@ def _run_async_from_erlang(module, func, args, kwargs):
396405
return run(coro)
397406

398407

399-
def install():
408+
def install(*, silent=False):
400409
"""Install ErlangEventLoopPolicy as the default event loop policy.
401410
402411
Deprecated in Python 3.12+; raises ``RuntimeError`` on Python 3.14+
@@ -408,23 +417,32 @@ def install():
408417
both work on every supported Python version and don't touch the
409418
global policy.
410419
420+
Args:
421+
silent: If True (keyword-only), suppress the per-call
422+
``DeprecationWarning`` on Python 3.12-3.13. Useful when
423+
you knowingly rely on the legacy pattern and don't want
424+
to silence ``DeprecationWarning`` globally. The 3.14+
425+
``RuntimeError`` is *not* suppressible — that pattern
426+
won't work on 3.16 and the call has no fallback there.
427+
411428
Example (legacy pattern, Python 3.9–3.13 only):
412429
import asyncio
413430
import erlang
414431
415-
erlang.install()
416-
asyncio.run(main()) # Uses Erlang event loop
432+
erlang.install(silent=True) # opt out of the warning
433+
asyncio.run(main()) # Uses Erlang event loop
417434
"""
418435
if sys.version_info >= (3, 14):
419436
raise RuntimeError(
420437
"erlang.install() is not supported on Python 3.14+. "
421438
"Use erlang.run(main) or "
422439
"asyncio.Runner(loop_factory=erlang.new_event_loop) instead."
423440
)
424-
if sys.version_info >= (3, 12):
441+
if sys.version_info >= (3, 12) and not silent:
425442
warnings.warn(
426443
"erlang.install() is deprecated in Python 3.12+. "
427-
"Use erlang.run(main()) instead.",
444+
"Use erlang.run(main()) instead, or pass silent=True "
445+
"to suppress this warning.",
428446
DeprecationWarning,
429447
stacklevel=2
430448
)

priv/tests/test_erlang_api.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,17 @@ def test_install_function(self):
279279
any(issubclass(warning.category, DeprecationWarning)
280280
for warning in w)
281281
)
282+
283+
# silent=True must suppress the warning even with
284+
# simplefilter("always").
285+
with warnings.catch_warnings(record=True) as w:
286+
warnings.simplefilter("always")
287+
erlang.install(silent=True)
288+
install_warnings = [
289+
warning for warning in w
290+
if "erlang.install()" in str(warning.message)
291+
]
292+
self.assertEqual(install_warnings, [])
282293
else:
283294
erlang.install()
284295

0 commit comments

Comments
 (0)