Coverage for src/local_deep_research/web/services/socketio_asgi.py: 97%

421 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1""" 

2ASGI Socket.IO integration for FastAPI. 

3 

4Replaces Flask-SocketIO's SocketIOService with python-socketio's AsyncServer 

5mounted as an ASGI sub-application. This is the pattern used by Open WebUI 

6(129k stars) and recommended by the python-socketio documentation. 

7 

8Usage: 

9 from .socketio_asgi import sio, socket_app, emit_to_subscribers 

10 

11 # Mount in FastAPI app: 

12 app.mount("/ws", socket_app) 

13 

14 # Emit from anywhere (owner is required — see _subscriptions): 

15 emit_to_subscribers("research_progress", research_id, data, owner=username) 

16""" 

17 

18import asyncio 

19import contextvars 

20import hashlib 

21from typing import Any 

22 

23import socketio 

24from loguru import logger 

25 

26from ...constants import ResearchStatus 

27from ...utilities.resource_utils import safe_close 

28from ..research_state import get_active_research_snapshot 

29 

30# Determine WebSocket CORS policy from env var 

31from ...settings.env_registry import get_env_setting 

32from ...security.log_sanitizer import redact_and_bound_for_log 

33 

34 

35def _resolve_socketio_cors(env_value: str | None) -> str | list[str] | None: 

36 """Resolve the WebSocket origin allowlist, never yielding ``[]``. 

37 

38 None (NOT []) is load-bearing: engine.io treats None as "derive the 

39 same-origin whitelist from Host/X-Forwarded-Proto", whereas an empty 

40 list DISABLES origin validation entirely (engineio checks 

41 ``if self.cors_allowed_origins != []`` before validating), i.e. [] is 

42 effectively allow-all for WebSocket handshakes. Every degenerate 

43 input (unset, empty, nothing but separators) therefore resolves to 

44 None; ``*`` requires the explicit env opt-in. A ``*`` entry anywhere 

45 inside a comma-separated list is treated as that same opt-in: engine.io 

46 has no "allow-all plus named origins" mode, so a list containing ``*`` 

47 already accepts every Origin -- returning it as a list would silently 

48 behave as allow-all while the startup log claims a finite allowlist. 

49 """ 

50 if env_value is None or not env_value.strip(): 

51 return None 

52 if env_value.strip() == "*": 

53 return "*" 

54 origins = [o.strip() for o in env_value.split(",") if o.strip()] 

55 if "*" in origins: 

56 return "*" 

57 return origins or None 

58 

59 

60_ws_origins_env = get_env_setting("security.websocket.allowed_origins") 

61# Plain assignment (not annotated): the startup-ordering freeze contract 

62# detects module-scope ast.Assign targets. 

63_socketio_cors = _resolve_socketio_cors(_ws_origins_env) 

64 

65 

66def _log_socketio_cors_policy( 

67 cors: str | list[str] | None, env_value: str | None 

68) -> None: 

69 """Log the resolved WebSocket origin policy at a default-visible level. 

70 

71 The allow-all posture must never be hidden below the default INFO sink. 

72 A ``*`` mixed with specific origins is logged at WARNING because it 

73 almost certainly is a misconfiguration (the named origins have no 

74 effect). Only the operator's own configured entries (stripped) are 

75 logged, never request-supplied values. 

76 """ 

77 if cors is None: 

78 logger.info( 

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

80 ) 

81 elif cors == "*": 81 ↛ 98line 81 didn't jump to line 98 because the condition on line 81 was always true

82 named = [ 

83 o 

84 for o in (env_value or "").split(",") 

85 if o.strip() and o.strip() != "*" 

86 ] 

87 if named: 

88 logger.warning( 

89 "Socket.IO CORS: all origins allowed -- " 

90 "LDR_SECURITY_WEBSOCKET_ALLOWED_ORIGINS contains '*' alongside " 

91 f"specific origins {[o.strip() for o in named]}; engine.io has " 

92 "no allow-all-plus-named mode, so the named origins have no " 

93 "effect. Remove '*' to restrict to them." 

94 ) 

95 else: 

96 logger.info("Socket.IO CORS: all origins allowed ('*')") 

97 else: 

98 logger.info(f"Socket.IO CORS: restricted to {cors}") 

99 

100 

101def log_socketio_cors_policy() -> None: 

102 """Log the import-time-resolved WebSocket origin policy. 

103 

104 Deliberately NOT called at module import: this module is imported at 

105 the top of ``utilities/log_utils.py``, i.e. while the package's 

106 ``logger.disable("local_deep_research")`` (``__init__.py``) is still in 

107 effect and before ``config_logger()`` re-enables it, so an import-time 

108 log line -- including the allow-all INFO/WARNING -- would be silently 

109 dropped. The FastAPI lifespan startup calls this instead, once the 

110 server is starting and logging is configured. 

111 """ 

112 _log_socketio_cors_policy(_socketio_cors, _ws_origins_env) 

113 

114 

115def _research_id_preview(research_id: Any) -> str: 

116 """Bounded preview of a client-sent ``research_id`` for log lines. 

117 

118 (#6938: the Socket.IO payload's id is any JSON value of up to the 

119 engine.io buffer size, logged on the rejection paths before any 

120 ownership match. Imported at call time so this module never imports 

121 the routers package at import time.) 

122 """ 

123 from ..routers.notes import _log_value_preview 

124 

125 return _log_value_preview(research_id) 

126 

127 

128# What engine.io's ``_log_error_once`` receives for a repeat it will not 

129# emit (see ``_install_origin_rejection_logging``). 

130_ENGINEIO_SUPPRESSED_TEXT = "Rejected (repeat; text not rendered)." 

131# ``logging.INFO``: engine.io logs through the standard library, whose 

132# level numbers are fixed; this module itself logs only through loguru. 

133_STDLIB_INFO_LEVEL = 20 

134 

135 

136def _install_origin_rejection_logging(sio: "socketio.AsyncServer") -> bool: 

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

138 

139 engine.io validates the Origin at handshake and calls 

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

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

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

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

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

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

146 

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

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

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

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

