@@ -164,3 +164,61 @@ class User:
164164Replacing ` assert ` statements with ` raise AssertionError(...) ` (or whatever
165165exception class you prefer) ensures that these checks cannot be trivially
166166disabled.
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