alexbotello: Not sure how to resolve the tests for this one. Or if we would even want to, since the current tests will be perfectly fine once we drop support of 'cleanup'
tomchristie: Using `# pragma: no cover` to exclude specific lines in contexts like this is perfectly okay. Let’s do that.
tomchristie: Looks great! Will merge this once in at a keyboard and able to push a new release. Thanks!!
diff --git a/docs/applications.md b/docs/applications.md
index 2cce4ab..d4b44ca 100644
--- a/docs/applications.md
+++ b/docs/applications.md
@@ -56,7 +56,7 @@ There are two ways to add event handlers:
 * `@app.on_event(event_type)` - Add an event, decorator style
 * `app.add_event_handler(event_type, func)` - Add an event through a function call.
 
-`event_type` must be specified as either `'startup'` or `'cleanup'`.
+`event_type` must be specified as either `'startup'` or `'shutdown'`.
 
 ### Submounting other applications
 
diff --git a/docs/events.md b/docs/events.md
index b6696bb..3378a83 100644
--- a/docs/events.md
+++ b/docs/events.md
@@ -20,7 +20,7 @@ app = Starlette()
 async def open_database_connection_pool():
     ...
 
-@app.on_event('cleanup')
+@app.on_event('shutdown')
 async def close_database_connection_pool():
     ...
 ```
@@ -39,14 +39,14 @@ async def close_database_connection_pool():
     ...
 
 app.add_event_handler('startup', open_database_connection_pool)
-app.add_event_handler('cleanup', close_database_connection_pool)
+app.add_event_handler('shutdown', close_database_connection_pool)
 
 ```
 
 Starlette will not start serving any incoming requests until all of the
 registered startup handlers have completed.
 
-The cleanup handlers will run once all connections have been closed, and
+The shutdown handlers will run once all connections have been closed, and
 any in-process background tasks have completed.
 
 **Note**: The ASGI lifespan protocol has only recently been added to the spec,
@@ -74,5 +74,5 @@ def test_homepage():
         response = client.get("/")
         assert response.status_code == 200
 