151 """ 

152 try: 

153 eio = sio.eio 

154 original = eio._log_error_once 

155 except AttributeError: 

156 logger.debug( 

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

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

159 ) 

160 return False 

161 

162 warned: set[str] = set() 

163 cap = 100 

164 

165 def _log_error_once(message, message_key): 

166 # (#6938: the message embeds the client's Origin header, which 

167 # has no length limit on the polling transport, so this module's 

168 # warning and the text handed on to engine.io's own logger are the 

169 # redacted, bounded form. Redaction still costs a few ms on 

170 # adversarial input (``redact_and_bound_for_log`` scans up to 1,200 

171 # characters for a 300-character result) and this path is pre-auth 

172 # and not rate limited, so it runs only when that text is actually 

173 # logged: for each of the first ``cap`` distinct messages, and when 

174 # engine.io's logger will emit the line (the first time a key is 

175 # seen, unless its level was lowered). The dedup key is a fixed-size SHA-256 

176 # digest of the raw message, so deciding whether to warn needs no 

177 # redaction; once the cap is reached it is not computed at all.) 

178 is_text = isinstance(message, str) 

179 redacted: list[str] = [] 

180 

181 def _bounded() -> str: 

182 if not redacted: 

183 redacted.append( 

184 redact_and_bound_for_log(message, 300) 

185 if is_text 

186 else type(message).__name__ 

187 ) 

188 return redacted[0] 

189 

190 if message_key == "bad-origin" and len(warned) < cap: 

191 dedup_key = ( 

192 hashlib.sha256( 

193 message.encode("utf-8", "surrogatepass") 

194 ).hexdigest() 

195 if is_text 

196 else type(message).__name__ 

197 ) 

198 if dedup_key not in warned: 

199 warned.add(dedup_key) 

200 logger.warning( 

201 f"Socket.IO rejected a WebSocket handshake: {_bounded()} " 

202 "Set LDR_SECURITY_WEBSOCKET_ALLOWED_ORIGINS to this origin " 

203 "if it is your front-end; behind a TLS-terminating proxy, " 

204 "also forward X-Forwarded-Proto so the same-origin check " 

205 "sees https." 

206 ) 

207 if not is_text: 207 ↛ 208line 207 didn't jump to line 208 because the condition on line 207 was never true

208 return original(message, message_key) 

209 if _engineio_will_log(message_key): 

210 return original(_bounded(), message_key) 

211 # engine.io drops this line (a repeat key, logged at INFO under its 

212 # ERROR-level logger); hand it a constant so its bookkeeping runs 

213 # unchanged without paying for a redaction nobody reads. 

214 return original(_ENGINEIO_SUPPRESSED_TEXT, message_key) 

215 

216 def _engineio_will_log(message_key) -> bool: 

217 """Whether engine.io's ``_log_error_once`` emits for this key. 

218 

219 It logs at ERROR the first time a key is seen and at INFO after 

220 that (``engineio.base_server``). Anything this cannot determine 

221 counts as "will log", so the redacted text is passed on. 

222 """ 

223 try: 

224 if message_key not in eio.log_message_keys: 

225 return True 

226 return bool(eio.logger.isEnabledFor(_STDLIB_INFO_LEVEL)) 

227 except Exception: 

228 return True 

229 

230 eio._log_error_once = _log_error_once 

231 return True 

232 

233 

234# Create the async Socket.IO server 

235sio = socketio.AsyncServer( 

236 async_mode="asgi", 

237 cors_allowed_origins=_socketio_cors, 

238 logger=False, 

239 engineio_logger=False, 

240 ping_timeout=20, 

241 ping_interval=5, 

242) 

243 

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

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

246# nothing. 

247if _socketio_cors != "*": 

248 _install_origin_rejection_logging(sio) 

249 

250# Create the ASGI app for mounting 

251# socketio_path must match how the client connects 

252socket_app = socketio.ASGIApp(sio, socketio_path="/ws/socket.io") 

253 

254# Subscription tracking: (username, research_id) -> set of sids. 

255# 

256# Keyed by the OWNER as well as the id, never by the id alone. Research ids 

257# are UUIDs, but the benchmark page subscribes with a numeric 

258# ``BenchmarkRun.id``, and that id autoincrements inside each user's own 

259# encrypted database — so every user's first benchmark run is id 1. 

260# 

261# The ownership check in ``_owns_research_sync`` cannot catch this, and it is 

262# not wrong: it asks "does THIS user own this id in THEIR database?", and for 

263# two different users' run 1 the honest answer is yes for both. Keyed by the 

264# bare id, both then landed in ``_subscriptions["1"]`` and one user's 

265# benchmark progress was emitted to the other's browser. Reproduced: 

266# ``_subscriptions[1] = {'sid-alice', 'sid-bob'}``, with Bob receiving 

267# Alice's run data. 

268# 

269# So the id alone is not a key — a correct per-user check plus a global map 

270# keyed by a per-user id is still a cross-user leak, and it is invisible in 

271# either file on its own. See ADR-0009; ``routers/rag.py`` already keys 

272# ``_active_sse_indexers`` this way. 

273_subscriptions: dict[tuple[str, str], set[str]] = {} 

274# Authenticated socket sessions: sid -> username (resolved from session cookie at connect) 

275_sid_users: dict[str, str] = {} 

276 

277# sid -> the auth session_id that authorised the handshake. Tracked so 

278# logout can sever only the sockets of the session being logged out, 

279# leaving the user's other tabs and devices connected (#5535). Without 

280# this, single-session logout would either leave every socket alive or 

281# have to disconnect all of them. Populated in `connect` and popped in 

282# `disconnect`, under the same `_lock` as `_sid_users`. 

283_sid_sessions: dict[str, str] = {} 

284# Eagerly initialised in init_lock() during lifespan startup. We can't 

285# create asyncio.Lock at import time because that binds it to whatever 

286# loop (or no loop) is running at import — typically wrong, and on 

287# Python 3.12+ raises during pre-startup imports. Initialising once in 

288# the lifespan hook (after the running loop exists) gives us a single 

289# Lock instance bound to the right loop and avoids the previous lazy 

290# double-checked-locking race in _get_lock(): two concurrent first-time 

291# callers could each create a fresh Lock, with the second write 

292# stomping the first instance and orphaning its awaiters. 

293_lock: asyncio.Lock | None = None 

294 

295 

296def init_lock() -> None: 

297 """Initialise the module-level asyncio.Lock. Call from lifespan 

