Coverage for src/local_deep_research/web/services/socket_service.py: 96%

231 statements  

« prev     ^ index     » next       coverage.py v7.15.1, created at 2026-07-19 23:35 +0000

1from threading import Lock 

2from typing import Any 

3 

4from flask import Flask, request, session 

5from flask_socketio import SocketIO, join_room 

6from loguru import logger 

7 

8from ...constants import ResearchStatus 

9from ...database.encrypted_db import db_manager 

10from ...database.session_passwords import session_password_store 

11from ..routes.globals import get_active_research_snapshot 

12 

13 

14def _install_origin_rejection_logging(socketio: SocketIO) -> bool: 

15 """Re-emit engine.io's silenced WebSocket origin rejections via loguru. 

16 

17 engine.io validates the Origin at handshake and calls 

18 ``_log_error_once('<origin> is not an accepted origin.', 'bad-origin')``, 

19 but the server runs with ``logger=False`` so that message never surfaces — 

20 the only symptom of a misconfigured WebSocket origin is a frozen progress 

21 UI. Wrap that one call to log a WARNING (deduped per origin) pointing at the 

22 fix. An Origin is a scheme+host, not PII. Best-effort: a no-op (returns 

23 False) if engine.io internals change, so it can never break startup. 

24 

25 The dedup set is capped: the handshake is pre-auth and ``Origin`` is 

26 attacker-controlled, so an unbounded set would be a memory-growth + log- 

27 amplification vector. After ``cap`` distinct origins we stop tracking/warning 

28 (an operator has more than enough signal by then). 

29 """ 

30 try: 

31 eio = socketio.server.eio 

32 original = eio._log_error_once 

33 except AttributeError: 

34 logger.debug( 

35 "Socket.IO: origin-rejection logging not installed " 

36 "(engine.io internals changed); handshake rejections stay silent" 

37 ) 

38 return False 

39 

40 warned: set[str] = set() 

41 cap = 100 

42 

43 def _log_error_once(message, message_key): 

44 if ( 

45 message_key == "bad-origin" 

46 and len(warned) < cap 

47 and message not in warned 

48 ): 

49 warned.add(message) 

50 logger.warning( 

51 f"Socket.IO rejected a WebSocket handshake: {message} Set " 

52 "LDR_SECURITY_WEBSOCKET_ALLOWED_ORIGINS to this origin if it is " 

53 "your front-end; behind a TLS-terminating proxy, also forward " 

54 "X-Forwarded-Proto so the same-origin check sees https." 

55 ) 

56 return original(message, message_key) 

57 

58 eio._log_error_once = _log_error_once 

59 return True 

60 

61 

62class SocketIOService: 

63 """ 

64 Singleton class for managing SocketIO connections and subscriptions. 

65 """ 

66 

67 _instance = None 

68 

69 def __new__(cls, *args: Any, app: Flask | None = None, **kwargs: Any): 

70 """ 

71 Args: 

72 app: The Flask app to bind this service to. It must be specified 

73 the first time this is called and the singleton instance is 

74 created, but will be ignored after that. 

75 *args: Arguments to pass to the superclass's __new__ method. 

76 **kwargs: Keyword arguments to pass to the superclass's __new__ method. 

77 """ 

78 if not cls._instance: 

79 if app is None: 

80 raise ValueError( 

81 "Flask app must be specified to create a SocketIOService instance." 

82 ) 

83 cls._instance = super(SocketIOService, cls).__new__( 

84 cls, *args, **kwargs 

85 ) 

86 cls._instance.__init_singleton(app) 

87 return cls._instance 

88 

89 def __init_singleton(self, app: Flask) -> None: 

90 """ 

91 Initializes the singleton instance. 

92 

93 Args: 

94 app: The app to bind this service to. 

95 

96 """ 

97 self.__app = app # Store the Flask app reference 

98 

99 # Determine WebSocket CORS policy from env var or default 

100 from ...settings.env_registry import get_env_setting 

101 

102 ws_origins_env = get_env_setting("security.websocket.allowed_origins") 

103 socketio_cors: str | list[str] | None 

104 if ws_origins_env is not None: 

105 if ws_origins_env == "*": 

106 socketio_cors = "*" 

107 elif ws_origins_env: 

