Coverage for src/local_deep_research/web/app.py: 96%
46 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +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 _normalise_forwarded_allow_ips(raw: str) -> str:
107 """The allow-list string uvicorn is handed for ``raw``.
109 TWIN of ``web/dependencies/rate_limit.py``'s
110 ``_normalise_forwarded_allow_ips`` -- duplicated rather than
111 imported because importing that module here would run its
112 import-time startup guard and freeze its view of the allow-list
113 before uvicorn has started, and would pull slowapi/limits and the
114 server config into this launcher. The two are pinned together by
115 tests/security/test_middleware_and_proxy_trust_fastapi.py::
116 TestTrustProxyHeadersUvicornWiring::
117 test_app_and_rate_limit_normalise_identically.
118 """
119 return (
120 ",".join(item.strip() for item in raw.split(",") if item.strip()) or "*"
121 )
124def _run_with_uvicorn(host: str, port: int, debug: bool) -> None:
125 """Launch the FastAPI app via uvicorn.
127 Lifespan-managed startup/shutdown lives in `fastapi_app.py`; this
128 function only deals with launching the ASGI server. Logging,
129 log-queue processor lifecycle, scheduler shutdown, and DB
130 teardown are all handled inside the FastAPI lifespan.
131 """
132 import os
134 import uvicorn
136 # When TRUST_PROXY_HEADERS is set (operator is behind nginx/caddy/traefik),
137 # honor X-Forwarded-Proto / X-Forwarded-For so request.url.scheme reflects
138 # TLS termination and HSTS + Secure-cookie logic work correctly. Without
139 # this, the app always sees http:// behind a TLS proxy.
140 trust_proxy = os.environ.get("TRUST_PROXY_HEADERS", "").lower() in (
141 "true",
142 "1",
143 "yes",
144 )
146 # Which direct peers uvicorn's ProxyHeadersMiddleware will trust forwarded
147 # headers from -- passed straight through to uvicorn's own
148 # `--forwarded-allow-ips`. Defaults to "*" (any peer) for backward
149 # compatibility with every deployment that has only ever set
150 # TRUST_PROXY_HEADERS=true.
151 #
152 # This default is exactly what makes TRUST_PROXY_HEADERS=true UNSAFE to
153 # combine with `web/dependencies/rate_limit.py`'s
154 # LDR_SERVER_TRUSTED_PROXY_COUNT hop-count mode: with "*", the middleware
155 # rewrites `scope["client"]` to the client-forgeable left-most
156 # X-Forwarded-For entry for ANY direct connection, not just ones from the
157 # real proxy -- poisoning the hop-count logic's fallback-to-direct-peer
158 # safety net before it ever runs (see rate_limit.py's import-time check).
159 # Restricting this to the actual reverse-proxy IP(s) closes that hole:
160 # uvicorn then only rewrites `scope["client"]` for connections whose
161 # DIRECT peer is one of these addresses, so an attacker connecting
162 # straight to LDR keeps their real socket peer and the rate limiter's
163 # trust boundary stays intact. See "Combining TRUST_PROXY_HEADERS with
164 # LDR_SERVER_TRUSTED_PROXY_COUNT" in docs/deployment/reverse-proxy.md
165 # for the recommended value here.
166 #
167 # Normalised per entry, not as one string: `web/dependencies/
168 # rate_limit.py` classifies the SAME value and names it in the
169 # RuntimeError, the startup warning and the info line it logs, so a
170 # value the two read differently would make every one of those
171 # messages describe an allow-list uvicorn was never handed. `","`
172 # used to arrive here as the literal `","` (one entry matching
173 # nothing, every client in one bucket) while rate_limit.py read it
174 # as unset.
175 forwarded_allow_ips = _normalise_forwarded_allow_ips(
176 os.environ.get("LDR_SERVER_FORWARDED_ALLOW_IPS", "")
177 )
179 uvicorn.run(
180 "local_deep_research.web.fastapi_app:app",
181 host=host,
182 port=port,
183 workers=1, # Required for Socket.IO without Redis message queue
184 log_level="warning", # uvicorn's own log level; app uses loguru
185 # No per-request access log. main justified this with an app-level
186 # request-logging middleware; nothing in the FastAPI app logs a
187 # request line today, so only explicit app events reach loguru.
188 access_log=False,
189 # Don't advertise the server stack. Flask/werkzeug's Server header
190 # was suppressed on main; uvicorn sends "server: uvicorn" by default,
191 # which re-introduces the fingerprinting main deliberately removed.
192 server_header=False,
193 timeout_keep_alive=5,
194 # Bound how long we wait for in-flight requests to drain on
195 # SIGTERM/SIGINT. Without this, uvicorn waits forever for
196 # long-running streams (research SSE) and the process never exits.
197 timeout_graceful_shutdown=10,
198 proxy_headers=trust_proxy,
199 forwarded_allow_ips=forwarded_allow_ips if trust_proxy else None,
200 )
203if __name__ == "__main__": 203 ↛ 204line 203 didn't jump to line 204 because the condition on line 203 was never true
204 main()