298 startup, after the event loop is running. Idempotent.""" 

299 global _lock 

300 if _lock is None: 

301 _lock = asyncio.Lock() 

302 

303 

304# Flag suppressing logging during an emit. Set by the log sink itself so an 

305# emit's own log lines cannot recurse back into the sink that triggered them. 

306# 

307# This MUST be a ContextVar, not a threading.local(). The emit no longer runs 

308# on the caller's thread: `emit_to_subscribers` schedules a coroutine onto the 

309# event loop via `run_coroutine_threadsafe`, so a thread-local set on the 

310# calling thread is invisible to the coroutine, which then reads the default 

311# (True) and logs anyway. Worse, the caller's `finally` restores the previous 

312# value before the coroutine has even run, so the window closed early too. 

313# 

314# Losing suppression is not cosmetic — it is unbounded amplification. The only 

315# caller that disables logging is `frontend_progress_sink`, a loguru sink: a 

316# failed emit logs, that record carries the inherited research_id, the sink 

317# fires again, emits again, fails again. A single research log line was 

318# measured producing 500+ emits before a hard cap tripped. 

319# 

320# A ContextVar is exactly right here: `run_coroutine_threadsafe` snapshots the 

321# calling context, so the value at scheduling time propagates INTO the 

322# coroutine, while the caller's own reset stays confined to the caller. It 

323# also keeps the per-thread isolation the threading.local was introduced for, 

324# since contexts are per-thread and per-task. 

325_logging_enabled_var: contextvars.ContextVar[bool] = contextvars.ContextVar( 

326 "socketio_logging_enabled", default=True 

327) 

328 

329 

330def _logging_is_enabled() -> bool: 

331 return _logging_enabled_var.get() 

332 

333 

334# uvicorn's main event loop, captured at app startup. Background threads 

335# (research workers, log queue, scheduler) need this to schedule emits via 

336# `asyncio.run_coroutine_threadsafe`. Without it, `asyncio.get_event_loop()` 

337# from a worker thread either raises (Python 3.12+) or creates a fresh loop 

338# that the AsyncServer doesn't know about, so emits silently no-op. 

339_main_loop: asyncio.AbstractEventLoop | None = None 

340 

341 

342def set_main_loop(loop: asyncio.AbstractEventLoop) -> None: 

343 """Capture the running event loop so background threads can dispatch to it.""" 

344 global _main_loop 

345 _main_loop = loop 

346 

347 

348def _get_main_loop() -> asyncio.AbstractEventLoop | None: 

349 """Return the captured main loop, or try to find it as a fallback.""" 

350 if _main_loop is not None and not _main_loop.is_closed(): 

351 return _main_loop 

352 # Last-resort fallback: try the current running loop (works only if 

353 # called from the main thread inside an async context). 

354 try: 

355 return asyncio.get_running_loop() 

356 except RuntimeError: 

357 return None 

358 

359 

360def _schedule_coroutine_threadsafe(coro, loop) -> None: 

361 """Hand *coro* to ``loop`` without leaking it on scheduling failure. 

362 

363 ``asyncio.run_coroutine_threadsafe`` owns and eventually closes the 

364 coroutine after it successfully queues the callback. During shutdown, 

365 however, ``loop.call_soon_threadsafe`` can raise synchronously after the 

366 caller has already created the coroutine. In that case ownership never 

367 transfers, so close it here before preserving the original exception for 

368 the public wrapper's best-effort error handling. 

369 """ 

370 try: 

371 asyncio.run_coroutine_threadsafe(coro, loop) 

372 except Exception: 

373 safe_close(coro, "rejected Socket.IO coroutine") 

374 raise 

375 

376 

377def _decode_session_cookie(cookie_header: str) -> dict | None: 

378 """Decode a Starlette SessionMiddleware cookie to extract session data. 

379 

380 Uses the same itsdangerous-based scheme Starlette uses, with the 

381 project's SECRET_KEY. Returns None on any failure (no session, bad 

382 signature, expired). 

383 """ 

384 if not cookie_header: 

385 return None 

386 try: 

387 from http.cookies import SimpleCookie 

388 import base64 

389 import json 

390 import itsdangerous 

391 

392 # Look up the session cookie value (same name as SessionMiddleware uses) 

393 jar = SimpleCookie() 

394 jar.load(cookie_header) 

395 morsel = jar.get("session") 

396 if morsel is None: 

397 return None 

398 cookie_value = morsel.value 

399 

400 from ..fastapi_app import SECRET_KEY 

401 from ...security import get_security_default 

402 

403 signer = itsdangerous.TimestampSigner(SECRET_KEY) 

404 # Must mirror SessionMiddleware's max_age so an expired/revoked 

405 # session cookie that the HTTP path would reject does not 

406 # authenticate a WebSocket connection forever. 

407 max_age_seconds = ( 

408 get_security_default("security.session_remember_me_days", 30) 

409 * 24 

410 * 3600 

411 ) 

412 unsigned = signer.unsign(cookie_value, max_age=max_age_seconds) 

413 return json.loads(base64.b64decode(unsigned)) 

414 except Exception: 

415 return None 

416 

417 

418@sio.event 

419async def connect(sid, environ, auth=None): 

420 """Handle client connection. 

421 

422 Parity with main's Flask-SocketIO handler: unauthenticated sockets 

423 are REJECTED at the handshake (returning False refuses the 

424 connection). Accepting them would hand every roomless broadcast — 

425 e.g. parallel_search_started, which carries the searching user's 

426 query text — to clients that never logged in. The JS client simply 

427 reconnects after login, exactly as it did under Flask. 

428 

429 The user's DB engine is lazily opened here (race vs first XHR after 

430 page load, server restart, idle eviction) so subscribe_to_research's 

431 ownership check works immediately after connect. 