108 socketio_cors = [o.strip() for o in ws_origins_env.split(",")] 

109 else: 

110 socketio_cors = None 

111 else: 

112 # No env var set — fail closed to same-origin only, matching HTTP CORS default 

113 socketio_cors = None 

114 

115 if socketio_cors is None: 

116 logger.info( 

117 "Socket.IO CORS: same-origin only (set LDR_SECURITY_WEBSOCKET_ALLOWED_ORIGINS to configure)" 

118 ) 

119 elif socketio_cors == "*": 

120 logger.debug("Socket.IO CORS: all origins allowed") 

121 else: 

122 logger.info(f"Socket.IO CORS: restricted to {socketio_cors}") 

123 

124 self.__socketio = SocketIO( 

125 app, 

126 cors_allowed_origins=socketio_cors, 

127 async_mode="threading", 

128 path="/socket.io", 

129 logger=False, 

130 engineio_logger=False, 

131 ping_timeout=20, 

132 ping_interval=5, 

133 ) 

134 

135 # Make a rejected WebSocket origin diagnosable (otherwise it is a silent 

136 # frozen progress UI). Skipped for the allow-all case, which rejects 

137 # nothing. 

138 if socketio_cors != "*": 

139 _install_origin_rejection_logging(self.__socketio) 

140 

141 # Socket subscription tracking. 

142 self.__socket_subscriptions: dict[str, Any] = {} 

143 # Set to false to disable logging in the event handlers. This can 

144 # be necessary because it will sometimes run the handlers directly 

145 # during a call to `emit` that was made in a logging handler. 

146 self.__logging_enabled = True 

147 # Protects access to shared state. 

148 self.__lock = Lock() 

149 

150 # Register events. 

151 @self.__socketio.on("connect") 

152 def on_connect(): 

153 return self.__handle_connect(request) 

154 

155 @self.__socketio.on("disconnect") 

156 def on_disconnect(reason: str): 

157 self.__handle_disconnect(request, reason) 

158 

159 @self.__socketio.on("subscribe_to_research") 

160 def on_subscribe(data): 

161 self.__handle_subscribe(data, request) 

162 

163 # Backwards-compatible alias: the JS client emits 'join' on subscribe. 

164 # Without this, the catch-up snapshot in __handle_subscribe never 

165 # fires and per-client targeting falls through to broadcast. 

166 @self.__socketio.on("join") 

167 def on_join(data): 

168 self.__handle_subscribe(data, request) 

169 

170 @self.__socketio.on("leave") 

171 def on_leave(data): 

172 self.__handle_unsubscribe(data, request) 

173 

174 @self.__socketio.on("unsubscribe_from_research") 

175 def on_unsubscribe(data): 

176 self.__handle_unsubscribe(data, request) 

177 

178 @self.__socketio.on_error 

179 def on_error(e): 

180 return self.__handle_socket_error(e) 

181 

182 @self.__socketio.on_error_default 

183 def on_default_error(e): 

184 return self.__handle_default_error(e) 

185 

186 def __log_info(self, message: str, *args: Any, **kwargs: Any) -> None: 

187 """Log an info message.""" 

188 if self.__logging_enabled: 

189 logger.info(message, *args, **kwargs) 

190 

191 def __log_error(self, message: str, *args: Any, **kwargs: Any) -> None: 

192 """Log an error message.""" 

193 if self.__logging_enabled: 

194 logger.error(message, *args, **kwargs) 

195 

196 def __log_exception(self, message: str, *args: Any, **kwargs: Any) -> None: 

197 """Log an exception.""" 

198 if self.__logging_enabled: 

199 logger.exception(message, *args, **kwargs) 

200 

201 @staticmethod 

202 def user_room(username: str) -> str: 

203 """Socket.IO room name that every one of a user's connected tabs joins. 

204 

205 Used to scope user-private events to a single account. Kept here so the 

206 connect handler and event emitters share one definition and cannot 

207 drift apart. 

208 """ 

209 return f"user:{username}" 

210 

211 def emit_socket_event(self, event, data, room=None): 

212 """ 

213 Emit a socket event to clients. 

214 

215 Args: 

216 event: The event name to emit 

217 data: The data to send with the event 

218 room: Optional room ID to send to specific client 

219 

220 Returns: 

221 bool: True if emission was successful, False otherwise 

222 """ 

