Skip to content

Commit a3c4247

Browse files
committed
fix: Avoid CancelledError from being propagated to lifespan's receive()
Currently `LifeSpanOn.main()` is excepting `BaseException`, which also catches `CancelledError`. `CancelledError` is used by `asyncio` to signal that a task has been cancelled and is not an error condition. This commit changes the behavior to catch `CancelledError` separately and log it as a "Lifespan task cancelled" instead of letting the error propagate.
1 parent 44a3071 commit a3c4247

File tree

2 files changed

+44
-0
lines changed

2 files changed

+44
-0
lines changed

tests/test_lifespan.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import asyncio
2+
from unittest import mock
23

34
import pytest
45

@@ -126,6 +127,44 @@ async def test():
126127
loop.close()
127128

128129

130+
@pytest.mark.parametrize("mode", ("auto", "on"))
131+
def test_lifespan_on_when_app_is_cancelled(mode):
132+
def get_app(started_event: asyncio.Event):
133+
async def app(scope, receive, send):
134+
started_event.set()
135+
await receive()
136+
pytest.fail("Should be cancelled before it gets here.")
137+
138+
return app
139+
140+
async def test():
141+
# Python 3.8/3.9 doesn't allow us to instantiate an asyncio.Event object
142+
# while outside of a running event loop context
143+
started_event = asyncio.Event()
144+
145+
config = Config(app=get_app(started_event), lifespan=mode)
146+
lifespan = LifespanOn(config)
147+
148+
main_lifespan_task = asyncio.create_task(lifespan.main())
149+
150+
started_response = await started_event.wait()
151+
assert started_response
152+
assert not lifespan.shutdown_event.is_set()
153+
154+
with mock.patch.object(lifespan, "logger") as logger:
155+
main_lifespan_task.cancel()
156+
await main_lifespan_task
157+
158+
assert not lifespan.error_occured
159+
logger.info.assert_called_with("Lifespan task cancelled.")
160+
assert lifespan.startup_event.is_set()
161+
assert lifespan.shutdown_event.is_set()
162+
163+
loop = asyncio.new_event_loop()
164+
loop.run_until_complete(test())
165+
loop.close()
166+
167+
129168
@pytest.mark.parametrize("mode", ("auto", "on"))
130169
@pytest.mark.parametrize("raise_exception", (True, False))
131170
def test_lifespan_with_failed_startup(mode, raise_exception, caplog):

uvicorn/lifespan/on.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,11 @@ async def main(self) -> None:
8484
"state": self.state,
8585
}
8686
await app(scope, self.receive, self.send)
87+
except asyncio.CancelledError:
88+
# Lifespan task was cancelled, likely due to the server shutting down.
89+
# This is not an error that should be handled by BaseException above,
90+
# so we just log it and exit.
91+
self.logger.info("Lifespan task cancelled.")
8792
except BaseException as exc:
8893
self.asgi = None
8994
self.error_occured = True

0 commit comments

Comments
 (0)