Coverage for src/local_deep_research/web/app.py: 86%
98 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-19 23:35 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-19 23:35 +0000
1import atexit
2import threading
3import traceback
4from loguru import logger
6from ..__version__ import __version__
7from ..utilities.log_utils import (
8 config_logger,
9 flush_log_queue,
10 start_log_queue_processor,
11 stop_log_queue_processor,
12)
13from .app_factory import create_app
14from .server_config import load_server_config
17def _install_thread_excepthook() -> None:
18 """Install a global hook that loudly logs uncaught exceptions on any
19 thread — including daemon threads — so silent crashes in the queue
20 processor, APScheduler jobs, or the post-login background thread
21 surface in logs instead of leaving the app wedged with no signal.
23 Respects a previously-installed hook if any (chains to it).
24 """
25 previous = threading.excepthook
27 def _hook(args: threading.ExceptHookArgs) -> None:
28 # Don't try to log for SystemExit-in-thread; that is intentional.
29 if issubclass(args.exc_type, SystemExit): 29 ↛ 30line 29 didn't jump to line 30 because the condition on line 29 was never true
30 return
31 try:
32 tb = "".join(
33 traceback.format_exception(
34 args.exc_type, args.exc_value, args.exc_traceback
35 )
36 )
37 thread_name = (
38 args.thread.name if args.thread is not None else "unknown"
39 )
40 logger.error(
41 f"Uncaught exception on thread {thread_name!r}: "
42 f"{args.exc_type.__name__}: {args.exc_value}\n{tb}"
43 )
44 except Exception:
45 pass # noqa: silent-exception — last-ditch; the excepthook itself must never crash the interpreter
46 finally:
47 # Chain to the previous hook (usually threading's default).
48 try:
49 previous(args)
50 except Exception:
51 pass # noqa: silent-exception — previous hook failing must not turn our hook into a crash vector
53 threading.excepthook = _hook
56@logger.catch
57def main():
58 """
59 Entry point for the web application when run as a command.
60 This function is needed for the package's entry point to work properly.
61 """
62 # Install the excepthook before any other threads are spawned so
63 # uncaught exceptions in daemon threads (queue processor, APScheduler
64 # jobs, post-login background thread) surface in logs instead of
65 # dying silently.
66 _install_thread_excepthook()
68 # Configure logging with milestone level
69 config = load_server_config()
70 config_logger("ldr_web", debug=config["debug"])
71 logger.info(f"Starting Local Deep Research v{__version__}")
73 # One-time removal of plaintext legacy RAG docstores (phase 1 of the
74 # vector-store cutover -- see vector_stores/legacy_cleanup.py). Pure
75 # filesystem, no DB/login required, so it belongs here at the real
76 # server-start entrypoint rather than in create_app() -- create_app()
77 # is exercised by ~32 tests (many without LDR_DATA_DIR isolation) and
78 # wiring it there would delete a developer's real .pkl files on every
79 # test run. Wrapped so a cleanup issue never blocks boot; the function
80 # logs its own errors and is idempotent.
81 try:
82 from ..vector_stores.legacy_cleanup import migrate_legacy_docstores
84 migrate_legacy_docstores()
85 except Exception:
86 logger.exception("Legacy RAG docstore migration failed at startup")
88 # Create the Flask app and SocketIO instance
89 app, socket_service = create_app()
91 # Surface a cipher misconfiguration that otherwise only shows up as
92 # affected users getting "Invalid username or password": a relaxed
93 # SQLCipher KDF (test mode) on a deployment that already holds real user
94 # databases. No-op on fresh installs and when the effective KDF is at the
95 # production floor. Wrapped so a check failure can never block server boot.
96 try:
97 from ..database.encrypted_db import db_manager
98 from ..database.sqlcipher_utils import (
99 warn_if_weak_kdf_with_existing_databases,
100 )
102 if db_manager.has_encryption: 102 ↛ 110line 102 didn't jump to line 110 because the condition on line 102 was always true
103 warn_if_weak_kdf_with_existing_databases(db_manager.data_dir)
104 except Exception:
105 logger.exception("Weak-KDF startup configuration check failed")
107 # Start the background log-queue processor. With no ``before_request``
108 # handler pulling from the queue, this daemon is the only drain path
109 # during normal operation; a final drain runs at atexit.
110 daemon_started = False
111 try:
112 start_log_queue_processor(app)
113 daemon_started = True
114 except Exception:
115 logger.exception("Failed to start log queue processor")
117 # Get web server settings from environment variables (LDR_WEB_HOST, etc.)
118 # These require a server restart to take effect
119 host = config["host"]
120 port = config["port"]
121 debug = config["debug"]
122 use_https = config["use_https"]
124 if use_https:
125 # For development, use self-signed certificate
126 logger.info("Starting server with HTTPS (self-signed certificate)")
127 # Note: SocketIOService doesn't support SSL context directly
128 # For production, use a reverse proxy like nginx for HTTPS
129 logger.warning(
130 "HTTPS requested but not supported directly. Use a reverse proxy for HTTPS."
131 )
133 # Start periodic cleanup of idle database connections
134 # Guard against Flask debug reloader spawning duplicate schedulers
135 import os
137 cleanup_scheduler = None
138 if not debug or os.environ.get("WERKZEUG_RUN_MAIN") == "true":
139 from .auth.connection_cleanup import start_connection_cleanup_scheduler
140 from .auth.session_manager import session_manager
141 from ..database.encrypted_db import db_manager
143 try:
144 cleanup_scheduler = start_connection_cleanup_scheduler(
145 session_manager, db_manager
146 )
147 except Exception:
148 logger.warning(
149 "Failed to start cleanup scheduler; idle connections will not be auto-closed",
150 )
152 def shutdown_scheduler():
153 if (
154 hasattr(app, "background_job_scheduler")
155 and app.background_job_scheduler
156 ):
157 try:
158 app.background_job_scheduler.stop()
159 logger.info("News subscription scheduler stopped gracefully")
160 except Exception:
161 logger.exception("Error stopping scheduler")
163 def shutdown_databases():
164 try:
165 from ..database.encrypted_db import db_manager
167 db_manager.close_all_databases()
168 logger.info("Database connections closed gracefully")
169 except Exception:
170 logger.exception("Error closing database connections")
172 def flush_logs_on_exit():
173 """Drain remaining queued logs after the daemon has stopped."""
174 try:
175 # Use a minimal Flask context here rather than the main app so
176 # the flush still works if the main app is already torn down.
177 from flask import Flask
179 exit_app = Flask(__name__)
180 with exit_app.app_context():
181 flush_log_queue()
182 except Exception:
183 logger.exception("Failed to flush logs on exit")
185 # atexit runs LIFO, so register in reverse of desired execution order.
186 # Desired execution:
187 # 1. stop_log_queue_processor — daemon releases the queue
188 # 2. flush_logs_on_exit — drain whatever the daemon missed
189 # 3. shutdown_scheduler + cleanup_scheduler — stop other workers
190 # 4. shutdown_databases — close engines last
191 atexit.register(shutdown_databases)
192 atexit.register(shutdown_scheduler)
193 if cleanup_scheduler is not None:
194 atexit.register(lambda: cleanup_scheduler.shutdown(wait=False))
195 atexit.register(flush_logs_on_exit)
196 if daemon_started: 196 ↛ 200line 196 didn't jump to line 200 because the condition on line 196 was always true
197 atexit.register(stop_log_queue_processor)
199 # Use the SocketIOService's run method which properly runs the socketio server
200 socket_service.run(host=host, port=port, debug=debug)
203if __name__ == "__main__": 203 ↛ 204line 203 didn't jump to line 204 because the condition on line 203 was never true
204 main()