223 try: 

224 # If room is specified, only emit to that room 

225 if room: 

226 self.__socketio.emit(event, data, room=room) 

227 else: 

228 # Otherwise broadcast to all 

229 self.__socketio.emit(event, data) 

230 return True 

231 except Exception: 

232 logger.exception(f"Error emitting socket event {event}") 

233 return False 

234 

235 def emit_to_subscribers( 

236 self, event_base, research_id, data, enable_logging: bool = True 

237 ): 

238 """ 

239 Emit an event to all subscribers of a specific research. 

240 

241 Args: 

242 event_base: Base event name (will be formatted with research_id) 

243 research_id: ID of the research 

244 data: The data to send with the event 

245 enable_logging: If set to false, this will disable all logging, 

246 which is useful if we are calling this inside of a logging 

247 handler. 

248 

249 Returns: 

250 bool: True if emission was successful, False otherwise 

251 

252 """ 

253 if not enable_logging: 

254 self.__logging_enabled = False 

255 

256 try: 

257 full_event = f"{event_base}_{research_id}" 

258 

259 # Emit only to specific subscribers (no broadcast) to avoid 

260 # duplicate messages and reduce server load under concurrency 

261 with self.__lock: 

262 subscriptions = self.__socket_subscriptions.get(research_id) 

263 if subscriptions: 

264 subscriptions = ( 

265 subscriptions.copy() 

266 ) # snapshot avoids RuntimeError 

267 else: 

268 subscriptions = None 

269 if subscriptions is not None: 

270 for sid in subscriptions: 

271 try: 

272 self.__socketio.emit(full_event, data, room=sid) 

273 except Exception: 

274 self.__log_exception( 

275 f"Error emitting to subscriber {sid}" 

276 ) 

277 # When no targeted subscribers exist yet, drop the event. 

278 # The catch-up snapshot in __handle_subscribe replays the 

279 # latest progress on subscribe, so early-arriving events 

280 # are recovered correctly without a cross-user broadcast. 

281 

282 return True 

283 except Exception: 

284 self.__log_exception( 

285 f"Error emitting to subscribers for research {research_id}" 

286 ) 

287 return False 

288 finally: 

289 self.__logging_enabled = True 

290 

291 def remove_subscriptions_for_research(self, research_id: str) -> None: 

292 """Remove all socket subscriptions for a completed research.""" 

293 with self.__lock: 

294 removed = self.__socket_subscriptions.pop(research_id, None) 

295 if removed is not None: 

296 self.__log_info( 

297 f"Removed {len(removed)} subscription(s) for research {research_id}" 

298 ) 

299 

300 def __handle_connect(self, request): 

301 """Handle client connection""" 

302 username = session.get("username") 

303 if not username: 

304 self.__log_info( 

305 f"Rejected unauthenticated WebSocket connection from {request.sid}" 

306 ) 

307 return False 

308 if not db_manager.is_user_connected(username): 

309 # Cookie is valid but the per-user DB engine isn't open yet (race vs first 

310 # XHR after page load, gunicorn worker restart, or idle eviction). Lazily 

311 # open it using the password the user authenticated with at login. 

312 session_id = session.get("session_id") 

313 password = ( 

314 session_password_store.get_session_password( 

315 username, session_id 

316 ) 

317 if session_id 

318 else None 

319 ) 

320 if not password: 

321 self.__log_info( 

322 f"Rejected WebSocket connection for {username}: no active DB session and no stored password" 

323 ) 

324 return False 

325 try: 

326 db_manager.open_user_database(username, password) 

327 except Exception as e: 

328 # Use __log_error (not __log_exception) so loguru cannot include 

329 # the `password` local in a diagnose=True traceback. 

330 self.__log_error( 

331 f"Lazy DB open failed for {username} at WebSocket connect: {type(e).__name__}" 

332 ) 

333 return False 

334 # Join a per-user room so user-scoped events (e.g. settings_changed, 

335 # which carries raw setting values including plaintext API keys) reach 

336 # only this user's own browser tabs and are never broadcast to every 

337 # connected client. Flask-SocketIO auto-removes the socket from the 

338 # room on disconnect. 

339 join_room(self.user_room(username)) 

