Skip to content

Commit 305f2b0

Browse files
committed
fix: Close polling transport in a finally so a cancelled stop() does not leak it
AsyncPollingUpdateProcessor.stop() awaited wait_stopped() and only then closed the requester. If the caller of stop() was cancelled during that wait, close() never ran and an owned aiohttp transport could leak. Move the close into a finally so it still runs on cancellation, while letting CancelledError propagate. Adds a regression test that cancels stop() mid-wait and asserts the transport is still closed.
1 parent 357fbcf commit 305f2b0

2 files changed

Lines changed: 34 additions & 5 deletions

File tree

‎ldclient/impl/datasource/async_polling.py‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,13 @@ def initialized(self):
4444

4545
async def stop(self):
4646
self.__stop_with_error_info(None)
47-
# Wait for the current poll to finish before closing the transport. This way
48-
# stop() does not return until background work has stopped, so we never close
49-
# the transport while a request is still using it.
50-
await self._task.wait_stopped()
51-
await self._requester.close()
47+
# Wait for the current poll to finish before closing the transport, so we do
48+
# not close it while a request is still using it. The close is in a finally
49+
# so an owned transport is still released if stop() is cancelled mid-wait.
50+
try:
51+
await self._task.wait_stopped()
52+
finally:
53+
await self._requester.close()
5254

5355
def __stop_with_error_info(self, error: Optional[DataSourceErrorInfo]):
5456
log.info("Stopping AsyncPollingUpdateProcessor")

‎ldclient/testing/impl/datasource/test_async_polling.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -438,3 +438,30 @@ async def test_second_start_call_raises(self, mock_interval):
438438
processor.start()
439439

440440
await processor.stop()
441+
442+
@pytest.mark.asyncio
443+
async def test_stop_closes_transport_when_cancelled_mid_wait(self):
444+
# If the caller of stop() is cancelled while it waits for the poll to
445+
# finish, the owned transport must still be closed (the close is in a
446+
# finally), rather than leaking.
447+
requester = MagicMock()
448+
requester.close = AsyncMock()
449+
processor = make_processor(requester=requester)
450+
451+
# Replace the repeating task so wait_stopped() hangs until we cancel stop().
452+
waiting = asyncio.Event()
453+
454+
async def hang():
455+
waiting.set()
456+
await asyncio.Event().wait()
457+
458+
processor._task = MagicMock()
459+
processor._task.wait_stopped = hang
460+
461+
stop_task = asyncio.create_task(processor.stop())
462+
await asyncio.wait_for(waiting.wait(), timeout=2.0)
463+
stop_task.cancel()
464+
with pytest.raises(asyncio.CancelledError):
465+
await stop_task
466+
467+
requester.close.assert_awaited_once()

0 commit comments

Comments
 (0)