Skip to content

Commit f6e1a35

Browse files
authored
Add erlang.spawn_task() for fire-and-forget async tasks (#22)
* Add erlang.spawn_task() for fire-and-forget async tasks Implements spawn_task() function that creates and schedules async tasks from both sync and async contexts. This is useful for spawning background work from synchronous Python code called by Erlang. The function: - Returns an asyncio.Task for optional await/cancel - Automatically wakes up the event loop in sync context - Works with both ErlangEventLoop and standard asyncio loops * Add spawn_task to changelog and asyncio documentation - Add Unreleased section with spawn_task feature - Document erlang.spawn_task() in asyncio.md API reference
1 parent c097811 commit f6e1a35

File tree

4 files changed

+241
-0
lines changed

4 files changed

+241
-0
lines changed

CHANGELOG.md

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

3+
## Unreleased
4+
5+
### Added
6+
7+
- **`erlang.spawn_task(coro)`** - Spawn async tasks from both sync and async contexts
8+
- Works in sync code called by Erlang (where `asyncio.get_running_loop()` fails)
9+
- Returns `asyncio.Task` for optional await/cancel (fire-and-forget pattern)
10+
- Automatically wakes up the event loop in sync context
11+
- Works with both ErlangEventLoop and standard asyncio loops
12+
313
## 2.0.0 (2026-03-09)
414

515
### Added

docs/asyncio.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,40 @@ async def main():
784784
erlang.run(main())
785785
```
786786

787+
#### erlang.spawn_task(coro, *, name=None)
788+
789+
Spawn an async task from both sync and async contexts. This is useful for fire-and-forget background work from synchronous Python code called by Erlang.
790+
791+
```python
792+
import erlang
793+
794+
# From sync code called by Erlang
795+
def handle_request(data):
796+
# This works even though there's no running event loop
797+
erlang.spawn_task(process_async(data))
798+
return 'ok'
799+
800+
# From async code
801+
async def handler():
802+
# Also works in async context
803+
erlang.spawn_task(background_work())
804+
await other_work()
805+
806+
async def process_async(data):
807+
await asyncio.sleep(0.1)
808+
# Do async processing...
809+
810+
async def background_work():
811+
await asyncio.sleep(0.1)
812+
# Do background work...
813+
```
814+
815+
**Key features:**
816+
- Works in sync context where `asyncio.get_running_loop()` would fail
817+
- Returns `asyncio.Task` for optional await/cancel
818+
- Automatically wakes up the event loop to ensure the task runs promptly
819+
- Works with both ErlangEventLoop and standard asyncio loops
820+
787821
#### asyncio.wait(fs, *, timeout=None, return_when=ALL_COMPLETED)
788822

789823
Wait for multiple futures/tasks.

priv/_erlang_impl/__init__.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@
6666

6767
__all__ = [
6868
'run',
69+
'spawn_task',
6970
'new_event_loop',
7071
'get_event_loop_policy',
7172
'install',
@@ -162,6 +163,89 @@ async def main():
162163
loop.close()
163164

164165

166+
def spawn_task(coro, *, name=None):
167+
"""Spawn an async task, working in both async and sync contexts.
168+
169+
This function creates and schedules a task on the event loop, with
170+
automatic wakeup for Erlang-driven loops where the loop may not be
171+
actively polling.
172+
173+
Args:
174+
coro: The coroutine to run as a task.
175+
name: Optional name for the task (Python 3.8+).
176+
177+
Returns:
178+
asyncio.Task: The created task. Can be ignored (fire-and-forget)
179+
or awaited/cancelled if needed.
180+
181+
Raises:
182+
RuntimeError: If no event loop is available or the loop is closed.
183+
184+
Example:
185+
# From sync code called by Erlang
186+
def handle_request(data):
187+
erlang.spawn_task(process_async(data))
188+
return 'ok'
189+
190+
# From async code
191+
async def handler():
192+
erlang.spawn_task(background_work())
193+
await other_work()
194+
"""
195+
# Try to get the running loop first (works in async context)
196+
try:
197+
loop = asyncio.get_running_loop()
198+
# In async context, just create_task directly
199+
if name is not None:
200+
return loop.create_task(coro, name=name)
201+
else:
202+
return loop.create_task(coro)
203+
except RuntimeError:
204+
pass
205+
206+
# Sync context: get the event loop
207+
try:
208+
loop = asyncio.get_event_loop()
209+
except RuntimeError:
210+
coro.close() # Prevent "coroutine was never awaited" warning
211+
raise RuntimeError(
212+
"No event loop available. Ensure erlang is initialized or "
213+
"call from within an async context."
214+
)
215+
216+
if loop.is_closed():
217+
coro.close()
218+
raise RuntimeError("Event loop is closed")
219+
220+
# Create the task
221+
try:
222+
if name is not None:
223+
task = loop.create_task(coro, name=name)
224+
else:
225+
task = loop.create_task(coro)
226+
except Exception:
227+
coro.close()
228+
raise
229+
230+
# Wake up the event loop to process the task
231+
# This is critical for sync context - without wakeup, the task
232+
# waits until the next event/timeout
233+
if hasattr(loop, '_pel') and hasattr(loop, '_loop_capsule'):
234+
# ErlangEventLoop - use native wakeup
235+
try:
236+
loop._pel._wakeup_for(loop._loop_capsule)
237+
except Exception:
238+
pass
239+
elif hasattr(loop, '_write_to_self'):
240+
# Standard asyncio loop - use self-pipe trick
241+
try:
242+
loop._write_to_self()
243+
except Exception:
244+
pass
245+
246+
return task
247+
248+
165249
def install():
166250
"""Install ErlangEventLoopPolicy as the default event loop policy.
167251

priv/tests/test_spawn_task.py

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
# Copyright 2026 Benoit Chesneau
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""
16+
Tests for erlang.spawn_task() function.
17+
18+
Tests spawning async tasks from both sync and async contexts.
19+
"""
20+
21+
import asyncio
22+
import os
23+
import sys
24+
import unittest
25+
26+
from . import _testbase as tb
27+
28+
29+
def _get_spawn_task():
30+
"""Get spawn_task function, handling import path setup."""
31+
try:
32+
import erlang
33+
return erlang.spawn_task
34+
except ImportError:
35+
pass
36+
37+
# Add priv directory to path for _erlang_impl
38+
priv_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
39+
if priv_dir not in sys.path:
40+
sys.path.insert(0, priv_dir)
41+
42+
from _erlang_impl import spawn_task
43+
return spawn_task
44+
45+
46+
spawn_task = _get_spawn_task()
47+
48+
49+
class _TestSpawnTask:
50+
"""Tests for spawn_task functionality."""
51+
52+
def test_spawn_task_from_async_context(self):
53+
"""Test spawning a task from async context."""
54+
results = []
55+
56+
async def background():
57+
results.append('background')
58+
59+
async def main():
60+
task = spawn_task(background())
61+
self.assertIsInstance(task, asyncio.Task)
62+
await asyncio.sleep(0.01)
63+
results.append('main')
64+
65+
self.loop.run_until_complete(main())
66+
self.assertIn('background', results)
67+
self.assertIn('main', results)
68+
69+
def test_spawn_task_returns_awaitable(self):
70+
"""Test that spawn_task returns an awaitable Task."""
71+
async def compute():
72+
return 42
73+
74+
async def main():
75+
task = spawn_task(compute())
76+
result = await task
77+
self.assertEqual(result, 42)
78+
79+
self.loop.run_until_complete(main())
80+
81+
def test_spawn_task_with_name(self):
82+
"""Test spawning a named task."""
83+
async def noop():
84+
pass
85+
86+
async def main():
87+
task = spawn_task(noop(), name='test-task')
88+
self.assertEqual(task.get_name(), 'test-task')
89+
await task
90+
91+
self.loop.run_until_complete(main())
92+
93+
def test_spawn_task_exception_handling(self):
94+
"""Test that exceptions in spawned tasks are captured."""
95+
async def failing():
96+
raise ValueError("test error")
97+
98+
async def main():
99+
task = spawn_task(failing())
100+
await asyncio.sleep(0.01)
101+
self.assertTrue(task.done())
102+
with self.assertRaises(ValueError):
103+
task.result()
104+
105+
self.loop.run_until_complete(main())
106+
107+
108+
class TestErlangSpawnTask(_TestSpawnTask, tb.ErlangTestCase):
109+
pass
110+
111+
112+
class TestAIOSpawnTask(_TestSpawnTask, tb.AIOTestCase):
113+
pass

0 commit comments

Comments
 (0)