340 self.__log_info(f"Client connected: {request.sid} (user: {username})") 

341 return True 

342 

343 def __handle_disconnect(self, request, reason: str): 

344 """Handle client disconnection""" 

345 try: 

346 self.__log_info( 

347 f"Client {request.sid} disconnected because: {reason}" 

348 ) 

349 # Clean up subscriptions for this client. 

350 # __socket_subscriptions is keyed by research_id → set of sids, 

351 # so we iterate all entries and discard the disconnecting sid. 

352 with self.__lock: 

353 empty_keys = [] 

354 for research_id, sids in self.__socket_subscriptions.items(): 

355 sids.discard(request.sid) 

356 if not sids: 

357 empty_keys.append(research_id) 

358 for key in empty_keys: 

359 del self.__socket_subscriptions[key] 

360 self.__log_info(f"Removed subscription for client {request.sid}") 

361 

362 # Clean up any thread-local database sessions that may have been 

363 # created during socket handler execution. This prevents file 

364 # descriptor leaks from unclosed SQLAlchemy sessions. 

365 try: 

366 from ...database.thread_local_session import ( 

367 cleanup_current_thread, 

368 ) 

369 

370 cleanup_current_thread() 

371 except ImportError: 

372 pass # Module not available, skip cleanup 

373 except Exception: 

374 self.__log_exception( 

375 "Error cleaning up thread session on disconnect" 

376 ) 

377 except Exception as e: 

378 self.__log_exception(f"Error handling disconnect: {e}") 

379 

380 def __handle_subscribe(self, data, request): 

381 """Handle client subscription to research updates.""" 

382 research_id = data.get("research_id") 

383 if not research_id: 

384 return 

385 

386 # Verify the connected user actually owns this research before 

387 # subscribing. The in-memory `_active_research` snapshot is keyed 

388 # only by research_id (no user tuple), so without this guard any 

389 # logged-in user could subscribe to any guessed/leaked research 

390 # UUID and receive its progress events. The per-user encrypted DB 

391 # is the ownership boundary: if the research row doesn't exist in 

392 # the user's DB, they don't own it. 

393 username = session.get("username") 

394 if not username or not self._user_owns_research(username, research_id): 394 ↛ 395line 394 didn't jump to line 395 because the condition on line 394 was never true

395 self.__log_info( 

396 f"Rejected subscribe from {request.sid}: user does not own research {research_id}" 

397 ) 

398 return 

399 

400 with self.__lock: 

401 if research_id not in self.__socket_subscriptions: 

402 self.__socket_subscriptions[research_id] = set() 

403 self.__socket_subscriptions[research_id].add(request.sid) 

404 self.__log_info( 

405 f"Client {request.sid} subscribed to research {research_id}" 

406 ) 

407 

408 # Send current status immediately if available in active research 

409 snapshot = get_active_research_snapshot(research_id) 

410 if snapshot is not None: 

411 progress = snapshot["progress"] 

412 latest_log = snapshot["log"][-1] if snapshot["log"] else None 

413 

414 if latest_log: 

415 self.emit_socket_event( 

416 f"progress_{research_id}", 

417 { 

418 "progress": progress, 

419 "message": latest_log.get("message", "Processing..."), 

420 "status": ResearchStatus.IN_PROGRESS, 

421 "log_entry": latest_log, 

422 }, 

423 room=request.sid, 

424 ) 

425 

426 @staticmethod 

427 def _user_owns_research(username: str, research_id: str) -> bool: 

428 """Return True if the given user owns this research / benchmark id. 

429 

430 Used as the authorization boundary for WebSocket subscriptions — 

431 ownership is checked against the user's encrypted SQLite database, 

432 which is the per-user data partition. A static helper so unit 

433 tests can exercise the authz logic without standing up the 

434 singleton/Flask app. 

435 

436 Recognizes both normal research (``ResearchHistory``, UUID id) and 

437 benchmark runs (``BenchmarkRun``, integer id) — the benchmark page 

438 subscribes with its ``BenchmarkRun.id``, which lives in the same 

439 per-user DB. Both checks stay scoped to the caller's own database, 

440 so no cross-user access is introduced. 

441 """ 

442 try: 

443 from ...database.session_context import get_user_db_session 

444 from ...database.models import ResearchHistory 

