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

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 # Create the Flask app and SocketIO instance 

74 app, socket_service = create_app() 

75 

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

85 

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

92 

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 ) 

101 

102 # Start periodic cleanup of idle database connections 

103 # Guard against Flask debug reloader spawning duplicate schedulers 

104 import os 

105 

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 

111 

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 ) 

120 

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

131 

132 def shutdown_databases(): 

133 try: 

134 from ..database.encrypted_db import db_manager 

135 

136 db_manager.close_all_databases() 

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

138 except Exception: 

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

140 

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 

147 

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

153 

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) 

167 

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

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

170 

171 

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

173 main()