App Lifecycle

How Chirp's App transitions from mutable setup to frozen runtime

Page actions AI-ready formats and sharing
Open LLM text
Share with AI
Ask Claude Ask ChatGPT Ask Gemini Ask Copilot

A ChirpApplives in two phases. During setup you register everything — routes, middleware, filters, lifecycle hooks. Then the app freezes: it compiles its route table, builds the kida template environment, and becomes immutable so concurrent requests need no locks on the hot path. Freeze happens automatically the first time you callapp.run() (or on the first request). After it, registering a new route raises an error.

Two Phases

  1. 1

    Setup (mutable)

    Register routes, middleware, filters, error handlers, and lifecycle hooks. The app is mutable during this phase.

  2. 2

    Runtime (frozen)

    The app compiles its route table, creates the kida environment, and becomes immutable. All shared state is read-only.

from chirp import App

app = App()

# --- Setup phase (mutable) ---
@app.route("/")
def index():
    return "Hello"

@app.route("/about")
def about():
    return "About"

app.add_middleware(my_middleware)

# --- Freeze happens here ---
app.run()  # Compiles routes, freezes config, starts serving

Freeze triggers when you call app.run(), or on the first ASGI request if you serve the app through an external server. After freeze, registering a new route raises an error.

If your app uses filesystem pages, register sections withapp.register_section() beforeapp.mount_pages(). At freeze, the framework validates the route directory contract, and in debug mode it runsapp.check().

Why Freeze?

Free-threading (Python 3.14t) lets multiple threads handle requests at once. If the route table, middleware stack, or template environment could change mid-request, you would need locks everywhere.

Instead, Chirp freezes the app once. All shared state becomes immutable, so the request hot path needs no locks.

Setup Phase          Freeze          Runtime Phase
─────────────────────┬──────────────────────────────
@app.route()         │               Request handling
app.add_middleware() │  Compile       (immutable data)
@app.template_filter │  routes,       (no locks on
app.on_startup()     │  create env    shared state)
─────────────────────┴──────────────────────────────

Freeze is guarded by double-check locking: the first request orapp.run() triggers it, concurrent requests block briefly on the lock, then proceed against the frozen state.

Lifecycle Hooks

Register callbacks for startup and shutdown. Use them to open and close a resource you own — an HTTP client, a cache, a background queue — stashed somewhere your handlers can reach it:

import httpx

client: httpx.AsyncClient | None = None

@app.on_startup
async def open_client():
    global client
    client = httpx.AsyncClient(base_url="https://api.example.com")

@app.on_shutdown
async def close_client():
    if client is not None:
        await client.aclose()

For per-worker initialization — resources that must live on a worker's event loop, such as async HTTP clients, async database pools, or per-worker caches — use worker hooks:

@app.on_worker_startup
async def init_worker():
    # Runs once per worker thread
    pass

@app.on_worker_shutdown
async def cleanup_worker():
    # Runs once per worker thread on shutdown
    pass

Debug Checks at Freeze

In debug mode, freeze runs the same hypermedia contract checks asapp.check() and exits on ERROR. Opt out withAppConfig(skip_contract_checks=True)or the CHIRP_SKIP_CONTRACT_CHECKSenvironment variable.

Next Steps