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
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.
Advanced: how freeze stays thread-safe
The freeze operation uses double-check locking so it is safe under free-threading. This is internal mechanism — you never call it directly.
# Simplified — actual implementation is App._ensure_frozen / App._freeze
if not self._frozen:
with self._freeze_lock:
if not self._frozen:
self._compile_routes()
self._create_kida_env()
self._frozen = True
The first request (or app.run()) triggers the freeze. Concurrent requests block
briefly on the lock, then proceed with the frozen state. After that, no
synchronization is needed on the hot path. The deep "why no locks" model lives in
Thread Safety.
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
Advanced: worker hooks under sync vs async workers
Worker hooks are a production worker contract, not a general app-startup
replacement. Use@app.on_startupfor everything that should run once when the
app boots.
On free-threaded Python, theworker_mode="auto"default resolves to sync
workers. Sync workers do not emitpounce.worker.startupor
pounce.worker.shutdownscopes, so Chirp rejects production launch when worker
hooks are registered and the effective worker mode is sync:
app = App(AppConfig(debug=False, worker_mode="async"))
If a worker startup hook can fail in a way that must abort boot, put the
must-succeed check in@app.on_startup(which runs before workers spin up) or
expose a health check, rather than relying on worker-startup failure to stop the
server.
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
- Return Values — what route handlers can return
- Configuration — all
AppConfigfields - Routes — route registration in detail
- Thread Safety — why frozen state needs no locks