-    # Application 'cleanup' handlers are called on exiting the block.
+    # Application 'shutdown' handlers are called on exiting the block.
 ```
diff --git a/starlette/lifespan.py b/starlette/lifespan.py
index a862298..b25ec8c 100644
--- a/starlette/lifespan.py
+++ b/starlette/lifespan.py
@@ -22,7 +22,7 @@ class LifespanHandler:
         return decorator
 
     def add_event_handler(self, event_type: str, func: typing.Callable) -> None:
-        assert event_type in ("startup", "cleanup")
+        assert event_type in ("startup", "shutdown", "cleanup")
 
         if event_type == "startup":
             self.startup_handlers.append(func)
@@ -53,19 +53,26 @@ class LifespanHandler:
         await self.run_startup()
         await send({"type": "lifespan.startup.complete"})
         message = await receive()
-        assert message["type"] == "lifespan.cleanup"
+        assert (
+            message["type"] == "lifespan.shutdown"
+            or message["type"] == "lifespan.cleanup"
+        )
         await self.run_cleanup()
-        await send({"type": "lifespan.cleanup.complete"})
+        if message["type"] == "lifespan.shutdown":
+            await send({"type": "lifespan.shutdown.complete"})
+
+        if message["type"] == "lifespan.cleanup":
+            await send({"type": "lifespan.cleanup.complete"})  # pragma: no cover
 
 
 class LifespanContext:
     def __init__(
-        self, app: ASGIApp, startup_timeout: int = 10, cleanup_timeout: int = 10
+        self, app: ASGIApp, startup_timeout: int = 10, shutdown_timeout: int = 10
     ) -> None:
         self.startup_timeout = startup_timeout
-        self.cleanup_timeout = cleanup_timeout
+        self.shutdown_timeout = shutdown_timeout
         self.startup_event = asyncio.Event()
-        self.cleanup_event = asyncio.Event()
+        self.shutdown_event = asyncio.Event()
         self.receive_queue = asyncio.Queue()  # type: asyncio.Queue
         self.asgi = app({"type": "lifespan"})  # type: ASGIInstance
 
@@ -81,25 +88,25 @@ class LifespanContext:
         tb: TracebackType,
     ) -> None:
         loop = asyncio.get_event_loop()
-        loop.run_until_complete(self.wait_cleanup())
+        loop.run_until_complete(self.wait_shutdown())
 
     async def run_lifespan(self) -> None:
         try:
             await self.asgi(self.receive, self.send)
         finally:
             self.startup_event.set()
-            self.cleanup_event.set()
+            self.shutdown_event.set()
 
     async def send(self, message: Message) -> None:
         if message["type"] == "lifespan.startup.complete":
             assert not self.startup_event.is_set(), STATE_TRANSITION_ERROR
-            assert not self.cleanup_event.is_set(), STATE_TRANSITION_ERROR
+            assert not self.shutdown_event.is_set(), STATE_TRANSITION_ERROR
             self.startup_event.set()
         else:
-            assert message["type"] == "lifespan.cleanup.complete"
+            assert message["type"] == "lifespan.shutdown.complete"
             assert self.startup_event.is_set(), STATE_TRANSITION_ERROR
-            assert not self.cleanup_event.is_set(), STATE_TRANSITION_ERROR
-            self.cleanup_event.set()
+            assert not self.shutdown_event.is_set(), STATE_TRANSITION_ERROR
+            self.shutdown_event.set()
 
     async def receive(self) -> Message:
         return await self.receive_queue.get()
@@ -108,6 +115,8 @@ class LifespanContext:
         await self.receive_queue.put({"type": "lifespan.startup"})
         await asyncio.wait_for(self.startup_event.wait(), timeout=self.startup_timeout)
 
-    async def wait_cleanup(self) -> None:
-        await self.receive_queue.put({"type": "lifespan.cleanup"})
-        await asyncio.wait_for(self.cleanup_event.wait(), timeout=self.cleanup_timeout)
+    async def wait_shutdown(self) -> None:
+        await self.receive_queue.put({"type": "lifespan.shutdown"})
+        await asyncio.wait_for(
+            self.shutdown_event.wait(), timeout=self.shutdown_timeout
+        )

Support `shutdown` as a synonym for `cleanup`
* Support either `cleanup` or `shutdown` as the ASGI lifespan message name.
* Update uvicorn to move to shutdown - https://github.com/encode/uvicorn/issues/233
* Finally, after a small period of time, drop `cleanup`

Easy PR for a contributor to jump on would be addressing the first part of this, and supporting either name.
diff --git a/tests/test_applications.py b/tests/test_applications.py
index 8dc4c40..64a48c0 100644
--- a/tests/test_applications.py
+++ b/tests/test_applications.py
@@ -181,7 +181,7 @@ def test_app_add_event_handler():
         cleanup_complete = True
 
     app.add_event_handler("startup", run_startup)
-    app.add_event_handler("cleanup", run_cleanup)
+    app.add_event_handler("shutdown", run_cleanup)
 
     assert not startup_complete
     assert not cleanup_complete
diff --git a/tests/test_lifespan.py b/tests/test_lifespan.py
index db4cb73..7b7372d 100644
--- a/tests/test_lifespan.py
+++ b/tests/test_lifespan.py
@@ -12,7 +12,7 @@ def test_lifespan_handler():
         nonlocal startup_complete
         startup_complete = True
 
-    @handler.on_event("cleanup")
+    @handler.on_event("shutdown")
     def run_cleanup():
         nonlocal cleanup_complete
         cleanup_complete = True
@@ -36,7 +36,7 @@ def test_async_lifespan_handler():
         nonlocal startup_complete
         startup_complete = True
 
-    @handler.on_event("cleanup")
+    @handler.on_event("shutdown")
     async def run_cleanup():
         nonlocal cleanup_complete
         cleanup_complete = True
@@ -60,7 +60,7 @@ def test_app_lifespan():
         nonlocal startup_complete
         startup_complete = True
 
-    @app.on_event("cleanup")
+    @app.on_event("shutdown")
     def run_cleanup():
         nonlocal cleanup_complete
         cleanup_complete = True

pip install -e .[full]
parse_log_pytest
requirements.txt
3.9
requirements.txt
pytest --no-header -rA --tb=line --color=no -p no:cacheprovider -W ignore::DeprecationWarning
aiofiles==24.1.0
babel==2.17.0
backrefs==5.8
black==25.1.0
certifi==2025.1.31
charset-normalizer==3.4.1
click==8.1.8
codecov==2.1.13
colorama==0.4.6
coverage==7.8.0
exceptiongroup==1.2.2
ghp-import==2.1.0
graphene==3.4.3
graphql-core==3.2.6
graphql-relay==3.2.0
idna==3.10
importlib_metadata==8.6.1
iniconfig==2.1.0
Jinja2==3.1.6
Markdown==3.7
MarkupSafe==3.0.2
mergedeep==1.3.4
mkdocs==1.6.1
mkdocs-get-deps==0.2.0
mkdocs-material==9.6.10
mkdocs-material-extensions==1.3.1
mypy==1.15.0
mypy-extensions==1.0.0
packaging==24.2
paginate==0.5.7
pathspec==0.12.1
platformdirs==4.3.7
pluggy==1.5.0
Pygments==2.19.1
pymdown-extensions==10.14.3
pytest==8.3.5
pytest-cov==6.0.0
python-dateutil==2.9.0.post0
python-multipart==0.0.20
PyYAML==6.0.2
pyyaml_env_tag==0.1
requests==2.32.3
six==1.17.0
-e git+https://github.com/encode/starlette.git@49f76ab5e9ef0ce5d966485553ad6019a9d37da5#egg=starlette
tomli==2.2.1
typing_extensions==4.13.0
ujson==5.10.0
urllib3==2.3.0
watchdog==6.0.0
zipp==3.21.0

name: starlette
channels:
  - defaults
  - https://repo.anaconda.com/pkgs/main
  - https://repo.anaconda.com/pkgs/r
  - conda-forge
dependencies:
  - _libgcc_mutex=0.1=main
  - _openmp_mutex=5.1=1_gnu
  - ca-certificates=2025.2.25=h06a4308_0
  - ld_impl_linux-64=2.40=h12ee557_0
  - libffi=3.4.4=h6a678d5_1
  - libgcc-ng=11.2.0=h1234567_1
  - libgomp=11.2.0=h1234567_1
  - libstdcxx-ng=11.2.0=h1234567_1
  - ncurses=6.4=h6a678d5_0
  - openssl=3.0.16=h5eee18b_0
  - pip=25.0=py39h06a4308_0
  - python=3.9.21=he870216_1
  - readline=8.2=h5eee18b_0
  - setuptools=75.8.0=py39h06a4308_0
  - sqlite=3.45.3=h5eee18b_0
  - tk=8.6.14=h39e8969_0
  - tzdata=2025a=h04d1e81_0
  - wheel=0.45.1=py39h06a4308_0
  - xz=5.6.4=h5eee18b_1
  - zlib=1.2.13=h5eee18b_1
  - pip:
      - aiofiles==24.1.0
      - babel==2.17.0
      - backrefs==5.8
      - black==25.1.0
      - certifi==2025.1.31
      - charset-normalizer==3.4.1
      - click==8.1.8
      - codecov==2.1.13
      - colorama==0.4.6
      - coverage==7.8.0
      - exceptiongroup==1.2.2
      - ghp-import==2.1.0
      - graphene==3.4.3
      - graphql-core==3.2.6
      - graphql-relay==3.2.0
      - idna==3.10
      - importlib-metadata==8.6.1
      - iniconfig==2.1.0
      - jinja2==3.1.6
      - markdown==3.7
      - markupsafe==3.0.2
      - mergedeep==1.3.4
      - mkdocs==1.6.1
      - mkdocs-get-deps==0.2.0
      - mkdocs-material==9.6.10
      - mkdocs-material-extensions==1.3.1
      - mypy==1.15.0
      - mypy-extensions==1.0.0
      - packaging==24.2
      - paginate==0.5.7
      - pathspec==0.12.1
      - platformdirs==4.3.7
      - pluggy==1.5.0
      - pygments==2.19.1
      - pymdown-extensions==10.14.3
      - pytest==8.3.5
      - pytest-cov==6.0.0
      - python-dateutil==2.9.0.post0
      - python-multipart==0.0.20
      - pyyaml==6.0.2
      - pyyaml-env-tag==0.1
      - requests==2.32.3
      - six==1.17.0
      - tomli==2.2.1
      - typing-extensions==4.13.0
      - ujson==5.10.0
      - urllib3==2.3.0
      - watchdog==6.0.0
      - zipp==3.21.0
prefix: /opt/conda/envs/starlette

tests/test_applications.py::test_app_add_event_handler tests/test_lifespan.py::test_lifespan_handler tests/test_lifespan.py::test_async_lifespan_handler tests/test_lifespan.py::test_app_lifespan
tests/test_applications.py::test_func_route tests/test_applications.py::test_async_route tests/test_applications.py::test_class_route tests/test_applications.py::test_route_kwargs tests/test_applications.py::test_websocket_route tests/test_applications.py::test_400 tests/test_applications.py::test_405 tests/test_applications.py::test_500 tests/test_applications.py::test_middleware tests/test_applications.py::test_app_mount tests/test_applications.py::test_app_debug
docs/applications.md docs/events.md starlette/lifespan.py
docs/applications.md docs/events.md starlette/lifespan.py