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

1import atexit 

2import threading 

3import traceback 

4from loguru import logger 

5 

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 

15 

16 

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. 

22 

23 Respects a previously-installed hook if any (chains to it). 

24 """ 

25 previous = threading.excepthook 

26 

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 

52 

53 threading.excepthook = _hook 

54 

55 

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() 

67 

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__}") 

72 

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 

83 

84 migrate_legacy_docstores() 

85 except Exception: 

86 logger.exception("Legacy RAG docstore migration failed at startup") 

87 

88 # Create the Flask app and SocketIO instance 

89 app, socket_service = create_app() 

90 

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 ) 

101 

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") 

106 

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") 

116 

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"] 

123 

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 ) 

132 

133 # Start periodic cleanup of idle database connections 

134 # Guard against Flask debug reloader spawning duplicate schedulers 

135 import os 

136 

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 

142 

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 ) 

151 

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") 

162 

163 def shutdown_databases(): 

164 try: 

165 from ..database.encrypted_db import db_manager 

166 

167 db_manager.close_all_databases() 

168 logger.info("Database connections closed gracefully") 

169 except Exception: 

170 logger.exception("Error closing database connections") 

171 

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 

178 

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") 

184 

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) 

198 

199 # Use the SocketIOService's run method which properly runs the socketio server 

200 socket_service.run(host=host, port=port, debug=debug) 

201 

202 

203if __name__ == "__main__": 203 ↛ 204line 203 didn't jump to line 204 because the condition on line 203 was never true

204 main()