Skip to content

Commit 9ae0c39

Browse files
authored
fix: close the root container after every after_shutdown hook (#83)
1 parent 2679a3f commit 9ae0c39

3 files changed

Lines changed: 64 additions & 8 deletions

File tree

‎README.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ The current `StreamMessage` is resolvable within DI via the pre-built `faststrea
7373

7474
### Sharing a container
7575

76-
By default the app opens the container on startup and closes it after shutdown, which runs every `APP`-scoped finalizer. When the container outlives the app, or other apps share it, pass `manage_lifespan=False` and open and close it yourself:
76+
By default the app opens the container on startup and closes it after shutdown, which runs every `APP`-scoped finalizer. The close runs after every `on_shutdown` and `after_shutdown` hook, including hooks registered after `setup_di`, so they can still resolve `APP`-scoped dependencies. The teardown of a `FastStream(lifespan=...)` context manager runs after the close, because FastStream exits it after all hooks. If that teardown needs the container, or the container outlives the app, or other apps share it, pass `manage_lifespan=False` and open and close it yourself:
7777

7878
```python
7979
container = Container(groups=[Dependencies])
@@ -114,7 +114,7 @@ FastStream's FastAPI routers, such as `faststream.nats.fastapi.NatsRouter`, need
114114

115115
| Symbol | Description |
116116
|---|---|
117-
| `setup_di(app, container, *, manage_lifespan=True)` | Stores the container in the app context and registers startup/shutdown lifecycle hooks. On startup it reopens the container and adds the DI middleware to every broker of the app, including one added via `app.add_broker` after `setup_di` or by an `on_startup` hook registered before `setup_di`. After shutdown it closes the container. With `manage_lifespan=False` it never opens or closes the container, and the middleware still builds and closes a request container per message. Raises `RuntimeError` at startup if the app has no broker by then. Returns the container |
117+
| `setup_di(app, container, *, manage_lifespan=True)` | Stores the container in the app context and registers startup/shutdown lifecycle hooks. On startup it reopens the container and adds the DI middleware to every broker of the app, including one added via `app.add_broker` after `setup_di` or by an `on_startup` hook registered before `setup_di`. After the `after_shutdown` hooks run, it closes the container. With `manage_lifespan=False` it never opens or closes the container, and the middleware still builds and closes a request container per message. Raises `RuntimeError` at startup if the app has no broker by then. Returns the container |
118118
| `FromDI(dependency, *, use_cache=True, cast=False)` | FastStream `Depends` that resolves a provider (or type) from the request container. Raises `RuntimeError` naming `setup_di` when a message reaches it without the middleware installed |
119119
| `fetch_di_container(app)` | Returns the root container from the app context |
120120
| `faststream_message_provider` | `ContextProvider` for the current `faststream.StreamMessage` (`REQUEST` scope) |

‎modern_di_faststream/main.py‎

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -87,13 +87,18 @@ def install_middleware() -> None:
8787
broker.add_middleware(middleware_factory)
8888

8989
app.on_startup(install_middleware)
90-
# FastStream's lifecycle is callback-based, so the root container can't be
91-
# wrapped in ``async with``. Reopen it on startup (before the broker consumes)
92-
# to pair with the shutdown close, so a broker restart works instead of
93-
# raising ContainerClosedError. Reopening an already-open container is a no-op.
9490
if manage_lifespan:
95-
app.on_startup(container.open)
96-
app.after_shutdown(container.close_async)
91+
close_registered = False
92+
93+
# Appended on startup so the close runs after every after_shutdown hook registered before it.
94+
def open_root() -> None:
95+
nonlocal close_registered
96+
container.open()
97+
if not close_registered:
98+
app.after_shutdown(container.close_async)
99+
close_registered = True
100+
101+
app.on_startup(open_root)
97102
return container
98103

99104

‎tests/test_lifespan.py‎

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
11
import typing
2+
from collections.abc import Callable
23

34
import faststream
5+
import pytest
46
from faststream import TestApp
7+
from faststream.asgi import AsgiFastStream
58
from faststream.nats import NatsBroker, TestNatsBroker
69
from modern_di import Container
710

@@ -95,3 +98,51 @@ async def test_manage_lifespan_false_lets_the_user_own_the_root() -> None:
9598

9699
assert container.closed
97100
assert app_instance.finalized == 1
101+
102+
103+
class _CloseCountingContainer(Container):
104+
close_calls = 0
105+
106+
async def close_async(self) -> None:
107+
self.close_calls += 1
108+
await super().close_async()
109+
110+
111+
def _setup_on_faststream(broker: NatsBroker, container: Container) -> faststream.FastStream | AsgiFastStream:
112+
app = faststream.FastStream(broker)
113+
setup_di(app, container)
114+
return app
115+
116+
117+
def _setup_on_asgi(broker: NatsBroker, container: Container) -> faststream.FastStream | AsgiFastStream:
118+
app = AsgiFastStream(broker)
119+
setup_di(app, container)
120+
return app
121+
122+
123+
def _setup_then_as_asgi(broker: NatsBroker, container: Container) -> faststream.FastStream | AsgiFastStream:
124+
app = faststream.FastStream(broker)
125+
setup_di(app, container)
126+
return app.as_asgi()
127+
128+
129+
@pytest.mark.parametrize("build_app", [_setup_on_faststream, _setup_on_asgi, _setup_then_as_asgi])
130+
async def test_after_shutdown_hook_registered_after_setup_di_sees_the_open_root(
131+
build_app: Callable[[NatsBroker, Container], faststream.FastStream | AsgiFastStream],
132+
) -> None:
133+
broker = NatsBroker()
134+
container = _CloseCountingContainer(groups=[Dependencies])
135+
app = build_app(broker, container)
136+
seen: list[tuple[Finalizable, int]] = []
137+
138+
@app.after_shutdown
139+
def user_hook() -> None:
140+
instance = container.resolve_provider(Dependencies.app_finalizable)
141+
seen.append((instance, instance.finalized))
142+
143+
for cycle in (1, 2):
144+
async with TestNatsBroker(broker), TestApp(app):
145+
pass
146+
assert [(instance.finalized, at_hook) for instance, at_hook in seen] == [(1, 0)] * cycle
147+
assert container.closed
148+
assert container.close_calls == cycle

0 commit comments

Comments
 (0)