Files
VoiceStudio/scripts/dump_api_routes.py
T
Palash DebnathandClaude Fable 5 bb813ff676 feat(startup): bind the socket in ~1s and narrate startup step by step (#1550)
* feat(startup): bind the socket in ~1s and narrate startup step by step

The structural fix for the "can't reach the local backend" class (~1 in 5
of every issue ever filed): uvicorn served nothing until torch import
(10-20s cold), the 30-router fan-out, an import-time DB migration, the
cuDNN preload, and alembic all finished — every slow or fragile step
rendered as an unexplained dead backend.

main.py now keeps module scope fast and defers the heavy work:
- _phase_a_build (executor thread): prefs/env restore + #963 migration,
  yt-dlp overlay, cuDNN preload, torchaudio, model_manager, router
  imports — order preserved, literal imports so PyInstaller still traces.
- _phase_a_finalize (event loop, no awaits → atomic wrt requests):
  include_router, mounts, MCP, SPA, openapi bust.
- _phase_b: the old lifespan startup body; handles on app.state so
  shutdown survives a startup that never finished.
- Eager mode (pytest / OMNIVOICE_EAGER_INIT=1) runs everything at import
  — byte-equivalent behavior for the ~100 lifespan-less TestClient sites
  and for embedders (dump_api_routes, probe boot runner opt in).

While starting: /health answers 503 with the current step, new
/startup/progress serves the full ledger (always 200), and
StartupGateMiddleware 503s everything else with the [starting] marker
(same skip-the-Report-button convention as [shutting_down]). A deferred
failure keeps import-crash semantics: traceback to stderr → shell crash
forensics, run sentinel stays uncleared, exit 1 names the failed step.

Shell: startup_progress() probe (marker-header-gated so a foreign
responder can't narrate the splash) feeds per-step log lines into the
launch poll and the supervisor's reconnect wait. --health-check absorbs
the deferred init (60→180s); --diagnose runs Phase A up front so it
still sees restored prefs. Docker HEALTHCHECK semantics unchanged
(curl -f fails on 503 exactly as it did on connection-refused).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(startup): join the Phase A thread on shutdown; async fail-path sleep

Bot-review harvest on #1550: cancelling the deferred-startup task cannot
stop the executor thread inside Phase A's blocking imports — shutdown now
waits (bounded, only when a build started and hasn't finished) on a
thread-completion event so interpreter teardown can't race a mid-import
(#1000 class). The failure path's last-poll beat is now awaited, not
time.sleep — a blocking sleep froze the very loop that beat exists to let
serve. Also: dump_api_routes forces eager (assignment, not setdefault),
and the integration test's child gets DEVNULL instead of an undrained
pipe that could wedge a cold boot.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(startup): close the Phase A submission race; CodeQL nits

Review finds on #1550: shutdown could sample _phase_a_started unset
while the executor callable was queued-but-not-running, skipping the
thread join. started is now set BEFORE submission, the submission is
shielded so a cancel can't strand a queued callable that would never set
_phase_a_finished, and the wrapper sets finished on every exit including
the already-built early return. Contract pinned by
test_phase_a_thread_join_contract. Plus explanatory comments on the new
bare excepts and a consistent return in the gate's websocket branch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 15:23:02 +00:00

81 lines
3.0 KiB
Python

#!/usr/bin/env python3
"""Regenerate the backend API route snapshot.
The snapshot (`tests/fixtures/api_routes.txt`) is the committed inventory of
every HTTP/WebSocket route the FastAPI app exposes. `tests/test_api_route_
inventory.py` diffs the live app against it, so an accidentally removed or
renamed endpoint fails CI. When you intentionally add/remove/rename a route,
run this script and commit the updated snapshot.
OMNIVOICE_MODEL=test uv run python scripts/dump_api_routes.py
"""
import os
import sys
from pathlib import Path
_REPO = Path(__file__).resolve().parents[1]
SNAPSHOT = _REPO / "tests" / "fixtures" / "api_routes.txt"
_HEADER = (
"# VoiceStudio backend API route snapshot — regenerate with "
"scripts/dump_api_routes.py\n"
"# Guards against accidental endpoint removal/rename "
"(tests/test_api_route_inventory.py).\n"
)
def route_lines(app):
"""Stable, sorted ``"METHODS /path"`` lines for every route on ``app``.
HEAD/OPTIONS are dropped (auto-added by Starlette); WebSocket routes use
``WS``. Excludes things that vary by environment and aren't part of the API
contract: ``Mount`` routes (StaticFiles / sub-app mounts like ``/demo_audio``,
``/outputs``, ``/mcp`` — they only register when their dir/sub-app exists, so
they differ between a dev checkout and a fresh CI runner) and the bare
``GET /`` root (a conditional frontend-serving fallback). HEAD/OPTIONS are
dropped (Starlette adds them automatically).
"""
from starlette.routing import Mount
rows = set()
for r in app.routes:
path = getattr(r, "path", None)
if not path or path == "/" or isinstance(r, Mount):
continue
methods = getattr(r, "methods", None)
if methods:
ms = ",".join(sorted(m for m in methods if m not in ("HEAD", "OPTIONS")))
elif "WebSocket" in type(r).__name__:
ms = "WS"
else:
continue # non-HTTP, non-WS, non-mount: not part of the API surface
rows.add(f"{ms} {path}")
return sorted(rows)
def load_app():
os.environ.setdefault("OMNIVOICE_MODEL", "test")
os.environ.setdefault("OMNIVOICE_DISABLE_FILE_LOG", "1")
# Early-bind refactor: a bare (non-pytest) import defers routers behind
# the startup gate — this dump needs the fully-built app at import.
os.environ["OMNIVOICE_EAGER_INIT"] = "1"
sys.path.insert(0, str(_REPO / "backend"))
from main import app
return app
def main():
lines = route_lines(load_app())
# `--print` dumps to stdout (the inventory test captures this in a subprocess
# so importing the app never pollutes the pytest process); default writes the
# committed snapshot.
if "--print" in sys.argv:
sys.stdout.write("\n".join(lines) + "\n")
else:
SNAPSHOT.write_text(_HEADER + "\n".join(lines) + "\n", encoding="utf-8")
sys.stderr.write(f"Wrote {len(lines)} routes to {SNAPSHOT.relative_to(_REPO)}\n")
if __name__ == "__main__":
main()