Skip to content

Commit bfb0877

Browse files
committed
Bugfix ensure ExceptionGroup lifespan failures crash the server
A lifespan.startup.failure should crash the server, however if these became wrapped in an Exception group in the ASGI app the server wouldn't crash, now fixed.
1 parent edd0aac commit bfb0877

File tree

5 files changed

+63
-32
lines changed

5 files changed

+63
-32
lines changed

src/hypercorn/asyncio/lifespan.py

+11-1
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
from __future__ import annotations
22

33
import asyncio
4+
import sys
45
from functools import partial
56
from typing import Any, Callable
67

78
from ..config import Config
89
from ..typing import AppWrapper, ASGIReceiveEvent, ASGISendEvent, LifespanScope, LifespanState
910
from ..utils import LifespanFailureError, LifespanTimeoutError
1011

12+
if sys.version_info < (3, 11):
13+
from exceptiongroup import BaseExceptionGroup
14+
1115

1216
class UnexpectedMessageError(Exception):
1317
pass
@@ -58,7 +62,13 @@ def _call_soon(func: Callable, *args: Any) -> Any:
5862
except LifespanFailureError:
5963
# Lifespan failures should crash the server
6064
raise
61-
except Exception:
65+
except (BaseExceptionGroup, Exception) as error:
66+
if isinstance(error, BaseExceptionGroup):
67+
failure_error = error.subgroup(LifespanFailureError)
68+
if failure_error is not None:
69+
# Lifespan failures should crash the server
70+
raise failure_error
71+
6272
self.supported = False
6373
if not self.startup.is_set():
6474
await self.config.log.warning(

src/hypercorn/trio/lifespan.py

+12-1
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,16 @@
11
from __future__ import annotations
22

3+
import sys
4+
35
import trio
46

57
from ..config import Config
68
from ..typing import AppWrapper, ASGIReceiveEvent, ASGISendEvent, LifespanScope, LifespanState
79
from ..utils import LifespanFailureError, LifespanTimeoutError
810

11+
if sys.version_info < (3, 11):
12+
from exceptiongroup import BaseExceptionGroup
13+
914

1015
class UnexpectedMessageError(Exception):
1116
pass
@@ -43,7 +48,13 @@ async def handle_lifespan(
4348
except LifespanFailureError:
4449
# Lifespan failures should crash the server
4550
raise
46-
except Exception:
51+
except (BaseExceptionGroup, Exception) as error:
52+
if isinstance(error, BaseExceptionGroup):
53+
failure_error = error.subgroup(LifespanFailureError)
54+
if failure_error is not None:
55+
# Lifespan failures should crash the server
56+
raise failure_error
57+
4758
self.supported = False
4859
if not self.startup.is_set():
4960
await self.config.log.warning(

tests/asyncio/test_lifespan.py

+20-5
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,14 @@
99
from hypercorn.app_wrappers import ASGIWrapper
1010
from hypercorn.asyncio.lifespan import Lifespan
1111
from hypercorn.config import Config
12-
from hypercorn.typing import Scope
12+
from hypercorn.typing import ASGIReceiveCallable, ASGISendCallable, Scope
1313
from hypercorn.utils import LifespanFailureError, LifespanTimeoutError
14-
from ..helpers import lifespan_failure, SlowLifespanFramework
14+
from ..helpers import SlowLifespanFramework
15+
16+
try:
17+
from asyncio import TaskGroup
18+
except ImportError:
19+
from taskgroup import TaskGroup # type: ignore
1520

1621

1722
async def no_lifespan_app(scope: Scope, receive: Callable, send: Callable) -> None:
@@ -47,17 +52,27 @@ async def test_startup_timeout_error() -> None:
4752
await task
4853

4954

55+
async def _lifespan_failure(
56+
scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable
57+
) -> None:
58+
async with TaskGroup():
59+
while True:
60+
message = await receive()
61+
if message["type"] == "lifespan.startup":
62+
await send({"type": "lifespan.startup.failed", "message": "Failure"})
63+
break
64+
65+
5066
@pytest.mark.asyncio
5167
async def test_startup_failure() -> None:
5268
event_loop: asyncio.AbstractEventLoop = asyncio.get_running_loop()
5369

54-
lifespan = Lifespan(ASGIWrapper(lifespan_failure), Config(), event_loop, {})
70+
lifespan = Lifespan(ASGIWrapper(_lifespan_failure), Config(), event_loop, {})
5571
lifespan_task = event_loop.create_task(lifespan.handle_lifespan())
5672
await lifespan.wait_for_startup()
5773
assert lifespan_task.done()
5874
exception = lifespan_task.exception()
59-
assert isinstance(exception, LifespanFailureError)
60-
assert str(exception) == "Lifespan failure in startup. 'Failure'"
75+
assert exception.subgroup(LifespanFailureError) is not None # type: ignore
6176

6277

6378
async def return_app(scope: Scope, receive: Callable, send: Callable) -> None:

tests/helpers.py

-10
Original file line numberDiff line numberDiff line change
@@ -71,16 +71,6 @@ async def echo_framework(
7171
await send({"type": "websocket.send", "text": event["text"], "bytes": event["bytes"]})
7272

7373

74-
async def lifespan_failure(
75-
scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable
76-
) -> None:
77-
while True:
78-
message = await receive()
79-
if message["type"] == "lifespan.startup":
80-
await send({"type": "lifespan.startup.failed", "message": "Failure"})
81-
break
82-
83-
8474
async def sanity_framework(
8575
scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable
8676
) -> None:

tests/trio/test_lifespan.py

+20-15
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@
1111
from hypercorn.app_wrappers import ASGIWrapper
1212
from hypercorn.config import Config
1313
from hypercorn.trio.lifespan import Lifespan
14+
from hypercorn.typing import ASGIReceiveCallable, ASGISendCallable, Scope
1415
from hypercorn.utils import LifespanFailureError, LifespanTimeoutError
15-
from ..helpers import lifespan_failure, SlowLifespanFramework
16+
from ..helpers import SlowLifespanFramework
1617

1718

1819
@pytest.mark.trio
@@ -26,19 +27,23 @@ async def test_startup_timeout_error(nursery: trio._core._run.Nursery) -> None:
2627
assert str(exc_info.value).startswith("Timeout whilst awaiting startup")
2728

2829

30+
async def _lifespan_failure(
31+
scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable
32+
) -> None:
33+
async with trio.open_nursery():
34+
while True:
35+
message = await receive()
36+
if message["type"] == "lifespan.startup":
37+
await send({"type": "lifespan.startup.failed", "message": "Failure"})
38+
break
39+
40+
2941
@pytest.mark.trio
3042
async def test_startup_failure() -> None:
31-
lifespan = Lifespan(ASGIWrapper(lifespan_failure), Config(), {})
32-
with pytest.raises(LifespanFailureError) as exc_info:
33-
try:
34-
async with trio.open_nursery() as lifespan_nursery:
35-
await lifespan_nursery.start(lifespan.handle_lifespan)
36-
await lifespan.wait_for_startup()
37-
except ExceptionGroup as exception:
38-
target_exception = exception
39-
if len(exception.exceptions) == 1:
40-
target_exception = exception.exceptions[0]
41-
42-
raise target_exception.with_traceback(target_exception.__traceback__)
43-
44-
assert str(exc_info.value) == "Lifespan failure in startup. 'Failure'"
43+
lifespan = Lifespan(ASGIWrapper(_lifespan_failure), Config(), {})
44+
try:
45+
async with trio.open_nursery() as lifespan_nursery:
46+
await lifespan_nursery.start(lifespan.handle_lifespan)
47+
await lifespan.wait_for_startup()
48+
except ExceptionGroup as error:
49+
assert error.subgroup(LifespanFailureError) is not None

0 commit comments

Comments
 (0)