432 """ 

433 cookie_header = environ.get("HTTP_COOKIE", "") 

434 session_data = _decode_session_cookie(cookie_header) or {} 

435 username = session_data.get("username") 

436 

437 if not username: 

438 logger.info(f"Rejected unauthenticated WebSocket connection from {sid}") 

439 return False 

440 

441 # The cookie's signature only proves it was issued by us, not that the 

442 # session behind it is still alive. Validate the session id against the 

443 # server-side store, exactly as require_auth does for HTTP. 

444 # 

445 # Without this a captured or stale cookie opens a BRAND NEW authenticated 

446 # socket after logout / password change / session expiry, and can then 

447 # subscribe to that user's live research events. The `is_user_connected` 

448 # check below is username-scoped, so it is true whenever ANY of the 

449 # user's sessions has the database open -- another device, or an 

450 # in-flight research run (logout deliberately leaves the DB open in that 

451 # case). That is a common condition, not an edge case. 

452 # 

453 # This is main's `__session_authorizes` connect-time gate (#5535), which 

454 # had no successor here: DatabaseMiddleware's `_enforce_session_revocation` 

455 # cannot cover it, because that middleware returns early for non-HTTP 

456 # scopes and additionally skips the "/ws/" prefix. 

457 from ..auth.session_manager import session_manager 

458 

459 handshake_session_id = session_data.get("session_id") 

460 if ( 

461 not handshake_session_id 

462 or session_manager.validate_session(handshake_session_id) != username 

463 ): 

464 logger.warning( 

465 f"Rejected WebSocket connection for {username} from {sid}: " 

466 "session is not valid server-side (logged out, expired, or " 

467 "revoked)" 

468 ) 

469 return False 

470 

471 from ...database.encrypted_db import db_manager 

472 from ...database.rekey_lock import RekeyOperationalError 

473 from ..dependencies.threadpool import run_db_sync 

474 

475 # The probe is not the microsecond dict lookup it once was: it routes 

476 # through _acquire_current_connection_generation, which takes the 

477 # per-user lifecycle lock and the cross-process rekey sidecar 

478 # (filesystem I/O, and a wait of up to a few seconds for another 

479 # holder). Run it through run_db_sync exactly like the 

480 # open_user_database call below, so a rekey in progress holds up only 

481 # this handshake's worker thread — not the event loop, which this 

482 # single-worker deployment shares with every user's sockets and async 

483 # routes. 

484 try: 

485 user_connected = await run_db_sync( 

486 db_manager.is_user_connected, username 

487 ) 

488 except RekeyOperationalError: 

489 # The probe already reports a sidecar that cannot be opened or 

490 # locked (fd exhaustion, EROFS, EACCES, ENOSPC), and an epoch read 

491 # that fails, as a plain False. A failure to release the sidecar 

492 # afterwards (unlock/close) raises RekeySidecarIOError instead: 

493 # degrade that to "not connected" too — ensure_user_database's 

494 # contract — so the handshake fails closed through the normal 

495 # no-connection path instead of raising out of connect(). Either 

496 # way, while an open/lock or epoch-read fault hides a cached 

497 # engine, the lazy open below raises that fault before deriving a 

498 # key, so the refused handshake costs no PBKDF2 derivation. 

499 user_connected = False 

500 

501 if not user_connected: 

502 from ...database.session_passwords import session_password_store 

503 

504 session_id = session_data.get("session_id") 

505 password = ( 

506 session_password_store.get_session_password(username, session_id) 

507 if session_id 

508 else None 

509 ) 

510 if not password: 

511 logger.info( 

512 f"Rejected WebSocket connection for {username}: " 

513 "no active DB session and no stored password" 

514 ) 

515 return False 

516 try: 

517 # SQLCipher open is sync I/O — keep it off the event loop. 

518 # 

519 # Use run_db_sync, not bare asyncio.to_thread: a cold 

520 # open_user_database() call runs the phase-2 rekey path 

521 # (_run_phase2_rekey -> rekey_user_indexes), which opens a 

522 # get_user_db_session() unconditionally, before its own marker 

523 # check (vector_stores/legacy_rekey.py). That leaves a thread-local 

524 # session on whichever asyncio default-executor thread served 

525 # this call, with no cleanup boundary. run_db_sync runs the sync 

526 # call on that same default executor, then cleans up the 

527 # thread-local DB session (and other ambient thread-local 

528 # state) on that worker before returning -- same shape as the 

529 # equivalent fix at fastapi_app.py's DatabaseMiddleware 

530 # (ensure_user_database). 

531 await run_db_sync(db_manager.open_user_database, username, password) 

532 except Exception as e: 

533 # Capture the type name only and log OUTSIDE the handler: 

534 # logger.exception's diagnose=True traceback would render the 

535 # `password` local, so the traceback must never be logged here. 

536 open_failure = type(e).__name__ 

537 else: 

538 open_failure = None 

539 if open_failure is not None: 

540 logger.error( 

541 f"Lazy DB open failed for {username} at WebSocket " 

542 f"connect: {open_failure}" 

543 ) 

544 return False 

545 

546 async with _lock: 

547 _sid_users[sid] = username 

548 # Remember which auth session authorised this socket so logout can 

549 # sever exactly this session's sockets (#5535). Always present here: 

550 # the connect gate above rejects a cookie without a valid session id. 

551 _sid_sessions[sid] = handshake_session_id 

552 

553 # Close the connect-vs-teardown race (main #5572, ported: main fixed this 

554 # in web/services/socket_service.py, which this branch replaced, so the 

555 # fix had no successor here and would otherwise have been lost). 

556 # 

557 # The gate above and this registration are not atomic with a concurrent 

558 # logout or session expiry. `disconnect_session` severs a session's 

559 # sockets by enumerating `_sid_sessions`; if that enumeration ran BEFORE 

560 # the block above added this sid, the teardown never sees this socket. It 

561 # would then sit registered with a dead session, still receiving that 

562 # user's events -- including `settings_changed` -- until some other 

563 # session ends. 

564 # 

565 # Re-validate after registering and fail closed. Returning False rejects 

566 # the handshake, so python-socketio never fires our `disconnect` handler 

567 # for this sid; the dict entries have to be removed here explicitly. 

568 # 

569 # This is correct only because teardown invalidates the session BEFORE 

570 # enumerating sockets -- logout destroys the session first, then 

571 # disconnects -- so a socket that registers after invalidation is 

572 # guaranteed to observe the failed re-check. 

573 if session_manager.validate_session(handshake_session_id) != username: 

574 async with _lock: 

575 _sid_users.pop(sid, None) 

576 _sid_sessions.pop(sid, None) 

577 logger.warning( 

578 f"WebSocket connect/teardown race for {username}: session was " 

579 f"invalidated while socket {sid} was registering; rejecting it" 

580 ) 

581 return False 

582 

583 logger.info(f"Client connected: {sid} (user={username})") 

584 return True 

585 

586 

587@sio.event 

588async def disconnect(sid): 

589 """Handle client disconnection. 

590 

591 The whole body is guarded, as main's ``__handle_disconnect`` was. 

592 ``AsyncServer._trigger_event`` does not swallow handler exceptions and 

593 ``_handle_disconnect`` calls ``manager.disconnect(sid, ...)`` -- which 

594 removes the sid from engine.io's own room bookkeeping -- only *after* 

595 this handler returns. So a raise from here would leave engine.io still 

596 holding the sid while our maps are half-swept, which is the opposite of 

597 what a disconnect is for. 

