Skip to content

fix: close the root containers of mounted apps at shutdown - #70

Merged
lesnik512 merged 2 commits into
mainfrom
fix/mounted-app-lifespan
Oct 17, 2026
Merged

lesnik512 merged 2 commits into
mainfrom
fix/mounted-app-lifespan

Conversation

@lesnik512

Copy link
Copy Markdown
Member

Closes #69

Starlette runs only the outermost app's lifespan. A mounted app that called setup_di never had its root container entered or closed, so its APP-scoped finalizers never ran.

Change

The lifespan that setup_di composes now walks the app's routes at startup. For every Mount and Host route whose app is a FastAPI app with a root container (recognised through fetch_di_container), it enters that container. It recurses through each route's routes, so apps mounted more than one level deep are found, including under Mount(routes=[...]) and under mounted apps that did not call setup_di.

All containers go on one AsyncExitStack inside the user lifespan, the app's own container first. They close in reverse order at shutdown, after the user's lifespan code. A container is entered once, compared by identity, so an app mounted twice or several apps sharing one container close it once. Because the walk runs at startup, mounts added after setup_di are included. The mounted app's own user lifespan still does not run.

This is the same shape as modern-di-starlette#55: one _mounted_containers generator and the same test file layout.

Notes

  • Only FastAPI apps are checked, because setup_di and fetch_di_container take a FastAPI app. A plain Starlette app mounted inside is skipped, but its routes are still walked.
  • Mount(app, middleware=[...]) wraps the app, so route.app is the middleware and not the FastAPI app. Such a mounted app's container is not found. Reaching it would mean reading Starlette's private Mount._base_app. Its nested routes are still walked through the public Mount.routes.
  • Calling setup_di twice with the same container raises DuplicateProviderTypeError, because the connection providers are registered again. The shared-container test therefore shares the container by setting app.state.di_container directly. That test passes before the fix too; it guards the deduplication.

Tests

tests/test_mount_lifespan.py covers the issue repro, a container two levels deep, a Host route, an app mounted twice, a shared container, a mount added after setup_di, ignored mounts (FastAPI app without setup_di, plain Starlette app, raw ASGI app, Mount(routes=...)), and two lifespan cycles. Six of the eight fail on main. The issue repro now records inner finalized at shutdown.

@lesnik512
lesnik512 merged commit b586289 into main Oct 17, 2026
12 checks passed
@lesnik512
lesnik512 deleted the fix/mounted-app-lifespan branch October 17, 2026 10:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

A mounted app's container is never closed, so its APP-scoped finalizers never run

1 participant