From f7783ddd7f66e4072950ad71067ff128ccf8405d Mon Sep 17 00:00:00 2001 From: "Yuichiro Tachibana (Tsuchiya)" Date: Sat, 29 Aug 2026 21:30:09 +1000 Subject: [PATCH] fix(runtime-sdk): chain the app's exception onto a reported lifespan failure A reported startup or shutdown failure surfaced as a bare RuntimeError carrying only the message text, so the app's own exception reached callers nowhere. Frameworks re-raise it right after sending the failed event, and in that same-tick case the awaiter has not resumed, so attaching it there delivers it as the reported error's cause. --- packages/runtime-sdk/src/workers/asgi.py | 10 +++ .../workerd-test/asgi/tests/test_asgi.py | 64 +++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/packages/runtime-sdk/src/workers/asgi.py b/packages/runtime-sdk/src/workers/asgi.py index 76d2a5fb..13e8b91d 100644 --- a/packages/runtime-sdk/src/workers/asgi.py +++ b/packages/runtime-sdk/src/workers/asgi.py @@ -165,6 +165,16 @@ async def run_lifespan(): if not startup.done(): startup.set_result(False) return + # Frameworks re-raise the original right after sending the failed + # event, with no await in between, so the awaiter has not resumed + # and the attachment still reaches it as the cause: + # https://github.com/encode/starlette/blob/1.3.1/starlette/routing.py + for reported in (startup, shutdown_complete): + if reported.done() and not reported.cancelled(): + failure = reported.exception() + if failure is not None: + failure.__cause__ = exc + return # After a successful startup, a shutdown-phase error can't affect the # already-served request, so log it and let shutdown complete. logger.exception("Exception in ASGI lifespan application", exc_info=exc) diff --git a/packages/runtime-sdk/tests/workerd-test/asgi/tests/test_asgi.py b/packages/runtime-sdk/tests/workerd-test/asgi/tests/test_asgi.py index 28c4479d..a5476563 100644 --- a/packages/runtime-sdk/tests/workerd-test/asgi/tests/test_asgi.py +++ b/packages/runtime-sdk/tests/workerd-test/asgi/tests/test_asgi.py @@ -396,6 +396,70 @@ async def _scope(path): return json.loads(await response.text()) +class _StartupFailWithCauseApp: + """Starlette-style failure: send startup.failed, then re-raise the error.""" + + async def __call__(self, scope, receive, send): + if scope["type"] == "lifespan": + message = await receive() + if message["type"] == "lifespan.startup": + try: + raise ValueError("real startup error") + except ValueError: + await send( + { + "type": "lifespan.startup.failed", + "message": "startup failed", + } + ) + raise + + +@pytest.mark.asyncio +async def test_lifespan_startup_failure_chains_the_apps_exception(): + req = js.Request.new("http://example.com/startup-fail-cause") + with pytest.raises(RuntimeError, match="startup failed") as excinfo: + await asyncio.wait_for( + asgi.fetch(_StartupFailWithCauseApp(), req, env), timeout=5 + ) + assert isinstance(excinfo.value.__cause__, ValueError) + assert "real startup error" in str(excinfo.value.__cause__) + + +class _ShutdownFailWithCauseApp: + async def __call__(self, scope, receive, send): + if scope["type"] == "lifespan": + while True: + message = await receive() + if message["type"] == "lifespan.startup": + await send({"type": "lifespan.startup.complete"}) + elif message["type"] == "lifespan.shutdown": + try: + raise ValueError("real shutdown error") + except ValueError: + await send( + { + "type": "lifespan.shutdown.failed", + "message": "shutdown failed", + } + ) + raise + await receive() + await send({"type": "http.response.start", "status": 200, "headers": []}) + await send({"type": "http.response.body", "body": b"ok"}) + + +@pytest.mark.asyncio +async def test_lifespan_shutdown_failure_chains_the_apps_exception(): + req = js.Request.new("http://example.com/shutdown-fail-cause") + with pytest.raises(RuntimeError, match="shutdown failed") as excinfo: + await asyncio.wait_for( + asgi.fetch(_ShutdownFailWithCauseApp(), req, env), timeout=5 + ) + assert isinstance(excinfo.value.__cause__, ValueError) + assert "real shutdown error" in str(excinfo.value.__cause__) + + @pytest.mark.asyncio async def test_scope_path_is_percent_decoded(): scope = await _scope("/scope/hello%20world")