598 """ 

599 logger.info(f"Client {sid} disconnected") 

600 try: 

601 # Clean up subscriptions and identity for this client 

602 async with _lock: 

603 _sid_users.pop(sid, None) 

604 _sid_sessions.pop(sid, None) 

605 empty_keys = [] 

606 # Keys are (username, research_id) tuples; a disconnecting sid is 

607 # dropped from every subscription set regardless of owner. 

608 for sub_key, sids in _subscriptions.items(): 

609 sids.discard(sid) 

610 if not sids: 

611 empty_keys.append(sub_key) 

612 for key in empty_keys: 

613 del _subscriptions[key] 

614 

615 # Clean up thread-local database sessions 

616 try: 

617 from ...database.thread_local_session import ( 

618 cleanup_current_thread, 

619 ) 

620 

621 cleanup_current_thread() 

622 except Exception: 

623 logger.debug("Error cleaning up thread session on disconnect") 

624 except Exception: 

625 # logger.exception already attaches the traceback; interpolating the 

626 # exception as well is redundant and the repo's custom-code check 

627 # rejects it. 

628 logger.exception(f"Error handling disconnect for {sid}") 

629 

630 

631def _owns_research_sync(username: str, research_id: str) -> bool: 

632 """Query the user's encrypted DB for research/benchmark ownership. 

633 

634 Synchronous (SQLAlchemy) — must be called via ``_user_owns_research`` 

635 (which offloads to a thread), never directly on the event loop. 

636 Shared by on_subscribe and on_unsubscribe so both handlers apply the 

637 exact same ownership rule. 

638 """ 

639 from ...database.models import ResearchHistory 

640 from ...database.session_context import get_user_db_session 

641 

642 with get_user_db_session(username) as db_session: 

643 if ( 

644 db_session.query(ResearchHistory).filter_by(id=research_id).first() 

645 is not None 

646 ): 

647 return True 

648 # The benchmark page subscribes with a numeric BenchmarkRun.id, 

649 # which has no matching ResearchHistory row. Without this branch 

650 # benchmark live-progress subscriptions are silently rejected. 

651 if str(research_id).isdigit(): 

652 from ...database.models.benchmark import BenchmarkRun 

653 

654 return ( 

655 db_session.query(BenchmarkRun.id) 

656 .filter(BenchmarkRun.id == research_id) 

657 .first() 

658 is not None 

659 ) 

660 return False 

661 

662 

663def _subscription_key(owner: str, research_id) -> tuple: 

664 """Key under which ``research_id``'s subscriptions are stored. 

665 

666 Ported from main's ``SocketIOService.__subscription_key`` (#5600) while 

667 merging that security fix in. This branch already keyed subscriptions by 

668 ``(owner, research_id)`` -- and more strictly than main, which composites 

669 only numeric ids -- but it keyed on the RAW id, and main's version also 

670 normalizes a numeric id to ``int``. 

671 

672 That normalization is load-bearing: subscribe-time ids arrive from a JSON 

673 socket payload (so a benchmark run arrives as ``"1"``) while emit-time ids 

674 come from the database (``1``). Keyed raw, those are two different dict 

675 keys, and the event is silently never delivered to a subscriber who is 

676 correctly subscribed. ``isdigit()`` guarantees the ``int()`` is safe. 

677 

678 UUID research ids are left exactly as they are. 

679 """ 

680 if str(research_id).isdigit(): 

681 return (owner, int(research_id)) 

682 return (owner, research_id) 

683 

684 

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

686 """Return True if `username` owns `research_id` (research or benchmark). 

687 

688 Offloaded to a thread — SQLAlchemy is sync and must not block the 

689 uvicorn event loop that's serving every other WebSocket + HTTP 

690 request. Used by both on_subscribe and on_unsubscribe as the shared 

691 authorization boundary for the per-research subscription map. 

692 """ 

693 try: 

694 # Use run_db_sync — _owns_research_sync opens get_user_db_session, 

695 # so the worker's thread-local DB session needs cleanup before 

696 # the next task lands on the same worker. 

697 from ..dependencies.threadpool import run_db_sync 

698 

699 return await run_db_sync(_owns_research_sync, username, research_id) 

700 except Exception: 

701 logger.exception( 

702 "Ownership check failed for user={} rid={}", 

703 username, 

704 _research_id_preview(research_id), 

705 ) 

706 return False 

707 

708 

709async def _socket_session_still_valid( 

710 sid: str, username: str 

711) -> tuple[str | None, bool]: 

712 """Re-check that this socket's originating session is still alive. 

713 

714 Returns ``(session_id, is_valid)``. The id comes back so the caller can 

715 tear down EVERY socket of a dead session, not just the one that asked -- 

716 see the rejection path in ``on_subscribe``. 

717 

718 The connect-time gate is not sufficient on its own: identity is captured 

719 into ``_sid_users`` at handshake and then frozen for the socket's whole 

720 lifetime. A session that expires, is logged out, or has its password 

721 changed WHILE the socket is open leaves that socket able to keep acting -- 

722 including subscribing to research it had not subscribed to before. 

723 

724 Logout and password change disconnect sockets actively. Idle expiry has a 

725 bounded revocation-latency window until the periodic sweep (5 minutes in 

726 production). Revalidating on subscribe and unsubscribe closes that window 

727 whenever the socket next performs either action; the sweep remains the 

728 fallback for an otherwise idle socket. 

729 

730 Fails closed: no recorded session id means the socket predates the gate, 

731 which is not a state a current client can reach. 

732 """ 

733 async with _lock: 

734 session_id = _sid_sessions.get(sid) 

735 if not session_id: 

736 return None, False 

737 

738 from ..auth.session_manager import session_manager 

739 

740 return session_id, session_manager.validate_session(session_id) == username 

741 

742 

743@sio.on("subscribe_to_research") 

744async def on_subscribe(sid, data): 

745 """Handle client subscription to research updates. 

746 

747 Verifies the requesting socket is authenticated AND that the 

748 requested research_id belongs to that user. Without this check 

749 any logged-in user could spy on any other user's research progress 

750 in real time by guessing/enumerating UUIDs. 

