Skip to content

feat(server): expose lifespan / on_startup / on_shutdown hooks on serve() #709

@bokelley

Description

@bokelley

Problem

Adopters who need to run background work tied to the server lifecycle — schedulers, connection pools, cache warmers, message-queue consumers, telemetry exporters — have no public hook into serve()'s lifespan composition.

serve.py:1703 builds a private _composed_lifespan that chains the MCP inner app's lifespan with the A2A inner app's lifespan, mounts it on the parent Starlette, and offers no extension point for the caller.

What adopters do today

Salesagent wires a 61-LOC SchedulerLifespanMiddleware (core/middleware/scheduler_lifespan.py) that intercepts ASGI lifespan.startup / lifespan.shutdown scope events, fires user-supplied coroutines, and forwards the events through. Every adopter who needs lifecycle-tied background work writes the same shape of middleware.

Sketch:

class SchedulerLifespanMiddleware:
    def __init__(self, app, startups, shutdowns):
        self.app = app
        self.startups = startups
        self.shutdowns = shutdowns

    async def __call__(self, scope, receive, send):
        if scope["type"] == "lifespan":
            async def wrapped_receive():
                msg = await receive()
                if msg["type"] == "lifespan.startup":
                    for hook in self.startups: await hook()
                elif msg["type"] == "lifespan.shutdown":
                    for hook in self.shutdowns: await hook()
                return msg
            await self.app(scope, wrapped_receive, send)
            return
        await self.app(scope, receive, send)

This works but:

  1. It re-implements what Starlette's lifespan= already provides.
  2. It's a middleware, so it's positionally fragile — adopters get the ordering wrong (e.g. wrapping it outside SigningVerifyMiddleware would put it in the wrong scope).
  3. It can't await background tasks correctly without explicit error propagation, so adopters write subtly different (and subtly broken) variants.

Proposed

Pick one of:

(a) Pass-through to Starlette's lifespan signature

serve(
    handler,
    on_startup=[start_scheduler, warm_cache],
    on_shutdown=[stop_scheduler],
)

serve appends these to the lifespan it already composes. Simplest adopter surface, mirrors what Starlette/FastAPI users already know.

(b) Single composable lifespan kwarg

@asynccontextmanager
async def lifespan(app):
    await start_scheduler()
    try:
        yield
    finally:
        await stop_scheduler()

serve(handler, lifespan=lifespan)

serve chains this inside its existing _composed_lifespan. Lets adopters share state via yield-bound resources.

(a) and (b) are not mutually exclusive — Starlette supports both. The minimal-viable surface is (a).

Why this isn't just "file an ASGI middleware example"

The whole point of serve() is that it owns the lifespan composition (PR #680 made this explicit when callable public_url started breaking it). Forcing adopters to break that abstraction with middleware re-introduces exactly the ordering bugs PR #680 was meant to eliminate.

Acceptance

  • serve(on_startup=..., on_shutdown=...) accepted as kwargs and threaded into _composed_lifespan
  • (Optional follow-on) serve(lifespan=...) accepts an async context manager
  • Existing transport="both" + callable public_url (now working in 5.3.0 per fix(server): preserve Starlette lifespan when public_url is callable #680) continues to work
  • An example in examples/ demonstrating a scheduler tied to lifespan, replacing the middleware pattern
  • Salesagent can delete core/middleware/scheduler_lifespan.py and pass on_startup=[_start_schedulers], on_shutdown=[_stop_schedulers] instead

Real-world demand

Found while auditing the 5.3.0 bump in salesagent for SDK workarounds we could now rip out. This was the one workaround that couldn't be removed because the SDK exposes no extension point.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions