mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
984 lines
39 KiB
Python
984 lines
39 KiB
Python
"""Tests for multi-worker channel teardown coordination."""
|
|
import time
|
|
from unittest.mock import ANY, MagicMock, patch
|
|
|
|
from django.test import TestCase
|
|
|
|
from apps.proxy.live_proxy.constants import ChannelMetadataField, ChannelState
|
|
from apps.proxy.live_proxy.input.buffer import StreamBuffer
|
|
from apps.proxy.live_proxy.input.manager import StreamManager
|
|
from apps.proxy.live_proxy.redis_keys import RedisKeys
|
|
from apps.proxy.live_proxy.server import ProxyServer
|
|
from apps.proxy.live_proxy.services.channel_service import ChannelService
|
|
|
|
|
|
CHANNEL_ID = "00000000-0000-0000-0000-000000000099"
|
|
|
|
|
|
def _configure_ownership_pipeline(
|
|
redis,
|
|
*,
|
|
stop_exists=0,
|
|
metadata_exists=1,
|
|
client_count=0,
|
|
owner=None,
|
|
disconnect=None,
|
|
state=None,
|
|
):
|
|
pipe = MagicMock()
|
|
redis.pipeline.return_value = pipe
|
|
pipe.execute.return_value = (
|
|
stop_exists,
|
|
metadata_exists,
|
|
client_count,
|
|
owner,
|
|
disconnect,
|
|
state,
|
|
)
|
|
return pipe
|
|
|
|
|
|
def _mock_proxy_server(redis_client=None):
|
|
server = MagicMock()
|
|
server.redis_client = redis_client or MagicMock()
|
|
server._stopping_channels = set()
|
|
return server
|
|
|
|
|
|
class ChannelTeardownAvailabilityTests(TestCase):
|
|
@patch("apps.proxy.live_proxy.services.channel_service.ProxyServer.get_instance")
|
|
def test_teardown_active_when_stopping_key_exists(self, mock_get_instance):
|
|
redis = MagicMock()
|
|
redis.exists.side_effect = lambda key: key == RedisKeys.channel_stopping(CHANNEL_ID)
|
|
mock_get_instance.return_value = _mock_proxy_server(redis)
|
|
|
|
self.assertTrue(ChannelService.is_channel_teardown_active(CHANNEL_ID))
|
|
|
|
@patch("apps.proxy.live_proxy.services.channel_service.ProxyServer.get_instance")
|
|
def test_teardown_active_when_metadata_state_is_stopping(self, mock_get_instance):
|
|
redis = MagicMock()
|
|
redis.exists.return_value = False
|
|
redis.hget.return_value = ChannelState.STOPPING.encode()
|
|
mock_get_instance.return_value = _mock_proxy_server(redis)
|
|
|
|
self.assertTrue(ChannelService.is_channel_teardown_active(CHANNEL_ID))
|
|
|
|
@patch("apps.proxy.live_proxy.services.channel_service.ConfigHelper.channel_shutdown_delay")
|
|
@patch("apps.proxy.live_proxy.services.channel_service.ProxyServer.get_instance")
|
|
def test_shutdown_pending_within_delay_window(self, mock_get_instance, mock_delay):
|
|
mock_delay.return_value = 5
|
|
redis = MagicMock()
|
|
redis.exists.return_value = False
|
|
redis.get.return_value = str(time.time() - 2).encode()
|
|
mock_get_instance.return_value = _mock_proxy_server(redis)
|
|
|
|
self.assertTrue(ChannelService.is_shutdown_pending(CHANNEL_ID))
|
|
self.assertFalse(ChannelService.is_channel_unavailable_for_new_clients(CHANNEL_ID))
|
|
|
|
@patch("apps.proxy.live_proxy.services.channel_service.ConfigHelper.channel_shutdown_delay")
|
|
@patch("apps.proxy.live_proxy.services.channel_service.ProxyServer.get_instance")
|
|
def test_cancel_pending_shutdown_clears_disconnect_key(self, mock_get_instance, mock_delay):
|
|
mock_delay.return_value = 30
|
|
redis = MagicMock()
|
|
redis.exists.side_effect = lambda key: "last_client_disconnect" in key
|
|
redis.get.return_value = None
|
|
redis.hget.return_value = ChannelState.ACTIVE.encode()
|
|
mock_get_instance.return_value = _mock_proxy_server(redis)
|
|
|
|
self.assertTrue(ChannelService.cancel_pending_shutdown(CHANNEL_ID))
|
|
redis.delete.assert_any_call(RedisKeys.last_client_disconnect(CHANNEL_ID))
|
|
|
|
@patch("apps.proxy.live_proxy.services.channel_service.ConfigHelper.channel_shutdown_delay")
|
|
@patch("apps.proxy.live_proxy.services.channel_service.ProxyServer.get_instance")
|
|
def test_cancel_pending_shutdown_skips_during_active_stop(self, mock_get_instance, mock_delay):
|
|
mock_delay.return_value = 30
|
|
redis = MagicMock()
|
|
redis.exists.return_value = True
|
|
server = _mock_proxy_server(redis)
|
|
server._stopping_channels = {CHANNEL_ID}
|
|
mock_get_instance.return_value = server
|
|
|
|
self.assertFalse(ChannelService.cancel_pending_shutdown(CHANNEL_ID))
|
|
redis.delete.assert_not_called()
|
|
|
|
@patch("apps.proxy.live_proxy.services.channel_service.ConfigHelper.channel_shutdown_delay")
|
|
@patch("apps.proxy.live_proxy.services.channel_service.ProxyServer.get_instance")
|
|
def test_cancel_pending_shutdown_skips_real_teardown_without_grace(self, mock_get_instance, mock_delay):
|
|
mock_delay.return_value = 30
|
|
redis = MagicMock()
|
|
redis.exists.side_effect = lambda key: "stopping" in key
|
|
redis.get.return_value = None
|
|
redis.hget.return_value = ChannelState.STOPPING.encode()
|
|
mock_get_instance.return_value = _mock_proxy_server(redis)
|
|
|
|
self.assertFalse(ChannelService.cancel_pending_shutdown(CHANNEL_ID))
|
|
redis.hset.assert_not_called()
|
|
redis.delete.assert_not_called()
|
|
|
|
@patch("apps.proxy.live_proxy.services.channel_service.ConfigHelper.channel_shutdown_delay")
|
|
@patch("apps.proxy.live_proxy.services.channel_service.ProxyServer.get_instance")
|
|
def test_shutdown_pending_expired_after_delay(self, mock_get_instance, mock_delay):
|
|
mock_delay.return_value = 5
|
|
redis = MagicMock()
|
|
redis.exists.return_value = False
|
|
redis.get.return_value = str(time.time() - 10).encode()
|
|
mock_get_instance.return_value = _mock_proxy_server(redis)
|
|
|
|
self.assertFalse(ChannelService.is_shutdown_pending(CHANNEL_ID))
|
|
|
|
|
|
class ClientManagerAddClientTests(TestCase):
|
|
@patch("apps.proxy.live_proxy.services.channel_service.ChannelService.cancel_pending_shutdown", return_value=False)
|
|
@patch("apps.proxy.live_proxy.client_manager.send_websocket_update")
|
|
def test_add_client_stores_ip_and_user_agent_in_redis(self, _mock_ws, _mock_cancel):
|
|
from apps.proxy.live_proxy.client_manager import ClientManager
|
|
|
|
redis = MagicMock()
|
|
cm = ClientManager(CHANNEL_ID, redis_client=redis, worker_id="worker-1")
|
|
cm.proxy_server = MagicMock()
|
|
|
|
result = cm.add_client(
|
|
"client-1",
|
|
"10.0.2.163",
|
|
user_agent="VLC/3.0.21",
|
|
)
|
|
|
|
self.assertEqual(result, 1)
|
|
mapping = redis.hset.call_args[1]["mapping"]
|
|
self.assertEqual(mapping["ip_address"], "10.0.2.163")
|
|
self.assertEqual(mapping["user_agent"], "VLC/3.0.21")
|
|
|
|
|
|
class LocalStreamActivityTests(TestCase):
|
|
def _make_server(self):
|
|
with patch("apps.proxy.live_proxy.server.RedisClient.get_client", return_value=MagicMock()):
|
|
server = ProxyServer()
|
|
server.worker_id = "testhost:1"
|
|
server.stream_managers = {}
|
|
server.stream_buffers = {}
|
|
server.client_managers = {}
|
|
server.profile_managers = {}
|
|
server.profile_buffers = {}
|
|
server._live_stream_managers = {}
|
|
server._stopping_channels = set()
|
|
server.redis_client = MagicMock()
|
|
server.stop_all_output_formats = MagicMock()
|
|
server.stop_all_output_profiles = MagicMock()
|
|
return server
|
|
|
|
@patch.object(ProxyServer, "_join_stream_thread")
|
|
def test_stop_local_stream_activity_stops_live_manager(self, mock_join):
|
|
server = self._make_server()
|
|
manager = MagicMock()
|
|
server._live_stream_managers[CHANNEL_ID] = manager
|
|
|
|
server._stop_local_stream_activity(CHANNEL_ID)
|
|
|
|
manager.stop.assert_called_once()
|
|
mock_join.assert_called_once_with(CHANNEL_ID)
|
|
self.assertNotIn(CHANNEL_ID, server._live_stream_managers)
|
|
|
|
|
|
class OrphanMetadataCleanupTests(TestCase):
|
|
def _make_server(self):
|
|
with patch("apps.proxy.live_proxy.server.RedisClient.get_client", return_value=MagicMock()):
|
|
server = ProxyServer()
|
|
server.worker_id = "testhost:1"
|
|
server.stream_managers = {}
|
|
server.stream_buffers = {}
|
|
server.client_managers = {}
|
|
server.profile_managers = {}
|
|
server.profile_buffers = {}
|
|
server._live_stream_managers = {}
|
|
server._stopping_channels = set()
|
|
server.redis_client = MagicMock()
|
|
return server
|
|
|
|
@patch.object(ProxyServer, "_clean_redis_keys")
|
|
@patch.object(ProxyServer, "_stop_local_stream_activity")
|
|
@patch.object(ProxyServer, "_has_local_upstream_activity", return_value=True)
|
|
def test_orphan_metadata_stops_local_processes_before_redis(
|
|
self, mock_has_upstream, mock_stop_local, mock_clean_redis
|
|
):
|
|
server = self._make_server()
|
|
metadata_key = RedisKeys.channel_metadata(CHANNEL_ID)
|
|
server.redis_client.keys.return_value = [metadata_key.encode()]
|
|
server.redis_client.hgetall.return_value = {
|
|
b"owner": b"",
|
|
b"state": b"unknown",
|
|
}
|
|
server.redis_client.exists.return_value = False
|
|
server.redis_client.scard.return_value = 0
|
|
|
|
server._check_orphaned_metadata()
|
|
|
|
mock_has_upstream.assert_called_with(CHANNEL_ID)
|
|
mock_stop_local.assert_called_once_with(CHANNEL_ID)
|
|
mock_clean_redis.assert_called_once_with(CHANNEL_ID)
|
|
|
|
@patch.object(ProxyServer, "_clean_redis_keys")
|
|
@patch.object(ProxyServer, "_broadcast_upstream_stop")
|
|
@patch.object(ProxyServer, "_stop_local_stream_activity")
|
|
@patch.object(ProxyServer, "_has_local_upstream_activity", return_value=False)
|
|
def test_orphan_metadata_remote_channel_broadcasts_stop(
|
|
self, mock_has_upstream, mock_stop_local, mock_broadcast, mock_clean_redis
|
|
):
|
|
server = self._make_server()
|
|
metadata_key = RedisKeys.channel_metadata(CHANNEL_ID)
|
|
server.redis_client.keys.return_value = [metadata_key.encode()]
|
|
server.redis_client.hgetall.return_value = {b"owner": b"", b"state": b"unknown"}
|
|
server.redis_client.exists.return_value = False
|
|
server.redis_client.scard.return_value = 0
|
|
|
|
server._check_orphaned_metadata()
|
|
|
|
mock_broadcast.assert_called_once_with(CHANNEL_ID)
|
|
mock_stop_local.assert_not_called()
|
|
mock_clean_redis.assert_called_once_with(CHANNEL_ID)
|
|
|
|
|
|
class OrphanChannelCleanupTests(TestCase):
|
|
def _make_server(self):
|
|
with patch("apps.proxy.live_proxy.server.RedisClient.get_client", return_value=MagicMock()):
|
|
server = ProxyServer()
|
|
server.worker_id = "testhost:1"
|
|
server.stream_managers = {}
|
|
server.stream_buffers = {}
|
|
server.client_managers = {}
|
|
server.profile_managers = {}
|
|
server.profile_buffers = {}
|
|
server._live_stream_managers = {}
|
|
server._stopping_channels = set()
|
|
server.redis_client = MagicMock()
|
|
server.get_channel_owner = MagicMock(return_value=None)
|
|
return server
|
|
|
|
@patch.object(ProxyServer, "_clean_redis_keys")
|
|
@patch.object(ProxyServer, "_stop_local_stream_activity")
|
|
@patch.object(ProxyServer, "_has_local_upstream_activity", return_value=True)
|
|
def test_orphan_channel_stops_local_before_redis(
|
|
self, mock_has_upstream, mock_stop_local, mock_clean_redis
|
|
):
|
|
server = self._make_server()
|
|
metadata_key = RedisKeys.channel_metadata(CHANNEL_ID)
|
|
server.redis_client.keys.return_value = [metadata_key.encode()]
|
|
server.redis_client.scard.return_value = 0
|
|
|
|
server._check_orphaned_channels()
|
|
|
|
mock_stop_local.assert_called_once_with(CHANNEL_ID)
|
|
mock_clean_redis.assert_called_once_with(CHANNEL_ID)
|
|
|
|
|
|
class StreamManagerOwnershipTests(TestCase):
|
|
def test_still_owner_false_when_different_worker(self):
|
|
buffer = MagicMock()
|
|
_configure_ownership_pipeline(
|
|
buffer.redis_client,
|
|
owner=b"worker-b",
|
|
)
|
|
manager = StreamManager(
|
|
CHANNEL_ID, "http://example/stream", buffer, worker_id="worker-a"
|
|
)
|
|
|
|
self.assertFalse(manager._still_owner())
|
|
|
|
def test_still_owner_true_when_owner_lock_expired_but_not_stopping(self):
|
|
buffer = MagicMock()
|
|
_configure_ownership_pipeline(
|
|
buffer.redis_client,
|
|
client_count=0,
|
|
owner=None,
|
|
state=ChannelState.CONNECTING.encode(),
|
|
)
|
|
manager = StreamManager(
|
|
CHANNEL_ID, "http://example/stream", buffer, worker_id="worker-a"
|
|
)
|
|
|
|
self.assertTrue(manager._still_owner())
|
|
|
|
def test_still_owner_false_when_channel_stopping_key_set(self):
|
|
buffer = MagicMock()
|
|
_configure_ownership_pipeline(
|
|
buffer.redis_client,
|
|
stop_exists=1,
|
|
)
|
|
manager = StreamManager(
|
|
CHANNEL_ID, "http://example/stream", buffer, worker_id="worker-a"
|
|
)
|
|
|
|
self.assertFalse(manager._still_owner())
|
|
|
|
def test_update_bytes_skipped_after_ownership_lost(self):
|
|
buffer = MagicMock()
|
|
_configure_ownership_pipeline(
|
|
buffer.redis_client,
|
|
owner=b"other-worker",
|
|
)
|
|
manager = StreamManager(
|
|
CHANNEL_ID, "http://example/stream", buffer, worker_id="worker-a"
|
|
)
|
|
manager.bytes_processed = 1000
|
|
|
|
manager._update_bytes_processed(500)
|
|
|
|
buffer.redis_client.hincrby.assert_not_called()
|
|
|
|
|
|
class StreamBufferStopTests(TestCase):
|
|
def test_stop_discards_local_data_without_redis_writes(self):
|
|
redis = MagicMock()
|
|
buffer = StreamBuffer(channel_id=CHANNEL_ID, redis_client=redis)
|
|
buffer._write_buffer = bytearray(b"x" * 376)
|
|
|
|
buffer.stop()
|
|
|
|
self.assertTrue(buffer.stopping)
|
|
self.assertEqual(len(buffer._write_buffer), 0)
|
|
redis.incr.assert_not_called()
|
|
redis.setex.assert_not_called()
|
|
redis.delete.assert_not_called()
|
|
|
|
|
|
class StopChannelTeardownTests(TestCase):
|
|
def _make_server(self):
|
|
with patch("apps.proxy.live_proxy.server.RedisClient.get_client", return_value=MagicMock()):
|
|
server = ProxyServer()
|
|
server.worker_id = "testhost:1"
|
|
server.stream_managers = {}
|
|
server.stream_buffers = {}
|
|
server.client_managers = {}
|
|
server.profile_managers = {}
|
|
server.profile_buffers = {}
|
|
server._live_stream_managers = {}
|
|
server._stopping_channels = set()
|
|
server._stopping_since = {}
|
|
server.redis_client = MagicMock()
|
|
server.redis_client.exists.return_value = False
|
|
server.am_i_owner = MagicMock(return_value=False)
|
|
server._collect_channel_stop_event_data = MagicMock(return_value=None)
|
|
server.release_ownership = MagicMock()
|
|
return server
|
|
|
|
@patch.object(ProxyServer, "_spawn_channel_stop_event")
|
|
@patch.object(ProxyServer, "_clean_redis_keys")
|
|
@patch.object(ProxyServer, "_stop_local_stream_activity")
|
|
def test_stop_channel_cleans_redis_before_blocking_local_stop(
|
|
self, mock_stop_local, mock_clean_redis, mock_spawn_event
|
|
):
|
|
server = self._make_server()
|
|
call_order = []
|
|
|
|
def stop_local(channel_id):
|
|
call_order.append("local")
|
|
|
|
def clean_redis(channel_id):
|
|
call_order.append("redis")
|
|
|
|
mock_stop_local.side_effect = stop_local
|
|
mock_clean_redis.side_effect = clean_redis
|
|
|
|
server.stop_channel(CHANNEL_ID)
|
|
|
|
self.assertEqual(call_order, ["redis", "local"])
|
|
mock_spawn_event.assert_called_once_with(None)
|
|
|
|
@patch.object(ProxyServer, "_spawn_channel_stop_event")
|
|
@patch.object(ProxyServer, "_clean_redis_keys")
|
|
@patch.object(ProxyServer, "_stop_local_stream_activity", side_effect=RuntimeError("boom"))
|
|
def test_stop_channel_cleans_redis_in_finally_when_local_stop_fails(
|
|
self, mock_stop_local, mock_clean_redis, mock_spawn_event
|
|
):
|
|
server = self._make_server()
|
|
|
|
result = server.stop_channel(CHANNEL_ID)
|
|
|
|
self.assertFalse(result)
|
|
mock_clean_redis.assert_called_once_with(CHANNEL_ID)
|
|
mock_spawn_event.assert_not_called()
|
|
self.assertNotIn(CHANNEL_ID, server._stopping_channels)
|
|
|
|
@patch.object(ProxyServer, "_spawn_channel_stop_event")
|
|
@patch.object(ProxyServer, "_clean_redis_keys")
|
|
@patch.object(ProxyServer, "_stop_local_stream_activity")
|
|
def test_stop_channel_owner_releases_after_redis_cleanup(
|
|
self, mock_stop_local, mock_clean_redis, mock_spawn_event
|
|
):
|
|
server = self._make_server()
|
|
server.am_i_owner.return_value = True
|
|
stop_data = {"channel_id": CHANNEL_ID}
|
|
server._collect_channel_stop_event_data.return_value = stop_data
|
|
call_order = []
|
|
|
|
def stop_local(channel_id):
|
|
call_order.append("local")
|
|
|
|
def release(channel_id):
|
|
call_order.append("release")
|
|
|
|
def clean_redis(channel_id):
|
|
call_order.append("redis")
|
|
|
|
mock_stop_local.side_effect = stop_local
|
|
server.release_ownership.side_effect = release
|
|
mock_clean_redis.side_effect = clean_redis
|
|
|
|
server.stop_channel(CHANNEL_ID)
|
|
|
|
self.assertEqual(call_order, ["redis", "release", "local"])
|
|
server._collect_channel_stop_event_data.assert_called_once_with(CHANNEL_ID)
|
|
mock_spawn_event.assert_called_once_with(stop_data)
|
|
|
|
|
|
class CleanRedisKeysOrderTests(TestCase):
|
|
@patch("apps.proxy.live_proxy.server.Stream.objects.get")
|
|
@patch("apps.proxy.live_proxy.server.Channel.objects.get")
|
|
def test_clean_redis_keys_releases_profile_slot_before_live_keys_deleted(
|
|
self, mock_channel_get, mock_stream_get
|
|
):
|
|
from apps.channels.models import Channel, Stream
|
|
|
|
with patch("apps.proxy.live_proxy.server.RedisClient.get_client", return_value=MagicMock()):
|
|
server = ProxyServer()
|
|
server.redis_client = MagicMock()
|
|
call_order = []
|
|
|
|
channel = MagicMock()
|
|
channel.release_stream.return_value = True
|
|
|
|
def channel_get(uuid):
|
|
call_order.append("release")
|
|
return channel
|
|
|
|
mock_channel_get.side_effect = channel_get
|
|
mock_stream_get.side_effect = Stream.DoesNotExist
|
|
|
|
channel_key = f"live:channel:{CHANNEL_ID}:input:buffer:index".encode()
|
|
|
|
def scan(cursor, match=None, count=100):
|
|
call_order.append("redis")
|
|
if match == f"live:channel:{CHANNEL_ID}:*":
|
|
return (0, [channel_key])
|
|
return (0, [])
|
|
|
|
server.redis_client.scan.side_effect = scan
|
|
|
|
server._clean_redis_keys(CHANNEL_ID)
|
|
|
|
self.assertEqual(call_order, ["release", "redis", "redis"])
|
|
channel.release_stream.assert_called_once()
|
|
server.redis_client.delete.assert_called_once_with(channel_key)
|
|
|
|
|
|
class LocalUpstreamActivityTests(TestCase):
|
|
def _make_server(self):
|
|
with patch("apps.proxy.live_proxy.server.RedisClient.get_client", return_value=MagicMock()):
|
|
server = ProxyServer()
|
|
server.worker_id = "testhost:1"
|
|
server.stream_managers = {}
|
|
server.stream_buffers = {}
|
|
server.client_managers = {}
|
|
server._live_stream_managers = {}
|
|
return server
|
|
|
|
def test_upstream_activity_excludes_reader_only_client_manager(self):
|
|
server = self._make_server()
|
|
server.client_managers[CHANNEL_ID] = MagicMock()
|
|
server.stream_buffers[CHANNEL_ID] = MagicMock()
|
|
self.assertFalse(server._has_local_upstream_activity(CHANNEL_ID))
|
|
|
|
def test_upstream_activity_from_live_registry(self):
|
|
server = self._make_server()
|
|
server._live_stream_managers[CHANNEL_ID] = MagicMock()
|
|
self.assertTrue(server._has_local_upstream_activity(CHANNEL_ID))
|
|
|
|
|
|
class ClientDisconnectOwnershipTests(TestCase):
|
|
def test_last_client_triggers_stop_when_upstream_active_without_owner_lock(self):
|
|
from apps.proxy.live_proxy.client_manager import ClientManager
|
|
|
|
proxy_server = MagicMock()
|
|
proxy_server.worker_id = "testhost:1"
|
|
proxy_server.am_i_owner.return_value = False
|
|
proxy_server._has_local_upstream_activity.return_value = True
|
|
proxy_server.extend_ownership.return_value = False
|
|
|
|
redis = MagicMock()
|
|
redis.hget.return_value = b"viewer"
|
|
redis.scard.return_value = 0
|
|
|
|
manager = ClientManager(
|
|
channel_id=CHANNEL_ID,
|
|
redis_client=redis,
|
|
worker_id="testhost:1",
|
|
)
|
|
manager.proxy_server = proxy_server
|
|
manager.clients = {"client-1"}
|
|
manager.client_set_key = RedisKeys.clients(CHANNEL_ID)
|
|
manager._notify_owner_of_activity = MagicMock()
|
|
manager._trigger_stats_update = MagicMock()
|
|
manager.get_total_client_count = MagicMock(return_value=0)
|
|
proxy_server._spawn_on_hub = MagicMock()
|
|
|
|
manager.remove_client("client-1")
|
|
|
|
proxy_server._spawn_on_hub.assert_called_once_with(
|
|
proxy_server.handle_client_disconnect, CHANNEL_ID
|
|
)
|
|
|
|
|
|
class TeardownActiveLocalStopTests(TestCase):
|
|
@patch("apps.proxy.live_proxy.services.channel_service.ProxyServer.get_instance")
|
|
def test_teardown_active_when_local_stop_in_progress(self, mock_get_instance):
|
|
server = MagicMock()
|
|
server._stopping_channels = {CHANNEL_ID}
|
|
server.redis_client = MagicMock()
|
|
server.redis_client.exists.return_value = False
|
|
mock_get_instance.return_value = server
|
|
|
|
self.assertTrue(ChannelService.is_channel_teardown_active(CHANNEL_ID))
|
|
|
|
|
|
class HandleClientDisconnectUpstreamFirstTests(TestCase):
|
|
def _make_server(self):
|
|
with patch("apps.proxy.live_proxy.server.RedisClient.get_client", return_value=MagicMock()):
|
|
server = ProxyServer()
|
|
server.worker_id = "testhost:1"
|
|
server.client_managers = {CHANNEL_ID: MagicMock()}
|
|
server.stream_managers = {CHANNEL_ID: MagicMock()}
|
|
server._live_stream_managers = {}
|
|
server.profile_managers = {}
|
|
server.output_managers = {}
|
|
server.redis_client = MagicMock()
|
|
server.redis_client.scard.return_value = 0
|
|
server._stopping_channels = set()
|
|
return server
|
|
|
|
@patch.object(ProxyServer, "_coordinated_stop_channel")
|
|
@patch.object(ProxyServer, "_stop_upstream_before_redis_cleanup")
|
|
@patch.object(ProxyServer, "_has_local_upstream_activity", return_value=True)
|
|
def test_last_client_uses_coordinated_stop_only(
|
|
self, _mock_upstream, mock_stop_upstream, mock_coordinated
|
|
):
|
|
server = self._make_server()
|
|
|
|
with patch(
|
|
"apps.proxy.live_proxy.server.ConfigHelper.channel_shutdown_delay",
|
|
return_value=0,
|
|
):
|
|
server.handle_client_disconnect(CHANNEL_ID)
|
|
|
|
mock_stop_upstream.assert_not_called()
|
|
mock_coordinated.assert_called_once_with(CHANNEL_ID)
|
|
|
|
|
|
class ShutdownDelayWaitTests(TestCase):
|
|
def _make_server(self):
|
|
with patch("apps.proxy.live_proxy.server.RedisClient.get_client", return_value=MagicMock()):
|
|
with patch.object(ProxyServer, "_start_cleanup_thread"):
|
|
server = ProxyServer()
|
|
server.redis_client = MagicMock()
|
|
server.redis_client.scard.return_value = 0
|
|
return server
|
|
|
|
@patch("apps.proxy.live_proxy.server.gevent.sleep")
|
|
@patch("apps.proxy.live_proxy.server.time.time", return_value=1000.0)
|
|
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_shutdown_delay", return_value=30)
|
|
def test_aborts_when_disconnect_key_deleted(self, _mock_delay, _mock_time, mock_sleep):
|
|
server = self._make_server()
|
|
server.redis_client.get.side_effect = [b"1000.0", None]
|
|
|
|
result = server._wait_for_shutdown_delay(CHANNEL_ID)
|
|
|
|
self.assertFalse(result)
|
|
self.assertGreaterEqual(mock_sleep.call_count, 1)
|
|
|
|
@patch("apps.proxy.live_proxy.server.gevent.sleep")
|
|
@patch("apps.proxy.live_proxy.server.time.time", return_value=1000.0)
|
|
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_shutdown_delay", return_value=30)
|
|
def test_aborts_when_clients_reconnect(self, _mock_delay, _mock_time, _mock_sleep):
|
|
server = self._make_server()
|
|
server.redis_client.get.return_value = b"1000.0"
|
|
server.redis_client.scard.side_effect = [0, 1]
|
|
|
|
result = server._wait_for_shutdown_delay(CHANNEL_ID)
|
|
|
|
self.assertFalse(result)
|
|
server.redis_client.delete.assert_called_with(
|
|
RedisKeys.last_client_disconnect(CHANNEL_ID)
|
|
)
|
|
|
|
@patch("apps.proxy.live_proxy.server.gevent.sleep")
|
|
@patch("apps.proxy.live_proxy.server.time.time")
|
|
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_shutdown_delay", return_value=30)
|
|
def test_timer_resets_when_disconnect_timestamp_updated(
|
|
self, _mock_delay, mock_time, mock_sleep
|
|
):
|
|
server = self._make_server()
|
|
disconnect_key = RedisKeys.last_client_disconnect(CHANNEL_ID)
|
|
current_time = [1000.0]
|
|
disconnect_timestamp = [1000.0]
|
|
poll_count = [0]
|
|
|
|
mock_time.side_effect = lambda: current_time[0]
|
|
|
|
def get_side_effect(key):
|
|
poll_count[0] += 1
|
|
if poll_count[0] >= 3:
|
|
disconnect_timestamp[0] = 1020.0
|
|
if key == disconnect_key:
|
|
return str(disconnect_timestamp[0]).encode()
|
|
return None
|
|
|
|
server.redis_client.get.side_effect = get_side_effect
|
|
|
|
def advance_sleep(duration):
|
|
current_time[0] += duration
|
|
|
|
mock_sleep.side_effect = advance_sleep
|
|
|
|
result = server._wait_for_shutdown_delay(CHANNEL_ID)
|
|
|
|
self.assertTrue(result)
|
|
self.assertGreaterEqual(current_time[0], 1050.0)
|
|
|
|
@patch.object(ProxyServer, "_coordinated_stop_channel")
|
|
@patch.object(ProxyServer, "_wait_for_shutdown_delay", return_value=True)
|
|
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_shutdown_delay", return_value=30)
|
|
def test_handle_client_disconnect_uses_polling_wait(
|
|
self, _mock_delay, mock_wait, mock_coordinated
|
|
):
|
|
server = HandleClientDisconnectUpstreamFirstTests()._make_server()
|
|
server.redis_client.get.return_value = b"1700000000.0"
|
|
|
|
server.handle_client_disconnect(CHANNEL_ID)
|
|
|
|
mock_wait.assert_called_once_with(CHANNEL_ID)
|
|
mock_coordinated.assert_called_once_with(CHANNEL_ID)
|
|
|
|
@patch.object(ProxyServer, "_coordinated_stop_channel")
|
|
@patch.object(ProxyServer, "_wait_for_shutdown_delay", return_value=False)
|
|
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_shutdown_delay", return_value=30)
|
|
def test_handle_client_disconnect_skips_stop_when_wait_aborted(
|
|
self, _mock_delay, _mock_wait, mock_coordinated
|
|
):
|
|
server = HandleClientDisconnectUpstreamFirstTests()._make_server()
|
|
server.redis_client.get.return_value = b"1700000000.0"
|
|
|
|
server.handle_client_disconnect(CHANNEL_ID)
|
|
|
|
mock_coordinated.assert_not_called()
|
|
|
|
|
|
class InitWaitAbortTests(TestCase):
|
|
def _make_generator(self):
|
|
from apps.proxy.live_proxy.output.ts.generator import StreamGenerator
|
|
|
|
return StreamGenerator(
|
|
CHANNEL_ID,
|
|
"client-1",
|
|
"127.0.0.1",
|
|
"test-agent",
|
|
channel_initializing=True,
|
|
)
|
|
|
|
def test_abort_when_client_removed_locally(self):
|
|
generator = self._make_generator()
|
|
server = MagicMock()
|
|
client_manager = MagicMock()
|
|
client_manager.clients = set()
|
|
server.client_managers = {CHANNEL_ID: client_manager}
|
|
server.redis_client = MagicMock()
|
|
|
|
self.assertEqual(generator._init_wait_abort_reason(server, time.time()), "client_gone")
|
|
|
|
@patch("apps.proxy.live_proxy.output.ts.generator.ConfigHelper.channel_init_grace_period", return_value=10)
|
|
def test_abort_when_connect_stalled_without_buffer(self, _mock_grace):
|
|
generator = self._make_generator()
|
|
server = MagicMock()
|
|
client_manager = MagicMock()
|
|
client_manager.clients = {"client-1"}
|
|
server.client_managers = {CHANNEL_ID: client_manager}
|
|
buffer = MagicMock()
|
|
buffer.index = 0
|
|
server.stream_buffers = {CHANNEL_ID: buffer}
|
|
server.redis_client = MagicMock()
|
|
server.redis_client.hget.return_value = b"connecting"
|
|
server.redis_client.get.return_value = None
|
|
|
|
started = time.time() - 11
|
|
self.assertEqual(generator._init_wait_abort_reason(server, started), "stalled")
|
|
|
|
@patch("apps.proxy.live_proxy.output.ts.generator.ConfigHelper.channel_init_grace_period", return_value=30)
|
|
def test_no_stall_abort_within_init_grace_period(self, _mock_grace):
|
|
generator = self._make_generator()
|
|
server = MagicMock()
|
|
client_manager = MagicMock()
|
|
client_manager.clients = {"client-1"}
|
|
server.client_managers = {CHANNEL_ID: client_manager}
|
|
buffer = MagicMock()
|
|
buffer.index = 0
|
|
server.stream_buffers = {CHANNEL_ID: buffer}
|
|
server.redis_client = MagicMock()
|
|
server.redis_client.hget.return_value = b"connecting"
|
|
server.redis_client.get.return_value = None
|
|
|
|
started = time.time() - 15
|
|
self.assertIsNone(generator._init_wait_abort_reason(server, started))
|
|
|
|
@patch("apps.proxy.live_proxy.output.ts.generator.ConfigHelper.channel_init_grace_period", return_value=30)
|
|
def test_stall_abort_after_init_grace_period(self, _mock_grace):
|
|
generator = self._make_generator()
|
|
server = MagicMock()
|
|
client_manager = MagicMock()
|
|
client_manager.clients = {"client-1"}
|
|
server.client_managers = {CHANNEL_ID: client_manager}
|
|
buffer = MagicMock()
|
|
buffer.index = 0
|
|
server.stream_buffers = {CHANNEL_ID: buffer}
|
|
server.redis_client = MagicMock()
|
|
server.redis_client.hget.return_value = b"connecting"
|
|
server.redis_client.get.return_value = None
|
|
|
|
started = time.time() - 31
|
|
self.assertEqual(generator._init_wait_abort_reason(server, started), "stalled")
|
|
|
|
|
|
class PromoteChannelWhenBufferReadyTests(TestCase):
|
|
def _mock_proxy(self, redis_client):
|
|
proxy_server = MagicMock()
|
|
proxy_server.redis_client = redis_client
|
|
return patch(
|
|
"apps.proxy.live_proxy.services.channel_service.ProxyServer.get_instance",
|
|
return_value=proxy_server,
|
|
), proxy_server
|
|
|
|
@patch("apps.proxy.live_proxy.services.channel_service.ConfigHelper.initial_behind_chunks", return_value=4)
|
|
def test_buffer_ready_with_clients_becomes_active(self, _mock_chunks):
|
|
redis = MagicMock()
|
|
redis.hget.return_value = ChannelState.CONNECTING.encode()
|
|
redis.get.return_value = b"4"
|
|
redis.scard.return_value = 2
|
|
|
|
ctx, proxy_server = self._mock_proxy(redis)
|
|
with ctx:
|
|
result = ChannelService.promote_channel_when_buffer_ready(CHANNEL_ID)
|
|
|
|
self.assertEqual(result, ChannelState.ACTIVE)
|
|
proxy_server.update_channel_state.assert_called_once()
|
|
args = proxy_server.update_channel_state.call_args[0]
|
|
self.assertEqual(args[1], ChannelState.ACTIVE)
|
|
self.assertEqual(args[2]["clients_at_activation"], "2")
|
|
|
|
@patch("apps.proxy.live_proxy.services.channel_service.ConfigHelper.initial_behind_chunks", return_value=4)
|
|
def test_buffer_ready_without_clients_becomes_waiting(self, _mock_chunks):
|
|
redis = MagicMock()
|
|
redis.hget.return_value = ChannelState.CONNECTING.encode()
|
|
redis.get.return_value = b"5"
|
|
redis.scard.return_value = 0
|
|
|
|
ctx, proxy_server = self._mock_proxy(redis)
|
|
with ctx:
|
|
result = ChannelService.promote_channel_when_buffer_ready(CHANNEL_ID)
|
|
|
|
self.assertEqual(result, ChannelState.WAITING_FOR_CLIENTS)
|
|
proxy_server.update_channel_state.assert_called_once_with(
|
|
CHANNEL_ID,
|
|
ChannelState.WAITING_FOR_CLIENTS,
|
|
{
|
|
ChannelMetadataField.CONNECTION_READY_TIME: ANY,
|
|
ChannelMetadataField.BUFFER_CHUNKS: "5",
|
|
},
|
|
)
|
|
|
|
@patch("apps.proxy.live_proxy.services.channel_service.ConfigHelper.initial_behind_chunks", return_value=4)
|
|
def test_buffer_not_ready_does_not_promote(self, _mock_chunks):
|
|
redis = MagicMock()
|
|
redis.hget.return_value = ChannelState.CONNECTING.encode()
|
|
redis.get.return_value = b"2"
|
|
|
|
ctx, proxy_server = self._mock_proxy(redis)
|
|
with ctx:
|
|
result = ChannelService.promote_channel_when_buffer_ready(CHANNEL_ID)
|
|
|
|
self.assertIsNone(result)
|
|
proxy_server.update_channel_state.assert_not_called()
|
|
|
|
def test_waiting_for_clients_with_clients_becomes_active(self):
|
|
redis = MagicMock()
|
|
|
|
def hget_side_effect(key, field):
|
|
if field == ChannelMetadataField.STATE:
|
|
return ChannelState.WAITING_FOR_CLIENTS.encode()
|
|
if field == ChannelMetadataField.CONNECTION_READY_TIME:
|
|
return b"1700000000.0"
|
|
return None
|
|
|
|
redis.hget.side_effect = hget_side_effect
|
|
redis.scard.return_value = 1
|
|
|
|
ctx, proxy_server = self._mock_proxy(redis)
|
|
with ctx:
|
|
result = ChannelService.promote_channel_when_buffer_ready(CHANNEL_ID)
|
|
|
|
self.assertEqual(result, ChannelState.ACTIVE)
|
|
proxy_server.update_channel_state.assert_called_once_with(
|
|
CHANNEL_ID,
|
|
ChannelState.ACTIVE,
|
|
{"clients_at_activation": "1"},
|
|
)
|
|
|
|
|
|
class UpstreamStopBroadcastTests(TestCase):
|
|
def _make_server(self):
|
|
with patch("apps.proxy.live_proxy.server.RedisClient.get_client", return_value=MagicMock()):
|
|
server = ProxyServer()
|
|
server.worker_id = "testhost:1"
|
|
server.stream_managers = {}
|
|
server._live_stream_managers = {}
|
|
server.redis_client = MagicMock()
|
|
return server
|
|
|
|
@patch.object(ProxyServer, "_stop_local_stream_activity")
|
|
@patch.object(ProxyServer, "_broadcast_upstream_stop")
|
|
@patch.object(ProxyServer, "_has_local_upstream_activity", return_value=True)
|
|
def test_local_upstream_stops_locally_without_broadcast(
|
|
self, _mock_has, mock_broadcast, mock_stop_local
|
|
):
|
|
server = self._make_server()
|
|
server._stop_upstream_before_redis_cleanup(CHANNEL_ID)
|
|
mock_stop_local.assert_called_once_with(CHANNEL_ID)
|
|
mock_broadcast.assert_not_called()
|
|
|
|
@patch.object(ProxyServer, "_stop_local_stream_activity")
|
|
@patch.object(ProxyServer, "_broadcast_upstream_stop")
|
|
@patch.object(ProxyServer, "_has_local_upstream_activity", return_value=False)
|
|
def test_orphan_cleanup_broadcasts_when_no_local_upstream(
|
|
self, _mock_has, mock_broadcast, mock_stop_local
|
|
):
|
|
server = self._make_server()
|
|
server._stop_upstream_before_redis_cleanup(CHANNEL_ID)
|
|
mock_broadcast.assert_called_once_with(CHANNEL_ID)
|
|
mock_stop_local.assert_not_called()
|
|
|
|
|
|
class StreamManagerStillOwnerTests(TestCase):
|
|
def _make_manager(self, redis_client):
|
|
buffer = MagicMock()
|
|
buffer.redis_client = redis_client
|
|
buffer.channel_id = CHANNEL_ID
|
|
manager = StreamManager(
|
|
CHANNEL_ID,
|
|
"http://example/stream.ts",
|
|
buffer,
|
|
worker_id="testhost:1",
|
|
)
|
|
return manager
|
|
|
|
def test_stops_when_metadata_removed(self):
|
|
redis = MagicMock()
|
|
_configure_ownership_pipeline(redis, metadata_exists=0)
|
|
manager = self._make_manager(redis)
|
|
self.assertFalse(manager._still_owner())
|
|
|
|
def test_keeps_running_during_connecting_before_client_registered(self):
|
|
redis = MagicMock()
|
|
_configure_ownership_pipeline(
|
|
redis,
|
|
client_count=0,
|
|
owner=b"testhost:1",
|
|
state=ChannelState.CONNECTING.encode(),
|
|
)
|
|
manager = self._make_manager(redis)
|
|
self.assertTrue(manager._still_owner())
|
|
|
|
def test_stops_after_disconnect_when_shutdown_delay_is_zero(self):
|
|
redis = MagicMock()
|
|
_configure_ownership_pipeline(
|
|
redis,
|
|
client_count=0,
|
|
owner=b"testhost:1",
|
|
disconnect=b"1700000000.0",
|
|
state=ChannelState.ACTIVE.encode(),
|
|
)
|
|
manager = self._make_manager(redis)
|
|
with patch(
|
|
"apps.proxy.live_proxy.input.manager.ConfigHelper.channel_shutdown_delay",
|
|
return_value=0,
|
|
):
|
|
self.assertFalse(manager._still_owner())
|
|
|
|
def test_keeps_running_during_shutdown_delay(self):
|
|
redis = MagicMock()
|
|
_configure_ownership_pipeline(
|
|
redis,
|
|
client_count=0,
|
|
owner=b"testhost:1",
|
|
disconnect=str(time.time()).encode(),
|
|
state=ChannelState.ACTIVE.encode(),
|
|
)
|
|
manager = self._make_manager(redis)
|
|
with patch(
|
|
"apps.proxy.live_proxy.input.manager.ConfigHelper.channel_shutdown_delay",
|
|
return_value=5,
|
|
):
|
|
self.assertTrue(manager._still_owner())
|
|
|
|
|
|
class PreActiveNoClientsTimeoutTests(TestCase):
|
|
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_client_wait_period", return_value=5)
|
|
def test_buffer_ready_uses_client_wait_period(self, _mock_client_wait):
|
|
should_stop, timeout, reason = ProxyServer._pre_active_no_clients_should_stop(
|
|
connection_ready_time=1000.0,
|
|
start_time=900.0,
|
|
now=1006.0,
|
|
)
|
|
self.assertTrue(should_stop)
|
|
self.assertEqual(timeout, 5)
|
|
self.assertEqual(reason, "client_wait")
|
|
|
|
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_client_wait_period", return_value=5)
|
|
def test_buffer_ready_within_client_wait_period(self, _mock_client_wait):
|
|
should_stop, timeout, reason = ProxyServer._pre_active_no_clients_should_stop(
|
|
connection_ready_time=1000.0,
|
|
start_time=900.0,
|
|
now=1003.0,
|
|
)
|
|
self.assertFalse(should_stop)
|
|
self.assertEqual(timeout, 5)
|
|
self.assertEqual(reason, "client_wait")
|
|
|
|
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_init_grace_period", return_value=60)
|
|
def test_startup_uses_init_grace_period(self, _mock_init_grace):
|
|
should_stop, timeout, reason = ProxyServer._pre_active_no_clients_should_stop(
|
|
connection_ready_time=None,
|
|
start_time=1000.0,
|
|
now=1070.0,
|
|
)
|
|
self.assertTrue(should_stop)
|
|
self.assertEqual(timeout, 60)
|
|
self.assertEqual(reason, "startup")
|
|
|
|
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_init_grace_period", return_value=60)
|
|
def test_startup_within_init_grace_period(self, _mock_init_grace):
|
|
should_stop, timeout, reason = ProxyServer._pre_active_no_clients_should_stop(
|
|
connection_ready_time=None,
|
|
start_time=1000.0,
|
|
now=1030.0,
|
|
)
|
|
self.assertFalse(should_stop)
|
|
self.assertEqual(timeout, 60)
|
|
self.assertEqual(reason, "startup")
|
|
|
|
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_shutdown_delay", return_value=30)
|
|
@patch("apps.proxy.live_proxy.server.ConfigHelper.channel_client_wait_period", return_value=5)
|
|
def test_buffer_ready_does_not_use_shutdown_delay(self, mock_client_wait, mock_shutdown_delay):
|
|
should_stop, _, reason = ProxyServer._pre_active_no_clients_should_stop(
|
|
connection_ready_time=1000.0,
|
|
start_time=900.0,
|
|
now=1006.0,
|
|
)
|
|
self.assertTrue(should_stop)
|
|
self.assertEqual(reason, "client_wait")
|
|
mock_client_wait.assert_called_once()
|
|
mock_shutdown_delay.assert_not_called()
|