445 

446 with get_user_db_session(username) as db: 

447 if ( 

448 db.query(ResearchHistory.id) 

449 .filter(ResearchHistory.id == research_id) 

450 .first() 

451 is not None 

452 ): 

453 return True 

454 

455 # Benchmark pages subscribe with their BenchmarkRun.id. 

456 # Recognize the user's own benchmark runs so the ownership 

457 # gate doesn't drop benchmark live progress (regression vs. 

458 # the removed cross-user broadcast). research_id stays a 

459 # string (never coerced to int — IDs are strings/UUIDs 

460 # repo-wide); SQLite applies numeric affinity to match the 

461 # Integer column. Only attempt this for numeric ids. 

462 if str(research_id).isdigit(): 

463 from ...database.models.benchmark import BenchmarkRun 

464 

465 return ( 

466 db.query(BenchmarkRun.id) 

467 .filter(BenchmarkRun.id == research_id) 

468 .first() 

469 is not None 

470 ) 

471 return False 

472 except Exception: 

473 # Conservative: deny on any DB-open or query failure so a 

474 # transient infra error never silently widens authz. 

475 logger.opt(exception=True).warning( 

476 "Failed to verify research ownership for socket subscribe" 

477 ) 

478 return False 

479 

480 def __handle_unsubscribe(self, data, request): 

481 """Handle client unsubscribe from research updates.""" 

482 research_id = ( 

483 data.get("research_id") if isinstance(data, dict) else None 

484 ) 

485 if not research_id: 

486 return 

487 

488 # Symmetric with __handle_subscribe: require the caller to own the 

489 # research before mutating the per-research subscription set. The 

490 # practical impact of an unguarded unsubscribe is small (no data 

491 # exfiltration; subscribe is already guarded), but it keeps the 

492 # authz boundary consistent and avoids log spam from spoofed sids. 

493 username = session.get("username") 

494 if not username or not self._user_owns_research(username, research_id): 

495 self.__log_info( 

496 f"Rejected unsubscribe from {request.sid}: user does not own research {research_id}" 

497 ) 

498 return 

499 

500 with self.__lock: 

501 subs = self.__socket_subscriptions.get(research_id) 

502 if subs: 

503 subs.discard(request.sid) 

504 # Prune empty sets so the dict doesn't grow unbounded with 

505 # stale research_ids over long server runtimes. 

506 if not subs: 

507 self.__socket_subscriptions.pop(research_id, None) 

508 self.__log_info( 

509 f"Client {request.sid} unsubscribed from research {research_id}" 

510 ) 

511 

512 def __handle_socket_error(self, e): 

513 """Handle Socket.IO errors""" 

514 self.__log_exception(f"Socket.IO error: {str(e)}") 

515 # Don't propagate exceptions to avoid crashing the server 

516 return False 

517 

518 def __handle_default_error(self, e): 

519 """Handle unhandled Socket.IO errors""" 

520 self.__log_exception(f"Unhandled Socket.IO error: {str(e)}") 

521 # Don't propagate exceptions to avoid crashing the server 

522 return False 

523 

524 def run(self, host: str, port: int, debug: bool = False) -> None: 

525 """ 

526 Runs the SocketIO server. 

527 

528 Args: 

529 host: The hostname to bind the server to. 

530 port: The port number to listen on. 

531 debug: Whether to run in debug mode. Defaults to False. 

532 

533 """ 

534 # Suppress Server header to prevent version information disclosure 

535 # This must be done before starting the server because Werkzeug adds 

536 # the header at the HTTP layer, not WSGI layer 

537 try: 

538 from werkzeug.serving import WSGIRequestHandler 

539 

540 WSGIRequestHandler.version_string = lambda self: "" # type: ignore[method-assign] 

541 logger.debug("Suppressed Server header for security") 

542 except ImportError: 

543 logger.warning( 

544 "Could not suppress Server header - werkzeug not found" 

545 ) 

546 

547 logger.info(f"Starting web server on {host}:{port} (debug: {debug})") 

548 self.__socketio.run( 

549 self.__app, # Use the stored Flask app reference 

550 debug=debug, 

551 host=host, 

552 port=port, 

553 allow_unsafe_werkzeug=True, 

554 use_reloader=False, 

555 )