751 """ 

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

753 if not research_id: 

754 return 

755 

756 async with _lock: 

757 username = _sid_users.get(sid) 

758 

759 if not username: 

760 logger.warning( 

761 "Rejected subscribe from unauthenticated sid {} for {}", 

762 sid, 

763 _research_id_preview(research_id), 

764 ) 

765 await sio.emit( 

766 "subscribe_error", 

767 {"error": "Authentication required", "research_id": research_id}, 

768 room=sid, 

769 ) 

770 return 

771 

772 stale_session_id, session_ok = await _socket_session_still_valid( 

773 sid, username 

774 ) 

775 if not session_ok: 

776 logger.warning( 

777 f"Rejected subscribe from {sid} ({username}): originating session " 

778 "is no longer valid; disconnecting that session's sockets" 

779 ) 

780 try: 

781 await sio.emit( 

782 "subscribe_error", 

783 {"error": "Session expired", "research_id": research_id}, 

784 room=sid, 

785 ) 

786 except Exception: 

787 # Revocation is the security boundary; the explanatory frame is 

788 # best-effort. A broken transport must not strand this socket (or 

789 # its siblings) after session validation has already failed. 

790 if _logging_is_enabled(): 790 ↛ 803line 790 didn't jump to line 803 because the condition on line 790 was always true

791 logger.debug(f"Error notifying stale socket {sid}") 

792 # Sever EVERY socket of that session, not just this one. 

793 # 

794 # `validate_session` DELETES an expired session as a side effect of 

795 # checking it, so by the time we get here the session is already gone 

796 # from the store. Disconnecting only the calling sid would leave a 

797 # sibling socket (a second tab on the same session) connected AND 

798 # permanently un-revocable: the periodic idle sweep can never find 

799 # that session again to tear it down, so the sibling keeps receiving 

800 # the user's events -- including `settings_changed`, which carries 

801 # plaintext secrets -- indefinitely. Main revoked per-session for 

802 # exactly this reason. 

803 if stale_session_id: 

804 disconnect_session(stale_session_id) 

805 else: 

806 try: 

807 await sio.disconnect(sid) 

808 except Exception: 

809 if _logging_is_enabled(): 809 ↛ 811line 809 didn't jump to line 811 because the condition on line 809 was always true

810 logger.debug(f"Error disconnecting stale socket {sid}") 

811 return 

812 

813 # Verify ownership: the research must exist in this user's encrypted DB. 

814 owns = await _user_owns_research(username, research_id) 

815 

816 if not owns: 

817 logger.warning( 

818 "Rejected subscribe: user {} does not own research {}", 

819 username, 

820 _research_id_preview(research_id), 

821 ) 

822 await sio.emit( 

823 "subscribe_error", 

824 {"error": "Not authorized", "research_id": research_id}, 

825 room=sid, 

826 ) 

827 return 

828 

829 async with _lock: 

830 key = _subscription_key(username, research_id) 

831 if key not in _subscriptions: 831 ↛ 833line 831 didn't jump to line 833 because the condition on line 831 was always true

832 _subscriptions[key] = set() 

833 _subscriptions[key].add(sid) 

834 

835 logger.info( 

836 f"Client {sid} (user={username}) subscribed to research {research_id}" 

837 ) 

838 

839 # Send current status immediately if available 

840 snapshot = get_active_research_snapshot(research_id) 

841 if snapshot is not None: 

842 progress = snapshot["progress"] 

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

844 

845 if latest_log: 

846 await sio.emit( 

847 f"research_progress_{research_id}", 

848 { 

849 "progress": progress, 

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

851 "status": ResearchStatus.IN_PROGRESS, 

852 "log_entry": latest_log, 

853 }, 

854 room=sid, 

855 ) 

856 

857 

858@sio.on("unsubscribe_from_research") 

859async def on_unsubscribe(sid, data): 

860 """Handle client unsubscribe from research updates. 

861 

862 Applies the same ownership gate as on_subscribe before mutating the 

863 per-research subscription set. This restores authorization-boundary 

864 consistency with on_subscribe (matching main's own rationale for the 

865 check) — it is not closing an active exploit: ``subs.discard(sid)`` 

866 can only ever remove the CALLER's own sid, so without this gate an 

867 unauthorized unsubscribe attempt against another user's research_id 

868 was already a silent no-op (that sid was never a member of the set), 

869 and the victim's subscription was never affected. 

870 

871 Also re-validates the originating session, same as on_subscribe. This 

872 reduces idle-expiry revocation latency whenever a socket next sends an 

873 unsubscribe: an expired session is severed immediately instead of waiting 

874 for the bounded periodic sweep. An otherwise idle socket is still handled 

875 by that sweep. 

876 """ 

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

878 if not research_id: 

879 return 

880 

881 async with _lock: 

882 username = _sid_users.get(sid) 

883 

884 if not username: 

885 logger.info( 

886 "Rejected unsubscribe from unauthenticated sid {} for {}", 

887 sid, 

888 _research_id_preview(research_id), 

889 ) 

890 return 

891 

892 stale_session_id, session_ok = await _socket_session_still_valid( 

893 sid, username 

894 ) 

895 if not session_ok: 

896 logger.warning( 

897 f"Rejected unsubscribe from {sid} ({username}): originating " 

898 "session is no longer valid; disconnecting that session's sockets" 

899 ) 

900 # Same rationale as the rejection branch in on_subscribe: sever 

901 # EVERY socket of that session, not just this one. validate_session 

902 # already deleted the session from the store as a side effect of 

903 # checking it, so a sibling socket left connected would be 

904 # permanently un-revocable -- the idle sweep can never find that 

905 # session again to tear it down. 

906 if stale_session_id: 

907 disconnect_session(stale_session_id) 

908 else: 

909 try: 

910 await sio.disconnect(sid) 

911 except Exception: 

912 if _logging_is_enabled(): 912 ↛ 914line 912 didn't jump to line 914 because the condition on line 912 was always true

913 logger.debug(f"Error disconnecting stale socket {sid}") 

914 return 

915 

916 if not await _user_owns_research(username, research_id): 

917 logger.info( 

918 "Rejected unsubscribe from sid {}: user does not own research {}", 

919 sid, 

920 _research_id_preview(research_id), 

921 ) 

922 return 

923 

924 async with _lock: 

925 key = _subscription_key(username, research_id) 

926 subs = _subscriptions.get(key) 

927 if subs: 

928 subs.discard(sid) 

929 if not subs: 

930 _subscriptions.pop(key, None) 

931 

932 

933def emit_socket_event(event: str, data: Any, room: str | None = None) -> bool: 

934 """Emit a socket event (sync wrapper for use from background threads). 

935 

936 Schedules the emit on uvicorn's main event loop via 

937 `asyncio.run_coroutine_threadsafe`. Without the captured loop, emits 

938 from worker threads silently no-op because `asyncio.get_event_loop()` 

939 in a non-main thread on Python 3.12+ either raises or creates a new 

940 isolated loop. 

