7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
330 lines
10 KiB
Python
330 lines
10 KiB
Python
"""
|
|
Tests for SocketIOService concurrency and edge cases.
|
|
|
|
Covers: emit_to_subscribers snapshot safety, logging restoration,
|
|
empty subscriptions, mixed success/failure, remove_subscriptions,
|
|
and emit_socket_event modes.
|
|
|
|
Source: socket_service.py lines 129-216.
|
|
"""
|
|
|
|
from unittest.mock import patch, Mock
|
|
|
|
|
|
class MockSocketIO:
|
|
"""Mock SocketIO that records emitted events."""
|
|
|
|
def __init__(self, app=None, **kwargs):
|
|
self.emitted_events = []
|
|
self._handlers = {}
|
|
|
|
def emit(self, event, data, room=None):
|
|
self.emitted_events.append({"event": event, "data": data, "room": room})
|
|
|
|
def on(self, event):
|
|
def decorator(f):
|
|
self._handlers[event] = f
|
|
return f
|
|
|
|
return decorator
|
|
|
|
@property
|
|
def on_error(self):
|
|
def decorator(f):
|
|
self._handlers["error"] = f
|
|
return f
|
|
|
|
return decorator
|
|
|
|
@property
|
|
def on_error_default(self):
|
|
def decorator(f):
|
|
self._handlers["error_default"] = f
|
|
return f
|
|
|
|
return decorator
|
|
|
|
def run(self, app, **kwargs):
|
|
pass
|
|
|
|
|
|
def _make_service():
|
|
"""Create a fresh SocketIOService with mocked dependencies."""
|
|
from local_deep_research.web.services.socket_service import SocketIOService
|
|
|
|
# Reset singleton
|
|
SocketIOService._instance = None
|
|
|
|
try:
|
|
mock_app = Mock()
|
|
mock_app.config = {}
|
|
mock_app.debug = False
|
|
|
|
with (
|
|
patch(
|
|
"local_deep_research.web.services.socket_service.SocketIO",
|
|
MockSocketIO,
|
|
),
|
|
patch(
|
|
"local_deep_research.settings.env_registry.get_env_setting",
|
|
return_value=None,
|
|
),
|
|
):
|
|
service = SocketIOService(app=mock_app)
|
|
return service
|
|
finally:
|
|
# Restore singleton after each test via cleanup
|
|
pass
|
|
|
|
|
|
def _cleanup_singleton(original):
|
|
from local_deep_research.web.services.socket_service import SocketIOService
|
|
|
|
SocketIOService._instance = original
|
|
|
|
|
|
class TestEmitToSubscribersConcurrency:
|
|
"""Tests for emit_to_subscribers concurrency edge cases."""
|
|
|
|
def setup_method(self):
|
|
from local_deep_research.web.services.socket_service import (
|
|
SocketIOService,
|
|
)
|
|
|
|
self._original = SocketIOService._instance
|
|
|
|
def teardown_method(self):
|
|
_cleanup_singleton(self._original)
|
|
|
|
def test_snapshot_prevents_runtime_error_during_iteration(self):
|
|
"""Modifying subscriptions during emit doesn't cause RuntimeError."""
|
|
service = _make_service()
|
|
research_id = "r1"
|
|
|
|
# Add subscriptions
|
|
with service._SocketIOService__lock:
|
|
service._SocketIOService__socket_subscriptions[research_id] = {
|
|
"sid1",
|
|
"sid2",
|
|
"sid3",
|
|
}
|
|
|
|
# Mock emit to modify subscriptions mid-iteration
|
|
original_emit = service._SocketIOService__socketio.emit
|
|
call_count = 0
|
|
|
|
def emit_and_modify(event, data, room=None):
|
|
nonlocal call_count
|
|
call_count += 1
|
|
if call_count == 1:
|
|
# Modify the original set during iteration
|
|
with service._SocketIOService__lock:
|
|
service._SocketIOService__socket_subscriptions[
|
|
research_id
|
|
].add("sid4")
|
|
service._SocketIOService__socket_subscriptions[
|
|
research_id
|
|
].discard("sid3")
|
|
original_emit(event, data, room=room)
|
|
|
|
service._SocketIOService__socketio.emit = emit_and_modify
|
|
|
|
# Should not raise RuntimeError
|
|
result = service.emit_to_subscribers(
|
|
"event", research_id, {"key": "val"}
|
|
)
|
|
assert result is True
|
|
|
|
def test_logging_re_enabled_after_subscriber_exception(self):
|
|
"""__logging_enabled is restored to True even when subscriber emit raises."""
|
|
service = _make_service()
|
|
research_id = "r2"
|
|
|
|
with service._SocketIOService__lock:
|
|
service._SocketIOService__socket_subscriptions[research_id] = {
|
|
"sid1"
|
|
}
|
|
|
|
# Make emit raise — caught by inner per-subscriber try/except
|
|
def raise_on_emit(event, data, room=None):
|
|
raise ConnectionError("socket broken")
|
|
|
|
service._SocketIOService__socketio.emit = raise_on_emit
|
|
|
|
result = service.emit_to_subscribers(
|
|
"event", research_id, {}, enable_logging=False
|
|
)
|
|
|
|
# logging should be re-enabled via finally block
|
|
assert service._SocketIOService__logging_enabled is True
|
|
# Inner exception is caught, outer try returns True
|
|
assert result is True
|
|
|
|
def test_logging_re_enabled_after_outer_exception(self):
|
|
"""__logging_enabled is restored even if the outer try block fails."""
|
|
service = _make_service()
|
|
|
|
# Force an exception in the outer try block by making the lock
|
|
# context manager's __enter__ raise after the first call
|
|
original_lock = service._SocketIOService__lock
|
|
|
|
class ExplodingLock:
|
|
def __enter__(self):
|
|
raise RuntimeError("lock exploded")
|
|
|
|
def __exit__(self, *args):
|
|
pass
|
|
|
|
service._SocketIOService__lock = ExplodingLock()
|
|
|
|
result = service.emit_to_subscribers(
|
|
"event", "r_outer", {}, enable_logging=False
|
|
)
|
|
|
|
# Restore lock for cleanup
|
|
service._SocketIOService__lock = original_lock
|
|
|
|
# logging should still be re-enabled via finally
|
|
assert service._SocketIOService__logging_enabled is True
|
|
assert result is False
|
|
|
|
def test_empty_subscription_set_drops_event(self):
|
|
"""With an empty subscriber set the event is dropped, not broadcast.
|
|
|
|
Cross-user broadcast on empty subscriptions was a cross-user leak —
|
|
see emit_to_subscribers in socket_service.py. The
|
|
catch-up snapshot in __handle_subscribe replays the latest
|
|
progress on subscribe so events that arrive before any client
|
|
subscribes are recovered correctly.
|
|
"""
|
|
service = _make_service()
|
|
research_id = "r3"
|
|
|
|
with service._SocketIOService__lock:
|
|
service._SocketIOService__socket_subscriptions[research_id] = set()
|
|
|
|
result = service.emit_to_subscribers("event", research_id, {"data": 1})
|
|
assert result is True # success: dropping is intentional, not failure
|
|
|
|
events = service._SocketIOService__socketio.emitted_events
|
|
assert events == []
|
|
|
|
def test_one_subscriber_fails_others_succeed(self):
|
|
"""If one subscriber's emit raises, others still receive the event."""
|
|
service = _make_service()
|
|
research_id = "r4"
|
|
|
|
with service._SocketIOService__lock:
|
|
service._SocketIOService__socket_subscriptions[research_id] = {
|
|
"sid1",
|
|
"sid2",
|
|
"sid3",
|
|
}
|
|
|
|
call_log = []
|
|
|
|
def selective_emit(event, data, room=None):
|
|
call_log.append(room)
|
|
if room == "sid2":
|
|
raise RuntimeError("connection lost")
|
|
|
|
service._SocketIOService__socketio.emit = selective_emit
|
|
|
|
# Should still return True (outer try succeeds)
|
|
result = service.emit_to_subscribers("event", research_id, {})
|
|
assert result is True
|
|
# All 3 subscribers were attempted
|
|
assert len(call_log) == 3
|
|
|
|
def test_no_subscription_entry_drops_event(self):
|
|
"""Missing subscription entry → drop, do not fall back to broadcast.
|
|
|
|
The broadcast fallback was removed to close a cross-user leak.
|
|
"""
|
|
service = _make_service()
|
|
|
|
result = service.emit_to_subscribers("event", "nonexistent", {"x": 1})
|
|
assert result is True # success: dropping is intentional
|
|
|
|
events = service._SocketIOService__socketio.emitted_events
|
|
assert events == []
|
|
|
|
|
|
class TestRemoveSubscriptions:
|
|
"""Tests for remove_subscriptions_for_research."""
|
|
|
|
def setup_method(self):
|
|
from local_deep_research.web.services.socket_service import (
|
|
SocketIOService,
|
|
)
|
|
|
|
self._original = SocketIOService._instance
|
|
|
|
def teardown_method(self):
|
|
_cleanup_singleton(self._original)
|
|
|
|
def test_removes_existing_subscriptions(self):
|
|
"""Removes subscriptions for a known research_id."""
|
|
service = _make_service()
|
|
rid = "r10"
|
|
|
|
with service._SocketIOService__lock:
|
|
service._SocketIOService__socket_subscriptions[rid] = {"s1", "s2"}
|
|
|
|
service.remove_subscriptions_for_research(rid)
|
|
|
|
with service._SocketIOService__lock:
|
|
assert rid not in service._SocketIOService__socket_subscriptions
|
|
|
|
def test_remove_nonexistent_no_error(self):
|
|
"""Removing subscriptions for unknown research_id doesn't raise."""
|
|
service = _make_service()
|
|
service.remove_subscriptions_for_research("unknown_id")
|
|
# Should not raise
|
|
|
|
|
|
class TestEmitSocketEvent:
|
|
"""Tests for emit_socket_event broadcast vs room modes."""
|
|
|
|
def setup_method(self):
|
|
from local_deep_research.web.services.socket_service import (
|
|
SocketIOService,
|
|
)
|
|
|
|
self._original = SocketIOService._instance
|
|
|
|
def teardown_method(self):
|
|
_cleanup_singleton(self._original)
|
|
|
|
def test_broadcast_mode_no_room(self):
|
|
"""Without room, socketio.emit is called without room kwarg."""
|
|
service = _make_service()
|
|
result = service.emit_socket_event("test_event", {"key": "val"})
|
|
|
|
assert result is True
|
|
events = service._SocketIOService__socketio.emitted_events
|
|
assert len(events) == 1
|
|
assert events[0]["room"] is None
|
|
|
|
def test_room_mode(self):
|
|
"""With room specified, socketio.emit is called with room=room."""
|
|
service = _make_service()
|
|
result = service.emit_socket_event(
|
|
"test_event", {"key": "val"}, room="abc123"
|
|
)
|
|
|
|
assert result is True
|
|
events = service._SocketIOService__socketio.emitted_events
|
|
assert len(events) == 1
|
|
assert events[0]["room"] == "abc123"
|
|
|
|
def test_exception_returns_false(self):
|
|
"""If socketio.emit raises, emit_socket_event returns False."""
|
|
service = _make_service()
|
|
service._SocketIOService__socketio.emit = Mock(
|
|
side_effect=RuntimeError("boom")
|
|
)
|
|
|
|
result = service.emit_socket_event("test_event", {})
|
|
assert result is False
|