Skip to content

Commit 9cdfbd5

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

File tree

1 file changed

+57
-0
lines changed

1 file changed

+57
-0
lines changed

antipatterns.md

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,3 +164,60 @@ 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`), causing us to leave the `acmanager()` context.
203+
204+
In order to avoid this we need to make sure our async context managers always yield a reference
205+
to something that can be used to wait for (or check the state) of the background task. In the
206+
above example it could be something like:
207+
208+
```python
209+
@contextlib.asynccontextmanager
210+
async def acmanager(stream_writer):
211+
future = asyncio.create_task(producer(stream_writer))
212+
try:
213+
yield future
214+
finally:
215+
...
216+
217+
async def run():
218+
reader, writer = await asyncio.open_connection(...)
219+
async with acmanager(writer) as streaming_task:
220+
await asyncio.wait(
221+
[consumer(reader), streaming_task],
222+
return_when=asyncio.FIST_COMPLETED)
223+
```

0 commit comments

Comments
 (0)