941 """ 

942 loop = _get_main_loop() 

943 if loop is None or not loop.is_running(): 

944 if _logging_is_enabled(): 944 ↛ 946line 944 didn't jump to line 946 because the condition on line 944 was always true

945 logger.debug(f"Cannot emit {event}: main event loop not available") 

946 return False 

947 try: 

948 _schedule_coroutine_threadsafe(_async_emit(event, data, room), loop) 

949 return True 

950 except Exception: 

951 if _logging_is_enabled(): 951 ↛ 953line 951 didn't jump to line 953 because the condition on line 951 was always true

952 logger.debug(f"Error emitting socket event {event}") 

953 return False 

954 

955 

956def emit_to_user(event: str, username: str, data: Any) -> bool: 

957 """Emit a socket event to all sockets authenticated as `username`. 

958 

959 Prevents cross-user leaks for events like settings_changed — each 

960 user's sockets get the event, nobody else's do. 

961 """ 

962 loop = _get_main_loop() 

963 if loop is None or not loop.is_running(): 

964 if _logging_is_enabled(): 964 ↛ 966line 964 didn't jump to line 966 because the condition on line 964 was always true

965 logger.debug(f"Cannot emit {event}: main event loop not available") 

966 return False 

967 

968 async def _emit() -> None: 

969 # Snapshot sids for this user under the lock — connect/disconnect 

970 # mutate _sid_users on this same event loop and could otherwise 

971 # interleave at any await point, raising 

972 # ``RuntimeError: dictionary changed size during iteration``. 

973 async with _lock: 

974 sids = [sid for sid, u in _sid_users.items() if u == username] 

975 for sid in sids: 

976 try: 

977 await sio.emit(event, data, room=sid) 

978 except Exception: 

979 if _logging_is_enabled(): 979 ↛ 975line 979 didn't jump to line 975 because the condition on line 979 was always true

980 logger.debug(f"Error emitting {event} to {sid}") 

981 

982 try: 

983 _schedule_coroutine_threadsafe(_emit(), loop) 

984 return True 

985 except Exception: 

986 if _logging_is_enabled(): 986 ↛ 988line 986 didn't jump to line 988 because the condition on line 986 was always true

987 logger.debug(f"Error emitting {event} to user {username}") 

988 return False 

989 

990 

991def disconnect_user(username: str) -> bool: 

992 """Disconnect every socket authenticated as ``username``. 

993 

994 A socket is authorised once, at handshake, and never re-checked. So 

995 when a user's session ends — logout, password change, or the idle 

996 sweep closing their database because they have no session left — any 

997 socket they still hold keeps receiving their events indefinitely. This 

998 severs them. 

999 

1000 Port of ``SocketIOService.disconnect_user`` (#5535), which grouped 

1001 sockets with Socket.IO rooms. This layer already tracks identity 

1002 directly in ``_sid_users``, so it filters that instead of maintaining 

1003 a parallel per-user room; the snapshot is taken under ``_lock`` for 

1004 the same reason ``emit_to_user`` takes it — connect/disconnect mutate 

1005 ``_sid_users`` on this event loop and would otherwise interleave at an 

1006 await point. 

1007 

1008 Dropping subscriptions is handled for free: ``sio.disconnect(sid)`` 

1009 fires the ``disconnect`` handler above, which removes the sid from 

1010 ``_sid_users`` and from every entry in ``_subscriptions``. 

1011 

1012 Best-effort and non-blocking, like the emit helpers: schedules onto 

1013 the main loop and returns whether it could be scheduled, not whether 

1014 the sockets are gone. Callers are teardown paths that must not block 

1015 or raise. 

1016 """ 

1017 return _disconnect_matching( 

1018 lambda: [sid for sid, u in _sid_users.items() if u == username], 

1019 f"user {username}", 

1020 ) 

1021 

1022 

1023def disconnect_session(session_id: str) -> bool: 

1024 """Disconnect only the sockets authorised by ``session_id``. 

1025 

1026 Single-session teardown for logout: the tab being logged out loses its 

1027 sockets while the user's other still-valid sessions keep theirs. Port 

1028 of ``SocketIOService.disconnect_session`` (#5535), which used a 

1029 per-session Socket.IO room; this layer matches on the handshake 

1030 session_id recorded in ``_sid_sessions`` instead. 

1031 

1032 Use ``disconnect_user`` for the all-sessions cases — password change 

1033 and the idle sweep — where every session is destroyed anyway. 

1034 """ 

1035 return _disconnect_matching( 

1036 lambda: [sid for sid, s in _sid_sessions.items() if s == session_id], 

1037 f"session {session_id[:8]}...", 

1038 ) 

1039 

1040 

1041def _disconnect_matching(select_sids, description: str) -> bool: 

1042 """Disconnect every sid returned by ``select_sids``. 

1043 

1044 ``select_sids`` is called while holding ``_lock`` — connect/disconnect 

1045 mutate the sid maps on this event loop, so the selection must be a 

1046 snapshot taken under the lock rather than a live view, or iteration can 

1047 raise ``RuntimeError: dictionary changed size during iteration``. 

1048 """ 

1049 loop = _get_main_loop() 

1050 if loop is None or not loop.is_running(): 

1051 if _logging_is_enabled(): 1051 ↛ 1056line 1051 didn't jump to line 1056 because the condition on line 1051 was always true

1052 logger.debug( 

1053 f"Cannot disconnect sockets for {description}: " 

1054 "main event loop not available" 

1055 ) 

1056 return False 

1057 

1058 async def _disconnect() -> None: 

1059 async with _lock: 

1060 sids = select_sids() 

1061 for sid in sids: 

1062 try: 

1063 await sio.disconnect(sid) 

1064 except Exception: 

1065 if _logging_is_enabled(): 1065 ↛ 1061line 1065 didn't jump to line 1061 because the condition on line 1065 was always true

1066 logger.debug(f"Error disconnecting {sid}") 

1067 if sids: 

1068 logger.info(f"Disconnected {len(sids)} socket(s) for {description}") 

1069 

1070 try: 

1071 _schedule_coroutine_threadsafe(_disconnect(), loop) 

1072 return True 

1073 except Exception: 

1074 if _logging_is_enabled(): 1074 ↛ 1076line 1074 didn't jump to line 1076 because the condition on line 1074 was always true

1075 logger.debug(f"Error disconnecting sockets for {description}") 

1076 return False 

1077 

1078 

1079def emit_to_subscribers( 

1080 event_base: str, 

1081 research_id: str, 

1082 data: Any, 

1083 *, 

1084 owner: str, 

1085 enable_logging: bool = True, 

1086) -> bool: 

1087 """Emit an event to all subscribers of a specific research. 

1088 

1089 Sync wrapper for use from background threads. 

1090 

1091 ``owner`` is the username whose research this is, and is REQUIRED and 

1092 keyword-only on purpose: subscriptions are keyed by ``(owner, 

1093 research_id)`` because a benchmark id is only unique within one user's 

1094 database (see ``_subscriptions``). Making it required means a call site 

1095 that forgets it fails loudly at the call rather than silently reverting 

1096 to the id-only lookup that leaked one user's progress to another. 

1097 

1098 `enable_logging` is carried by a ContextVar so concurrent emits from 

1099 different worker threads don't corrupt each other's suppression windows 

1100 (as a module global did), AND so the suppression reaches the coroutine 

1101 scheduled onto the event loop below — a threading.local could not, since 

1102 that coroutine runs on a different thread. See `_logging_enabled_var`. 

1103 """ 

1104 token = None 

1105 if not enable_logging: 

1106 token = _logging_enabled_var.set(False) 

1107 

1108 try: 

1109 loop = _get_main_loop() 

1110 if loop is None or not loop.is_running(): 

1111 if _logging_is_enabled(): 

1112 logger.debug( 

1113 f"Cannot emit subscribers for {research_id}: loop unavailable" 

1114 ) 

1115 return False 

1116 try: 

1117 _schedule_coroutine_threadsafe( 

1118 _async_emit_to_subscribers( 

1119 event_base, research_id, data, owner 

1120 ), 

1121 loop, 

1122 ) 

1123 return True 

1124 except Exception: 

1125 if _logging_is_enabled(): 

1126 logger.debug( 

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

1128 ) 

1129 return False 

1130 finally: 

1131 # Reset only this caller's context. The coroutine scheduled above got 

1132 # its own snapshot taken while the flag was still False, so it keeps 

1133 # the suppression it needs regardless of this reset. 

1134 if token is not None: 

1135 _logging_enabled_var.reset(token) 

1136 

1137 

1138async def _async_emit(event: str, data: Any, room: str | None = None) -> None: 

1139 """Async emit implementation.""" 

1140 try: 

1141 if room: 

1142 await sio.emit(event, data, room=room) 

1143 else: 

1144 await sio.emit(event, data) 

1145 except Exception: 

1146 # The concurrent Future returned by run_coroutine_threadsafe is 

1147 # intentionally discarded by the synchronous wrapper, so an 

1148 # exception escaping this coroutine would otherwise be silent. 

1149 if _logging_is_enabled(): 1149 ↛ exitline 1149 didn't return from function '_async_emit' because the condition on line 1149 was always true

1150 logger.debug(f"Error emitting socket event {event}") 

1151 

1152 

1153async def _async_emit_to_subscribers( 

1154 event_base: str, research_id: str, data: Any, owner: str 

1155) -> None: 

1156 """Async emit to subscribers implementation. 

1157 

1158 Looks up ``(owner, research_id)``. An unknown or wrong owner simply 

1159 matches no entry and the event is dropped — the map cannot be reached 

1160 with an id alone, so a caller that loses track of whose research it is 

1161 emitting delivers nothing rather than delivering to everyone. 

1162 """ 

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

1164 

1165 async with _lock: 

1166 subscriptions = _subscriptions.get( 

1167 _subscription_key(owner, research_id) 

1168 ) 

1169 if subscriptions: 

1170 subscriptions = subscriptions.copy() 

1171 else: 

1172 subscriptions = None 

1173 

1174 if subscriptions is not None: 

1175 from ..auth.session_manager import session_manager 

1176 

1177 for sid in subscriptions: 

1178 try: 

1179 await sio.emit(full_event, data, room=sid) 

1180 except Exception: 

1181 if _logging_is_enabled(): 

1182 logger.debug(f"Error emitting to subscriber {sid}") 

1183 

1184 # Watching a run over the socket IS activity. Without this, a 

1185 # user who starts a long research and then just watches the 

1186 # progress UI issues no HTTP requests, so nothing refreshes 

1187 # their idle timer and the session is reaped mid-run -- the 

1188 # idle sweep then disconnects the socket too, so the run 

1189 # appears to die in front of them. 

1190 # 

1191 # This is the second half of main's #5535 (778688295). The 

1192 # disconnect_user/disconnect_session half was ported; this half 

1193 # was not, which is why touch_session had zero call sites here. 

1194 # Own try/except, as main has, so a refresh failure cannot 

1195 # strand delivery to the remaining subscribers. 

1196 try: 

1197 sid_session = _sid_sessions.get(sid) 

1198 if sid_session: 

1199 session_manager.touch_session(sid_session) 

1200 except Exception: 

1201 if _logging_is_enabled(): 1201 ↛ 1177line 1201 didn't jump to line 1177 because the condition on line 1201 was always true

1202 logger.debug(f"Error refreshing session for {sid}") 

1203 # No subscribers: drop the event. We must NOT broadcast — that would 

1204 # leak one user's research progress to every connected client. 

1205 

1206 

1207def remove_subscriptions_for_research(research_id: str, owner: str) -> None: 

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

1209 

1210 ``owner`` scopes the removal to the user whose research finished, for 

1211 the same reason subscriptions are keyed that way: without it, one 

1212 user's benchmark finishing would tear down every other user's 

1213 subscription to their own run of the same numeric id. 

1214 

1215 Sync function safe to call from any thread. Schedules the async 

1216 cleanup on the main loop. If the loop is not yet available (very 

1217 early startup) or has been closed (shutdown), the cleanup is 

1218 silently dropped — disconnect handlers will reap stale entries on 

1219 their own, so the worst case is a small temporary leak that does 

1220 not race with the async path. 

1221 """ 

1222 loop = _get_main_loop() 

1223 if loop is None or not loop.is_running(): 

1224 logger.debug( 

1225 f"Skipping subscription cleanup for {research_id}: loop unavailable" 

1226 ) 

1227 return 

1228 try: 

1229 _schedule_coroutine_threadsafe( 

1230 _async_remove_subscriptions(research_id, owner), loop 

1231 ) 

1232 except Exception: 

1233 logger.debug( 

1234 f"Async cleanup scheduling failed for {research_id}", exc_info=True 

1235 ) 

1236 

1237 

1238async def _async_remove_subscriptions(research_id: str, owner: str) -> None: 

1239 """Async subscription removal, scoped to the research's owner.""" 

1240 async with _lock: 

1241 removed = _subscriptions.pop( 

1242 _subscription_key(owner, research_id), None 

1243 ) 

1244 if removed is not None: 

1245 logger.info( 

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

1247 )