Skip to content

Render executor lifecycle is not exception-safe or re-entrant (FastAPI/Starlette/Quart) #14

Description

@mikeckennedy

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions