Coverage for src/local_deep_research/web/app.py: 89%
86 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-03 23:15 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-03 23:15 +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 # Create the Flask app and SocketIO instance
74 app, socket_service = create_app()
76 # Start the background log-queue processor. With no ``before_request``
77 # handler pulling from the queue, this daemon is the only drain path
78 # during normal operation; a final drain runs at atexit.
79 daemon_started = False
80 try:
81 start_log_queue_processor(app)
82 daemon_started = True
83 except Exception:
84 logger.exception("Failed to start log queue processor")
86 # Get web server settings from environment variables (LDR_WEB_HOST, etc.)
87 # These require a server restart to take effect
88 host = config["host"]
89 port = config["port"]
90 debug = config["debug"]
91 use_https = config["use_https"]
93 if use_https:
94 # For development, use self-signed certificate
95 logger.info("Starting server with HTTPS (self-signed certificate)")
96 # Note: SocketIOService doesn't support SSL context directly
97 # For production, use a reverse proxy like nginx for HTTPS
98 logger.warning(
99 "HTTPS requested but not supported directly. Use a reverse proxy for HTTPS."
100 )
102 # Start periodic cleanup of idle database connections
103 # Guard against Flask debug reloader spawning duplicate schedulers
104 import os
106 cleanup_scheduler = None
107 if not debug or os.environ.get("WERKZEUG_RUN_MAIN") == "true":
108 from .auth.connection_cleanup import start_connection_cleanup_scheduler
109 from .auth.session_manager import session_manager
110 from ..database.encrypted_db import db_manager
112 try:
113 cleanup_scheduler = start_connection_cleanup_scheduler(
114 session_manager, db_manager
115 )
116 except Exception:
117 logger.warning(
118 "Failed to start cleanup scheduler; idle connections will not be auto-closed",
119 )
121 def shutdown_scheduler():
122 if (
123 hasattr(app, "background_job_scheduler")
124 and app.background_job_scheduler
125 ):
126 try:
127 app.background_job_scheduler.stop()
128 logger.info("News subscription scheduler stopped gracefully")
129 except Exception:
130 logger.exception("Error stopping scheduler")
132 def shutdown_databases():
133 try:
134 from ..database.encrypted_db import db_manager
136 db_manager.close_all_databases()
137 logger.info("Database connections closed gracefully")
138 except Exception:
139 logger.exception("Error closing database connections")
141 def flush_logs_on_exit():
142 """Drain remaining queued logs after the daemon has stopped."""
143 try:
144 # Use a minimal Flask context here rather than the main app so
145 # the flush still works if the main app is already torn down.
146 from flask import Flask
148 exit_app = Flask(__name__)
149 with exit_app.app_context():
150 flush_log_queue()
151 except Exception:
152 logger.exception("Failed to flush logs on exit")
154 # atexit runs LIFO, so register in reverse of desired execution order.
155 # Desired execution:
156 # 1. stop_log_queue_processor — daemon releases the queue
157 # 2. flush_logs_on_exit — drain whatever the daemon missed
158 # 3. shutdown_scheduler + cleanup_scheduler — stop other workers
159 # 4. shutdown_databases — close engines last
160 atexit.register(shutdown_databases)
161 atexit.register(shutdown_scheduler)
162 if cleanup_scheduler is not None:
163 atexit.register(lambda: cleanup_scheduler.shutdown(wait=False))
164 atexit.register(flush_logs_on_exit)
165 if daemon_started: 165 ↛ 169line 165 didn't jump to line 169 because the condition on line 165 was always true
166 atexit.register(stop_log_queue_processor)
168 # Use the SocketIOService's run method which properly runs the socketio server
169 socket_service.run(host=host, port=port, debug=debug)
172if __name__ == "__main__": 172 ↛ 173line 172 didn't jump to line 173 because the condition on line 172 was never true
173 main()