Coverage for src/local_deep_research/web/app.py: 96%
43 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
1import threading
2import traceback
4from loguru import logger
6from ..__version__ import __version__
7from ..utilities.log_utils import config_logger
8from .server_config import load_server_config
11def _install_thread_excepthook() -> None:
12 """Install a global hook that loudly logs uncaught exceptions on any
13 thread — including daemon threads — so silent crashes in the queue
14 processor, APScheduler jobs, or the post-login background thread
15 surface in logs instead of leaving the app wedged with no signal.
17 Respects a previously-installed hook if any (chains to it).
18 """
19 previous = threading.excepthook
21 def _hook(args: threading.ExceptHookArgs) -> None:
22 # Don't try to log for SystemExit-in-thread; that is intentional.
23 if issubclass(args.exc_type, SystemExit):
24 return
25 try:
26 tb = "".join(
27 traceback.format_exception(
28 args.exc_type, args.exc_value, args.exc_traceback
29 )
30 )
31 thread_name = (
32 args.thread.name if args.thread is not None else "unknown"
33 )
34 logger.error(
35 f"Uncaught exception on thread {thread_name!r}: "
36 f"{args.exc_type.__name__}: {args.exc_value}\n{tb}"
37 )
38 except Exception:
39 pass # noqa: silent-exception — last-ditch; the excepthook itself must never crash the interpreter
40 finally:
41 # Chain to the previous hook (usually threading's default).
42 try:
43 previous(args)
44 except Exception:
45 pass # noqa: silent-exception — previous hook failing must not turn our hook into a crash vector
47 threading.excepthook = _hook
50# reraise=True is load-bearing: without it a fatal startup error is logged and
51# then SWALLOWED, main() returns None, and the console script exits 0 — so
52# systemd `Restart=on-failure` and Kubernetes `restartPolicy: OnFailure` read a
53# dead server as a clean shutdown and never restart it. uvicorn loads the app
54# eagerly on the workers==1 path, so app-import failures land inside main()
55# rather than before it. This PR widened the exposure: _load_secret_key() now
56# hard-raises where the Flask app fell back to an ephemeral key. SystemExit
57# already propagated (a port conflict correctly exits 3); this makes real
58# exceptions behave the same way while keeping loguru's formatted traceback.
59@logger.catch(reraise=True)
60def main():
61 """
62 Entry point for the web application (ldr-web command).
64 Launches uvicorn with the FastAPI app.
65 """
66 # Install the excepthook before any other threads are spawned so
67 # uncaught exceptions in daemon threads (queue processor, APScheduler
68 # jobs, post-login background thread) surface in logs instead of
69 # dying silently.
70 _install_thread_excepthook()
72 config = load_server_config()
73 config_logger("ldr_web", debug=config["debug"])
74 logger.info(f"Starting Local Deep Research v{__version__}")
76 # One-time removal of plaintext legacy RAG docstores (phase 1 of the
77 # vector-store cutover -- see vector_stores/legacy_cleanup.py). Pure
78 # filesystem, no DB/login required, so it belongs here at the real
79 # server-start entrypoint (not in the app factory / lifespan, which the
80 # test suite exercises without LDR_DATA_DIR isolation -- wiring it there
81 # would delete a developer's real .pkl files on every test run). Wrapped
82 # so a cleanup issue never blocks boot; the function logs its own errors
83 # and is idempotent. Ports #5143.
84 try:
85 from ..vector_stores.legacy_cleanup import migrate_legacy_docstores
87 migrate_legacy_docstores()
88 except Exception:
89 logger.exception("Legacy RAG docstore migration failed at startup")
91 # Ported from main: `web.use_https` never actually served TLS on either
92 # framework -- main only logged that it is unsupported and told the
93 # operator to front the app with a reverse proxy. Without this, someone
94 # who sets LDR_WEB_USE_HTTPS=true gets total silence and reasonably
95 # assumes TLS is on. Say so plainly instead.
96 if config.get("use_https"):
97 logger.warning(
98 "web.use_https is set, but HTTPS is not served directly. "
99 "Terminate TLS at a reverse proxy (nginx, Caddy, Traefik) in "
100 "front of this server; it is listening on plain HTTP."
101 )
103 _run_with_uvicorn(config["host"], config["port"], config["debug"])
106def _run_with_uvicorn(host: str, port: int, debug: bool) -> None:
107 """Launch the FastAPI app via uvicorn.
109 Lifespan-managed startup/shutdown lives in `fastapi_app.py`; this
110 function only deals with launching the ASGI server. Logging,
111 log-queue processor lifecycle, scheduler shutdown, and DB
112 teardown are all handled inside the FastAPI lifespan.
113 """
114 import os
116 import uvicorn
118 # When TRUST_PROXY_HEADERS is set (operator is behind nginx/caddy/traefik),
119 # honor X-Forwarded-Proto / X-Forwarded-For so request.url.scheme reflects
120 # TLS termination and HSTS + Secure-cookie logic work correctly. Without
121 # this, the app always sees http:// behind a TLS proxy.
122 trust_proxy = os.environ.get("TRUST_PROXY_HEADERS", "").lower() in (
123 "true",
124 "1",
125 "yes",
126 )
128 uvicorn.run(
129 "local_deep_research.web.fastapi_app:app",
130 host=host,
131 port=port,
132 workers=1, # Required for Socket.IO without Redis message queue
133 log_level="warning", # uvicorn's own log level; app uses loguru
134 # No per-request access log. main justified this with an app-level
135 # request-logging middleware; nothing in the FastAPI app logs a
136 # request line today, so only explicit app events reach loguru.
137 access_log=False,
138 # Don't advertise the server stack. Flask/werkzeug's Server header
139 # was suppressed on main; uvicorn sends "server: uvicorn" by default,
140 # which re-introduces the fingerprinting main deliberately removed.
141 server_header=False,
142 timeout_keep_alive=5,
143 # Bound how long we wait for in-flight requests to drain on
144 # SIGTERM/SIGINT. Without this, uvicorn waits forever for
145 # long-running streams (research SSE) and the process never exits.
146 timeout_graceful_shutdown=10,
147 proxy_headers=trust_proxy,
148 forwarded_allow_ips="*" if trust_proxy else None,
149 )
152if __name__ == "__main__": 152 ↛ 153line 152 didn't jump to line 153 because the condition on line 152 was never true
153 main()