Follow-up from #13 (first open item there).
Problem
register_fastapi_extensions / register_starlette_extensions(app=...) create their render ThreadPoolExecutor at registration time and shut it down with plain code after the lifespan's yield — no try/finally. Consequences:
- One-shot registration: after the first lifespan cycle completes, the executor is permanently shut down. A second startup of the same app object — e.g. entering
TestClient(app) twice, common in test suites — fails on async-environment renders with RuntimeError: cannot schedule new futures after interpreter shutdown.
- Leak on exception: if the wrapped user lifespan raises during startup or shutdown, the executor is never shut down and its threads leak.
- Quart: same one-shot defect via a single
after_serving hook; additionally, if a later before_serving hook raises, after_serving never runs and the executor is orphaned.
Fix (staged, landing in the next commit)
- Create the executor inside the lifespan wrapper and shut it down in a
finally block (slot on app.state cleared first), so registration is exception-safe and supports unlimited startup/shutdown cycles.
- Quart: paired
before_serving/after_serving hooks, with a self-healing startup that replaces any executor orphaned by a previously failed startup.
- Renders outside a lifespan/serving cycle (e.g.
uvicorn --lifespan off, TestClient without with, render before first startup) use a lazy per-registration fallback executor honoring max_workers — an early draft fell back to the shared module-level 4-worker pool, which the adversarial review flagged as a silent concurrency/isolation regression before it shipped.
- Docstrings updated to state the real semantics.
Compatibility
No public API changes. The only observable semantic change: the undocumented app.state.jinja_partials_executor / app.extensions['jinja_partials_executor'] debugging slot is now None outside lifespan/serving cycles (previously a live executor from registration time). Renders after shutdown now succeed via the fallback instead of raising.
Test coverage (12 lifecycle tests, suite 17 → 29)
- Re-entrancy: fresh executor per cycle, old executors rejected — FastAPI, Starlette, Quart
- Exception paths: startup-raise and shutdown-raise (FastAPI, Starlette); failed-startup recovery with stale-executor replacement (Quart)
- Executor identity pinned via a recording-executor harness: in-cycle renders use the per-cycle pool, out-of-cycle renders use the registration fallback (mutation-tested: bypassing the per-cycle lookup fails the suite)
- User lifespan preserved: both phases run, lifespan state dict passes through the wrapper
max_workers reaches per-cycle and fallback executors in all three frameworks
- Render before first startup; nested partials through the executor without deadlock
Follow-up from #13 (first open item there).
Problem
register_fastapi_extensions/register_starlette_extensions(app=...)create their renderThreadPoolExecutorat registration time and shut it down with plain code after the lifespan'syield— notry/finally. Consequences:TestClient(app)twice, common in test suites — fails on async-environment renders withRuntimeError: cannot schedule new futures after interpreter shutdown.after_servinghook; additionally, if a laterbefore_servinghook raises,after_servingnever runs and the executor is orphaned.Fix (staged, landing in the next commit)
finallyblock (slot onapp.statecleared first), so registration is exception-safe and supports unlimited startup/shutdown cycles.before_serving/after_servinghooks, with a self-healing startup that replaces any executor orphaned by a previously failed startup.uvicorn --lifespan off,TestClientwithoutwith, render before first startup) use a lazy per-registration fallback executor honoringmax_workers— an early draft fell back to the shared module-level 4-worker pool, which the adversarial review flagged as a silent concurrency/isolation regression before it shipped.Compatibility
No public API changes. The only observable semantic change: the undocumented
app.state.jinja_partials_executor/app.extensions['jinja_partials_executor']debugging slot is nowNoneoutside lifespan/serving cycles (previously a live executor from registration time). Renders after shutdown now succeed via the fallback instead of raising.Test coverage (12 lifecycle tests, suite 17 → 29)
max_workersreaches per-cycle and fallback executors in all three frameworks