Skip to content

Commit ec0bb00

Browse files
committed
Fire and forget async ctx blocks anti-pattern
1 parent 2ce3fa4 commit ec0bb00

File tree

1 file changed

+58
-0
lines changed

1 file changed

+58
-0
lines changed

antipatterns.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,61 @@ class User:
164164
Replacing `assert` statements with `raise AssertionError(...)` (or whatever
165165
exception class you prefer) ensures that these checks cannot be trivially
166166
disabled.
167+
168+
169+
## Fire and forget async context blocks
170+
171+
When writing asyncio-based async context blocks (i.e. using async context managers), we sometimes
172+
fail to continuously check that the background task started is still running. For example,
173+
our Connection multiplexing was implemented
174+
(https://github.com/ethereum/trinity/blob/0db2a36706e5327fa040258bb5fef3fae75d9d8c/p2p/connection.py#L132-L153)
175+
using an async context manager that used a bare yield, so its callsites could not perform any
176+
health checks on the task running in the background, hence a crash in the background task that failed
177+
to cancel the connection would leave the service running forever.
178+
179+
Here's a simpler, self-contained example:
180+
181+
```python
182+
@contextlib.asynccontextmanager
183+
async def acmanager(stream_writer):
184+
future = asyncio.create_task(producer(stream_writer))
185+
try:
186+
yield
187+
finally:
188+
if not future.done():
189+
future.cancel()
190+
with contextlib.suppress(asyncio.CancelledError):
191+
await future
192+
193+
async def run():
194+
reader, writer = await asyncio.open_connection(...)
195+
async with acmanager(writer):
196+
await consumer(reader)
197+
```
198+
199+
In the above example, if the background task running `producer(stream_writer)` terminates without
200+
closing the the writer, the `run()` function would continue running indefinitely and the exception
201+
from the background task would only propagate once the `await consumer(reader)` was cancelled (e.g
202+
by some external event like a `KeyboardInterrupt`) that caused us to leave the `acmanager()`
203+
context.
204+
205+
In order to avoid this we need to make sure our async context managers always yield a reference
206+
to something that can be used to wait for (or check the state) of the background task. In the
207+
above example it could be something like:
208+
209+
```python
210+
@contextlib.asynccontextmanager
211+
async def acmanager(stream_writer):
212+
future = asyncio.create_task(producer(stream_writer))
213+
try:
214+
yield future
215+
finally:
216+
...
217+
218+
async def run():
219+
reader, writer = await asyncio.open_connection(...)
220+
async with acmanager(writer) as streaming_task:
221+
await asyncio.wait(
222+
[consumer(reader), streaming_task],
223+
return_when=asyncio.FIST_COMPLETED)
224+
```

0 commit comments

Comments
 (0)